# -*- coding: utf-8 -*- """단가산출 수식 문서 셈 (PLAN 802-1 · 요구서_664 · 꼴 `_수식문서_틀.md`). 수식 문서를 그대로 셈 — 옛 로직 엔진(`master_formula`)을 안 부름. 기호는 필요할 때 셈(차례 무관) · 무엇을 지워도 예외 없음: 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(None) · 값 없음(빈 입력 · 표 줄 없음 · 요소 값 없음) = 그 기호를 쓰는 줄만 비움(까닭). 연결 풀이(표 · 요소 · 단가산출 문서 찾기)는 `links`(M01 = `master_doc_link.Links`). 파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_calc.ts` · 거울 시험 `resources/tester/test_802_1_formula_doc.py`. 수는 Decimal · 금액 버림은 엑셀 ROUNDDOWN(작은 오차 무시). """ from __future__ import annotations import math from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation import master_doc as md COSTS, BUNDLE = md.COSTS, md.BUNDLE EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음 DEEP = 20 # 단가산출 부름 깊이 _QUANT = {"버림": ROUND_DOWN, "반올림": ROUND_HALF_UP, "올림": ROUND_UP} class Gap(Exception): """연결 값 없음 — 그 기호를 쓰는 줄만 비움.""" class Broken(Exception): """연결 끊김(없는 키 · 칸 · 조건) — 셈 멈춤 · 금액 「—」.""" class Blank: def __init__(self, why: str): self.why = why class Stop: def __init__(self, why: str, kind: str = "멈춤", sign: str = ""): self.why, self.kind, self.sign = why, kind, sign def _bad(v) -> bool: return isinstance(v, (Blank, Stop)) # ── 수 ──────────────────────────────────────────────────────────────── def rounddown(value: float, places: int = 0) -> float: """엑셀 ROUNDDOWN(B09 `rounddown` 과 같음).""" a, scale = abs(value), 10**places out = math.floor(a * scale + EPS) / scale return -out if value < 0 else out def cut(value, rule: dict | None, plain: bool = False): """끝수 한 번 — 버림 = 엑셀 ROUNDDOWN(금액 끝수). plain = Decimal 자리 맞춤(식 함수 · 계 끝수).""" if value is None or not rule: return value way, places = rule.get("방법"), int(rule.get("자리") or 0) if way == "버림" and not plain: return Decimal(str(rounddown(float(value), places))) return Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=_QUANT[way]) def plain_text(v) -> str: """구멍(`{기호}`)에 넣는 글 — 수는 군더더기 없이.""" return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v) def number(v): if isinstance(v, bool): return v if isinstance(v, (int, float)): return Decimal(str(v)) return v # ── 셈 한 벌 ────────────────────────────────────────────────────────── class _Run: def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict): self.doc, self.links, self.stack, self.memo = doc, links, stack, memo self.given = given or {} self.vars = {md.var_id(v): v for v in doc.get("변수") or []} self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []} self.values: dict[str, object] = {} self.busy: list[str] = [] self.marks: list[dict] = [] self.money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")} def mark(self, stop: Stop) -> Stop: one = {"갈래": stop.kind, "글": stop.why, **({"기호": stop.sign} if stop.sign else {})} if one not in self.marks: self.marks.append(one) return stop # 이름 값 def get(self, name: str, scope: dict | None = None): if scope is not None and name in scope: return scope[name] if name in md.MONEY_NAMES or name in self.money_syms: return self.mark(Stop(f"「{name}」 은 금액 줄에서만", "금액 이름", name)) if name in self.values: return self.values[name] if name in self.busy: loop = self.busy[self.busy.index(name) :] return self.mark(Stop(" → ".join([*loop, name]), "순환", name)) self.busy.append(name) try: value = self._define(name) finally: self.busy.pop() if isinstance(value, Stop): self.mark(value) self.values[name] = value return value def _define(self, name: str): var, line = self.vars.get(name), self.lines.get(name) kind = ((var or {}).get("연결") or {}).get("갈래", "식") if line is not None and kind != "식": return Stop(f"「{name}」 정의가 식 줄 · 연결 둘", "겹침", name) if line is not None: return self.expr(line.get("식"), name) if var is None or kind == "식": return Stop(f"「{name}」 정의 필요", "정의 필요", name) return self.link(var, name) def expr(self, text, where: str = "", scope: dict | None = None): try: node = md.parse(text) except md.DocError as e: return self.mark(Stop(str(e), "식 틀림", where)) return self.ev(node, scope, where) # 식 def ev(self, node, scope, where): kind = node[0] if kind in ("num", "str"): return node[1] if kind == "var": return self.get(node[1], scope) if kind == "field": base = self.ev(node[1], scope, where) if _bad(base): return base if not isinstance(base, dict) or node[2] not in base: return self.mark(Stop(f"칸 「{node[2]}」 없음", "칸 없음", where)) got = base[node[2]] return Blank(f"칸 「{node[2]}」 비어 있음") if got is None else got if kind == "neg": a = self.ev(node[1], scope, where) return a if _bad(a) else self.arith(lambda: -a, where) if kind == "pct": a = self.ev(node[1], scope, where) return a if _bad(a) else self.arith(lambda: a / 100, where) if kind == "call": return self.call(node, scope, where) op = node[1] a = self.ev(node[2], scope, where) if _bad(a): return a if op == "그리고" and not a or op == "또는" and a: return a b = self.ev(node[3], scope, where) if _bad(b) or op in ("그리고", "또는"): return b if op == "/" and isinstance(b, Decimal) and b == 0: return self.mark(Stop(f"{where} — 0 나눔", "0 나눔", where)) return self.arith(lambda: _BIN[op](a, b), where) def arith(self, fn, where): try: got = fn() except (TypeError, ArithmeticError): return self.mark(Stop(f"{where} — 수 아닌 값을 셈", "셈 틀림", where)) return got def call(self, node, scope, where): name, items = node[1], node[2] if name == "만약": if len(items) != 3: return self.mark(Stop("만약(조건, 참, 거짓)", "식 틀림", where)) test = self.ev(items[0], scope, where) if _bad(test): return test return self.ev(items[1] if test else items[2], scope, where) values = [self.ev(x, scope, where) for x in items] bad = next((v for v in values if _bad(v)), None) if bad is not None: return bad if name in ("최소", "최대"): return self.arith(lambda: (min if name == "최소" else max)(values), where) if len(values) > 2: return self.mark(Stop(f"{name}(값, 자리)", "식 틀림", where)) places = values[1] if len(values) > 1 else 0 return self.arith(lambda: cut(values[0], {"방법": name, "자리": int(places)}, True), where) # 연결 def fill(self, text, where: str): out, bad = str(text), None def one(m): nonlocal bad got = self.get(m.group(1)) if _bad(got): bad = bad or got return "" return plain_text(got) out = md.HOLE.sub(one, out) return bad if bad is not None else out def link(self, var: dict, name: str): link = var.get("연결") or {} kind = link.get("갈래") own = var.get("열쇠") if kind in ("숫자", "입력", "선택"): given = own in self.given if own else False value = number(self.given[own] if given else link.get("값")) if kind == "숫자" and isinstance(value, dict): # 손 묶음(노 · 재 · 경 — 엑셀 값 그대로) got = {k: number(value.get(k)) for k in COSTS} if not all(isinstance(v, Decimal) for v in got.values()): return Stop(f"「{name}」 묶음 값은 노무비 · 재료비 · 경비 수", "연결 꼴", name) return {**got, "계": sum(got.values())} if value is None or value == "": return Blank(f"「{var.get('이름') or name}」 값 비어 있음") if kind == "입력" and isinstance(value, str): try: value = Decimal(value.strip().replace(",", "")) except InvalidOperation: pass if kind == "선택" and not given: options = [number(x) for x in link.get("목록") or []] if value not in options and plain_text(value) not in [ plain_text(x) for x in options ]: return Stop(f"「{name}」 값 「{plain_text(value)}」 목록 밖", "목록 밖", name) value = next((x for x in options if plain_text(x) == plain_text(value)), value) low_high = link.get("범위") if kind == "입력" and low_high and isinstance(value, Decimal): low, high = (number(x) for x in low_high) if (low is not None and value < low) or (high is not None and value > high): return Stop( f"「{name}」 {plain_text(value)} 범위 [{low}, {high}] 밖", "범위 밖", name ) return value payload, bad = {}, None for key in ("키", "표"): if link.get(key): payload[key] = self.fill(link[key], name) if isinstance(link.get("재료"), dict): payload["재료"] = { k: self.fill(v, name) if isinstance(v, str) else v for k, v in link["재료"].items() } for slot in ("지역", "계약종별"): if link.get(slot): payload[slot] = self.get(link[slot]) for slot in ("조건", "입력"): if isinstance(link.get(slot), dict): payload[slot] = {k: self.expr(v, name) for k, v in link[slot].items()} for v in (payload.get("키"), payload.get("표"), *(payload.get("재료") or {}).values()): bad = bad or (v if _bad(v) else None) for v in (payload.get("지역"), payload.get("계약종별")): bad = bad or (v if _bad(v) else None) for v in [*(payload.get("조건") or {}).values(), *(payload.get("입력") or {}).values()]: bad = bad or (v if _bad(v) else None) if bad is not None: return bad for k in ("칸", "단위"): if link.get(k): payload[k] = link[k] try: if kind == "요소": return number(self.links.element(payload)) if kind == "표": return number(self.links.table(payload)) if kind == "단가산출": return self.sub(payload, name) except Gap as e: return Blank(str(e)) except Broken as e: return Stop(f"「{name}」 {e}", "연결 끊김", name) return Stop(f"「{name}」 연결 갈래 「{kind}」", "연결 꼴", name) def sub(self, payload: dict, name: str): key = str(payload.get("키") or "") if not key: # 고르는 중 — 표지(예외 아님) · 짝 TS sub raise Broken("단가산출 고르지 않음") if key in self.stack or len(self.stack) > DEEP: return Stop(f"단가산출 부름 돌고 돎 — {' → '.join([*self.stack, key])}", "순환", name) doc = self.links.doc(key) if doc is None: raise Broken(f"없는 단가산출 「{key}」") given = payload.get("입력") or {} memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items()))) if memo_key not in self.memo: self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo) got = self.memo[memo_key] if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤 why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"]) if got.get("까닭"): return Blank(f"단가산출 「{key}」 못 셈 — {why}") return Stop(f"단가산출 「{key}」 못 셈 — {why}", "부름 멈춤", name) value = got["결과"] if "결과" in got else {k: got[k] for k in BUNDLE} col = payload.get("칸") if col: if not isinstance(value, dict) or col not in value: raise Broken(f"칸 「{col}」 없음") return value[col] return value _BIN = { "+": lambda a, b: a + b, "-": lambda a, b: a - b, "*": lambda a, b: a * b, "/": lambda a, b: a / b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "==": lambda a, b: a == b, "!=": lambda a, b: a != b, } # ── 금액 ────────────────────────────────────────────────────────────── def _zero() -> dict: return dict.fromkeys(COSTS, Decimal(0)) def _add(acc: dict, parts: dict) -> None: for k, v in parts.items(): if k in acc and v is not None: acc[k] += v def _bundle(acc: dict) -> dict: return {**acc, "계": sum(acc[k] for k in COSTS)} def _cover(doc: dict) -> list[tuple[dict, set]]: """단계마다 (단계, 담은 줄 차례) — 단 1 = 제 줄 · 단 k = 제 줄 + 아직 안 담긴 아래 단 단계.""" where = {x.get("이름"): i for i, x in enumerate(doc.get("금액") or []) if x.get("이름")} out, open_ = [], [] for st in doc.get("단계") or []: level, mine = int(st.get("단") or 1), {where[n] for n in st.get("줄") or [] if n in where} keep = [] for low, got in open_: if low < level: mine |= got else: keep.append((low, got)) open_ = keep if st.get("제외") else [*keep, (level, mine)] out.append((st, mine)) return out def _steps(cover, outs: list[dict]) -> list[dict]: rows = [] for st, mine in cover: acc = _zero() for i in sorted(mine): if i < len(outs): _add(acc, outs[i]["비목"]) rows.append( {"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)} | ({"제외": True} if st.get("제외") else {}) ) return rows def _scope(doc: dict, outs: list[dict], running: dict, cover, syms: dict) -> dict: places: dict[str, dict] = {} for line, one in zip(doc.get("금액") or [], outs): if line.get("번지"): _add(places.setdefault(str(line["번지"]), _zero()), one["비목"]) return { **running, "번지": {k: _bundle(v) for k, v in places.items()}, "단계": {x["이름"]: {k: x[k] for k in BUNDLE} for x in _steps(cover, outs)}, **syms, } def _money(run: _Run, doc: dict, rules: dict): """금액 줄 차례대로 — 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계.""" lines = doc.get("금액") or [] cover = _cover(doc) skip = {i for i, x in enumerate(lines) if x.get("합밖")} for st, mine in cover: if st.get("제외"): skip |= mine running, syms, outs, stopped = _zero(), {}, [], False for i, line in enumerate(lines): scope = _scope(doc, outs, dict(running), cover, syms) where = str(line.get("이름") or f"금액 {i + 1}") qty = ( run.expr(line["수량"], where, scope) if line.get("수량") not in (None, "") else Decimal(1) ) price = run.expr(line.get("단가"), where, scope) kind = line.get("비목") parts, why = {}, None if isinstance(qty, Stop) or isinstance(price, Stop): stopped, why = True, (qty if isinstance(qty, Stop) else price).why elif isinstance(qty, Blank): why = qty.why elif not isinstance(qty, Decimal): stopped, why = True, run.mark(Stop(f"{where} 수량이 수 아님", "셈 틀림", where)).why elif qty == 0: parts = {k: Decimal(0) for k in (COSTS if kind == "묶음" else (kind,))} elif isinstance(price, Blank): why = price.why elif kind == "묶음": if isinstance(price, dict) and all(isinstance(price.get(k), Decimal) for k in COSTS): parts = {k: qty * price[k] for k in COSTS} else: stopped, why = ( True, run.mark( Stop(f"{where} 묶음 줄 단가가 단가산출 묶음 아님", "셈 틀림", where) ).why, ) elif kind in COSTS and isinstance(price, Decimal): parts = {kind: qty * price} else: what = ( "묶음 값 — 칸(.계 · .노무비 …) 필요" if isinstance(price, dict) else "단가가 수 아님" ) stopped, why = True, run.mark(Stop(f"{where} {what}", "셈 틀림", where)).why rule = line.get("자리") or rules["줄"] parts = {k: cut(v, rule) for k, v in parts.items()} amount = sum(parts.values(), Decimal(0)) if parts else None if i not in skip: _add(running, parts) if line.get("기호"): syms[str(line["기호"])] = amount if amount is not None else Blank(why or "빈 줄") outs.append( { "이름": line.get("이름") or "", "비목": parts, "수량": qty if isinstance(qty, Decimal) else None, "단가": price if isinstance(price, (Decimal, dict)) else None, "금액": amount, **({"까닭": why} if why else {}), **({"합밖": True} if i in skip else {}), **({"묶음": line["묶음"]} if line.get("묶음") else {}), } ) return outs, running, stopped, cover def _settle(doc: dict, rules: dict, total: dict) -> dict: """비목 합 끝수 → 끝전(큰나머지) → 계 끝수 → 조정 → 비목 끝수.""" costs = {k: cut(v, rules["비목"]) for k, v in total.items()} if rules.get("끝전") == "큰나머지": rest = int(cut(sum(total.values()), rules["비목"]) - sum(costs.values())) order = sorted(COSTS, key=lambda k: (costs[k] - total[k], COSTS[::-1].index(k))) for k in order[:rest]: costs[k] += 1 if rules.get("계"): costs = {k: cut(v, rules["계"], True) for k, v in costs.items()} adjust = doc.get("조정") or {} for k, rate in (adjust.get("배율") or {}).items(): costs[k] = costs[k] * number(rate) if adjust.get("옮김"): whole = sum(costs.values()) costs = {k: whole if k == adjust["옮김"] else Decimal(0) for k in COSTS} return {k: cut(v, rules["비목"]) for k, v in costs.items()} def _groups(outs: list[dict]) -> list[dict]: order: list[str] = [] acc: dict[str, dict] = {} for one in outs: name = str(one.get("묶음") or "") if name not in acc: order.append(name) acc[name] = _zero() if not one.get("합밖"): _add(acc[name], one["비목"]) return [{"이름": n, **_bundle(acc[n])} for n in order] def rules_of(doc: dict) -> dict: """끝수 — 문서 칸 › 기본(`md.CUT`) · 셈 답에 그대로 실음(숨은 규칙 없음).""" got = dict(doc.get("끝수") or {}) return { k: got.get(k) or md.CUT.get(k) for k in ("줄", "비목", "끝전", "계") if got.get(k) or md.CUT.get(k) } def calc( doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None ) -> dict: """수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과, 묶음, 머리, 단계?, 표지, 셈됨, 끝수}. given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음.""" memo = {} if memo is None else memo stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환 run = _Run(doc, links, given, stack, memo) for one in md.check(doc): if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"): run.marks.append(one) rules = rules_of(doc) for name in [*run.lines, *run.vars]: if name: run.get(name) out: dict = {"끝수": rules} has_money = bool(doc.get("금액")) if doc.get("결과"): value = run.get(str(doc["결과"])) ok = isinstance(value, Decimal) out |= {"결과": value if ok else None} if isinstance(value, Blank): out["까닭"] = value.why out["셈됨"] = ok elif has_money: outs, running, stopped, cover = _money(run, doc, rules) stopped = stopped or any(m["갈래"] in ("겹침", "금액 줄", "끝수") for m in run.marks) costs = None if stopped else _settle(doc, rules, running) out |= { "줄": outs, **{k: (costs or {}).get(k) for k in COSTS}, "계": None if costs is None else sum(costs.values()), "묶음": _groups(outs), } out["머리"] = "합계" if len(out["묶음"]) > 1 else "계" if doc.get("단계"): out["단계"] = _steps(cover, outs) out["셈됨"] = costs is not None else: run.marks.append({"갈래": "빈 문서", "글": "금액 줄 · 결과 없음"}) out |= {"줄": [], **dict.fromkeys(BUNDLE), "묶음": [], "머리": "계", "셈됨": False} out["값"] = {k: (None if _bad(v) else v) for k, v in run.values.items()} out["빈값"] = {k: v.why for k, v in run.values.items() if isinstance(v, Blank)} out["표지"] = run.marks return out