Files
Aislo/B09_Estimation/B09_Estimation_Engine_Cost.py
T
eomsangdonandClaude Opus 5 fb4eb59cdb feat(b09): 원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식
- 10 일반관리비: (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식 칸(일반 = 순공사원가 + 관리품목자재대)
- 11 절사: 총공사비/공급가액 N원 미만 — 고른 때만 이윤 자동보정(총공사비·공급가액 비례 부가세면 ÷1.1 반올림, 실무 봉화 373→339·영월 695→632, 산림조합은 차액 그대로) · 잔차 맞춤
- 12 부가세: 공급가액·재료비·없음·산림조합 형식·산림조합-면세품 (칸 이름은 기성 선택지를 받을 수 있게 문자열)
- 기본값은 종전 계산과 같음 · 자동보정 줄은 「… 절사 자동보정」으로 적음
- 원가계산서 탭: 10·11·12 고르개 · 관리품목 자재대 · 면세품 금액 칸
- 시험: 기본값 불변 · 밑수·표 갈림 · 실무 절사 식 · 끝자리 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 02:43:14 +09:00

622 lines
25 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_Engine_Cost_Options import (
CUT_BASES,
VAT_MODES,
cut_gap,
overhead_base,
profit_cut,
vat_base,
)
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` 는 **수수료를 뺀 순자재대**다. 실무 서류의
#: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다
#: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 →
#: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다.
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
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
profit_adjustment_label: str = ""
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
waste_disposal_krw: Decimal = _ZERO
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
waste_separate_order: bool = False
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
form: str = "general"
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
overhead_class: str = "civil_landscape_industrial"
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
overhead_managed_material_krw: Decimal = _ZERO
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
vat_mode: str = "supply"
tax_exempt_material_krw: Decimal = _ZERO
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · 빈 값은 안 자름.
#: ⚠ 고른 때만 이윤을 자동보정한다 — 기본은 수동 이윤보정만(2026-09-08 합의).
cut_basis: str = ""
cut_unit_krw: int = 0
#: 환경보전비 공종 (`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 or self.key == "safety_management_cost":
# 채택 요약 줄은 요율을 다시 붙이지 않는다 — 산식은 A·B 줄에 이미 있다.
return self.base_label
# 밑수가 합·차로 이루어졌으면 괄호를 씌운다.
# 안 씌우면 「(순공사원가+일반관리비) − 재료비 × 15%」처럼 곱하는 대상이 뒤바뀌어 읽힌다.
base = self.base_label
if any(mark in base for mark in ("+", "", "×")):
base = f"({base})"
text = f"{base} × {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, overhead_class: str = "civil_landscape_industrial"
) -> tuple:
"""이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다.
"""
parts: list[str] = []
for variable, bracket_field, key in (
("rate_overhead", "estimated_price_bracket", overhead_class),
("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=bracket_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(bracket_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, data.overhead_class)
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, data.overhead_class) == signature:
return trial
scale = estimated_price
return _calculate_with_scale(
data, dataset, scale, [f"규모 구간이 {_SCALE_MAX_PASSES}회 안에 안 굳음 — 마지막 값 채택"]
)
#: 절사 뒤 잔차 맞춤 반복 상한 — 실무 수식도 잔차 칸 한 번뿐(표본 넷 모두 0).
_CUT_MAX_PASSES = 3
def _calculate_with_scale(
data: CostInput,
dataset: RateDataset,
scale: Decimal,
notes: list[str],
) -> CostResult:
"""규모 기준액을 못 박고 계산 — 11. 절사를 **고른 때만** 이윤을 보정해 다시 셈."""
result = _calculate_once(data, dataset, scale, notes)
if not data.cut_basis or data.cut_unit_krw <= 0:
return result
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
automatic = _ZERO
for _ in range(_CUT_MAX_PASSES):
gap = cut_gap(result.totals[key], data.cut_unit_krw)
if gap == 0:
break
# 첫 차례는 실무 식(총공사비·공급가액 비례 부가세면 ÷1.1), 그 뒤는 잔차 그대로.
automatic += profit_cut(gap, data.cut_basis, data.vat_mode) if automatic == 0 else gap
adjusted = replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
cut_basis="",
profit_adjustment_label=(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정" + (" + 설계자 입력" if data.profit_adjustment_krw else "")
),
)
result = _calculate_once(adjusted, dataset, scale, notes)
result.notes.append(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
f" 이윤에서 {automatic:,}원 자동보정(설계자가 고른 절사 기준)"
)
return result
def _calculate_once(
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)
# 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다.
waste_in_expense = _ZERO if data.waste_separate_order else _waste_line(data, emit)
expense_total = data.direct_expense_krw + statutory + waste_in_expense
emit(
key="expense",
name="경비",
base_label="직접경비(산출경비)+법정경비" + ("+폐기물처리비" if waste_in_expense else ""),
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,
)
# 10. (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식이 정함
# (일반 = 순공사원가 + 관리품목자재대).
overhead_row = select_bracket(
dataset.variable("rate_overhead")[data.overhead_class],
amount_field="estimated_price_bracket",
amount=ctx.scale_reference,
label="일반관리비",
)
managed = data.overhead_managed_material_krw
overhead = emit(
key="general_overhead",
name="일반관리비",
base_label="순공사원가+관리품목자재대" if managed else "순공사원가",
base=overhead_base(data.form, net_construction_cost, managed),
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,
)
# 12. 부가세 방식 — 밑수만 갈림(공급가액·재료비·없음·산림조합 둘). 율은 요율 데이터.
vat = emit(
key="vat",
name="부가가치세",
base_label="총원가" if data.vat_mode == "supply" else VAT_MODES[data.vat_mode],
base=vat_base(
data.vat_mode,
total_cost,
material_cost,
data.direct_expense_krw,
data.tax_exempt_material_krw,
),
percent=_ZERO if data.vat_mode == "none" else 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) if data.waste_separate_order else _ZERO
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_in_expense + 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=data.profit_adjustment_label or "설계자 명시 입력",
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=(
"분리발주 — 총원가 밖 별도 표기"
if data.waste_separate_order
else "경비(예정가격작성기준 제19조③18호) — 일반관리비·이윤 밑수에 듦"
),
)
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"))