"""부모 공종 모양 `parent_mode` (명세 2장 · 2026-09-13 축 C Ⓐ). 지키는 것 ① 마스터 — 부모는 기본 choose_one, 합산형(9-4·9-5·12-38)만 사람이 적은 단계·가중치를 든다 ② 흙깎기 리핑암 = 9-4 암절취 = 암파쇄(암질별 1/Q) + 집토 — 부모 제목이 잎들의 합으로 선다 ③ 내역 줄의 암 갈래가 그대로 갈래 값으로 가고, 표에 없는 암질은 9-4-1 [주]① 평균으로만 선다 ④ 단계 하나라도 못 서면 부모를 안 세우고 사유 · 갈래 고르기형 부모를 가리키면 오류로 막는다 """ from __future__ import annotations from decimal import Decimal from functools import lru_cache from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_work_item_master from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices @lru_cache(maxsize=1) def _build(): return build_unit_prices() def _node(code: str) -> dict: return next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == code) def test_마스터_부모_모양은_기본_고르기_합산형만_단계를_든다(): assert _node("FP-10-12")["parent_mode"] == "choose_one" rock = _node("FP-09-05") assert rock["parent_mode"] == "sum_steps" assert [(s["code"], s["weight"]) for s in rock["steps"]] == [ ("FP-09-05-01", "0.1"), ("FP-09-05-02", "0.9"), ("FP-09-05-03", "1"), ] assert "parent_mode" not in _node("FP-09-04-01") # 잎 def test_암절취는_암파쇄와_집토의_합으로_선다(): book = _build().book # 층 차례 X → D → B — 잎 일위대가는 D(단가산출)를 1 로 부르고, 기계 1/Q 줄은 D 에 삶. assert [d.ref_code for d in book.details["B-FP-09-04-01#연암"]] == ["D-FP-09-04-01#연암"] leaf = book.details["D-FP-09-04-01#연암"] assert {d.ref_code for d in leaf} == {"X-0201-0070#조합", "X-0230-0007"} # 조합 16 % assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) whole = book.resolve("B-FP-09-04#연암").total parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total # 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합. assert parts > 0 and abs(whole - parts) < Decimal("1e-15") assert "B-FP-09-04#평균" in book.titles # [주]① 평균 def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다(): build = _build() assert not any(code.startswith("B-FP-09-05#") for code in build.book.titles) assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음 assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 — 임대료·손료 단가 안 넣음 # 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음) assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에 def _bill(rock: str, method: str = "ripping"): handoff = build_handoff( summary_table={ "rows": [{"group": "흙깎기", "item": rock, "spec": "", "unit": "㎥", "amount": 10.0}] }, ground_methods={rock: method}, ) return handoff["work_items"][0], build_bill(handoff, build=_build()) def test_암_갈래가_그대로_단가를_고른다(): row, bill = _bill("보통암") assert (row["work_item_code"], row["variant_value"]) == ("FP-09-04", "보통암") priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") assert priced.unit_price_krw and "보통암" in priced.spec def test_표에_없는_암질은_주1_평균으로만_선다(): _row, bill = _bill("풍화암") priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") assert priced.unit_price_krw and "[주]①" in priced.note def test_발파암은_단계_사유로_막히고_고르기형_부모는_오류(): _row, bill = _bill("경암", "blasting") assert any("단계 합산 미완" in m["reason"] for m in bill.missing) handoff = { "work_items": [{**_row, "work_item_code": "FP-10-12", "variant_value": None}], "materials": [], } blocked = build_bill(handoff, build=_build()) assert any("갈래 고르기형 부모" in m["reason"] for m in blocked.missing) def test_깨기_브레이커는_굴착기_몸체_시간과_함께_붙는다(): """9-12·9-13 「깨기 대형브레이커」 — 부착 장비라 몸체도 같은 1/Q 로(2026-09-13 판정).""" details = _build().book.details["D-FP-09-13-07"] # 작업량 줄은 D(단가산출)에 breaker = next(d for d in details if d.ref_code == "X-0230-0007") assert breaker.quantity == Decimal(1) / Decimal("3.5") * Decimal("0.9") # 장비 90 % 몫 breaking = [d for d in details if d.quantity == breaker.quantity] assert {d.ref_code for d in breaking} == {"X-0230-0007", "X-0201-0070#조합"}