Files
Aislo/B09_Estimation/B09_Estimation_Rates.py
T
eomsangdonandClaude Opus 5 8b7325a28e feat(B09): 원가계산 엔진·요율 로더 뼈대 추가
- `B09_Estimation_Rates.py` — 요율 데이터(`resources/data_cost_input_value/rates_*.json`)
  로더 + 구간 조회. 요율을 코드에 안 박음. 구간 라벨의 `billion` = 십억 원.
  구간 판정 실패 시 기본값으로 안 때우고 `RateLookupError` 로 멈춤.
- `B09_Estimation_Engine_Cost.py` — ⑤ 공사원가계산서 계산. 순공사비를 받아
  법정경비·일반관리비·이윤·부가세를 얹음. 수량·단가와 무관하게 홀로 돎.
- 지킨 것 (PLAN 8-9·8-10, 실무 원가계산서 재현으로 확인된 것만)
  · 모든 줄 원 단위 버림 · 밑수가 항목마다 갈림(직노 / 직노+간노 / 건강보험료 / …)
  · 안전관리비 A·B 두 값 중 작은 쪽 — 대상액이 구간 경계를 넘으면 A 가 더 커짐
  · 이윤 수동 조정액은 설계자 명시 입력일 때만 · 비목 목록을 코드에 안 박음
- 검사 16건 별도(`tmp/tests/test_b09_cost_engine.py`, git 밖) 전건 통과.
  실무 실측 두 방향(울진 A 채택 · 거창 B 채택)을 고정값으로 씀.
- 기존 파일 무수정 · DB 미사용(기준자료는 파일, PLAN 8-5·9-2).

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

246 lines
9.1 KiB
Python

"""B09 원가계산 — 요율 데이터 로더·구간 조회.
요율은 **코드에 박지 않는다**. `resources/data_cost_input_value/rates_*.json` 이 정본이고
이 모듈은 그 파일을 읽어 구간을 골라 주는 일만 한다 (PLAN 9-2·8-10 ★법대로).
핵심 규칙 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인):
- 요율표는 **한 벌**이다. 안전관리비 A/B 는 요율이 두 벌인 것이 아니라
**같은 표를 대상액 두 개로 각각 조회**하는 것이다.
- 구간 라벨의 `billion` 은 **십억 원(10^9)**, `million` 은 **백만 원(10^6)** 이다.
`lt_5_billion` = 50억 미만. (2026-09-07 값 파일 대조로 확정)
- 판정 실패는 **조용히 넘기지 않는다** — 기본값으로 때우면 금액이 조용히 틀린다.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from decimal import Decimal
from functools import lru_cache
from typing import Any
# 구간 라벨의 단위 접미사 → 원(KRW) 배수.
_UNIT_MULTIPLIER: dict[str, int] = {
"million": 1_000_000,
"billion": 1_000_000_000,
}
_RESOURCE_SUBPATH = ("resources", "data_cost_input_value")
# 라벨 문법 — 숫자 구간만 해석한다. 그 밖(`turnkey_or_alternative` 등)은 명시 선택자로 고른다.
_RE_LT = re.compile(r"^lt_(\d+(?:\.\d+)?)_(million|billion)$")
_RE_GTE = re.compile(r"^gte_(\d+(?:\.\d+)?)_(million|billion)(?:_(.+))?$")
_RE_RANGE_ONE_UNIT = re.compile(r"^(\d+(?:\.\d+)?)_to_(\d+(?:\.\d+)?)_(million|billion)$")
_RE_RANGE_TWO_UNIT = re.compile(
r"^(\d+(?:\.\d+)?)_(million|billion)_to_(\d+(?:\.\d+)?)_(million|billion)$"
)
_RE_DAYS_LTE = re.compile(r"^lte_(\d+)_days$")
_RE_DAYS_GTE = re.compile(r"^gte_(\d+)_days$")
_RE_DAYS_RANGE = re.compile(r"^(\d+)_to_(\d+)_days$")
class RateLookupError(LookupError):
"""요율 구간을 못 고른 경우. 기본값으로 때우지 않고 여기서 멈춘다."""
@dataclass(frozen=True)
class RateDataset:
"""요율 데이터셋 한 벌 — 재현성 표기용 신원(9-2)을 함께 든다."""
dataset_id: str
effective_date: str
sha256: str
variables: dict[str, Any]
def variable(self, name: str) -> Any:
try:
return self.variables[name]
except KeyError as exc: # pragma: no cover - 데이터 파손 시에만
raise RateLookupError(f"요율 항목이 데이터셋에 없습니다: {name}") from exc
@property
def version_stamp(self) -> dict[str, str]:
"""내역서·화면에 남길 「어느 판으로 계산했나」 표기."""
return {
"dataset_id": self.dataset_id,
"effective_date": self.effective_date,
"sha256": self.sha256,
}
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _dataset_dir() -> str:
return os.path.join(_project_root(), *_RESOURCE_SUBPATH)
def _manifest_entry(file_name: str) -> dict[str, Any]:
manifest_path = os.path.join(_dataset_dir(), "_manifest.json")
with open(manifest_path, encoding="utf-8") as handle:
manifest = json.load(handle)
for entry in manifest.get("files", []):
if entry.get("file") == file_name:
return entry
raise RateLookupError(f"매니페스트에 없는 요율 파일입니다: {file_name}")
@lru_cache(maxsize=8)
def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset:
"""요율 파일 한 벌을 읽는다. 매니페스트의 지문·적용일을 함께 실어 재현성을 남긴다."""
entry = _manifest_entry(file_name)
with open(os.path.join(_dataset_dir(), file_name), encoding="utf-8") as handle:
payload = json.load(handle)
return RateDataset(
dataset_id=payload.get("dataset_id", entry.get("dataset_id", "")),
effective_date=payload.get("effective_date", entry.get("effective_date", "")),
sha256=entry.get("sha256", ""),
variables=payload.get("variables", {}),
)
def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None:
"""금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None."""
match = _RE_LT.match(label)
if match:
return Decimal(0), Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)]
match = _RE_RANGE_TWO_UNIT.match(label)
if match:
low = Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)]
high = Decimal(match.group(3)) * _UNIT_MULTIPLIER[match.group(4)]
return low, high
match = _RE_RANGE_ONE_UNIT.match(label)
if match:
unit = _UNIT_MULTIPLIER[match.group(3)]
return Decimal(match.group(1)) * unit, Decimal(match.group(2)) * unit
match = _RE_GTE.match(label)
if match:
return Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)], Decimal("Infinity")
return None
def _duration_bounds(label: str) -> tuple[int, int] | None:
"""공사기간 구간 라벨 → [하한일, 상한일]. 숫자 구간이 아니면 None."""
match = _RE_DAYS_LTE.match(label)
if match:
return 0, int(match.group(1))
match = _RE_DAYS_RANGE.match(label)
if match:
return int(match.group(1)), int(match.group(2))
match = _RE_DAYS_GTE.match(label)
if match:
return int(match.group(1)), 10**9
return None
def _amount_matches(label: str, amount: Decimal) -> bool:
bounds = _bracket_bounds(label)
if bounds is None:
return False
low, high = bounds
return low <= amount < high
def _duration_matches(label: str, days: int) -> bool:
bounds = _duration_bounds(label)
if bounds is None:
return False
low, high = bounds
return low <= days <= high
def select_bracket(
brackets: list[dict[str, Any]],
*,
amount_field: str | None = None,
amount: Decimal | None = None,
duration_days: int | None = None,
duration_field: str = "duration_bracket",
equals: dict[str, Any] | None = None,
residual_label: str | None = None,
label: str,
) -> dict[str, Any]:
"""구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다.
`equals` 는 `work_type` 처럼 값이 그대로 맞아야 하는 열이다.
`residual_label` 은 숫자 구간이 아닌 **잔여 구간** 라벨이다(예: 고용보험료의
`below_official_threshold`). 숫자 구간이 하나도 안 맞을 때만 쓰며, **부르는 쪽이
이름을 대야** 한다 — 조용한 기본값이 아니다.
"""
candidates = list(brackets)
if equals:
for key, expected in equals.items():
candidates = [row for row in candidates if row.get(key) == expected]
if amount_field is not None and amount is not None:
candidates = [
row for row in candidates if _amount_matches(str(row.get(amount_field, "")), amount)
]
if duration_days is not None:
candidates = [
row
for row in candidates
if _duration_matches(str(row.get(duration_field, "")), duration_days)
]
if not candidates and residual_label is not None and amount_field is not None:
candidates = [row for row in brackets if row.get(amount_field) == residual_label]
if equals:
for key, expected in equals.items():
candidates = [row for row in candidates if row.get(key) == expected]
if not candidates:
raise RateLookupError(
f"{label}: 조건에 맞는 요율 구간이 없습니다 "
f"(금액={amount}, 기간={duration_days}일, 조건={equals})"
)
if len(candidates) > 1:
raise RateLookupError(
f"{label}: 요율 구간이 {len(candidates)}개 겹칩니다 — 데이터 점검 필요 "
f"({[row.get(amount_field) for row in candidates]})"
)
return candidates[0]
def rate_percent(row: dict[str, Any], *, label: str) -> Decimal:
if "rate_percent" not in row:
raise RateLookupError(f"{label}: 고른 구간에 요율이 없습니다 ({row})")
return Decimal(str(row["rate_percent"]))
def base_amount(row: dict[str, Any]) -> Decimal:
"""구간에 딸린 기초액(안전관리비 등). 없으면 0."""
return Decimal(str(row.get("base_amount_krw", 0)))
def flat_rate(dataset: RateDataset, name: str) -> Decimal:
"""구간이 없는 단일 요율(산재·건강·요양·부가세 등)."""
variable = dataset.variable(name)
if "rate_percent" not in variable:
raise RateLookupError(f"{name}: 단일 요율이 아닙니다 — 구간 조회가 필요합니다")
return Decimal(str(variable["rate_percent"]))
def pension_rate_percent(dataset: RateDataset, year: int) -> Decimal:
"""국민연금 — 연도별 특례 스케줄(2026 = 4.75 %, 2033~ 본칙 6.5 %)."""
variable = dataset.variable("rate_pension")
for row in variable.get("annual_rates", []):
if int(row.get("year", 0)) == year:
return Decimal(str(row["rate_percent"]))
fallback = variable.get("rate_from_2033_percent")
if fallback is None:
raise RateLookupError(f"rate_pension: {year}년 요율이 없습니다")
return Decimal(str(fallback))