feat(B09): 원가계산 엔진 완성 — 법정경비 분리·이중계상 감시·재현 검산
- `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>
This commit is contained in:
@@ -2,86 +2,65 @@
|
||||
|
||||
순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어
|
||||
**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5).
|
||||
법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한).
|
||||
|
||||
지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만)
|
||||
지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인)
|
||||
1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다.
|
||||
2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / …
|
||||
하나로 뭉치면 틀린다.
|
||||
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호).
|
||||
⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다
|
||||
(실증: 울진 A 채택 / 거창 B 채택).
|
||||
4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는
|
||||
관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다.
|
||||
5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등).
|
||||
6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다.
|
||||
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다.
|
||||
4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.**
|
||||
5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10).
|
||||
6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기.
|
||||
7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_Rates import (
|
||||
RateDataset,
|
||||
RateLookupError,
|
||||
base_amount,
|
||||
flat_rate,
|
||||
load_rate_dataset,
|
||||
pension_rate_percent,
|
||||
load_rate_dataset_from_path,
|
||||
rate_percent,
|
||||
select_bracket,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Statutory import (
|
||||
ExpenseContext,
|
||||
available_items,
|
||||
statutory_expenses,
|
||||
)
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
_HUNDRED = Decimal(100)
|
||||
_VAT_DIVISOR = Decimal("1.1")
|
||||
|
||||
#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다.
|
||||
DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = (
|
||||
"industrial_accident_insurance",
|
||||
"employment_insurance",
|
||||
"health_insurance",
|
||||
"long_term_care_insurance",
|
||||
"national_pension",
|
||||
"safety_management_cost",
|
||||
"other_expense",
|
||||
"environment_preservation",
|
||||
"retirement_mutual_aid",
|
||||
)
|
||||
|
||||
#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장).
|
||||
OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = (
|
||||
"wage_claim_contribution",
|
||||
"asbestos_contribution",
|
||||
"equipment_payment_guarantee",
|
||||
"subcontract_payment_guarantee",
|
||||
"performance_guarantee_fee",
|
||||
)
|
||||
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
|
||||
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
|
||||
DEFAULT_ITEMS = "ALL_AVAILABLE"
|
||||
|
||||
|
||||
def floor_won(value: Decimal) -> Decimal:
|
||||
"""원 단위 버림 — 원가계산서 모든 줄의 기본 처리."""
|
||||
"""원 단위 버림 — 원가계산서 모든 줄의 기본 처리 (PLAN 8-9 규칙 1)."""
|
||||
return value.quantize(Decimal(1), rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
def ceil_thousand(value: Decimal) -> Decimal:
|
||||
"""천원 올림 — 관급자재대 표기."""
|
||||
"""천원 올림 — 관급자재대 표기 (PLAN 8-9 규칙 7)."""
|
||||
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostInput:
|
||||
"""원가계산 입력.
|
||||
|
||||
금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다.
|
||||
"""
|
||||
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
|
||||
|
||||
direct_material_krw: Decimal
|
||||
direct_labor_krw: Decimal
|
||||
direct_expense_krw: Decimal
|
||||
indirect_material_krw: Decimal = _ZERO
|
||||
|
||||
#: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다.
|
||||
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
|
||||
work_type_indirect_labor: str = "civil"
|
||||
work_type_safety: str = "civil"
|
||||
duration_days: int = 183
|
||||
@@ -92,31 +71,44 @@ class CostInput:
|
||||
procurement_fee_krw: Decimal = _ZERO
|
||||
include_fee_in_owner_material_total: bool = True
|
||||
|
||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다.
|
||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
||||
owner_supplied_for_safety_krw: Decimal | None = None
|
||||
#: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준).
|
||||
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
|
||||
owner_supplied_includes_vat: bool = True
|
||||
#: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐.
|
||||
#: 옛 서류(울진 2024) 재현 검산에만 켠다.
|
||||
deduct_procurement_fee_for_safety: bool = False
|
||||
|
||||
#: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피).
|
||||
#: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피).
|
||||
estimated_price_krw: Decimal | None = None
|
||||
|
||||
#: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다.
|
||||
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
|
||||
profit_adjustment_krw: Decimal = _ZERO
|
||||
|
||||
#: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값).
|
||||
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
|
||||
#: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」).
|
||||
#: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐.
|
||||
waste_disposal_krw: Decimal = _ZERO
|
||||
|
||||
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
|
||||
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
|
||||
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
|
||||
environment_work_type: str = "civil_road"
|
||||
#: 건설기계대여대금 지급보증 공종.
|
||||
equipment_guarantee_work_type: str = "civil_general"
|
||||
|
||||
enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS
|
||||
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
|
||||
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
|
||||
|
||||
#: 요율 데이터 파일명. 연도를 갈아끼우는 자리.
|
||||
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
|
||||
rate_file_name: str = "rates_2026.json"
|
||||
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
|
||||
rate_file_path: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostLine:
|
||||
"""원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다."""
|
||||
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
@@ -127,6 +119,21 @@ class CostLine:
|
||||
amount_krw: Decimal
|
||||
note: str = ""
|
||||
|
||||
@property
|
||||
def formula_text(self) -> str:
|
||||
"""화면 `산출근거` 칸 문구 — 줄마다 **제 산식**을 적는다.
|
||||
|
||||
실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13).
|
||||
"""
|
||||
if self.rate_percent is None:
|
||||
return self.base_label
|
||||
text = f"{self.base_label} × {self.rate_percent}%"
|
||||
if self.flat_amount_krw:
|
||||
text += f" + {self.flat_amount_krw:,.0f}"
|
||||
if self.key == "safety_management_cost_b":
|
||||
text = f"({text}) × 1.2"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostResult:
|
||||
@@ -144,336 +151,57 @@ class CostResult:
|
||||
def amount(self, key: str) -> Decimal:
|
||||
return self.line(key).amount_krw
|
||||
|
||||
|
||||
def _line(
|
||||
result: CostResult,
|
||||
*,
|
||||
key: str,
|
||||
name: str,
|
||||
base_label: str,
|
||||
base: Decimal,
|
||||
percent: Decimal | None = None,
|
||||
flat: Decimal = _ZERO,
|
||||
amount: Decimal | None = None,
|
||||
note: str = "",
|
||||
) -> Decimal:
|
||||
"""줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림."""
|
||||
if amount is None:
|
||||
computed = base * (percent or _ZERO) / _HUNDRED + flat
|
||||
amount = floor_won(computed)
|
||||
result.lines.append(
|
||||
CostLine(
|
||||
key=key,
|
||||
name=name,
|
||||
base_label=base_label,
|
||||
base_amount_krw=base,
|
||||
rate_percent=percent,
|
||||
flat_amount_krw=flat,
|
||||
amount_krw=amount,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
return amount
|
||||
def has(self, key: str) -> bool:
|
||||
return any(item.key == key for item in self.lines)
|
||||
|
||||
|
||||
def _safety_management_cost(
|
||||
result: CostResult,
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
*,
|
||||
material_cost: 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.owner_supplied_includes_vat:
|
||||
owner_supplied = owner_supplied / _VAT_DIVISOR
|
||||
|
||||
base_with = material_cost + data.direct_labor_krw + owner_supplied
|
||||
base_without = material_cost + data.direct_labor_krw
|
||||
|
||||
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)
|
||||
amount = floor_won((base * percent / _HUNDRED + flat) * multiplier)
|
||||
return amount, percent, flat
|
||||
|
||||
amount_a, percent_a, flat_a = evaluate(
|
||||
base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)"
|
||||
)
|
||||
amount_b, percent_b, flat_b = evaluate(
|
||||
base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)"
|
||||
)
|
||||
|
||||
adopted = "A" if amount_a <= amount_b else "B"
|
||||
|
||||
_line(
|
||||
result,
|
||||
key="safety_management_cost_a",
|
||||
name="산업안전보건관리비 A(관급 포함)",
|
||||
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
|
||||
base=base_with,
|
||||
percent=percent_a,
|
||||
flat=flat_a,
|
||||
amount=amount_a,
|
||||
note="채택" if adopted == "A" else "미채택",
|
||||
)
|
||||
_line(
|
||||
result,
|
||||
key="safety_management_cost_b",
|
||||
name="산업안전보건관리비 B(관급 제외 × 1.2)",
|
||||
base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배",
|
||||
base=base_without,
|
||||
percent=percent_b,
|
||||
flat=flat_b,
|
||||
amount=amount_b,
|
||||
note="채택" if adopted == "B" else "미채택",
|
||||
)
|
||||
|
||||
adopted_amount = min(amount_a, amount_b)
|
||||
return _line(
|
||||
result,
|
||||
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,
|
||||
amount=adopted_amount,
|
||||
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
|
||||
)
|
||||
def _load_dataset(data: CostInput) -> RateDataset:
|
||||
if data.rate_file_path:
|
||||
return load_rate_dataset_from_path(data.rate_file_path)
|
||||
return load_rate_dataset(data.rate_file_name)
|
||||
|
||||
|
||||
def _statutory_expenses(
|
||||
result: CostResult,
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
*,
|
||||
material_cost: Decimal,
|
||||
total_labor_cost: Decimal,
|
||||
direct_construction_cost: Decimal,
|
||||
) -> Decimal:
|
||||
"""법정경비 묶음. `enabled_items` 에 든 줄만 계산한다."""
|
||||
enabled = set(data.enabled_items)
|
||||
total = _ZERO
|
||||
def _emitter(result: CostResult):
|
||||
"""줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다."""
|
||||
|
||||
if "industrial_accident_insurance" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="industrial_accident_insurance",
|
||||
name="산재보험료",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_sanjae"),
|
||||
)
|
||||
|
||||
if "employment_insurance" in enabled:
|
||||
variable = dataset.variable("rate_goyong")
|
||||
# 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라
|
||||
# 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %).
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_amount_bracket",
|
||||
amount=_scale_reference(data, direct_construction_cost),
|
||||
residual_label="below_official_threshold",
|
||||
label="고용보험료",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="employment_insurance",
|
||||
name="고용보험료",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=rate_percent(row, label="고용보험료"),
|
||||
)
|
||||
|
||||
health_amount = _ZERO
|
||||
if "health_insurance" in enabled:
|
||||
health_amount = _line(
|
||||
result,
|
||||
key="health_insurance",
|
||||
name="국민건강보험료",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=flat_rate(dataset, "rate_health"),
|
||||
)
|
||||
total += health_amount
|
||||
|
||||
if "long_term_care_insurance" in enabled:
|
||||
if "health_insurance" not in enabled:
|
||||
raise RateLookupError(
|
||||
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
|
||||
def emit(
|
||||
*,
|
||||
key: str,
|
||||
name: str,
|
||||
base_label: str,
|
||||
base: Decimal,
|
||||
percent: Decimal | None = None,
|
||||
flat: Decimal = _ZERO,
|
||||
raw: Decimal | None = None,
|
||||
amount: Decimal | None = None,
|
||||
note: str = "",
|
||||
) -> Decimal:
|
||||
if amount is None:
|
||||
computed = raw if raw is not None else base * (percent or _ZERO) / _HUNDRED + flat
|
||||
amount = floor_won(computed)
|
||||
result.lines.append(
|
||||
CostLine(
|
||||
key=key,
|
||||
name=name,
|
||||
base_label=base_label,
|
||||
base_amount_krw=base,
|
||||
rate_percent=percent,
|
||||
flat_amount_krw=flat,
|
||||
amount_krw=amount,
|
||||
note=note,
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="long_term_care_insurance",
|
||||
name="노인장기요양보험료",
|
||||
base_label="국민건강보험료",
|
||||
base=health_amount,
|
||||
percent=flat_rate(dataset, "rate_care"),
|
||||
)
|
||||
return amount
|
||||
|
||||
if "national_pension" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="national_pension",
|
||||
name="국민연금보험료",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=pension_rate_percent(dataset, data.pension_year),
|
||||
)
|
||||
|
||||
if "safety_management_cost" in enabled:
|
||||
total += _safety_management_cost(result, dataset, data, material_cost=material_cost)
|
||||
|
||||
if "other_expense" in enabled:
|
||||
variable = dataset.variable("rate_other_expense")
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=direct_construction_cost,
|
||||
duration_days=data.duration_days,
|
||||
equals={"work_type": data.work_type_indirect_labor},
|
||||
label="기타경비",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="other_expense",
|
||||
name="기타경비",
|
||||
base_label="재료비+노무비(직접+간접)",
|
||||
base=material_cost + total_labor_cost,
|
||||
percent=rate_percent(row, label="기타경비"),
|
||||
)
|
||||
|
||||
if "environment_preservation" in enabled:
|
||||
variable = dataset.variable("rate_environment")
|
||||
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
|
||||
if _scale_reference(data, direct_construction_cost) >= threshold:
|
||||
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}"
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="environment_preservation",
|
||||
name="환경보전비",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="환경보전비"),
|
||||
note=(
|
||||
"⚠ 임도 공종 채택값 미확정 — 지식DB "
|
||||
"`rate_environment.forest_road_selection_status: pending`"
|
||||
),
|
||||
)
|
||||
|
||||
if "retirement_mutual_aid" in enabled:
|
||||
variable = dataset.variable("rate_retirement_mutual_aid")
|
||||
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
|
||||
if _scale_reference(data, direct_construction_cost) >= threshold:
|
||||
total += _line(
|
||||
result,
|
||||
key="retirement_mutual_aid",
|
||||
name="퇴직공제부금비",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=Decimal(str(variable["rate_percent"])),
|
||||
)
|
||||
|
||||
if "wage_claim_contribution" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="wage_claim_contribution",
|
||||
name="임금채권보장기금 부담금",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_wage_claim_contribution"),
|
||||
)
|
||||
|
||||
if "asbestos_contribution" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="asbestos_contribution",
|
||||
name="석면피해구제 분담금",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_asbestos_contribution"),
|
||||
)
|
||||
|
||||
if "equipment_payment_guarantee" in enabled:
|
||||
variable = dataset.variable("rate_equipment_payment_guarantee")
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in variable["general_construction"] + variable["specialty_construction"]
|
||||
if r.get("work_type") == data.equipment_guarantee_work_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(
|
||||
"건설기계대여대금 지급보증: 공종을 못 찾았습니다 — "
|
||||
f"{data.equipment_guarantee_work_type}"
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="equipment_payment_guarantee",
|
||||
name="건설기계대여대금 지급보증수수료",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"),
|
||||
)
|
||||
|
||||
if "subcontract_payment_guarantee" in enabled:
|
||||
variable = dataset.variable("rate_subcontract_payment_guarantee")
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=_scale_reference(data, direct_construction_cost),
|
||||
label="하도급대금 지급보증수수료",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="subcontract_payment_guarantee",
|
||||
name="하도급대금 지급보증수수료",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="하도급대금 지급보증수수료"),
|
||||
)
|
||||
|
||||
return total
|
||||
return emit
|
||||
|
||||
|
||||
def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal:
|
||||
"""규모 구간 판정 기준액.
|
||||
|
||||
조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에
|
||||
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을,
|
||||
없으면 **직접공사비**를 기준으로 쓴다.
|
||||
조달청 제비율표는 「추정가격」으로 구간을 나누지만 추정가격은 원가 계산 결과에
|
||||
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 명시하면 그 값을, 없으면
|
||||
**직접공사비**를 쓴다.
|
||||
"""
|
||||
if data.estimated_price_krw is not None:
|
||||
return data.estimated_price_krw
|
||||
@@ -482,12 +210,15 @@ def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Deci
|
||||
|
||||
def calculate_cost(data: CostInput) -> CostResult:
|
||||
"""공사원가계산서 한 장을 계산한다."""
|
||||
dataset = load_rate_dataset(data.rate_file_name)
|
||||
dataset = _load_dataset(data)
|
||||
result = CostResult(rate_version=dataset.version_stamp)
|
||||
emit = _emitter(result)
|
||||
|
||||
if data.enabled_items == DEFAULT_ITEMS:
|
||||
data = replace(data, enabled_items=available_items(dataset))
|
||||
|
||||
material_cost = data.direct_material_krw + data.indirect_material_krw
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="material_cost",
|
||||
name="재료비",
|
||||
base_label="직접재료비+간접재료비",
|
||||
@@ -497,7 +228,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
|
||||
direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw
|
||||
|
||||
indirect_labor_row = select_bracket(
|
||||
indirect_row = select_bracket(
|
||||
dataset.variable("rate_indirect_labor")["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=direct_construction_cost,
|
||||
@@ -505,17 +236,15 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
equals={"work_type": data.work_type_indirect_labor},
|
||||
label="간접노무비",
|
||||
)
|
||||
indirect_labor = _line(
|
||||
result,
|
||||
indirect_labor = emit(
|
||||
key="indirect_labor_cost",
|
||||
name="간접노무비",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=rate_percent(indirect_labor_row, label="간접노무비"),
|
||||
percent=rate_percent(indirect_row, label="간접노무비"),
|
||||
)
|
||||
total_labor_cost = data.direct_labor_krw + indirect_labor
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="labor_cost",
|
||||
name="노무비",
|
||||
base_label="직접노무비+간접노무비",
|
||||
@@ -523,17 +252,17 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=total_labor_cost,
|
||||
)
|
||||
|
||||
statutory = _statutory_expenses(
|
||||
result,
|
||||
dataset,
|
||||
data,
|
||||
ctx = ExpenseContext(
|
||||
material_cost=material_cost,
|
||||
direct_labor_cost=data.direct_labor_krw,
|
||||
total_labor_cost=total_labor_cost,
|
||||
direct_construction_cost=direct_construction_cost,
|
||||
scale_reference=_scale_reference(data, direct_construction_cost),
|
||||
)
|
||||
statutory = statutory_expenses(dataset, data, ctx, result, emit)
|
||||
|
||||
expense_total = data.direct_expense_krw + statutory
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="expense",
|
||||
name="경비",
|
||||
base_label="직접경비(산출경비)+법정경비",
|
||||
@@ -542,8 +271,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
)
|
||||
|
||||
net_construction_cost = material_cost + total_labor_cost + expense_total
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="net_construction_cost",
|
||||
name="순공사원가",
|
||||
base_label="재료비+노무비+경비",
|
||||
@@ -551,15 +279,13 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=net_construction_cost,
|
||||
)
|
||||
|
||||
scale = _scale_reference(data, direct_construction_cost)
|
||||
overhead_row = select_bracket(
|
||||
dataset.variable("rate_overhead")["civil_landscape_industrial"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=scale,
|
||||
amount=ctx.scale_reference,
|
||||
label="일반관리비",
|
||||
)
|
||||
overhead = _line(
|
||||
result,
|
||||
overhead = emit(
|
||||
key="general_overhead",
|
||||
name="일반관리비",
|
||||
base_label="순공사원가",
|
||||
@@ -567,55 +293,17 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
percent=rate_percent(overhead_row, label="일반관리비"),
|
||||
)
|
||||
|
||||
profit_row = select_bracket(
|
||||
dataset.variable("rate_profit")["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=scale,
|
||||
label="이윤",
|
||||
)
|
||||
profit_base = total_labor_cost + expense_total + overhead
|
||||
profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED)
|
||||
_line(
|
||||
result,
|
||||
key="profit_before_adjustment",
|
||||
name="이윤(조정 전)",
|
||||
base_label="노무비+경비+일반관리비 (재료비 제외)",
|
||||
base=profit_base,
|
||||
percent=rate_percent(profit_row, label="이윤"),
|
||||
amount=profit_before,
|
||||
)
|
||||
if data.profit_adjustment_krw:
|
||||
_line(
|
||||
result,
|
||||
key="profit_adjustment",
|
||||
name="이윤 조정액",
|
||||
base_label="설계자 명시 입력",
|
||||
base=_ZERO,
|
||||
amount=-data.profit_adjustment_krw,
|
||||
note="도급공사비 끝수 맞춤 — 법정 항목 아님",
|
||||
)
|
||||
profit = profit_before - data.profit_adjustment_krw
|
||||
_line(
|
||||
result,
|
||||
key="profit",
|
||||
name="이윤",
|
||||
base_label="조정 전 이윤 − 조정액",
|
||||
base=profit_base,
|
||||
amount=profit,
|
||||
)
|
||||
profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead)
|
||||
|
||||
total_cost = net_construction_cost + overhead + profit
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="total_cost",
|
||||
name="총원가",
|
||||
base_label="순공사원가+일반관리비+이윤",
|
||||
base=total_cost,
|
||||
amount=total_cost,
|
||||
)
|
||||
|
||||
vat = _line(
|
||||
result,
|
||||
vat = emit(
|
||||
key="vat",
|
||||
name="부가가치세",
|
||||
base_label="총원가",
|
||||
@@ -623,8 +311,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
percent=flat_rate(dataset, "rate_vat"),
|
||||
)
|
||||
contract_amount = total_cost + vat
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="contract_amount",
|
||||
name="도급공사비",
|
||||
base_label="총원가+부가가치세",
|
||||
@@ -632,32 +319,14 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=contract_amount,
|
||||
)
|
||||
|
||||
owner_total = _ZERO
|
||||
if data.owner_supplied_material_krw:
|
||||
raw = data.owner_supplied_material_krw
|
||||
if data.include_fee_in_owner_material_total:
|
||||
raw = raw + data.procurement_fee_krw
|
||||
owner_total = ceil_thousand(raw)
|
||||
_line(
|
||||
result,
|
||||
key="owner_supplied_material_total",
|
||||
name="관급자재대",
|
||||
base_label=(
|
||||
"순자재대+조달수수료 (천원 올림)"
|
||||
if data.include_fee_in_owner_material_total
|
||||
else "순자재대 (천원 올림)"
|
||||
),
|
||||
base=raw,
|
||||
amount=owner_total,
|
||||
note="총원가 밖 별도 표기",
|
||||
)
|
||||
owner_total = _owner_supplied_line(data, emit)
|
||||
waste = _waste_line(data, emit)
|
||||
|
||||
grand_total = contract_amount + owner_total
|
||||
_line(
|
||||
result,
|
||||
grand_total = contract_amount + owner_total + waste
|
||||
emit(
|
||||
key="grand_total",
|
||||
name="총공사비",
|
||||
base_label="도급공사비+관급자재대",
|
||||
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
|
||||
base=grand_total,
|
||||
amount=grand_total,
|
||||
)
|
||||
@@ -674,6 +343,99 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
"vat": vat,
|
||||
"contract_amount": contract_amount,
|
||||
"owner_supplied_material_total": owner_total,
|
||||
"waste_disposal": waste,
|
||||
"grand_total": grand_total,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _profit_lines(
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
emit,
|
||||
ctx: ExpenseContext,
|
||||
net_construction_cost: Decimal,
|
||||
overhead: Decimal,
|
||||
) -> Decimal:
|
||||
"""이윤 — 조정 전 / 조정액 / 조정 후 세 줄. 조정은 **명시 입력일 때만**."""
|
||||
profit_row = select_bracket(
|
||||
dataset.variable("rate_profit")["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=ctx.scale_reference,
|
||||
label="이윤",
|
||||
)
|
||||
percent = rate_percent(profit_row, label="이윤")
|
||||
profit_base = net_construction_cost + overhead - ctx.material_cost
|
||||
before = emit(
|
||||
key="profit_before_adjustment",
|
||||
name="이윤(조정 전)",
|
||||
base_label="(순공사원가+일반관리비) − 재료비",
|
||||
base=profit_base,
|
||||
percent=percent,
|
||||
)
|
||||
if data.profit_adjustment_krw:
|
||||
emit(
|
||||
key="profit_adjustment",
|
||||
name="이윤 조정액",
|
||||
base_label="설계자 명시 입력",
|
||||
base=_ZERO,
|
||||
amount=-data.profit_adjustment_krw,
|
||||
note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)",
|
||||
)
|
||||
profit = before - data.profit_adjustment_krw
|
||||
emit(
|
||||
key="profit",
|
||||
name="이윤",
|
||||
base_label="조정 전 이윤 − 조정액",
|
||||
base=profit_base,
|
||||
amount=profit,
|
||||
)
|
||||
return profit
|
||||
|
||||
|
||||
def _owner_supplied_line(data: CostInput, emit) -> Decimal:
|
||||
"""관급자재대 — 총원가 밖 별도 표기, 천원 올림."""
|
||||
if not data.owner_supplied_material_krw:
|
||||
return _ZERO
|
||||
raw = data.owner_supplied_material_krw
|
||||
if data.include_fee_in_owner_material_total:
|
||||
raw = raw + data.procurement_fee_krw
|
||||
return emit(
|
||||
key="owner_supplied_material_total",
|
||||
name="관급자재대",
|
||||
base_label=(
|
||||
"순자재대+조달수수료 (천원 올림)"
|
||||
if data.include_fee_in_owner_material_total
|
||||
else "순자재대 (천원 올림)"
|
||||
),
|
||||
base=raw,
|
||||
amount=ceil_thousand(raw),
|
||||
note="총원가 밖 별도 표기",
|
||||
)
|
||||
|
||||
|
||||
def _waste_line(data: CostInput, emit) -> Decimal:
|
||||
"""폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다."""
|
||||
if not data.waste_disposal_krw:
|
||||
return _ZERO
|
||||
return emit(
|
||||
key="waste_disposal",
|
||||
name="폐기물처리비",
|
||||
base_label="설계자 입력(실비)",
|
||||
base=data.waste_disposal_krw,
|
||||
amount=floor_won(data.waste_disposal_krw),
|
||||
note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)",
|
||||
)
|
||||
|
||||
|
||||
def proposed_profit_adjustment(result: CostResult, target_contract_amount: Decimal) -> Decimal:
|
||||
"""목표 도급공사비를 맞추려면 이윤을 얼마 깎아야 하는지 **보여만 준다**.
|
||||
|
||||
★ 법대로(8-10) — 프로그램이 스스로 적용하지 않는다. 설계자가 이 값을 보고
|
||||
`CostInput.profit_adjustment_krw` 에 명시로 넣어야 반영된다.
|
||||
"""
|
||||
gap = result.totals["contract_amount"] - target_contract_amount
|
||||
if gap <= 0:
|
||||
return _ZERO
|
||||
# 이윤 1원을 깎으면 총원가 1원 + 부가세 0.1원이 줄어든다.
|
||||
return floor_won(gap / Decimal("1.1"))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종).
|
||||
|
||||
**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안,
|
||||
「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서
|
||||
위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는
|
||||
검사**를 두어, 규칙을 어기면 계산이 멈추게 한다.
|
||||
|
||||
세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢)
|
||||
㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다
|
||||
(품셈 1-3-1 「할증 중복 적용 금지」).
|
||||
㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미
|
||||
포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6).
|
||||
㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래
|
||||
분해는 일위대가에서 한 번만 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
_TOLERANCE = Decimal("0.5")
|
||||
|
||||
|
||||
class DoubleCountError(AssertionError):
|
||||
"""이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다."""
|
||||
|
||||
|
||||
def check_surcharge_once(
|
||||
*,
|
||||
material_summary_total: Decimal,
|
||||
unit_price_material_total: Decimal,
|
||||
surcharge_rate_percent: Decimal,
|
||||
label: str = "자재",
|
||||
) -> None:
|
||||
"""㉠ 할증이 두 번 붙지 않았는가.
|
||||
|
||||
`material_summary_total` = 자재총괄의 **할증 포함** 합계.
|
||||
`unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계.
|
||||
둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다.
|
||||
"""
|
||||
if unit_price_material_total <= 0:
|
||||
return
|
||||
expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100))
|
||||
if material_summary_total > expected + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > "
|
||||
f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) "
|
||||
f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)."
|
||||
)
|
||||
|
||||
|
||||
def check_free_haul_not_priced(
|
||||
*,
|
||||
haul_rows: list[dict],
|
||||
equipment_field: str = "equipment",
|
||||
unit_price_field: str = "unit_price_krw",
|
||||
free_haul_equipment: str = "free_haul",
|
||||
) -> None:
|
||||
"""㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가.
|
||||
|
||||
줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재).
|
||||
금지되는 것은 **단가를 붙이는 것**이다.
|
||||
"""
|
||||
for row in haul_rows:
|
||||
if row.get(equipment_field) != free_haul_equipment:
|
||||
continue
|
||||
price = Decimal(str(row.get(unit_price_field) or 0))
|
||||
if price != 0:
|
||||
raise DoubleCountError(
|
||||
f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — "
|
||||
"소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)."
|
||||
)
|
||||
|
||||
|
||||
def check_haul_volume_within_cut(
|
||||
*,
|
||||
haul_volume_total_m3: Decimal,
|
||||
total_cut_volume_m3: Decimal,
|
||||
) -> None:
|
||||
"""㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가.
|
||||
|
||||
무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다.
|
||||
"""
|
||||
if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 "
|
||||
f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 "
|
||||
"(PLAN 8-7 ㉡)."
|
||||
)
|
||||
|
||||
|
||||
def check_mix_decomposed_once(
|
||||
*,
|
||||
cement_total_kg: Decimal,
|
||||
concrete_volume_m3: Decimal,
|
||||
cement_per_m3_kg: Decimal,
|
||||
) -> None:
|
||||
"""㉢ 콘크리트를 두 번 쪼개지 않았는가.
|
||||
|
||||
시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을
|
||||
일위대가가 또 분해한 것이다.
|
||||
"""
|
||||
if concrete_volume_m3 <= 0:
|
||||
return
|
||||
expected = concrete_volume_m3 * cement_per_m3_kg
|
||||
if cement_total_kg > expected + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × "
|
||||
f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 "
|
||||
"쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)."
|
||||
)
|
||||
@@ -102,6 +102,24 @@ def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset:
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""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="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배",
|
||||
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":
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=ctx.scale_reference,
|
||||
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}")
|
||||
Reference in New Issue
Block a user