merge: sub_desktop_1 되받기 — main.py 라우터 등록 충돌 해소

B08 토적표 라우터와 B09 원가계산 라우터가 main.py 의 같은 자리(import 블록·
include_router 목록)를 각자 한 줄씩 늘려 충돌함. 둘 다 필요한 줄이라 양쪽을
그대로 살림 — 어느 쪽도 버리지 않음.

확인 — 충돌 표시 0건, 앱 import 성공, 두 라우터 경로가 모두 등록됨
(quantity 3건 · estimation 3건).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 20:19:09 +09:00
co-authored by Claude Opus 5
9 changed files with 1550 additions and 474 deletions
+320 -465
View File
@@ -2,86 +2,65 @@
순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어
**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5).
법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한).
지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만)
지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인)
1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다.
2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / …
하나로 뭉치면 틀린다.
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호).
⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다
(실증: 울진 A 채택 / 거창 B 채택).
4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는
관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다.
5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등).
6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다.
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다.
4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.**
5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10).
6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기.
7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.**
"""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from B09_Estimation.B09_Estimation_Rates import (
RateDataset,
RateLookupError,
base_amount,
flat_rate,
load_rate_dataset,
pension_rate_percent,
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)
_VAT_DIVISOR = Decimal("1.1")
#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다.
DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = (
"industrial_accident_insurance",
"employment_insurance",
"health_insurance",
"long_term_care_insurance",
"national_pension",
"safety_management_cost",
"other_expense",
"environment_preservation",
"retirement_mutual_aid",
)
#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장).
OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = (
"wage_claim_contribution",
"asbestos_contribution",
"equipment_payment_guarantee",
"subcontract_payment_guarantee",
"performance_guarantee_fee",
)
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
DEFAULT_ITEMS = "ALL_AVAILABLE"
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 CostInput:
"""원가계산 입력.
금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다.
"""
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
direct_material_krw: Decimal
direct_labor_krw: Decimal
direct_expense_krw: Decimal
indirect_material_krw: Decimal = _ZERO
#: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다.
#: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 표기를 그대로 쓴다.
work_type_indirect_labor: str = "civil"
work_type_safety: str = "civil"
duration_days: int = 183
@@ -92,31 +71,49 @@ class CostInput:
procurement_fee_krw: Decimal = _ZERO
include_fee_in_owner_material_total: bool = True
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다.
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
owner_supplied_for_safety_krw: Decimal | None = None
#: 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준).
#: 금액이 부가세 포함인가 — 포함이면 1.1 로 나다(규정: 부가세 제외 기준).
owner_supplied_includes_vat: bool = True
#: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐.
#: 옛 서류(울진 2024) 재현 검산에만 켠다.
deduct_procurement_fee_for_safety: bool = False
#: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피).
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
estimated_price_krw: Decimal | None = None
#: 이윤 수동 조정액 — 설계자 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다.
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값).
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
#: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」).
#: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐.
waste_disposal_krw: Decimal = _ZERO
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
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, ...] = DEFAULT_STATUTORY_ITEMS
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
#: 요율 데이터 파일명. 연도를 갈아끼우는 자리.
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
rate_file_name: str = "rates_2026.json"
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
rate_file_path: str | None = None
@dataclass
class CostLine:
"""원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 을 다 든다."""
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 을 다 든다."""
key: str
name: str
@@ -127,6 +124,27 @@ class CostLine:
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:
@@ -144,350 +162,156 @@ class CostResult:
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 _line(
result: CostResult,
*,
key: str,
name: str,
base_label: str,
base: Decimal,
percent: Decimal | None = None,
flat: Decimal = _ZERO,
amount: Decimal | None = None,
note: str = "",
) -> Decimal:
"""줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림."""
if amount is None:
computed = 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,
def _load_dataset(data: CostInput) -> RateDataset:
if data.rate_file_path:
return load_rate_dataset_from_path(data.rate_file_path)
return load_rate_dataset(data.rate_file_name)
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 amount
return emit
def _safety_management_cost(
result: CostResult,
dataset: RateDataset,
data: CostInput,
*,
material_cost: Decimal,
) -> Decimal:
"""산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다.
#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다.
_SCALE_MAX_PASSES = 5
A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액
B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다.
def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple:
"""이 금액이 어느 구간에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다.
"""
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw
if data.owner_supplied_includes_vat:
owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = material_cost + data.direct_labor_krw + owner_supplied
base_without = material_cost + data.direct_labor_krw
def evaluate(
base: Decimal, *, multiplier: Decimal, label: str
) -> tuple[Decimal, Decimal, Decimal]:
row = select_bracket(
brackets,
amount_field="target_amount_bracket",
amount=base,
equals={"work_type": data.work_type_safety},
label=label,
)
percent = rate_percent(row, label=label)
flat = base_amount(row)
amount = floor_won((base * percent / _HUNDRED + flat) * multiplier)
return amount, percent, flat
amount_a, percent_a, flat_a = evaluate(
base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)"
)
amount_b, percent_b, flat_b = evaluate(
base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)"
)
adopted = "A" if amount_a <= amount_b else "B"
_line(
result,
key="safety_management_cost_a",
name="산업안전보건관리비 A(관급 포함)",
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
base=base_with,
percent=percent_a,
flat=flat_a,
amount=amount_a,
note="채택" if adopted == "A" else "미채택",
)
_line(
result,
key="safety_management_cost_b",
name="산업안전보건관리비 B(관급 제외 × 1.2)",
base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배",
base=base_without,
percent=percent_b,
flat=flat_b,
amount=amount_b,
note="채택" if adopted == "B" else "미채택",
)
adopted_amount = min(amount_a, amount_b)
return _line(
result,
key="safety_management_cost",
name="산업안전보건관리비",
base_label=f"A·B 중 작은 금액 (채택 = {adopted})",
base=base_with if adopted == "A" else base_without,
percent=percent_a if adopted == "A" else percent_b,
amount=adopted_amount,
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
)
def _statutory_expenses(
result: CostResult,
dataset: RateDataset,
data: CostInput,
*,
material_cost: Decimal,
total_labor_cost: Decimal,
direct_construction_cost: Decimal,
) -> Decimal:
"""법정경비 묶음. `enabled_items` 에 든 줄만 계산한다."""
enabled = set(data.enabled_items)
total = _ZERO
if "industrial_accident_insurance" in enabled:
total += _line(
result,
key="industrial_accident_insurance",
name="산재보험료",
base_label="노무비(직접+간접)",
base=total_labor_cost,
percent=flat_rate(dataset, "rate_sanjae"),
)
if "employment_insurance" in enabled:
variable = dataset.variable("rate_goyong")
# 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라
# 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %).
row = select_bracket(
variable["brackets"],
amount_field="estimated_amount_bracket",
amount=_scale_reference(data, direct_construction_cost),
residual_label="below_official_threshold",
label="고용보험료",
)
total += _line(
result,
key="employment_insurance",
name="고용보험료",
base_label="노무비(직접+간접)",
base=total_labor_cost,
percent=rate_percent(row, label="고용보험료"),
)
health_amount = _ZERO
if "health_insurance" in enabled:
health_amount = _line(
result,
key="health_insurance",
name="국민건강보험료",
base_label="직접노무비",
base=data.direct_labor_krw,
percent=flat_rate(dataset, "rate_health"),
)
total += health_amount
if "long_term_care_insurance" in enabled:
if "health_insurance" not in enabled:
raise RateLookupError(
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
parts: list[str] = []
for variable, bracket_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=bracket_field,
amount=amount,
residual_label="below_official_threshold",
label=variable,
)
total += _line(
result,
key="long_term_care_insurance",
name="노인장기요양보험료",
base_label="국민건강보험료",
base=health_amount,
percent=flat_rate(dataset, "rate_care"),
)
except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다
parts.append(f"{variable}:none")
continue
parts.append(f"{variable}:{row.get(bracket_field)}")
if "national_pension" in enabled:
total += _line(
result,
key="national_pension",
name="국민연금보험료",
base_label="직접노무비",
base=data.direct_labor_krw,
percent=pension_rate_percent(dataset, data.pension_year),
)
if "safety_management_cost" in enabled:
total += _safety_management_cost(result, dataset, data, material_cost=material_cost)
if "other_expense" in enabled:
variable = dataset.variable("rate_other_expense")
row = select_bracket(
variable["brackets"],
amount_field="direct_cost_bracket",
amount=direct_construction_cost,
duration_days=data.duration_days,
equals={"work_type": data.work_type_indirect_labor},
label="기타경비",
)
total += _line(
result,
key="other_expense",
name="기타경비",
base_label="재료비+노무비(직접+간접)",
base=material_cost + total_labor_cost,
percent=rate_percent(row, label="기타경비"),
)
if "environment_preservation" in enabled:
variable = dataset.variable("rate_environment")
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
if _scale_reference(data, direct_construction_cost) >= threshold:
row = next(
(
r
for r in variable["all_work_types"]
if r.get("work_type") == data.environment_work_type
),
None,
)
if row is None:
raise RateLookupError(
f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}"
)
total += _line(
result,
key="environment_preservation",
name="환경보전비",
base_label="직접공사비",
base=direct_construction_cost,
percent=rate_percent(row, label="환경보전비"),
note=(
"⚠ 임도 공종 채택값 미확정 — 지식DB "
"`rate_environment.forest_road_selection_status: pending`"
),
)
if "retirement_mutual_aid" in enabled:
variable = dataset.variable("rate_retirement_mutual_aid")
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
if _scale_reference(data, direct_construction_cost) >= threshold:
total += _line(
result,
key="retirement_mutual_aid",
name="퇴직공제부금비",
base_label="직접노무비",
base=data.direct_labor_krw,
percent=Decimal(str(variable["rate_percent"])),
)
if "wage_claim_contribution" in enabled:
total += _line(
result,
key="wage_claim_contribution",
name="임금채권보장기금 부담금",
base_label="노무비(직접+간접)",
base=total_labor_cost,
percent=flat_rate(dataset, "rate_wage_claim_contribution"),
)
if "asbestos_contribution" in enabled:
total += _line(
result,
key="asbestos_contribution",
name="석면피해구제 분담금",
base_label="노무비(직접+간접)",
base=total_labor_cost,
percent=flat_rate(dataset, "rate_asbestos_contribution"),
)
if "equipment_payment_guarantee" in enabled:
variable = dataset.variable("rate_equipment_payment_guarantee")
row = next(
(
r
for r in variable["general_construction"] + variable["specialty_construction"]
if r.get("work_type") == data.equipment_guarantee_work_type
),
None,
)
if row is None:
raise RateLookupError(
"건설기계대여대금 지급보증: 공종을 못 찾았습니다 — "
f"{data.equipment_guarantee_work_type}"
)
total += _line(
result,
key="equipment_payment_guarantee",
name="건설기계대여대금 지급보증수수료",
base_label="직접공사비",
base=direct_construction_cost,
percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"),
)
if "subcontract_payment_guarantee" in enabled:
variable = dataset.variable("rate_subcontract_payment_guarantee")
row = select_bracket(
variable["brackets"],
amount_field="estimated_price_bracket",
amount=_scale_reference(data, direct_construction_cost),
label="하도급대금 지급보증수수료",
)
total += _line(
result,
key="subcontract_payment_guarantee",
name="하도급대금 지급보증수수료",
base_label="직접공사비",
base=direct_construction_cost,
percent=rate_percent(row, label="하도급대금 지급보증수수료"),
)
return total
def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal:
"""규모 구간 판정 기준액.
조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을,
없으면 **직접공사비**를 기준으로 쓴다.
"""
if data.estimated_price_krw is not None:
return data.estimated_price_krw
return direct_construction_cost
# 적용 하한(추정금액 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:
"""공사원가계산서 한 장을 계산한다."""
dataset = load_rate_dataset(data.rate_file_name)
result = CostResult(rate_version=dataset.version_stamp)
"""공사원가계산서 한 장을 계산한다.
**규모 구간은 「추정가격」으로 판정한다** — 국가계약법 시행령 제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)
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:
data = replace(data, enabled_items=available_items(dataset))
material_cost = data.direct_material_krw + data.indirect_material_krw
_line(
result,
emit(
key="material_cost",
name="재료비",
base_label="직접재료비+간접재료비",
@@ -497,7 +321,7 @@ def calculate_cost(data: CostInput) -> CostResult:
direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw
indirect_labor_row = select_bracket(
indirect_row = select_bracket(
dataset.variable("rate_indirect_labor")["brackets"],
amount_field="direct_cost_bracket",
amount=direct_construction_cost,
@@ -505,17 +329,15 @@ def calculate_cost(data: CostInput) -> CostResult:
equals={"work_type": data.work_type_indirect_labor},
label="간접노무비",
)
indirect_labor = _line(
result,
indirect_labor = emit(
key="indirect_labor_cost",
name="간접노무비",
base_label="직접노무비",
base=data.direct_labor_krw,
percent=rate_percent(indirect_labor_row, label="간접노무비"),
percent=rate_percent(indirect_row, label="간접노무비"),
)
total_labor_cost = data.direct_labor_krw + indirect_labor
_line(
result,
emit(
key="labor_cost",
name="노무비",
base_label="직접노무비+간접노무비",
@@ -523,17 +345,17 @@ def calculate_cost(data: CostInput) -> CostResult:
amount=total_labor_cost,
)
statutory = _statutory_expenses(
result,
dataset,
data,
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)
expense_total = data.direct_expense_krw + statutory
_line(
result,
emit(
key="expense",
name="경비",
base_label="직접경비(산출경비)+법정경비",
@@ -542,8 +364,7 @@ def calculate_cost(data: CostInput) -> CostResult:
)
net_construction_cost = material_cost + total_labor_cost + expense_total
_line(
result,
emit(
key="net_construction_cost",
name="순공사원가",
base_label="재료비+노무비+경비",
@@ -551,15 +372,13 @@ def calculate_cost(data: CostInput) -> CostResult:
amount=net_construction_cost,
)
scale = _scale_reference(data, direct_construction_cost)
overhead_row = select_bracket(
dataset.variable("rate_overhead")["civil_landscape_industrial"],
amount_field="estimated_price_bracket",
amount=scale,
amount=ctx.scale_reference,
label="일반관리비",
)
overhead = _line(
result,
overhead = emit(
key="general_overhead",
name="일반관리비",
base_label="순공사원가",
@@ -567,55 +386,17 @@ def calculate_cost(data: CostInput) -> CostResult:
percent=rate_percent(overhead_row, label="일반관리비"),
)
profit_row = select_bracket(
dataset.variable("rate_profit")["brackets"],
amount_field="estimated_price_bracket",
amount=scale,
label="이윤",
)
profit_base = total_labor_cost + expense_total + overhead
profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED)
_line(
result,
key="profit_before_adjustment",
name="이윤(조정 전)",
base_label="노무비+경비+일반관리비 (재료비 제외)",
base=profit_base,
percent=rate_percent(profit_row, label="이윤"),
amount=profit_before,
)
if data.profit_adjustment_krw:
_line(
result,
key="profit_adjustment",
name="이윤 조정액",
base_label="설계자 명시 입력",
base=_ZERO,
amount=-data.profit_adjustment_krw,
note="도급공사비 끝수 맞춤 — 법정 항목 아님",
)
profit = profit_before - data.profit_adjustment_krw
_line(
result,
key="profit",
name="이윤",
base_label="조정 전 이윤 조정액",
base=profit_base,
amount=profit,
)
profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead)
total_cost = net_construction_cost + overhead + profit
_line(
result,
emit(
key="total_cost",
name="총원가",
base_label="순공사원가+일반관리비+이윤",
base=total_cost,
amount=total_cost,
)
vat = _line(
result,
vat = emit(
key="vat",
name="부가가치세",
base_label="총원가",
@@ -623,8 +404,7 @@ def calculate_cost(data: CostInput) -> CostResult:
percent=flat_rate(dataset, "rate_vat"),
)
contract_amount = total_cost + vat
_line(
result,
emit(
key="contract_amount",
name="도급공사비",
base_label="총원가+부가가치세",
@@ -632,32 +412,14 @@ def calculate_cost(data: CostInput) -> CostResult:
amount=contract_amount,
)
owner_total = _ZERO
if data.owner_supplied_material_krw:
raw = data.owner_supplied_material_krw
if data.include_fee_in_owner_material_total:
raw = raw + data.procurement_fee_krw
owner_total = ceil_thousand(raw)
_line(
result,
key="owner_supplied_material_total",
name="관급자재대",
base_label=(
"순자재대+조달수수료 (천원 올림)"
if data.include_fee_in_owner_material_total
else "순자재대 (천원 올림)"
),
base=raw,
amount=owner_total,
note="총원가 밖 별도 표기",
)
owner_total = _owner_supplied_line(data, emit)
waste = _waste_line(data, emit)
grand_total = contract_amount + owner_total
_line(
result,
grand_total = contract_amount + owner_total + waste
emit(
key="grand_total",
name="총공사비",
base_label="도급공사비+관급자재대",
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
base=grand_total,
amount=grand_total,
)
@@ -674,6 +436,99 @@ def calculate_cost(data: CostInput) -> CostResult:
"vat": vat,
"contract_amount": contract_amount,
"owner_supplied_material_total": owner_total,
"waste_disposal": 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="설계자 명시 입력",
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="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)",
)
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"))
+111
View File
@@ -0,0 +1,111 @@
"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종).
**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안,
「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서
위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는
검사**를 두어, 규칙을 어기면 계산이 멈추게 한다.
세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢)
㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다
(품셈 1-3-1 「할증 중복 적용 금지」).
㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미
포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6).
㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래
분해는 일위대가에서 한 번만 한다.
"""
from __future__ import annotations
from decimal import Decimal
_TOLERANCE = Decimal("0.5")
class DoubleCountError(AssertionError):
"""이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다."""
def check_surcharge_once(
*,
material_summary_total: Decimal,
unit_price_material_total: Decimal,
surcharge_rate_percent: Decimal,
label: str = "자재",
) -> None:
"""㉠ 할증이 두 번 붙지 않았는가.
`material_summary_total` = 자재총괄의 **할증 포함** 합계.
`unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계.
둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다.
"""
if unit_price_material_total <= 0:
return
expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100))
if material_summary_total > expected + _TOLERANCE:
raise DoubleCountError(
f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > "
f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) "
f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)."
)
def check_free_haul_not_priced(
*,
haul_rows: list[dict],
equipment_field: str = "equipment",
unit_price_field: str = "unit_price_krw",
free_haul_equipment: str = "free_haul",
) -> None:
"""㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가.
줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재).
금지되는 것은 **단가를 붙이는 것**이다.
"""
for row in haul_rows:
if row.get(equipment_field) != free_haul_equipment:
continue
price = Decimal(str(row.get(unit_price_field) or 0))
if price != 0:
raise DoubleCountError(
f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — "
"소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)."
)
def check_haul_volume_within_cut(
*,
haul_volume_total_m3: Decimal,
total_cut_volume_m3: Decimal,
) -> None:
"""㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가.
무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다.
"""
if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE:
raise DoubleCountError(
f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 "
f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 "
"(PLAN 8-7 ㉡)."
)
def check_mix_decomposed_once(
*,
cement_total_kg: Decimal,
concrete_volume_m3: Decimal,
cement_per_m3_kg: Decimal,
) -> None:
"""㉢ 콘크리트를 두 번 쪼개지 않았는가.
시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을
일위대가가 또 분해한 것이다.
"""
if concrete_volume_m3 <= 0:
return
expected = concrete_volume_m3 * cement_per_m3_kg
if cement_total_kg > expected + _TOLERANCE:
raise DoubleCountError(
f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f}× "
f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 "
"쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)."
)
+26
View File
@@ -102,6 +102,24 @@ def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset:
)
@lru_cache(maxsize=8)
def load_rate_dataset_from_path(path: str) -> RateDataset:
"""매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**.
정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면
그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에
`sha256=""` 로 남아 **정본이 아님이 드러난다**.
"""
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
return RateDataset(
dataset_id=payload.get("dataset_id", ""),
effective_date=payload.get("effective_date", ""),
sha256="",
variables=payload.get("variables", {}),
)
def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None:
"""금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None."""
match = _RE_LT.match(label)
@@ -168,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` — 기본값으로 안 때운다.
@@ -201,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}: 조건에 맞는 요율 구간이 없습니다 "
+175
View File
@@ -0,0 +1,175 @@
"""B09 원가계산 라우터 — ⑤ 공사원가계산서 계산 결과를 화면에 낸다.
지금은 **무상태 계산 엔드포인트**다. 순공사비를 받아 원가계산서 한 장을 돌려주고,
저장은 하지 않는다. 프로젝트 저장(채택 단가 스냅샷 `B09_Estimation/v1/`)은 PLAN 9-2
항목으로 뒤에 붙인다.
화면이 「비목 · 금액 · 요율 · 산출근거」 네 칸을 다 보이므로 (PLAN 8-13) 줄마다 그 넷을
그대로 실어 보낸다. 안전관리비는 A·B 두 줄이 나란히 오고 `note` 에 채택 표시가 붙는다.
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B09_Estimation.B09_Estimation_Engine_Cost import (
CostInput,
CostResult,
calculate_cost,
proposed_profit_adjustment,
)
from B09_Estimation.B09_Estimation_Rates import RateLookupError
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
from common_util.common_util_workflow_state import complete_stage
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
class CostRequest(BaseModel):
"""원가계산 입력 — 금액은 원 단위."""
direct_material_krw: Decimal = Field(default=Decimal(0), ge=0)
direct_labor_krw: Decimal = Field(default=Decimal(0), ge=0)
direct_expense_krw: Decimal = Field(default=Decimal(0), ge=0)
indirect_material_krw: Decimal = Field(default=Decimal(0), ge=0)
work_type_indirect_labor: str = "civil"
work_type_safety: str = "civil"
duration_days: int = Field(default=183, ge=1)
pension_year: int = 2026
owner_supplied_material_krw: Decimal = Field(default=Decimal(0), ge=0)
procurement_fee_krw: Decimal = Field(default=Decimal(0), ge=0)
include_fee_in_owner_material_total: bool = True
owner_supplied_for_safety_krw: Decimal | None = None
owner_supplied_includes_vat: bool = True
deduct_procurement_fee_for_safety: bool = False
estimated_price_krw: Decimal | None = None
profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0)
waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0)
environment_work_type: str = "civil_road"
equipment_guarantee_work_type: str = "civil_general"
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
rate_file_name: str = "rates_2026.json"
#: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**.
#: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다.
target_contract_amount_krw: Decimal | None = None
def to_engine_input(self) -> CostInput:
return CostInput(
direct_material_krw=self.direct_material_krw,
direct_labor_krw=self.direct_labor_krw,
direct_expense_krw=self.direct_expense_krw,
indirect_material_krw=self.indirect_material_krw,
work_type_indirect_labor=self.work_type_indirect_labor,
work_type_safety=self.work_type_safety,
duration_days=self.duration_days,
pension_year=self.pension_year,
owner_supplied_material_krw=self.owner_supplied_material_krw,
procurement_fee_krw=self.procurement_fee_krw,
include_fee_in_owner_material_total=self.include_fee_in_owner_material_total,
owner_supplied_for_safety_krw=self.owner_supplied_for_safety_krw,
owner_supplied_includes_vat=self.owner_supplied_includes_vat,
deduct_procurement_fee_for_safety=self.deduct_procurement_fee_for_safety,
estimated_price_krw=self.estimated_price_krw,
profit_adjustment_krw=self.profit_adjustment_krw,
waste_disposal_krw=self.waste_disposal_krw,
environment_work_type=self.environment_work_type,
equipment_guarantee_work_type=self.equipment_guarantee_work_type,
subcontract_guarantee_variant=self.subcontract_guarantee_variant,
rate_file_name=self.rate_file_name,
)
def _serialize(result: CostResult) -> dict[str, Any]:
"""계산 결과를 화면이 그대로 그릴 수 있는 모양으로 편다."""
return {
"lines": [
{
"key": line.key,
"name": line.name,
"base_label": line.base_label,
"base_amount_krw": str(line.base_amount_krw),
"rate_percent": (None if line.rate_percent is None else str(line.rate_percent)),
"flat_amount_krw": str(line.flat_amount_krw),
"amount_krw": str(line.amount_krw),
"formula_text": line.formula_text,
"note": line.note,
}
for line in result.lines
],
"totals": {key: str(value) for key, value in result.totals.items()},
"rate_version": result.rate_version,
"notes": result.notes,
}
@router.post("/{project_id}/estimation/cost")
async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
"""공사원가계산서 한 장을 계산해 돌려준다 (저장 없음)."""
try:
result = calculate_cost(payload.to_engine_input())
except RateLookupError as error:
# 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다.
logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error)
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("B09 원가계산 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "원가계산에 실패했습니다."},
)
body = _serialize(result)
if payload.target_contract_amount_krw is not None:
# 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다.
body["suggested_profit_adjustment_krw"] = str(
proposed_profit_adjustment(result, payload.target_contract_amount_krw)
)
return JSONResponse(content={"status": "success", **body})
@router.get("/{project_id}/estimation/items")
async def list_items(project_id: UUID) -> JSONResponse:
"""비목 정의 목록 — 화면이 무엇을 켜고 끌 수 있는지 알기 위한 것."""
return JSONResponse(
content={
"status": "success",
"items": [
{"key": item.key, "name": item.name, "base_label": item.base_label}
for item in STATUTORY_ITEMS
],
}
)
@router.post("/{project_id}/estimation/confirm")
async def confirm_estimation(project_id: UUID) -> JSONResponse:
"""원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다."""
pool = get_db_pool()
async with pool.acquire() as connection:
try:
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 6)
await connection.commit()
except Exception:
await connection.rollback()
logger.exception("B09 원가계산 확정 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."},
)
return JSONResponse(content={"status": "success", "project_id": str(project_id)})
+408
View File
@@ -0,0 +1,408 @@
"""B09 원가계산 — 법정경비 묶음 (⑤ 공사원가계산서의 경비 부분).
`B09_Estimation_Engine_Cost` 가 부르는 하위 모듈. 700줄 제한(CLAUDE.md 4장)에 맞춰
경비 계산만 떼어 두었다.
지켜야 할 것 (PLAN 8-9·8-10·8-13·8-14)
- **비목 목록을 코드에 박지 않는다.** 아래 `STATUTORY_ITEMS` 는 「무엇을 어떤 밑수로
계산하는가」의 정의일 뿐이고, **그 해에 그 비목이 있는지는 요율 데이터가 정한다**
(해당 요율 변수가 데이터셋에 없으면 그 해에는 없는 비목).
- **밑수가 항목마다 갈린다** — 산재·고용 = 직노+간노 / 건강·연금 = 직노 /
요양 = 건강보험료 / 안전 = 재료비+직노+관급항 / 기타경비 = 재료비+직노+간노 /
환경·보증 = 직접공사비. 뭉뚱그리면 틀린다.
- **안전관리비는 A·B 두 값을 다 내고 작은 쪽** (고용노동부 고시 제2025-11호).
⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다
(실증: 울진 A 채택 / 거창 B 채택).
- ★ **법대로**(8-10) — 조달수수료 차감은 규정 문구가 아니다. 기본은 **차감하지 않음**.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Callable
from B09_Estimation.B09_Estimation_Rates import (
RateDataset,
RateLookupError,
base_amount,
pension_rate_percent,
rate_percent,
select_bracket,
)
if TYPE_CHECKING: # pragma: no cover - 순환 import 회피용
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult
_ZERO = Decimal(0)
_HUNDRED = Decimal(100)
_VAT_DIVISOR = Decimal("1.1")
_SAFETY_B_MULTIPLIER = Decimal("1.2")
#: 공사이행보증수수료 요율 데이터가 값 대신 들고 있는 식 문자열 형태.
_RE_GUARANTEE_FORMULA = re.compile(r"([0-9.]+)\s*%")
@dataclass(frozen=True)
class StatutoryItem:
"""법정경비 한 비목의 정의 — 이름·요율 변수·밑수 뽑는 법."""
key: str
name: str
rate_variable: str
base_label: str
#: 비목 정의. **순서가 곧 원가계산서 줄 순서**다.
STATUTORY_ITEMS: tuple[StatutoryItem, ...] = (
StatutoryItem(
"industrial_accident_insurance", "산재보험료", "rate_sanjae", "노무비(직접+간접)"
),
StatutoryItem("employment_insurance", "고용보험료", "rate_goyong", "노무비(직접+간접)"),
StatutoryItem("health_insurance", "국민건강보험료", "rate_health", "직접노무비"),
StatutoryItem("long_term_care_insurance", "노인장기요양보험료", "rate_care", "국민건강보험료"),
StatutoryItem("national_pension", "국민연금보험료", "rate_pension", "직접노무비"),
StatutoryItem(
"safety_management_cost", "산업안전보건관리비", "rate_safety_pct", "A·B 중 작은 금액"
),
StatutoryItem("other_expense", "기타경비", "rate_other_expense", "재료비+노무비(직접+간접)"),
StatutoryItem("environment_preservation", "환경보전비", "rate_environment", "직접공사비"),
StatutoryItem(
"retirement_mutual_aid", "퇴직공제부금비", "rate_retirement_mutual_aid", "직접노무비"
),
StatutoryItem(
"wage_claim_contribution",
"임금채권보장기금 부담금",
"rate_wage_claim_contribution",
"노무비(직접+간접)",
),
StatutoryItem(
"asbestos_contribution",
"석면피해구제 분담금",
"rate_asbestos_contribution",
"노무비(직접+간접)",
),
StatutoryItem(
"equipment_payment_guarantee",
"건설기계대여대금 지급보증수수료",
"rate_equipment_payment_guarantee",
"직접공사비",
),
StatutoryItem(
"subcontract_payment_guarantee",
"하도급대금 지급보증수수료",
"rate_subcontract_payment_guarantee",
"직접공사비",
),
StatutoryItem(
"performance_guarantee_fee",
"공사이행보증수수료",
"rate_performance_guarantee_fee",
"직접공사비 × 공사기간(년)",
),
)
_ITEM_BY_KEY = {item.key: item for item in STATUTORY_ITEMS}
def available_items(dataset: RateDataset) -> tuple[str, ...]:
"""**그 해에 유효한 비목 목록** — 요율 데이터에 그 변수가 있는 것만 (PLAN 8-13).
비목 목록을 코드에 박지 않기 위한 자리. 연도별 요율 파일이 갈리면 목록도 따라 갈린다.
"""
return tuple(item.key for item in STATUTORY_ITEMS if item.rate_variable in dataset.variables)
def item_name(key: str) -> str:
return _ITEM_BY_KEY[key].name
@dataclass
class ExpenseContext:
"""법정경비 계산에 필요한 밑수 묶음 — 엔진이 채워 넘긴다."""
material_cost: Decimal
direct_labor_cost: Decimal
total_labor_cost: Decimal
direct_construction_cost: Decimal
scale_reference: Decimal
def _threshold_met(dataset: RateDataset, variable: str, field: str, amount: Decimal) -> bool:
"""적용 하한(추정금액 1억 이상 등)을 넘겼는가."""
minimum = dataset.variable(variable).get(field)
if minimum is None:
return True
return amount >= Decimal(str(minimum))
def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decimal:
"""공사이행보증수수료는 요율 데이터가 값이 아니라 **식 문자열**을 들고 있다.
예: `"(direct_cost * 0.0108%) * duration_years"` → 0.0108 을 뽑는다.
식 모양이 바뀌면 조용히 넘기지 않고 멈춘다.
"""
formula = str(row.get("formula", ""))
match = _RE_GUARANTEE_FORMULA.search(formula)
if not match:
raise RateLookupError(f"{label}: 요율 식에서 백분율을 못 읽었습니다 — {formula!r}")
return Decimal(match.group(1))
def safety_management_cost(
dataset: RateDataset,
data: CostInput,
ctx: ExpenseContext,
emit: Callable[..., Decimal],
) -> Decimal:
"""산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다.
A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액
B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
"""
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw
if data.deduct_procurement_fee_for_safety:
# ★ 법대로(8-10) — 규정 문구가 아니다. 옛 서류 재현용으로만 켠다.
owner_supplied = owner_supplied - data.procurement_fee_krw
if data.owner_supplied_includes_vat:
owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
base_without = ctx.material_cost + ctx.direct_labor_cost
def evaluate(
base: Decimal, multiplier: Decimal, label: str
) -> tuple[Decimal, Decimal, Decimal]:
row = select_bracket(
brackets,
amount_field="target_amount_bracket",
amount=base,
equals={"work_type": data.work_type_safety},
label=label,
)
percent = rate_percent(row, label=label)
flat = base_amount(row)
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
raw_b, percent_b, flat_b = evaluate(
base_without, _SAFETY_B_MULTIPLIER, "안전관리비 B(관급 제외 × 1.2)"
)
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
amount_a = emit(
key="safety_management_cost_a",
name="산업안전보건관리비 A(관급 포함)",
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
base=base_with,
percent=percent_a,
flat=flat_a,
raw=raw_a,
note="채택" if adopted == "A" else "미채택",
)
amount_b = emit(
key="safety_management_cost_b",
name="산업안전보건관리비 B(관급 제외 × 1.2)",
base_label="재료비+직접노무비 (관급 제외)",
base=base_without,
percent=percent_b,
flat=flat_b,
raw=raw_b,
note="채택" if adopted == "B" else "미채택",
)
return emit(
key="safety_management_cost",
name="산업안전보건관리비",
base_label=f"A·B 중 작은 금액 (채택 = {adopted})",
base=base_with if adopted == "A" else base_without,
percent=percent_a if adopted == "A" else percent_b,
flat=_ZERO,
raw=min(amount_a, amount_b),
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
)
def statutory_expenses(
dataset: RateDataset,
data: CostInput,
ctx: ExpenseContext,
result: CostResult,
emit: Callable[..., Decimal],
) -> Decimal:
"""켜진 비목만 순서대로 계산해 합계를 돌려준다."""
enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY]
unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY]
if unknown:
raise RateLookupError(f"모르는 비목입니다: {unknown}")
missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables]
if missing:
raise RateLookupError(
f"이 요율 판({dataset.effective_date})에 없는 비목입니다: "
f"{[item_name(k) for k in missing]}"
)
total = _ZERO
health_amount = _ZERO
for key in enabled:
item = _ITEM_BY_KEY[key]
if key == "safety_management_cost":
total += safety_management_cost(dataset, data, ctx, emit)
continue
if key == "long_term_care_insurance":
if "health_insurance" not in enabled:
raise RateLookupError(
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
)
total += emit(
key=key,
name=item.name,
base_label=item.base_label,
base=health_amount,
percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])),
)
continue
base, percent, note = _base_and_rate(dataset, data, ctx, item)
if base is None:
continue # 적용 하한 미달 — 줄 자체를 만들지 않는다
amount = emit(
key=key,
name=item.name,
base_label=item.base_label,
base=base,
percent=percent,
note=note,
)
if key == "health_insurance":
health_amount = amount
total += amount
return total
def _base_and_rate(
dataset: RateDataset,
data: CostInput,
ctx: ExpenseContext,
item: StatutoryItem,
) -> tuple[Decimal | None, Decimal, str]:
"""비목별 밑수·요율. 밑수가 `None` 이면 적용 대상이 아니다."""
variable = dataset.variable(item.rate_variable)
key = item.key
if key in ("industrial_accident_insurance", "wage_claim_contribution", "asbestos_contribution"):
return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), ""
if key == "employment_insurance":
# 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로
# 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율).
row = select_bracket(
variable["brackets"],
amount_field="estimated_amount_bracket",
amount=ctx.scale_reference,
residual_label="below_official_threshold",
label=item.name,
)
return ctx.total_labor_cost, rate_percent(row, label=item.name), ""
if key == "health_insurance":
return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), ""
if key == "national_pension":
return ctx.direct_labor_cost, pension_rate_percent(dataset, data.pension_year), ""
if key == "other_expense":
row = select_bracket(
variable["brackets"],
amount_field="direct_cost_bracket",
amount=ctx.direct_construction_cost,
duration_days=data.duration_days,
equals={"work_type": data.work_type_indirect_labor},
label=item.name,
)
return ctx.material_cost + ctx.total_labor_cost, rate_percent(row, label=item.name), ""
if key == "environment_preservation":
if not _threshold_met(
dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference
):
return None, _ZERO, ""
row = next(
(
r
for r in variable["all_work_types"]
if r.get("work_type") == data.environment_work_type
),
None,
)
if row is None:
raise RateLookupError(
f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}"
)
note = ""
if variable.get("forest_road_selection_status") == "pending":
note = "⚠ 임도 공종 채택값 미확정 (지식DB `forest_road_selection_status: pending`)"
return ctx.direct_construction_cost, rate_percent(row, label=item.name), note
if key == "retirement_mutual_aid":
if not _threshold_met(
dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference
):
return None, _ZERO, ""
return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), ""
if key == "equipment_payment_guarantee":
rows = variable["general_construction"] + variable["specialty_construction"]
row = next(
(r for r in rows if r.get("work_type") == data.equipment_guarantee_work_type), None
)
if row is None:
raise RateLookupError(
"건설기계대여대금 지급보증: 공종을 못 찾았습니다 — "
f"{data.equipment_guarantee_work_type}"
)
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), ""
if key == "performance_guarantee_fee":
row = select_bracket(
variable["brackets"],
amount_field="direct_cost_bracket",
amount=ctx.direct_construction_cost,
label=item.name,
)
percent = _guarantee_percent_from_formula(row, label=item.name)
years = Decimal(str(data.duration_days)) / Decimal(365)
note = variable.get("typical_forest_road_applicability", "")
return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else ""
raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")
+461 -6
View File
@@ -1,26 +1,481 @@
/* =============================================================================
* B09_Estimation_UI_Page.ts
* 로그인 후 09: 6차 워크플로우 (견적·문서)
* 로그인 후 09: 6차 워크플로우 (원가계산)
*
* ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화.
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
* 화면 규칙 (PLAN 8-13 · 화면 기획)
* - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표.
* - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다.
* 결과 숫자만 보이면 설계자가 검산을 못 한다.
* - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다
* (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12).
* - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2).
* - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을
* 보여만 준다 (★법대로 PLAN 8-10).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 타입 — 라우터 응답과 1:1
* -------------------------------------------------------------------------- */
interface CostLineDto {
key: string;
name: string;
base_label: string;
base_amount_krw: string;
rate_percent: string | null;
flat_amount_krw: string;
amount_krw: string;
formula_text: string;
note: string;
}
interface CostSheetDto {
status: string;
lines: CostLineDto[];
totals: Record<string, string>;
rate_version: { dataset_id: string; effective_date: string; sha256: string };
notes: string[];
suggested_profit_adjustment_krw?: string;
}
/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */
interface CostFormState {
direct_material_krw: string;
direct_labor_krw: string;
direct_expense_krw: string;
duration_days: string;
owner_supplied_material_krw: string;
procurement_fee_krw: string;
profit_adjustment_krw: string;
target_contract_amount_krw: string;
}
const INITIAL_FORM: CostFormState = {
direct_material_krw: "0",
direct_labor_krw: "0",
direct_expense_krw: "0",
duration_days: "183",
owner_supplied_material_krw: "0",
procurement_fee_krw: "0",
profit_adjustment_krw: "0",
target_contract_amount_krw: "",
};
/** 총계 성격의 줄 — 표에서 굵게 띄운다. */
const TOTAL_KEYS = new Set([
"material_cost",
"labor_cost",
"expense",
"net_construction_cost",
"total_cost",
"contract_amount",
"grand_total",
]);
/* -----------------------------------------------------------------------------
* 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지)
* -------------------------------------------------------------------------- */
const STYLE_ID = "b09-estimation-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
.b09-panel__legend {
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
color: var(--color-text-secondary); text-transform: uppercase;
}
.b09-panel__readonly {
font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary);
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border); padding: 2px 0;
}
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; }
.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; }
.b09-tab {
font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer;
border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary);
}
.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); }
.b09-tab:disabled { cursor: not-allowed; opacity: .55; }
.b09-sheet { overflow: auto; min-height: 0; flex: 1; }
.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); }
.b09-sheet th, .b09-sheet td {
border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right;
white-space: nowrap; font-variant-numeric: tabular-nums;
}
.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; }
.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; }
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); }
`;
document.head.append(style);
}
/* -----------------------------------------------------------------------------
* 표 그리기
* -------------------------------------------------------------------------- */
function formatWon(value: string): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
return n.toLocaleString("ko-KR");
}
function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet";
const table = document.createElement("table");
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const headers: Array<[string, boolean]> = [
[L("B09_Estimation_Col_Item"), true],
[L("B09_Estimation_Col_Amount"), false],
[L("B09_Estimation_Col_Rate"), false],
[L("B09_Estimation_Col_Basis"), true],
[L("B09_Estimation_Col_Note"), true],
];
for (const [text, left] of headers) {
const th = document.createElement("th");
th.textContent = text;
if (left) th.className = "b09-left";
headRow.append(th);
}
thead.append(headRow);
table.append(thead);
const tbody = document.createElement("tbody");
for (const line of sheet.lines) {
const tr = document.createElement("tr");
if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total");
if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped");
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = line.name;
const amount = document.createElement("td");
amount.textContent = formatWon(line.amount_krw);
const rate = document.createElement("td");
rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`;
const basis = document.createElement("td");
basis.className = "b09-left";
basis.textContent = line.formula_text;
const note = document.createElement("td");
note.className = "b09-left";
note.textContent = line.note;
tr.append(name, amount, rate, basis, note);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
return wrap;
}
/* -----------------------------------------------------------------------------
* 좌측 패널
* -------------------------------------------------------------------------- */
interface PanelHandles {
root: HTMLElement;
rateVersionBox: HTMLElement;
hintBox: HTMLElement;
}
function buildSidePanel(
form: CostFormState,
onRecalc: () => void,
onConfirm: () => void,
): PanelHandles {
const root = document.createElement("div");
root.className = "b09-panel";
const addGroup = (
legendKey: keyof typeof ui_locales,
fields: Array<[keyof CostFormState, keyof typeof ui_locales]>,
): void => {
const group = document.createElement("div");
group.className = "b09-panel__group";
const legend = document.createElement("span");
legend.className = "b09-panel__legend";
legend.textContent = L(legendKey);
group.append(legend);
for (const [field, labelKey] of fields) {
const handle = createInputField({
label: L(labelKey),
type: "number",
min: 0,
value: form[field],
onInput: (value) => {
form[field] = value;
},
});
group.append(handle.root);
}
root.append(group);
};
addGroup("B09_Estimation_Group_Condition", [
["direct_material_krw", "B09_Estimation_Field_DirectMaterial"],
["direct_labor_krw", "B09_Estimation_Field_DirectLabor"],
["direct_expense_krw", "B09_Estimation_Field_DirectExpense"],
["duration_days", "B09_Estimation_Field_Duration"],
]);
// 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다.
const rateGroup = document.createElement("div");
rateGroup.className = "b09-panel__group";
const rateLegend = document.createElement("span");
rateLegend.className = "b09-panel__legend";
rateLegend.textContent = L("B09_Estimation_Group_RateVersion");
const rateVersionBox = document.createElement("div");
rateGroup.append(rateLegend, rateVersionBox);
root.append(rateGroup);
addGroup("B09_Estimation_Group_Supplied", [
["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"],
["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"],
]);
addGroup("B09_Estimation_Group_Profit", [
["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"],
["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"],
]);
const hintBox = document.createElement("div");
hintBox.className = "b09-hint";
root.append(hintBox);
const actions = document.createElement("div");
actions.className = "b09-panel__actions";
actions.append(
createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }),
createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }),
);
root.append(actions);
return { root, rateVersionBox, hintBox };
}
function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
box.replaceChildren();
if (!sheet) return;
const rows: Array<[string, string]> = [
["적용일", sheet.rate_version.effective_date || "—"],
["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}` : "—"],
];
for (const [label, value] of rows) {
const row = document.createElement("div");
row.className = "b09-panel__readonly";
const left = document.createElement("span");
left.textContent = label;
const right = document.createElement("span");
right.textContent = value;
row.append(left, right);
box.append(row);
}
}
/* -----------------------------------------------------------------------------
* 탭
* -------------------------------------------------------------------------- */
const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", false],
["unit_price", "B09_Estimation_Tab_UnitPrice", false],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["machine", "B09_Estimation_Tab_Machine", false],
["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", false],
["base_data", "B09_Estimation_Tab_BaseData", false],
];
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
const bar = document.createElement("div");
bar.className = "b09-tabs";
for (const [key, labelKey, enabled] of TAB_KEYS) {
const button = document.createElement("button");
button.type = "button";
button.className = "b09-tab";
button.dataset.tab = key;
button.textContent = L(labelKey);
button.disabled = !enabled;
if (!enabled) button.title = L("B09_Estimation_Tab_Pending");
if (key === active) button.classList.add("is-active");
button.addEventListener("click", () => onSelect(key));
bar.append(button);
}
return bar;
}
/* -----------------------------------------------------------------------------
* API
* -------------------------------------------------------------------------- */
function toRequestBody(form: CostFormState): Record<string, unknown> {
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
const body: Record<string, unknown> = {
direct_material_krw: num(form.direct_material_krw),
direct_labor_krw: num(form.direct_labor_krw),
direct_expense_krw: num(form.direct_expense_krw),
duration_days: Number(num(form.duration_days)),
owner_supplied_material_krw: num(form.owner_supplied_material_krw),
procurement_fee_krw: num(form.procurement_fee_krw),
profit_adjustment_krw: num(form.profit_adjustment_krw),
};
if (form.target_contract_amount_krw.trim() !== "") {
body.target_contract_amount_krw = form.target_contract_amount_krw.trim();
}
return body;
}
async function fetchCostSheet(projectId: string, form: CostFormState): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toRequestBody(form)),
},
);
if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`);
return (await response.json()) as CostSheetDto;
}
async function confirmEstimationStage(projectId: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
{ method: "POST", credentials: "include" },
);
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
await renderPendingWorkflow(root, {
injectStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const form: CostFormState = { ...INITIAL_FORM };
let activeTab = "cost_sheet";
let sheet: CostSheetDto | null = null;
const main = document.createElement("div");
main.className = "b09-main";
const body = document.createElement("div");
body.style.flex = "1";
body.style.minHeight = "0";
body.style.display = "flex";
body.style.flexDirection = "column";
const drawBody = (): void => {
body.replaceChildren();
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Tab_Pending");
body.append(empty);
return;
}
if (!sheet) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Btn_Recalc");
body.append(empty);
return;
}
body.append(buildCostSheetTable(sheet));
for (const note of sheet.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note;
body.append(line);
}
};
const drawTabs = (): void => {
const bar = buildTabs(activeTab, (key) => {
activeTab = key;
drawTabs();
drawBody();
});
const old = main.querySelector(".b09-tabs");
if (old) old.replaceWith(bar);
else main.prepend(bar);
};
const panel = buildSidePanel(
form,
async () => {
if (!projectId) return;
try {
sheet = await fetchCostSheet(projectId, form);
renderRateVersion(panel.rateVersionBox, sheet);
panel.hintBox.textContent =
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}`
: "";
drawBody();
} catch {
showToast(L("B09_Estimation_Calc_Failed"), "error");
}
},
async () => {
if (!projectId) return;
try {
await confirmEstimationStage(projectId);
showToast(L("B09_Estimation_Confirm_Success"), "success");
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]);
} catch {
showToast(L("B09_Estimation_Confirm_Failed"), "error");
}
},
);
main.append(body);
drawTabs();
drawBody();
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel.root,
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
}
+2
View File
@@ -60,6 +60,7 @@ from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
@@ -537,6 +538,7 @@ app.include_router(b07_design_router, dependencies=protected_with_company)
app.include_router(b07_frame_router, dependencies=protected_with_company)
app.include_router(b08_quantity_router, dependencies=protected_with_company)
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
# ─────────────────────────────────────────────────────────────────────────
+46 -2
View File
@@ -623,8 +623,52 @@ export const ui_locales_b2 = {
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
/* --- B09_Estimation 견적·문서 --- */
B09_Estimation_Title: ["설계도서", "Design Docs"],
/* --- B09_Estimation 원가계산 --- */
B09_Estimation_Title: ["원가계산", "Cost Estimate"],
B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"],
B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"],
B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"],
B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"],
B09_Estimation_Tab_Machine: ["중기", "Equipment"],
B09_Estimation_Tab_Duration: ["공사기간", "Duration"],
B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"],
B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"],
B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"],
B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"],
B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"],
B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"],
B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"],
B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"],
B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"],
B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"],
B09_Estimation_Field_WorkType: ["공종", "Work Type"],
B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"],
B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"],
B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"],
B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"],
B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"],
B09_Estimation_Btn_Confirm: ["확정", "Confirm"],
B09_Estimation_Col_Item: ["비목", "Item"],
B09_Estimation_Col_Amount: ["금액", "Amount"],
B09_Estimation_Col_Rate: ["요율", "Rate"],
B09_Estimation_Col_Basis: ["산출근거", "Basis"],
B09_Estimation_Col_Note: ["비고", "Note"],
B09_Estimation_Adopted: ["채택", "Adopted"],
B09_Estimation_NotAdopted: ["미채택", "Not adopted"],
B09_Estimation_Suggest_Adjust: [
"목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.",
"To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.",
],
B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."],
B09_Estimation_Confirm_Success: [
"원가계산 단계를 확정했습니다.",
"Cost estimate stage confirmed.",
],
B09_Estimation_Confirm_Failed: [
"원가계산 단계 확정에 실패했습니다.",
"Failed to confirm the cost estimate stage.",
],
B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"],
/* --- B10_Payment 결재 --- */
B10_Payment_Title: ["결재", "Payment"],
+1 -1
View File
@@ -82,7 +82,7 @@ export const ui_locales_common = {
WF_Step_ProfileCross: ["횡단설계", "Cross Design"],
WF_Step_DesignDetail: ["상세설계", "Detail Design"],
WF_Step_Quantity: ["수량산출", "Quantity"],
WF_Step_Estimation: ["설계도서", "Design Docs"],
WF_Step_Estimation: ["원가계산", "Cost Estimate"],
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
WF_State_Failed: ["실패", "Failed"],
WF_State_Complete: ["완료", "Complete"],