feat(B09): ③ 단가산출서(D층) 신설 — 내역 줄에서 검산 경로가 이어짐
실무 내역서는 줄마다 비고에 「단산 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>
This commit is contained in:
@@ -163,9 +163,13 @@ class BillResult:
|
||||
missing: list[dict[str, str]] = field(default_factory=list)
|
||||
#: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다.
|
||||
excluded: list[BillRow] = field(default_factory=list)
|
||||
#: 이 내역서에 쓰인 일위대가 코드 — ③ 단가산출서 번호를 매기는 차례가 된다.
|
||||
used_unit_prices: list[str] = field(default_factory=list)
|
||||
#: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다.
|
||||
material_rows: list[BillRow] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
|
||||
price_basis: Any = None
|
||||
|
||||
@property
|
||||
def direct_material_krw(self) -> Decimal:
|
||||
@@ -422,6 +426,18 @@ def build_bill(
|
||||
total_cut_volume_m3=cut_total,
|
||||
)
|
||||
|
||||
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
|
||||
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
|
||||
|
||||
sheet = build_price_basis(result.used_unit_prices, unit_prices)
|
||||
for row in result.rows:
|
||||
if row.is_group or row.code is None or row.amount_krw is None:
|
||||
continue
|
||||
entry = sheet.by_unit_price(f"B-{row.code}")
|
||||
if entry is not None:
|
||||
row.note = " / ".join(part for part in (entry.label, row.note) if part)
|
||||
result.price_basis = sheet
|
||||
|
||||
if any(m.surcharge_pct is None for m in materials):
|
||||
result.notes.append(
|
||||
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
|
||||
@@ -651,6 +667,10 @@ def _leaf_row(
|
||||
if part
|
||||
)
|
||||
|
||||
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
|
||||
if price_code not in result.used_unit_prices:
|
||||
result.used_unit_prices.append(price_code)
|
||||
|
||||
unit_money = unit_prices.book.resolve(price_code)
|
||||
line = unit_money.scaled(item.quantity)
|
||||
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -296,5 +296,22 @@ async def get_bill(project_id: UUID) -> JSONResponse:
|
||||
"excluded": [row.as_dict() for row in result.excluded],
|
||||
"materials": [row.as_dict() for row in result.material_rows],
|
||||
"summary": bill_summary(result),
|
||||
"price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/price-basis/{code}")
|
||||
async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||
"""③ 단가산출서 한 장 — 그 단가가 무엇을 참조해 나왔는지."""
|
||||
from B09_Estimation.B09_Estimation_PriceBasis import price_basis_detail
|
||||
|
||||
try:
|
||||
body = price_basis_detail(code)
|
||||
except Exception:
|
||||
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 단가산출서를 찾지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", **body})
|
||||
|
||||
@@ -549,7 +549,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
|
||||
["boq", "B09_Estimation_Tab_Boq", true],
|
||||
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
|
||||
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
|
||||
["price_basis", "B09_Estimation_Tab_PriceBasis", true],
|
||||
["machine", "B09_Estimation_Tab_Machine", false],
|
||||
["duration", "B09_Estimation_Tab_Duration", false],
|
||||
["supply", "B09_Estimation_Tab_Supply", false],
|
||||
@@ -660,6 +660,17 @@ interface BillRowDto {
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface PriceBasisEntryDto {
|
||||
number: number;
|
||||
label: string;
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
unit_price_krw: string;
|
||||
ref_code: string;
|
||||
}
|
||||
|
||||
interface BillDto {
|
||||
rows: BillRowDto[];
|
||||
excluded: BillRowDto[];
|
||||
@@ -677,6 +688,7 @@ interface BillDto {
|
||||
}>;
|
||||
notes: string[];
|
||||
};
|
||||
price_basis: { entries: PriceBasisEntryDto[] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -727,6 +739,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||
let selectedUnitPrice: string | null = null;
|
||||
let bill: BillDto | null = null;
|
||||
let priceBasis: string | null = null;
|
||||
|
||||
const main = document.createElement("div");
|
||||
main.className = "b09-main";
|
||||
@@ -914,6 +927,66 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
/** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */
|
||||
const drawPriceBasisTab = (): void => {
|
||||
if (!bill) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
empty.textContent = L("B09_Estimation_PB_Empty");
|
||||
body.append(empty);
|
||||
return;
|
||||
}
|
||||
const entries = bill.price_basis?.entries ?? [];
|
||||
// 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌
|
||||
// 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다).
|
||||
const split = document.createElement("div");
|
||||
|
||||
const list = document.createElement("table");
|
||||
list.className = "b09-sheet b09-up-list";
|
||||
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const entry of entries) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const text of [
|
||||
String(entry.number),
|
||||
`${entry.name} ${entry.spec}`.trim(),
|
||||
entry.unit,
|
||||
entry.unit_price_krw,
|
||||
]) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = text;
|
||||
tr.append(td);
|
||||
}
|
||||
tr.style.cursor = "pointer";
|
||||
if (entry.code === priceBasis) tr.style.fontWeight = "600";
|
||||
tr.addEventListener("click", () => {
|
||||
priceBasis = entry.code;
|
||||
drawBody();
|
||||
});
|
||||
tbody.append(tr);
|
||||
}
|
||||
list.append(tbody);
|
||||
split.append(list);
|
||||
|
||||
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b09-up-detail";
|
||||
if (picked === null) {
|
||||
panel.textContent = L("B09_Estimation_PB_Pick");
|
||||
} else {
|
||||
const head = document.createElement("div");
|
||||
head.className = "b09-hint";
|
||||
head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`;
|
||||
const ref = document.createElement("div");
|
||||
ref.className = "b09-hint";
|
||||
// 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다.
|
||||
ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`;
|
||||
panel.append(head, ref);
|
||||
}
|
||||
split.append(panel);
|
||||
body.append(split);
|
||||
};
|
||||
|
||||
const drawBody = (): void => {
|
||||
body.replaceChildren();
|
||||
if (activeTab === "unit_price") {
|
||||
@@ -924,6 +997,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawBoqTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab === "price_basis") {
|
||||
drawPriceBasisTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "cost_sheet") {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
|
||||
@@ -671,6 +671,12 @@ export const ui_locales_b2 = {
|
||||
B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"],
|
||||
B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"],
|
||||
B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"],
|
||||
B09_Estimation_PB_Empty: [
|
||||
"설계내역서를 먼저 불러오면 단가산출서가 섭니다.",
|
||||
"Load the bill first and the price-basis sheets appear.",
|
||||
],
|
||||
B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."],
|
||||
B09_Estimation_PB_Ref: ["참조", "Refers to"],
|
||||
B09_Estimation_Boq_Precision: [
|
||||
"수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.",
|
||||
"Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.",
|
||||
|
||||
Reference in New Issue
Block a user