"""M02 기준 목록 · 계산서 끝수(PLAN 704-2 딸림) — B09 산근 끝수 규칙으로 셈(③ 서버 단독 · `B09_Estimation_Sangeun` 과 규칙 짝 · 거울 시험 `test_704_settle`). 줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 비목 총계 = ROUNDDOWN(Σ 줄, 0) · 계 = 셋 합 · 작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · % 덧줄 밑수 = 그때까지 줄 합(줄 끝수 뒤). 근거 = 지식DB `technical_info/01_임도/05_원가정보/단수처리_규칙.md`(행 금액 절사 · 관측 후보 규칙). M01 엔진(`master_formula.run`)의 구조 그대로 — 끝수 자리만 다름(엔진은 줄 끝수 없이 계에서 한 번). 하위 로직(기계경비 포함)은 같은 규칙으로 다시 셈 · 흐름 · 결과 로직은 엔진 값 그대로. """ from __future__ import annotations import re from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal from typing import Any from M01_MasterData import M01_MasterData_Store as m01 mf = m01.cm.mf _Q = re.compile(r"Q\d*") def _down(value: Any, places: int) -> Any: return None if value is None else mf._round(Decimal(value), places, ROUND_DOWN) def _bundle(whole: Any, item: dict, env: dict, depth: int) -> dict: """로직 호표 줄이 부르는 하위 로직 — 같은 규칙으로 다시 셈(엔진 `logic` 마디 그대로).""" node = mf.parse(item["요소"]) if node[0] != "logic": return mf.evaluate(node, env, whole, depth) given = {k: mf.evaluate(v, env, whole, depth) for k, v in node[2]} got = settle(whole, mf.fill(node[1], env), given, depth + 1) return got["결과"] if "결과" in got else {k: got[k] for k in mf.BUNDLE} def settle(whole: Any, ref: str, given: dict, depth: int = 0) -> dict: """`master_formula.run` 과 같은 꼴 `{줄, 노무비, 재료비, 경비, 계, 중간}` — 끝수만 B09 규칙.""" row = whole.logic(ref) if isinstance(row.get("흐름"), dict) or "결과" in row: return mf.run(whole, ref, given, depth) env = mf._inputs(row, given) for step in row.get("중간", []): try: value = mf.evaluate(mf.parse(step["식"]), env, whole, depth) except mf.EmptyInput as e: env[step["이름"]] = mf.Blank(str(e)) continue if _Q.fullmatch(step["이름"]) and isinstance(value, Decimal): value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) env[step["이름"]] = value lines: list[dict] = [] for n, item in enumerate(row.get("호표", [])): try: qty = mf.evaluate(mf.parse(item["수량"]), env, whole, depth) if item["종류"] == "로직": bundle = _bundle(whole, item, env, depth) price = bundle["계"] split = ( {k: qty * bundle[k] for k in mf.COST_ITEMS} if "비목" not in item else {item["비목"]: qty * price} ) else: ref_ = ( { k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items() } if isinstance(item["요소"], dict) else mf.fill(item["요소"], env) ) if isinstance(ref_, str) and qty != 0: whole.get(ref_) price = mf.mp.priced(whole, item, ref_, env, n if depth == 0 else None)[0] if price is None and qty == 0: price = Decimal(0) split = {item["비목"]: None if price is None else qty * price} except mf.EmptyInput as e: lines.append(mf._blank_line(item, str(e))) continue lines.append(_line(item["이름"], split)) sums = {k: sum((x["비목"].get(k) or 0 for x in lines), Decimal(0)) for k in mf.COST_ITEMS} env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} for extra in row.get("덧줄", []): env.update(sums) try: value = mf.evaluate(mf.parse(extra["식"]), env, whole, depth) env[extra["이름"]] = value except mf.FormulaError: value = None sums[extra["비목"]] += _down(value, 1) or 0 lines.append(_line(extra["이름"], {extra["비목"]: value})) if value is not None: env["줄"][extra["이름"]] = lines[-1]["금액"] sums = {k: mf.cut(row, _down(v, 0), "계") for k, v in sums.items()} return {"줄": lines, **sums, "계": sum(sums.values()), "중간": mf._shown(env)} def _line(name: str, split: dict) -> dict: cut = {k: _down(v, 1) for k, v in split.items()} whole_sum = None if all(v is None for v in cut.values()) else sum(v or 0 for v in cut.values()) return {"이름": name, "비목": cut, "금액": whole_sum} def apply(groups: list[dict], engine: dict, done: dict) -> None: """계산서 묶음의 줄 금액 · 소계를 `settle` 값으로 바꿈 — 줄 차례는 엔진과 같음.""" seq: dict[str, list] = {k: [] for k in mf.COST_ITEMS} for eng, mine in zip(engine["줄"], done["줄"]): for part, _ in m01.mtx._shares(eng): seq[part].append(mine["비목"].get(part)) for group in groups: for line, amount in zip(group["줄"], seq.get(group["비목"], [])): line["금액"] = amount group["소계"] = done[group["비목"]]