diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index ae5fde2d..d3b7ec0e 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py index d6197181..21ea9d21 100644 --- a/B09_Estimation/B09_Estimation_Rates.py +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -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}: 조건에 맞는 요율 구간이 없습니다 " diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 37a7eebe..a4f710ad 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -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), ""