- 10 일반관리비: (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식 칸(일반 = 순공사원가 + 관리품목자재대) - 11 절사: 총공사비/공급가액 N원 미만 — 고른 때만 이윤 자동보정(총공사비·공급가액 비례 부가세면 ÷1.1 반올림, 실무 봉화 373→339·영월 695→632, 산림조합은 차액 그대로) · 잔차 맞춤 - 12 부가세: 공급가액·재료비·없음·산림조합 형식·산림조합-면세품 (칸 이름은 기성 선택지를 받을 수 있게 문자열) - 기본값은 종전 계산과 같음 · 자동보정 줄은 「… 절사 자동보정」으로 적음 - 원가계산서 탭: 10·11·12 고르개 · 관리품목 자재대 · 면세품 금액 칸 - 시험: 기본값 불변 · 밑수·표 갈림 · 실무 절사 식 · 끝자리 · 골든셋 초록 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
161 lines
6.7 KiB
Python
161 lines
6.7 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,
|
||
CHOICE_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: str(getattr(data, key)) for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_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],
|
||
"choices": [{"key": k, "label": label} for k, label in CHOICE_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),
|
||
}
|
||
)
|