refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
"""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,
|
||||
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)
|
||||
|
||||
# ⬇ 입력 한 벌은 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) —
|
||||
# 쓰던 이름이 그대로 살게 다시 내보낸다.
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost_Input import ( # noqa: E402,F401
|
||||
DEFAULT_ITEMS,
|
||||
CostInput,
|
||||
)
|
||||
|
||||
|
||||
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 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}회 안에 안 굳음 — 마지막 값 채택"]
|
||||
)
|
||||
|
||||
|
||||
#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 —
|
||||
#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다.
|
||||
_CUT_DESCENT_MAX = 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`.
|
||||
|
||||
⚠ **못 밟는 배수가 있다**(2026-09-14 둘째 고침). 한 걸음 낙차가 1원일 때도 2원일 때도 있어
|
||||
(부가세가 버림이라 열 걸음에 한 번쯤 2원) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면
|
||||
지나쳐 놓고 못 돌아온다 — 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았다. 그래서
|
||||
**목표를 못 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가).
|
||||
그 배수를 못 밟으면 **한 칸 아래 배수**로 목표를 내려 다시 찾는다(뒷받침일 뿐 — 실측 거창 꼴
|
||||
40 자리에서는 한 칸도 안 내려갔다). 이윤은 늘 **가장 적게** 깎인다 — 실무 식(÷1.1 한 걸음)이
|
||||
앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632).
|
||||
"""
|
||||
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"
|
||||
label = (
|
||||
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
|
||||
" 절사 자동보정(산림청고시 2025-82호)"
|
||||
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
|
||||
)
|
||||
|
||||
def attempt(extra: Decimal) -> CostResult:
|
||||
"""이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로."""
|
||||
if extra == 0:
|
||||
return result
|
||||
return _calculate_once(
|
||||
replace(
|
||||
data,
|
||||
profit_adjustment_krw=data.profit_adjustment_krw + extra,
|
||||
cut_basis="none",
|
||||
profit_adjustment_label=label,
|
||||
),
|
||||
dataset,
|
||||
scale,
|
||||
notes,
|
||||
)
|
||||
|
||||
raw = result.totals[key]
|
||||
target = raw - cut_gap(raw, data.cut_unit_krw)
|
||||
automatic = _ZERO
|
||||
gap = _ZERO
|
||||
landed = result
|
||||
for _ in range(_CUT_DESCENT_MAX):
|
||||
# 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을
|
||||
# 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다.
|
||||
low, high = _ZERO, raw - target
|
||||
while low < high:
|
||||
middle = (low + high) // 2
|
||||
if attempt(middle).totals[key] <= target:
|
||||
high = middle
|
||||
else:
|
||||
low = middle + 1
|
||||
landed = attempt(low)
|
||||
gap = landed.totals[key] - target
|
||||
if gap == 0:
|
||||
automatic = low
|
||||
break
|
||||
# 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다.
|
||||
target -= data.cut_unit_krw
|
||||
result = landed
|
||||
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_DESCENT_MAX}칸 안에 안 앉음 —"
|
||||
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"))
|
||||
Reference in New Issue
Block a user