실무 내역서는 줄마다 비고에 「단산 46 참조」를 적고 그 산출서를 펴서 검산함(8-13).
STC 실측도 `D` 가 `B`(일위대가)를 참조하는 한 층 위였음(9-3)
- `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind=PRICE_BASIS` 만 얹음 —
표를 세 벌 만들지 않음. 화면도 일위대가 탭과 **같은 2단 모양**
- 번호는 **코드에 안 박음** — 실무 참조번호는 그 내역서 안의 차례라
프로젝트마다 다름. 코드는 공종을 가리키고 번호는 조판할 때 매김
- 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**, 두 줄이 같은 번호를 가리킴
- 내역 줄 비고에 「단산 N 참조」를 달음
- 지금은 일위대가를 그대로 한 줄로 참조 — 할증·기타 비용이 붙을 자리를 미리 열어 둠
- `GET …/estimation/price-basis/{code}` 로 본표 조회
화면 실측: 내역 줄 「2-2-1 측구터파기 488,926 / 단산 1 참조」,
산출서 탭에 1장(토사 FP-09-12-01 ㎥ 5,288.6), 눌러 본표·참조 코드 확인
검증: pytest 195 통과, tsc 0건
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
6.1 KiB
Python
166 lines
6.1 KiB
Python
"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3).
|
|
|
|
**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는
|
|
줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다
|
|
(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B`
|
|
(일위대가)를 참조하는 한 층 위**였다.
|
|
|
|
D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그
|
|
|
|
⚠ **표를 세 벌 만들지 않는다** (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 PriceDetail, PriceKind, PriceTitle
|
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
|
|
|
_ONE = Decimal(1)
|
|
|
|
|
|
@dataclass
|
|
class PriceBasisEntry:
|
|
"""단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지."""
|
|
|
|
number: int
|
|
code: str
|
|
name: str
|
|
spec: str
|
|
unit: str
|
|
unit_price_krw: Decimal
|
|
#: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리.
|
|
ref_code: str
|
|
|
|
@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,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class PriceBasisSheet:
|
|
"""그 내역서에 딸린 단가산출서 한 벌."""
|
|
|
|
entries: list[PriceBasisEntry] = field(default_factory=list)
|
|
|
|
def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None:
|
|
return next((entry for entry in self.entries if entry.ref_code == ref_code), None)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {"entries": [entry.as_dict() for entry in self.entries]}
|
|
|
|
|
|
def build_price_basis(
|
|
unit_price_codes: list[str],
|
|
build: UnitPriceBuild | None = None,
|
|
) -> PriceBasisSheet:
|
|
"""내역서에 쓰인 일위대가마다 산출서 한 장을 세운다.
|
|
|
|
번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다.
|
|
같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다.
|
|
"""
|
|
prices = build or cached_build()
|
|
sheet = PriceBasisSheet()
|
|
seen: set[str] = set()
|
|
|
|
for code in unit_price_codes:
|
|
if not code or code in seen or code not in prices.book.titles:
|
|
continue
|
|
seen.add(code)
|
|
title = prices.book.title(code)
|
|
money = prices.book.resolve(code)
|
|
basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}"
|
|
|
|
if basis_code not in prices.book.titles:
|
|
prices.book.add_title(
|
|
PriceTitle(
|
|
code=basis_code,
|
|
kind=PriceKind.PRICE_BASIS,
|
|
name=title.name,
|
|
spec=title.spec,
|
|
unit=title.unit,
|
|
)
|
|
)
|
|
# ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는
|
|
# 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다.
|
|
prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로"))
|
|
|
|
sheet.entries.append(
|
|
PriceBasisEntry(
|
|
number=len(sheet.entries) + 1,
|
|
code=basis_code,
|
|
name=title.name,
|
|
spec=title.spec,
|
|
unit=title.unit,
|
|
unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW),
|
|
ref_code=code,
|
|
)
|
|
)
|
|
return sheet
|
|
|
|
|
|
def price_basis_detail(
|
|
code: str,
|
|
build: UnitPriceBuild | None = None,
|
|
) -> dict[str, Any]:
|
|
"""산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지.
|
|
|
|
일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다.
|
|
"""
|
|
from B09_Estimation.B09_Estimation_UnitPrice import detail_of
|
|
|
|
prices = build or cached_build()
|
|
title = prices.book.title(code)
|
|
rows: list[dict[str, Any]] = []
|
|
for detail in prices.book.details.get(code, []):
|
|
child = prices.book.title(detail.ref_code)
|
|
money = prices.book.resolve(detail.ref_code).scaled(detail.quantity)
|
|
rows.append(
|
|
{
|
|
"code": detail.ref_code,
|
|
"name": child.name,
|
|
"spec": child.spec,
|
|
"unit": child.unit,
|
|
"quantity": str(detail.quantity),
|
|
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
|
|
"drillable": True,
|
|
"note": detail.note,
|
|
}
|
|
)
|
|
|
|
money = prices.book.resolve(code)
|
|
return {
|
|
"code": code,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"rows": rows,
|
|
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
|
|
"material": str(money.material),
|
|
"labor": str(money.labor),
|
|
"expense": str(money.expense),
|
|
# 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게.
|
|
"unit_price": detail_of(prices, rows[0]["code"]) if rows else None,
|
|
}
|