diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index d2c5bf2f..b5299552 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -66,9 +66,10 @@ FIELD_HINTS: dict[str, str] = { ), "overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사", "cut_basis": ( - "기본 「안 함」 — 이윤을 자동으로 깎지 않는다는 합의(2026-09-08) · 참고: 실무 임도 3건" - "(봉화·영월·거창) 모두 「총공사비 1,000원 미만 + 이윤 자동보정」을 씀 — 고르면 이윤 줄에" - " 「절사 자동보정」으로 적힘" + "기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」" + "(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·" + " 깎은 몫은 이윤 줄에 「절사 자동보정(산림청고시 2025-82호)」으로 적혀 조용히 안 깎임 ·" + " 발주처가 달리 정하면 「절사 안 함」으로 끄고 이윤보정액을 직접 넣을 것" ), "vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건", "private_material_position": ( @@ -118,7 +119,12 @@ VAT_MODE_LABELS = { def choice_options() -> dict[str, list[dict[str, str]]]: - """10·11·12 선택지. 절사 「안 함」이 첫째 = 기본(이윤을 자동으로 안 깎음).""" + """10·11·12 선택지. + + ⚠ 절사 「안 함」 값은 **`none`** 임 — 빈 값이면 저장되지 않아(`clean_settings`) + 기본으로 되돌아가 **끌 수가 없다**. + 기본은 「총공사비에서 조정 · 1,000원 미만」(산림청고시 2025-82호). + """ return { "employment_insurance_grade": _GRADE_OPTIONS, "retirement_mutual_aid_mode": [ @@ -154,8 +160,15 @@ def choice_options() -> dict[str, list[dict[str, str]]]: ], "overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()], "cut_basis": [ - {"value": "", "label": "절사 안 함(이윤보정액 입력)"}, - *({"value": k, "label": f"{v}에서 조정"} for k, v in CUT_BASES.items()), + *( + { + "value": k, + "label": f"{v}에서 조정" + + (" (기본 · 고시 2025-82호)" if k == "grand_total" else ""), + } + for k, v in CUT_BASES.items() + ), + {"value": "none", "label": "절사 안 함(이윤보정액 직접 입력)"}, ], "cut_unit_krw": [{"value": str(u), "label": f"{u:,}원 미만"} for u in CUT_UNITS_KRW], "vat_mode": [{"value": k, "label": VAT_MODE_LABELS[k]} for k in VAT_MODES], diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index b3bbb89b..47a46469 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -123,10 +123,15 @@ class CostInput: #: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액. vat_mode: str = "supply" tax_exempt_material_krw: Decimal = _ZERO - #: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · 빈 값은 안 자름. - #: ⚠ 고른 때만 이윤을 자동보정한다 — 기본은 수동 이윤보정만(2026-09-08 합의). - cut_basis: str = "" - cut_unit_krw: int = 0 + #: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름. + #: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」 + #: 「설계서의 총액 · 원 · 1,000 · 미만버림」. + #: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거. + #: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과 + #: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고, + #: 설계자가 `none` 으로 **끌 수 있게** 둔다. + cut_basis: str = "grand_total" + cut_unit_krw: int = 1000 #: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접). employment_insurance_grade: str = "auto" #: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용). @@ -360,8 +365,11 @@ def calculate_cost(data: CostInput) -> CostResult: ) -#: 절사 뒤 잔차 맞춤 반복 상한 — 실무 수식도 잔차 칸 한 번뿐(표본 넷 모두 0). +#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음 +#: (다섯 건 한 걸음 · 울진 신설 두 걸음). _CUT_MAX_PASSES = 3 +#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트). +CUT_OFF = ("", "none") def _calculate_with_scale( @@ -370,32 +378,47 @@ def _calculate_with_scale( scale: Decimal, notes: list[str], ) -> CostResult: - """규모 기준액을 못 박고 계산 — 11. 절사를 **고른 때만** 이윤을 보정해 다시 셈.""" + """규모 기준액을 못 박고 계산 — 11. 절사가 켜져 있으면 이윤을 보정해 다시 셈. + + ⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어 + 총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 — + 울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`. + """ result = _calculate_once(data, dataset, scale, notes) - if not data.cut_basis or data.cut_unit_krw <= 0: + if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0: return result key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" automatic = _ZERO + gap = _ZERO for _ in range(_CUT_MAX_PASSES): gap = cut_gap(result.totals[key], data.cut_unit_krw) if gap == 0: break - # 첫 차례는 실무 식(총공사비·공급가액 비례 부가세면 ÷1.1), 그 뒤는 잔차 그대로. - automatic += profit_cut(gap, data.cut_basis, data.vat_mode) if automatic == 0 else gap + # 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음). + automatic += profit_cut(gap, data.cut_basis, data.vat_mode) adjusted = replace( data, profit_adjustment_krw=data.profit_adjustment_krw + automatic, - cut_basis="", + cut_basis="none", profit_adjustment_label=( f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" - " 절사 자동보정" + (" + 설계자 입력" if data.profit_adjustment_krw else "") + " 절사 자동보정(산림청고시 2025-82호)" + + (" + 설계자 입력" if data.profit_adjustment_krw else "") ), ) result = _calculate_once(adjusted, dataset, scale, notes) result.notes.append( f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —" - f" 이윤에서 {automatic:,}원 자동보정(설계자가 고른 절사 기준)" + f" 이윤에서 {automatic:,}원 자동보정." + " 근거: 산림청고시 제2025-82호 「금액의 단위표준」(설계서의 총액 1,000원 미만버림) ·" + " 기준 입력판 11 에서 끌 수 있음" ) + if gap: + # 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다. + result.notes.append( + f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —" + f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)" + ) return result diff --git a/resources/tester/fixtures/rates_2024_uljin.json b/resources/tester/fixtures/rates_2024_uljin.json new file mode 100644 index 00000000..068e586e --- /dev/null +++ b/resources/tester/fixtures/rates_2024_uljin.json @@ -0,0 +1,73 @@ +{ + "schema_version": "1.0", + "dataset_id": "rates_2024_fixture", + "effective_date": "2024-01-01", + "_note": "검산 전용 픽스처 — 정본 아님. 울진 공통(2024) 공사원가계산서를 재현해 「연도별 요율 교체 구조가 실제로 도는가」를 시험한다. 값 출처는 그 서류의 관측 요율(PLAN 8-9)이며, 프로그램 기본값으로 쓰지 않는다.", + "variables": { + "rate_indirect_labor": { + "base": "direct_labor_cost", + "brackets": [ + { + "direct_cost_bracket": "lt_1_billion", + "duration_bracket": "lte_183_days", + "work_type": "civil", + "rate_percent": 14.5 + } + ] + }, + "rate_sanjae": { "rate_percent": 3.56, "base": "total_labor_cost" }, + "rate_goyong": { + "base": "total_labor_cost", + "brackets": [ + { "grade": "below_7", "estimated_amount_bracket": "below_official_threshold", "rate_percent": 1.01 } + ] + }, + "rate_health": { "rate_percent": 3.545, "base": "direct_labor_cost" }, + "rate_care": { "rate_percent": 12.95, "base": "health_insurance_amount" }, + "rate_pension": { + "base": "direct_labor_cost", + "annual_rates": [{ "year": 2024, "rate_percent": 4.5 }] + }, + "rate_safety_pct": { + "base_with_owner_supplied_material": "minimum_of_statutory_two_formulas", + "brackets": [ + { + "target_amount_bracket": "500_million_to_5_billion", + "work_type": "civil", + "rate_percent": 1.86, + "base_amount_krw": 5349000 + }, + { "target_amount_bracket": "lt_500_million", "work_type": "civil", "rate_percent": 2.35 } + ] + }, + "rate_other_expense": { + "base": "material_cost_plus_total_labor_cost", + "brackets": [ + { + "direct_cost_bracket": "lt_1_billion", + "duration_bracket": "lte_183_days", + "work_type": "civil", + "rate_percent": 6.0 + } + ] + }, + "rate_environment": { + "base": "direct_construction_cost", + "minimum_estimated_amount_krw": 100000000, + "all_work_types": [{ "work_type": "civil_road", "rate_percent": 0.8 }] + }, + "rate_equipment_payment_guarantee": { + "general_construction": [{ "work_type": "civil_general", "rate_percent": 0.4 }], + "specialty_construction": [] + }, + "rate_overhead": { + "base": "material_plus_labor_plus_expense", + "civil_landscape_industrial": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 6.0 }] + }, + "rate_profit": { + "base": "labor_plus_expense_plus_overhead", + "brackets": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 15.0 }] + }, + "rate_vat": { "rate_percent": 10.0, "base": "construction_cost_before_vat" } + } +} diff --git a/resources/tester/test_b09_cost_fixtures.py b/resources/tester/test_b09_cost_fixtures.py new file mode 100644 index 00000000..42014b49 --- /dev/null +++ b/resources/tester/test_b09_cost_fixtures.py @@ -0,0 +1,327 @@ +# -*- 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 diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index c2f8dfd2..22b69816 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -2,6 +2,8 @@ ⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀: 봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996. +⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호). + 「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다. """ from __future__ import annotations @@ -30,11 +32,17 @@ BASE = CostInput( ) -def test_기본값은_종전과_같다() -> None: +def test_기본값은_주공사_공급가액_총공사비절사() -> None: + """10 주공사 · 12 공급가액 10% · **11 총공사비 1,000원 미만 절사**(2026-09-14 기본으로 켬).""" explicit = replace( - BASE, overhead_class="civil_landscape_industrial", vat_mode="supply", cut_basis="" + BASE, + overhead_class="civil_landscape_industrial", + vat_mode="supply", + cut_basis="grand_total", + cut_unit_krw=1000, ) assert calculate_cost(explicit).totals == calculate_cost(BASE).totals + assert calculate_cost(BASE).totals["grand_total"] % 1000 == 0 def test_전문공사는_요율표만_바뀐다() -> None: @@ -93,7 +101,7 @@ def test_실무_절사_식() -> None: @pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"]) def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: - plain = calculate_cost(replace(BASE, vat_mode=vat_mode)) + plain = calculate_cost(replace(BASE, vat_mode=vat_mode, cut_basis="none")) cut = calculate_cost( replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000) ) @@ -107,20 +115,24 @@ def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: def test_공급가액_절사는_차액_그대로() -> None: - plain = calculate_cost(BASE) + plain = calculate_cost(replace(BASE, cut_basis="none")) cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000)) assert cut.amount("total_cost") % 10_000 == 0 assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000 -def test_절사를_안_고르면_이윤을_안_건드린다() -> None: - assert not calculate_cost(replace(BASE, cut_unit_krw=1000)).has("profit_adjustment") +def test_절사를_끄면_이윤을_안_건드린다() -> None: + """고르개 「절사 안 함」 = `none`. ⚠ 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).""" + for off in ("none", ""): + assert not calculate_cost(replace(BASE, cut_basis=off)).has("profit_adjustment"), off + # 기준만 있고 단위가 0 이면 안 자른다. + assert not calculate_cost(replace(BASE, cut_unit_krw=0)).has("profit_adjustment") def test_자동보정_줄은_제_이름을_적는다() -> None: cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000)) assert "절사 자동보정" in cut.line("profit_adjustment").base_label - manual = calculate_cost(replace(BASE, profit_adjustment_krw=Decimal(100))) + manual = calculate_cost(replace(BASE, cut_basis="none", profit_adjustment_krw=Decimal(100))) assert manual.line("profit_adjustment").base_label == "설계자 명시 입력" diff --git a/resources/tester/test_b09_golden_cost_sheet.py b/resources/tester/test_b09_golden_cost_sheet.py index 529e25a3..853f3076 100644 --- a/resources/tester/test_b09_golden_cost_sheet.py +++ b/resources/tester/test_b09_golden_cost_sheet.py @@ -321,6 +321,9 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: 울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525). 조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임. 도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000. + + ⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 그래서 「안 켠 값」을 볼 때는 + `cut_basis="none"` 으로 **손수 꺼서** 봄. 자동절사 쪽은 기본값 그대로 돌림. """ sheets = _cost_sheets() if not sheets: @@ -329,7 +332,7 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: assert len(full) >= 3, [s[0] for s in full] chained, automatic, manual, misses = 0, [], [], [] for index, (name, got, unknown) in enumerate(full): - data = _cost_input(got, tmp_path / f"chain{index}.json") + data = replace(_cost_input(got, tmp_path / f"chain{index}.json"), cut_basis="none") plain = calculate_cost(data) gap = plain.amount("profit_before_adjustment") - got["profit"][0] cut = calculate_cost(replace(data, cut_basis="grand_total", cut_unit_krw=1000)) @@ -367,3 +370,28 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: assert len(automatic) == 2, automatic assert [g for _n, g in automatic] == [Decimal(339), Decimal(632)], automatic assert [g for _n, g in manual] == [Decimal(905_980)], manual + + +def test_총공사비는_천원_미만_버림이_기본(tmp_path) -> None: + """⑤ 총액 절사 — 실무 6건이 **여섯 다** `000` 으로 앉음(2026-09-14 기본으로 켬). + + 근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」. + 끄면(`cut_basis="none"`) 여섯 다 끝자리가 남는 것으로 **절사가 실제로 도는지**를 가름. + + ⚠ 잔차 걸음도 ÷1.1 을 해야 앉음(2026-09-14 고침) — 그대로 빼면 울진 신설이 + `654 → 999 → 900 → 910 → 909 …` 로 진동해 끝자리가 남았음. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + landed, left_over = [], [] + for index, (name, got, _unknown) in enumerate(sheets): + data = _cost_input(got, tmp_path / f"cut{index}.json") # 기본값 그대로 + result = calculate_cost(data) + landed.append((name, result.totals["grand_total"] % 1000)) + off = calculate_cost(replace(data, cut_basis="none")) + left_over.append((name, off.totals["grand_total"] % 1000)) + assert any("2025-82호" in note for note in result.notes), result.notes + assert not [n for n in result.notes if "안 앉음" in n], result.notes + assert [tail for _n, tail in landed] == [Decimal(0)] * len(sheets), landed + assert all(tail for _n, tail in left_over), left_over diff --git a/resources/tester/test_b09_waste_disposal_cost.py b/resources/tester/test_b09_waste_disposal_cost.py index dca10d08..c9ce66ab 100644 --- a/resources/tester/test_b09_waste_disposal_cost.py +++ b/resources/tester/test_b09_waste_disposal_cost.py @@ -42,8 +42,15 @@ MOVES = { def _run(waste: Decimal, separate: bool = False): + # ⚠ 절사는 꺼서 본다 — 총공사비가 바뀌면 자동보정액도 따라 바뀌어 **구조**를 못 잰다 + # (2026-09-14 절사 기본 켜짐, 산림청고시 제2025-82호). return calculate_cost( - CostInput(**BASE, waste_disposal_krw=waste, waste_separate_order=separate) + CostInput( + **BASE, + waste_disposal_krw=waste, + waste_separate_order=separate, + cut_basis="none", + ) )