diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 96a30ce6..fd50b7fc 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -32,6 +32,7 @@ from B09_Estimation.B09_Estimation_Guards import ( check_haul_volume_within_cut, ) from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( UnitPriceBuild, @@ -157,6 +158,13 @@ class BillRow: def money(value: Decimal | None) -> str | None: return None if value is None else str(value) + # 수량 소수자리는 **종목마다 다르다** (품셈 1-2-2). 값은 전정밀로 두고 **찍을 자리만** + # 함께 보낸다 — 자르는 것은 표를 그리는 쪽 몫이다(단수 규칙과 같은 원칙). + shown, digits = ( + (None, None) + if self.quantity is None + else round_quantity(self.quantity, self.name, self.unit, self.spec) + ) return { "item_no": self.item_no, "level": self.level, @@ -165,6 +173,10 @@ class BillRow: "spec": self.spec, "unit": self.unit, "quantity": money(self.quantity), + #: 품셈 1-2-2 종목별 자리로 **반올림한** 표시값. 자리를 모르면 `None` 이고, + #: 그때 화면은 종전대로 찍는다(지어내지 않는다). + "quantity_shown": money(shown) if digits is not None else None, + "quantity_digits": digits, "unit_price_krw": money(self.unit_price_krw), "amount_krw": money(self.amount_krw), "material_krw": str(self.material_krw), diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index f3544b42..fb2d04fe 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -277,6 +277,29 @@ def _leaf_row( if part ) + if title.unit and row.unit and not _same_unit(title.unit, row.unit): + # ⚠⚠ **단위가 다르면 곱하지 않는다** (2026-09-08 실측으로 드러난 자리). + # 돌쌓기(찰)이 B08 에서 **연장 10 m** 로 오는데 품셈 일위대가는 **㎡당**이라, + # 52,938.9원/㎡ × 10 m = 529,389원이 조용히 서 있었다. 면적으로 세면 26.101㎡ × + # 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다. + # 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음 + # 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.** + row.note = ( + f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. " + "곱하면 금액이 틀리므로 비워 둡니다." + ) + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단위 불일치 — 수량 {row.unit} vs 단가 {title.unit}당", + "blocked_kind": "unit_mismatch", + } + ) + return row + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -340,3 +363,35 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: } ) return row + + +#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다. +_UNIT_ALIASES = { + "㎥": "m3", + "m³": "m3", + "M3": "m3", + "루베": "m3", + "㎡": "m2", + "m²": "m2", + "M2": "m2", + "㎏": "kg", + "KG": "kg", + "톤": "ton", + "TON": "ton", + "t": "ton", + "개소": "개", + "EA": "개", + "ea": "개", + "인": "인", + "인/일": "인", +} + + +def _same_unit(left: str, right: str) -> bool: + """두 단위가 같은가. **표기 차이만 흡수하고, 환산은 하지 않는다** — m 과 ㎡ 는 다르다.""" + + def key(text: str) -> str: + tight = "".join(str(text or "").split()) + return _UNIT_ALIASES.get(tight, tight) + + return key(left) == key(right) diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index ad91cc60..fa09427a 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -215,8 +215,21 @@ class PriceBook: # 명시하는 서식인데 13-6-2 는 그냥 「노무비」다. # 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 # 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다. - # ⚠ 잠정 — 조종원 노임을 넣으면 찰쌓기 60~80 기준 2,098.46 → 2,565.90 - # (약 +22 %). 사용자 확정 대기(PLAN 9-6). + # + # ⚠⚠ **닫힌 물음이다 — 다시 뒤집지 말 것** (2026-09-08 전수 대조로 확정). + # 이 값은 하루에 두 번 뒤집혔고 금액이 약 ±22 % 움직였다. 세 번째가 + # 없도록 근거를 여기 못 박는다. + # ㉠ **산림품셈이 1차 적용**이고(품셈 1-1·별표2, 건설품셈은 보완), + # 그 [주]③ 은 여섯 자리에서 한결같이 **「노무비의 합계액」**이라 적는다 + # (원문 L5498·L7013·L7299·L7323·L7350·L7401). + # ㉡ **일위대가 안에는 간접노무비가 없다.** 간접노무비는 원가계산서 층에서 + # 「직접노무비 × 율」로 나중에 생기는 값이다 + # (resources/knowledge/.../05_원가정보/원가계산_체계.md §2 밑수 정의표). + # ⇒ 그러므로 **그 표의 노무비 줄 합 = 직접노무비**이고, 「직접노무비냐 + # 노무비 계정이냐」라는 물음 자체가 이 층에서는 성립하지 않는다. + # ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로 + # 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다. + # ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다. total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100)) continue diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py new file mode 100644 index 00000000..0141c6a1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -0,0 +1,104 @@ +"""B09 원가계산 — **수량의 소수자리**는 종목마다 다르다 (산림품셈 1-2-2의 1). + +**왜 있는가** — 지금까지 화면이 **모든 수량을 2자리로** 찍고 있었는데, 품셈은 종목별로 +자리를 따로 정해 두었다. 체적합계·시멘트·철근은 **정수**, 돌쌓기·옹벽·떼는 **1자리**, +철강재는 **3자리**다. 2자리 고정은 그 어느 것도 아니다 (2026-09-08 지식DB 대조에서 드러남). + + 원문 = resources/knowledge/original/행정규칙/임도 품셈 적용기준 + (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md L575~660 + 요약 = resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md §2 + +⚠ **수량은 반올림이다** ([주]① 「본 표에 따르며, **반올림**하여 적용한다」). +금액이 전부 **버림**인 것과 반대다 — 한 모듈에 섞어 두면 반드시 헷갈리므로 +금액은 `B09_Estimation_Rounding` 이 따로 맡는다. + +⚠ **항목이 표를 이긴다** ([주]② 「품셈 각 항목에서 제시한 소숫자리가 본 표와 상이할 경우 +**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종 +마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다. + +⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만 +여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 — +「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다. +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal + +#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다. +#: ⚠ 값을 고칠 일이 생기면 원문을 먼저 볼 것 — 여기서 고치면 근거가 끊긴다. +_DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( + # 종목 이름 후보 · 단위 후보 · 소수자리 + (("공사연장", "노선연장"), ("m",), 0), + (("공사폭원", "노폭"), ("m",), 1), + (("직공인부",), ("인",), 2), + (("체적합계", "토적합계"), ("㎥", "m3"), 0), + (("떼", "평떼", "줄떼"), ("㎡", "m2"), 1), + (("모래", "자갈", "조약돌"), ("㎥", "m3"), 2), + (("견치돌", "깬돌"), ("㎡", "m2"), 1), + (("야면석",), ("㎥", "m3"), 1), + (("돌쌓기", "돌붙임"), ("㎥", "m3", "㎡", "m2"), 1), + (("사석",), ("㎥", "m3"), 1), + (("다듬돌", "절석", "판석"), ("개",), 2), + (("벽돌", "블록"), ("개",), 0), + (("시멘트",), ("kg", "㎏"), 0), + (("모르타르", "모르터"), ("kg", "㎏"), 2), + (("콘크리트",), ("㎥", "m3"), 2), + (("합판",), ("장",), 1), + (("말뚝",), ("개",), 0), + (("철강재", "강재"), ("kg", "㎏"), 3), + (("용접봉",), ("kg", "㎏"), 1), + (("철근",), ("kg", "㎏"), 0), + (("볼트", "너트", "꺽쇠"), ("개",), 0), + (("철선", "철사"), ("kg", "㎏"), 2), + (("못",), ("kg", "㎏"), 2), + (("화약",), ("kg", "㎏"), 3), + (("뇌관",), ("개",), 0), + (("도화선",), ("m",), 1), + (("수로연장",), ("m",), 1), + (("옹벽",), ("㎡", "m2"), 1), + (("도장", "칠하기"), ("㎡", "m2"), 1), + (("방수",), ("㎡", "m2"), 1), + (("보오링", "보링"), ("m",), 1), +) + +#: 위 표에 이름이 안 걸릴 때 **단위만으로** 줄 수 있는 자리 — 품셈 「토적」 줄이 근거다. +#: 토적: 높이·너비 m 2 · 단면적 ㎡ 1 · 체적 ㎥ 2. +#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라 +#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. +_UNIT_ONLY = {"㎥": 2, "m3": 2} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def digits_for(name: str, unit: str, spec: str = "") -> int | None: + """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.""" + haystack = _tight(name) + _tight(spec) + unit_tight = _tight(unit) + for words, units, digits in _DIGITS: + if unit_tight not in units: + continue + if any(word in haystack for word in words): + return digits + return _UNIT_ONLY.get(unit_tight) + + +def round_quantity( + value: Decimal, + name: str, + unit: str, + spec: str = "", + override: int | None = None, +) -> tuple[Decimal, int | None]: + """수량을 그 종목의 자리로 **반올림**한다. + + 돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`. + `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다. + """ + digits = override if override is not None else digits_for(name, unit, spec) + if digits is None: + return value, None + quantum = Decimal(1).scaleb(-digits) + return value.quantize(quantum, rounding=ROUND_HALF_UP), digits diff --git a/B09_Estimation/B09_Estimation_Rounding.py b/B09_Estimation/B09_Estimation_Rounding.py index 5a939ebe..e56eaf3e 100644 --- a/B09_Estimation/B09_Estimation_Rounding.py +++ b/B09_Estimation/B09_Estimation_Rounding.py @@ -38,6 +38,12 @@ class OutputPlace(str, Enum): OWNER_MATERIAL_TOTAL = "owner_material_total" #: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」) UNIT_PRICE_ROW = "unit_price_row" + #: 일위대가표의 **계금** — 1원 미만 버림. ⚠ 금액란(0.1원)과 **자리가 다르다** + #: (품셈 1-2-2의 2 표에 두 줄이 따로 있다). + UNIT_PRICE_TOTAL = "unit_price_total" + #: **설계서의 총액** — 1,000원 미만 버림 (품셈 1-2-2의 2 「설계서의 총액 … 1,000 미만버림」). + #: ⚠ 소계·금액란(1원)과 다르다. 맨 마지막 한 자리에서만 쓴다. + GRAND_TOTAL = "grand_total" def round_at(value: Decimal, place: OutputPlace) -> Decimal: @@ -46,7 +52,14 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal: 자리를 안 대고 부르는 길을 두지 않는다 — 기본값을 두면 어느 자리인지 모른 채 아무 함수나 쓰게 된다. """ - if place in (OutputPlace.BOQ_ROW, OutputPlace.OVERHEAD_ROW, OutputPlace.PROCUREMENT_FEE): + if place is OutputPlace.GRAND_TOTAL: + return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_FLOOR) * _THOUSAND + if place in ( + OutputPlace.BOQ_ROW, + OutputPlace.OVERHEAD_ROW, + OutputPlace.PROCUREMENT_FEE, + OutputPlace.UNIT_PRICE_TOTAL, + ): return value.quantize(_ONE, rounding=ROUND_FLOOR) if place is OutputPlace.RESOURCE_SUMMARY: return value.quantize(_ONE, rounding=ROUND_HALF_UP) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index abf5fa18..700db411 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -653,6 +653,9 @@ interface BillRowDto { spec: string; unit: string; quantity: string | null; + /** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */ + quantity_shown: string | null; + quantity_digits: number | null; unit_price_krw: string | null; amount_krw: string | null; is_group: boolean; @@ -713,19 +716,24 @@ interface MaterialSheetDto { } /** - * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. + * 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1). + * + * 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다 + * `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다. + * ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을 + * 아는 척 자르지 않는다. * * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. - * 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다. */ -function formatQuantity(value: string | null): string { +function formatQuantity(value: string | null, digits: number | null = null): string { if (value === null || value === "") return ""; const parsed = Number(value); if (!Number.isFinite(parsed)) return value; + const places = digits ?? 2; return parsed.toLocaleString("ko-KR", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: places, + maximumFractionDigits: places, }); } @@ -863,7 +871,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { indent + row.name, row.spec, row.unit, - formatQuantity(row.quantity), + formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits), row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, @@ -902,7 +910,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { note.textContent = `${L("B09_Estimation_Boq_Excluded")}: ` + bill.excluded - .map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`) + .map( + (row) => + `${row.name} ${formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits)}${row.unit}`, + ) .join(", "); body.append(note); }