Files
Aislo/B09_Estimation/B09_Estimation_Router_CostSheet.py
T
eomsangdonandClaude Opus 5 fb4eb59cdb feat(b09): 원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식
- 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
2026-09-14 02:43:14 +09:00

161 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
}
)