"""B09 원가계산 — ⑤ 공사원가계산서 엔진. 순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어 **공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5). 지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만) 1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다. 2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / … 하나로 뭉치면 틀린다. 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호). ⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다 (실증: 울진 A 채택 / 거창 B 채택). 4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는 관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다. 5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등). 6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다. """ from __future__ import annotations from dataclasses import dataclass, field 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, rate_percent, select_bracket, ) _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", ) def floor_won(value: Decimal) -> Decimal: """원 단위 버림 — 원가계산서 모든 줄의 기본 처리.""" return value.quantize(Decimal(1), rounding=ROUND_FLOOR) def ceil_thousand(value: Decimal) -> Decimal: """천원 올림 — 관급자재대 표기.""" return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 @dataclass class CostInput: """원가계산 입력. 금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다. """ direct_material_krw: Decimal direct_labor_krw: Decimal direct_expense_krw: Decimal indirect_material_krw: Decimal = _ZERO #: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다. work_type_indirect_labor: str = "civil" work_type_safety: str = "civil" duration_days: int = 183 pension_year: int = 2026 #: 관급자재 — 순자재대와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1). owner_supplied_material_krw: Decimal = _ZERO procurement_fee_krw: Decimal = _ZERO include_fee_in_owner_material_total: bool = True #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다. owner_supplied_for_safety_krw: Decimal | None = None #: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준). owner_supplied_includes_vat: bool = True #: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피). estimated_price_krw: Decimal | None = None #: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다. profit_adjustment_krw: Decimal = _ZERO #: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값). environment_work_type: str = "civil_road" #: 건설기계대여대금 지급보증 공종. equipment_guarantee_work_type: str = "civil_general" enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS #: 요율 데이터 파일명. 연도를 갈아끼우는 자리. rate_file_name: str = "rates_2026.json" @dataclass class CostLine: """원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다.""" key: str name: str base_label: str base_amount_krw: Decimal rate_percent: Decimal | None flat_amount_krw: Decimal amount_krw: Decimal note: str = "" @dataclass class CostResult: lines: list[CostLine] = field(default_factory=list) totals: dict[str, Decimal] = field(default_factory=dict) rate_version: dict[str, str] = field(default_factory=dict) notes: list[str] = field(default_factory=list) def line(self, key: str) -> CostLine: for item in self.lines: if item.key == key: return item raise KeyError(f"원가계산서에 없는 줄입니다: {key}") 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 _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 _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 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( "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" ) total += _line( result, key="long_term_care_insurance", name="노인장기요양보험료", base_label="국민건강보험료", base=health_amount, percent=flat_rate(dataset, "rate_care"), ) 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 def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal: """규모 구간 판정 기준액. 조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을, 없으면 **직접공사비**를 기준으로 쓴다. """ if data.estimated_price_krw is not None: return data.estimated_price_krw return direct_construction_cost def calculate_cost(data: CostInput) -> CostResult: """공사원가계산서 한 장을 계산한다.""" dataset = load_rate_dataset(data.rate_file_name) result = CostResult(rate_version=dataset.version_stamp) material_cost = data.direct_material_krw + data.indirect_material_krw _line( result, key="material_cost", name="재료비", base_label="직접재료비+간접재료비", base=material_cost, amount=material_cost, ) direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw indirect_labor_row = select_bracket( dataset.variable("rate_indirect_labor")["brackets"], amount_field="direct_cost_bracket", amount=direct_construction_cost, duration_days=data.duration_days, equals={"work_type": data.work_type_indirect_labor}, label="간접노무비", ) indirect_labor = _line( result, key="indirect_labor_cost", name="간접노무비", base_label="직접노무비", base=data.direct_labor_krw, percent=rate_percent(indirect_labor_row, label="간접노무비"), ) total_labor_cost = data.direct_labor_krw + indirect_labor _line( result, key="labor_cost", name="노무비", base_label="직접노무비+간접노무비", base=total_labor_cost, amount=total_labor_cost, ) statutory = _statutory_expenses( result, dataset, data, material_cost=material_cost, total_labor_cost=total_labor_cost, direct_construction_cost=direct_construction_cost, ) expense_total = data.direct_expense_krw + statutory _line( result, key="expense", name="경비", base_label="직접경비(산출경비)+법정경비", base=expense_total, amount=expense_total, ) net_construction_cost = material_cost + total_labor_cost + expense_total _line( result, key="net_construction_cost", name="순공사원가", base_label="재료비+노무비+경비", base=net_construction_cost, 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, label="일반관리비", ) overhead = _line( result, key="general_overhead", name="일반관리비", base_label="순공사원가", base=net_construction_cost, 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, ) total_cost = net_construction_cost + overhead + profit _line( result, key="total_cost", name="총원가", base_label="순공사원가+일반관리비+이윤", base=total_cost, amount=total_cost, ) vat = _line( result, key="vat", name="부가가치세", base_label="총원가", base=total_cost, percent=flat_rate(dataset, "rate_vat"), ) contract_amount = total_cost + vat _line( result, key="contract_amount", name="도급공사비", base_label="총원가+부가가치세", base=contract_amount, 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="총원가 밖 별도 표기", ) grand_total = contract_amount + owner_total _line( result, key="grand_total", name="총공사비", base_label="도급공사비+관급자재대", base=grand_total, amount=grand_total, ) result.totals = { "material_cost": material_cost, "labor_cost": total_labor_cost, "expense": expense_total, "direct_construction_cost": direct_construction_cost, "net_construction_cost": net_construction_cost, "general_overhead": overhead, "profit": profit, "total_cost": total_cost, "vat": vat, "contract_amount": contract_amount, "owner_supplied_material_total": owner_total, "grand_total": grand_total, } return result