"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절). ⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀: 봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996. ⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호). 「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다. """ from __future__ import annotations import sys from dataclasses import replace from decimal import Decimal from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( # noqa: E402 cut_gap, profit_cut, ) BASE = CostInput( direct_material_krw=Decimal(123_456_789), direct_labor_krw=Decimal(234_567_891), direct_expense_krw=Decimal(45_678_912), estimated_price_krw=Decimal(1_000_000_000), # 10억 — 주공사 8.0 / 전문 6.5 가 갈리는 자리 ) 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="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: main, special = ( calculate_cost(BASE), calculate_cost(replace(BASE, overhead_class="specialty_electric_communication_fire_other")), ) assert ( main.line("general_overhead").base_amount_krw == special.line("general_overhead").base_amount_krw ) assert ( main.line("general_overhead").rate_percent != special.line("general_overhead").rate_percent ) def test_관리품목자재대는_일반관리비_밑수에_든다() -> None: plain = calculate_cost(BASE) managed = calculate_cost(replace(BASE, overhead_managed_material_krw=Decimal(10_000_000))) gain = ( managed.line("general_overhead").base_amount_krw - plain.line("general_overhead").base_amount_krw ) assert gain == Decimal(10_000_000) assert "관리품목자재대" in managed.line("general_overhead").base_label @pytest.mark.parametrize( ("mode", "expected"), [ ("material", lambda r, d: r.amount("material_cost")), ("none", lambda r, d: Decimal(0)), ("forest_coop", lambda r, d: r.amount("material_cost") + d.direct_expense_krw), ( "forest_coop_exempt", lambda r, d: ( r.amount("material_cost") - d.tax_exempt_material_krw + d.direct_expense_krw ), ), ], ) def test_부가세_방식은_밑수만_바꾼다(mode, expected) -> None: data = replace(BASE, vat_mode=mode, tax_exempt_material_krw=Decimal(3_327_500)) result = calculate_cost(data) assert result.line("vat").base_amount_krw == expected(result, data) if mode == "none": assert result.amount("vat") == 0 def test_실무_절사_식() -> None: assert profit_cut(Decimal(373), "grand_total", "supply") == 339 # 봉화 assert profit_cut(Decimal(695), "grand_total", "supply") == 632 # 영월 assert profit_cut(Decimal(996), "grand_total", "forest_coop_exempt") == 996 # 영덕 assert cut_gap(Decimal(1_270_728_373), 1000) == 373 @pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"]) def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: 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) ) assert cut.amount("grand_total") % 1000 == 0 assert 0 <= plain.amount("grand_total") - cut.amount("grand_total") < 1000 assert cut.amount("profit") < plain.amount("profit") assert cut.has("profit_adjustment") and any("자동보정" in note for note in cut.notes) # 이윤 말고는 안 움직임 — 순공사원가·일반관리비 그대로. for key in ("net_construction_cost", "general_overhead"): assert cut.amount(key) == plain.amount(key) def test_공급가액_절사는_차액_그대로() -> None: 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: """고르개 「절사 안 함」 = `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, cut_basis="none", profit_adjustment_krw=Decimal(100))) assert manual.line("profit_adjustment").base_label == "설계자 명시 입력" def test_고용보험_등급_고르기와_없음() -> None: auto = calculate_cost(BASE) grade1 = calculate_cost(replace(BASE, employment_insurance_grade="1")) none = calculate_cost(replace(BASE, employment_insurance_grade="none")) assert grade1.line("employment_insurance").rate_percent == Decimal("1.57") assert grade1.amount("employment_insurance") > auto.amount("employment_insurance") assert not none.has("employment_insurance") or none.amount("employment_insurance") == 0 with pytest.raises(Exception): calculate_cost(replace(BASE, employment_insurance_grade="9")) def test_퇴직공제_적용_여부() -> None: small = replace(BASE, estimated_price_krw=Decimal(50_000_000)) # 1억 미만 — 자동이면 안 섬 assert ( not calculate_cost(small).has("retirement_mutual_aid") or calculate_cost(small).amount("retirement_mutual_aid") == 0 ) applied = calculate_cost(replace(small, retirement_mutual_aid_mode="apply")) assert applied.amount("retirement_mutual_aid") > 0 off = calculate_cost(replace(BASE, retirement_mutual_aid_mode="none")) assert not off.has("retirement_mutual_aid") or off.amount("retirement_mutual_aid") == 0 def test_폐기물_승률_밖_자리() -> None: """실무 관행(울진소광) — 이윤 뒤 · 총원가 안 · 부가세 안 · 일반관리비·이윤 밑수 밖.""" waste = Decimal(1_000_000) law = calculate_cost(replace(BASE, waste_disposal_krw=waste)) practice = calculate_cost( replace(BASE, waste_disposal_krw=waste, waste_placement="after_profit") ) none = calculate_cost(BASE) assert practice.amount("general_overhead") == none.amount("general_overhead") assert practice.amount("profit") == none.amount("profit") assert practice.amount("total_cost") - none.amount("total_cost") == waste assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼 keys = [line.key for line in practice.lines] assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost") def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None: assert not calculate_cost(BASE).has("performance_guarantee_fee") tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech")) assert tech.amount("performance_guarantee_fee") > 0 def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None: """제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년).""" big = CostInput( direct_material_krw=Decimal(500_000_000), direct_labor_krw=Decimal(300_000_000), direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억 estimated_price_krw=Decimal(12_000_000_000), duration_days=365, performance_guarantee_mode="lowest_price_tech", ) assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000) def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None: on = replace(BASE, subcontract_guarantee="on") assert calculate_cost(on).line("subcontract_payment_guarantee").rate_percent == Decimal("0.081") turnkey = calculate_cost(replace(on, bid_method="turnkey")) assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084") comp = calculate_cost(replace(on, bid_method="comprehensive")) assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071") def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None: same = calculate_cost( replace(BASE, private_material_position="material", contract_law_basis="local") ) assert same.totals == calculate_cost(BASE).totals def test_산업안전_50억_이상은_선임_대상으로_가른다() -> None: """고시 별표1 — 「50억 이상」 열과 「보건관리자 선임 대상」 열은 공사가 선임 대상인가로 갈림.""" big = replace( BASE, direct_material_krw=Decimal(4_000_000_000), direct_labor_krw=Decimal(3_000_000_000), estimated_price_krw=Decimal(20_000_000_000), # 토목 선임 기준 1,000억 미만 ) assert calculate_cost(big).line("safety_management_cost_b").rate_percent == Decimal("2.6") target = calculate_cost(replace(big, estimated_price_krw=Decimal(120_000_000_000))) assert target.line("safety_management_cost_b").rate_percent == Decimal("2.73") def test_하도급대금_지급보증은_기본_꺼짐() -> None: assert not calculate_cost(BASE).has("subcontract_payment_guarantee") assert ( calculate_cost(replace(BASE, subcontract_guarantee="on")).amount( "subcontract_payment_guarantee" ) > 0 ) #: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가 #: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다). _SKIPPING = CostInput( direct_material_krw=Decimal(80_165_010), direct_labor_krw=Decimal(243_648_150), direct_expense_krw=Decimal(0), owner_supplied_material_krw=Decimal(74_634_214), enabled_items=("safety_management_cost",), cut_basis="grand_total", cut_unit_krw=1000, ) @pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None: """⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다. 종전엔 ÷1.1 어림이 한 칸 아래로 지나친 뒤 3걸음 안에 못 돌아와 끝자리 999 가 남았다 (거창 꼴 40 자리 중 16). 못 밟는 배수면 **한 칸 아래 배수로 내려가** 앉아야 한다. """ result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))) assert result.totals["grand_total"] % 1000 == 0, delta assert not [n for n in result.notes if "안 앉음" in n], result.notes @pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None: """버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다. ⚠ 이 줄이 깨지면 「못 밟는 배수를 만나 한 칸 내려갔다」는 뜻이다 — 실측(거창 꼴 40 자리)에서는 한 칸도 안 내려갔다. 깨지는 자리가 생기면 그 자체가 봐야 할 소식이다. """ data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)) plain = calculate_cost(replace(data, cut_basis="none")) cut = calculate_cost(data) drop = plain.totals["grand_total"] - cut.totals["grand_total"] assert 0 <= drop < 1000, (delta, drop)