diff --git a/B09_Estimation/B09_Estimation_Rounding.py b/B09_Estimation/B09_Estimation_Rounding.py index 3447e4be..5a939ebe 100644 --- a/B09_Estimation/B09_Estimation_Rounding.py +++ b/B09_Estimation/B09_Estimation_Rounding.py @@ -36,6 +36,8 @@ class OutputPlace(str, Enum): RESOURCE_SUMMARY = "resource_summary" #: 관급자재대 총액 — **천원 올림** OWNER_MATERIAL_TOTAL = "owner_material_total" + #: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」) + UNIT_PRICE_ROW = "unit_price_row" def round_at(value: Decimal, place: OutputPlace) -> Decimal: @@ -48,6 +50,8 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal: return value.quantize(_ONE, rounding=ROUND_FLOOR) if place is OutputPlace.RESOURCE_SUMMARY: return value.quantize(_ONE, rounding=ROUND_HALF_UP) + if place is OutputPlace.UNIT_PRICE_ROW: + return value.quantize(Decimal("0.1"), rounding=ROUND_FLOOR) if place is OutputPlace.OWNER_MATERIAL_TOTAL: return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_CEILING) * _THOUSAND raise ValueError(f"단수 처리 자리를 모릅니다: {place}") diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index fc8bcbb5..813d2dd1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -78,6 +78,7 @@ interface UnitPriceDetailRow extends UnitPriceRow { interface UnitPriceDetailDto { status: string; + precise_total: string; code: string; name: string; spec: string; @@ -320,6 +321,15 @@ function buildUnitPriceDetail( ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; wrap.append(caption); + // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** + // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. + if (detail.precise_total !== detail.total) { + const gap = document.createElement("div"); + gap.className = "b09-hint"; + gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`; + wrap.append(gap); + } + const table = document.createElement("table"); const head = document.createElement("tr"); for (const [key, left] of [ diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 1aed4d1d..34351e6d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -41,6 +41,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( load_labor_catalog, load_work_item_master, ) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. @@ -293,6 +294,15 @@ def build_summary(build: UnitPriceBuild) -> dict: } +def _money_text(value: Decimal) -> str: + """화면에 낼 금액 — **일위대가 금액란은 0.1원 미만 버림**(품셈 1-2-2). + + 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 + (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). + """ + return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) + + def list_unit_prices(build: UnitPriceBuild) -> list[dict]: """목록표 — 「무엇이 있나」 한 줄씩.""" rows: list[dict] = [] @@ -306,10 +316,10 @@ def list_unit_prices(build: UnitPriceBuild) -> list[dict]: "name": title.name, "spec": title.spec, "unit": title.unit, - "material": str(money.material), - "labor": str(money.labor), - "expense": str(money.expense), - "total": str(money.total), + "material": _money_text(money.material), + "labor": _money_text(money.labor), + "expense": _money_text(money.expense), + "total": _money_text(money.total), } ) return rows @@ -334,28 +344,42 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "source_label": SOURCE_LABEL.get(child.kind, ""), "drillable": child.kind in DRILLABLE_KINDS, "quantity": str(detail.quantity), - "unit_material": str(unit_money.material), - "unit_labor": str(unit_money.labor), - "unit_expense": str(unit_money.expense), - "unit_total": str(unit_money.total), - "material": str(line.material), - "labor": str(line.labor), - "expense": str(line.expense), - "total": str(line.total), + "unit_material": _money_text(unit_money.material), + "unit_labor": _money_text(unit_money.labor), + "unit_expense": _money_text(unit_money.expense), + "unit_total": _money_text(unit_money.total), + "material": _money_text(line.material), + "labor": _money_text(line.labor), + "expense": _money_text(line.expense), + # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. + # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. + "total": str( + round_at(line.material, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) + ), "note": detail.note, } ) + # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 + # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. + summed = { + key: sum((Decimal(r[key]) for r in rows), Decimal(0)) + for key in ("material", "labor", "expense", "total") + } return { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "kind": title.kind.value, - "material": str(money.material), - "labor": str(money.labor), - "expense": str(money.expense), - "total": str(money.total), + "material": str(summed["material"]), + "labor": str(summed["labor"]), + "expense": str(summed["expense"]), + "total": str(summed["total"]), # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. - "sum_matches": money.total == money.material + money.labor + money.expense, + "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], + # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. + "precise_total": _money_text(money.total), "rows": rows, } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 076421a9..e64aa2c3 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -701,6 +701,10 @@ export const ui_locales_b2 = { B09_Estimation_Col_Total: ["합계", "Total"], B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_RoundGap: [ + "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", + "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", + ], B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */