feat(B09): 기초자료 네 표 신설 — 노무비·재료비·경비 목록표 + 중기목록표
사용자 확정 12번 「내야 할 표 16개 전체」. 안 내던 일곱 중 넷을 냄. - 서식은 지어내지 않고 실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 옮김 — 코드번호·명칭·규격·단위·단가·비고. 중기목록표만 합계 + 3분할(노무·재료·경비). - ⚠ 경비목록표는 취득가(천원)이지 시간당 손료가 아님. 실무 실측 「S00104 불도저(무한궤도) 19톤 천원 184,499」와 자릿수를 맞춤 — 손료를 실으면 세 자리 어긋난 채 「경비」로 읽힘. - 자원 집계표(역집계)도 함께 냄. 일위대가 안쪽을 한 겹만 폄 — 기계 사용료를 다시 손료·연료로 쪼개면 중기 집계표와 이중계상이 됨. 비율 줄(제잡비)은 자원으로 안 셈. - 집계표는 반올림, 내역서 본체는 절사 — 어긋나는 것이 정상임을 문구로 함께 냄. - 새 계산 없음. PriceBook 자료를 접기만 함. 실무 대조(2024 대 2026 자료): 보통인부 165,545→172,068 · 불도저19 취득가 184,499→198,150 · 시간당 126,029→124,842. 연도 차이 범위 안. 라우터 조회 하나 추가(GET .../estimation/base-data). 시험 8건, 262건 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체).
|
||||
|
||||
지금까지 안 내던 일곱 표 중 여섯이 여기서 난다.
|
||||
|
||||
A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고
|
||||
A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고
|
||||
A7 재료비목록표 〃
|
||||
A8 경비목록표 〃 (기계 취득가 `S-` 층이 여기 온다)
|
||||
A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고
|
||||
— 노무비·재료비·경비·중기 네 벌
|
||||
|
||||
**서식은 지어내지 않았다** — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를
|
||||
그대로 옮겼다(2026-09-09 실측). 칸 이름·차례가 그 시트와 같다.
|
||||
|
||||
⚠ **새 계산이 아니다.** 목록표는 `PriceBook` 의 제목을 종류별로 늘어놓는 것이고,
|
||||
집계표는 **내역서에 이미 선 금액을 자원별로 되모으는 것**이다. 값을 여기서 다시 만들면
|
||||
내역서와 어긋난다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」).
|
||||
|
||||
⚠ **집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라
|
||||
**두 표의 합이 원 단위로 어긋나는 것이 정상**이다 — 그 사실을 화면에 함께 낸다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||
from B09_Estimation.B09_Estimation_Rounding import (
|
||||
SUMMARY_MISMATCH_NOTE,
|
||||
OutputPlace,
|
||||
round_at,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
#: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다.
|
||||
LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = (
|
||||
("labor", "노무비목록표", PriceKind.LABOR),
|
||||
("material", "재료비목록표", PriceKind.MATERIAL),
|
||||
("expense", "경비목록표", PriceKind.MACHINE_BASE),
|
||||
)
|
||||
|
||||
|
||||
def _money(value: Decimal | None) -> str | None:
|
||||
return None if value is None else str(value)
|
||||
|
||||
|
||||
def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]:
|
||||
"""목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다.
|
||||
|
||||
⚠ 단가가 안 선 줄도 **빼지 않는다.** 빼면 「없는 것」과 「값을 못 구한 것」이 같아 보인다.
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for code in sorted(build.book.titles):
|
||||
title = build.book.titles[code]
|
||||
if title.kind is not kind:
|
||||
continue
|
||||
try:
|
||||
price: Decimal | None = title.adopted_price()
|
||||
note = ""
|
||||
except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다
|
||||
price, note = None, str(error)
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": title.unit,
|
||||
"unit_price_krw": _money(price),
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def machine_base_list() -> list[dict[str, Any]]:
|
||||
"""경비목록표 — **기계 취득가격(천원)** 목록.
|
||||
|
||||
⚠ 내 `S-` 층과 **다른 값**이다. `S-` 는 「취득가 × 시간당 손료계수」라 **원/시간**이고,
|
||||
실무 경비목록표는 **취득가 그 자체를 천원 단위**로 싣는다(영월 실측:
|
||||
`S00104 불도저(무한궤도) 19톤 **천원** 184,499`). 손료를 여기 실으면 자릿수가 세 자리
|
||||
어긋난 채 「경비」로 읽힌다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for code in sorted(catalog.machines):
|
||||
machine = catalog.machines[code]
|
||||
rows.append(
|
||||
{
|
||||
"code": f"S-{code}",
|
||||
"name": machine.name,
|
||||
"spec": machine.specification,
|
||||
"unit": "천원",
|
||||
"unit_price_krw": _money(machine.price_thousand_krw),
|
||||
"note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]:
|
||||
"""중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸).
|
||||
|
||||
실무 서식: `X00205 굴삭기(무한궤도) 0.7㎥ 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128`
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for code in sorted(build.book.titles):
|
||||
title = build.book.titles[code]
|
||||
if title.kind is not PriceKind.MACHINE_HOURLY:
|
||||
continue
|
||||
try:
|
||||
money = build.book.resolve(code)
|
||||
row = {
|
||||
"total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
|
||||
"labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)),
|
||||
"material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)),
|
||||
"expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)),
|
||||
"note": "",
|
||||
}
|
||||
except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다
|
||||
row = {
|
||||
"total_krw": None,
|
||||
"labor_krw": None,
|
||||
"material_krw": None,
|
||||
"expense_krw": None,
|
||||
"note": str(error),
|
||||
}
|
||||
rows.append(
|
||||
{"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def resource_summary(
|
||||
quantities: dict[str, Decimal],
|
||||
build: UnitPriceBuild | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""자원 집계표 — 공종 수량을 **자원별로 되모은다**.
|
||||
|
||||
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
|
||||
한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다.
|
||||
|
||||
⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가
|
||||
실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와
|
||||
이중으로 세는 것**이 된다.
|
||||
"""
|
||||
prices = build or cached_build()
|
||||
book = prices.book
|
||||
#: 자원코드 → [수량, 제목]
|
||||
picked: dict[str, list[Any]] = {}
|
||||
missing: list[str] = []
|
||||
|
||||
for raw_code, quantity in quantities.items():
|
||||
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
|
||||
if code not in book.titles:
|
||||
missing.append(raw_code)
|
||||
continue
|
||||
amount = Decimal(str(quantity))
|
||||
for detail in book.details.get(code, []):
|
||||
if detail.percent_of_labor is not None or detail.percent_of_parent is not None:
|
||||
continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다
|
||||
ref = detail.ref_code
|
||||
if ref == code:
|
||||
continue
|
||||
slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)])
|
||||
slot[0] += detail.quantity * amount
|
||||
|
||||
groups: dict[str, list[dict[str, Any]]] = {
|
||||
"labor": [],
|
||||
"material": [],
|
||||
"expense": [],
|
||||
"machine": [],
|
||||
}
|
||||
for ref, (amount, title) in sorted(picked.items()):
|
||||
if title is None:
|
||||
missing.append(ref)
|
||||
continue
|
||||
bucket = {
|
||||
PriceKind.LABOR: "labor",
|
||||
PriceKind.MATERIAL: "material",
|
||||
PriceKind.MACHINE_BASE: "expense",
|
||||
PriceKind.MACHINE_HOURLY: "machine",
|
||||
}.get(title.kind)
|
||||
if bucket is None:
|
||||
continue
|
||||
try:
|
||||
unit_money = book.resolve(ref)
|
||||
unit_price: Decimal | None = unit_money.total
|
||||
# ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다.
|
||||
money: Decimal | None = round_at(
|
||||
unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY
|
||||
)
|
||||
note = ""
|
||||
except Exception as error:
|
||||
unit_price, money, note = None, None, str(error)
|
||||
groups[bucket].append(
|
||||
{
|
||||
"code": ref,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"quantity": str(amount),
|
||||
"unit": title.unit,
|
||||
"unit_price_krw": _money(unit_price),
|
||||
"amount_krw": _money(money),
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"missing": sorted(set(missing)),
|
||||
"note": SUMMARY_MISMATCH_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]:
|
||||
"""목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다."""
|
||||
prices = build or cached_build()
|
||||
return {
|
||||
"labor": catalog_list(prices, PriceKind.LABOR),
|
||||
"material": catalog_list(prices, PriceKind.MATERIAL),
|
||||
"expense": machine_base_list(),
|
||||
"machine": machine_list(prices),
|
||||
}
|
||||
@@ -217,6 +217,25 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/base-data")
|
||||
async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||
"""**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번).
|
||||
|
||||
별표2 설계서 구성에 드는 표들이라 **없으면 설계서가 성립하지 않는다.** 서식은
|
||||
실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 따랐다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_Lists import all_lists
|
||||
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **all_lists(cached_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:
|
||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||
|
||||
Reference in New Issue
Block a user