"""B09 대가 블록 셈법(PLAN 792-4) — 791-1 엔진 새 의미(줄 자리 · 합밖 · 번지 · 단계 · 블록 끝전 · 결과 조정)를 쓰는 로직만 블록 줄 · 블록에 표지를 붙임(`B09_Estimation_Labor_Source._poomsem`): 줄 `place` {방법, 자리} 줄 금액 끝수(없으면 ROUNDDOWN 1) 줄 `out` true 비목 합에서 뺌(합밖 줄 · 제외 단계가 담은 줄 · 번지 · 단계엔 쌓임) 덧줄 = 수 하나 × 밑 합 꼴이면 % 줄 밑 `rows:`(그 줄 차례 · `@비목` · % = 그 수 × 100) — 밑 = `줄.'이름'` · 앞 덧줄 이름 · `번지.X.비목` · `단계.'이름'.비목` · `노무비` 등(앞 줄 합) 덧줄 = 이름 없는 수 식 = 고정 줄(수량 1 · 엔진 값) 블록 `rule` {끝전?, 조정?, 끝수?} 블록 비목 합 = `B09_Estimation_Calc_Labor.settle` 그 밖 꼴(밑마다 다른 수 · 곱 · 중간 이름) = 빈 줄 그대로(대가 단가로 못 셈 · 틀린 값 대신 빈칸). 셈 = 셈 짝(`B09_Estimation_Calc_Labor` .py · .ts)이 B09 단가로 · 옛 로직(새 칸 없음) = 표지 없음 · 지킴 시험 = `test_792_3_engine_paths.py`(엔진 · SK · SC · B09 산근 · B09 대가 다섯 길 같은 금액). """ from __future__ import annotations import importlib from decimal import Decimal from typing import Any from M01_MasterData import M01_MasterData_Store as _m01 # noqa: F401 — scripts 길을 둠 mf = importlib.import_module("master_formula") ms = importlib.import_module("master_formula_sum") PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} FLAGS = ("place", "out") # 블록 줄 덧칸 — 단가산출 블록이 줄을 바꿔도 이어 받음 Refs = list[tuple[int, str | None]] # (줄 차례, 비목 | None = 줄 전체) def _f(value: Any) -> float | None: try: return None if value is None else float(value) except (TypeError, ValueError): return None def _cost(rows: list[int], cost: str) -> Refs | None: if cost == "계": return [(i, None) for i in rows] return [(i, PART[cost]) for i in rows] if cost in PART else None def _ref(row: dict, node: tuple, n: int) -> Refs | None: """밑 이름 하나 → 줄 차례(n 앞만) · 모름 = None.""" lines = ms.items(row) names = {x.get("이름"): i for i, x in reversed(list(enumerate(lines[:n]))) if x.get("이름")} if node[0] == "name": if node[1] in PART: # 비목 합 = 앞 줄 중 합에 드는 줄 skip = ms.skipped(row) return [(i, PART[node[1]]) for i in range(n) if i not in skip] return [(names[node[1]], None)] if node[1] in names else None if node[0] != "field": return None head, name = node[1], node[2] if head == ("name", "줄"): return [(names[name], None)] if name in names else None if head[0] != "field" or head[1][0] != "name": return None group, key = head[1][1], head[2] if group == ms.ADDR: return _cost([i for i, x in enumerate(lines[:n]) if x.get(ms.ADDR) == key], name) if group == ms.STEP: got = next((mine for st, mine in ms._cover(row) if st.get("이름") == key), None) return None if got is None else _cost(sorted(i for i in got if i < n), name) return None def _linear(row: dict, node: tuple, n: int) -> tuple[Refs, Decimal] | None: """식 = 수 하나 × 밑 합 → (밑, 수) · 다른 꼴 = None.""" if node[0] in ("name", "field"): got = _ref(row, node, n) return None if got is None else (got, Decimal(1)) if node[0] != "bin": return None op, a, b = node[1], node[2], node[3] if op in ("*", "/") and b[0] == "num" and (left := _linear(row, a, n)): return left[0], left[1] * b[1] if op == "*" else left[1] / b[1] if op == "*" and a[0] == "num" and (right := _linear(row, b, n)): return right[0], a[1] * right[1] if op == "+" and (left := _linear(row, a, n)) and (right := _linear(row, b, n)): return (left[0] + right[0], left[1]) if left[1] == right[1] else None return None def _pct(row: dict, formula: str, n: int) -> tuple[str, float] | None: """덧줄 식 → (`rows:` 밑, %) · 못 바꿈 = None(밑 없음 · 겹친 밑 포함).""" try: got = _linear(row, mf.parse(formula), n) except mf.FormulaError: return None if not got or not got[0] or len(set(got[0])) != len(got[0]): return None spec = ",".join(str(i) if part is None else f"{i}@{part}" for i, part in got[0]) return f"rows:{spec}", float(got[1] * 100) def mark(row: dict, lines: list[dict], engine: list[dict]) -> dict | None: """새 의미 로직 = 블록 줄에 `place` · `out` · 덧줄 % 밑 · 상수 덧줄(제자리) → 블록 `rule` · 옛 로직 = None.""" if not ms.uses(row): return None items, skip, heads = ms.items(row), ms.skipped(row), len(row.get("호표") or []) for n, (item, line) in enumerate(zip(items, lines)): if item.get("자리"): line["place"] = dict(item["자리"]) if n in skip: line["out"] = True if n < heads: continue formula = str(item.get("식") or "") part = PART.get(str(item.get("비목")), "expense") try: plain = _plain(mf.parse(formula)) except mf.FormulaError: continue value = _f(engine[n].get("금액")) if n < len(engine) else None if plain and value is not None: # 이름 없는 수 식 = 고정 줄(수량 1) line.update(kind="fixed", qty=1.0, own={part: value}, link={}, unit="식") elif got := _pct(row, formula, n): line.update(kind="pct", part=part, base=got[0], spec=formula, unit="%", qty=got[1]) else: continue line.pop("why", None) return {k: row[k] for k in ("끝전", "조정", "끝수") if k in row} def _plain(node: tuple) -> bool: """수 · 사칙 · 부호만 있는 식(이름 없음).""" if node[0] == "num": return True if node[0] == "bin": return _plain(node[2]) and _plain(node[3]) return node[0] == "neg" and _plain(node[1])