diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index e5ca8445..ae5fde2d 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -2,86 +2,65 @@ 순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어 **공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5). +법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한). -지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만) +지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인) 1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다. 2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / … - 하나로 뭉치면 틀린다. - 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호). - ⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다 - (실증: 울진 A 채택 / 거창 B 채택). - 4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는 - 관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다. - 5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등). - 6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다. + 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다. + 4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.** + 5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10). + 6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기. + 7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.** """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from B09_Estimation.B09_Estimation_Rates import ( RateDataset, - RateLookupError, - base_amount, flat_rate, load_rate_dataset, - pension_rate_percent, + load_rate_dataset_from_path, rate_percent, select_bracket, ) +from B09_Estimation.B09_Estimation_Statutory import ( + ExpenseContext, + available_items, + statutory_expenses, +) _ZERO = Decimal(0) _HUNDRED = Decimal(100) -_VAT_DIVISOR = Decimal("1.1") -#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다. -DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = ( - "industrial_accident_insurance", - "employment_insurance", - "health_insurance", - "long_term_care_insurance", - "national_pension", - "safety_management_cost", - "other_expense", - "environment_preservation", - "retirement_mutual_aid", -) - -#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장). -OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = ( - "wage_claim_contribution", - "asbestos_contribution", - "equipment_payment_guarantee", - "subcontract_payment_guarantee", - "performance_guarantee_fee", -) +#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. +#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. +DEFAULT_ITEMS = "ALL_AVAILABLE" def floor_won(value: Decimal) -> Decimal: - """원 단위 버림 — 원가계산서 모든 줄의 기본 처리.""" + """원 단위 버림 — 원가계산서 모든 줄의 기본 처리 (PLAN 8-9 규칙 1).""" return value.quantize(Decimal(1), rounding=ROUND_FLOOR) def ceil_thousand(value: Decimal) -> Decimal: - """천원 올림 — 관급자재대 표기.""" + """천원 올림 — 관급자재대 표기 (PLAN 8-9 규칙 7).""" return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 @dataclass class CostInput: - """원가계산 입력. - - 금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다. - """ + """원가계산 입력. 금액은 전부 원 단위 `Decimal`.""" direct_material_krw: Decimal direct_labor_krw: Decimal direct_expense_krw: Decimal indirect_material_krw: Decimal = _ZERO - #: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다. + #: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다. work_type_indirect_labor: str = "civil" work_type_safety: str = "civil" duration_days: int = 183 @@ -92,31 +71,44 @@ class CostInput: procurement_fee_krw: Decimal = _ZERO include_fee_in_owner_material_total: bool = True - #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다. + #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. owner_supplied_for_safety_krw: Decimal | None = None - #: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준). + #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준). owner_supplied_includes_vat: bool = True + #: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐. + #: 옛 서류(울진 2024) 재현 검산에만 켠다. + deduct_procurement_fee_for_safety: bool = False - #: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피). + #: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피). estimated_price_krw: Decimal | None = None - #: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다. + #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. profit_adjustment_krw: Decimal = _ZERO - #: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값). + #: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히. + #: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」). + #: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐. + waste_disposal_krw: Decimal = _ZERO + + #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). + #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. + #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. environment_work_type: str = "civil_road" #: 건설기계대여대금 지급보증 공종. equipment_guarantee_work_type: str = "civil_general" - enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS + #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. + enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS - #: 요율 데이터 파일명. 연도를 갈아끼우는 자리. + #: 요율 데이터 파일명. **연도를 갈아끼우는 자리.** rate_file_name: str = "rates_2026.json" + #: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선. + rate_file_path: str | None = None @dataclass class CostLine: - """원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다.""" + """원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다.""" key: str name: str @@ -127,6 +119,21 @@ class CostLine: amount_krw: Decimal note: str = "" + @property + def formula_text(self) -> str: + """화면 `산출근거` 칸 문구 — 줄마다 **제 산식**을 적는다. + + 실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13). + """ + if self.rate_percent is None: + return self.base_label + text = f"{self.base_label} × {self.rate_percent}%" + if self.flat_amount_krw: + text += f" + {self.flat_amount_krw:,.0f}" + if self.key == "safety_management_cost_b": + text = f"({text}) × 1.2" + return text + @dataclass class CostResult: @@ -144,336 +151,57 @@ class CostResult: def amount(self, key: str) -> Decimal: return self.line(key).amount_krw - -def _line( - result: CostResult, - *, - key: str, - name: str, - base_label: str, - base: Decimal, - percent: Decimal | None = None, - flat: Decimal = _ZERO, - amount: Decimal | None = None, - note: str = "", -) -> Decimal: - """줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림.""" - if amount is None: - computed = base * (percent or _ZERO) / _HUNDRED + flat - amount = floor_won(computed) - result.lines.append( - CostLine( - key=key, - name=name, - base_label=base_label, - base_amount_krw=base, - rate_percent=percent, - flat_amount_krw=flat, - amount_krw=amount, - note=note, - ) - ) - return amount + def has(self, key: str) -> bool: + return any(item.key == key for item in self.lines) -def _safety_management_cost( - result: CostResult, - dataset: RateDataset, - data: CostInput, - *, - material_cost: 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 = material_cost + data.direct_labor_krw + owner_supplied - base_without = material_cost + data.direct_labor_krw - - 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) - amount = floor_won((base * percent / _HUNDRED + flat) * multiplier) - return amount, percent, flat - - amount_a, percent_a, flat_a = evaluate( - base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)" - ) - amount_b, percent_b, flat_b = evaluate( - base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)" - ) - - adopted = "A" if amount_a <= amount_b else "B" - - _line( - result, - key="safety_management_cost_a", - name="산업안전보건관리비 A(관급 포함)", - base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", - base=base_with, - percent=percent_a, - flat=flat_a, - amount=amount_a, - note="채택" if adopted == "A" else "미채택", - ) - _line( - result, - key="safety_management_cost_b", - name="산업안전보건관리비 B(관급 제외 × 1.2)", - base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", - base=base_without, - percent=percent_b, - flat=flat_b, - amount=amount_b, - note="채택" if adopted == "B" else "미채택", - ) - - adopted_amount = min(amount_a, amount_b) - return _line( - result, - 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, - amount=adopted_amount, - note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", - ) +def _load_dataset(data: CostInput) -> RateDataset: + if data.rate_file_path: + return load_rate_dataset_from_path(data.rate_file_path) + return load_rate_dataset(data.rate_file_name) -def _statutory_expenses( - result: CostResult, - dataset: RateDataset, - data: CostInput, - *, - material_cost: Decimal, - total_labor_cost: Decimal, - direct_construction_cost: Decimal, -) -> Decimal: - """법정경비 묶음. `enabled_items` 에 든 줄만 계산한다.""" - enabled = set(data.enabled_items) - total = _ZERO +def _emitter(result: CostResult): + """줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다.""" - if "industrial_accident_insurance" in enabled: - total += _line( - result, - key="industrial_accident_insurance", - name="산재보험료", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_sanjae"), - ) - - if "employment_insurance" in enabled: - variable = dataset.variable("rate_goyong") - # 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 - # 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %). - row = select_bracket( - variable["brackets"], - amount_field="estimated_amount_bracket", - amount=_scale_reference(data, direct_construction_cost), - residual_label="below_official_threshold", - label="고용보험료", - ) - total += _line( - result, - key="employment_insurance", - name="고용보험료", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=rate_percent(row, label="고용보험료"), - ) - - health_amount = _ZERO - if "health_insurance" in enabled: - health_amount = _line( - result, - key="health_insurance", - name="국민건강보험료", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=flat_rate(dataset, "rate_health"), - ) - total += health_amount - - if "long_term_care_insurance" in enabled: - if "health_insurance" not in enabled: - raise RateLookupError( - "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" + def emit( + *, + key: str, + name: str, + base_label: str, + base: Decimal, + percent: Decimal | None = None, + flat: Decimal = _ZERO, + raw: Decimal | None = None, + amount: Decimal | None = None, + note: str = "", + ) -> Decimal: + if amount is None: + computed = raw if raw is not None else base * (percent or _ZERO) / _HUNDRED + flat + amount = floor_won(computed) + result.lines.append( + CostLine( + key=key, + name=name, + base_label=base_label, + base_amount_krw=base, + rate_percent=percent, + flat_amount_krw=flat, + amount_krw=amount, + note=note, ) - total += _line( - result, - key="long_term_care_insurance", - name="노인장기요양보험료", - base_label="국민건강보험료", - base=health_amount, - percent=flat_rate(dataset, "rate_care"), ) + return amount - if "national_pension" in enabled: - total += _line( - result, - key="national_pension", - name="국민연금보험료", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=pension_rate_percent(dataset, data.pension_year), - ) - - if "safety_management_cost" in enabled: - total += _safety_management_cost(result, dataset, data, material_cost=material_cost) - - if "other_expense" in enabled: - variable = dataset.variable("rate_other_expense") - row = select_bracket( - variable["brackets"], - amount_field="direct_cost_bracket", - amount=direct_construction_cost, - duration_days=data.duration_days, - equals={"work_type": data.work_type_indirect_labor}, - label="기타경비", - ) - total += _line( - result, - key="other_expense", - name="기타경비", - base_label="재료비+노무비(직접+간접)", - base=material_cost + total_labor_cost, - percent=rate_percent(row, label="기타경비"), - ) - - if "environment_preservation" in enabled: - variable = dataset.variable("rate_environment") - threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) - if _scale_reference(data, direct_construction_cost) >= threshold: - 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}" - ) - total += _line( - result, - key="environment_preservation", - name="환경보전비", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="환경보전비"), - note=( - "⚠ 임도 공종 채택값 미확정 — 지식DB " - "`rate_environment.forest_road_selection_status: pending`" - ), - ) - - if "retirement_mutual_aid" in enabled: - variable = dataset.variable("rate_retirement_mutual_aid") - threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) - if _scale_reference(data, direct_construction_cost) >= threshold: - total += _line( - result, - key="retirement_mutual_aid", - name="퇴직공제부금비", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=Decimal(str(variable["rate_percent"])), - ) - - if "wage_claim_contribution" in enabled: - total += _line( - result, - key="wage_claim_contribution", - name="임금채권보장기금 부담금", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_wage_claim_contribution"), - ) - - if "asbestos_contribution" in enabled: - total += _line( - result, - key="asbestos_contribution", - name="석면피해구제 분담금", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_asbestos_contribution"), - ) - - if "equipment_payment_guarantee" in enabled: - variable = dataset.variable("rate_equipment_payment_guarantee") - row = next( - ( - r - for r in variable["general_construction"] + variable["specialty_construction"] - if r.get("work_type") == data.equipment_guarantee_work_type - ), - None, - ) - if row is None: - raise RateLookupError( - "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " - f"{data.equipment_guarantee_work_type}" - ) - total += _line( - result, - key="equipment_payment_guarantee", - name="건설기계대여대금 지급보증수수료", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"), - ) - - if "subcontract_payment_guarantee" in enabled: - variable = dataset.variable("rate_subcontract_payment_guarantee") - row = select_bracket( - variable["brackets"], - amount_field="estimated_price_bracket", - amount=_scale_reference(data, direct_construction_cost), - label="하도급대금 지급보증수수료", - ) - total += _line( - result, - key="subcontract_payment_guarantee", - name="하도급대금 지급보증수수료", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="하도급대금 지급보증수수료"), - ) - - return total + return emit def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal: """규모 구간 판정 기준액. - 조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에 - 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을, - 없으면 **직접공사비**를 기준으로 쓴다. + 조달청 제비율표는 「추정가격」으로 구간을 나누지만 추정가격은 원가 계산 결과에 + 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 명시하면 그 값을, 없으면 + **직접공사비**를 쓴다. """ if data.estimated_price_krw is not None: return data.estimated_price_krw @@ -482,12 +210,15 @@ def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Deci def calculate_cost(data: CostInput) -> CostResult: """공사원가계산서 한 장을 계산한다.""" - dataset = load_rate_dataset(data.rate_file_name) + dataset = _load_dataset(data) result = CostResult(rate_version=dataset.version_stamp) + emit = _emitter(result) + + if data.enabled_items == DEFAULT_ITEMS: + data = replace(data, enabled_items=available_items(dataset)) material_cost = data.direct_material_krw + data.indirect_material_krw - _line( - result, + emit( key="material_cost", name="재료비", base_label="직접재료비+간접재료비", @@ -497,7 +228,7 @@ def calculate_cost(data: CostInput) -> CostResult: direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw - indirect_labor_row = select_bracket( + indirect_row = select_bracket( dataset.variable("rate_indirect_labor")["brackets"], amount_field="direct_cost_bracket", amount=direct_construction_cost, @@ -505,17 +236,15 @@ def calculate_cost(data: CostInput) -> CostResult: equals={"work_type": data.work_type_indirect_labor}, label="간접노무비", ) - indirect_labor = _line( - result, + indirect_labor = emit( key="indirect_labor_cost", name="간접노무비", base_label="직접노무비", base=data.direct_labor_krw, - percent=rate_percent(indirect_labor_row, label="간접노무비"), + percent=rate_percent(indirect_row, label="간접노무비"), ) total_labor_cost = data.direct_labor_krw + indirect_labor - _line( - result, + emit( key="labor_cost", name="노무비", base_label="직접노무비+간접노무비", @@ -523,17 +252,17 @@ def calculate_cost(data: CostInput) -> CostResult: amount=total_labor_cost, ) - statutory = _statutory_expenses( - result, - dataset, - data, + ctx = ExpenseContext( material_cost=material_cost, + direct_labor_cost=data.direct_labor_krw, total_labor_cost=total_labor_cost, direct_construction_cost=direct_construction_cost, + scale_reference=_scale_reference(data, direct_construction_cost), ) + statutory = statutory_expenses(dataset, data, ctx, result, emit) + expense_total = data.direct_expense_krw + statutory - _line( - result, + emit( key="expense", name="경비", base_label="직접경비(산출경비)+법정경비", @@ -542,8 +271,7 @@ def calculate_cost(data: CostInput) -> CostResult: ) net_construction_cost = material_cost + total_labor_cost + expense_total - _line( - result, + emit( key="net_construction_cost", name="순공사원가", base_label="재료비+노무비+경비", @@ -551,15 +279,13 @@ def calculate_cost(data: CostInput) -> CostResult: amount=net_construction_cost, ) - scale = _scale_reference(data, direct_construction_cost) overhead_row = select_bracket( dataset.variable("rate_overhead")["civil_landscape_industrial"], amount_field="estimated_price_bracket", - amount=scale, + amount=ctx.scale_reference, label="일반관리비", ) - overhead = _line( - result, + overhead = emit( key="general_overhead", name="일반관리비", base_label="순공사원가", @@ -567,55 +293,17 @@ def calculate_cost(data: CostInput) -> CostResult: percent=rate_percent(overhead_row, label="일반관리비"), ) - profit_row = select_bracket( - dataset.variable("rate_profit")["brackets"], - amount_field="estimated_price_bracket", - amount=scale, - label="이윤", - ) - profit_base = total_labor_cost + expense_total + overhead - profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED) - _line( - result, - key="profit_before_adjustment", - name="이윤(조정 전)", - base_label="노무비+경비+일반관리비 (재료비 제외)", - base=profit_base, - percent=rate_percent(profit_row, label="이윤"), - amount=profit_before, - ) - if data.profit_adjustment_krw: - _line( - result, - key="profit_adjustment", - name="이윤 조정액", - base_label="설계자 명시 입력", - base=_ZERO, - amount=-data.profit_adjustment_krw, - note="도급공사비 끝수 맞춤 — 법정 항목 아님", - ) - profit = profit_before - data.profit_adjustment_krw - _line( - result, - key="profit", - name="이윤", - base_label="조정 전 이윤 − 조정액", - base=profit_base, - amount=profit, - ) + profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead) total_cost = net_construction_cost + overhead + profit - _line( - result, + emit( key="total_cost", name="총원가", base_label="순공사원가+일반관리비+이윤", base=total_cost, amount=total_cost, ) - - vat = _line( - result, + vat = emit( key="vat", name="부가가치세", base_label="총원가", @@ -623,8 +311,7 @@ def calculate_cost(data: CostInput) -> CostResult: percent=flat_rate(dataset, "rate_vat"), ) contract_amount = total_cost + vat - _line( - result, + emit( key="contract_amount", name="도급공사비", base_label="총원가+부가가치세", @@ -632,32 +319,14 @@ def calculate_cost(data: CostInput) -> CostResult: amount=contract_amount, ) - owner_total = _ZERO - if data.owner_supplied_material_krw: - raw = data.owner_supplied_material_krw - if data.include_fee_in_owner_material_total: - raw = raw + data.procurement_fee_krw - owner_total = ceil_thousand(raw) - _line( - result, - key="owner_supplied_material_total", - name="관급자재대", - base_label=( - "순자재대+조달수수료 (천원 올림)" - if data.include_fee_in_owner_material_total - else "순자재대 (천원 올림)" - ), - base=raw, - amount=owner_total, - note="총원가 밖 별도 표기", - ) + owner_total = _owner_supplied_line(data, emit) + waste = _waste_line(data, emit) - grand_total = contract_amount + owner_total - _line( - result, + grand_total = contract_amount + owner_total + waste + emit( key="grand_total", name="총공사비", - base_label="도급공사비+관급자재대", + base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""), base=grand_total, amount=grand_total, ) @@ -674,6 +343,99 @@ def calculate_cost(data: CostInput) -> CostResult: "vat": vat, "contract_amount": contract_amount, "owner_supplied_material_total": owner_total, + "waste_disposal": waste, "grand_total": grand_total, } return result + + +def _profit_lines( + dataset: RateDataset, + data: CostInput, + emit, + ctx: ExpenseContext, + net_construction_cost: Decimal, + overhead: Decimal, +) -> Decimal: + """이윤 — 조정 전 / 조정액 / 조정 후 세 줄. 조정은 **명시 입력일 때만**.""" + profit_row = select_bracket( + dataset.variable("rate_profit")["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + label="이윤", + ) + percent = rate_percent(profit_row, label="이윤") + profit_base = net_construction_cost + overhead - ctx.material_cost + before = emit( + key="profit_before_adjustment", + name="이윤(조정 전)", + base_label="(순공사원가+일반관리비) − 재료비", + base=profit_base, + percent=percent, + ) + if data.profit_adjustment_krw: + emit( + key="profit_adjustment", + name="이윤 조정액", + base_label="설계자 명시 입력", + base=_ZERO, + amount=-data.profit_adjustment_krw, + note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)", + ) + profit = before - data.profit_adjustment_krw + emit( + key="profit", + name="이윤", + base_label="조정 전 이윤 − 조정액", + base=profit_base, + amount=profit, + ) + return profit + + +def _owner_supplied_line(data: CostInput, emit) -> Decimal: + """관급자재대 — 총원가 밖 별도 표기, 천원 올림.""" + if not data.owner_supplied_material_krw: + return _ZERO + raw = data.owner_supplied_material_krw + if data.include_fee_in_owner_material_total: + raw = raw + data.procurement_fee_krw + return emit( + key="owner_supplied_material_total", + name="관급자재대", + base_label=( + "순자재대+조달수수료 (천원 올림)" + if data.include_fee_in_owner_material_total + else "순자재대 (천원 올림)" + ), + base=raw, + amount=ceil_thousand(raw), + note="총원가 밖 별도 표기", + ) + + +def _waste_line(data: CostInput, emit) -> Decimal: + """폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다.""" + if not data.waste_disposal_krw: + return _ZERO + return emit( + key="waste_disposal", + name="폐기물처리비", + base_label="설계자 입력(실비)", + base=data.waste_disposal_krw, + amount=floor_won(data.waste_disposal_krw), + note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)", + ) + + +def proposed_profit_adjustment(result: CostResult, target_contract_amount: Decimal) -> Decimal: + """목표 도급공사비를 맞추려면 이윤을 얼마 깎아야 하는지 **보여만 준다**. + + ★ 법대로(8-10) — 프로그램이 스스로 적용하지 않는다. 설계자가 이 값을 보고 + `CostInput.profit_adjustment_krw` 에 명시로 넣어야 반영된다. + """ + gap = result.totals["contract_amount"] - target_contract_amount + if gap <= 0: + return _ZERO + # 이윤 1원을 깎으면 총원가 1원 + 부가세 0.1원이 줄어든다. + return floor_won(gap / Decimal("1.1")) diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py new file mode 100644 index 00000000..9d69ffbd --- /dev/null +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -0,0 +1,111 @@ +"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종). + +**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안, +「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서 +위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는 +검사**를 두어, 규칙을 어기면 계산이 멈추게 한다. + +세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢) + ㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다 + (품셈 1-3-1 「할증 중복 적용 금지」). + ㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미 + 포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6). + ㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래 + 분해는 일위대가에서 한 번만 한다. +""" + +from __future__ import annotations + +from decimal import Decimal + +_TOLERANCE = Decimal("0.5") + + +class DoubleCountError(AssertionError): + """이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다.""" + + +def check_surcharge_once( + *, + material_summary_total: Decimal, + unit_price_material_total: Decimal, + surcharge_rate_percent: Decimal, + label: str = "자재", +) -> None: + """㉠ 할증이 두 번 붙지 않았는가. + + `material_summary_total` = 자재총괄의 **할증 포함** 합계. + `unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계. + 둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다. + """ + if unit_price_material_total <= 0: + return + expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100)) + if material_summary_total > expected + _TOLERANCE: + raise DoubleCountError( + f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > " + f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) " + f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)." + ) + + +def check_free_haul_not_priced( + *, + haul_rows: list[dict], + equipment_field: str = "equipment", + unit_price_field: str = "unit_price_krw", + free_haul_equipment: str = "free_haul", +) -> None: + """㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가. + + 줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재). + 금지되는 것은 **단가를 붙이는 것**이다. + """ + for row in haul_rows: + if row.get(equipment_field) != free_haul_equipment: + continue + price = Decimal(str(row.get(unit_price_field) or 0)) + if price != 0: + raise DoubleCountError( + f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — " + "소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)." + ) + + +def check_haul_volume_within_cut( + *, + haul_volume_total_m3: Decimal, + total_cut_volume_m3: Decimal, +) -> None: + """㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가. + + 무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다. + """ + if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE: + raise DoubleCountError( + f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 " + f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 " + "(PLAN 8-7 ㉡)." + ) + + +def check_mix_decomposed_once( + *, + cement_total_kg: Decimal, + concrete_volume_m3: Decimal, + cement_per_m3_kg: Decimal, +) -> None: + """㉢ 콘크리트를 두 번 쪼개지 않았는가. + + 시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을 + 일위대가가 또 분해한 것이다. + """ + if concrete_volume_m3 <= 0: + return + expected = concrete_volume_m3 * cement_per_m3_kg + if cement_total_kg > expected + _TOLERANCE: + raise DoubleCountError( + f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × " + f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 " + "쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)." + ) diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py index b1c0155b..d6197181 100644 --- a/B09_Estimation/B09_Estimation_Rates.py +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -102,6 +102,24 @@ def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset: ) +@lru_cache(maxsize=8) +def load_rate_dataset_from_path(path: str) -> RateDataset: + """매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**. + + 정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면 + 그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에 + `sha256=""` 로 남아 **정본이 아님이 드러난다**. + """ + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + return RateDataset( + dataset_id=payload.get("dataset_id", ""), + effective_date=payload.get("effective_date", ""), + sha256="", + variables=payload.get("variables", {}), + ) + + def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None: """금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None.""" match = _RE_LT.match(label) diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py new file mode 100644 index 00000000..37a7eebe --- /dev/null +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -0,0 +1,406 @@ +"""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.deduct_procurement_fee_for_safety: + # ★ 법대로(8-10) — 규정 문구가 아니다. 옛 서류 재현용으로만 켠다. + owner_supplied = owner_supplied - data.procurement_fee_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="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", + 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": + row = select_bracket( + variable["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + 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}")