Files
eomsangdonandClaude Opus 5.5 f7038cc280 feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md
- 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts
- 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖
- M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름
- 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름)
- 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서)

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

546 lines
20 KiB
Python
Raw Permalink 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 -*-
"""단가산출 수식 문서 — 꼴 · 풀이(파스) · 글로 되돌림 · 검사 (PLAN 802-1 · 요구 664 · 요구서_664).
꼴 글 = `resources/master_data/_수식문서_틀.md` · 셈 = `master_doc_calc`
· 연결 풀이 = `master_doc_link`.
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc.ts`(풀이 · 글 · 기호) · 거울 시험
`resources/tester/test_802_1_formula_doc.py`. 고칠 때 둘을 같이 고침.
식 글: 기호(`Q_시공^표` · `C_{m 3}`) · 수 · '글' · + - × ÷(* / − 도 됨) · % · 괄호 · 비교
· 그리고 · 또는 ·
함수(만약 · 올림 · 버림 · 반올림 · 최소 · 최대) · 칸 꺼냄(`B_굴착기.경비` · `번지.A01.재료비` ·
`단계.'소계'.계`). eval 없음.
"""
from __future__ import annotations
import re
from decimal import Decimal
COSTS = ("노무비", "재료비", "경비")
BUNDLE = (*COSTS, "계")
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
MONEY_NAMES = (*COSTS, "번지", "단계") # 금액 줄에서만 쓰는 이름(앞 줄까지 누계 · 번지 · 단계)
RESERVED = (*BUNDLE, "번지", "단계", "그리고", "또는", *FUNCS)
LINKS = ("숫자", "선택", "입력", "요소", "표", "단가산출", "식")
WAYS = ("버림", "반올림", "올림")
ENDS = ("비목버림", "큰나머지")
CUT = { # 기본 끝수 — 새 문서에 그대로 적어 둠(품셈 1-2-2 · 일위대가 0.1 원 · 계 1 원 · 미만 버림)
"줄": {"방법": "버림", "자리": 1},
"비목": {"방법": "버림", "자리": 0},
"끝전": "비목버림",
}
PREC = {"또는": 1, "그리고": 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3}
PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
SHOW = {"*": "×", "/": "÷"}
_ALIAS = {"×": "*", "÷": "/", "−": "-", "≤": "<=", "≥": ">=", "≠": "!="}
_BASE = r"[^\W\d_](?:[^\W_]|[′'])*"
_RUN = r"(?:[^\W_]|[′'])+"
_PART = r"(?:\{[^{}]*\}|" + _RUN + ")"
ID = re.compile(_BASE + r"(?:_" + _PART + r")?(?:\^" + _PART + r")?")
_NUM = re.compile(r"\d+(?:\.\d+)?")
_FIELD = re.compile(r"(?:[^\W_]|[′'])+")
HOLE = re.compile(r"\{([^{}]+)\}")
class DocError(ValueError):
"""식 틀림 — 글 · 자리."""
# ── 기호 ──────────────────────────────────────────────────────────────
def _part(text: str) -> str:
return text if re.fullmatch(_RUN, text) else "{" + text + "}"
def sym_id(sign: str, low: str = "", up: str = "") -> str:
"""기호 · 아래 · 위 → 식 글 이름(`Q_시공^표`) — 첨자에 낱말 아닌 글이 들면 `{…}`."""
out = str(sign or "")
if low:
out += "_" + _part(str(low))
if up:
out += "^" + _part(str(up))
return out
def split_id(text: str) -> tuple[str, str, str]:
"""식 글 이름 → (기호, 아래, 위)."""
m = re.fullmatch(r"(" + _BASE + r")(?:_(" + _PART + r"))?(?:\^(" + _PART + r"))?", text or "")
if not m:
return text, "", ""
strip = lambda s: (s[1:-1] if s.startswith("{") else s) if s else "" # noqa: E731
return m.group(1), strip(m.group(2) or ""), strip(m.group(3) or "")
def var_id(var: dict) -> str:
return sym_id(var.get("기호") or "", var.get("아래") or "", var.get("위") or "")
def good_id(text: str) -> bool:
return bool(ID.fullmatch(text or "")) and text not in RESERVED
# ── 풀이 ──────────────────────────────────────────────────────────────
class _Parser:
def __init__(self, text: str):
self.s, self.i = str(text), 0
def fail(self, why: str):
raise DocError(f"{why} — 「{self.s}」 {self.i}자")
def ws(self):
while self.i < len(self.s) and self.s[self.i].isspace():
self.i += 1
def op(self, *words: str) -> str | None:
self.ws()
for w in words:
if self.s.startswith(w, self.i):
if w in ("그리고", "또는") and ID.match(self.s, self.i).end() != self.i + len(w):
continue # 「그리고값」 같은 이름
self.i += len(w)
return _ALIAS.get(w, w)
return None
def need(self, word: str):
if not self.op(word):
self.fail(f"「{word}」 없음")
def parse(self):
self.ws()
if self.i >= len(self.s):
self.fail("빈 식")
node = self.expr()
self.ws()
if self.i != len(self.s):
self.fail("식 끝에 남은 글자")
return node
def expr(self):
node = self.both()
while self.op("또는"):
node = ("bin", "또는", node, self.both())
return node
def both(self):
node = self.compare()
while self.op("그리고"):
node = ("bin", "그리고", node, self.compare())
return node
def compare(self):
node = self.add()
o = self.op("<=", ">=", "==", "!=", "≤", "≥", "≠", "<", ">")
return ("bin", o, node, self.add()) if o else node
def add(self):
node = self.mul()
while o := self.op("+", "-", "−"):
node = ("bin", o, node, self.mul())
return node
def mul(self):
node = self.unary()
while o := self.op("*", "/", "×", "÷"):
node = ("bin", o, node, self.unary())
return node
def unary(self):
if self.op("-", "−"):
return ("neg", self.unary())
node = self.primary()
while True:
self.ws()
if self.s.startswith(".", self.i):
self.i += 1
node = ("field", node, self.field())
elif self.s.startswith("%", self.i):
self.i += 1
node = ("pct", node)
else:
return node
def field(self) -> str:
if self.s.startswith("'", self.i):
return self.quoted()
m = _FIELD.match(self.s, self.i)
if not m:
self.fail("칸 이름 없음")
self.i = m.end()
return m.group()
def quoted(self) -> str:
end = self.s.find("'", self.i + 1)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i + 1 : end], end + 1
return text
def primary(self):
self.ws()
if self.op("("):
node = self.expr()
self.need(")")
return node
if self.s.startswith("'", self.i):
return ("str", self.quoted())
m = _NUM.match(self.s, self.i)
if m:
self.i = m.end()
return ("num", Decimal(m.group()))
m = ID.match(self.s, self.i)
if not m:
self.fail("기호 · 수 없음")
self.i = m.end()
word = m.group()
self.ws()
if self.s.startswith("(", self.i):
if word not in FUNCS:
self.fail(f"모르는 함수 「{word}」")
self.i += 1
items = [self.expr()]
while self.op(","):
items.append(self.expr())
self.need(")")
return ("call", word, items)
if word in ("그리고", "또는") or word in FUNCS:
self.fail(f"「{word}」 자리 아님")
return ("var", word)
def parse(text) -> tuple:
"""식 글 → 나무(튜플) · 틀리면 DocError."""
if isinstance(text, (int, float, Decimal)) and not isinstance(text, bool):
return ("num", Decimal(str(text)))
return _Parser(str(text or "")).parse()
def walk(node):
yield node
kind = node[0]
if kind == "bin":
yield from walk(node[2])
yield from walk(node[3])
elif kind in ("neg", "pct", "field"):
yield from walk(node[1])
elif kind == "call":
for x in node[2]:
yield from walk(x)
def names(node) -> list[str]:
"""식이 부르는 이름(차례 · 겹침 없이) — 칸 이름은 빼고."""
out: list[str] = []
for n in walk(node):
if n[0] == "var" and n[1] not in out:
out.append(n[1])
return out
# ── 글로 되돌림 ───────────────────────────────────────────────────────
def num_text(d) -> str:
d = Decimal(str(d))
text = format(d, "f")
return text
def _prec(node) -> int:
if node[0] == "bin":
return PREC[node[1]]
if node[0] == "neg":
return 6
return 9
def text_of(node) -> str:
"""나무 → 식 글(× ÷ · 나무 묶음 그대로 · 괄호는 꼭 필요한 만큼)."""
kind = node[0]
if kind == "num":
return num_text(node[1])
if kind == "str":
return f"'{node[1]}'"
if kind == "var":
return node[1]
if kind == "field":
base = text_of(node[1])
if _prec(node[1]) < 9:
base = f"({base})"
name = node[2] if _FIELD.fullmatch(node[2]) else f"'{node[2]}'"
return f"{base}.{name}"
if kind == "pct":
inner = text_of(node[1])
return f"({inner})%" if _prec(node[1]) < 9 else f"{inner}%"
if kind == "neg":
inner = text_of(node[1])
return f"-({inner})" if node[1][0] == "bin" else f"-{inner}"
if kind == "call":
return f"{node[1]}({', '.join(text_of(x) for x in node[2])})"
_, op, a, b = node
mine = PREC[op]
left, right = text_of(a), text_of(b)
if _prec(a) < mine or (mine == 3 and _prec(a) == 3):
left = f"({left})"
if _prec(b) <= mine:
right = f"({right})"
return f"{left} {SHOW.get(op, op)} {right}"
def tidy(text) -> str:
"""식 글 다듬기(풀이 → 되돌림) — 못 풀면 그대로."""
try:
return text_of(parse(text))
except DocError:
return str(text)
def _swap(node, old: str, new: str):
kind = node[0]
if kind == "var":
return ("var", new) if node[1] == old else node
if kind == "bin":
return ("bin", node[1], _swap(node[2], old, new), _swap(node[3], old, new))
if kind in ("neg", "pct"):
return (kind, _swap(node[1], old, new))
if kind == "field":
return ("field", _swap(node[1], old, new), node[2])
if kind == "call":
return ("call", node[1], [_swap(x, old, new) for x in node[2]])
return node
def rename_text(text, old: str, new: str) -> str:
"""식 글 안 이름 바꿈 — 못 푸는 글은 그대로."""
if text is None or text == "":
return text
try:
node = parse(text)
except DocError:
return text
return text_of(_swap(node, old, new)) if old in names(node) else text
def holes(text) -> list[str]:
return HOLE.findall(str(text or ""))
def link_texts(link: dict):
"""연결 안 식 글 자리 — (칸, 열쇠, 글) · 조건 · 넘길 입력."""
for slot in ("조건", "입력"):
for k, v in (link.get(slot) or {}).items():
yield slot, k, v
def link_names(link: dict) -> list[str]:
"""연결이 부르는 이름 — 키 · 재료 조건 안 `{이름}` · 지역 · 계약종별 · 조건 · 넘길 입력 식."""
out: list[str] = []
for text in (link.get("키"), link.get("표"), *(link.get("재료") or {}).values()):
out += [h for h in holes(text) if h not in out]
for slot in ("지역", "계약종별"):
if link.get(slot) and link[slot] not in out:
out.append(link[slot])
for _, _, text in link_texts(link):
try:
out += [n for n in names(parse(text)) if n not in out]
except DocError:
pass
return out
def rename(doc: dict, old: str, sign: str, low: str = "", up: str = "") -> dict:
"""기호 · 첨자 바꿈 — 변수표 · 식 줄 머리 · 모든 식 · 연결 안 이름까지.
제자리 고침 · 같은 것 돌려줌."""
new = sym_id(sign, low, up)
if not good_id(new):
raise DocError(f"기호 꼴 아님 「{new}」")
if new != old and new in defined(doc):
raise DocError(f"같은 기호 있음 「{new}」")
for var in doc.get("변수") or []:
if var_id(var) == old:
var["기호"], var["아래"], var["위"] = sign, low, up
link = var.get("연결") or {}
for key in ("키", "표"):
if link.get(key):
link[key] = HOLE.sub(
lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", link[key]
)
if isinstance(link.get("재료"), dict):
link["재료"] = {
k: HOLE.sub(
lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", str(v)
)
if isinstance(v, str)
else v
for k, v in link["재료"].items()
}
for slot in ("지역", "계약종별"):
if link.get(slot) == old:
link[slot] = new
for slot in ("조건", "입력"):
if isinstance(link.get(slot), dict):
link[slot] = {k: rename_text(v, old, new) for k, v in link[slot].items()}
for line in doc.get("식") or []:
if line.get("기호") == old:
line["기호"] = new
line["식"] = rename_text(line.get("식"), old, new)
for line in doc.get("금액") or []:
if line.get("기호") == old:
line["기호"] = new
for k in ("수량", "단가"):
line[k] = rename_text(line.get(k), old, new)
if doc.get("결과") == old:
doc["결과"] = new
return doc
# ── 정의 · 검사 ───────────────────────────────────────────────────────
def defined(doc: dict) -> dict[str, list[str]]:
"""이름 → 정의 자리 목록(「식」 · 「연결」 · 「금액」) — 둘 이상 = 겹침."""
out: dict[str, list[str]] = {}
for var in doc.get("변수") or []:
if (var.get("연결") or {}).get("갈래", "식") != "식":
out.setdefault(var_id(var), []).append("연결")
for line in doc.get("식") or []:
out.setdefault(str(line.get("기호") or ""), []).append("식")
for line in doc.get("금액") or []:
if line.get("기호"):
out.setdefault(str(line["기호"]), []).append("금액")
return out
def _mark(kind: str, text: str, where: str = "", sign: str = "") -> dict:
return {
"갈래": kind,
"글": text,
**({"자리": where} if where else {}),
**({"기호": sign} if sign else {}),
}
def check(doc: dict) -> list[dict]:
"""정적 검사(셈 없음) — 식 틀림 · 정의 필요 · 겹침 · 순환 · 기호 꼴 · 연결 꼴.
금액 줄 · 끝수 꼴도 봄."""
out: list[dict] = []
defs = defined(doc)
money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
for name, where in defs.items():
if not good_id(name):
out.append(_mark("기호 꼴", f"「{name}」 은 기호로 못 씀", sign=name))
if len(where) > 1:
out.append(
_mark("겹침", f"「{name}」 정의가 {' · '.join(where)} 에 둘 이상", sign=name)
)
uses: dict[str, list[str]] = {}
def need(text, where: str, money: bool, head: str = "") -> list[str]:
try:
got = names(parse(text))
except DocError as e:
out.append(_mark("식 틀림", str(e), where, head))
return []
for n in got:
if n in MONEY_NAMES and not money:
out.append(_mark("금액 이름", f"「{n}」 은 금액 줄에서만", where, head))
elif n not in defs and n not in BUNDLE and n not in MONEY_NAMES:
out.append(_mark("정의 필요", f"「{n}」 정의 필요", where, n))
elif n in money_syms and not money:
out.append(
_mark("금액 이름", f"「{n}」(금액 줄 기호)는 금액 줄에서만", where, head)
)
return got
for var in doc.get("변수") or []:
link, me = var.get("연결") or {}, var_id(var)
kind = link.get("갈래", "식")
if kind not in LINKS:
out.append(_mark("연결 꼴", f"「{me}」 연결 갈래 「{kind}」", sign=me))
continue
if kind == "식" and me not in defs:
out.append(_mark("정의 필요", f"「{me}」 식 줄 없음", sign=me))
got = []
for slot, key, text in link_texts(link):
got += need(text, f"{me} {slot} {key}", False, me)
for h in link_names(link):
if h not in got and h not in defs:
out.append(_mark("정의 필요", f"「{h}」 정의 필요", me, h))
uses[me] = link_names(link)
if kind == "선택" and not isinstance(link.get("목록"), list):
out.append(_mark("연결 꼴", f"「{me}」 선택 목록 없음", sign=me))
if kind == "표" and not (link.get("표") and link.get("칸")):
out.append(_mark("연결 꼴", f"「{me}」 표 · 칸 없음", sign=me))
if kind == "요소" and not (link.get("키") or link.get("재료")):
out.append(_mark("연결 꼴", f"「{me}」 요소 없음", sign=me))
if kind == "단가산출" and not link.get("키"):
out.append(_mark("연결 꼴", f"「{me}」 단가산출 없음", sign=me))
for line in doc.get("식") or []:
head = str(line.get("기호") or "")
if not head:
out.append(_mark("식 틀림", "식 줄 기호 없음"))
uses[head] = uses.get(head, []) + need(line.get("식"), head, False, head)
for at, line in enumerate(doc.get("금액") or []):
where = f"금액 {line.get('이름') or at + 1}"
if line.get("비목") not in (*COSTS, "묶음"):
out.append(_mark("금액 줄", f"{where} 비목 「{line.get('비목')}」"))
for k in ("수량", "단가"):
if k == "단가" or line.get(k) not in (None, ""):
need(line.get(k), f"{where} {k}", True)
if line.get("자리") is not None and not _way(line["자리"]):
out.append(_mark("끝수", f"{where} 자리 「{line['자리']}」"))
cut = doc.get("끝수") or {}
for k in ("줄", "비목", "계"):
if cut.get(k) is not None and not _way(cut[k]):
out.append(_mark("끝수", f"끝수 {k} 「{cut[k]}」"))
if cut.get("끝전") not in (None, *ENDS):
out.append(_mark("끝수", f"끝전 「{cut['끝전']}」 — {' · '.join(ENDS)}"))
if doc.get("결과") and doc["결과"] not in defs:
out.append(_mark("정의 필요", f"결과 「{doc['결과']}」 정의 필요", sign=doc["결과"]))
for loop in loops(uses):
out.append(_mark("순환", " → ".join([*loop, loop[0]]), sign=loop[0]))
return out
def _way(rule) -> bool:
return (
isinstance(rule, dict)
and rule.get("방법") in WAYS
and isinstance(rule.get("자리", 0), (int, Decimal))
and 0 <= int(rule.get("자리", 0)) <= 6
)
def loops(uses: dict[str, list[str]]) -> list[list[str]]:
"""돌고 도는 정의(이름 → 부르는 이름들) — 고리마다 한 번."""
out, seen, done = [], set(), set()
def go(name: str, path: list[str]):
if name in path:
loop = path[path.index(name) :]
key = frozenset(loop)
if key not in seen:
seen.add(key)
out.append(loop)
return
if name in done or name not in uses:
return
for n in uses[name]:
go(n, [*path, name])
done.add(name)
for name in list(uses):
go(name, [])
return out
def blank(key: str = "", name: str = "", unit: str = "원") -> dict:
"""빈 문서 — 빈 화면에서 시작(변수 · 식 · 금액 없음 · 기본 끝수만 적어 둠)."""
return {
"꼴": "수식문서",
"판": 1,
"키": key,
"이름": name,
"결과단위": unit,
"변수": [],
"식": [],
"금액": [],
"끝수": {k: (dict(v) if isinstance(v, dict) else v) for k, v in CUT.items()},
}