"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장). ⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값. ⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」. ⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 성분 소계 1원 미만 버림(명세 7장). 하위 일위대가를 윗 표에 넣을 때도 **자른 성분 소계**를 씀 — B09 `resolve` 의 B 호표와 같음. ⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어 일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장). 깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함. ⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ). ⚠ **저장 자리는 둘**(브레인 판정 2026-09-13) — 줄 더하기·빼기·고르개로 고친 **줄 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [내 라이브러리에 저장] 때 양식에 실림) · **수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 내 단가가 남의 프로젝트로 감). 수동 단가 줄은 「미확정」으로 셈. """ from __future__ import annotations from collections.abc import Callable, Iterable from dataclasses import dataclass from decimal import Decimal from typing import Any #: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`. SUB_STRUCTURE_PREFIX = "B-AX-ST-" #: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」). MAX_DEPTH = 5 #: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`. ROWS_KEY = "structure_unit_price_rows" MANUAL_KEY = "structure_manual_prices" #: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기). SEARCH_KINDS = { "work": ("unit_price", "price_basis"), "resource": ("material", "labor", "machine_hourly"), } _MONEY_KEYS = ("material", "labor", "expense") _UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"} def _unit(text: Any) -> str: value = str(text or "").strip() return _UNIT_ALIASES.get(value, value) @dataclass class _Context: book: Any find_variant: Callable[[str, str], str | None] library: dict[str, dict[str, Any]] #: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님). manual: dict[str, dict[str, Any]] @dataclass class _Priced: """줄 단가 한 벌 — 안 자른 3분할과 이름·단위.""" money: Any name: str spec: str unit: str def _ref_of( row: dict[str, Any], values: dict[str, Any], find_variant: Callable[[str, str], str | None] ) -> tuple[str | None, str]: """줄이 가리키는 단가표 코드 — 못 정하면 `None` 과 까닭.""" if row.get("ref_code"): return str(row["ref_code"]), "" code = row.get("work_item_code") if not code: return None, "공종 코드 미정" var = row.get("variant_from") if not var: return f"B-{code}", "" value = values.get(var) found = find_variant(str(code), str(value if value is not None else "")) if found: return found, "" return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음" def _sub_sheet(sub: dict[str, Any], sub_vars: dict[str, Any]) -> dict[str, Any]: """하위 양식을 **줄이 준 제원**으로 단위당(L=1) 풂 — 윗 장과 같은 모양의 장 한 벌.""" from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_sheet from B09_Estimation.B09_Estimation_PriceBook import PriceBookError values: dict[str, Any] = {} missing = [] for name, spec in (sub.get("vars") or {}).items(): if name in sub_vars: values[name] = sub_vars[name] elif "default" in spec: values[name] = spec["default"] elif spec.get("source") == "length_m": values[name] = 1.0 else: missing.append(name) if missing: raise PriceBookError( f"하위 양식 {sub.get('code')} 의 제원이 비어 있음: {', '.join(missing)}" ) body = template_sheet(sub, values) solved = evaluate_sheets([body]) if solved is None: raise PriceBookError("식 풀이기를 못 돌려 하위 양식을 못 풂") units = {row["seq"]: row.get("unit") for row in body["rows"]} rows = [ { "no": result["seq"], "unit": units.get(result["seq"]), "unit_amount": None if result.get("amount") is None else float(result["amount"]), "skipped": bool(result.get("skipped")), "reason": result.get("reason") or "", "error": result.get("error") or "", } for result in solved[0] ] unit = (sub.get("unit_price") or {}).get("unit") or "m" return {"rows": rows, "formula_sheet": {"vars": values}, "billing_unit": unit} def _price( ref: str, row: dict[str, Any], ctx: _Context, depth: int, seen: tuple[str, ...] ) -> _Priced: """줄 하나의 단가 — B-AX-ST 는 하위 양식을 재귀로, 나머지는 B09 단가표.""" from B09_Estimation.B09_Estimation_PriceBook import PriceBookError if not ref.startswith(SUB_STRUCTURE_PREFIX): title = ctx.book.title(ref) return _Priced(ctx.book.resolve(ref), title.name, title.spec, _unit(title.unit)) code = ref[2:] if code in seen: raise PriceBookError(f"하위 일위대가가 돌고 있음: {' → '.join((*seen, code))}") if depth + 1 > MAX_DEPTH: raise PriceBookError(f"하위 일위대가가 {MAX_DEPTH}단을 넘음: {' → '.join((*seen, code))}") sub = ctx.library.get(code) if sub is None: raise PriceBookError(f"하위 양식 {code} 을 프로젝트·프로그램 기본에서 못 찾음") sheet = _sub_sheet(sub, row.get("sub_vars") or {}) assembled = _assemble(sub, sheet, ctx, depth + 1, (*seen, code)) if assembled is None: raise PriceBookError(f"하위 양식 {code} 에 일위대가 줄이 없음") table, money = assembled if table["blocked"]: raise PriceBookError(f"하위 일위대가 {code} 미완 — 막힌 줄 {table['blocked']}") return _Priced(money, str(sub.get("name") or code), "", _unit(sheet["billing_unit"])) def _assemble( template: dict[str, Any], sheet: dict[str, Any], ctx: _Context, depth: int, seen: tuple[str, ...], ) -> tuple[dict[str, Any], Any] | None: """표 한 벌과 **안 자른** 단위당 3분할 합(윗 표가 쓸 값).""" from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at spec = template.get("unit_price") if not spec: return None sheet_rows = {row.get("no"): row for row in sheet.get("rows") or []} values = (sheet.get("formula_sheet") or {}).get("vars") or {} rows: list[dict[str, Any]] = [] sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)} blocked = 0 unconfirmed = 0 for row in spec.get("rows") or []: out: dict[str, Any] = { "seq": row.get("seq"), "name": row.get("name") or "", "spec": row.get("spec") or "", "ref_code": "", "unit": _unit(row.get("unit")), "quantity": None, "skipped": False, "reason": "", } source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None # 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음. from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or "")) if owner: out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']} 은 {owner}로 가는 줄" blocked += 1 rows.append(out) continue if source is not None and (source.get("skipped") or source.get("error")): # 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등). out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "") if source.get("error"): out.update(skipped=False, reason=f"원단위 줄이 안 풀림 — {source['error']}") blocked += 1 rows.append(out) continue if "from_row" in row: if source is None or source.get("unit_amount") is None: out["reason"] = f"원단위 줄 {row['from_row']} 이 없음" blocked += 1 rows.append(out) continue quantity = Decimal(str(source["unit_amount"])) out["unit"] = out["unit"] or _unit(source.get("unit")) elif row.get("quantity") is not None: quantity = Decimal(str(row["quantity"])) else: out["reason"] = "수량 없음" blocked += 1 rows.append(out) continue out["quantity"] = float(quantity) ref, why = _ref_of(row, values, ctx.find_variant) out["ref_code"] = ref or "" priced = None manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None if manual: # 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈. money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS)) priced = _Priced(money, out["name"], out["spec"], out["unit"]) out.update( manual=True, manual_source=manual.get("source") or "", manual_entered_at=manual.get("entered_at") or "", ) unconfirmed += 1 elif ref: try: priced = _price(ref, row, ctx, depth, seen) if out["unit"] and priced.unit and priced.unit != out["unit"]: why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {priced.unit}" priced = None except PriceBookError as exc: why = str(exc) if priced is None: out["reason"] = why blocked += 1 rows.append(out) continue money = priced.money cells = { "material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW), "labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW), "expense": round_at(money.expense * quantity, OutputPlace.UNIT_PRICE_ROW), } for key, value in cells.items(): sums[key] += value out.update( name=priced.name or out["name"], spec=priced.spec or out["spec"], depth=depth, unit_material=float(money.material), unit_labor=float(money.labor), unit_expense=float(money.expense), **{key: float(value) for key, value in cells.items()}, total=float(sum(cells.values())), ) rows.append(out) table = { "code": f"B-{template.get('code')}" if template.get("code") else "", "name": template.get("name") or "", "unit": sheet.get("billing_unit") or "", "rows": rows, # 성분 소계 — 성분마다 1원 미만 버림(명세 7장 · B09 일위대가 호표와 같은 규칙). **{ key: float(round_at(value, OutputPlace.UNIT_PRICE_TOTAL)) for key, value in sums.items() }, # 계금 = 자른 성분 소계의 합. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음. "total": float( sum(round_at(value, OutputPlace.UNIT_PRICE_TOTAL) for value in sums.values()) ), "blocked": blocked, "complete": blocked == 0, #: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지. "unconfirmed": unconfirmed, } # 윗 표·내역이 쓸 단위당 3분할 = **자른 성분 소계**(하위 호표 합계를 그대로 부름 — 명세 7장). return table, Money3(*(Decimal(str(table[key])) for key in _MONEY_KEYS)) def unit_price_table( template: dict[str, Any], sheet: dict[str, Any], book: Any, find_variant: Callable[[str, str], str | None], library: Iterable[dict[str, Any]] = (), manual: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any] | None: """장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`. 수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`. 코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래). `library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본). `manual` — 이 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`. """ assembled = unit_price_money(template, sheet, book, find_variant, library, manual) return assembled[0] if assembled else None def unit_price_money( template: dict[str, Any], sheet: dict[str, Any], book: Any, find_variant: Callable[[str, str], str | None], library: Iterable[dict[str, Any]] = (), manual: dict[str, dict[str, Any]] | None = None, ) -> tuple[dict[str, Any], Any] | None: """`unit_price_table` 과 같되 **단위당 3분할**(자른 성분 소계)도 — B09 내역 줄이 이 값에 수량을 곱함.""" ctx = _Context( book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {} ) code = str(template.get("code") or "") return _assemble(template, sheet, ctx, 1, (code,) if code else ()) def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]: """고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움.""" spec = template.get("unit_price") or {} picked = (spec.get("rows") or []) if rows is None else rows return {**template, "unit_price": {**spec, "rows": picked}} def save_rows( current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]] ) -> tuple[dict[str, Any], bool]: """줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜).""" merged = {key: value for key, value in current.items() if key != type_id} if rows != ((template.get("unit_price") or {}).get("rows") or []): merged[type_id] = rows return merged, merged != current def save_manual( current: dict[str, Any], type_id: str, rows: list[dict[str, Any]], prices: dict[str, dict[str, Any]], today: str, ) -> dict[str, Any]: """수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게). 넣은 날짜는 값·출처가 그대로면 옛 날짜를 둠 — 다시 저장했다고 날짜가 새로 서면 안 됨. """ seqs = {str(row.get("seq")) for row in rows} before = current.get(type_id) or {} kept = {} for seq, price in prices.items(): if str(seq) not in seqs: continue old = before.get(str(seq)) or {} same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source")) kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today} merged = {key: value for key, value in current.items() if key != type_id} if kept: merged[type_id] = kept return merged def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]: """고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께.""" from B09_Estimation.B09_Estimation_PriceBook import PriceBookError kinds = SEARCH_KINDS[kind] words = query.lower().split() found: list[dict[str, Any]] = [] for title in book.titles.values() if words else (): if len(found) >= limit: break if title.kind.value not in kinds: continue text = f"{title.code} {title.name} {title.spec}".lower() if all(word in text for word in words): try: money = book.resolve(title.code) total: float | None = float(money.material + money.labor + money.expense) except PriceBookError: total = None found.append( { "code": title.code, "name": title.name, "spec": title.spec, "unit": _unit(title.unit), "price": total, } ) return found