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