"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종). **왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안, 「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서 위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는 검사**를 두어, 규칙을 어기면 계산이 멈추게 한다. 세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢) ㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다 (품셈 1-3-1 「할증 중복 적용 금지」). ㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미 포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6). ㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래 분해는 일위대가에서 한 번만 한다. """ from __future__ import annotations from decimal import Decimal _TOLERANCE = Decimal("0.5") class DoubleCountError(AssertionError): """이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다.""" def check_surcharge_once( *, material_summary_total: Decimal, unit_price_material_total: Decimal, surcharge_rate_percent: Decimal, label: str = "자재", ) -> None: """㉠ 할증이 두 번 붙지 않았는가. `material_summary_total` = 자재총괄의 **할증 포함** 합계. `unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계. 둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다. """ if unit_price_material_total <= 0: return expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100)) if material_summary_total > expected + _TOLERANCE: raise DoubleCountError( f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > " f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) " f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)." ) def check_free_haul_not_priced( *, haul_rows: list[dict], equipment_field: str = "equipment", unit_price_field: str = "unit_price_krw", free_haul_equipment: str = "free_haul", ) -> None: """㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가. 줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재). 금지되는 것은 **단가를 붙이는 것**이다. """ for row in haul_rows: if row.get(equipment_field) != free_haul_equipment: continue price = Decimal(str(row.get(unit_price_field) or 0)) if price != 0: raise DoubleCountError( f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — " "소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)." ) def check_haul_volume_within_cut( *, haul_volume_total_m3: Decimal, total_cut_volume_m3: Decimal, ) -> None: """㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가. 무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다. """ if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE: raise DoubleCountError( f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 " f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 " "(PLAN 8-7 ㉡)." ) def check_mix_decomposed_once( *, cement_total_kg: Decimal, concrete_volume_m3: Decimal, cement_per_m3_kg: Decimal, ) -> None: """㉢ 콘크리트를 두 번 쪼개지 않았는가. 시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을 일위대가가 또 분해한 것이다. """ if concrete_volume_m3 <= 0: return expected = concrete_volume_m3 * cement_per_m3_kg if cement_total_kg > expected + _TOLERANCE: raise DoubleCountError( f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × " f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 " "쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)." )