"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). 문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. """ from __future__ import annotations import json from decimal import Decimal, InvalidOperation from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store mf = m01.cm.mf mm = mf.mp.mm COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") def _bad(text: str) -> store.StoreError: return store.StoreError(400, text) def _number(raw: Any) -> Decimal | None: """칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌).""" if raw is None or isinstance(raw, bool): return None try: got = Decimal(str(raw).replace(",", "").strip()) except InvalidOperation: return None return got if got.is_finite() else None def _specs(logic: dict) -> list[dict]: """로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」.""" if isinstance(logic.get("흐름"), dict): return mf.inputs_of(logic) return list(logic.get("입력") or []) def _qty(value: Any, where: str) -> dict | None: if value is None: return None hand = _number(value.get("손")) if isinstance(value, dict) else None if hand is None: raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}") return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)} # ── 정리 · 검사 ─────────────────────────────────────────────────────── def clean(doc: Any) -> dict[str, Any]: """서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 · 모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것.""" if not isinstance(doc, dict): raise _bad("일위대가 문서가 묶음 아님") whole = m01.loaded()[1] lines, seen = [], set() for at, one in enumerate(doc.get("담은로직") or [], start=1): where = f"담은 로직 {at}째" if not isinstance(one, dict): raise _bad(f"{where} · 묶음 아님") code = str(one.get("로직") or "") try: logic = whole.logic(code) except mf.FormulaError as e: raise _bad(f"{where} · {e}") from e if code in seen: raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」") seen.add(code) names = {str(spec.get("이름")) for spec in _specs(logic)} given = one.get("입력값") or {} if not isinstance(given, dict): raise _bad(f"{where} · 입력값이 묶음 아님") memo = one.get("메모") lines.append( { "로직": code, "메모": str(memo) if memo else None, "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), } ) mats, codes = [], set() for at, one in enumerate(doc.get("자재") or [], start=1): where = f"자재 {at}째" code = str(one.get("코드") or "") if isinstance(one, dict) else "" try: row = whole.get(code) if code[:2] == mm.MARKET else None except mf.FormulaError: row = None if row is None: raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") if code in codes: raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」") codes.add(code) mats.append( { "코드": code, **{k: row.get(k) or "" for k in _MAT_COLS[1:]}, "수량": _qty(one.get("수량"), where), } ) out = { "종류": "일위대가", "판": doc.get("판") or 1, "열": doc.get("열"), "단위": str(doc["단위"]) if doc.get("단위") else None, "본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None, "담은로직": lines, "자재": mats, } if doc.get("일위대가번"): out["일위대가번"] = doc["일위대가번"] return out def from_combo(key: str) -> dict[str, Any]: """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). 없는 조합은 404.""" from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos row = combos.find(key) if row is None: raise store.StoreError(404, f"없는 일위대가 조합 「{key}」") layer, owner = combos.where(key) or (None, None) return { "단위": row.get("단위"), "본뜬": {"층": layer, "주인": owner, "키": key}, "담은로직": [ {"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None} for one in row.get("담은로직") or [] if isinstance(one, dict) ], "자재": [], } # ── 알약 · 면적 값 ──────────────────────────────────────────────────── def material_key(code: str) -> str: return f"{MATERIAL_HEAD}{code}" def pills(doc: dict) -> list[dict[str, Any]]: """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」.""" logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] mats = [ { "키": material_key(m["코드"]), "이름": m.get("이름") or m["코드"], "단위": m.get("단위") or "", "자재": {k: m.get(k) or "" for k in _MAT_COLS}, } for m in doc.get("자재") or [] ] return [*logic, *mats] def area_values(area: Any) -> dict[str, Decimal]: """면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만.""" out: dict[str, Decimal] = {} if not isinstance(area, dict): return out calc = area.get("계산값") or {} for sheet in area.get("시트") or []: cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {} for link in sheet.get("연결") or []: at = link.get("칸") raw = (got.get(at) or {}).get("수") if raw is None: raw = (cells.get(at) or {}).get("값") value = _number(raw) if value is not None and link.get("키"): out[str(link["키"])] = value return out def _area_of(column: Any) -> dict[str, Decimal]: """지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것.""" try: path = store._path("area", str(column or "")) except store.StoreError: return {} return area_values(json.loads(path.read_bytes())) if path.is_file() else {} # ── 계산 ────────────────────────────────────────────────────────────── def _money(value: Any) -> dict[str, Decimal] | None: if not isinstance(value, dict): return None return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")} def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None: """반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None.""" if qty is None: return None return {k: base[k] * qty / times for k in (*COSTS, "계")} def _sum(rows: list[dict | None]) -> dict[str, Decimal]: return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")} def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]: """(수량, 출처) — 손 · 칸(알약 키) · 없음.""" if isinstance(hand, dict) and _number(hand.get("손")) is not None: return _number(hand["손"]), "손" if key and key in given: return given[key], "칸" return None, "없음" def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) lines, mats, bases, reflects = [], [], [], [] for line in result.get("줄") or []: qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have) base = _money(line.get("기준")) done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None bases.append(base) reflects.append(done) lines.append({**line, "수량": qty, "수량출처": source, "반영": done}) for one in result.get("자재") or []: qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) price = _number(one.get("기준단가")) cost = price * qty if price is not None and qty is not None else None bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" whole = m01.loaded()[1] by_logic = { code: pill for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] for code in pill["로직"] } lines = [] for one in doc.get("담은로직") or []: code = one["로직"] pill = by_logic.get(code) or {} line = { "로직": code, "알약": pill.get("키"), "분모": pill.get("단위"), "배": pill.get("배") or 1, } try: logic = whole.logic(code) auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})}) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__}) continue if "결과" in got: # 돈 아닌 로직 — 반영 없음 lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) mats = [] for one in doc.get("자재") or []: try: price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) except mf.FormulaError as e: price, why = None, str(e) else: why = None if price is not None else "단가 없음" mats.append( {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} ) have = _area_of(doc.get("열")) if given is None else given return settle(_json({"줄": lines, "자재": mats}), doc, have) def _json(value: Any) -> Any: """Decimal → JSON 수(정수면 int).""" if isinstance(value, Decimal): return int(value) if value == value.to_integral_value() else float(value) if isinstance(value, list): return [_json(v) for v in value] if isinstance(value, dict): return {k: _json(v) for k, v in value.items()} return value def server(doc: Any) -> dict[str, Any]: """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" got = clean(doc) return {**got, "계산값": calc(got)}