- 서버: /estimation/cost-sheet(GET·PUT) · /estimation/rate-table — 새 라우터 파일
- 직접비 = 설계내역서 합계 · 기준 입력 = estimation.cost_sheet · 폐기물처리비 = 임목폐기물 톤 × 수동 단가
- 서식 줄: 나.순공사원가 … 바.총공사비 (DFM TWM_KANJUB 차례·표기) · 경비 세목 번호 차례
- 기준 입력판: 형식(일반만) · 2.공사의종류 · 3.공사기간 · 6.관/사급 · 7.산업안전 · 8.환경보전 · 9.건설기계 — 선택지는 요율 데이터에서
- 요율표: 첫 탭 묶음 10개(보기 전용 · 수정은 잠김)
- 탭 계약 파일은 서브 314c0040 과 같은 내용(blob 동일) · 사전은 새 벌 b4
- 시험: 서식 차례·엔진 값 일치·폐기물 자리·저장값 거름·구간 표기
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
"""B09 원가계산서 · 제비율 요율표 탭 API (PLAN 12장 · 랩탑 메인).
|
||
|
||
⚠ 옛 `/estimation/cost`(수기 입력)는 그대로 둔다 — 새 탭은 **프로젝트 값**으로 선다:
|
||
직접비 = 예산내역서(`/estimation/bill`) 합계 · 기준 입력 = `estimation.cost_sheet` 저장값 ·
|
||
폐기물처리비 = B08 「임목폐기물 처리」 톤 × 수동 처리단가(분리발주 여부도 B08 산출 조건).
|
||
⚠ 화면이 값을 만들지 않는다 — 서식 줄·금액·산식은 전부 여기서 낸다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import math
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from fastapi import APIRouter
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from B09_Estimation.B09_Estimation_CostSheet import (
|
||
AMOUNT_FIELDS,
|
||
SETTINGS_KEY,
|
||
WORK_TYPE_FIELDS,
|
||
clean_settings,
|
||
cost_input,
|
||
field_options,
|
||
sheet_rows,
|
||
status_line,
|
||
)
|
||
from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost
|
||
from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset
|
||
from B09_Estimation.B09_Estimation_RateTable import rate_sections
|
||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Cost Sheet"])
|
||
|
||
TREE_WASTE_ITEM = "임목폐기물 처리"
|
||
|
||
|
||
async def _root(project_id: UUID) -> str | None:
|
||
from B09_Estimation.B09_Estimation_Router import _project_root_of
|
||
|
||
return await _project_root_of(project_id)
|
||
|
||
|
||
def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, bool, str]:
|
||
"""(금액, 분리발주, 사유) — 톤은 내역서 제외 줄에서, 단가는 B08 수동 입력에서."""
|
||
tons = next(
|
||
(
|
||
Decimal(str(row.get("quantity") or 0))
|
||
for row in bill.get("excluded") or []
|
||
if row.get("name") == TREE_WASTE_ITEM
|
||
),
|
||
Decimal(0),
|
||
)
|
||
price = quantity.get("tree_waste_unit_price_krw_per_ton")
|
||
separate = bool(quantity.get("waste_separate_order"))
|
||
if tons <= 0:
|
||
return Decimal(0), separate, "임목폐기물 톤이 아직 안 섬(수량산출 산출 조건)"
|
||
if not price:
|
||
return Decimal(0), separate, f"임목폐기물 {tons:,.2f}톤 — 처리단가 없음(수동 입력 전)"
|
||
amount = Decimal(math.floor(tons * Decimal(str(price))))
|
||
return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)"
|
||
|
||
|
||
@router.get("/{project_id}/estimation/cost-sheet")
|
||
async def get_cost_sheet(project_id: UUID) -> JSONResponse:
|
||
"""원가계산서(일반형식) 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄."""
|
||
from B09_Estimation.B09_Estimation_Router import get_bill
|
||
from common_util.common_util_project_settings import estimation_settings, quantity_settings
|
||
|
||
root = await _root(project_id)
|
||
if root is None:
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||
)
|
||
response = await get_bill(project_id)
|
||
bill = json.loads(bytes(response.body).decode("utf-8"))
|
||
if response.status_code != 200 or "summary" not in bill:
|
||
# 내역서가 안 서면 원가계산서도 못 섬 — 사유를 그대로 넘긴다.
|
||
return JSONResponse(status_code=response.status_code or 502, content=bill)
|
||
summary = bill["summary"]
|
||
place = OutputPlace.RESOURCE_SUMMARY
|
||
direct = {
|
||
part: round_at(Decimal(str(summary.get(f"direct_{part}_krw") or 0)), place)
|
||
for part in ("material", "labor", "expense")
|
||
}
|
||
stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {})
|
||
waste, separate, waste_note = _waste(bill, quantity_settings(root))
|
||
data = cost_input(direct, stored, waste, separate)
|
||
try:
|
||
dataset = load_rate_dataset(data.rate_file_name)
|
||
result = calculate_cost(data)
|
||
except RateLookupError as error:
|
||
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
||
return JSONResponse(
|
||
content={
|
||
"status": "success",
|
||
"form": "일반",
|
||
"rows": sheet_rows(result, data),
|
||
"status_line": status_line(result, data),
|
||
"settings": {
|
||
**{key: getattr(data, key) for key, _ in WORK_TYPE_FIELDS},
|
||
**{key: str(getattr(data, key)) for key, _ in AMOUNT_FIELDS},
|
||
"duration_days": data.duration_days,
|
||
},
|
||
"stored": stored,
|
||
"fields": {
|
||
"work_types": [{"key": k, "label": label} for k, label in WORK_TYPE_FIELDS],
|
||
"amounts": [{"key": k, "label": label} for k, label in AMOUNT_FIELDS],
|
||
"options": field_options(dataset),
|
||
},
|
||
"waste_note": waste_note,
|
||
"bill_missing_count": len(summary.get("missing") or []),
|
||
"bill_unconfirmed_count": int(summary.get("unconfirmed_count") or 0),
|
||
"rate_version": result.rate_version,
|
||
"notes": result.notes,
|
||
}
|
||
)
|
||
|
||
|
||
@router.put("/{project_id}/estimation/cost-sheet")
|
||
async def put_cost_sheet(project_id: UUID, body: dict[str, Any]) -> JSONResponse:
|
||
"""기준 입력 저장 — `estimation.cost_sheet` 한 칸만 통째로 갈아 끼움."""
|
||
from common_util.common_util_project_settings import save_section
|
||
|
||
root = await _root(project_id)
|
||
if root is None:
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||
)
|
||
cleaned = clean_settings(body, load_rate_dataset())
|
||
try:
|
||
save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,))
|
||
except Exception:
|
||
logger.exception("B09 원가계산서 기준 저장 실패: project_id=%s", project_id)
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"status": "error", "message": "원가계산서 기준을 저장하지 못했습니다."},
|
||
)
|
||
return JSONResponse(content={"status": "success", "stored": cleaned})
|
||
|
||
|
||
@router.get("/{project_id}/estimation/rate-table")
|
||
async def get_rate_table(project_id: UUID) -> JSONResponse:
|
||
"""제비율 요율표 — 지금 판의 요율 데이터를 DFM 첫 탭 묶음 차례로(보기 전용)."""
|
||
dataset = load_rate_dataset()
|
||
return JSONResponse(
|
||
content={
|
||
"status": "success",
|
||
"rate_version": dataset.version_stamp,
|
||
"sections": rate_sections(dataset),
|
||
}
|
||
)
|