feat(B09): 수량 자릿수를 품셈 1-2-2 종목별로, 단위 불일치는 금액을 안 만듦
수량 자릿수 (품셈 1-2-2의 1) - 화면이 모든 수량을 2자리로 찍고 있었음. 품셈은 종목별로 자리를 따로 정함 — 체적합계·시멘트·철근 정수 / 돌쌓기·옹벽·떼 1자리 / 철강재 3자리. - 수량은 반올림([주]①), 금액은 버림 — 반대라 모듈을 갈라 둠. - 항목이 표를 이김([주]②) → override 받음. 표에 없는 종목은 안 건드림. - 서버가 줄마다 quantity_digits 를 실어 보내고 화면은 그 자리로 찍기만 함. 금액 자리 보강 (품셈 1-2-2의 2) - 설계서 총액 1,000원 미만 버림 · 일위대가 계금 1원 미만 버림 추가. 금액란(0.1원)·소계(1원)와 자리가 넷 다 다름. ⚠ 단위 불일치 가드 - 돌쌓기(찰)이 연장 10m 로 오는데 일위대가는 ㎡당이라 52,938.9×10 = 529,389원이 조용히 서 있었음. 면적으로 세면 1,381,753원 — 2.6배 적은 금액이었음. - 단위가 다르면 곱하지 않고 사유를 남김. 표기 차이(㎥/m3·개소/개)는 흡수하되 환산은 안 함. 내역 합계 6,252,172 → 5,048,775 (틀린 두 줄이 빠짐). 제잡비 밑수 - 닫힌 물음임을 원문 인용으로 못 박음 — 산림품셈 [주]③ 여섯 자리가 「노무비의 합계액」이고, 일위대가 안에는 간접노무비가 없으므로 그 표의 노무비 합 = 직접노무비. 하루에 두 번 뒤집힌 값이라 세 번째가 없게 근거를 남김. 시험 10건 추가, 238건 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user