- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음 (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임 - 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144) - 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함 - 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에 - 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot) - 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감 - 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임) - 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
354 lines
15 KiB
Python
354 lines
15 KiB
Python
"""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 (
|
|
HOURLY_WAGE_SUFFIX,
|
|
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 or code.endswith(HOURLY_WAGE_SUFFIX):
|
|
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 machine_summary_amounts(unit_money: Any, quantity: Decimal) -> dict[str, Decimal]:
|
|
"""중기시간금액집계표 금액 — **성분마다 반올림한 뒤 합**(단가 열 없이 합계·노무·재료·경비).
|
|
|
|
근거 실무 6건 골든셋 — 성분마다 131/144 · 합계를 한 번에 반올림하면 107/144.
|
|
"""
|
|
return {
|
|
key: round_at(getattr(unit_money, key) * quantity, OutputPlace.RESOURCE_SUMMARY)
|
|
for key in ("labor", "material", "expense")
|
|
}
|
|
|
|
|
|
def resource_summary(
|
|
quantities: dict[str, Decimal],
|
|
build: UnitPriceBuild | None = None,
|
|
) -> dict[str, Any]:
|
|
"""자원 집계표 — 공종 수량을 **자원별로 되모은다**.
|
|
|
|
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
|
|
한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다.
|
|
|
|
⚠ **자원(노무·자재·기계 사용료)까지 편다** — 일위대가 → 자원이 실무 집계표의 깊이다.
|
|
기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 이중으로 세는 것**이 된다.
|
|
⚠ 가운데 층(단계 합산 부모의 잎 일위대가 `B→B` · 단가산출 `B→D`)은 **끝까지 풀어** 자원에 닿음.
|
|
종전엔 한 겹만 펴 그런 참조를 **조용히 버렸음**(암절취 부모 · 층 차례 뒤 기계 몫, 2026-09-13).
|
|
"""
|
|
prices = build or cached_build()
|
|
book = prices.book
|
|
#: 자원코드 → [수량, 제목]
|
|
picked: dict[str, list[Any]] = {}
|
|
missing: list[str] = []
|
|
#: 풀어 내려갈 가운데 층 — 자원이 아니라 자원을 품은 표.
|
|
unfold = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
|
|
|
|
def walk(code: str, amount: Decimal, seen: tuple[str, ...]) -> None:
|
|
for detail in book.details.get(code, []):
|
|
if (
|
|
detail.percent_of_labor is not None
|
|
or detail.percent_of_parent is not None
|
|
or detail.percent_of_material is not None
|
|
):
|
|
continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다
|
|
ref = detail.ref_code
|
|
if ref == code or ref in seen:
|
|
continue
|
|
title = book.titles.get(ref)
|
|
if title is not None and title.kind in unfold:
|
|
walk(ref, detail.quantity * amount, (*seen, ref))
|
|
continue
|
|
slot = picked.setdefault(ref, [_ZERO, title])
|
|
slot[0] += detail.quantity * amount
|
|
|
|
for raw_code, quantity in quantities.items():
|
|
code = raw_code if raw_code in book.titles or raw_code.startswith("B-") else f"B-{raw_code}"
|
|
if code not in book.titles:
|
|
missing.append(raw_code)
|
|
continue
|
|
if book.titles[code].kind in unfold:
|
|
walk(code, Decimal(str(quantity)), (code,))
|
|
else:
|
|
# 구조물도 표 줄이 자원(노임·자재·중기)을 바로 부른 자리 — 그대로 한 줄.
|
|
slot = picked.setdefault(code, [_ZERO, book.titles[code]])
|
|
slot[0] += Decimal(str(quantity))
|
|
|
|
groups: dict[str, list[dict[str, Any]]] = {
|
|
"labor": [],
|
|
"material": [],
|
|
"expense": [],
|
|
"machine": [],
|
|
"lumpsum": [],
|
|
}
|
|
# ⚠ 차례 = **처음 쓰인 차례**(내역 줄 → 호표 안 줄) — 호표 번호가 그 차례(2026-09-14 브레인 판정).
|
|
for ref, (amount, title) in 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",
|
|
PriceKind.LUMPSUM: "lumpsum",
|
|
}.get(title.kind)
|
|
if bucket is None:
|
|
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
|
|
continue
|
|
row: dict[str, Any] = {}
|
|
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
|
|
)
|
|
if bucket == "machine":
|
|
parts = machine_summary_amounts(unit_money, amount)
|
|
money = sum(parts.values(), _ZERO)
|
|
row = {f"{key}_krw": str(value) for key, value in parts.items()}
|
|
row.update({f"unit_{key}_krw": str(getattr(unit_money, key)) for key in parts})
|
|
note = ""
|
|
except Exception as error:
|
|
unit_price, money, note = None, None, str(error)
|
|
groups[bucket].append(
|
|
{
|
|
"number": len(groups[bucket]) + 1,
|
|
"code": ref,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"quantity": str(amount),
|
|
"unit": title.unit,
|
|
"unit_price_krw": _money(unit_price),
|
|
"amount_krw": _money(money),
|
|
**row,
|
|
"note": note,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"groups": groups,
|
|
"missing": sorted(set(missing)),
|
|
"note": SUMMARY_MISMATCH_NOTE,
|
|
}
|
|
|
|
|
|
def add_material_rows(summary: dict[str, Any], sheet: Any) -> None:
|
|
"""재료비 집계표에 **자재대 줄**(사급·관급)을 이어 붙임 — 실무 집계표는 내역 자재 줄까지 한 표.
|
|
|
|
값은 자재대 표의 단가·수량 그대로, 금액만 집계표 자리(반올림)로.
|
|
⚠ 단가 못 세운 줄·공급 미정 줄은 안 실음(자재대 표가 이미 이름째 드러냄).
|
|
"""
|
|
if sheet is None:
|
|
return
|
|
rows = summary["groups"]["material"]
|
|
for supply, items in (("사급", sheet.contractor_rows), ("관급", sheet.owner_rows)):
|
|
for item in items:
|
|
if item.unit_price_krw is None:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"number": len(rows) + 1,
|
|
"code": "",
|
|
"name": item.name,
|
|
"spec": item.spec,
|
|
"quantity": str(item.total_amount),
|
|
"unit": item.unit,
|
|
"unit_price_krw": str(item.unit_price_krw),
|
|
"amount_krw": str(
|
|
round_at(
|
|
item.unit_price_krw * item.total_amount, OutputPlace.RESOURCE_SUMMARY
|
|
)
|
|
),
|
|
"note": f"자재대({supply})",
|
|
}
|
|
)
|
|
|
|
|
|
def bill_lists(summary: dict[str, Any]) -> dict[str, Any]:
|
|
"""내역이 쓴 자원의 **목록표**(색인) — 집계표와 같은 코드·같은 차례, 수량·금액만 뺌.
|
|
|
|
⚠ 값을 새로 세지 않음 — 집계표 줄의 단가 칸을 그대로 옮김. 경비목록표만 **기계 취득가(천원)**를
|
|
더 실음(실무 서식: 중기 호표가 부르는 `S` 층) — 카탈로그 값 그대로.
|
|
"""
|
|
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
|
|
|
groups = summary["groups"]
|
|
keep = ("number", "code", "name", "spec", "unit", "unit_price_krw", "note")
|
|
|
|
def index(rows: list[dict[str, Any]], extra: tuple[str, ...] = ()) -> list[dict[str, Any]]:
|
|
return [{key: row.get(key) for key in (*keep, *extra)} for row in rows]
|
|
|
|
catalog = load_machine_catalog()
|
|
machines = dict.fromkeys(row["code"][2:].split("#")[0] for row in groups["machine"])
|
|
expense = [
|
|
{
|
|
"code": f"S-{code}",
|
|
"name": catalog.machines[code].name,
|
|
"spec": catalog.machines[code].specification,
|
|
"unit": "천원",
|
|
"unit_price_krw": _money(catalog.machines[code].price_thousand_krw),
|
|
"note": "",
|
|
}
|
|
for code in machines
|
|
if code in catalog.machines
|
|
] + index(groups["expense"])
|
|
for number, row in enumerate(expense, start=1):
|
|
row["number"] = number
|
|
unit_keys = ("unit_labor_krw", "unit_material_krw", "unit_expense_krw")
|
|
return {
|
|
"labor": index(groups["labor"]),
|
|
"material": index(groups["material"]),
|
|
"expense": expense,
|
|
"machine": index(groups["machine"], unit_keys),
|
|
"lumpsum": index(groups["lumpsum"]),
|
|
}
|
|
|
|
|
|
def bill_sheets(result: Any, build: UnitPriceBuild) -> dict[str, Any]:
|
|
"""내역 한 벌의 집계표 넷(`resources`)과 목록표(`lists`) — 내역 응답에 함께 실음."""
|
|
resources = resource_summary(result.resource_quantities(), build)
|
|
add_material_rows(resources, result.material_sheet)
|
|
return {"resources": resources, "lists": bill_lists(resources)}
|
|
|
|
|
|
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),
|
|
}
|