Files
Aislo/B09_Estimation/B09_Estimation_Rates.py
T
eomsangdonandClaude Opus 5 c044e33dab fix(B09): 규모 구간을 추정가격 반복 수렴으로 판정
- 근거 = 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 **관급자재로 공급될 부분의
  가격을 제외한 금액**」. 우리 계산의 그 값은 **총원가**(부가세 전·관급 밖)임.
- 씨앗은 직접공사비로 두되 **거기서 멈추지 않음** — 나온 총원가로 구간을 다시 판정해
  구간 지문이 굳을 때까지 되풀이(최대 5회). 진동하면 **높은 쪽 구간 채택**하고
  결과 `notes` 에 남김. `estimated_price_krw` 명시 시 반복 없음.
- 구간 지문 = 일반관리비·이윤·고용보험·하도급보증 구간 + 적용 하한(환경·퇴직) 충족 여부.
- ⚠ 잠재 결함 하나 같이 고침 — 하도급보증 30억 이상 구간이 공종(토목·산업설비/건축)으로
  갈려 **구간 2개가 겹쳐 멈추던 자리**. `select_bracket(prefer_suffix=…)` 로 공종을
  부르는 쪽이 대게 함 (조용한 기본값 아님).
- 자체검증: 32 passed. 씨앗 47억(8 %) → 총원가 50억 초과 → **6.5 % 로 재판정**되는
  경계 넘기 사례를 테스트로 박음.

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

272 lines
10 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,
prefer_suffix: 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 len(candidates) > 1 and prefer_suffix is not None and amount_field is not None:
# 같은 금액 구간이 공종으로 갈리는 표가 있다(하도급보증의 `…_integrated_civil…`).
# 부르는 쪽이 공종을 대야 하며, 조용한 기본값이 아니다.
narrowed = [r for r in candidates if str(r.get(amount_field, "")).endswith(prefer_suffix)]
if narrowed:
candidates = narrowed
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))