From d8cbd48baf6b3cb385dbfd930e6fd6591c90ec4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:51:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=E2=91=A2=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EC=84=9C(D=EC=B8=B5)=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=82=B4=EC=97=AD=20=EC=A4=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=82=B0=20=EA=B2=BD=EB=A1=9C=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 내역서는 줄마다 비고에 「단산 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) --- .../B09_Estimation_BillOfQuantities.py | 20 +++ B09_Estimation/B09_Estimation_PriceBasis.py | 165 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 17 ++ B09_Estimation/B09_Estimation_UI_Page.ts | 79 ++++++++- ui_template/ui_template_locale_b2.ts | 6 + 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 B09_Estimation/B09_Estimation_PriceBasis.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index a86d7f48..fc760edc 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py new file mode 100644 index 00000000..dc35bb2b --- /dev/null +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -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, + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 9531369b..bf58a57c 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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}) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index a40987a1..8f5e3fe1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -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 { 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 { } }; + /** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 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 = "번호공종단위단가"; + 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 { drawBoqTab(); return; } + if (activeTab === "price_basis") { + drawPriceBasisTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 40e7a78a..e8426f88 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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.",