Files
Aislo/B09_Estimation/B09_Estimation_Engine_Cost.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

680 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가계산 — ⑤ 공사원가계산서 엔진.
순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어
**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5).
지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만)
1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다.
2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / …
하나로 뭉치면 틀린다.
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호).
⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다
(실증: 울진 A 채택 / 거창 B 채택).
4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는
관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다.
5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등).
6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
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,
rate_percent,
select_bracket,
)
_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",
)
def floor_won(value: Decimal) -> Decimal:
"""원 단위 버림 — 원가계산서 모든 줄의 기본 처리."""
return value.quantize(Decimal(1), rounding=ROUND_FLOOR)
def ceil_thousand(value: Decimal) -> Decimal:
"""천원 올림 — 관급자재대 표기."""
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
@dataclass
class CostInput:
"""원가계산 입력.
금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다.
"""
direct_material_krw: Decimal
direct_labor_krw: Decimal
direct_expense_krw: Decimal
indirect_material_krw: Decimal = _ZERO
#: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다.
work_type_indirect_labor: str = "civil"
work_type_safety: str = "civil"
duration_days: int = 183
pension_year: int = 2026
#: 관급자재 — 순자재대와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1).
owner_supplied_material_krw: Decimal = _ZERO
procurement_fee_krw: Decimal = _ZERO
include_fee_in_owner_material_total: bool = True
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다.
owner_supplied_for_safety_krw: Decimal | None = None
#: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준).
owner_supplied_includes_vat: bool = True
#: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값).
environment_work_type: str = "civil_road"
#: 건설기계대여대금 지급보증 공종.
equipment_guarantee_work_type: str = "civil_general"
enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS
#: 요율 데이터 파일명. 연도를 갈아끼우는 자리.
rate_file_name: str = "rates_2026.json"
@dataclass
class CostLine:
"""원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다."""
key: str
name: str
base_label: str
base_amount_krw: Decimal
rate_percent: Decimal | None
flat_amount_krw: Decimal
amount_krw: Decimal
note: str = ""
@dataclass
class CostResult:
lines: list[CostLine] = field(default_factory=list)
totals: dict[str, Decimal] = field(default_factory=dict)
rate_version: dict[str, str] = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
def line(self, key: str) -> CostLine:
for item in self.lines:
if item.key == key:
return item
raise KeyError(f"원가계산서에 없는 줄입니다: {key}")
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 _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 _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
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(
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
)
total += _line(
result,
key="long_term_care_insurance",
name="노인장기요양보험료",
base_label="국민건강보험료",
base=health_amount,
percent=flat_rate(dataset, "rate_care"),
)
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
def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal:
"""규모 구간 판정 기준액.
조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을,
없으면 **직접공사비**를 기준으로 쓴다.
"""
if data.estimated_price_krw is not None:
return data.estimated_price_krw
return direct_construction_cost
def calculate_cost(data: CostInput) -> CostResult:
"""공사원가계산서 한 장을 계산한다."""
dataset = load_rate_dataset(data.rate_file_name)
result = CostResult(rate_version=dataset.version_stamp)
material_cost = data.direct_material_krw + data.indirect_material_krw
_line(
result,
key="material_cost",
name="재료비",
base_label="직접재료비+간접재료비",
base=material_cost,
amount=material_cost,
)
direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw
indirect_labor_row = select_bracket(
dataset.variable("rate_indirect_labor")["brackets"],
amount_field="direct_cost_bracket",
amount=direct_construction_cost,
duration_days=data.duration_days,
equals={"work_type": data.work_type_indirect_labor},
label="간접노무비",
)
indirect_labor = _line(
result,
key="indirect_labor_cost",
name="간접노무비",
base_label="직접노무비",
base=data.direct_labor_krw,
percent=rate_percent(indirect_labor_row, label="간접노무비"),
)
total_labor_cost = data.direct_labor_krw + indirect_labor
_line(
result,
key="labor_cost",
name="노무비",
base_label="직접노무비+간접노무비",
base=total_labor_cost,
amount=total_labor_cost,
)
statutory = _statutory_expenses(
result,
dataset,
data,
material_cost=material_cost,
total_labor_cost=total_labor_cost,
direct_construction_cost=direct_construction_cost,
)
expense_total = data.direct_expense_krw + statutory
_line(
result,
key="expense",
name="경비",
base_label="직접경비(산출경비)+법정경비",
base=expense_total,
amount=expense_total,
)
net_construction_cost = material_cost + total_labor_cost + expense_total
_line(
result,
key="net_construction_cost",
name="순공사원가",
base_label="재료비+노무비+경비",
base=net_construction_cost,
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,
label="일반관리비",
)
overhead = _line(
result,
key="general_overhead",
name="일반관리비",
base_label="순공사원가",
base=net_construction_cost,
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,
)
total_cost = net_construction_cost + overhead + profit
_line(
result,
key="total_cost",
name="총원가",
base_label="순공사원가+일반관리비+이윤",
base=total_cost,
amount=total_cost,
)
vat = _line(
result,
key="vat",
name="부가가치세",
base_label="총원가",
base=total_cost,
percent=flat_rate(dataset, "rate_vat"),
)
contract_amount = total_cost + vat
_line(
result,
key="contract_amount",
name="도급공사비",
base_label="총원가+부가가치세",
base=contract_amount,
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="총원가 밖 별도 표기",
)
grand_total = contract_amount + owner_total
_line(
result,
key="grand_total",
name="총공사비",
base_label="도급공사비+관급자재대",
base=grand_total,
amount=grand_total,
)
result.totals = {
"material_cost": material_cost,
"labor_cost": total_labor_cost,
"expense": expense_total,
"direct_construction_cost": direct_construction_cost,
"net_construction_cost": net_construction_cost,
"general_overhead": overhead,
"profit": profit,
"total_cost": total_cost,
"vat": vat,
"contract_amount": contract_amount,
"owner_supplied_material_total": owner_total,
"grand_total": grand_total,
}
return result