From 09247fd16f91c846da0147682a494d67f45834f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 20:32:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20300=20=EB=B0=9C=ED=8C=8C=209-5-1?= =?UTF-8?q?=20=E2=80=94=20=ED=99=94=EC=95=BD=EB=A5=98(=ED=8F=AD=EC=95=BD?= =?UTF-8?q?=C2=B7=EB=87=8C=EA=B4=80=C2=B7=EB=B9=84=ED=8A=B8)=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EB=8B=A8=EA=B0=80=20=EC=B9=B8=20=C2=B7=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EC=9E=90=20=EB=8B=A8=EA=B0=80=EA=B0=80=20=EB=93=A4?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=20=EC=88=98=EB=9F=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=99=EA=B3=A0=20=EC=95=84=EB=8B=88=EB=A9=B4=20=E3=80=8C?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=EB=AF=B8=EC=A0=95=E3=80=8D=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=C2=B7=20=EC=B0=A9=EC=95=94=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B3=B5=EA=B8=B0=EC=95=95=EC=B6=95=EA=B8=B0=20=EB=B6=80?= =?UTF-8?q?=EC=88=98=EB=AC=BC(=EA=B1=B4=EC=84=A4=ED=92=88=EC=85=88=208-3-6?= =?UTF-8?q?=20(5205)=20[=EC=A3=BC]=E2=91=A0=20=E3=80=8C=EB=B6=80=EC=88=98?= =?UTF-8?q?=EB=AC=BC=EC=9D=80=20=EB=B3=84=EB=8F=84=20=EA=B3=84=EC=83=81?= =?UTF-8?q?=E3=80=8D)=EC=9D=B4=EB=9D=BC=20=EC=95=95=EC=B6=95=EA=B8=B0=20?= =?UTF-8?q?=EC=86=90=EB=A3=8C=EC=97=90=20=EC=95=88=20=EB=93=A4=EA=B3=A0=20?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=ED=95=B4=EB=A8=B8=20=EC=86=90=EB=A3=8C=20?= =?UTF-8?q?=EA=B3=A0=EC=8B=9C=20=EC=97=86=EC=9D=8C=20=EC=82=AC=EC=9C=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A1=EC=9E=AC=EB=A3=8C=205%=20=EC=82=AC=EC=9C=A0(?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8=20=ED=8C=90=EC=A0=95)?= 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 --- B09_Estimation/B09_Estimation_Explosives.py | 85 +++++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 4 + resources/tester/test_b09_explosives.py | 28 +++++++ 3 files changed, 117 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_Explosives.py create mode 100644 resources/tester/test_b09_explosives.py diff --git a/B09_Estimation/B09_Estimation_Explosives.py b/B09_Estimation/B09_Estimation_Explosives.py new file mode 100644 index 00000000..74824d84 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Explosives.py @@ -0,0 +1,85 @@ +"""B09 원가계산 — **발파 화약류 자재**(9-5-1 · 2026-09-14 브레인 300 판정). + +표 F0243 「폭약 kg 0.35 · 뇌관 개 1.0 · 비트 개 0.008」 은 **규격을 안 적음** — 자원 목록(`AR-M`) +항목은 있으나 조인 규칙(규격이 같아야 · 후보 하나여도 자동 안 고름)에 걸려 못 붙었음. +⇒ 치즐과 같은 모양: 「자재 단가」 탭에 칸이 서고 **설계자가 규격·단가를 넣으면** 표 수량으로 붙음. + 안 넣으면 「규격 미정」 사유로 못 붙은 줄에 남음(임의로 규격을 고르지 않음). +⚠ 잡재료비 「주재료의 5%」(폭약 줄 비고)는 아직 안 걺 — 사유에 적음. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +#: 공종 → (표 이름, 자원 목록 코드). 표 이름은 공백을 지운 글. +EXPLOSIVES: dict[str, tuple[tuple[str, str], ...]] = { + "FP-09-05-01": ( + ("폭약", "AR-M-6fc2930f"), + ("뇌관", "AR-M-0965627d"), + ("비트", "AR-M-a1a18ec4"), + ), +} +EXPLOSIVE_MISSING = ( + "{name} — 규격 미정(원문 표가 규격을 안 적음) · 「자재 단가」 탭에서 규격·단가를 넣으면 붙음" +) +MISC_NOTE = "ⓘ 폭약 줄 비고 「잡재료비: 주재료의 5%」 는 아직 안 걺" +#: 착암기 — 건설품셈 8-3-6 (5205) 공기압축기 손료표 [주]① 「부수물(호스포함)은 별도 계상한다」 · +#: 부수물 관계표에 「래그 해머 2.7㎥/min」 · 래그해머 손료표는 고시 없음(2026-09-14 안티그래비티 · +#: 원문 L2646~2683). 압축기 손료에 든다고 **정하지 않음** — 원문이 「별도」. +LEG_HAMMER = "착암기2.7㎥/min" +LEG_HAMMER_NO_LOSS = ( + "착암기 2.7㎥/min — 공기압축기의 부수물 「래그 해머」(건설품셈 8-3-6 (5205) [주]① 「부수물은" + " 별도 계상」)이라 압축기 손료에 안 듦 · 래그해머 손료표는 원문에 고시 없음 → 손료를 못 셈" +) + + +def _table_amount(node: dict[str, Any], name: str) -> Decimal | None: + """표에서 그 이름 줄의 첫 수 — 없으면 `None`.""" + from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount + + for table in node.get("tables") or []: + for row in table.get("raw_row") or []: + cells = [str(cell) for cell in row] + names = ["".join(cell.split()) for cell in cells] + if name not in names: + continue + index = names.index(name) + value = next((parse_amount(c) for c in cells[index + 1 :] if parse_amount(c)), None) + if value is not None: + return value + return None + + +def attach_explosives(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None: + """칸을 세우고(`material_uses`) · 단가가 든 것은 붙이고 · 안 든 것은 사유로 갈아 끼움.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + for code, items in EXPLOSIVES.items(): + node = nodes_by_code.get(code) or {} + labels = list(build.unattached.get(code) or []) + for name, material in items: + build.material_uses.setdefault(material, []) + if code not in build.material_uses[material]: + build.material_uses[material].append(code) + amount = _table_amount(node, name) + labels = [label for label in labels if "".join(label.split()) != name] + if amount is None: + continue + titles = [ + t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#") + ] + if material in build.book.titles and titles: + for title in titles: + build.book.add_detail( + PriceDetail(title, material, amount, note=f"{name} — 설계자 규격·단가") + ) + else: + labels.append(EXPLOSIVE_MISSING.format(name=name)) + if MISC_NOTE not in labels: + labels.append(MISC_NOTE) + labels = [ + LEG_HAMMER_NO_LOSS if "".join(label.split()) == LEG_HAMMER else label + for label in labels + ] + build.unattached[code] = labels diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 4d5570d3..24f5169f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1077,6 +1077,10 @@ def build_unit_prices( # 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」). attach_dump_hauls(build, master, dump_haul_m) + # 발파 화약류(9-5-1) — 규격 미정이라 설계자 단가가 들면 붙고 아니면 사유(2026-09-14 300). + from B09_Estimation.B09_Estimation_Explosives import attach_explosives + + attach_explosives(build, nodes_by_code) # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. build.combined_swapped = _apply_combined_misc_rate( diff --git a/resources/tester/test_b09_explosives.py b/resources/tester/test_b09_explosives.py new file mode 100644 index 00000000..abcd3d00 --- /dev/null +++ b/resources/tester/test_b09_explosives.py @@ -0,0 +1,28 @@ +"""발파 화약류(9-5-1) — 2026-09-14 브레인 300: 규격 미정은 설계자 선택 + 사유 · 값은 수동 단가.""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of + + +def test_단가가_없으면_규격_미정_사유와_자재_단가_칸() -> None: + build = cached_build() + labels = build.unattached.get("FP-09-05-01") or [] + assert any("뇌관 — 규격 미정" in label for label in labels), labels + assert not any(label.replace(" ", "") == "뇌관" for label in labels), labels + assert "FP-09-05-01" in build.material_uses.get("AR-M-0965627d", []) + + +def test_설계자_단가가_들면_표_수량으로_붙음() -> None: + build = cached_build(material_prices=(("AR-M-0965627d", "500", "견적"),)) + rows = {r["ref_code"]: float(r["quantity"]) for r in detail_of(build, "B-FP-09-05-01")["rows"]} + assert rows.get("AR-M-0965627d") == 1.0, rows + labels = build.unattached.get("FP-09-05-01") or [] + assert not any("뇌관" in label for label in labels), labels + + +def test_착암기는_압축기_부수물이라_손료_고시_없음_사유() -> None: + """건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」 — 압축기 손료에 든다고 안 정함.""" + labels = cached_build().unattached.get("FP-09-05-01") or [] + note = next((label for label in labels if label.startswith("착암기")), "") + assert "부수물" in note and "고시 없음" in note, labels