"""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 typing import Any 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_RateOverride import apply_overrides 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 로 나눈다. #: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당 #: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외 #: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인). 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 #: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) · #: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고). #: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖). waste_placement: str = "expense" #: ── 기준 입력 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`(공급가액) · `none`·빈 값은 안 자름. #: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」 #: 「설계서의 총액 · 원 · 1,000 · 미만버림」. #: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거. #: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과 #: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고, #: 설계자가 `none` 으로 **끌 수 있게** 둔다. cut_basis: str = "grand_total" cut_unit_krw: int = 1000 #: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접). employment_insurance_grade: str = "auto" #: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용). #: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% — #: 율이 안 갈리는 구분은 칸으로 안 세움. retirement_mutual_aid_mode: str = "auto" #: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로 #: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶. private_material_position: str = "material" #: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`. #: 하도급대금 지급보증 요율 줄만 고름. bid_method: str = "not_comprehensive" #: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법 #: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름). subcontract_guarantee: str = "off" #: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) · #: `lowest_price_tech`(최저가·기술제안: 규모 무관). performance_guarantee_mode: str = "general" #: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방 #: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음. contract_law_basis: str = "" #: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율. rate_overrides: tuple[dict[str, Any], ...] = () #: 환경보전비 공종 (`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: dataset = load_rate_dataset_from_path(data.rate_file_path) else: dataset = load_rate_dataset(data.rate_file_name) # 프로젝트 요율 덮어쓰기 — 복사본에만 얹음(마스터 파일은 그대로). return apply_overrides(dataset, data.rate_overrides) 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}회 안에 안 굳음 — 마지막 값 채택"] ) #: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음 #: (다섯 건 한 걸음 · 울진 신설 두 걸음). _CUT_MAX_PASSES = 3 #: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트). CUT_OFF = ("", "none") def _calculate_with_scale( data: CostInput, dataset: RateDataset, scale: Decimal, notes: list[str], ) -> CostResult: """규모 기준액을 못 박고 계산 — 11. 절사가 켜져 있으면 이윤을 보정해 다시 셈. ⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어 총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 — 울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`. """ result = _calculate_once(data, dataset, scale, notes) if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0: return result key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" automatic = _ZERO gap = _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) adjusted = replace( data, profit_adjustment_krw=data.profit_adjustment_krw + automatic, cut_basis="none", profit_adjustment_label=( f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" " 절사 자동보정(산림청고시 2025-82호)" + (" + 설계자 입력" 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:,}원 자동보정." " 근거: 산림청고시 제2025-82호 「금액의 단위표준」(설계서의 총액 1,000원 미만버림) ·" " 기준 입력판 11 에서 끌 수 있음" ) if gap: # 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다. result.notes.append( f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —" f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)" ) 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)) if data.rate_overrides: # 판 지문은 마스터 그대로 — 덮어쓴 칸 수·사유를 따로 남겨 「왜 다른지」가 보이게. result.notes.append( f"프로젝트 요율 덮어쓰기 {len(data.rate_overrides)}건 — " + " · ".join(str(o.get("reason") or "") for o in data.rate_overrides) ) 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) # 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다. after_profit = not data.waste_separate_order and data.waste_placement == "after_profit" waste_in_expense = ( _ZERO if data.waste_separate_order or after_profit 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) # 실무 관행 자리 — 이윤 뒤 · 총원가 안(부가세 밑수에 듦) · 일반관리비·이윤 밑수 밖. waste_after_profit = _waste_line(data, emit) if after_profit else _ZERO total_cost = net_construction_cost + overhead + profit + waste_after_profit emit( key="total_cost", name="총원가", base_label="순공사원가+일반관리비+이윤" + ("+폐기물처리비" if waste_after_profit else ""), 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_after_profit + 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 "실무 관행 자리 — 이윤 뒤 · 총원가 안 · 일반관리비·이윤 밑수 밖(설계자 선택)" if data.waste_placement == "after_profit" 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"))