**`B09_Estimation_MaterialCatalog.py` 신설** — 두 창이 맞춘 이름을 그대로 씀: `supply_type` = `owner_supplied` / `contractor_supplied` · `owner_supplied_install_by` = `contractor` / `owner` / `None`. - 관급 = 나라장터 **6,999건**(`vat_basis: 부가가치세별도` → 부가세 제외 단가). - **설치 주체는 지어내지 않음** — 원천에 없으므로 전부 `None`(미지정)으로 두고 **세어서 알림**. 안전관리비 대상액이 관급 전액이 아니라 **도급자설치분**을 쓰므로(8-10) 잘못 찍으면 금액이 조용히 틀리는 자리임. - **규격 필수** — 같은 품명에 규격이 수백 개(「연돌」 410 · 「육각볼트」 323). 이름만 맞추면 엉뚱한 규격 단가가 붙음. - 매칭 카탈로그에 자재를 붙이면서 **이름 색인**을 넣음(7,700건이라 매번 훑으면 느림). **⚠ 붙여 보고 알아낸 것 — 관급 목록에 임도 자재가 거의 없음** - 나라장터 목록이 **건축·설비 자재 중심**(덕트·밸브·복공판·스피커…)이고, **시멘트·모래·자갈·레미콘·철근·파형강관이 한 건도 없음**. - 까닭 둘 — ① 철근·레미콘·아스콘은 그 파일이 `excluded_named_groups` 로 **명시적으로 빼 둠**(쇼핑몰 스냅샷 미보관) ② **시멘트·모래·자갈은 애초에 사급**임. 실무 울진에서 관급은 **파형강관 Ø800/Ø1000 뿐**이었음. - ⇒ **자재 매칭 0건.** 억지로 이름을 느슨하게 맞추지 않음 — 그렇게 하면 엉뚱한 건축 자재 단가가 임도 공종에 붙음. - **이 사실을 시험으로 못 박음**(`test_forest_road_materials_are_absent_from_owner_catalog`) — 나중에 「자재가 왜 안 붙나」를 다시 파는 일이 없도록. **화면 요약 문구를 실측대로 갈아 끼움** — 「자재 카탈로그가 없어서」가 아니라 「관급 6,999건을 붙였으나 임도 자재가 거의 없고, **사급 단가가 미결**이라 구조물 계열이 안 섬」으로. 설치 주체 미지정 건수도 함께 냄. 자체검증 — 신규 6건 포함 `pytest tmp/tests/ -q` **129 passed** · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
416 lines
17 KiB
Python
416 lines
17 KiB
Python
"""B09 원가계산 — ③ 단가산출·일위대가 조립 (PLAN 9-3 · 9-5).
|
|
|
|
자원 축(`resource_axis`)이 「이 공종 1단위에 무엇이 얼마나」를 갖고 있고, 카탈로그가
|
|
「그 자원 하나가 얼마」를 갖고 있다. 이 모듈이 둘을 곱해 **일위대가 한 줄**을 만든다.
|
|
|
|
층은 그대로 쌓는다 (PLAN 9-3):
|
|
|
|
S 취득가 · L 노임 · M 자재 → X 시간당 중기사용료 → B 일위대가
|
|
|
|
`PriceBook` 에 제목·상세로 앉히므로 **표를 따로 만들지 않는다.**
|
|
|
|
**부르는 가드** (함수만 있고 안 부르면 없는 것과 같다)
|
|
- ㉠ 자재는 **할증 전** 값 — 할증은 자재총괄 한 곳뿐 (`check_surcharge_once`).
|
|
- ㉣ 작업효율은 사용료 쪽에 안 넣음 (`reject_efficiency_in_hourly_rate`,
|
|
`B09_Estimation_MachineCost` 안에서 호출됨).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from functools import lru_cache
|
|
|
|
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
|
|
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
|
from B09_Estimation.B09_Estimation_MachineOperating import (
|
|
load_fuel_price,
|
|
load_operating_records,
|
|
load_operator_wages,
|
|
)
|
|
from B09_Estimation.B09_Estimation_PriceBook import (
|
|
PriceBook,
|
|
PriceDetail,
|
|
PriceKind,
|
|
PriceTitle,
|
|
)
|
|
from B09_Estimation.B09_Estimation_ResourceAxis import (
|
|
AxisResult,
|
|
build_resource_axis,
|
|
load_combined_catalog,
|
|
load_labor_catalog,
|
|
load_work_item_master,
|
|
)
|
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
|
|
|
_ZERO = Decimal(0)
|
|
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
|
|
FUEL_CODE_PREFIX = "M-FUEL-"
|
|
|
|
|
|
def _slots(value: Decimal) -> list[Decimal | None]:
|
|
"""6번(적용 단가) 슬롯에만 값을 넣는다 — 유료 물가지 미구독 상태의 기본 모양."""
|
|
slots: list[Decimal | None] = [None] * 6
|
|
slots[5] = value
|
|
return slots
|
|
|
|
|
|
@dataclass
|
|
class UnitPriceBuild:
|
|
book: PriceBook = field(default_factory=PriceBook)
|
|
#: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다.
|
|
skipped: list[str] = field(default_factory=list)
|
|
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
|
|
incomplete_machines: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
|
|
catalog = load_labor_catalog()
|
|
for entry in catalog.entries:
|
|
wage = wages.get(entry.code)
|
|
if wage is None or entry.code in book.titles:
|
|
continue
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=entry.code,
|
|
kind=PriceKind.LABOR,
|
|
name=entry.name,
|
|
unit="인",
|
|
slots=_slots(wage),
|
|
)
|
|
)
|
|
|
|
|
|
def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
|
|
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
|
|
|
|
시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이
|
|
「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음).
|
|
"""
|
|
catalog = load_machine_catalog()
|
|
operating = {r.machine_code: r for r in load_operating_records().records}
|
|
fuel_price, _ = load_fuel_price()
|
|
wages = load_operator_wages()
|
|
incomplete: list[str] = []
|
|
|
|
fuel_code = f"{FUEL_CODE_PREFIX}경유"
|
|
if fuel_code not in book.titles:
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=fuel_code,
|
|
kind=PriceKind.MATERIAL,
|
|
name="경유",
|
|
unit="L",
|
|
slots=_slots(fuel_price),
|
|
)
|
|
)
|
|
|
|
for code in sorted(machine_codes):
|
|
machine = catalog.machines.get(code)
|
|
record = operating.get(code)
|
|
if machine is None or machine.loss_coefficient_per_hour is None or record is None:
|
|
incomplete.append(code)
|
|
continue
|
|
|
|
base_code = f"S-{code}"
|
|
hourly_code = f"X-{code}"
|
|
if hourly_code in book.titles:
|
|
continue
|
|
|
|
# S — 취득가에서 나온 시간당 손료. 경비 성분만 갖는다.
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=base_code,
|
|
kind=PriceKind.MACHINE_BASE,
|
|
name=machine.name,
|
|
spec=machine.specification,
|
|
unit="hr",
|
|
slots=_slots(
|
|
machine.price_thousand_krw * Decimal(1000) * machine.loss_coefficient_per_hour
|
|
),
|
|
)
|
|
)
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=hourly_code,
|
|
kind=PriceKind.MACHINE_HOURLY,
|
|
name=machine.name,
|
|
spec=machine.specification,
|
|
unit="hr",
|
|
)
|
|
)
|
|
book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료"))
|
|
|
|
liters = record.fuel_liters_per_hour
|
|
if liters is not None:
|
|
if record.misc_material_percent is not None:
|
|
# 잡재료는 **주연료의 %** — 유가와 같이 움직인다.
|
|
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
|
|
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료"))
|
|
else:
|
|
incomplete.append(f"{code} (연료소모량 없음)")
|
|
|
|
wage_code = record.operator_occupation_code
|
|
if wage_code and wage_code in wages and record.operator_person_days is not None:
|
|
# ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다.
|
|
per_hour_person = record.operator_person_days / Decimal(8)
|
|
if wage_code not in book.titles:
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=wage_code,
|
|
kind=PriceKind.LABOR,
|
|
name="조종원",
|
|
unit="인",
|
|
slots=_slots(wages[wage_code]),
|
|
)
|
|
)
|
|
book.add_detail(
|
|
PriceDetail(hourly_code, wage_code, per_hour_person, note="조종원 (1일 8시간)")
|
|
)
|
|
else:
|
|
incomplete.append(f"{code} (조종원 없음)")
|
|
|
|
return incomplete
|
|
|
|
|
|
def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
|
"""자원 축을 일위대가(`B`)로 조립한다.
|
|
|
|
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
|
|
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
|
|
"""
|
|
master = load_work_item_master()
|
|
if axis is None:
|
|
axis = build_resource_axis(master, load_combined_catalog())
|
|
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
|
|
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
|
|
|
|
build = UnitPriceBuild()
|
|
wages = load_operator_wages()
|
|
_add_labor_titles(build.book, wages)
|
|
|
|
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
|
|
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
|
|
|
|
by_item: dict[str, list] = {}
|
|
for row in axis.rows:
|
|
by_item.setdefault(row.work_item_code, []).append(row)
|
|
|
|
for work_item_code, rows in sorted(by_item.items()):
|
|
title_code = f"B-{work_item_code}"
|
|
if title_code in build.book.titles:
|
|
continue
|
|
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
|
|
# 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다
|
|
# (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음).
|
|
attachable = [
|
|
(row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}")
|
|
for row in rows
|
|
]
|
|
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
|
|
if not attachable:
|
|
build.skipped.append(work_item_code)
|
|
continue
|
|
|
|
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
|
|
build.book.add_title(
|
|
PriceTitle(
|
|
code=title_code,
|
|
kind=PriceKind.UNIT_PRICE,
|
|
name=names.get(work_item_code) or work_item_code,
|
|
spec=work_item_code,
|
|
unit=unit,
|
|
)
|
|
)
|
|
for row, ref in attachable:
|
|
build.book.add_detail(PriceDetail(title_code, ref, row.amount))
|
|
return build
|
|
|
|
|
|
def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal:
|
|
"""일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값."""
|
|
return build.book.resolve(code).material
|
|
|
|
|
|
def verify_surcharge_once(
|
|
build: UnitPriceBuild,
|
|
code: str,
|
|
*,
|
|
material_summary_total: Decimal,
|
|
surcharge_rate_percent: Decimal,
|
|
) -> None:
|
|
"""㉠ 자재총괄 합과 대조한다 — 할증이 두 번 붙었으면 여기서 멈춘다."""
|
|
check_surcharge_once(
|
|
material_summary_total=material_summary_total,
|
|
unit_price_material_total=material_total_before_surcharge(build, code),
|
|
surcharge_rate_percent=surcharge_rate_percent,
|
|
label=code,
|
|
)
|
|
|
|
|
|
#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축).
|
|
SOURCE_INDEX: dict[PriceKind, int] = {
|
|
PriceKind.MATERIAL: 5,
|
|
PriceKind.LABOR: 6,
|
|
PriceKind.MACHINE_BASE: 105,
|
|
PriceKind.MACHINE_HOURLY: 105,
|
|
PriceKind.UNIT_PRICE: 103,
|
|
PriceKind.PRICE_BASIS: 104,
|
|
PriceKind.LUMPSUM: 0,
|
|
}
|
|
SOURCE_LABEL: dict[PriceKind, str] = {
|
|
PriceKind.MATERIAL: "자재",
|
|
PriceKind.LABOR: "노임",
|
|
PriceKind.MACHINE_BASE: "기계경비",
|
|
PriceKind.MACHINE_HOURLY: "기계경비",
|
|
PriceKind.UNIT_PRICE: "일위대가",
|
|
PriceKind.PRICE_BASIS: "단가산출",
|
|
PriceKind.LUMPSUM: "일식·견적",
|
|
}
|
|
|
|
#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다.
|
|
DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS})
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def cached_build() -> UnitPriceBuild:
|
|
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다."""
|
|
return build_unit_prices()
|
|
|
|
|
|
def _status_notes() -> list[str]:
|
|
"""화면에 낼 「지금 무엇이 안 선 상태인가」.
|
|
|
|
자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
|
|
거의 없다는 것이 이 자리의 진짜 공백이다.
|
|
"""
|
|
from B09_Estimation.B09_Estimation_MaterialCatalog import (
|
|
catalog_summary,
|
|
load_material_catalog,
|
|
)
|
|
|
|
summary = catalog_summary(load_material_catalog())
|
|
return [
|
|
f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
|
|
"나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
|
|
"철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
|
|
"**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — "
|
|
"값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.",
|
|
(
|
|
f"관급 자재 **설치 주체가 미지정**"
|
|
f"({summary['owner_supplied_install_unspecified']:,}건)이라 "
|
|
"안전관리비 대상액에 자동으로 넣지 않습니다."
|
|
),
|
|
]
|
|
|
|
|
|
def build_summary(build: UnitPriceBuild) -> dict:
|
|
"""산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
|
|
kinds: dict[str, int] = {}
|
|
for title in build.book.titles.values():
|
|
kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
|
|
return {
|
|
"titles": len(build.book.titles),
|
|
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
|
|
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
|
|
"skipped_work_items": len(build.skipped),
|
|
"incomplete_machines": len(build.incomplete_machines),
|
|
"kinds": kinds,
|
|
# ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
|
|
"notes": _status_notes(),
|
|
}
|
|
|
|
|
|
def _money_text(value: Decimal) -> str:
|
|
"""화면에 낼 금액 — **일위대가 금액란은 0.1원 미만 버림**(품셈 1-2-2).
|
|
|
|
계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다
|
|
(`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다).
|
|
"""
|
|
return str(round_at(value, OutputPlace.UNIT_PRICE_ROW))
|
|
|
|
|
|
def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
|
|
"""목록표 — 「무엇이 있나」 한 줄씩."""
|
|
rows: list[dict] = []
|
|
for code, title in sorted(build.book.titles.items()):
|
|
if title.kind is not PriceKind.UNIT_PRICE:
|
|
continue
|
|
money = build.book.resolve(code)
|
|
rows.append(
|
|
{
|
|
"code": code,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"material": _money_text(money.material),
|
|
"labor": _money_text(money.labor),
|
|
"expense": _money_text(money.expense),
|
|
"total": _money_text(money.total),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
|
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
|
|
title = build.book.title(code)
|
|
money = build.book.resolve(code)
|
|
rows: list[dict] = []
|
|
for detail in build.book.details.get(code, []):
|
|
child = build.book.title(detail.ref_code)
|
|
unit_money = build.book.resolve(detail.ref_code)
|
|
line = unit_money.scaled(detail.quantity)
|
|
rows.append(
|
|
{
|
|
"ref_code": detail.ref_code,
|
|
"name": child.name,
|
|
"spec": child.spec,
|
|
"unit": child.unit,
|
|
"source_index": SOURCE_INDEX.get(child.kind, 0),
|
|
"source_label": SOURCE_LABEL.get(child.kind, ""),
|
|
"drillable": child.kind in DRILLABLE_KINDS,
|
|
"quantity": str(detail.quantity),
|
|
"unit_material": _money_text(unit_money.material),
|
|
"unit_labor": _money_text(unit_money.labor),
|
|
"unit_expense": _money_text(unit_money.expense),
|
|
"unit_total": _money_text(unit_money.total),
|
|
"material": _money_text(line.material),
|
|
"labor": _money_text(line.labor),
|
|
"expense": _money_text(line.expense),
|
|
# 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다.
|
|
# 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다.
|
|
"total": str(
|
|
round_at(line.material, OutputPlace.UNIT_PRICE_ROW)
|
|
+ round_at(line.labor, OutputPlace.UNIT_PRICE_ROW)
|
|
+ round_at(line.expense, OutputPlace.UNIT_PRICE_ROW)
|
|
),
|
|
"note": detail.note,
|
|
}
|
|
)
|
|
# 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」
|
|
# (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다.
|
|
summed = {
|
|
key: sum((Decimal(r[key]) for r in rows), Decimal(0))
|
|
for key in ("material", "labor", "expense", "total")
|
|
}
|
|
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
|
|
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
|
|
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
|
|
return {
|
|
"code": code,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"kind": title.kind.value,
|
|
"material": str(summed["material"]),
|
|
"labor": str(summed["labor"]),
|
|
"expense": str(summed["expense"]),
|
|
"total": str(summed["total"]),
|
|
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
|
|
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
|
|
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
|
|
"precise_total": _money_text(money.total),
|
|
"rows": rows,
|
|
}
|