"""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 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, 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)})