fix(b09): 내역 수량을 1-2-2 자리로 반올림해 확정한 뒤 금액(버림) — 표에 없는 종목 기본 2자리 · 철근 ton 소수 3자리(kg 정수 환산) · 단위 대소문자 흡수 · STmate 원본 수량 321줄 대조 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 11:55:23 +09:00
co-authored by Claude Opus 5
parent bd69aa4788
commit d6820db7b0
5 changed files with 93 additions and 18 deletions
@@ -65,6 +65,8 @@ def raise_material_rows(
bill_line: Callable[[Money3, Decimal], Money3],
) -> None:
"""자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만."""
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import settle_quantity
sheet = result.material_sheet
picked = []
for item in getattr(sheet, "contractor_rows", []):
@@ -99,7 +101,6 @@ def raise_material_rows(
)
for index, item in enumerate(picked, start=1):
price = item.unit_price_krw
line = bill_line(Money3(material=price), item.total_amount)
row = BillRow(
item_no=f"{group_no}-{index}",
level=2,
@@ -109,6 +110,7 @@ def raise_material_rows(
unit=item.unit,
quantity=item.total_amount,
)
line = bill_line(Money3(material=price), settle_quantity(row)) # 수량 확정 뒤 금액
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO
row.unit_price_krw = price
row.amount_krw = line.total
@@ -31,6 +31,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
)
from B09_Estimation.B09_Estimation_MaterialPrices import manual_count
from B09_Estimation.B09_Estimation_PriceBook import Money3
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,
@@ -52,6 +53,17 @@ def bill_line(unit: Money3, quantity) -> Money3:
)
def settle_quantity(row: BillRow) -> Decimal:
"""수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈.
산림청고시 2025-82호 1-2-2 [주]① 「설계서 수량의 단위와 소수자리 표시는 본 표에 따르며,
**반올림하여 적용**한다」 ⇒ 수량 먼저 확정 → 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14
브레인 판정). 종전엔 인계 전정밀(0.28181999…96)로 곱해 원 미만 절사에 1원씩 샜음.
"""
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
return row.quantity
def _sum_groups(rows: list[BillRow]) -> None:
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
@@ -141,7 +153,7 @@ def _composite_row(
return row
money = money.floored(Decimal(1))
line = bill_line(money, item.quantity)
line = bill_line(money, settle_quantity(row))
_set_unit(row, money)
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
row.amount_krw = line.total
@@ -199,7 +211,7 @@ def _structure_price_row(
return row
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
line = bill_line(entry["money"], item.quantity)
line = bill_line(entry["money"], settle_quantity(row))
_set_unit(row, entry["money"])
row.price_code = ref
row.parts = list(entry.get("parts") or [])
@@ -544,7 +556,7 @@ def _leaf_row(
row.price_code = price_code
unit_money = unit_prices.book.resolve(price_code)
line = bill_line(unit_money, item.quantity)
line = bill_line(unit_money, settle_quantity(row))
_set_unit(row, unit_money)
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
+19 -12
View File
@@ -16,9 +16,10 @@
**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만
여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 —
「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다.
⚠ **표에 없는 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 — 종전 「손대지 않음」 폐기).
수량을 이 자리로 **먼저 확정한 뒤** 금액을 셈 — [주]① 이 시킨 차례이고, 명세의 「Q 를 소수 2자리로
먼저 확정한 뒤 나눔」과 같은 원칙(내역 줄 `BillOfQuantities_Rows.settle_quantity`).
못 맞춘 이름은 종전대로 로그로 드러냄.
"""
from __future__ import annotations
@@ -50,6 +51,9 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
(("철강재", "강재"), ("kg", ""), 3),
(("용접봉",), ("kg", ""), 1),
(("철근",), ("kg", ""), 0),
# 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진
# 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님).
(("철근",), ("ton", ""), 3),
(("볼트", "너트", "꺽쇠"), ("",), 0),
(("철선", "철사"), ("kg", ""), 2),
(("",), ("kg", ""), 2),
@@ -68,6 +72,8 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
_UNIT_ONLY = {"": 2, "m3": 2}
#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14).
DEFAULT_DIGITS = 2
logger = logging.getLogger(__name__)
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
@@ -78,15 +84,16 @@ def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.
def digits_for(name: str, unit: str, spec: str = "") -> int:
"""그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2.
⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
(금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄).
"""
haystack = _tight(name) + _tight(spec)
unit_tight = _tight(unit)
# 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수.
unit_tight = _tight(unit).lower()
for words, units, digits in _DIGITS:
if unit_tight not in units:
continue
@@ -100,9 +107,11 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s%s%s",
name,
unit,
"단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음",
"단위만으로 %s자리" % fallback
if fallback is not None
else "기본 %s자리" % DEFAULT_DIGITS,
)
return _UNIT_ONLY.get(unit_tight)
return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS)
def round_quantity(
@@ -114,11 +123,9 @@ def round_quantity(
) -> tuple[Decimal, int | None]:
"""수량을 그 종목의 자리로 **반올림**한다.
돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`.
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다.
돌려주는 것 — (자른 값, 쓴 자리).
`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
+27 -2
View File
@@ -109,8 +109,9 @@ def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량
group = next(r for r in result.rows if r.name == "자재(사급)")
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460")
# 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000
assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500")
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500")
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
sheet = result.material_sheet
assert (
@@ -162,3 +163,27 @@ def test_목록은_자원_축_자재와_사라진_저장_줄() -> None:
"material_sheet",
)
assert sheet_row["supply_type"] == "contractor_supplied" and rows[""]["missing"] is True
def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None:
"""1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정).
인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000.
"""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_QuantityDigits import digits_for
payload = {
"work_items": [],
"materials": [
_자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"])
],
}
result = build_bill(
payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}}
)
row = next(r for r in result.rows if r.name == "각재")
assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000"))
assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값
assert digits_for("낯선종목", "") == 2 # 표에 없는 종목은 우리 기본 2자리
assert digits_for("돌쌓기(찰)", "") == 1 and digits_for("철근", "kg") == 0
@@ -0,0 +1,29 @@
"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함.
2026-09-14 브레인 판정 — 내역 수량은 1-2-2 자리로 반올림해 확정한 뒤 금액(버림). 골든 금액 시험
(`test_b09_golden_stmate`)은 원본 수량을 `bill_line` 에 바로 넣어 이 길을 안 거치므로, 원본 수량
자체로 자리 규칙을 따로 잰다(321줄 · 철근 ton 소수 3자리가 이 대조에서 드러남).
"""
from __future__ import annotations
import pytest
from test_b09_golden_stmate import _bill_rows, _num
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None:
rows = _bill_rows()
if not rows:
pytest.skip("실무 원본 XLSX 가 없음")
changed = []
for name, row in rows:
quantity, unit = _num(row[3]), str(row[4] or "").strip()
if quantity is None or unit == "%":
continue
settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or ""))
if settled != quantity:
changed.append((name, row[1], unit, str(quantity), digits))
assert len(rows) >= 300, len(rows)
assert not changed, (len(changed), changed[:5])