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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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), ""
|
||||
|
||||
Reference in New Issue
Block a user