"""B09 원가계산 — 법정경비 묶음 (⑤ 공사원가계산서의 경비 부분). `B09_Estimation_Engine_Cost` 가 부르는 하위 모듈. 700줄 제한(CLAUDE.md 4장)에 맞춰 경비 계산만 떼어 두었다. 지켜야 할 것 (PLAN 8-9·8-10·8-13·8-14) - **비목 목록을 코드에 박지 않는다.** 아래 `STATUTORY_ITEMS` 는 「무엇을 어떤 밑수로 계산하는가」의 정의일 뿐이고, **그 해에 그 비목이 있는지는 요율 데이터가 정한다** (해당 요율 변수가 데이터셋에 없으면 그 해에는 없는 비목). - **밑수가 항목마다 갈린다** — 산재·고용 = 직노+간노 / 건강·연금 = 직노 / 요양 = 건강보험료 / 안전 = 재료비+직노+관급항 / 기타경비 = 재료비+직노+간노 / 환경·보증 = 직접공사비. 뭉뚱그리면 틀린다. - **안전관리비는 A·B 두 값을 다 내고 작은 쪽** (고용노동부 고시 제2025-11호). ⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다 (실증: 울진 A 채택 / 거창 B 채택). - ★ **법대로**(8-10) — 조달수수료 차감이라는 처리는 없다. 안전관리비 관급항은 **순자재대**(수수료를 애초에 안 포함)를 부가세 제외로 환산해 쓴다. """ from __future__ import annotations import re from dataclasses import dataclass from decimal import Decimal from typing import TYPE_CHECKING, Any, Callable from B09_Estimation.B09_Estimation_Rates import ( RateDataset, RateLookupError, base_amount, pension_rate_percent, rate_percent, select_bracket, ) if TYPE_CHECKING: # pragma: no cover - 순환 import 회피용 from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult _ZERO = Decimal(0) _HUNDRED = Decimal(100) _VAT_DIVISOR = Decimal("1.1") _SAFETY_B_MULTIPLIER = Decimal("1.2") #: 공사이행보증수수료 요율 데이터가 값 대신 들고 있는 식 문자열 형태. _RE_GUARANTEE_FORMULA = re.compile(r"([0-9.]+)\s*%") @dataclass(frozen=True) class StatutoryItem: """법정경비 한 비목의 정의 — 이름·요율 변수·밑수 뽑는 법.""" key: str name: str rate_variable: str base_label: str #: 비목 정의. **순서가 곧 원가계산서 줄 순서**다. STATUTORY_ITEMS: tuple[StatutoryItem, ...] = ( StatutoryItem( "industrial_accident_insurance", "산재보험료", "rate_sanjae", "노무비(직접+간접)" ), StatutoryItem("employment_insurance", "고용보험료", "rate_goyong", "노무비(직접+간접)"), StatutoryItem("health_insurance", "국민건강보험료", "rate_health", "직접노무비"), StatutoryItem("long_term_care_insurance", "노인장기요양보험료", "rate_care", "국민건강보험료"), StatutoryItem("national_pension", "국민연금보험료", "rate_pension", "직접노무비"), StatutoryItem( "safety_management_cost", "산업안전보건관리비", "rate_safety_pct", "A·B 중 작은 금액" ), StatutoryItem("other_expense", "기타경비", "rate_other_expense", "재료비+노무비(직접+간접)"), StatutoryItem("environment_preservation", "환경보전비", "rate_environment", "직접공사비"), StatutoryItem( "retirement_mutual_aid", "퇴직공제부금비", "rate_retirement_mutual_aid", "직접노무비" ), StatutoryItem( "wage_claim_contribution", "임금채권보장기금 부담금", "rate_wage_claim_contribution", "노무비(직접+간접)", ), StatutoryItem( "asbestos_contribution", "석면피해구제 분담금", "rate_asbestos_contribution", "노무비(직접+간접)", ), StatutoryItem( "equipment_payment_guarantee", "건설기계대여대금 지급보증수수료", "rate_equipment_payment_guarantee", "직접공사비", ), StatutoryItem( "subcontract_payment_guarantee", "하도급대금 지급보증수수료", "rate_subcontract_payment_guarantee", "직접공사비", ), StatutoryItem( "performance_guarantee_fee", "공사이행보증수수료", "rate_performance_guarantee_fee", "직접공사비 × 공사기간(년)", ), ) _ITEM_BY_KEY = {item.key: item for item in STATUTORY_ITEMS} def available_items(dataset: RateDataset) -> tuple[str, ...]: """**그 해에 유효한 비목 목록** — 요율 데이터에 그 변수가 있는 것만 (PLAN 8-13). 비목 목록을 코드에 박지 않기 위한 자리. 연도별 요율 파일이 갈리면 목록도 따라 갈린다. """ return tuple(item.key for item in STATUTORY_ITEMS if item.rate_variable in dataset.variables) def item_name(key: str) -> str: return _ITEM_BY_KEY[key].name @dataclass class ExpenseContext: """법정경비 계산에 필요한 밑수 묶음 — 엔진이 채워 넘긴다.""" material_cost: Decimal direct_labor_cost: Decimal total_labor_cost: Decimal direct_construction_cost: Decimal scale_reference: Decimal def _threshold_met(dataset: RateDataset, variable: str, field: str, amount: Decimal) -> bool: """적용 하한(추정금액 1억 이상 등)을 넘겼는가.""" minimum = dataset.variable(variable).get(field) if minimum is None: return True return amount >= Decimal(str(minimum)) def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decimal: """공사이행보증수수료는 요율 데이터가 값이 아니라 **식 문자열**을 들고 있다. 예: `"(direct_cost * 0.0108%) * duration_years"` → 0.0108 을 뽑는다. 식 모양이 바뀌면 조용히 넘기지 않고 멈춘다. """ formula = str(row.get("formula", "")) match = _RE_GUARANTEE_FORMULA.search(formula) if not match: raise RateLookupError(f"{label}: 요율 식에서 백분율을 못 읽었습니다 — {formula!r}") return Decimal(match.group(1)) def safety_management_cost( dataset: RateDataset, data: CostInput, ctx: ExpenseContext, emit: Callable[..., Decimal], ) -> Decimal: """산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다. A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액 B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨 화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식). """ variable = dataset.variable("rate_safety_pct") brackets = variable["brackets"] owner_supplied = data.owner_supplied_for_safety_krw if owner_supplied is None: owner_supplied = data.owner_supplied_material_krw if data.owner_supplied_includes_vat: owner_supplied = owner_supplied / _VAT_DIVISOR base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied base_without = ctx.material_cost + ctx.direct_labor_cost def evaluate( base: Decimal, multiplier: Decimal, label: str ) -> tuple[Decimal, Decimal, Decimal]: row = select_bracket( brackets, amount_field="target_amount_bracket", amount=base, equals={"work_type": data.work_type_safety}, label=label, ) percent = rate_percent(row, label=label) flat = base_amount(row) return (base * percent / _HUNDRED + flat) * multiplier, percent, flat raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") raw_b, percent_b, flat_b = evaluate( base_without, _SAFETY_B_MULTIPLIER, "안전관리비 B(관급 제외 × 1.2)" ) # 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고 # 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식). from B09_Estimation.B09_Estimation_Engine_Cost import floor_won adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B" amount_a = emit( key="safety_management_cost_a", name="산업안전보건관리비 A(관급 포함)", base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", base=base_with, percent=percent_a, flat=flat_a, raw=raw_a, note="채택" if adopted == "A" else "미채택", ) amount_b = emit( key="safety_management_cost_b", name="산업안전보건관리비 B(관급 제외 × 1.2)", base_label="재료비+직접노무비 (관급 제외)", base=base_without, percent=percent_b, flat=flat_b, raw=raw_b, note="채택" if adopted == "B" else "미채택", ) return emit( key="safety_management_cost", name="산업안전보건관리비", base_label=f"A·B 중 작은 금액 (채택 = {adopted})", base=base_with if adopted == "A" else base_without, percent=percent_a if adopted == "A" else percent_b, flat=_ZERO, raw=min(amount_a, amount_b), note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", ) def statutory_expenses( dataset: RateDataset, data: CostInput, ctx: ExpenseContext, result: CostResult, emit: Callable[..., Decimal], ) -> Decimal: """켜진 비목만 순서대로 계산해 합계를 돌려준다.""" enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY] unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY] if unknown: raise RateLookupError(f"모르는 비목입니다: {unknown}") missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables] if missing: raise RateLookupError( f"이 요율 판({dataset.effective_date})에 없는 비목입니다: " f"{[item_name(k) for k in missing]}" ) total = _ZERO health_amount = _ZERO for key in enabled: item = _ITEM_BY_KEY[key] if key == "safety_management_cost": total += safety_management_cost(dataset, data, ctx, emit) continue if key == "long_term_care_insurance": if "health_insurance" not in enabled: raise RateLookupError( "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" ) total += emit( key=key, name=item.name, base_label=item.base_label, base=health_amount, percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])), ) continue base, percent, note = _base_and_rate(dataset, data, ctx, item) if base is None: continue # 적용 하한 미달 — 줄 자체를 만들지 않는다 amount = emit( key=key, name=item.name, base_label=item.base_label, base=base, percent=percent, note=note, ) if key == "health_insurance": health_amount = amount total += amount return total def _base_and_rate( dataset: RateDataset, data: CostInput, ctx: ExpenseContext, item: StatutoryItem, ) -> tuple[Decimal | None, Decimal, str]: """비목별 밑수·요율. 밑수가 `None` 이면 적용 대상이 아니다.""" variable = dataset.variable(item.rate_variable) key = item.key if key in ("industrial_accident_insurance", "wage_claim_contribution", "asbestos_contribution"): return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), "" if key == "employment_insurance": # 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로 # 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율). row = select_bracket( variable["brackets"], amount_field="estimated_amount_bracket", amount=ctx.scale_reference, residual_label="below_official_threshold", label=item.name, ) return ctx.total_labor_cost, rate_percent(row, label=item.name), "" if key == "health_insurance": return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" if key == "national_pension": return ctx.direct_labor_cost, pension_rate_percent(dataset, data.pension_year), "" if key == "other_expense": row = select_bracket( variable["brackets"], amount_field="direct_cost_bracket", amount=ctx.direct_construction_cost, duration_days=data.duration_days, equals={"work_type": data.work_type_indirect_labor}, label=item.name, ) return ctx.material_cost + ctx.total_labor_cost, rate_percent(row, label=item.name), "" if key == "environment_preservation": if not _threshold_met( dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference ): return None, _ZERO, "" row = next( ( r for r in variable["all_work_types"] if r.get("work_type") == data.environment_work_type ), None, ) if row is None: raise RateLookupError( f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}" ) note = "" if variable.get("forest_road_selection_status") == "pending": note = "⚠ 임도 공종 채택값 미확정 (지식DB `forest_road_selection_status: pending`)" return ctx.direct_construction_cost, rate_percent(row, label=item.name), note if key == "retirement_mutual_aid": if not _threshold_met( dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference ): return None, _ZERO, "" return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" if key == "equipment_payment_guarantee": rows = variable["general_construction"] + variable["specialty_construction"] row = next( (r for r in rows if r.get("work_type") == data.equipment_guarantee_work_type), None ) if row is None: raise RateLookupError( "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " f"{data.equipment_guarantee_work_type}" ) return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" if key == "subcontract_payment_guarantee": # 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다. row = select_bracket( variable["brackets"], amount_field="estimated_price_bracket", amount=ctx.scale_reference, prefer_suffix=data.subcontract_guarantee_variant, label=item.name, ) return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" if key == "performance_guarantee_fee": row = select_bracket( variable["brackets"], amount_field="direct_cost_bracket", amount=ctx.direct_construction_cost, label=item.name, ) percent = _guarantee_percent_from_formula(row, label=item.name) years = Decimal(str(data.duration_days)) / Decimal(365) note = variable.get("typical_forest_road_applicability", "") return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else "" raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")