From e250d2f716b6f2154937c91a48d1a9b0ada73b11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:02:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9E=AC=EB=8C=80=20?= =?UTF-8?q?=ED=91=9C=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EA=B4=80=EA=B8=89?= =?UTF-8?q?=C2=B7=EC=82=AC=EA=B8=89=C2=B7=EB=AF=B8=EC=A0=95=20=EC=85=8B?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=9D=BC=20=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 8-7 「자재대·관급자재대(금액)는 B09」 — B08 자재총괄이 낸 수량·할증에 단가를 붙여 금액을 내는 자리. 수량은 B08 것이 정본이라 다시 세지 않음 - **관급은 총원가 밖 별도 표기**(⑤ 관급자재대와 같은 값), 사급은 도급 재료비 - ⚠ `unknown`(관급·사급 미정)은 **어느 합계에도 안 넣음** — 넣으면 총액이 틀리고 어느 쪽으로 넣었는지 나중에 못 가림. 지금 실물 4건이 전부 이 상태 - 단가가 없으면 **금액을 안 만들고** 사유를 남김(사급 물가지 미결 No.18) - 할증률이 없으면 「할증 전 수량」임을 표에 적음 — 여기서 또 곱하지 않음(㉠) - 관급자재대 합계는 **천원 올림**(단수처리 규칙) - 화면 탭 신설 — 세 무리를 각각 표로 보이고 합계·사유를 함께 냄 검증: pytest 205 통과(신규 5 — 합계 분리·미정 제외·단가 없음·할증 깃발·천원 올림), tsc 0건. 화면 확인은 공용 브라우저 세션 만료로 다음 차례에 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 11 ++ .../B09_Estimation_MaterialSheet.py | 180 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 84 +++++++- ui_template/ui_template_locale_b2.ts | 13 ++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 B09_Estimation/B09_Estimation_MaterialSheet.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 72d85a48..9e798367 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -177,6 +177,8 @@ class BillResult: notes: list[str] = field(default_factory=list) #: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다. price_basis: Any = None + #: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」). + material_sheet: Any = None @property def direct_material_krw(self) -> Decimal: @@ -435,6 +437,14 @@ def build_bill( total_cut_volume_m3=cut_total, ) + # 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다. + from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet + + result.material_sheet = build_material_sheet( + materials, + surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"), + ) + # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis @@ -744,6 +754,7 @@ def bill_summary(result: BillResult) -> dict[str, Any]: "group_rows": sum(1 for r in result.rows if r.is_group), "excluded_rows": len(result.excluded), "material_rows": len(result.material_rows), + "material_sheet": result.material_sheet.as_dict() if result.material_sheet else None, "missing": result.missing, "body_total_krw": str(result.body_total_krw), "direct_material_krw": str(result.direct_material_krw), diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py new file mode 100644 index 00000000..135b36ff --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -0,0 +1,180 @@ +"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」). + +**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다. +수량은 B08 것이 정본이고 여기서 다시 세지 않는다. + +**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1) + - **사급** — 도급 재료비. 내역서 안에 들어간다. + - **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의 + 「관급자재대」와 같은 값이라 그쪽과 이어야 한다. + - **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간 + 총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다. + +⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을 +반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가 +`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다. + +⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은 +대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MaterialCatalog import ( + SUPPLY_CONTRACTOR, + SUPPLY_OWNER, + MaterialCatalog, + load_material_catalog, +) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +_ZERO = Decimal(0) + +#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다. +SUPPLY_UNKNOWN = "unknown" + +#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08). +SURCHARGE_APPLIED = "applied" +SURCHARGE_NOT_APPLIED = "not_applied" +SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" + + +@dataclass +class MaterialSheetRow: + """자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다.""" + + name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + unit_price_krw: Decimal | None = None + amount_krw: Decimal | None = None + surcharge_pct: Decimal | None = None + source_structure: tuple[str, ...] = () + note: str = "" + + def as_dict(self) -> dict[str, Any]: + def money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + return { + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "net_amount": str(self.net_amount), + "total_amount": str(self.total_amount), + "supply_type": self.supply_type, + "unit_price_krw": money(self.unit_price_krw), + "amount_krw": money(self.amount_krw), + "surcharge_pct": money(self.surcharge_pct), + "source_structure": list(self.source_structure), + "note": self.note, + } + + +@dataclass +class MaterialSheet: + """자재대 한 벌 — 사급·관급·미정 셋으로 갈린다.""" + + contractor_rows: list[MaterialSheetRow] = field(default_factory=list) + owner_rows: list[MaterialSheetRow] = field(default_factory=list) + unknown_rows: list[MaterialSheetRow] = field(default_factory=list) + #: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.** + missing: list[dict[str, str]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + @property + def contractor_total_krw(self) -> Decimal: + """사급 자재비 합계 — 도급 재료비로 들어간다.""" + return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO) + + @property + def owner_total_krw(self) -> Decimal: + """관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다.""" + return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO) + + def as_dict(self) -> dict[str, Any]: + return { + "contractor": [row.as_dict() for row in self.contractor_rows], + "owner": [row.as_dict() for row in self.owner_rows], + "unknown": [row.as_dict() for row in self.unknown_rows], + "contractor_total_krw": str(self.contractor_total_krw), + # 관급자재대는 **천원 올림** 자리다(단수처리 규칙). + "owner_total_krw": str( + round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL) + ), + "missing": self.missing, + "notes": self.notes, + } + + +def build_material_sheet( + materials: list, + *, + surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, + catalog: MaterialCatalog | None = None, +) -> MaterialSheet: + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + book = catalog or load_material_catalog() + sheet = MaterialSheet() + + if surcharge_status == SURCHARGE_RATE_UNAVAILABLE: + sheet.notes.append( + "할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 " + "붙습니다 (PLAN 8-7 ㉠)." + ) + + for material in materials: + row = MaterialSheetRow( + name=getattr(material, "material_name", ""), + spec=getattr(material, "spec", ""), + unit=getattr(material, "unit", ""), + net_amount=getattr(material, "net_amount", _ZERO), + total_amount=getattr(material, "total_amount", _ZERO), + supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN), + surcharge_pct=getattr(material, "surcharge_pct", None), + source_structure=tuple(getattr(material, "source_structure", ()) or ()), + ) + + if row.supply_type == SUPPLY_UNKNOWN: + # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. + row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다." + sheet.unknown_rows.append(row) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"} + ) + continue + + found = book.resolve(row.name, row.spec) + if found is None: + row.note = ( + "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " + "6번 슬롯(적용 단가) 수동 입력 대기." + ) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} + ) + else: + row.unit_price_krw = found.price_krw + # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. + row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + + if row.supply_type == SUPPLY_OWNER: + sheet.owner_rows.append(row) + elif row.supply_type == SUPPLY_CONTRACTOR: + sheet.contractor_rows.append(row) + else: + sheet.unknown_rows.append(row) + + if sheet.owner_rows: + sheet.notes.append( + "관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 " + "관급자재대와 같은 값이어야 합니다." + ) + return sheet diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 8f5e3fe1..7afc0166 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -552,7 +552,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["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], + ["supply", "B09_Estimation_Tab_Supply", true], ["base_data", "B09_Estimation_Tab_BaseData", false], ]; @@ -687,10 +687,31 @@ interface BillDto { blocked_kind?: string; }>; notes: string[]; + material_sheet: MaterialSheetDto | null; }; price_basis: { entries: PriceBasisEntryDto[] }; } +interface MaterialSheetRowDto { + name: string; + spec: string; + unit: string; + total_amount: string; + unit_price_krw: string | null; + amount_krw: string | null; + note: string; +} + +interface MaterialSheetDto { + contractor: MaterialSheetRowDto[]; + owner: MaterialSheetRowDto[]; + unknown: MaterialSheetRowDto[]; + contractor_total_krw: string; + owner_total_krw: string; + missing: Array<{ name: string; reason: string }>; + notes: string[]; +} + /** * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. * @@ -987,6 +1008,63 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.append(split); }; + /** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */ + const drawMaterialTab = (): void => { + const sheet = bill?.summary.material_sheet ?? null; + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Mat_Empty"); + body.append(empty); + return; + } + + for (const [labelKey, rows, total] of [ + ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw], + ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw], + ["B09_Estimation_Mat_Unknown", sheet.unknown, null], + ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) { + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); + body.append(head); + if (rows.length === 0) continue; + + const table = document.createElement("table"); + table.className = "b09-sheet"; + table.innerHTML = + "자재규격단위수량" + + "단가금액비고"; + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (const text of [ + row.name, + row.spec, + row.unit, + formatQuantity(row.total_amount), + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + tbody.append(tr); + } + table.append(tbody); + body.append(table); + } + + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note.replace(/\*\*/g, ""); + body.append(line); + } + }; + const drawBody = (): void => { body.replaceChildren(); if (activeTab === "unit_price") { @@ -1001,6 +1079,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawPriceBasisTab(); return; } + if (activeTab === "supply") { + drawMaterialTab(); + 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 e8426f88..63743d55 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -677,6 +677,19 @@ export const ui_locales_b2 = { ], B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], B09_Estimation_PB_Ref: ["참조", "Refers to"], + B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"], + B09_Estimation_Mat_Owner: [ + "관급 자재 — 총원가 밖 별도 표기", + "Owner-supplied — listed outside the total cost", + ], + B09_Estimation_Mat_Unknown: [ + "관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다", + "Supply type undecided — excluded from both totals", + ], + B09_Estimation_Mat_Empty: [ + "설계내역서를 먼저 불러오면 자재대가 섭니다.", + "Load the bill first and the material sheet appears.", + ], 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.",