# -*- coding: utf-8 -*- """옛 M01 로직 줄 → 수식 문서 변환 (PLAN 802-2 · 요구서_664 1 장 3 · 꼴 `_수식문서_틀.md`). 빌더 `build_수식문서_802_2.py` 가 부름 · 다시 돌리면 같은 결과. - 기호 = 800-1 · 800-5 기호(수식 탭이 보이는 것 · `master_equation.export` + `master_equation_sign.settle`) · 못 내보내는 로직은 같은 규칙(`master_symbol`)으로 · 한 문서 안 겹침 = 아래첨자 끝 숫자. - 입력 = 선택(고르기) · 입력 · 값 = 기본 옵션(`master_equation.given_of`) · 중간 = 식 줄 · 한 마디 중간 (표 찾기 · 요소 · 로직 부름 하나) = 그 기호에 바로 연결 · 표 찾기 · 요소 · 로직 부름 = 기호 + 연결. - 호표 = 금액 줄(수량 × 단가 · 단가 = 요소 · 재료 고르기 · 단가산출 묶음) · 덧줄 = 금액 줄(단가 = 식) · `줄.'이름'` · 앞 덧줄 이름 = 그 금액 줄 기호 · 끝수 결과 · 표시 = 식 안 반올림 · 금액 끝수 = 문서 `끝수`. 옛 엔진 셈은 안 함(기호 · 이름 보기만 export 를 씀). """ from __future__ import annotations import re from decimal import Decimal import master_doc as md import master_equation as meq import master_equation_edit as mee import master_equation_sign as mes import master_formula as mf import master_material as mm import master_symbol as msym _ERR = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, IndexError) SUBMAX = msym.SUBMAX def _harvest(master, key: str) -> dict: """수식 탭 내보내기 기호 — (꼴, 갈래, 식 | 열쇠) → 마디 · 못 내보내면 빈 것.""" try: ex = mes.settle(meq.export(master, key)) except _ERR: return {} out: dict = {} def visit(t): if isinstance(t, list): for x in t: visit(x) elif isinstance(t, dict): if t.get("꼴") in ("변수", "블록"): out.setdefault((t["꼴"], t.get("갈래"), str(t.get("식") or t.get("열쇠"))), t) for k, v in t.items(): if k != "나무" and isinstance(v, (dict, list)): visit(v) for line in ex.get("줄") or []: visit(line.get("이름식")) if isinstance(line.get("결과"), dict): head = line["결과"] out.setdefault(("머리", line.get("갈래"), str(head.get("열쇠"))), head) return out _CODE = re.compile(r"^(?:EQ|MT|LB)\d") def res_low(master, link: dict, low: str) -> str: """요소 키가 아래첨자로 샌 것(`k_EQ0006` · 804-7 · 요구 687-18) → 자원 이름 끝 낱말(`k_하베스터`) · 괄호 · 쉼표 뒤 · 숫자로 시작하는 규격 낱말 뺌 · 겹침은 부른 쪽이 2 · 3.""" if not _CODE.match(low or "") or (link or {}).get("갈래") != "요소": return low try: row = master.get(str(link.get("키") or "")) except _ERR: return low name = re.sub(r"\(.*?\)", "", str(row.get("이름") or "")).split(",")[0] words = [w for w in name.split() if not w[:1].isdigit()] got = msym.short(words[-1]) if words else "" return got if got and got != "·" else low def _clean(text: str) -> str: """기호 · 첨자 글 다듬기 — 낱말 아닌 글 뺌.""" return re.sub(r"[^\w′'·]|_", "", str(text or "")) class _Reg: """기호 자리 — 열쇠마다 (기호, 아래, 위) 제안 → 겹침 없는 이름.""" def __init__(self): self.props: dict[tuple, tuple[str, str, str]] = {} self.ids: dict[tuple, str] = {} self.used: set[str] = set() self.notes: list[str] = [] def want(self, key: tuple, parts: tuple[str, str, str]) -> None: self.props.setdefault(key, parts) def settle(self) -> None: for key, (sign, low, up) in self.props.items(): if key in self.ids: continue if not md.good_id(md.sym_id(sign, "", "")): fixed = _clean(sign) fixed = fixed if fixed and md.good_id(fixed) else "X" self.notes.append(f"기호 「{sign}」 → 「{fixed}」") sign = fixed n, base = 1, low while md.sym_id(sign, low, up) in self.used or not md.good_id(md.sym_id(sign, low, up)): n += 1 low = (base or "")[: SUBMAX - len(str(n))] + str(n) if n > 99: raise ValueError(f"기호 못 가름 {key}") self.used.add(md.sym_id(sign, low, up)) self.ids[key] = md.sym_id(sign, low, up) def id(self, key: tuple) -> str: return self.ids.get(key, "?") class _Conv: def __init__(self, master, row: dict, given: dict): self.master, self.row, self.given = master, row, given self.key = str(row["키"]) self.found = _harvest(master, self.key) self.table = {v["열쇠"]: v for v in meq.table(row, master)} self.reg = _Reg() self.links: dict[tuple, dict] = {} # 열쇠 → 변수 칸(이름 · 단위 · 연결) self.order: list[tuple] = [] self.alias: dict[str, tuple] = {} # 한 마디 중간 식 → ('중간', 이름) self.names = {s["이름"] for s in row.get("입력") or []} | { s["이름"] for s in row.get("중간") or [] } self.kinds = {s["이름"]: "입력" for s in row.get("입력") or []} self.kinds |= {s["이름"]: "중간" for s in row.get("중간") or []} self.notes: list[str] = [] self.used_lines: set[tuple] = set() # 식이 부른 금액 줄 — ('합' | '덧', 이름) self.quiet = "" # 한 마디 중간 연결만 뜰 때(그 마디 식) — 기호 자리 안 잡음 self.last: dict = {} # 기호 제안 def _look(self, kind: str, raw: str) -> dict | None: for k in (("변수", kind, raw), ("블록", None, raw), ("변수", "요소", raw)): if k in self.found: return self.found[k] return None def _var(self, key: tuple, family: str, raw: str, name: str, link: dict, unit: str = "") -> str: if self.quiet and self.quiet == raw: self.quiet, self.last = "", link return "?" if key not in self.links: node = self._look(family, raw) or {} sign = str(node.get("기호") or "") if sign and node.get("기호") != node.get("열쇠"): parts = _merge(sign, str(node.get("아래") or ""), str(node.get("위") or "")) else: fam = {"블록": "결과"}.get(family, family) parts = msym.lookup(name or raw, fam if fam in msym.FAMILY else "요소") parts = (parts[0], res_low(self.master, link, parts[1]), parts[2]) self.reg.want(key, parts) self.order.append(key) label = str(node.get("이름") or name or raw) self.links[key] = { "이름": label, "단위": str(node.get("단위") or unit or ""), "연결": link, } self.links[key]["연결"] = link # 두 번째 돌이 = 겹침 가른 이름으로 쓴 연결 return self.reg.id(key) def own(self, name: str) -> str: """입력 · 중간 이름 → 기호.""" if name not in self.names: self.notes.append(f"모르는 이름 「{name}」") return name if md.good_id(name) else "X_" + _clean(name) return self.reg.id((self.kinds[name], name)) def holes(self, text): if not isinstance(text, str): return text return md.HOLE.sub(lambda m: "{" + self.own(m.group(1)) + "}", text) def _place(self) -> dict: out = {} if mm.REGION in self.kinds: out["지역"] = self.own(mm.REGION) if mm.TARIFF in self.kinds: out["계약종별"] = self.own(mm.TARIFF) return out # 식 def emit(self, node, money: dict | None = None): kind = node[0] if kind in ("num", "str"): return node if kind == "name": name = node[1] if money is not None and name in md.COSTS: return ("var", name) if money is not None and name in money: self.used_lines.add(money[name]) return ("var", self.reg.id(money[name])) return ("var", self.own(name)) if kind == "ref": return ("var", self.element(node[1])) if kind == "neg": return ("neg", self.emit(node[1], money)) if kind == "bin": return ("bin", node[1], self.emit(node[2], money), self.emit(node[3], money)) if kind == "call": return ("call", node[1], [self.emit(x, money) for x in node[2]]) if kind == "find": return ("var", self.find(node, None)) if kind == "logic": return ("var", self.block(node, None)) base, col = node[1], node[2] if base[0] == "find": return ("var", self.find(base, col)) if base[0] == "logic": return ("var", self.block(base, col)) if base[0] == "ref": return ("var", self.element(base[1], col)) if base[0] == "name" and base[1] == "줄" and money is not None: self.used_lines.add(("합", col)) return ("var", self.reg.id(("합", col))) if base[0] == "field" and base[1][0] == "name" and base[1][1] in ("번지", "단계"): return ("field", ("field", ("var", base[1][1]), base[2]), col) return ("field", self.emit(base, money), col) def text(self, raw, money: dict | None = None) -> str: return md.text_of(self.emit(mf.parse(raw), money)) # 연결 마디 def find(self, node, col) -> str: raw = mee.raw_text(node if col is None else ("field", node, col)) if raw in self.alias: return self.reg.id(self.alias[raw]) link = {"갈래": "표", "표": self.holes(node[1])} link["조건"] = {k: md.text_of(self.emit(v)) for k, v in node[2]} if col is None: self.notes.append(f"칸 없는 찾기 「{raw}」") else: link["칸"] = col return self._var(("찾기", raw), "찾기", raw, col or raw, link) def block(self, node, col) -> str: raw = mee.raw_text(node if col is None else ("field", node, col)) if raw in self.alias: return self.reg.id(self.alias[raw]) link = {"갈래": "단가산출", "키": self.holes(node[1])} link["입력"] = {k: md.text_of(self.emit(v)) for k, v in node[2]} if col is not None: link["칸"] = col try: sub = self.master.logic(mf.fill(node[1], {})) if "{" not in node[1] else {} except _ERR: sub = {} unit = str(sub.get("결과단위") or "") return self._var(("블록", raw), "블록", raw, str(sub.get("이름") or node[1]), link, unit) def element(self, ref: str, col: str | None = None, unit: str | None = None) -> str: raw = ref if col is None else mee.raw_text(("field", ("ref", ref), col)) if raw in self.alias and unit is None: return self.reg.id(self.alias[raw]) link = {"갈래": "요소", "키": self.holes(ref)} if col is not None: link["칸"] = col if unit is not None: link["단위"] = unit link |= self._place() try: label = self.master.label(ref) if "{" not in ref else ref except _ERR: label = ref return self._var(("요소", raw, unit), "요소", raw, label, link) def material(self, at: int, item: dict) -> str: cond = {k: self.holes(v) for k, v in item["요소"].items()} link = {"갈래": "요소", "재료": cond, "단위": item.get("단위") or ""} | self._place() name = str(item.get("이름") or "") return self._var(("재료", at), "요소", f"단가:{name}", f"{name}단가", link) def lone(self, raw: str): """한 마디 식(표 찾기 · 요소 · 로직 부름 하나) — 마디 · 아니면 None.""" try: node = mf.parse(raw) except mf.FormulaError: return None if node[0] in ("ref", "logic"): return node if node[0] == "field" and node[1][0] in ("find", "logic", "ref"): return node return None def _merge(sign: str, low: str, up: str) -> tuple[str, str, str]: """기호 칸에 첨자까지 든 것(「t_1」 · 「C_m」)을 가름.""" if "_" in sign or "^" in sign: head, low0, up0 = md.split_id(sign) if head != sign: return head, low0 + low, up or up0 return sign, low, up def _parts(var: dict) -> tuple[str, str, str]: return _merge( str(var.get("기호") or var["열쇠"]), str(var.get("아래") or ""), str(var.get("위") or "") ) def _line_parts(c: _Conv, name: str) -> tuple[str, str, str]: head = c.found.get(("머리", "호표", name)) or c.found.get(("머리", "덧줄", name)) or {} if head.get("기호") and head.get("기호") != name: return str(head["기호"]), str(head.get("아래") or ""), str(head.get("위") or "") return msym.lookup(name, "합") def convert(master, row: dict, given: dict | None = None) -> tuple[dict, list[str]]: """로직 줄 → (수식 문서, 알림). given = 기본 옵션(없으면 `given_of`).""" given = meq.given_of(master, row) if given is None else given c = _Conv(master, row, given) for spec in row.get("입력") or []: c.reg.want( ("입력", spec["이름"]), _parts(c.table.get(spec["이름"], {"열쇠": spec["이름"]})) ) for step in row.get("중간") or []: c.reg.want( ("중간", step["이름"]), _parts(c.table.get(step["이름"], {"열쇠": step["이름"]})) ) for item in row.get("호표") or []: c.reg.want(("합", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or ""))) for item in row.get("덧줄") or []: c.reg.want(("덧", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or ""))) lone_steps = {} for step in row.get("중간") or []: node = c.lone(step["식"]) if node is not None and step.get("표시") is None: raw = mee.raw_text(node) if raw not in c.alias: c.alias[raw] = ("중간", step["이름"]) lone_steps[step["이름"]] = node doc = None for _ in range(2): # 한 번 = 기호 모음 · 두 번 = 겹침 가른 이름으로 글 c.reg.settle() doc = _build(c, row, given, lone_steps) c.notes += c.reg.notes return slim_doc(doc), sorted(set(c.notes)) def _is(node, value) -> bool: return node[0] == "num" and node[1] == value def slim(node): """옮김 군더더기 걷기(802-3) — `+ 0` · `- 0` · `× 1` · `÷ 1` 지움 · `A × k ÷ 100` → `A × k%`(값 같음).""" kind = node[0] if kind == "bin": op, a, b = node[1], node[2], node[3] if op == "/" and _is(b, 100) and a[0] == "bin" and a[1] == "*" and a[3][0] == "num": return ("bin", "*", slim(a[2]), ("pct", a[3])) a, b = slim(a), slim(b) if (op in ("+", "-") and _is(b, 0)) or (op in ("*", "/") and _is(b, 1)): return a if (op == "+" and _is(a, 0)) or (op == "*" and _is(a, 1)): return b return ("bin", op, a, b) if kind in ("neg", "pct"): return (kind, slim(node[1])) if kind == "field": return ("field", slim(node[1]), node[2]) if kind == "call": return ("call", node[1], [slim(x) for x in node[2]]) return node def _slim_text(text): if not isinstance(text, str) or not text.strip(): return text try: return md.text_of(slim(md.parse(text))) except md.DocError: return text def slim_doc(doc: dict) -> dict: """식 줄 · 금액 줄(수량 · 단가) 글에 `slim`.""" for line in doc.get("식") or []: line["식"] = _slim_text(line.get("식")) for line in doc.get("금액") or []: for k in ("수량", "단가"): if k in line: line[k] = _slim_text(line[k]) return doc def _link_of(c: _Conv, node) -> dict: """한 마디 중간의 연결 — 찾기 · 요소 · 로직 부름.""" alias = dict(c.alias) c.alias, c.quiet, c.last = {}, mee.raw_text(node), {} try: c.emit(node) return dict(c.last) finally: c.alias, c.quiet = alias, "" def _build(c: _Conv, row: dict, given: dict, lone_steps: dict) -> dict: variables: list[dict] = [] for spec in row.get("입력") or []: name = spec["이름"] look = c.table.get(name, {}) value = given.get(name) if isinstance(value, (int, float)) and not isinstance(value, bool): value = Decimal(str(value)) if "고르기" in spec: link = {"갈래": "선택", "목록": list(spec["고르기"]), "값": value} else: link = {"갈래": "입력", "값": value} if spec.get("범위"): link["범위"] = list(spec["범위"]) variables.append(_var_row(c.reg.id(("입력", name)), name, look, spec, link)) lines: list[dict] = [] for step in row.get("중간") or []: name = step["이름"] look = c.table.get(name, {}) me = c.reg.id(("중간", name)) if name in lone_steps: variables.append(_var_row(me, name, look, step, _link_of(c, lone_steps[name]))) continue try: text = c.text(step["식"]) except mf.FormulaError as e: c.notes.append(f"중간 「{name}」 식 못 읽음 — {e}") text = str(step["식"]) if step.get("표시") is not None: text = f"반올림({text}, {int(step['표시'])})" variables.append(_var_row(me, name, look, step, {"갈래": "식"})) lines.append({"기호": me, "식": text}) doc = { "꼴": "수식문서", "판": 1, "키": c.key, "이름": row.get("이름") or "", "결과단위": row.get("결과단위") or "", "출처": row.get("출처") or "", } money: list[dict] = [] if "결과" in row: head = c.found.get(("머리", "결과", "결과")) or {} sign = head.get("기호") if msym.is_symbol(str(head.get("기호") or "")) else "" parts = (sign, str(head.get("아래") or ""), str(head.get("위") or "")) if sign else None parts = parts or msym.rule(str(row.get("결과단위") or ""), "결과")[:3] c.reg.want(("결과", "결과"), parts) text = c.text(row["결과"]["식"]) rule = row.get("끝수") if isinstance(rule, dict) and rule.get("대상") == "결과": text = f"{rule['방법']}({text}, {int(rule.get('자리') or 0)})" me = c.reg.id(("결과", "결과")) variables.append( { "열쇠": "결과", **_split(me), "이름": "결과", "단위": row.get("결과단위") or "", "연결": {"갈래": "식"}, } ) lines.append({"기호": me, "식": text}) doc["결과"] = me else: money = _money(c, row) for key in c.order: if key[0] in ("입력", "중간") or key in [("결과", "결과")]: continue if any(alias == key for alias in c.alias.values()): continue one = c.links[key] me = c.reg.id(key) variables.append( { "열쇠": me, **_split(me), "이름": one["이름"], "단위": one["단위"], "연결": one["연결"], } ) doc |= {"변수": variables, "식": lines, "금액": money} cut = {k: (dict(v) if isinstance(v, dict) else v) for k, v in md.CUT.items()} rule = row.get("끝수") if isinstance(rule, dict) and rule.get("대상") == "계": cut["계"] = {"방법": rule["방법"], "자리": int(rule.get("자리") or 0)} if row.get("끝전"): cut["끝전"] = row["끝전"] doc["끝수"] = cut for k in ("단계", "조정"): if row.get(k): doc[k] = row[k] return doc def _split(me: str) -> dict: sign, low, up = md.split_id(me) return {"기호": sign, "아래": low, "위": up} def _var_row(me: str, name: str, look: dict, spec: dict, link: dict) -> dict: out = { "열쇠": name, **_split(me), "이름": look.get("이름") or name, "단위": spec.get("단위") or "", } for k in ("설명", "출처"): if spec.get(k): out[k] = spec[k] out["연결"] = link return out def _hand(c: _Conv, at: int, item: dict) -> str: """손 값 줄 — 값묶음(노 · 재 · 경) 또는 손 단가 = 숫자 연결.""" name = str(item.get("이름") or "") given = item.get("값묶음") if given: if set(given) < set(md.COSTS): c.notes.append(f"호표 「{name}」 값묶음 일부 — 빠진 비목 0") value = {k: Decimal(str(given.get(k) or 0)) for k in md.COSTS} else: value = Decimal(str(item["단가"])) link = {"갈래": "숫자", "값": value} return c._var(("손", at), "요소", f"단가:{name}", f"{name}단가", link) def _group_name(items: list[dict]) -> str: kinds = [] for x in items: k = x.get("종류") or str(x.get("이름") or "") if k not in kinds: kinds.append(k) return " · ".join(kinds) def _money(c: _Conv, row: dict) -> list[dict]: items, adds = row.get("호표") or [], row.get("덧줄") or [] out: list[dict] = [] for at, item in enumerate(items): name = str(item.get("이름") or "") line = {"이름": name} if item.get("규격"): line["규격"] = item["규격"] line["단위"] = item.get("단위") or "" try: qty = c.text(item["수량"]) except mf.FormulaError as e: c.notes.append(f"호표 「{name}」 수량 못 읽음 — {e}") qty = str(item["수량"]) kind = item.get("종류") if item.get("값묶음") or item.get("단가") is not None: # M02 손 값(값묶음 · 손 단가) price, line["비목"] = _hand(c, at, item), item.get("비목") or "묶음" if line["비목"] != "묶음" and item.get("값묶음"): price = f"{price}.계" elif kind == "로직": node = mf.parse(str(item["요소"])) price = md.text_of(c.emit(node)) bundle = node[0] == "logic" if "비목" in item: line["비목"] = item["비목"] if bundle: price = f"{price}.계" else: line["비목"] = "묶음" elif isinstance(item.get("요소"), dict): line["비목"] = item["비목"] price = c.material(at, item) else: line["비목"] = item["비목"] ref = str( item["요소"] ) # 품셈재료(MP)만 줄 단위로 값이 갈림 — 그 밖은 식 속 요소와 한 기호 price = c.element( ref, None, str(item.get("단위") or "") if ref[:2] == mm.LINKED else None ) line |= {"수량": qty, "단가": price} for k in ("번지", "합밖", "자리", "출처"): if k in item: line[k] = item[k] out.append(line) seen: dict[str, tuple] = {} for add in adds: name = str(add.get("이름") or "") try: price = c.text(add["식"], dict(seen)) except mf.FormulaError as e: c.notes.append(f"덧줄 「{name}」 식 못 읽음 — {e}") price = str(add["식"]) line = {"이름": name, "단위": "식", "비목": add["비목"], "단가": price} for k in ("번지", "합밖", "자리", "출처"): if k in add: line[k] = add[k] out.append(line) seen[name] = ("덧", name) last = { str(x.get("이름") or ""): i for i, x in enumerate(items) } # 같은 이름 = 뒤 줄(옛 `줄` 묶음) for i, line in enumerate(out): key = ("합", line["이름"]) if i < len(items) else ("덧", line["이름"]) if key in c.used_lines and (i >= len(items) or last[line["이름"]] == i): line["기호"] = c.reg.id(key) _groups(out, [*items, *adds]) return out def _groups(out: list[dict], items: list[dict]) -> None: """묶음 — 단가산출 부름 줄마다 한 묶음 · 그 사이 다른 줄은 이어진 것끼리 · 묶음 하나면 안 둠.""" runs: list[tuple[str, list[int]]] = [] for i, item in enumerate(items): if item.get("종류") == "로직": runs.append((str(item.get("이름") or ""), [i])) elif runs and runs[-1][0] == "" and items[runs[-1][1][-1]].get("종류") != "로직": runs[-1][1].append(i) else: runs.append(("", [i])) if len(runs) < 2: return for name, members in runs: label = name or _group_name([items[i] for i in members]) for i in members: out[i]["묶음"] = label