Files
Aislo/B09_Estimation/B09_Estimation_Statutory.py
eomsangdonandClaude Opus 5 5a016979c4 fix(b09): 안전관리비 제3조 적용 하한을 엔진이 읽게 함 · 1.2배 차례와 관급 ÷1.1 사유 명기
3. 제3조(적용범위) 「총공사금액 2천만 원 이상」 — 요율 데이터의
   minimum_total_construction_amount_krw 를 규모 기준액과 견주고 미달이면 줄 자체를 안 만듦
   (A·B 곁줄도 안 섬 · 0 원으로 안 채움). 잴 시험 먼저 빨강 확인한 뒤 고침.
   ⚠ 견주는 값은 규모 기준액(추정가격 · 없으면 수렴한 총원가) — 고시의 「총공사금액」과 딱 같은
     말은 아니나 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐. 보건관리자 문턱과 같은 축.

1. 1.2배 차례 = STmate 쪽 확정(브레인 판정) — 안 고른 갈래의 사유를 코드에 남김.
   거창 2025 원본은 버림(밑수 × 율 × 1.2) 로 1원 위지만 STmate 출력이 아니라 채택하지 않음.

4. 관급 ÷1.1 = 실무 따름 — 근거를 정확히 적음. 고시 제4조① 단서는 「해당 재료비를 대상액에
   포함」까지만이고 부가세를 말하지 않음 · ÷1.1 은 부가세 제외 환산으로 실무 6건 공통.

전체 시험 1952 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 19:22:08 +09:00

547 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))
#: 70억 이상 구간 식 — `(기초액 + (direct_cost - 기준액) * 율%) * duration_years`.
_RE_GUARANTEE_STEP = re.compile(r"\(\s*(\d+)\s*\+\s*\(\s*direct_cost\s*-\s*(\d+)\s*\)")
def _guarantee_step(row: dict[str, Any]) -> tuple[Decimal, Decimal]:
"""(기초액, 기준액) — 70억 미만 식은 둘 다 0.
⚠ 2026-09-14 고침: 종전엔 백분율만 뽑아 **기초액·기준액 차감을 버렸다**(직공비 70억 이상에서
틀림 — 조달청 제비율 적용기준 §10 「[79만원 + (직공비−75억원) × 0.0070%] × 공기(년)」).
"""
match = _RE_GUARANTEE_STEP.search(str(row.get("formula", "")))
if not match:
return _ZERO, _ZERO
return Decimal(match.group(1)), Decimal(match.group(2))
#: 15. 이행보증 — 일반계약이면 **추정가격 300억 이상**일 때만 계상(STmate 힌트 「일반계약일경우
#: 추정가격 300억 이상공사에 적용」 · 지방계약법 시행령 이행보증서 의무 대상 · 제비율 기준
#: 「소규모 공사는 통상 비대상」). 최저가·기술제안이면 규모와 무관하게 계상.
PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW = Decimal(30_000_000_000)
def _performance_guarantee_applies(data: CostInput, ctx: ExpenseContext) -> bool:
mode = str(getattr(data, "performance_guarantee_mode", "general") or "general")
if mode == "lowest_price_tech":
return True
return ctx.scale_reference >= PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW
def safety_management_cost(
dataset: RateDataset,
data: CostInput,
ctx: ExpenseContext,
emit: Callable[..., Decimal],
) -> Decimal:
"""산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다.
A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액
B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
"""
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
if not _threshold_met(
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
):
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
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:
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
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
# ⚠ 2026-09-14 고침 — 대상액 50억 이상에서 두 줄(선임 대상 아님/대상)이 겹쳐 멈추던 자리.
# 고시 별표1 은 「대상액 50억원 이상」 열과 **따로** 「영 별표5에 따른 보건관리자 선임 대상
# 건설공사의 적용비율」 열을 둔다 — 대상액이 아니라 **공사가 선임 대상인가**로 가르는 열.
# 선임 대상 금액은 요율 데이터 `manager_thresholds`(800억 · 토목 1,000억, 산업안전보건법
# 시행령 별표5 — 원문 미보유). 추정금액 = 규모 기준액으로 견줌.
thresholds = variable.get("manager_thresholds") or {}
limit = thresholds.get(
"civil_main_work_estimated_amount_krw"
if data.work_type_safety == "civil"
else "default_estimated_amount_krw"
)
manager_target = limit is not None and ctx.scale_reference >= Decimal(str(limit))
def evaluate(
base: Decimal, multiplier: Decimal, label: str
) -> tuple[Decimal, Decimal, Decimal]:
if manager_target:
row = next(
(
r
for r in brackets
if str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold")
and r.get("work_type") == data.work_type_safety
),
None,
)
if row is None:
raise RateLookupError(f"{label}: 보건관리자 선임 대상 요율이 없습니다")
else:
row = select_bracket(
[
r
for r in brackets
if not str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold")
],
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)
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
return floor_won(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 실무 `안전관리비검토` 시트 서식).
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
amount_a = emit(
key="safety_management_cost_a",
name="산업안전보건관리비 A(관급 포함)",
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
base=base_with,
percent=percent_a,
flat=flat_a,
raw=raw_a,
note="채택" if adopted == "A" else "미채택",
)
amount_b = emit(
key="safety_management_cost_b",
name="산업안전보건관리비 B(관급 제외 × 1.2)",
base_label="재료비+직접노무비 (관급 제외)",
base=base_without,
percent=percent_b,
flat=flat_b,
raw=raw_b,
note="채택" if adopted == "B" else "미채택",
)
return emit(
key="safety_management_cost",
name="산업안전보건관리비",
base_label=f"A·B 중 작은 금액 (채택 = {adopted})",
base=base_with if adopted == "A" else base_without,
percent=percent_a if adopted == "A" else percent_b,
flat=_ZERO,
raw=min(amount_a, amount_b),
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
)
def statutory_expenses(
dataset: RateDataset,
data: CostInput,
ctx: ExpenseContext,
result: CostResult,
emit: Callable[..., Decimal],
) -> Decimal:
"""켜진 비목만 순서대로 계산해 합계를 돌려준다."""
enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY]
unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY]
if unknown:
raise RateLookupError(f"모르는 비목입니다: {unknown}")
missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables]
if missing:
raise RateLookupError(
f"이 요율 판({dataset.effective_date})에 없는 비목입니다: "
f"{[item_name(k) for k in missing]}"
)
total = _ZERO
health_amount = _ZERO
for key in enabled:
item = _ITEM_BY_KEY[key]
if key == "safety_management_cost":
total += safety_management_cost(dataset, data, ctx, emit)
continue
if key == "long_term_care_insurance":
if "health_insurance" not in enabled:
raise RateLookupError(
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
)
total += emit(
key=key,
name=item.name,
base_label=item.base_label,
base=health_amount,
percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])),
)
continue
if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx):
continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다
if key == "subcontract_payment_guarantee" and (
str(getattr(data, "subcontract_guarantee", "off") or "off") != "on"
):
# 기본 꺼짐(2026-09-14) — 실무 원가계산서 여섯 건에 이 줄이 없음. 건설산업기본법
# 제34조 대상(하도급대금 지급보증서 교부)이면 설계자가 켬.
continue
base, percent, note = _base_and_rate(dataset, data, ctx, item)
if base is None:
continue # 적용 하한 미달 — 줄 자체를 만들지 않는다
flat = _ZERO
if key == "performance_guarantee_fee":
# 70억 이상 구간 기초액 × 공기(년) — 밑수는 `_base_and_rate` 가 기준액을 뺐다.
row = select_bracket(
dataset.variable(item.rate_variable)["brackets"],
amount_field="direct_cost_bracket",
amount=ctx.direct_construction_cost,
label=item.name,
)
flat = _guarantee_step(row)[0] * Decimal(str(data.duration_days)) / Decimal(365)
amount = emit(
key=key,
name=item.name,
base_label=item.base_label,
base=base,
percent=percent,
flat=flat,
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":
# 4. 등급 — 「없음」이면 안 섬 · 등급을 고르면 그 줄 · 자동이면 추정금액 구간(종전).
grade = str(getattr(data, "employment_insurance_grade", "auto") or "auto")
if grade == "none":
return None, _ZERO, ""
if grade != "auto":
row = next((r for r in variable["brackets"] if str(r.get("grade")) == grade), None)
if row is None:
raise RateLookupError(f"고용보험료: 요율표에 없는 등급입니다 — {grade}")
return ctx.total_labor_cost, rate_percent(row, label=item.name), f"{grade}등급 선택"
# 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로
# 잔여 구간을 이름으로 지정한다(등급 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":
# 5. 적용 여부 — 자동은 추정금액 하한(종전) · 적용/미적용은 설계자가 고름.
mode = str(getattr(data, "retirement_mutual_aid_mode", "auto") or "auto")
if mode == "none":
return None, _ZERO, ""
if mode == "auto" and not _threshold_met(
dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference
):
return None, _ZERO, ""
note = "적용 선택(추정금액 하한과 무관)" if mode == "apply" else ""
return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), note
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":
# 14. 낙찰방식 — 턴키(대안)는 규모와 무관한 한 줄(0.084) · 종합심사 대상은 300억 이상 줄.
bid = str(getattr(data, "bid_method", "not_comprehensive") or "not_comprehensive")
if bid == "turnkey":
row = next(
(
r
for r in variable["brackets"]
if r["estimated_price_bracket"].startswith("turnkey")
),
None,
)
if row is None:
raise RateLookupError(f"{item.name}: 턴키(대안) 요율이 요율 데이터에 없습니다")
return ctx.direct_construction_cost, rate_percent(row, label=item.name), "턴키(대안)"
if bid == "comprehensive":
suffix = f"gte_30_billion_{data.subcontract_guarantee_variant}"
row = next(
(r for r in variable["brackets"] if r["estimated_price_bracket"] == suffix), None
)
if row is None:
raise RateLookupError(f"{item.name}: 종합심사 대상 요율이 없습니다 — {suffix}")
return ctx.direct_construction_cost, rate_percent(row, label=item.name), "종합심사 대상"
# 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
row = select_bracket(
variable["brackets"],
amount_field="estimated_price_bracket",
amount=ctx.scale_reference,
prefer_suffix=data.subcontract_guarantee_variant,
label=item.name,
)
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
if key == "performance_guarantee_fee":
row = select_bracket(
variable["brackets"],
amount_field="direct_cost_bracket",
amount=ctx.direct_construction_cost,
label=item.name,
)
percent = _guarantee_percent_from_formula(row, label=item.name)
years = Decimal(str(data.duration_days)) / Decimal(365)
note = variable.get("typical_forest_road_applicability", "")
deduct = _guarantee_step(row)[1]
return (
(ctx.direct_construction_cost - deduct) * years,
percent,
f"임도 적용성: {note}" if note else "",
)
raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")