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

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

529 lines
20 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).
법정경비 계산은 `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
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
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"
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
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
#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다.
_SCALE_MAX_PASSES = 5
def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple:
"""이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다.
"""
parts: list[str] = []
for variable, field, key in (
("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"),
("rate_profit", "estimated_price_bracket", "brackets"),
("rate_goyong", "estimated_amount_bracket", "brackets"),
("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"),
):
if variable not in dataset.variables:
continue
rows = dataset.variable(variable)[key]
try:
row = select_bracket(
rows,
amount_field=field,
amount=amount,
residual_label="below_official_threshold",
label=variable,
)
except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다
parts.append(f"{variable}:none")
continue
parts.append(f"{variable}:{row.get(field)}")
# 적용 하한(추정금액 1억 이상 등)도 구간과 같은 축이다.
for variable in ("rate_environment", "rate_retirement_mutual_aid"):
if variable not in dataset.variables:
continue
minimum = dataset.variable(variable).get("minimum_estimated_amount_krw")
if minimum is not None:
parts.append(f"{variable}:met={amount >= Decimal(str(minimum))}")
return tuple(parts)
def calculate_cost(data: CostInput) -> CostResult:
"""공사원가계산서 한 장을 계산한다.
**규모 구간은 「추정가격」으로 판정한다** — 국가계약법 시행령 제7조 1호:
「공사계약의 경우에는 **관급자재로 공급될 부분의 가격을 제외한 금액**」.
우리 계산에서 그 값은 **총원가**다(부가세 전, 관급자재대는 애초에 총원가 밖).
그런데 총원가는 계산 **결과**라 구간 판정에 그대로 쓰면 순환이 된다. 그래서
직접공사비를 씨앗으로 한 번 돌린 뒤 **나온 총원가로 구간을 다시 판정**해
구간이 고정될 때까지 되풀이한다(최대 `_SCALE_MAX_PASSES` 회). 설계자가
`estimated_price_krw` 를 명시하면 반복 없이 그 값으로 한 번만 판정한다.
"""
dataset = _load_dataset(data)
if data.estimated_price_krw is not None:
return _calculate_with_scale(data, dataset, data.estimated_price_krw, [])
scale = (
data.direct_material_krw
+ data.indirect_material_krw
+ data.direct_labor_krw
+ data.direct_expense_krw
)
seen_signatures: list[tuple] = []
tried_amounts: list[Decimal] = []
for _ in range(_SCALE_MAX_PASSES):
signature = _scale_signature(dataset, scale)
if signature in seen_signatures:
# 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다.
highest = max([*tried_amounts, scale])
return _calculate_with_scale(
data, dataset, highest, ["규모 구간 진동 — 높은 쪽 구간 채택"]
)
seen_signatures.append(signature)
tried_amounts.append(scale)
trial = _calculate_with_scale(data, dataset, scale, [])
estimated_price = trial.totals["total_cost"]
if _scale_signature(dataset, estimated_price) == signature:
return trial
scale = estimated_price
return _calculate_with_scale(
data, dataset, scale, [f"규모 구간이 {_SCALE_MAX_PASSES}회 안에 안 굳음 — 마지막 값 채택"]
)
def _calculate_with_scale(
data: CostInput,
dataset: RateDataset,
scale: Decimal,
notes: list[str],
) -> CostResult:
"""규모 기준액을 못 박고 한 번 계산한다."""
result = CostResult(rate_version=dataset.version_stamp, notes=list(notes))
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,
)
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"))