"""M02 수식 문서 사본(PLAN 802-4a · 요구 664 · 665 · 요구서_664 1 장 4) — 서버 단독. 단가산출 기초 정본 = 수식 문서(`resources/master_data/_수식문서_틀.md`) — M02 단가산출(SK) · 일위대가 · 사전조합(SC) · B09 사본은 만들 때 M01 문서를 찍어 가짐(M01 이 바뀌어도 그대로). 문서 칸 `수식`(옛 로직 사본 `사본` 은 802-5 까지 옆에 둠): {판, 시점, 로직 [M01 줄], 단가판, 단가시점, 단가 [M01 자원 줄], ← 값 사본(`M02_Sangeun_Copy` 꼴) 부름 {키: 문서}(닫힌 묶음 — 연결 · [재생성] 때 새로) · SK 만: 문서(이 SK 문서 · 사용자 고침) · 끊음 {키: 문서}(이 사본 안에 굳힌 부름 · 고칠 수 있음) · 따로(문서를 수식 길로 고침 — 옛 칸에서 다시 짓지 않음)} 셈 = `master_doc_calc.calc`(M01 과 같은 계산기) · 연결 = `master_doc_link.Links`(값 사본 색인 위) · [단가 갱신] = 자원 단가 줄만 지금 M01(`skc.refresh`) · 문서 그대로 · [재생성] = 지금 M01 문서로. 부름 문서 = 값 사본 줄이 지금 M01 줄과 같으면 M01 문서(`수식문서/`) · 다르면(만든 때 옛 줄) 그 줄을 변환(`master_doc_convert` · 만든 때 그대로). 줄 규칙(요구 664 · 브레인 802-4a ④) = 일위대가 줄 옵션을 문서 안에 드러냄(`line_doc`) — 켜쌓기율 = 변수 `r_켜쌓기`(입력) · 금액 줄 수량 × r ÷ 100 · 고른 자재 = 그 줄 단가 기호 연결 요소 키 · 별도계상 = 그 금액 줄 `합밖`. """ from __future__ import annotations import copy as cp import hashlib import json import sys from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Sangeun_Copy as skc _SCRIPTS = str(Path(__file__).resolve().parents[1] / "resources/master_data/scripts") if _SCRIPTS not in sys.path: sys.path.insert(0, _SCRIPTS) import master_auto as mau # noqa: E402 import master_doc as md # noqa: E402 import master_doc_calc as mc # noqa: E402 import master_doc_convert as cv # noqa: E402 import master_doc_link as ml # noqa: E402 KEY = "수식" COSTS = ("노무비", "재료비", "경비", "계") RATE = {"열쇠": "켜쌓기율", "기호": "r", "아래": "켜쌓기", "위": "", "이름": "켜쌓기율"} RATE_ID = md.sym_id(RATE["기호"], RATE["아래"]) _ERR = (Exception,) # 변환 · 셈 틀림 = 그 문서 못 셈(까닭) — 셈 길은 멈추지 않음 def has(doc: Any) -> bool: got = doc.get(KEY) if isinstance(doc, dict) else None return isinstance(got, dict) and isinstance(got.get("부름"), dict) def fp(value: Any) -> str: raw = json.dumps(ml._norm(value), ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] # ── M01 문서(정본 폴더) ──────────────────────────────────────────────── _FOLDER: dict[str, Any] = {"자국": None, "문서": {}} def folder() -> dict[str, dict]: """M01 수식 문서 {키: 문서} — 폴더 파일 자국이 바뀌면 다시 읽음(M01 관리자 고침).""" files = sorted(ml.FOLDER.glob("수식_*.json")) mark = tuple((p.name, p.stat().st_mtime_ns, p.stat().st_size) for p in files) if _FOLDER["자국"] != mark: _FOLDER["문서"], _FOLDER["자국"] = ml.load_all(), mark return _FOLDER["문서"] def _live(key: str) -> Any: whole = skc.whole_() return whole.index.get(key[:2], {}).get(key) def doc_of(whole: Any, key: str) -> dict | None: """키 하나의 문서 — 값 사본 줄 = 지금 M01 줄이면 M01 문서 · 다르면 그 줄 변환 · 없으면 None.""" try: row = whole.logic(key) except _ERR: row = None live = folder().get(key) if row is None: return cp.deepcopy(live) if live else None if live and (_live(key) is None or skc._fp(_live(key)) == skc._fp(prices.to_json(row))): return cp.deepcopy(live) try: return cv.convert(whole, row)[0] except _ERR: return cp.deepcopy(live) if live else None def calls(doc: dict) -> list[str]: """문서가 부르는 단가산출 키.""" return [ str(v["연결"]["키"]) for v in doc.get("변수") or [] if (v.get("연결") or {}).get("갈래") == "단가산출" and (v.get("연결") or {}).get("키") ] def closure(whole: Any, keys: list[str], have: dict | None = None) -> dict[str, dict]: """키들과 그 문서가 부르는 단가산출 닫힌 묶음 {키: 문서} — `have`(만든 때 것) 먼저.""" have, out, todo = have or {}, {}, list(keys) while todo: key = str(todo.pop()) if key in out: continue doc = have.get(key) or doc_of(whole, key) if doc is None: continue out[key] = doc todo += [k for k in calls(doc) if k not in out] return out # ── 값 사본 색인 · 연결 ──────────────────────────────────────────────── def values_of(copy: dict) -> dict: """값 사본 몫만(`skc` 꼴) — 셈 색인 · [단가 갱신] 에 씀.""" return {k: copy[k] for k in ("판", "시점", "로직", "단가판", "단가시점", "단가") if k in copy} def base(whole: Any) -> Any: while isinstance(whole, skc.Copy): whole = whole._whole return whole def over(copy: dict, whole: Any = None) -> Any: """값 사본 색인 — M01 색인 위 사본 줄(`skc.over` 같은 메모 · 같은 줄 = 같은 색인).""" return skc.over({skc.KEY: values_of(copy)}, base(whole or skc.whole_())) def docs_of(copy: dict) -> dict[str, dict]: return {**(copy.get("부름") or {}), **(copy.get("끊음") or {})} def run(doc: dict, copy: dict, given: dict | None = None, whole: Any = None) -> dict: """문서 셈 — 연결 = 값 사본 · 부름 = 사본 문서(끊음 먼저).""" try: return mc.calc(doc, ml.Links(whole or over(copy), docs_of(copy)), given) except _ERR as e: # 계산기는 예외 없음이 원칙 — 남은 틈도 금액 「—」 로 return {"셈됨": False, "표지": [{"갈래": "셈 틀림", "글": str(e)}], **dict.fromkeys(COSTS)} def money(ans: dict) -> dict | None: if not ans.get("셈됨") or "결과" in ans: return None return {k: ans.get(k) for k in COSTS} def why(ans: dict) -> str: marks = " · ".join(f"{m.get('갈래')} — {m.get('글')}" for m in ans.get("표지") or []) return marks or str(ans.get("까닭") or "") or " · ".join((ans.get("빈값") or {}).values()) # ── 줄 규칙을 문서에(켜쌓기율 · 고른 자재 · 별도계상) ──────────────────── def _qty_scaled(line: dict) -> bool: return line.get("수량") is not None or str(line.get("단위") or "") != "식" def line_doc(doc: dict, rate: Any = None, picks: dict | None = None, apart=()) -> dict: """일위대가 줄 옵션을 문서 규칙으로 — 새 문서(원본 안 고침). `picks` = {호표 차례: MT 키} · `apart` = 별도계상 금액 줄 차례.""" if rate in (None, 100) and not picks and not apart: return doc doc = cp.deepcopy(doc) lines, by_id = doc.get("금액") or [], {md.var_id(v): v for v in doc.get("변수") or []} if rate not in (None, 100): doc["변수"].append({**RATE, "단위": "%", "연결": {"갈래": "입력", "값": rate}}) for line in lines: if _qty_scaled(line): line["수량"] = f"({line.get('수량') or 1}) × {RATE_ID} ÷ 100" for at, key in (picks or {}).items(): n = int(at) if str(at).isdigit() else -1 var = by_id.get(str(lines[n].get("단가"))) if 0 <= n < len(lines) else None link = (var or {}).get("연결") or {} if link.get("갈래") == "요소": keep = {k: link[k] for k in ("단위", "지역", "계약종별") if link.get(k)} var["연결"] = {"갈래": "요소", "키": str(key), **keep} for n in apart: if 0 <= n < len(lines): _apart(doc, lines, n) return doc def _plus(node, cost: str, sym: str): """식 나무 안 `cost`(앞 줄 누계) → (cost + sym).""" kind = node[0] if kind == "var": return ("bin", "+", node, ("var", sym)) if node[1] == cost else node if kind == "bin": return ("bin", node[1], _plus(node[2], cost, sym), _plus(node[3], cost, sym)) if kind in ("neg", "pct"): return (kind, _plus(node[1], cost, sym)) if kind == "field": return ("field", _plus(node[1], cost, sym), node[2]) if kind == "call": return ("call", node[1], [_plus(x, cost, sym) for x in node[2]]) return node def _apart(doc: dict, lines: list[dict], n: int) -> None: """별도계상 = 그 줄 `합밖` · 뒤 줄이 그 비목 누계를 쓰면 그 줄 금액을 더해 씀 (옛 엔진 = 뺀 재료 몫도 「재료비의 %」 밑에 듦 — `M02_Labor_Doc.drop_apart`).""" line, cost = lines[n], str(lines[n].get("비목") or "") line["합밖"] = True if cost not in md.COSTS: return taken = {md.var_id(v) for v in doc.get("변수") or []} | {str(x.get("기호")) for x in lines} sym = str(line.get("기호") or "") if not sym: sym = next( s for k in range(n + 1, n + 99) if (s := md.sym_id("S", f"별도{k}")) not in taken ) line["기호"] = sym for later in lines[n + 1 :]: for slot in ("수량", "단가"): try: node = md.parse(later.get(slot)) except md.DocError: continue if later.get(slot) not in (None, "") and cost in md.names(node): later[slot] = md.text_of(_plus(node, cost, sym)) def given_of(whole: Any, code: str, given: Any) -> dict: """옛 `run_line` 과 같은 입력 — 빈 입력 = 자동값(M01 줄) › 받은 값.""" from M01_MasterData import M01_MasterData_Store as m01 try: auto = {k: v for k, v in mau.values(whole, whole.logic(code)).items() if v is not None} except _ERR: auto = {} return {**auto, **m01._dec(given or {})} def apart_of(marks: Any, mats: Any) -> list[int]: """별도계상 금액 줄 차례 — 옛 `drop_apart` 와 같은 판정(엔진 답 `재료` 의 차례 · 자재표시).""" from M02_MasterTemplete import M02_Labor_Doc as labor marks = marks if isinstance(marks, dict) else {} return [ n for m in mats or [] if isinstance(n := m.get("차례"), int) and labor.apart(marks.get(str(n))) ] # ── 찍기 · 갱신 ──────────────────────────────────────────────────────── def stamp(values: dict | None, keys: list[str], have: dict | None = None) -> dict | None: """값 사본(`skc.snap` 꼴) + 부름 닫힌 묶음 → `수식` 칸 · 값 사본 없음 = None.""" if not values: return None whole = over({**values, "부름": {}}) return {**values_of(values), "부름": closure(whole, keys, have)} def grow(copy: dict, values: dict | None, keys: list[str]) -> dict: """저장 때 — 새 키(더한 기초 · 줄)만 지금 M01 에서 더함 · 있는 부름 · 값 줄 그대로.""" rows = skc.grow(values_of(copy), values) whole = over({**rows, "부름": {}}) return {**copy, **rows, "부름": closure(whole, keys, copy.get("부름"))} def refresh(copy: dict) -> tuple[dict, list[dict]]: """[단가 갱신] — 자원 단가 줄만 지금 M01 · 문서 그대로.""" rows, changed = skc.refresh(values_of(copy), skc.whole_()) return {**copy, **rows}, changed def state(copy: dict) -> dict[str, Any]: """{M01새판, 지운키, 단가새값, 문서새판} — 값 줄 · 부름 문서를 지금 M01 과 대조.""" got = skc.state(values_of(copy), skc.whole_()) live, base = folder(), copy.get("원본") or {} new = {k for k, d in (copy.get("부름") or {}).items() if k in live and fp(d) != fp(live[k])} if base.get("키") in live and fp(live[base["키"]]) != base.get("판"): new.add(base["키"]) # 이 SK 문서를 찍은 M01 문서가 바뀜 return {**got, "문서새판": sorted(new)}