"""M02 단가산출(SK) 셈 — 바탕 사슬 → 고친 줄 → M01 엔진 한 벌(PLAN 715-1b · 715-2). 셈 = ① 바탕(M01 로직 · 바탕 SK 의 고친 줄 · 없음) ② `M02_Sangeun_Doc.patched` ③ 더한줄 참조 · (SK · SC) · 고정 줄 = 먼저 셈한 1 단위 노 · 재 · 경 → `값묶음` ④ M01 색인 위에 고친 줄을 SK 키로 얹어 (M01 시험 계산 `_swap` 과 같은 꼴 · 메모리만 · 정본 · 캐시 안 고침) `labor.run_line` — 자동값 · 켜쌓기율 · 고름 · % 덧줄을 엔진이 다시 셈. 1 단위 = 기준 ÷ 배(바탕 로직 결과단위 앞 수). 계산 자리(CLAUDE.md 5장 ③) = 서버 단독 · 화면은 셈 길(저장 안 함 · 최신 응답만). 저장(`clean_doc`) = 꼴 검사 + 참조 위 층 · 있는 문서 · 순환 422 + 서버가 `원값` · `바탕판` 적음. """ from __future__ import annotations import hashlib import json from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_Doc as sd COSTS = labor.COSTS Ref = dict[str, Any] class _Over: """M01 색인 위에 고친 줄 하나 — `logic(키)` 만 가로챔(그 밖 그대로 · 색인 안 고침).""" def __init__(self, whole: Any, key: str, row: dict) -> None: self._whole, self._key, self._row = whole, key, row def logic(self, ref: str) -> dict: return self._row if ref == self._key else self._whole.logic(ref) def __getattr__(self, name: str) -> Any: return getattr(self._whole, name) def _fail(e: sd.DocError) -> store.StoreError: return store.StoreError(e.status, str(e)) def times_of(row: dict) -> Decimal: """결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌 return per[0][0] if len(per) == 1 else Decimal(1) def fingerprint(row: dict) -> str: raw = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] # ── 바탕 사슬 → 고친 줄 ───────────────────────────────────────────────── def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[str, Any]: """이 SK 가 얹힐 바탕 — {줄, 고름, 자재표시, 입력값, 켜쌓기율, 짝잃음}.""" base = doc.get("바탕") empty = {"고름": {}, "자재표시": {}, "입력값": {}, "켜쌓기율": None, "짝잃음": []} if not base: return {**empty, "줄": sd.empty_row(doc)} if "로직" in base: try: return {**empty, "줄": whole.logic(base["로직"])} except Exception as e: # noqa: BLE001 — 엔진 FormulaError raise sd.DocError(f"바탕 로직 · {e}") from e ref = base["참조"] rid = refs.ref_id(ref) if rid in trail: keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)] raise sd.DocError("순환 — " + " → ".join(keys), 422) if len(trail) >= refs.DEPTH: raise sd.DocError(f"바탕 사슬이 {refs.DEPTH}단을 넘음", 422) parent = read(ref) if not isinstance(parent, dict): raise sd.DocError(f"없는 바탕 「{ref.get('키')}」") up = resolve(parent, whole, read, (*trail, rid)) lost = [{**x, "바탕": ref.get("키")} for x in up["짝잃음"]] return {**up, "짝잃음": lost} def resolve(doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = ()) -> dict[str, Any]: """SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` · `trail` = 지나온 참조 이름표(순환 · 깊이 422).""" base = _base_of(doc, whole, read, trail) got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"]) rate = doc.get("켜쌓기율", base["켜쌓기율"]) return { **got, "짝잃음": base["짝잃음"] + got["짝잃음"], "입력값": {**base["입력값"], **(doc.get("입력값") or {})}, "켜쌓기율": rate, "배": times_of(base["줄"]), "바탕판": fingerprint(base["줄"]), } def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> str | None: """더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈이면 까닭.""" for item in row["호표"]: if item.get("값묶음") is not None or not ("참조" in item or "고정" in item): continue sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"]) if sub.get("기준") is None: return f"「{sub.get('이름') or item.get('이름')}」 {sub.get('까닭')}" item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS} item["이름"] = item.get("이름") or sub.get("이름") or "" item["단위"] = item.get("단위") or sub.get("단위") or "" return None def run( doc: dict, whole: Any, given: dict | None = None, read: Any = refs.read_ref, expand: Any = None, trail: tuple = (), ) -> dict[str, Any]: """SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` · 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError`.""" from M02_MasterTemplete.M02_Labor_Expand import Expand got = resolve(doc, whole, read, trail) row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} why = _bundles(row, whole, expand or Expand(whole, read), trail) head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]} if why: return {**head, "기준": None, "1단위": None, "줄": [], "까닭": why} key = str(doc.get("키") or "SK-000000") over = _Over(whole, key, {**row, "키": key}) values = {**got["입력값"], **(given or {})} out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) covers = doc.get("덮개") or {} lines = [] for cid, line in zip(got["정체"], out.get("줄") or []): mine = covers.get(cid) or {} mark = {"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {} lines.append( {**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})} ) out = labor.drop_apart({**out, "줄": lines}, got["자재표시"]) base = out.get("기준") unit = {k: Decimal(str(v)) / got["배"] for k, v in base.items()} if base else None return {**out, **head, "1단위": unit} def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: """펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭.""" head = {"이름": doc.get("이름"), "단위": doc.get("단위")} try: got = run(doc, whole, None, expand.read, expand, trail) except sd.DocError as e: return {**head, "기준": None, "까닭": str(e)} why = got.get("까닭") or (None if got.get("1단위") else "돈 아닌 로직") times = got["배"] lines = [ { **x, "수량": None if x.get("수량") is None else Decimal(str(x["수량"])) / times, "금액": None if x.get("금액") is None else Decimal(str(x["금액"])) / times, } for x in got.get("줄") or [] ] return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} def head_of(doc: dict) -> str: return f"1 {doc.get('단위')} 당" # ── 저장 ────────────────────────────────────────────────────────────── def _check_refs(doc: dict, here_: refs.Here) -> None: me = {"층": here_[0], "주인": here_[1], "키": doc["키"]} base = doc.get("바탕") or {} if "참조" in base: base["참조"] = refs.check_ref(base["참조"], "바탕", here_) if refs.kind_of(base["참조"]["키"]) != sd.KIND: raise store.StoreError(400, "바탕 참조는 단가산출(SK)만") for at, one in enumerate(doc.get("더한줄") or [], 1): if "참조" in one: one["참조"] = refs.check_ref(one["참조"], f"더한줄 {at}째", here_) for ref in refs.refs_in(doc): if refs.ref_id(ref) == refs.ref_id(me): raise store.StoreError(422, f"순환 — 자기 자신을 담음 「{doc['키']}」") refs._no_loop(ref, [me, ref]) def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: """저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`.""" from M01_MasterData import M01_MasterData_Store as m01 whole = m01.loaded()[1] try: rough = sd.clean(doc, None, whole) _check_refs(rough, here_) base = _base_of(rough, whole, refs.read_ref, ()) out = sd.clean(doc, base["줄"], whole) out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"] if out["덮개"]: bare = run({**out, "덮개": {}}, whole) # 덮기 전 값 = 원값(그때 M01) was = {x["cid"]: x for x in bare.get("줄") or []} for cid, one in out["덮개"].items(): if cid in was: one["원값"] = _was(was[cid]) out["바탕판"] = fingerprint(base["줄"]) except sd.DocError as e: raise _fail(e) from e return out def _was(line: dict) -> dict[str, Any]: keep = {"qty": line.get("율", line.get("수량")), "단가": line.get("단가")} return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None} def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: """셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422.""" from M01_MasterData import M01_MasterData_Store as m01 whole = m01.loaded()[1] try: clean = sd.clean(doc, None, whole) _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) got = run(clean, whole, given, trail=(me,)) except sd.DocError as e: raise _fail(e) from e return labor._json({**got, "머리": head_of(clean)}) def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: """[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK · 답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜).""" from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Patch as patch if not isinstance(line, dict): raise store.StoreError(400, "줄이 묶음 아님") doc = patch.to_sangeun(line, name, unit, m01.loaded()[1]) made = refs.create(sd.KIND, doc) at = refs.here() keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line} return { "이름": made["이름"], "줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep}, }