# -*- coding: utf-8 -*- """계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8). `master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet` 두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌. - `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). - `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). - `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). - `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 (712-2 `master_unit.note`) · 없으면 「」. 줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). """ from __future__ import annotations import re from decimal import Decimal import master_basis as mbs import master_bundle as mb import master_formula as mf import master_text as mtx import master_unit as mu KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") _HOLE = re.compile(r"\{([^{}]+)\}") _ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) def _names(expr) -> tuple[set[str], set[str]]: """식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈.""" if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만 return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set() if expr in (None, ""): return set(), set() try: tree = mf.parse(expr) except mf.FormulaError: return set(_HOLE.findall(str(expr))), set() names, tables = set(), set() for node in mf.walk(tree): if node[0] == "name": names.add(node[1]) elif node[0] in ("find", "logic", "ref"): names |= set(_HOLE.findall(node[1])) if node[0] == "find": tables.add(node[1]) return names, tables def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]: """식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로.""" steps = {s["이름"]: s["식"] for s in row.get("중간", [])} inputs = [s["이름"] for s in row.get("입력", [])] seen, tables, todo = set(), [], list(exprs) while todo: names, found = _names(todo.pop()) tables += [t for t in sorted(found) if t not in tables] for name in names - seen: seen.add(name) if name in steps: todo.append(steps[name]) return [n for n in inputs if n in seen], tables def _times(x: Decimal) -> str: return format(x.normalize(), "f") def _per_text(per: list[tuple[Decimal, str]]) -> str: return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per)) def _basis(master, tables: list[str], env: dict) -> dict | None: for ref in tables: try: got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref)) except (*_ERRORS, mf.EmptyInput): continue if got["갈래"] == "단위": return got return None def _note(basis: dict | None, per: list, line: dict) -> str: notes = [] if basis and len(per) == 1: times, one = per[0] if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times: ratio = basis["기준배"] / times how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}" notes.append( f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}" ) goods = line.get("품목") or {} if goods.get("단위") and line.get("단위"): notes.append(mu.note(goods["단위"], line["단위"])) return " · ".join(n for n in notes if n) def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict: uses, tables = _closure(row, exprs) basis = _basis(master, tables, env) out = { "쓰는입력": uses, "기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}" if basis else None, "환산근거": _note(basis, per, line), } if bundle: out["묶음"] = bundle[0] if bundle[1]: out["장비"] = True return out def annotate(master, key: str, given: dict, answer: dict) -> dict: """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" if not answer.get("ok") or "groups" not in answer: return answer row = master.logic(key) per = mbs.result_per(row.get("결과단위")) answer["기준단위"] = _per_text(per) or None try: env = mtx._env_of(master, row, given) except _ERRORS: env = {} if "결과" in row: for line in answer["groups"][0]["줄"]: line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None)) return answer try: result = mf.run(master, key, given) except _ERRORS: result = None slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]} items = row.get("호표") or [] for n, item in enumerate(items): line = result["줄"][n] if result else {} if result: parts = [p for p, _ in mtx._shares(line)] elif item.get("종류") == "로직" and "비목" not in item: parts = list(mf.COST_ITEMS) else: parts = [item.get("비목")] exprs = [item.get("수량"), item.get("요소")] got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) for part in parts: target = next(slots.get(part, iter(())), None) if target is not None: target.update(got) for extra in row.get("덧줄") or []: target = next(slots.get(extra["비목"], iter(())), None) if target is not None: target.update( _fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra)) ) return answer