"""M01 일위대가 조합 저장소 — 목록 · 하나 읽기 · 만들기 · 고치기 · 지우기 · 미리 보기. 계약: `resources/master_data/_화면_계약.md` 9장 · 파일 모양: `_틀.md` 10장 · 엔진: `scripts/master_combo.py`. 조합은 담은 단가산출 로직 목록만 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫). 자리는 `일위대가조합.json` 한 파일 · 키는 `_키대장.json` 의 `UA` 다음 번호(서버가 줌). """ from __future__ import annotations from datetime import date from pathlib import Path from M01_MasterData import M01_MasterData_Store as store cm, mk, mcb = store.cm, store.mk, store.mcb FILE = mcb.FILE OWNERS = mcb.OWNERS def _path() -> Path: return store.FOLDER / FILE def _make_file() -> None: """조합 파일이 없으면 머리만 두고 만듦.""" path = _path() if path.is_file(): return head = {"그룹": mcb.GROUP, "판": str(date.today().year), "줄": []} path.write_text(store.dump(head), encoding="utf-8", newline="\n") def _read() -> tuple[dict, str]: _make_file() return store.read(FILE) def _clean(combo: dict, owner: str, key: str | None = None) -> dict: """서버가 잡는 칸 — 키 · 소유. 화면이 보낸 키·소유는 안 씀 · 열 차례는 `_틀.md` 10장.""" if not isinstance(combo, dict): raise store.StoreError(400, "조합 줄이 묶음 아님") name = combo.get("이름") if not isinstance(name, str) or not name.strip(): raise store.StoreError(400, "조합 줄에 「이름」 이 없거나 글자 아님") if owner not in OWNERS: raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") rest = {k: v for k, v in combo.items() if k not in ("키", "원문번호", "소유")} one = {**({"키": key} if key else {}), "소유": owner, **rest} one.setdefault("출처", "자체") for col in mcb.COLS: # 열 한 벌 — 안 준 칸은 null 로 채움 one.setdefault(col, None) return {k: one[k] for k in mcb.COLS if k in one} def _find(data: dict, key: str) -> dict: for row in data.get("줄") or []: if str(row.get("키")) == key: return row raise store.StoreError(404, f"없는 조합 「{key}」") def _write(changes: list[dict], version: str | None = None) -> dict: """조합 파일 한 벌 저장 — 검사는 `/save` 와 같음(422 면 아무것도 안 씀).""" fresh = not _path().is_file() _make_file() try: out = store.save( [{"file": FILE, "version": version or store.read(FILE)[1], "changes": changes}] ) except store.StoreError: if fresh: _path().unlink(missing_ok=True) raise return out[0] # ── 읽기 ────────────────────────────────────────────────────────────── def _brief(whole, row: dict) -> list[dict]: """담은 로직 줄에 그 로직의 이름·단위를 붙여 보임 — 없는 키는 `ok` false.""" out = [] for at, one in enumerate(mcb.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: got = whole.logic(key) except cm.mf.FormulaError as e: out.append({**head, "ok": False, "까닭": str(e)}) continue out.append( { **head, "ok": True, **{ k: got.get(k) for k in ("이름", "원문번호", "구분", "상세구분", "결과단위", "출처") }, } ) return out def combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> list[dict]: """조합 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름.""" files, whole = store.loaded() out = [] for _, row in mcb.rows(files): if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail): continue if owner and row.get("소유") != owner: continue if not store._hit(row, q): continue problems = mcb.check_row(whole, row) out.append( { "file": FILE, **{k: row.get(k) for k in mcb.COLS if k != "담은로직"}, "count": len(mcb.items(row)), "blocked": bool(problems), "reasons": problems, } ) return out def combo(key: str) -> dict: """조합 하나 — 담은 로직에 이름·단위를 붙여 줌.""" data, version = _read() row = _find(data, key) whole = store.loaded()[1] problems = mcb.check_row(whole, row) return { "file": FILE, "version": version, "combo": row, "줄": _brief(whole, row), "blocked": bool(problems), "reasons": problems, } def combos_of_logic(key: str) -> dict: """「이 로직을 쓰는 조합」 — 로직 화면이 거꾸로 볼 때(소요량 「용도」 와 같은 결).""" files, _ = store.loaded() return {"key": key, "combos": mcb.using(files, key)} # ── 만들기 · 고치기 · 지우기 ─────────────────────────────────────────── def _keys() -> list[str]: if not _path().is_file(): return [] return [str(r.get("키")) for r in store.read(FILE)[0].get("줄") or []] def combo_new(combo_: dict, owner: str = OWNERS[0]) -> dict: """새 조합 — 키는 서버가 `UA` 다음 번호로 줌.""" before = set(_keys()) done = _write([{"op": "add", "row": _clean(combo_, owner)}]) key = next((k for k in reversed(_keys()) if k not in before), "") return {**done, "key": key, "combo": _find(store.read(FILE)[0], key) if key else {}} def combo_edit(key: str, version: str, combo_: dict, owner: str = "") -> dict: data, _ = _read() was = _find(data, key) if store.read(FILE)[1] != version: raise store.StoreError(409, {"stale": [FILE]}) fixed = _clean(combo_, owner or str(was.get("소유") or OWNERS[0]), key) done = _write([{"op": "edit", "key": key, "row": fixed}], version) return {**done, "combo": _find(store.read(FILE)[0], key)} def combo_delete(key: str, version: str) -> dict: data, _ = _read() _find(data, key) if store.read(FILE)[1] != version: raise store.StoreError(409, {"stale": [FILE]}) return _write([{"op": "delete", "key": key}], version) # ── 미리 보기 ───────────────────────────────────────────────────────── def combo_preview(combo_: dict | str, inputs: dict | None = None) -> dict: """담은 로직마다 그 자리 입력값으로 값을 뽑아 비목별로 갈라 합침 — **저장하지 않음**. `combo_` = 조합 줄 통째(저장 전 화면 상태) 또는 저장된 조합 키. `inputs` = {로직 키: {입력 이름: 값}}. """ files = cm.load(folder=store.FOLDER) whole = cm.mf.Master(files) row = _find(_read()[0], combo_) if isinstance(combo_, str) else store._dec(combo_) if not isinstance(row, dict): raise store.StoreError(400, "조합 줄이 묶음 아님") given = { str(k): {kk: store._dec(vv) for kk, vv in (v or {}).items()} for k, v in (inputs or {}).items() } got = mcb.run(whole, row, given) return {"키": row.get("키"), "이름": row.get("이름"), "단위": row.get("단위"), **got}