refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
"""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 가 항상 작지는 않다. 두 줄을 다 남겨
|
||||
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
||||
|
||||
variable = dataset.variable("rate_safety_pct")
|
||||
brackets = variable["brackets"]
|
||||
|
||||
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
|
||||
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
|
||||
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
|
||||
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
|
||||
if not _threshold_met(
|
||||
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
|
||||
):
|
||||
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
|
||||
|
||||
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:
|
||||
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
|
||||
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
|
||||
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
|
||||
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
|
||||
|
||||
# ⚠ 2026-09-14 고침 — 대상액 50억 이상에서 두 줄(선임 대상 아님/대상)이 겹쳐 멈추던 자리.
|
||||
# 고시 별표1 은 「대상액 50억원 이상」 열과 **따로** 「영 별표5에 따른 보건관리자 선임 대상
|
||||
# 건설공사의 적용비율」 열을 둔다 — 대상액이 아니라 **공사가 선임 대상인가**로 가르는 열.
|
||||
# 선임 대상 금액은 요율 데이터 `manager_thresholds`(800억 · 토목 1,000억, 산업안전보건법
|
||||
# 시행령 별표5 — 원문 미보유). 추정금액 = 규모 기준액으로 견줌.
|
||||
thresholds = variable.get("manager_thresholds") or {}
|
||||
limit = thresholds.get(
|
||||
"civil_main_work_estimated_amount_krw"
|
||||
if data.work_type_safety == "civil"
|
||||
else "default_estimated_amount_krw"
|
||||
)
|
||||
manager_target = limit is not None and ctx.scale_reference >= Decimal(str(limit))
|
||||
|
||||
def evaluate(
|
||||
base: Decimal, multiplier: Decimal, label: str
|
||||
) -> tuple[Decimal, Decimal, Decimal]:
|
||||
if manager_target:
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in brackets
|
||||
if str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold")
|
||||
and r.get("work_type") == data.work_type_safety
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(f"{label}: 보건관리자 선임 대상 요율이 없습니다")
|
||||
else:
|
||||
row = select_bracket(
|
||||
[
|
||||
r
|
||||
for r in brackets
|
||||
if not str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold")
|
||||
],
|
||||
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)
|
||||
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
|
||||
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
|
||||
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
|
||||
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
|
||||
# A(배수 1)는 어느 차례로 해도 같은 값이다.
|
||||
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
|
||||
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
|
||||
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
|
||||
return floor_won(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 실무 `안전관리비검토` 시트 서식).
|
||||
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억 미만 — 줄 자체를 만들지 않는다
|
||||
if key == "subcontract_payment_guarantee" and (
|
||||
str(getattr(data, "subcontract_guarantee", "off") or "off") != "on"
|
||||
):
|
||||
# 기본 꺼짐(2026-09-14) — 실무 원가계산서 여섯 건에 이 줄이 없음. 건설산업기본법
|
||||
# 제34조 대상(하도급대금 지급보증서 교부)이면 설계자가 켬.
|
||||
continue
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user