# -*- coding: utf-8 -*- """수식 문서 연결 풀이 — M01 마스터(PLAN 802-1 · 꼴 `_수식문서_틀.md` 3 장). `master_doc_calc.calc(doc, Links(...))` 가 부름. 셈 엔진(`master_formula.run` · `evaluate`)은 안 부름 — 마스터 색인(`Master`) · 요소 값(준용 · 자재 고르기 · 단위 환산 = `master_material` · `master_pick`)만 씀. 표 찾기(범위 규칙 · 여러 줄 가름)는 여기 · TS 짝 = `common_util/common_util_formula_doc_calc.ts` `findRow`. 수식 문서 저장 자리 = `resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json` (로직 파일마다 한 벌). """ from __future__ import annotations import json from decimal import Decimal from pathlib import Path import master_doc as md import master_doc_calc as mc import master_formula as mf import master_material as mm import master_pick as mp FOLDER = Path(__file__).resolve().parents[1] / "수식문서" _ARITH_OK = ("num", "bin", "neg") def _arith(text: str): """표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로.""" try: node = md.parse(text) except md.DocError: return text if any(n[0] not in _ARITH_OK for n in md.walk(node)): return text def ev(n): if n[0] == "num": return n[1] if n[0] == "neg": return -ev(n[1]) a, b = ev(n[2]), ev(n[3]) return {"+": a + b, "-": a - b, "*": a * b}.get(n[1]) if n[1] != "/" else a / b try: return ev(node) except ArithmeticError: return text def _cell(value, col: str): if value is None: raise mc.Gap(f"칸 「{col}」 값 없음 — 관리자가 채울 값") if isinstance(value, list): raise mc.Broken(f"칸 「{col}」 값이 범위 — 입력으로 받아야 함") if isinstance(value, str): return _arith(value) return value # ── 표 찾기 ─────────────────────────────────────────────────────────── def _match(table: dict, row: dict, cond: str, want) -> bool: if cond not in row: # 빈 조건 칸 = 무엇이든 맞음 return True have = row[cond] if table.get("조건", {}).get(cond) != "범위": return have == want low, high = have rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ") rule = rule.split(" (")[0] if rule in ("이상·이하",): return (low is None or want >= low) and (high is None or want <= high) if rule in ("초과·이하", "이하"): return (low is None or want > low) and (high is None or want <= high) if rule == "이상·미만": top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None) closed = any("이하 포함" in x for x in more) and high == top return (low is None or want >= low) and ( high is None or want < high or (closed and want == high) ) raise mc.Broken(f"모르는 범위규칙 「{table.get('범위규칙')}」") def find(table: dict, conds: dict, want: str | None = None) -> dict: """조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄. 여럿이면 찾는 칸 있는 줄.""" for cond in conds: if cond not in table.get("조건", {}): raise mc.Broken(f"표 {table.get('키')} 에 없는 조건 「{cond}」") try: hits = [r for r in table["줄"] if all(_match(table, r, c, v) for c, v in conds.items())] except TypeError: raise mc.Broken(f"표 {table.get('키')} 범위 조건에 수 아닌 값") from None if len(hits) > 1 and str(table.get("범위규칙") or "").split(" · ")[0] in ("이하", "이상·이하"): for c in conds: ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)] if ups and None not in ups: hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)] if len(hits) > 1 and want: hits = [r for r in hits if want in r] if len(hits) != 1: what = " · ".join(f"{c} {mc.plain_text(v)}" for c, v in conds.items()) where = f"표 {table.get('키')}({table.get('원문번호')})" raise mc.Gap(f"{where}에 {what} 인 줄이 " + ("없음" if not hits else f"{len(hits)}개")) return hits[0] # ── 연결 ────────────────────────────────────────────────────────────── class Links: """M01 마스터 연결 — 표 · 요소 · 단가산출 문서(`docs` = {키: 문서} · 없으면 저장 폴더).""" def __init__(self, master: mf.Master, docs: dict[str, dict] | None = None): self.master = master self.docs = load_all() if docs is None else docs def _get(self, ref: str) -> dict: try: return self.master.get(ref) except mf.FormulaError as e: raise mc.Broken(str(e)) from None def table(self, p: dict): if not p.get("표") or not p.get("칸"): # 고르는 중 — 표지(예외 아님) raise mc.Broken("표 · 칸 고르지 않음") table = self._get(p["표"]) if "줄" not in table or "조건" not in table: raise mc.Broken(f"「{p['표']}」 표 아님") row = find(table, p.get("조건") or {}, p.get("칸")) if p["칸"] not in row: # 원문 「-」 = 그 줄에 값 칸 없음 if p["칸"] not in (table.get("값칸") or {}) and not any( p["칸"] in r for r in table["줄"] ): raise mc.Broken(f"표 {table.get('키')} 에 칸 「{p['칸']}」 없음") raise mc.Gap(f"표 {table.get('키')} 고른 줄에 「{p['칸']}」 값 없음") return _cell(row[p["칸"]], p["칸"]) def element(self, p: dict): if not p.get("키") and p.get("재료") is None: # 고르는 중 — 표지(예외 아님) raise mc.Broken("요소 고르지 않음") env = {} if p.get("지역") is not None: env[mm.REGION] = p["지역"] if p.get("계약종별") is not None: env[mm.TARIFF] = p["계약종별"] if p.get("재료") is not None or p.get( "단위" ): # 금액 줄 단가 — 고르기 · 단위 환산 · 값 없음 = 빈 줄 ref = p.get("재료") if p.get("재료") is not None else p["키"] price, _, why, _ = mp.priced(self.master, {"단위": p.get("단위")}, ref, env, None) if price is None: raise mc.Gap(why or "단가 없음") if isinstance(price, (dict, list)): raise mc.Broken("단가가 묶음 · 범위") return price self._get(p["키"]) try: value, _ = mm.element(self.master, p["키"], env) except mf.FormulaError as e: raise mc.Broken(str(e)) from None col = p.get("칸") if col: if not isinstance(value, dict): raise mc.Broken(f"「{p['키']}」 값에 칸 없음") if col not in value: raise mc.Broken(f"「{p['키']}」 칸 「{col}」 없음") return _cell(value[col], col) if isinstance(value, dict): raise mc.Broken(f"「{p['키']}」 값이 묶음 — 칸 필요") return _cell(value, self.master.label(p["키"])) def doc(self, key: str) -> dict | None: if key in self.docs: return self.docs[key] try: row = self.master.get(key) except mf.FormulaError: return None return self.docs.get(str(row.get("키"))) # ── 저장 ────────────────────────────────────────────────────────────── def file_of(logic_file: str) -> str: """옛 로직 파일 이름 → 수식 문서 파일 이름(`로직_X.json` → `수식_X.json`).""" return "수식_" + logic_file.split("_", 1)[1] def load_file(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal) def load_all(folder: Path = FOLDER) -> dict[str, dict]: """저장 폴더 문서 전부 — {키: 문서}.""" out: dict[str, dict] = {} for path in sorted(folder.glob("수식_*.json")): for doc in load_file(path).get("줄") or []: out[str(doc.get("키"))] = doc return out def _norm(v): if isinstance(v, Decimal): if v == v.to_integral_value() and "E" not in str(v) and "." not in str(v): return int(v) return float(v) if isinstance(v, dict): return {k: _norm(x) for k, x in v.items()} if isinstance(v, list): return [_norm(x) for x in v] return v def _enc(v, depth: int, pad: str) -> str: """깊이 4(변수 · 식 · 금액 줄 하나)부터 한 줄.""" one = json.dumps(v, ensure_ascii=False, separators=(", ", ": ")) if depth >= 4 or not isinstance(v, (dict, list)) or not v: return one inner, cut = pad + " ", ",\n" if isinstance(v, list): body = cut.join(inner + _enc(x, depth + 1, inner) for x in v) return "[\n" + body + "\n" + pad + "]" body = cut.join( inner + json.dumps(k, ensure_ascii=False) + ": " + _enc(x, depth + 1, inner) for k, x in v.items() ) return "{\n" + body + "\n" + pad + "}" def dump(data) -> str: """저장 글 — UTF-8 · LF · 들여쓰기 2칸 · 변수 · 식 · 금액 줄 하나 = 한 줄. 수는 군더더기 없이.""" return _enc(_norm(data), 0, "") + "\n"