- `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>
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""B09 원가계산 — ⑤ 공사원가계산서 엔진.
|
||
|
||
순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어
|
||
**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5).
|
||
법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한).
|
||
|
||
지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인)
|
||
1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다.
|
||
2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / …
|
||
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다.
|
||
4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.**
|
||
5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10).
|
||
6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기.
|
||
7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.**
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field, replace
|
||
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||
|
||
from B09_Estimation.B09_Estimation_Rates import (
|
||
RateDataset,
|
||
flat_rate,
|
||
load_rate_dataset,
|
||
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)
|
||
|
||
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
|
||
#: 사용자 확정(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`."""
|
||
|
||
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
|
||
#: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐.
|
||
#: 옛 서류(울진 2024) 재현 검산에만 켠다.
|
||
deduct_procurement_fee_for_safety: bool = False
|
||
|
||
#: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피).
|
||
estimated_price_krw: Decimal | None = None
|
||
|
||
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
|
||
profit_adjustment_krw: Decimal = _ZERO
|
||
|
||
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
|
||
#: 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, ...] | str = DEFAULT_ITEMS
|
||
|
||
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
|
||
rate_file_name: str = "rates_2026.json"
|
||
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
|
||
rate_file_path: str | None = None
|
||
|
||
|
||
@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 = ""
|
||
|
||
@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:
|
||
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 has(self, key: str) -> bool:
|
||
return any(item.key == key for item in self.lines)
|
||
|
||
|
||
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 _emitter(result: CostResult):
|
||
"""줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다."""
|
||
|
||
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,
|
||
)
|
||
)
|
||
return amount
|
||
|
||
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
|
||
return direct_construction_cost
|
||
|
||
|
||
def calculate_cost(data: CostInput) -> CostResult:
|
||
"""공사원가계산서 한 장을 계산한다."""
|
||
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
|
||
emit(
|
||
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_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 = emit(
|
||
key="indirect_labor_cost",
|
||
name="간접노무비",
|
||
base_label="직접노무비",
|
||
base=data.direct_labor_krw,
|
||
percent=rate_percent(indirect_row, label="간접노무비"),
|
||
)
|
||
total_labor_cost = data.direct_labor_krw + indirect_labor
|
||
emit(
|
||
key="labor_cost",
|
||
name="노무비",
|
||
base_label="직접노무비+간접노무비",
|
||
base=total_labor_cost,
|
||
amount=total_labor_cost,
|
||
)
|
||
|
||
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
|
||
emit(
|
||
key="expense",
|
||
name="경비",
|
||
base_label="직접경비(산출경비)+법정경비",
|
||
base=expense_total,
|
||
amount=expense_total,
|
||
)
|
||
|
||
net_construction_cost = material_cost + total_labor_cost + expense_total
|
||
emit(
|
||
key="net_construction_cost",
|
||
name="순공사원가",
|
||
base_label="재료비+노무비+경비",
|
||
base=net_construction_cost,
|
||
amount=net_construction_cost,
|
||
)
|
||
|
||
overhead_row = select_bracket(
|
||
dataset.variable("rate_overhead")["civil_landscape_industrial"],
|
||
amount_field="estimated_price_bracket",
|
||
amount=ctx.scale_reference,
|
||
label="일반관리비",
|
||
)
|
||
overhead = emit(
|
||
key="general_overhead",
|
||
name="일반관리비",
|
||
base_label="순공사원가",
|
||
base=net_construction_cost,
|
||
percent=rate_percent(overhead_row, label="일반관리비"),
|
||
)
|
||
|
||
profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead)
|
||
|
||
total_cost = net_construction_cost + overhead + profit
|
||
emit(
|
||
key="total_cost",
|
||
name="총원가",
|
||
base_label="순공사원가+일반관리비+이윤",
|
||
base=total_cost,
|
||
amount=total_cost,
|
||
)
|
||
vat = emit(
|
||
key="vat",
|
||
name="부가가치세",
|
||
base_label="총원가",
|
||
base=total_cost,
|
||
percent=flat_rate(dataset, "rate_vat"),
|
||
)
|
||
contract_amount = total_cost + vat
|
||
emit(
|
||
key="contract_amount",
|
||
name="도급공사비",
|
||
base_label="총원가+부가가치세",
|
||
base=contract_amount,
|
||
amount=contract_amount,
|
||
)
|
||
|
||
owner_total = _owner_supplied_line(data, emit)
|
||
waste = _waste_line(data, emit)
|
||
|
||
grand_total = contract_amount + owner_total + waste
|
||
emit(
|
||
key="grand_total",
|
||
name="총공사비",
|
||
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
|
||
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,
|
||
"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"))
|