# -*- 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()}, }