Files
Aislo/B09_Estimation/B09_Estimation_Engine_Cost.py
eomsangdonandClaude Opus 5 e0c5e36433 refactor(b09): 700줄 넘은 모듈 둘 쪼갬 — 순수 분리, 그 김에 아무것도 안 고침
· BillOfQuantities.py 733 → 597 · 새 _BillOfQuantities_Input.py 162
  (인계 자료형 HandoffWorkItem·HandoffMaterial · parse_handoff · _decimal · BillError ·
   공급구분·미확정 상수)
· Engine_Cost.py 725 → 609 · 새 _Engine_Cost_Input.py 136 (CostInput 한 벌 · DEFAULT_ITEMS)
  반올림 두 함수(floor_won·ceil_thousand)는 엔진 것이라 도로 옮겨 둠
· 쓰던 이름은 그대로 — 본 모듈이 다시 내보내(noqa: E402,F401)
  from …BillOfQuantities import HandoffWorkItem · from …Engine_Cost import CostInput 가 종전대로 돎
· ruff — 새 파일 둘 다 깨끗 · 본 모듈 둘은 HEAD 와 같은 수(옮기며 안 늘음)

⚠ UnitPrice.py 1403 은 아직 남음 — 따로 배정 필요.

전체 시험 2102 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-15 03:19:40 +09:00

612 lines
24 KiB
Python
Raw Permalink 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,
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"))