근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
실무 6건 원가계산서가 여섯 다 000 으로 끝나는 것이 증거.
· 기본 켬 — cut_basis="grand_total" · cut_unit_krw=1000
· 조용히 안 깎음(2026-09-08 합의의 정신) — 이윤 조정액 줄과 결과 메모에 고시 번호를 달고
기준 입력판 11 에서 끌 수 있음을 함께 적음 · 칸 밑 안내도 고쳐 씀
· 끄는 값을 none 으로 바꿈 — 빈 값은 저장이 안 돼 기본으로 되돌아가 끌 수가 없었음
· 진동 고침 — 걸음마다 ÷1.1(종전엔 첫 걸음만). 이윤 1원이 총공사비 1.1원이라
잔차를 그대로 빼면 경계를 지나침(울진 신설 654→999→900→910→909…)
· 안 앉으면 「끝자리 N원이 남음」을 메모로 보임
· 실무 6건 전수 000 판정을 새 벌로 지킴
곁 — tmp/tests/test_b09_cost_fixtures.py 를 resources/tester/ 로 옮김(픽스처 포함).
tmp 쪽 사본은 안 지움 · 겹침은 파일 머리에 적음.
기본이 바뀌어 깨진 남의 시험 6건은 「안 자른 값」 자리에 cut_basis="none" 을 넣어 고침.
전체 시험 1782 통과 · 28 건너뜀.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
328 lines
14 KiB
Python
328 lines
14 KiB
Python
# -*- 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,821 → A 채택
|
||
"""
|
||
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_821)
|
||
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 채택. `안전관리비검토` 시트 원문 「적은금액 적용」."""
|
||
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_137)
|
||
assert r.amount("safety_management_cost") == _won(12_240_137)
|
||
|
||
|
||
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
|