diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 85079cbb..9e21fdf4 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -414,11 +414,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402 _composite_row, _excluded_row, _leaf_row, - _material_row, _structure_price_row, _sum_groups, bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름) ) +from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402 def build_bill( diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py index ce8063a6..e861f242 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -15,7 +15,13 @@ from __future__ import annotations from decimal import Decimal from typing import Any, Callable -from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow +from B09_Estimation.B09_Estimation_BillOfQuantities import ( + SUPPLY_OWNER, + SUPPLY_UNKNOWN, + BillResult, + BillRow, + HandoffMaterial, +) from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild @@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect" _ZERO = Decimal(0) +def _material_row( + material: HandoffMaterial, result: BillResult, manual: dict | None = None +) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**. + + `manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림 + (금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`). + """ + row = BillRow( + item_no="", + level=1, + code=None, + name=material.material_name, + spec=material.spec, + unit=material.unit, + quantity=material.total_amount, + ) + # 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다. + row.add_note("quantity", material.surcharge_note) + if material.supply_type == SUPPLY_UNKNOWN: + row.add_note( + "", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + ) + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": "공급 구분 미정(unknown)", + } + ) + return row + # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 + # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 + # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). + if material.supply_type == SUPPLY_OWNER: + row.add_note( + "unit_price_krw", + "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " + "관급자재대(총원가 밖 별도 표기)로 갑니다.", + ) + reason = "관급 자재 단가 없음" + elif f"{material.material_name} {material.spec}".strip() in (manual or {}): + row.add_note( + "unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬" + ) + return row + else: + row.add_note( + "unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기." + ) + reason = "사급 자재 단가 없음(미결 No.18)" + + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": reason, + "supply_type": material.supply_type, + } + ) + return row + + def _flat(text: Any) -> str: return "".join(str(text or "").split()) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index eacfa1f7..1844b89e 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -12,11 +12,8 @@ from __future__ import annotations from decimal import Decimal from B09_Estimation.B09_Estimation_BillOfQuantities import ( - SUPPLY_OWNER, - SUPPLY_UNKNOWN, BillResult, BillRow, - HandoffMaterial, HandoffWorkItem, _BLOCKED_LABELS, _MasterNode, @@ -112,6 +109,7 @@ def _composite_row( in_bill=item.in_bill, ) missing_parts: list[str] = [] + reasons: list[str] = [] money = None for part in item.composite_parts: code = str(part.get("code") or "") @@ -119,12 +117,24 @@ def _composite_row( if not code or amount is None or f"B-{code}" not in unit_prices.book.titles: missing_parts.append(code or str(part.get("name") or "이름 없음")) continue + # 조각도 보통 줄과 같은 두 검사 — 일부 몫만 선 단가·밑수 모르는 표를 묶음에 더하면 + # 묶음 줄만 온전한 금액처럼 섬(2026-09-14 ㉱ 구조 결함). + plain = code.split("#", 1)[0] + covered = unit_prices.partial_ratio.get(plain) + basis = unit_prices.basis_missing.get(plain) + if covered is not None or basis: + missing_parts.append(code) + reasons.append( + f"{code}: 단가 일부만 섬(붙은 몫 {covered}%)" + if covered is not None + else f"{code}: 밑수 미확보 — 원문 {basis}" + ) + continue # 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장). scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1")) money = scaled if money is None else money + scaled row.parts.append((f"B-{code}", amount)) - reasons: list[str] = [] for pending in item.composite_not_ready: # 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다. if isinstance(pending, str): @@ -613,71 +623,6 @@ def pending_formula_note(code: str | None) -> str: return "" -def _material_row( - material: HandoffMaterial, result: BillResult, manual: dict | None = None -) -> BillRow: - """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**. - - `manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림 - (금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`). - """ - row = BillRow( - item_no="", - level=1, - code=None, - name=material.material_name, - spec=material.spec, - unit=material.unit, - quantity=material.total_amount, - ) - # 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다. - row.add_note("quantity", material.surcharge_note) - if material.supply_type == SUPPLY_UNKNOWN: - row.add_note( - "", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." - ) - result.missing.append( - { - "name": material.display_name, - "unit": material.unit, - "quantity": str(material.total_amount), - "reason": "공급 구분 미정(unknown)", - } - ) - return row - # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 - # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 - # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). - if material.supply_type == SUPPLY_OWNER: - row.add_note( - "unit_price_krw", - "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " - "관급자재대(총원가 밖 별도 표기)로 갑니다.", - ) - reason = "관급 자재 단가 없음" - elif f"{material.material_name} {material.spec}".strip() in (manual or {}): - row.add_note( - "unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬" - ) - return row - else: - row.add_note( - "unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기." - ) - reason = "사급 자재 단가 없음(미결 No.18)" - - result.missing.append( - { - "name": material.display_name, - "unit": material.unit, - "quantity": str(material.total_amount), - "reason": reason, - "supply_type": material.supply_type, - } - ) - return row - - #: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다. _UNIT_ALIASES = { "㎥": "m3", diff --git a/resources/tester/test_b09_composite_partial_parts.py b/resources/tester/test_b09_composite_partial_parts.py new file mode 100644 index 00000000..559363cb --- /dev/null +++ b/resources/tester/test_b09_composite_partial_parts.py @@ -0,0 +1,54 @@ +"""조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 2026-09-14 브레인 ㉱ 첫째(구조 결함). + +종전 `_composite_row` 는 조각 제목이 **있기만 하면** 금액에 더했음 → 조각 단가가 일부 몫만 섰거나(인력만) +밑수를 모르는 표여도 조립 줄이 **온전한 금액처럼** 섰음. 보통 줄은 그 둘을 막는데 조립 줄만 새던 자리. +⇒ 조각마다 보통 줄과 같은 두 검사 — 걸리면 금액을 안 세우고 어느 조각이 왜인지 사유. +""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def _composite(parts: list[dict]) -> dict: + return { + "work_items": [ + { + "work_item_code": None, + "name": "시험 묶음", + "spec": "", + "unit": "개소", + "quantity": "1", + "in_bill": True, + "composite_parts": parts, + "composite_not_ready": [], + } + ], + "materials": [], + } + + +def _line(parts: list[dict]): + rows = build_bill(_composite(parts)).rows + return next(r for r in rows if r.code is None and r.name == "시험 묶음") + + +def test_일부만_선_조각이_있으면_묶음_금액을_안_세우고_까닭() -> None: + build = cached_build() + partial = next(c for c in build.partial_ratio if f"B-{c}" in build.book.titles) + line = _line([{"code": partial, "quantity": 1}]) + assert line.amount_krw is None, (partial, line.amount_krw) + assert partial in line.note and "일부만" in line.note, line.note + + +def test_밑수_없는_조각도_같이_막음() -> None: + build = cached_build() + missing = next(c for c in build.basis_missing if f"B-{c}" in build.book.titles) + line = _line([{"code": missing, "quantity": 1}]) + assert line.amount_krw is None and "밑수" in line.note, (missing, line.note) + + +def test_온전한_조각만이면_종전대로_금액() -> None: + line = _line([{"code": "FP-09-15-02", "quantity": 2}]) + assert line.amount_krw and line.amount_krw > 0, line.note