feat(B09): 일위대가 금액 0.1원 버림 — 자리 규칙 적용 + 끝자리 차이를 화면에 드러냄

- `OutputPlace.UNIT_PRICE_ROW` 추가 — **0.1원 미만 버림**(품셈 1-2-2 「일위대가 금액란
  0.1원 미만 버림」). 계산은 전정밀, **표 그리는 자리에서만** 자름(단수는 출력 위치에 붙음).
- **행 합계 = 자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 섬. 전정밀 합을
  따로 자르면 성분과 합계가 어긋나 보임. 표 전체 합계도 **행별로 자른 값을 더함**
  (`단수처리_규칙.md` §2 「행별 처리(합계 후 아님)」).
- ⚠ **행별 절사로 생기는 끝자리 차이를 숨기지 않음** — 화면에 「행별로 0.1원 미만을 버려
  합계 끝자리가 다릅니다 (정상). 자르기 전 합계: …」를 띄움. 숨기면 나중에 「합계가 안
  맞는다」며 계산을 고치려 듦.

**화면 실측(5174)** — 일위대가 탭 → 목록 64건 → 「제근」 본표 →
`▸ 굴착기(무한궤도) 0.2 | 기계경비(105) | hr | 0.8 | 8,936.5 | 28,329.7 | 11,098.5 | 48,364.7`
→ 기계 줄 클릭 → **시간당 사용료 본표로 파고듦**(손료 13,873.1 · 경유 11,170.6 ·
건설기계운전사 35,412.1 = 60,455.9). 합계 98,292.9 / 자르기 전 98,293.2 표시.
산출 요약 2줄(자재 미확보로 구조물 계열 안 섬)도 탭 상단에 뜸.

⚠ 검증 함정 기록 — 해시 라우트에 쿼리를 붙일 때 `#/b09-estimation?v=2` 는 라우트가
안 잡힘. **`?v=2#/b09-estimation` 처럼 해시 앞에** 붙여야 함.

자체검증 — `pytest tmp/tests/ -q` 116 passed · ruff 통과 · tsc(B09) 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:23:09 +09:00
co-authored by Claude Opus 5
parent 9eeea3bc59
commit 29cf61f4ad
4 changed files with 59 additions and 17 deletions
@@ -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}")
+10
View File
@@ -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 [
+41 -17
View File
@@ -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,
}
+4
View File
@@ -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 결재 --- */