Files
Aislo/resources/master_data/scripts/master_combo.py
T
eomsangdonandClaude Sonnet 5 a4b3e7d77a feat(M01): 일위대가 조합 마스터 — 데이터 · 엔진 · 서버 길(화면은 아직)
PLAN 4-2. 조합 = 관리자가 이름을 붙이고 단가산출 로직을 골라 담은 묶음.
마스터에는 담은 로직 목록만 두고 수량·비율·계산은 두지 않음(4-0 확정 · 계산은 프로젝트 몫).

- 새 파일 `일위대가조합.json`(빈 채 · 줄은 사용자가 화면에서 만듦) · 테이블ID `UA`
  · 원문이 없어 머리 `원문` 과 줄 `원문번호` 를 두지 않음(`mk.NO_NUMBER` 에 `UA`)
- 엔진 `scripts/master_combo.py` — 검사 넷(없는 로직 키 · 같은 로직 두 번 · 빈 조합
  · 조합이 조합을 담음) · 「이 로직을 쓰는 조합」 거꾸로 찾기 · 미리 보기 계산
- `check_master.py` 에 「조합」 갈래 더함 · 저장 전 검사(`check_saved`)에도 얹어 422 로 막음
- 서버 `M01_MasterData_Store_Combo.py` — `GET /combos`·`/combo`·`/logic/combos`
  · `POST /combo/new`·`/edit`·`/delete`·`/preview`(저장 없음)
- 조합은 요소 그룹이 아님 — `/groups` 는 여덟 그대로 두고 제 길로만 다룸
- 틀 `_틀.md` 10장 · 계약 `_화면_계약.md` 9장 · 시험 `test_m01_combo.py` 17

검증 — 견본 조합(13-4-1 · 13-4-2 · 13-2-1) 미리 보기 합계가 그 로직 셋의 시험 계산을
비목별로 더한 값과 같음 · 못 도는 줄은 그 줄만 멈추고 나머지 합계는 그대로 ·
저장 전 조합 줄로도 미리 보기 되고 아무것도 안 생김 · 검사 넷 다 422 · 낡은 판본 409 ·
정본 로직 안 바뀜 · `check_master 틀`·`조합` 0건 · pytest 44 통과(조합 17 + api 27)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaS48Nvs26o3aFUpcfJxVv
2026-09-21 23:50:42 +09:00

138 lines
6.2 KiB
Python

# -*- coding: utf-8 -*-
"""일위대가 조합 — 단가산출 로직을 골라 담은 묶음 (`_틀.md` 10장 · 계약 `_화면_계약.md` 9장).
조합은 **담은 로직 목록만** 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫).
미리 보기(`run`)는 담은 로직마다 그 자리에서 받은 입력값으로 값을 뽑아
비목별로 갈라 합침 — 저장하지 않음.
"""
from __future__ import annotations
from decimal import Decimal
import master_formula as mf
import master_keys as mk
GROUP = "일위대가조합"
FILE = "일위대가조합.json"
TID = "UA"
OWNERS = ("현장", "공용")
# 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장)
COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고")
ITEM_COLS = ("로직", "메모")
def rows(files: dict[str, dict]):
"""(파일 이름, 조합 줄) — 조합 파일은 하나지만 그룹으로 찾음."""
for name, data in files.items():
if data.get("그룹") == GROUP:
for row in data.get("줄") or []:
yield name, row
def items(row: dict) -> list[dict]:
"""담은 로직 줄 목록 — 차례가 곧 조합 안 차례."""
got = row.get("담은로직")
return got if isinstance(got, list) else []
def logic_keys(row: dict) -> list[str]:
return [str(one.get("로직") or "") for one in items(row) if isinstance(one, dict)]
# ── 검사 ──────────────────────────────────────────────────────────────
def check_row(whole: mf.Master, row: dict) -> list[str]:
"""조합 한 줄 — 빈 조합 · 없는 로직 키 · 같은 로직 두 번 · 조합을 담음(1단만)."""
where = f"{row.get('키')} {row.get('이름') or ''}".strip()
got = items(row)
out = [] if got else [f"{where} · 빈 조합 — 담은 로직이 없음"]
if row.get("담은로직") is not None and not isinstance(row.get("담은로직"), list):
out.append(f"{where} · 「담은로직」 이 목록 아님")
seen: set[str] = set()
for at, one in enumerate(got, start=1):
if not isinstance(one, dict):
out.append(f"{where}{at} · 담은 로직 줄이 묶음 아님 「{one}」")
continue
extra = set(one) - set(ITEM_COLS)
if extra:
out.append(f"{where}{at} · 모르는 칸 {sorted(extra)}")
key = str(one.get("로직") or "")
if not mk.KEY.fullmatch(key):
out.append(f"{where}{at} · 로직 키 모양 아님 「{key}」")
continue
if mk.id_of(key) == TID:
out.append(f"{where}{at} · 조합은 조합을 담지 못함 「{key}」 — 단가산출 로직만")
continue
try:
whole.logic(key)
except mf.FormulaError as e:
out.append(f"{where}{at} · {e}")
continue
if key in seen:
out.append(f"{where}{at} · 같은 로직을 두 번 담음 「{key}」")
seen.add(key)
return out
def check_all(files: dict[str, dict], whole: mf.Master) -> list[str]:
"""조합 전부 — 파일 이름을 앞에 붙임."""
return [f"{name} · {x}" for name, row in rows(files) for x in check_row(whole, row)]
def using(files: dict[str, dict], key: str) -> list[dict]:
"""「이 로직을 쓰는 조합」 — 소요량 「용도」 와 결은 같되 자리가 다름.
조합은 관리자가 그때그때 만드는 것이라 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음
(박아 두면 조합을 고칠 때마다 낡음).
"""
out = []
for _, row in rows(files):
at = [i for i, k in enumerate(logic_keys(row), start=1) if k == key]
if at:
out.append(
{
**{k: row.get(k) for k in ("키", "이름", "구분", "상세구분", "단위", "소유")},
"차례": at,
}
)
return out
# ── 미리 보기 ─────────────────────────────────────────────────────────
def run(master: mf.Master, row: dict, given: dict[str, dict] | None = None) -> dict:
"""담은 로직마다 그 자리의 입력값으로 값을 뽑아 비목별로 갈라 합침 — 저장하지 않음.
`given` = {로직 키: {입력 이름: 값}} · 못 도는 줄은 까닭을 적고 합계에서 뺌(통째로 멈추지 않음).
돈이 아닌 로직(결과 수 하나)은 비목이 없어 합계에 얹지 않고 결과만 보임.
"""
given = given or {}
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
lines, stuck = [], []
for at, one in enumerate(items(row), start=1):
key = str(one.get("로직") or "") if isinstance(one, dict) else ""
head = {"차례": at, "로직": key, "메모": one.get("메모") if isinstance(one, dict) else None}
try:
target = master.logic(key)
except mf.FormulaError as e:
lines.append({**head, "ok": False, "까닭": str(e)})
stuck.append(f"{key} · {e}")
continue
head.update(
{k: target.get(k) for k in ("이름", "원문번호", "결과단위", "구분", "상세구분", "출처")}
)
try:
got = mf.run(master, key, dict(given.get(key) or {}))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
reason = str(e) or type(e).__name__
lines.append({**head, "ok": False, "까닭": reason})
stuck.append(f"{key} {target.get('이름')} · {reason}")
continue
if "결과" in got: # 돈 아닌 로직 — 비목이 없어 합계에 안 얹음
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
continue
part = {k: got[k] for k in mf.COST_ITEMS}
for k, v in part.items():
sums[k] += v
lines.append({**head, "ok": True, "갈래": "돈", **part, "계": got["계"]})
return {"줄": lines, **sums, "계": sum(sums.values()), "멈춤": stuck}