From d6820db7b02f9dc66993d22456b6a0f13e8cbece Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:55:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(b09):=20=EB=82=B4=EC=97=AD=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=EC=9D=84=201-2-2=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=AC=EB=A6=BC=ED=95=B4=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=ED=95=9C=20=EB=92=A4=20=EA=B8=88=EC=95=A1(=EB=B2=84=EB=A6=BC)?= =?UTF-8?q?=20=E2=80=94=20=ED=91=9C=EC=97=90=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EA=B8=B0=EB=B3=B8=202=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=C2=B7=20=EC=B2=A0=EA=B7=BC=20ton=20=EC=86=8C=EC=88=98=203?= =?UTF-8?q?=EC=9E=90=EB=A6=AC(kg=20=EC=A0=95=EC=88=98=20=ED=99=98=EC=82=B0?= =?UTF-8?q?)=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EB=8C=80=EC=86=8C=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=20=ED=9D=A1=EC=88=98=20=C2=B7=20STmate=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EC=88=98=EB=9F=89=20321=EC=A4=84=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- ...9_Estimation_BillOfQuantities_Materials.py | 4 ++- .../B09_Estimation_BillOfQuantities_Rows.py | 18 +++++++++-- .../B09_Estimation_QuantityDigits.py | 31 ++++++++++++------- resources/tester/test_b09_material_prices.py | 29 +++++++++++++++-- .../tester/test_b09_quantity_digits_golden.py | 29 +++++++++++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_b09_quantity_digits_golden.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py index 62d6b98e..ce8063a6 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 0a3a4928..6a0f97ff 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -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) # 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상. diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py index edd3363c..f5c4d3e2 100644 --- a/B09_Estimation/B09_Estimation_QuantityDigits.py +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -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 diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py index 2ece9f5f..0bb875d0 100644 --- a/resources/tester/test_b09_material_prices.py +++ b/resources/tester/test_b09_material_prices.py @@ -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 diff --git a/resources/tester/test_b09_quantity_digits_golden.py b/resources/tester/test_b09_quantity_digits_golden.py new file mode 100644 index 00000000..e241fe2e --- /dev/null +++ b/resources/tester/test_b09_quantity_digits_golden.py @@ -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]) From 298133dce40355183e31e134733c3937a53ce85a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:57:31 +0900 Subject: [PATCH 2/4] =?UTF-8?q?knowledge(=EC=B6=95C=20=EB=AA=85=EC=84=B8):?= =?UTF-8?q?=20=EA=B2=80=EC=82=B0=20=EA=B3=84=EC=95=BD=EC=97=90=20=E3=80=8C?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9D=B4=20=EB=B3=80=EA=B2=BD=EC=9D=84=20?= =?UTF-8?q?=EC=9E=B4=20=EC=88=98=20=EC=9E=88=EB=8A=94=EC=A7=80=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EB=B3=BC=20=EA=B2=83=E3=80=8D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 골든 금액 시험이 원본 수량을 bill_line 에 바로 넣어 새 길을 안 거침 — 초록이 증거가 아니던 자리 - 원본 321줄 자리 규칙 대조를 따로 만들어 철근 Ton 소수 3자리 4줄을 뽑아냄 - 재지 못한 것을 잰 척하지 않을 것 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index a9e4d7bd..994020cd 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -424,6 +424,21 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 **STmate 골든셋 재현이 초록이면 끝.** +### ⚠⚠ 2026-09-14 보강 — 초록이 늘 증거인 것은 아님 + +**판정 기준을 받으면, 그 기준이 이 변경을 실제로 잴 수 있는지부터 볼 것.** +못 재면 그 자리에서 말하고 **잴 수 있는 것을 새로 만들 것.** 브레인이 준 기준도 마찬가지임. + +드러난 자리 — 인계 수량 자리 맞춤(1-2-2 [주]① 반올림) 때 브레인이 「골든셋 전수 초록」을 +판정 기준으로 줬으나, **골든 금액 시험은 STmate 원본 수량을 `bill_line` 에 바로 넣어 +새 길을 안 거침.** 초록이어도 증거가 아니었음. 랩탑 메인이 이를 알아채고 +**원본 내역 수량 321줄을 자리 규칙에 통과시키는 대조**를 따로 만들어 4줄 빨강을 뽑아냄 +(영월·울진 철근·철근운반 `Ton` 0.264·0.008 을 기본 2자리가 자름 → 철근 kg 정수 = ton 소수 3자리 +환산 + 단위 대소문자 흡수로 321/321). 그 대조를 `test_b09_quantity_digits_golden.py` 로 남김. + +**재지 못한 것을 잰 척하지 않을 것** — 같은 보고에 「원본 수량 대부분이 정수라 자리 규칙 +전체를 입증하진 못함」이라고 한계를 그대로 적었음. 그것이 옳은 모양임. + - 기준점 — 실무 6건 · 내역 1,515행을 **원 단위로 재현한 실증본** (`…/STmate 분석/20_분석/16_골든셋_회귀_결과.md`) - 시험 자리 — `resources/tester/` (git 추적 대상) From beb30999e3b24c0084a8cd42d37b1f2702b1b860 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:57:37 +0900 Subject: [PATCH 3/4] auto: 2026-09-14 11:57 (EOMSANGDON-HOME) --- main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/main.py b/main.py index a682f00f..ad281e75 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,9 @@ from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router +from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( + router as b09_material_prices_router, +) from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -648,6 +651,7 @@ app.include_router(b09_execution_router, dependencies=protected_with_company) app.include_router(b09_progress_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) +app.include_router(b09_material_prices_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) From acb377f5dbbf28312262a9a74318a811c3e1b7f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:09:18 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(b08):=20=EC=B2=A0=EA=B7=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=C2=B7=EA=B7=9C=EA=B2=A9=20=EA=B0=80=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?(=EC=9D=B4=ED=98=95=EC=B2=A0=EA=B7=BC=20+=20D13=C2=B7D16)=20?= =?UTF-8?q?=E2=80=94=20=EB=8C=80=EC=9D=91=ED=91=9C=20=EC=A1=B0=EA=B0=81?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=EB=A6=84+=EA=B7=9C=EA=B2=A9=20=ED=82=A4?= =?UTF-8?q?=C2=B7=EC=9D=B4=EB=A6=84=20=EB=91=90=20=EC=83=89=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A7=91=EA=B3=A0=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EB=8F=84=20=EB=91=90=20=ED=82=A4=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=EC=B8=A1=20=EC=9B=90=EB=8B=A8=EC=9C=84=20spe?= =?UTF-8?q?c=20=EC=B9=B8=EC=9D=84=20=EB=84=98=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=A0=84=ED=9B=84=20=EB=AC=BC=EB=9F=89=20=EB=8C=80=EC=A1=B0=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98(=EA=B0=80=EB=93=9C=20=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EB=A9=B4=20=EB=B9=A8=EA=B0=95=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Boundaries.py | 3 + .../B08_Quantity_Engine_Handoff_Mapping.py | 22 ++-- .../B08_Quantity_Engine_ObservedUnit.py | 2 + .../B08_Quantity_Engine_UnitQuantity_Base.py | 5 +- ..._Quantity_Engine_UnitQuantity_Revetment.py | 5 +- .../structure_unit_observed_2026-01-01.json | 9 +- .../tester/test_b08_destination_no_default.py | 3 +- resources/tester/test_b08_rebar_name_spec.py | 116 ++++++++++++++++++ 8 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_b08_rebar_name_spec.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py index c4e0ad5b..f1ee3631 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -72,6 +72,9 @@ def verify_double_count_boundaries( name = str(component.get("name") or "").strip() destination = str(component.get("destination") or "") destinations.setdefault(name, set()).add(destination) + # 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂. + key = f"{name} {str(component.get('spec') or '').strip()}".strip() + destinations.setdefault(key, set()).add(destination) if name in EARTHWORK_ONLY and destination != "earthwork": found.append( f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — " diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 40b2be44..4d8fa446 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -327,10 +327,18 @@ def composite_quantities( note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" return [], [{"code": None, "reason": note}] - amounts: dict[str, tuple[float, str]] = {} - for component in structure.get("components") or []: + # ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두 + # 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**. + by_key: dict[str, float] = {} + by_name: dict[str, float] = {} + key_of: dict[int, str] = {} + for index, component in enumerate(structure.get("components") or []): name = str(component.get("name") or "").strip() - amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) + key = f"{name} {str(component.get('spec') or '').strip()}".strip() + amount = float(component.get("amount") or 0.0) + by_key[key] = by_key.get(key, 0.0) + amount + by_name[name] = by_name.get(name, 0.0) + amount + key_of[index] = key kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) parts: list[dict[str, Any]] = [] @@ -340,14 +348,14 @@ def composite_quantities( parts.append({"code": str(spec)}) continue sources = list(spec.get("from_components") or []) - found = [name for name in sources if name in amounts] - total = sum(amounts[name][0] for name in found) + found = [name for name in sources if name in by_key or name in by_name] + total = sum(by_key[name] if name in by_key else by_name[name] for name in found) if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": total *= kg_to_ton kinds = { component.get("basis_kind") - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in found + for index, component in enumerate(structure.get("components") or []) + if key_of[index] in found or str(component.get("name") or "").strip() in found } suffix = spec.get("kind_suffix") entry: dict[str, Any] = { diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index b8ba88ed..a29c376f 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -304,6 +304,8 @@ def expand_observed( + (f" ({note})" if note else ""), "basis_kind": BASIS_OBSERVED, "source": source_key, + # 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기). + "spec": str(item.get("spec") or ""), } ) notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"] diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index 8b8aabc5..0fbf4bac 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -53,9 +53,8 @@ DESTINATION = { RUBBLE_BASE_NAME: "unit_price", # ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄). # 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게). - # ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가 - # 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로). - "이형철근 D13": "material", + # 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음). + "이형철근": "material", "유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품 "면목": "material", "떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 3b84fb2b..b376f997 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -439,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { 0.1508, "{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1", ), - ("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"), + ("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로 ("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"), ("면목", "m", 1.0, "1"), ), @@ -540,8 +540,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component per_m * length_m, destination, f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})", + spec="".join(spec), ) - for name, unit, per_m, basis in table["rows"] + for name, unit, per_m, basis, *spec in table["rows"] if (destination := routed(name, notes)) ] if not options.get("ditch_spec"): diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index c0cf2cfe..85f1bfda 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -86,13 +86,15 @@ "basis_note": "Ø50" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", "amount": 13.45, "destination": "material" }, { - "name": "이형철근 D16", + "name": "이형철근", + "spec": "D16", "unit": "kg", "amount": 30.42, "destination": "material" @@ -218,7 +220,8 @@ "basis_note": "원문 값(라이브러리 표기 21.28)" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", "amount": 4.776, "destination": "material", diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py index 8e540db1..e604b41a 100644 --- a/resources/tester/test_b08_destination_no_default.py +++ b/resources/tester/test_b08_destination_no_default.py @@ -45,7 +45,8 @@ def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: def test_드러난_넷이_제자리로_감() -> None: ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) got = {component.name: component.destination for component in ditch} - assert got["이형철근 D13"] == "material" and got["면목"] == "material" + assert got["이형철근"] == "material" and got["면목"] == "material" + assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로 assert not [note for note in notes if "갈 곳이 표" in note] plain, _ = open_ditch(10.0, {}) assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" diff --git a/resources/tester/test_b08_rebar_name_spec.py b/resources/tester/test_b08_rebar_name_spec.py new file mode 100644 index 00000000..0a7f0cd4 --- /dev/null +++ b/resources/tester/test_b08_rebar_name_spec.py @@ -0,0 +1,116 @@ +"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼. + +「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어 +하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]` +(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂) +③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함. + + 옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏ + 집수정 □형 Ø800 1개소 D13 4.776 ㎏ + L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏ +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402 + verify_double_count_boundaries, +) +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 + + +def _구조물들() -> dict: + return build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + }, + { + "structure_id": "b1", + "type_id": "pipe_inlet_basin", + "start_m": 200.0, + "end_m": 200.0, + "options": { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800", + }, + }, + { + "structure_id": "d1", + "type_id": "open_ditch", + "start_m": 300.0, + "end_m": 310.0, + "options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0}, + }, + ], + {"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"}, + ) + + +def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None: + row = next( + r + for r in build_handoff(unit_quantity_table=_구조물들())["work_items"] + if r.get("composite_parts") + ) + rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code"))) + assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready") + from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind + + wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall") + assert structure_kind(wall) == "철근구조물" + + +def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None: + table = build_table(_구조물들()) + rebar = { + row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"]) + for row in table["rows"] + if row["supply_key"].startswith("이형철근") + } + assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자 + assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98) + assert rebar["이형철근 D16"][0] == pytest.approx(304.2) + assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근 + assert "이형철근" not in " ".join(table["missing_rate_materials"]) + + +def test_경계_검사가_철근을_여전히_집는다() -> None: + """철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂.""" + unit = copy.deepcopy(_구조물들()) + wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall") + for component in wall["components"]: + if str(component["name"]).startswith("이형철근"): + component["destination"] = "earthwork" + found = verify_double_count_boundaries(unit, None, load_mapping()) + assert any("이형철근" in text and "묶음 조각" in text for text in found), found + assert not verify_double_count_boundaries(_구조물들(), None, load_mapping()) + + +def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None: + table = build_table(_구조물들()) + rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")] + assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")] + unit = _구조물들() + specs = { + (c["name"], c.get("spec")) + for s in unit["structures"] + for c in s["components"] + if str(c["name"]).startswith("이형철근") + } + assert specs == {("이형철근", "D13"), ("이형철근", "D16")}