diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index b3e00a18..3c42e1ac 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -25,6 +25,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Guards import ( check_excluded_rows_not_priced, + check_included_materials_not_listed, check_free_haul_not_priced, check_haul_volume_within_cut, ) @@ -373,6 +374,15 @@ def build_bill( # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) + # ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가. + check_included_materials_not_listed( + work_item_codes=[row.code or "" for row in result.rows], + materials=[ + {"material_name": m.material_name, "source_structure": list(m.source_structure)} + for m in materials + ], + ) + # ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡). # 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다. # 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다. diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index e05f00f0..800ceac2 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -245,3 +245,40 @@ def check_drain_pipe_not_double_counted( "실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 " "(품셈 13-6-2 [주]③)." ) + + +#: 큰돌쌓기(13-6) 품에 **이미 들어 있는** 자재 — 따로 세우면 두 번이다. +#: 근거: 품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」. +#: ⚠ **13-6 한정**이다 — 돌쌓기(13-4)·돌붙임(13-7)에는 이 [주]가 없으므로 +#: 그쪽에서 고임돌이 자재로 오는 것은 정상이다. 넓게 잡으면 정상 자재를 지운다. +BOULDER_INCLUDED_MATERIALS = ("고임돌", "채움콘크리트", "채움 콘크리트") +BOULDER_WORK_ITEM_PREFIX = "FP-13-06" + + +def check_included_materials_not_listed( + *, + work_item_codes: list[str], + materials: list[dict], + name_field: str = "material_name", + source_field: str = "source_structure", + label: str = "큰돌쌓기", +) -> None: + """㉦ 품에 포함된 자재를 따로 세지 않았는가 (품셈 13-6 [주]①). + + 큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면 + 같은 것을 두 번 센다. 자재의 `source_structure` 로 **그 구조물에서 온 것만** 본다 — + 다른 구조물(돌쌓기 13-4)의 고임돌은 정상이다. + """ + if not any(str(code).startswith(BOULDER_WORK_ITEM_PREFIX) for code in work_item_codes): + return + for material in materials: + name = "".join(str(material.get(name_field) or "").split()) + if name not in {"".join(x.split()) for x in BOULDER_INCLUDED_MATERIALS}: + continue + sources = material.get(source_field) or [] + if any(label in str(source) for source in sources): + raise DoubleCountError( + f"{label}: 「{material.get(name_field)}」이 자재로도 실렸습니다 — " + "큰돌쌓기 품에 이미 들어 있습니다 (품셈 13-6 [주]① 「고임돌 및 " + "채움 콘크리트 등은 품에 포함」)." + ) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 603cbf96..5dd1e8fe 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -673,6 +673,23 @@ interface BillDto { }; } +/** + * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. + * + * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 + * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. + * 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다. + */ +function formatQuantity(value: string | null): string { + if (value === null || value === "") return ""; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return parsed.toLocaleString("ko-KR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + async function fetchBill(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`, @@ -806,7 +823,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { indent + row.name, row.spec, row.unit, - row.quantity ?? "", + formatQuantity(row.quantity), row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, @@ -827,6 +844,12 @@ export async function renderB09Estimation(root: HTMLElement): Promise { total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`; body.append(total); + // 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다. + const precision = document.createElement("div"); + precision.className = "b09-hint"; + precision.textContent = L("B09_Estimation_Boq_Precision"); + body.append(precision); + // ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다. const shortfall = document.createElement("div"); shortfall.className = "b09-hint"; @@ -838,7 +861,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise { note.className = "b09-hint"; note.textContent = `${L("B09_Estimation_Boq_Excluded")}: ` + - bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); + bill.excluded + .map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`) + .join(", "); body.append(note); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index df7f32db..038fb415 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -671,6 +671,10 @@ 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_Boq_Precision: [ + "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", + "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", + ], B09_Estimation_Boq_Excluded: [ "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", "Check rows — quantity only, never priced",