Files
Aislo/resources/master_data/scripts/master_doc_convert.py
T
eomsangdonandClaude Opus 5.5 a003587aca fix(802-3): 선택으로 꺼진 금액 줄 숨김 · 값 식 한 번 · 옮김 군더더기 걷음(브레인 사진 지적)
- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌
  1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서)
- 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로
- 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만
- 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만
- 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:43:00 +09:00

611 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
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 "요소")
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