"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3 · 6장 층 차례). **무엇인가** — 시공능력 **Q** 로 선 기계 몫의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는 줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다(8-13 관측). X 시간당 중기사용료 → D 단가산출(Q 식: 시간당 성분 ÷ Q) → B 일위대가 → 내역 ⚠⚠ **층 차례 정정(2026-09-13 브레인 판정 · 명세 3장)** — 종전엔 D 가 **B 를 그대로 부르는 껍데기** (D → B 수량 1)였고 B 가 X 를 바로 불렀음(뒤집힘). 이제 D 는 **Q 쓰는 자리에만** 서고 (`PriceBook.add_output_detail`), B 가 D 를 수량 1 로 부름. 내역 코드 축은 B 하나(금액 같음). ⚠ 그래서 「단산 N」은 **D 를 품은 내역 줄에만** 붙음 — 사람 품만 있는 일위대가엔 없는 근거라 안 붙음 (STmate 도 D 가 있는 줄에만). ⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다. ⚠ **번호는 코드에 박지 않는다.** 실무 참조번호(「단산 46」)는 **그 내역서 안에서의 차례**라 프로젝트마다 다르다. 코드(`D-FP-…`)는 공종을 가리키고, 번호는 조판할 때 매긴다. """ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build @dataclass class PriceBasisEntry: """단가산출서 한 장 — 참조번호 + 그 산출을 부르는 일위대가.""" number: int code: str name: str spec: str unit: str unit_price_krw: Decimal #: 이 산출서를 **처음 부른** 일위대가 코드 — 화면에서 눌러 내려가는 자리. ref_code: str #: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함). unit_price_codes: list[str] = field(default_factory=list) #: 성분 금액 — 목록표 「노무비·재료비·경비」 칸(머리 성분 원 미만 절사 값). money: Money3 = field(default_factory=Money3) @property def label(self) -> str: """내역서 비고에 적는 문구 — 실무 서식 그대로 「단산 46 참조」.""" return f"단산 {self.number} 참조" def as_dict(self) -> dict[str, Any]: return { "number": self.number, "label": self.label, "code": self.code, "name": self.name, "spec": self.spec, "unit": self.unit, "unit_price_krw": str(self.unit_price_krw), "ref_code": self.ref_code, "unit_price_codes": list(self.unit_price_codes), "material_krw": str(self.money.material), "labor_krw": str(self.money.labor), "expense_krw": str(self.money.expense), } @dataclass class PriceBasisSheet: """그 내역서에 딸린 단가산출서 한 벌.""" entries: list[PriceBasisEntry] = field(default_factory=list) def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None: return next((e for e in self.entries if ref_code in e.unit_price_codes), None) def label_for(self, ref_code: str) -> str: """그 일위대가가 품은 산출서 번호 전부 — 단계 합산 부모는 둘 이상일 수 있음(「단산 3·4」).""" numbers = [str(e.number) for e in self.entries if ref_code in e.unit_price_codes] return f"단산 {'·'.join(numbers)} 참조" if numbers else "" def as_dict(self) -> dict[str, Any]: return {"entries": [entry.as_dict() for entry in self.entries]} def _bases_of(book: PriceBook, code: str, seen: tuple[str, ...] = ()) -> list[str]: """일위대가가 품은 D — 단계 합산 부모(B → 잎 B)는 잎까지 내려가 찾음. 줄 차례 그대로.""" found: list[str] = [] for detail in book.details.get(code, []): ref = detail.ref_code title = book.titles.get(ref) if title is None or ref == code or ref in seen: continue if title.kind is PriceKind.PRICE_BASIS: found.append(ref) elif title.kind is PriceKind.UNIT_PRICE: found.extend(b for b in _bases_of(book, ref, (*seen, code)) if b not in found) return found def build_price_basis( unit_price_codes: list[str], build: UnitPriceBuild | None = None, ) -> PriceBasisSheet: """내역에 쓰인 일위대가가 품은 **단가산출(D)** 마다 한 장. 번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다. 같은 D 를 두 줄이 부르면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다. ⚠ D 가 없는 일위대가(사람 품만)는 산출서가 없음 — 없는 근거를 가리키지 않음. """ prices = build or cached_build() book = prices.book sheet = PriceBasisSheet() numbered: dict[str, PriceBasisEntry] = {} for code in unit_price_codes: if not code or code not in book.titles: continue for basis in _bases_of(book, code): entry = numbered.get(basis) if entry is None: title = book.title(basis) money = book.resolve(basis) entry = PriceBasisEntry( number=len(sheet.entries) + 1, code=basis, name=title.name, spec=title.spec, unit=title.unit, unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), ref_code=code, money=money, ) sheet.entries.append(entry) numbered[basis] = entry if code not in entry.unit_price_codes: entry.unit_price_codes.append(code) return sheet def price_basis_detail( code: str, build: UnitPriceBuild | None = None, ) -> dict[str, Any]: """산출서 한 장의 본표 — Q 식 줄(시간당 중기사용료 × 1/Q)과 성분 합. 일위대가 본표와 **같은 모양**(`detail_of`)이라 화면이 같은 표를 쓴다. """ from B09_Estimation.B09_Estimation_UnitPrice import detail_of prices = build or cached_build() if prices.book.title(code).kind is not PriceKind.PRICE_BASIS: raise LookupError(f"단가산출서 코드가 아닙니다: {code}") return detail_of(prices, code)