Files
Aislo/resources/tester/test_b09_cost_fixtures.py
T
eomsangdonandClaude Opus 5 aeb18266bd fix(b09): 총액 1,000원 미만 버림을 기본으로 켬 · 절사 자동보정 진동 고침
근거 — 산림청고시 제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
2026-09-14 09:40:24 +09:00

328 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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