Files
Aislo/B09_Estimation/B09_Estimation_UnitPrice.py
T
eomsangdonandClaude Opus 5 2ad66dce5d feat(B09): 열이 자원인 표(전치형) 읽기 + 오독 3종 차단
품셈에는 자원이 행이 아니라 **열 머리**에 오는 표가 39건 있음
(「구 분 | 콘크리트공(인) | 보통인부(인)」, 행은 무근·철근·소형구조물).
행-자원으로 읽어 콘크리트 타설(12-1)이 하나도 안 서고 있었음

- 갈래(variant)마다 따로 세움 — 품이 달라 뭉치면 어느 것도 안 맞음.
  코드는 `B-FP-12-01-01#무근구조물`, 이름에 갈래를 적어 사람이 고를 수 있음
- 레디믹스트콘크리트 타설: 무근 58,635.0 / 철근 65,826.4 / 소형 117,270.0 원

읽다가 잡은 오독 3종 — 전부 **고쳐 읽지 않고 버림** (자리를 단정할 수 없음)
- 병합 셀로 한 칸 밀린 행: 목재틀흙막이가 등급 글자를 건축목공 자리에 두어
  단가 503만원으로 섰음
- 좌우 두 판이 붙은 표: 소운반에서 거리값 60 이 「보통인부 60인」으로 읽혀
  1,553만원이 섰음. 열 머리가 되풀이되면 표째 버림
- 같은 갈래 이름 중복: 목재틀흙막이 「상」이 8.760 + 13.767 로 합산돼
  667만원이 섰음. 그 줄들을 버림
- 갈래 이름에 자원용 머리글 필터를 쓰던 것도 고침 — 「중」·「상」 등급이
  통째로 지워지고 있었음. 합계 줄만 걸러 냄

기준 단위 미상 드러내기 (막지 않음 — 막으면 125 중 122 가 멈춤)
- `unknown_basis` 122건, 그중 100만원 넘는 10건을 목록으로 냄
  (떼채취 평떼 1,032,408원 등 — 「100매당」류 묶음 기준 의심)
- 내역서 줄 비고에도 「기준 단위가 표에 없습니다 — 수량 단위와 같다고 보고
  곱했습니다」를 적음. 반영률 문구를 덮지 않고 이어 붙임

700줄 제한으로 전치형 처리를 `_Transposed.py` 로 분리 (647 + 138줄)

검증: pytest 163 통과(신규 5). 자원 축 198줄·갈래 54,
일위대가 85 → 125, 분포 최소 233.4 · 중앙 72,268.5 · 최대 5,030,954.6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:53:58 +09:00

669 lines
28 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
import re
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_MachineProductivity import (
CycleFactors,
FactorGap,
extract_cycle_factors,
machine_hours_per_unit,
)
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
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0)
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
FUEL_CODE_PREFIX = "M-FUEL-"
#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다.
SUSPICIOUSLY_LOW_KRW = Decimal(100)
#: 기준 단위를 모르는 채 이 금액을 넘으면 **사람이 한 번 봐야 한다**.
#: 품셈 표가 「10㎡당」처럼 묶음 기준일 수 있어 값 자체는 맞고 기준만 모르는 경우가 많다
#: (2026-09-08: 목재틀흙막이 상등구조 = 건축목공 16.975인 → 503만원. 값은 품셈대로다).
#: **막지 않고 드러내기만 한다** — 막으면 120 중 117 이 멈춘다.
SUSPICIOUSLY_HIGH_KRW = Decimal(1_000_000)
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)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
cycle_factors: dict[str, CycleFactors] = field(default_factory=dict)
#: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」.
variants: dict[str, list[str]] = field(default_factory=dict)
#: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다.
factor_gaps: dict[str, FactorGap] = field(default_factory=dict)
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)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
by_item: dict[tuple[str, str], list] = {}
for row in axis.rows:
by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row)
for (work_item_code, variant), rows in sorted(by_item.items()):
title_code = f"B-{work_item_code}" + (f"#{variant}" if variant else "")
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), "")
base_name = names.get(work_item_code) or work_item_code
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"{base_name} ({variant})" if variant else base_name,
spec=variant or work_item_code,
unit=unit,
)
)
if variant:
build.variants.setdefault(work_item_code, []).append(variant)
# 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다.
for row, ref in attachable:
share = _share_of(row)
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
machine_share = (
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
# 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다.
covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build)
if covered is not None:
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
return build
def _share_of(row) -> Decimal:
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
ratio = getattr(row, "group_ratio_pct", None)
return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100)
def _attach_machine_share(
build: UnitPriceBuild,
master: dict,
work_item_code: str,
title_code: str,
) -> Decimal:
"""시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
"""
node = next(
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
None,
)
if node is None:
return _ZERO
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
build.factor_gaps[work_item_code] = factors
continue
hourly_code = f"X-{factors.machine_code}"
if hourly_code not in build.book.titles:
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
build.factor_gaps[work_item_code] = FactorGap(
work_item_code=work_item_code,
pum_table_id=factors.pum_table_id,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
share = (
Decimal(1)
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
)
)
build.cycle_factors[work_item_code] = factors
return share * Decimal(100)
return _ZERO
def _covered_ratio_pct(
rows: list, attached_refs: set[str], build: UnitPriceBuild
) -> Decimal | None:
"""배분율 표에서 **실제로 붙은 몫**의 합계(%). 배분율이 없는 표면 `None`."""
ratios = {
row.group_ratio_pct for row in rows if getattr(row, "group_ratio_pct", None) is not None
}
if not ratios:
return None
covered = Decimal(0)
seen: set[Decimal] = set()
for row in rows:
ratio = getattr(row, "group_ratio_pct", None)
if ratio is None or ratio in seen:
continue
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}"
if ref in attached_refs:
seen.add(ratio)
covered += ratio
return covered
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 _plain(text: str) -> str:
"""화면용 평문 — 마크다운 강조 표시를 벗긴다."""
return _RE_EMPHASIS.sub(lambda match: match.group(1), text)
def _status_notes() -> list[str]:
"""화면에 낼 「지금 무엇이 안 선 상태인가」.
자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
거의 없다는 것이 이 자리의 진짜 공백이다.
"""
from B09_Estimation.B09_Estimation_MaterialCatalog import (
catalog_summary,
load_material_catalog,
)
summary = catalog_summary(load_material_catalog())
# 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다.
return [
_plain(note)
for note in [
f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
"나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
"철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
"**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — "
"유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 "
"일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)",
(
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
# ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
# 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
# (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
totals = sorted(
build.book.resolve(code).total
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
)
stats: dict[str, str] = {}
low: list[dict[str, str]] = []
if totals:
stats = {
"min": _money_text(totals[0]),
"median": _money_text(totals[len(totals) // 2]),
"max": _money_text(totals[-1]),
}
low = [
{"code": code, "name": title.name, "total": _money_text(money)}
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
]
# 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
high = [
{"code": code, "name": title.name, "total": _money_text(money)}
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
and not title.unit
and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
]
return {
"titles": len(build.book.titles),
"unit_price_totals": stats,
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
"suspiciously_low": low,
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
"unknown_basis_high": high,
"unknown_basis": sum(
1
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE and not title.unit
),
"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,
}
@dataclass
class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**.
⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고
(산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …),
뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미
들고 있으니 **성분별로 접어 넣는다.**
"""
material: Decimal = _ZERO
labor: Decimal = _ZERO
expense: Decimal = _ZERO
#: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다).
missing: list[str] = field(default_factory=list)
@property
def total(self) -> Decimal:
return self.material + self.labor + self.expense
def direct_cost_from_quantities(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
) -> DirectCostBreakdown:
"""공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다.
`quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나
`B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다.
단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가
없으면 그 공종이 총액에서 조용히 빠진다.
"""
book = (build or cached_build()).book
result = DirectCostBreakdown()
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:
result.missing.append(raw_code)
continue
unit_money = book.resolve(code)
line = unit_money.scaled(Decimal(str(quantity)))
result.material += line.material
result.labor += line.labor
result.expense += line.expense
return result
def cost_input_from_quantities(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
**cost_input_kwargs,
):
"""직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다.
성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 —
**뭉치지 않는다.**
"""
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput
breakdown = direct_cost_from_quantities(quantities, build)
# ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다
# (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다.
summary = OutputPlace.RESOURCE_SUMMARY
return (
CostInput(
direct_material_krw=round_at(breakdown.material, summary),
direct_labor_krw=round_at(breakdown.labor, summary),
direct_expense_krw=round_at(breakdown.expense, summary),
**cost_input_kwargs,
),
breakdown,
)