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>
This commit is contained in:
2026-09-07 20:06:03 +09:00
co-authored by Claude Opus 5
parent 50871e60b9
commit c044e33dab
3 changed files with 109 additions and 12 deletions
+99 -12
View File
@@ -79,7 +79,10 @@ class CostInput:
#: 옛 서류(울진 2024) 재현 검산에만 켠다.
deduct_procurement_fee_for_safety: bool = False
#: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피).
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
@@ -96,6 +99,8 @@ class CostInput:
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
@@ -196,22 +201,104 @@ def _emitter(result: CostResult):
return emit
def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal:
"""규모 구간 판정 기준액.
#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다.
_SCALE_MAX_PASSES = 5
조달청 제비율표는 「추정가격」으로 구간을 나누지만 추정가격은 원가 계산 결과에
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 명시하면 그 값을, 없으면
**직접공사비**를 쓴다.
def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple:
"""이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다.
"""
if data.estimated_price_krw is not None:
return data.estimated_price_krw
return direct_construction_cost
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)
result = CostResult(rate_version=dataset.version_stamp)
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:
@@ -257,7 +344,7 @@ def calculate_cost(data: CostInput) -> CostResult:
direct_labor_cost=data.direct_labor_krw,
total_labor_cost=total_labor_cost,
direct_construction_cost=direct_construction_cost,
scale_reference=_scale_reference(data, direct_construction_cost),
scale_reference=scale,
)
statutory = statutory_expenses(dataset, data, ctx, result, emit)
+8
View File
@@ -186,6 +186,7 @@ def select_bracket(
duration_field: str = "duration_bracket",
equals: dict[str, Any] | None = None,
residual_label: str | None = None,
prefer_suffix: str | None = None,
label: str,
) -> dict[str, Any]:
"""구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다.
@@ -219,6 +220,13 @@ def select_bracket(
for key, expected in equals.items():
candidates = [row for row in candidates if row.get(key) == expected]
if len(candidates) > 1 and prefer_suffix is not None and amount_field is not None:
# 같은 금액 구간이 공종으로 갈리는 표가 있다(하도급보증의 `…_integrated_civil…`).
# 부르는 쪽이 공종을 대야 하며, 조용한 기본값이 아니다.
narrowed = [r for r in candidates if str(r.get(amount_field, "")).endswith(prefer_suffix)]
if narrowed:
candidates = narrowed
if not candidates:
raise RateLookupError(
f"{label}: 조건에 맞는 요율 구간이 없습니다 "
@@ -383,10 +383,12 @@ def _base_and_rate(
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
if key == "subcontract_payment_guarantee":
# 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
row = select_bracket(
variable["brackets"],
amount_field="estimated_price_bracket",
amount=ctx.scale_reference,
prefer_suffix=data.subcontract_guarantee_variant,
label=item.name,
)
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""