# -*- coding: utf-8 -*- """B09 ⑤ 엔진 — 실무 서류 재현 검산 고정값 두 벌 + 거울 테스트 3종. 목적은 **구조 검증**이지 실무 금액 추종이 아니다 (PLAN 8-10 ★ 사용자 확정: 법대로). ① **2024 요율** 로 돌리면 그 해 서류가 재현되는가 → 「연도 교체 구조가 실제로 도는가」 ② **현행 요율** 로 돌리면 규정대로 다른 값이 나오는가 ③ 안전관리비 A/B min 이 **양방향**으로 도는가 (울진 A 채택 · 거창 B 채택) 고정값 출처 = PLAN 8-9 · 8-10 · 8-12. ⚠ 2026-09-14 `tmp/tests/` 에서 **여기로 옮겨 옴**(브레인 지시) — 시험은 git 안에 둬야 다른 창이 돌림. `tmp/tests/` 쪽 사본은 지우지 않음 (tmp 는 손대지 않는 자리) — **같은 파일이 두 벌** 있음. ⚠ 짝 벌과 **겹치는 자리** — `test_b09_golden_cost_sheet.py` 가 울진 2024 를 **XLSX 시트에서** 되풀므로 ① 의 금액 사슬과 겹침. 여기는 **고정값**, 저기는 **원본 칸**이라 되짚는 길이 달라 둘 다 둠(정리는 사용자 몫). ⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 「안 켠 값」을 보는 자리는 `cut_basis="none"` 으로 손수 끈다. """ import os from decimal import Decimal import pytest from B09_Estimation.B09_Estimation_Engine_Cost import ( CostInput, calculate_cost, proposed_profit_adjustment, ) from B09_Estimation.B09_Estimation_Guards import ( DoubleCountError, check_free_haul_not_priced, check_haul_volume_within_cut, check_mix_decomposed_once, check_surcharge_once, ) FIXTURE_2024 = os.path.join( os.path.dirname(os.path.abspath(__file__)), "fixtures", "rates_2024_uljin.json" ) def _won(v) -> Decimal: return Decimal(str(v)) # 울진 공통(2024) 원가계산서 입력 3종 — PLAN 8-9 ULJIN_DIRECT_MATERIAL = _won(194_234_417) ULJIN_DIRECT_LABOR = _won(346_800_554) ULJIN_DIRECT_EXPENSE = _won(169_324_165) ULJIN_OWNER_MATERIAL = _won(69_474_220) # 순자재대 (수수료 375,160 별도) ULJIN_PROCUREMENT_FEE = _won(375_160) def _uljin(**overrides) -> CostInput: base = dict( direct_material_krw=ULJIN_DIRECT_MATERIAL, direct_labor_krw=ULJIN_DIRECT_LABOR, direct_expense_krw=ULJIN_DIRECT_EXPENSE, owner_supplied_material_krw=ULJIN_OWNER_MATERIAL, procurement_fee_krw=ULJIN_PROCUREMENT_FEE, duration_days=183, ) base.update(overrides) return CostInput(**base) # -------------------------------------------------------------------------- # ① 2024 요율 재현 — 16/16 금액 사슬 # -------------------------------------------------------------------------- def _uljin_2024() -> CostInput: """그 해 서류를 그대로 재현하려면 그 해 관행 처리 둘을 켜야 한다. 2024 요율 파일과 그 해 연금 요율만 갈아끼우면 된다 — **관행 옵션은 필요 없다**. 관급은 **순자재대 69,474,220 + 수수료 375,160** 으로 나눠 넣으면 관급자재대 `ROUNDUP(69,849,380, −3) = 69,850,000` 과 안전관리비 관급항 `69,474,220 ÷ 1.1` 이 둘 다 저절로 맞는다. """ return _uljin( rate_file_path=FIXTURE_2024, pension_year=2024, profit_adjustment_krw=_won(905_980), ) def test_uljin_2024_chain_reproduced(): """2024 요율로 돌리면 그 해 원가계산서가 재현된다 — 금액 사슬 전건.""" r = calculate_cost(_uljin_2024()) assert r.amount("indirect_labor_cost") == _won(50_286_080) assert r.amount("industrial_accident_insurance") == _won(14_136_284) assert r.amount("employment_insurance") == _won(4_010_575) assert r.amount("health_insurance") == _won(12_294_079) assert r.amount("long_term_care_insurance") == _won(1_592_083) assert r.amount("national_pension") == _won(15_606_024) assert r.amount("safety_management_cost") == _won(16_586_996) assert r.amount("other_expense") == _won(35_479_263) assert r.amount("environment_preservation") == _won(5_682_873) assert r.amount("equipment_payment_guarantee") == _won(2_841_436) assert r.totals["expense"] == _won(277_553_778) assert r.totals["net_construction_cost"] == _won(868_874_829) assert r.amount("general_overhead") == _won(52_132_489) assert r.amount("profit_before_adjustment") == _won(109_015_935) assert r.amount("profit") == _won(108_109_955) assert r.totals["total_cost"] == _won(1_029_117_273) assert r.amount("vat") == _won(102_911_727) assert r.totals["contract_amount"] == _won(1_132_029_000) assert r.amount("owner_supplied_material_total") == _won(69_850_000) assert r.totals["grand_total"] == _won(1_201_879_000) def test_uljin_2024_safety_adopts_a(): """2024 울진은 A 채택 — A 16,586,996 < B 18,494,700.""" r = calculate_cost(_uljin_2024()) assert r.amount("safety_management_cost_a") == _won(16_586_996) assert r.amount("safety_management_cost_b") == _won(18_494_700) assert r.amount("safety_management_cost") == r.amount("safety_management_cost_a") def test_uljin_2024_item_list_comes_from_data(): """비목 목록은 코드가 아니라 **요율 데이터**가 정한다 (PLAN 8-13). 2024 픽스처에는 퇴직공제부금비가 없으므로 그 줄이 서지 않는다. """ r = calculate_cost(_uljin_2024()) assert not r.has("retirement_mutual_aid") assert not r.has("wage_claim_contribution") assert r.has("environment_preservation") # -------------------------------------------------------------------------- # ② 현행 요율 — 같은 입력, 규정대로 다른 값 # -------------------------------------------------------------------------- def test_owner_supplied_input_is_net_material_not_total(): """⚠ 관급 입력은 **순자재대**다 — 실무 서류의 「관급자재대」를 그대로 넣으면 틀린다. 울진: 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → 천원 올림 **69,850,000**. 안전관리비 관급항은 **순자재대만** 부가세 제외로 환산(69,474,220 ÷ 1.1). 합계를 순자재대 자리에 넣으면 안전관리비가 8,629 원 커진다. """ right = calculate_cost(_uljin()) wrong = calculate_cost(_uljin(owner_supplied_material_krw=_won(69_849_380))) assert right.amount("owner_supplied_material_total") == _won(69_850_000) assert right.amount("safety_management_cost") == _won(18_586_091) assert wrong.amount("safety_management_cost") - right.amount("safety_management_cost") == _won( 8_629 ) def test_uljin_current_rates_safety_18_586_091(): """현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10). A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091 B 버림(대상액 541,034,971 × 3.15 %) × 1.2 = 20,385,820 → A 채택 ⚠ 2026-09-14 B 가 1원 내려감 — 1.2배의 대상을 **호별 산정이 끝난 금액**으로 바꿨음 (고시 제4조① 단서 문언 · STmate 영월 실증). 여기 값은 현행 요율이라 실무 대조본이 없음. """ r = calculate_cost(_uljin()) assert r.amount("safety_management_cost_a") == _won(18_586_091) assert r.amount("safety_management_cost_b") == _won(20_385_820) assert r.amount("safety_management_cost") == _won(18_586_091) def test_year_swap_changes_result_but_not_structure(): """같은 입력·같은 코드, 요율만 갈아끼우면 값이 달라진다 — 교체 구조가 도는 증거.""" old = calculate_cost(_uljin_2024()) new = calculate_cost(_uljin()) assert old.amount("safety_management_cost") != new.amount("safety_management_cost") assert old.amount("indirect_labor_cost") != new.amount("indirect_labor_cost") assert old.rate_version["effective_date"] == "2024-01-01" assert new.rate_version["effective_date"] == "2026-04-13" # 픽스처는 지문이 없어 정본이 아님이 드러난다. assert old.rate_version["sha256"] == "" assert len(new.rate_version["sha256"]) == 64 def test_current_rates_add_items_absent_in_2024(): """현행 요율 판에는 2024 에 없던 비목이 붙는다 (PLAN 8-14 사용자 「전부 필요」).""" r = calculate_cost(_uljin()) for key in ("retirement_mutual_aid", "wage_claim_contribution", "asbestos_contribution"): assert r.has(key), key # -------------------------------------------------------------------------- # ③ 안전관리비 A/B — 양방향 # -------------------------------------------------------------------------- def test_geochang_adopts_b(): """거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」. ⚠⚠ **두 실증본이 갈림**(2026-09-14) — 1.2배를 언제 곱하나. STmate(영월 2024) `버림(밑수 × 율) × 1.2` 뒤 버림 → 영월 B 20,330,638 = 원본 거창 원본(다른 프로그램) `버림(밑수 × 율 × 1.2)` → 거창 B 12,240,137 거창 원가계산서는 시트 이름이 「원가계산서」로 **STmate 출력이 아님**(줄 차례도 다름). 우리 목표가 STmate 골든셋 재현이라 **STmate 쪽으로 맞춤** — 그래서 거창 B 가 1원 내려감. ⇒ 발주처가 거창 쪽 셈을 요구하면 갈래를 둬야 함. **판정 대기(브레인)**. """ r = calculate_cost( CostInput( direct_material_krw=_won(80_165_010), direct_labor_krw=_won(243_648_150), direct_expense_krw=_won(0), owner_supplied_material_krw=_won(74_634_214), enabled_items=("safety_management_cost",), ) ) assert r.amount("safety_management_cost_a") == _won(12_337_367) assert r.amount("safety_management_cost_b") == _won(12_240_136) # 거창 원본은 12,240,137 assert r.amount("safety_management_cost") == _won(12_240_136) def test_safety_formula_text_is_per_row(): """각 줄에 **제 산식**이 붙는다 — 실무 원문은 A 식을 B 줄에 복사한 오류가 있었다.""" r = calculate_cost(_uljin_2024()) a = r.line("safety_management_cost_a").formula_text b = r.line("safety_management_cost_b").formula_text assert a != b assert "× 1.2" in b and "× 1.2" not in a # -------------------------------------------------------------------------- # 이윤 조정 — 보여만 주고 적용은 명시로 # -------------------------------------------------------------------------- def test_proposed_adjustment_is_advice_only(): """목표 도급액을 주면 필요한 조정액을 계산해 보여 준다. 스스로 적용하지 않는다.""" plain = calculate_cost(_uljin(rate_file_path=FIXTURE_2024, pension_year=2024, cut_basis="none")) suggested = proposed_profit_adjustment(plain, _won(1_132_029_000)) assert suggested == _won(905_980) # 제안값을 받았다고 해서 결과가 바뀌지는 않는다. assert plain.amount("profit") == plain.amount("profit_before_adjustment") # -------------------------------------------------------------------------- # 거울 테스트 3종 — 이중계상 감시 (PLAN 8-7 ㉠㉡㉢) # -------------------------------------------------------------------------- def test_guard_surcharge_double_detected(): """㉠ 자재총괄이 할증 전 값에 두 번 붙으면 잡힌다.""" check_surcharge_once( material_summary_total=_won(110), unit_price_material_total=_won(100), surcharge_rate_percent=_won(10), ) with pytest.raises(DoubleCountError): check_surcharge_once( material_summary_total=_won(121), unit_price_material_total=_won(100), surcharge_rate_percent=_won(10), ) def test_guard_free_haul_must_not_be_priced(): """㉡ 무대 줄은 남기되 단가가 붙으면 잡힌다.""" rows = [ {"equipment": "free_haul", "unit_price_krw": 0}, {"equipment": "dozer", "unit_price_krw": 827}, ] check_free_haul_not_priced(haul_rows=rows) rows[0]["unit_price_krw"] = 500 with pytest.raises(DoubleCountError): check_free_haul_not_priced(haul_rows=rows) def test_guard_haul_volume_within_cut(): """㉡ 보조 — 운반토량 합이 절취량을 넘으면 잡힌다.""" check_haul_volume_within_cut(haul_volume_total_m3=_won(9_000), total_cut_volume_m3=_won(10_044)) with pytest.raises(DoubleCountError): check_haul_volume_within_cut( haul_volume_total_m3=_won(10_915), total_cut_volume_m3=_won(10_044) ) def test_guard_mix_decomposed_once(): """㉢ 시멘트가 콘크리트 체적 × 배합비를 넘으면 잡힌다.""" check_mix_decomposed_once( cement_total_kg=_won(3_200), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) ) with pytest.raises(DoubleCountError): check_mix_decomposed_once( cement_total_kg=_won(6_400), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) ) # -------------------------------------------------------------------------- # 규모 구간 — 추정가격 반복 수렴 (국가계약법 시행령 제7조 1호) # -------------------------------------------------------------------------- def test_scale_converges_to_estimated_price(): """구간은 씨앗(직접공사비)이 아니라 **수렴한 추정가격**으로 판정된다. 울진은 직접공사비 7.1억 · 총원가 10.3억 — 둘 다 50억 미만이라 구간은 같지만, 엔진이 씨앗에서 멈추지 않고 총원가로 다시 판정했는지는 아래로 확인한다. """ r = calculate_cost(_uljin()) # 수렴이 끝났으면 「안 굳음」·「진동」 메모가 없다(절사 메모는 늘 붙는다). assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes def test_explicit_estimated_price_skips_convergence(): """설계자가 추정가격을 명시하면 반복 없이 그 값으로 판정한다.""" small = calculate_cost(_uljin(estimated_price_krw=_won(1_000_000_000))) big = calculate_cost(_uljin(estimated_price_krw=_won(6_000_000_000))) # 50억을 넘기면 일반관리비 8 % → 6.5 %, 이윤 15 % → 12 % 로 갈린다. assert small.line("general_overhead").rate_percent == Decimal("8.0") assert big.line("general_overhead").rate_percent == Decimal("6.5") assert small.line("profit_before_adjustment").rate_percent == Decimal("15.0") assert big.line("profit_before_adjustment").rate_percent == Decimal("12.0") def test_scale_crossing_bracket_is_resolved_by_convergence(): """씨앗은 50억 미만인데 총원가가 50억을 넘는 공사 — 구간이 재판정되어야 한다.""" r = calculate_cost( CostInput( direct_material_krw=_won(900_000_000), direct_labor_krw=_won(2_400_000_000), direct_expense_krw=_won(1_400_000_000), duration_days=400, ) ) # 씨앗(직접공사비) 47억 → 8 % 구간. 총원가가 50억을 넘으면 6.5 % 로 내려간다. assert r.totals["direct_construction_cost"] < _won(5_000_000_000) assert r.totals["total_cost"] > _won(5_000_000_000) assert r.line("general_overhead").rate_percent == Decimal("6.5") assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes