- `B09_Estimation_Statutory.py` 신설(법정경비 14비목) — 700줄 제한 대비 분리.
**비목 목록을 코드에 안 박음**: 그 해 요율 데이터에 변수가 있는 것만 유효(8-13·8-14).
- `B09_Estimation_Guards.py` 신설 — 이중계상 거울 테스트 3종을 함수로 둠(8-7 ㉠㉡㉢).
할증 두 번 · 무대 줄 단가 · 배합 두 번 쪼개기를 **수치로 잡아 멈춤**.
- 엔진: 이윤 3줄(조정 전·조정액·조정 후) · 관급자재대 천원 올림 · 폐기물처리비 실비 슬롯 ·
`formula_text`(줄마다 제 산식 — 실무 원문의 A식 복사 오류를 안 따라감) ·
`proposed_profit_adjustment`(필요액을 보여만 주고 적용은 명시로, ★법대로 8-10).
- 요율 로더에 `load_rate_dataset_from_path` 추가 — 옛 연도 재현 검산 전용, 지문이 없어
정본이 아님이 결과에 드러남.
- 검산 고정값 두 벌(`tmp/tests`, git 밖):
· 2024 요율 → 울진 공통 금액 사슬 **전건 재현**(안전 16,586,996 · 이윤 108,109,955 ·
총원가 1,029,117,273 · 총공사비 1,201,879,000)
· 현행 요율 → 안전 18,586,091. **같은 입력·같은 코드, 요율만 교체** — 연도 교체 구조 증명
· 거창 A/B min **양방향**(울진 A 채택 · 거창 B 채택)
- 자체검증: `pytest tmp/tests/ -q` **68 passed** · `ruff check` 통과 · 파일 최대 441줄.
- 잠정 반영(TODO 주석 + 계획서 항목번호): 환경보전비 임도 요율 0.9 % 잠정 ·
폐기물처리비 자리 미확정 · 조달수수료 차감은 기본 꺼짐(옛 서류 재현용 옵션).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
9.8 KiB
Python
264 lines
9.8 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", {}),
|
|
)
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def load_rate_dataset_from_path(path: str) -> RateDataset:
|
|
"""매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**.
|
|
|
|
정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면
|
|
그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에
|
|
`sha256=""` 로 남아 **정본이 아님이 드러난다**.
|
|
"""
|
|
with open(path, encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
return RateDataset(
|
|
dataset_id=payload.get("dataset_id", ""),
|
|
effective_date=payload.get("effective_date", ""),
|
|
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))
|