feat(b09): 300 발파 9-5-1 — 화약류(폭약·뇌관·비트) 자재 단가 칸 · 설계자 단가가 들면 표 수량으로 붙고 아니면 「규격 미정」 사유 · 착암기는 공기압축기 부수물(건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」)이라 압축기 손료에 안 들고 래그해머 손료 고시 없음 사유 · 잡재료 5% 사유(브레인 판정)

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 20:32:30 +09:00
co-authored by Claude Opus 5
parent c7f34b8c4b
commit 09247fd16f
3 changed files with 117 additions and 0 deletions
@@ -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
@@ -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(
+28
View File
@@ -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