Files
Aislo/B09_Estimation/B09_Estimation_Router.py
T
eomsangdonandClaude Opus 5 6c4d0251db feat(B09): 일위대가 탭 — 목록표+본표 2단, 원천 표시, 층 파고들기
**API 2종** (`B09_Estimation_Router.py`)
- `GET …/estimation/unit-prices` — **목록표** + **산출 요약**. 요약을 같이 보내는 까닭은
  사용자가 「무엇이 안 선 상태인가」를 화면에서 알아야 하기 때문임(자재 카탈로그
  미확보로 구조물 계열이 안 섬).
- `GET …/estimation/unit-prices/{code}` — **본표**. 줄마다 원천(자재5·노임6·기계경비105·
  일위대가103·단가산출104)과 **파고들기 가능 여부**가 붙음. 없는 코드는 404.
- 품셈 3 MB 를 요청마다 다시 안 읽게 `cached_build()` 로 한 번만 조립.

**화면** (`B09_Estimation_UI_Page.ts`)
- 일위대가 탭 활성화. **목록표(위) + 본표(아래) 2단** — 9-3 「제목+상세 한 쌍」이 화면에도
  그대로 섬.
- 본표 줄마다 `원천(번호)` 표시, **기계 줄을 누르면 그 시간당 사용료 본표로 파고듦**
  (거기서 취득가·연료·조종원까지 보임). 값을 못 믿을 때 사람이 하는 일이 이것임.
- **재료·노무·경비 3분할 + 합계 줄**, `TC = NC + GC + JC` 성립 여부를 화면 문구로 냄.
- **산출 요약을 화면에 표시** — 자재가 없어 구조물 계열이 못 선다는 것을 그 자리에 적음.

locale 은 **B09 키만** 추가(16줄), 공용 파일 다른 줄 무수정.

⚠ 화면 조작 검증은 다음 단계 — `tsc` 는 통과했고(남은 오류 1건은 메인 창 B08 파일),
백엔드 재시작·클릭 검증은 이어서 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:13:20 +09:00

220 lines
9.4 KiB
Python

"""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_PriceBook import PriceBookError
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
from B09_Estimation.B09_Estimation_UnitPrice import (
build_summary,
cached_build,
detail_of,
list_unit_prices,
)
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.get("/{project_id}/estimation/unit-prices")
async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
"""일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약.
요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서**
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬).
"""
try:
build = cached_build()
return JSONResponse(
content={
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
)
except Exception:
logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "일위대가 목록을 못 만들었습니다."},
)
@router.get("/{project_id}/estimation/unit-prices/{code}")
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
try:
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
except PriceBookError as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."},
)
@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)})