diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 762d581f..9531369b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -10,6 +10,7 @@ from __future__ import annotations +import json import logging from dataclasses import replace as dataclass_replace from decimal import Decimal @@ -27,6 +28,8 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( proposed_profit_adjustment, ) from B09_Estimation.B09_Estimation_PriceBook import PriceBookError +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill +from B09_Estimation.B09_Estimation_Guards import DoubleCountError from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at @@ -246,3 +249,52 @@ async def confirm_estimation(project_id: UUID) -> JSONResponse: content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."}, ) return JSONResponse(content={"status": "success", "project_id": str(project_id)}) + + +@router.get("/{project_id}/estimation/bill") +async def get_bill(project_id: UUID) -> JSONResponse: + """④ 예산내역서 한 장 — B08 인계를 그대로 받아 계층을 세워 돌려준다. + + ⚠ **수량을 다시 세지 않는다.** B08 인계가 정본이고 여기서는 단가를 붙여 금액만 + 만든다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」). + + ⚠ 단가가 없거나 밑수를 모르는 줄은 **0 으로 안 때우고** `missing` 으로 드러낸다 — + 화면이 그 목록을 그대로 보인다. + """ + from B08_Quantity.B08_Quantity_Router_Material import get_handoff + + try: + response = await get_handoff(project_id) + payload = json.loads(bytes(response.body).decode("utf-8")) + except Exception: + logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id) + return JSONResponse( + status_code=502, + content={"status": "error", "message": "B08 인계 자료를 받지 못했습니다."}, + ) + if "work_items" not in payload: + # B08 이 오류 응답을 준 경우 — 그 사유를 그대로 넘긴다(감추지 않는다). + return JSONResponse(status_code=502, content={"status": "error", **payload}) + + try: + result = build_bill(payload) + except DoubleCountError as error: + # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. + logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) + return JSONResponse(status_code=409, 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": "예산내역서를 세우지 못했습니다."}, + ) + + return JSONResponse( + content={ + "status": "success", + "rows": [row.as_dict() for row in result.rows], + "excluded": [row.as_dict() for row in result.excluded], + "materials": [row.as_dict() for row in result.material_rows], + "summary": bill_summary(result), + } + )