Files
Aislo/B09_Estimation/B09_Estimation_Statutory.py
T
eomsangdonandClaude Opus 5 8693cf0012 feat(b09): 원가계산서 기준 입력 13~16 — 사급비 위치 · 낙찰 · 이행보증 · 적용기준 · 이행보증 식 고침
- 13 사급비 위치: 재료비에 포함만(사급은 이미 내역 재료비 · 실무 거창·영월) — 칸만 받음
- 14 낙찰방식: 하도급대금 지급보증 요율 줄 — 종합심사 외(규모 구간) · 종합심사 대상(300억 이상 줄) · 턴키(0.084)
- 15 이행보증: 일반계약 = 추정가격 300억 이상만 공사이행보증수수료 · 최저가·기술제안 = 규모 무관
  (종전 늘 계산 — 제비율 기준 「소규모 통상 비대상」·실무 여섯 건 금액 없음과 어긋났음)
- 곁 고침: 70억 이상 구간 [기초액 + (직공비−기준액) × 율] × 공기 — 종전 백분율만 쓰고 기초액·기준액을 버렸음
- 16 적용기준: 칸만 받음(조달청 표 한 벌 · 제20조 국가 표 이미지 미확인 · 행자부 표 없음)
- 칸 밑 안내 · 시험: 300억 미만 안 섬 · 100억 식 965,000 · 요율 줄 셋 · 13·16 계산 불변 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:56:26 +09:00

491 lines
20 KiB
Python
Raw 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 가 항상 작지는 않다. 두 줄을 다 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
"""
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
if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx):
continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다
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}")