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:
@@ -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}")
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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 결재 --- */
|
||||
|
||||
Reference in New Issue
Block a user