feat(B09): 원가계산 엔진 완성 — 법정경비 분리·이중계상 감시·재현 검산

- `B09_Estimation_Statutory.py` 신설(법정경비 14비목) — 700줄 제한 대비 분리.
  **비목 목록을 코드에 안 박음**: 그 해 요율 데이터에 변수가 있는 것만 유효(8-13·8-14).
- `B09_Estimation_Guards.py` 신설 — 이중계상 거울 테스트 3종을 함수로 둠(8-7 ㉠㉡㉢).
  할증 두 번 · 무대 줄 단가 · 배합 두 번 쪼개기를 **수치로 잡아 멈춤**.
- 엔진: 이윤 3줄(조정 전·조정액·조정 후) · 관급자재대 천원 올림 · 폐기물처리비 실비 슬롯 ·
  `formula_text`(줄마다 제 산식 — 실무 원문의 A식 복사 오류를 안 따라감) ·
  `proposed_profit_adjustment`(필요액을 보여만 주고 적용은 명시로, ★법대로 8-10).
- 요율 로더에 `load_rate_dataset_from_path` 추가 — 옛 연도 재현 검산 전용, 지문이 없어
  정본이 아님이 결과에 드러남.
- 검산 고정값 두 벌(`tmp/tests`, git 밖):
  · 2024 요율 → 울진 공통 금액 사슬 **전건 재현**(안전 16,586,996 · 이윤 108,109,955 ·
    총원가 1,029,117,273 · 총공사비 1,201,879,000)
  · 현행 요율 → 안전 18,586,091. **같은 입력·같은 코드, 요율만 교체** — 연도 교체 구조 증명
  · 거창 A/B min **양방향**(울진 A 채택 · 거창 B 채택)
- 자체검증: `pytest tmp/tests/ -q` **68 passed** · `ruff check` 통과 · 파일 최대 441줄.
- 잠정 반영(TODO 주석 + 계획서 항목번호): 환경보전비 임도 요율 0.9 % 잠정 ·
  폐기물처리비 자리 미확정 · 조달수수료 차감은 기본 꺼짐(옛 서류 재현용 옵션).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 20:01:08 +09:00
co-authored by Claude Opus 5
parent 128a40d8f0
commit 50871e60b9
4 changed files with 751 additions and 454 deletions
+216 -454
View File
@@ -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"))
+111
View File
@@ -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 ㉢)."
)
+18
View File
@@ -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)
+406
View File
@@ -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}")