Files
Aislo/B09_Estimation/B09_Estimation_Statutory.py
T
eomsangdonandClaude Opus 5 ec23f87f83 fix(B09): 산출근거 문구 괄호 정정 · 진행단계 라벨도 「원가계산」으로
화면 실측에서 드러난 표기 오류 셋 (공용 브라우저 5174 조작 확인):
- **이윤 밑수가 뒤바뀌어 읽힘** — `(순공사원가+일반관리비) − 재료비 × 15%` 로 나와
  재료비에만 15 %를 곱하는 것처럼 보였음. 밑수가 합·차면 괄호를 씌우도록 고쳐
  `((순공사원가+일반관리비) − 재료비) × 15.0%` 로 나옴.
- **안전관리비 B 산출근거에 밑수 설명 문장이 통째로 박혀** 식이 안 읽혔음.
  밑수 라벨을 「재료비+직접노무비 (관급 제외)」로 줄이고 ×1.2 는 대괄호로 감쌈.
- **채택 요약 줄에 요율이 한 번 더 붙던 것** 제거 — 산식은 A·B 줄에 이미 있음.
- `WF_Step_Estimation` 라벨도 「설계도서」→「원가계산」 (페이지명과 어긋나 있었음).
- `F402` 정리: 구간 지문 루프 변수가 `dataclasses.field` 를 가리던 것 이름 변경.

자체검증 — 브라우저 실측 29줄 렌더 · 요율 판(2026-04-13 / 지문 250b1e4d…) 표시 ·
안전관리비 A 채택/B 취소선 · 환경보전비 「미확정」 경고 표시 ·
목표 도급공사비 1,192,000,000 입력 시 필요 조정액 **394,456** 제안(적용은 안 함) ·
`(1,192,433,902 − 1,192,000,000) ÷ 1.1 = 394,456` 손검산 일치.
pytest 32 passed · ruff 통과 · tsc 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:16:22 +09:00

409 lines
16 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))
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="재료비+직접노무비 (관급 제외)",
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":
# 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}")