Files
Aislo/B09_Estimation/B09_Estimation_UnitPrice.py
T
eomsangdonandClaude Opus 5 dd23c9d04f feat(B09): 기계만 쓰는 공종이 공식만으로 서게 함 — 층따기 살아남
「미판정 77건」을 훑다 나온 것 — 그중 일부는 **미판정이 아니라 시공능력 공식표**였음
```
F0287 9-18 층따기        굴착기 0.7㎥ · K 0.9 · f 1/1.3 · E 0.7 · Cm 22
F0328 11-4 쇄석·혼합석 부설  같은 모양
```
- ⚠ **자원 줄이 하나도 없으면 조립 반복문이 그 공종을 아예 안 보고 있었음** —
  표에 인력 줄이 없는 기계 전용 공종이 그 자리. 공식이 온전하면 세우게 고침
- 층따기 **1,550.9원/㎡** · 쇄석 부설 **3,549.5원/㎡** 로 섬 (일위대가 203 → 206)
- 내역서(2f940d8a) 합계 **2,149,464 → 6,252,172원** —
  층따기 2,645.21㎡ × 1,550.9 = 4,102,708 이 실제로 붙음
- ⚠ 짝 시험 — 자원도 공식도 없으면 **여전히 안 섬**(0 원 일위대가 금지).
  그 문이 넓으면 빈 제목이 무더기로 서고 화면이 「단가가 있다」고 거짓말함

검증: pytest 215 통과(신규 2)

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

674 lines
28 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 원가계산 — ③ 단가산출·일위대가 조립 (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 (
RANGE_DASHES,
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)
#: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구.
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
component_gaps: dict[str, str] = field(default_factory=dict)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
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)
#: 밑수(「10㎡당」)를 못 찾은 표를 쓰는 공종 — **곱하면 안 되는 줄**이다.
#: 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다(B08 `basis_missing` 목록).
basis_missing: dict[str, 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
@lru_cache(maxsize=1)
def load_basis_missing(
file_name: str = "basis_missing_2026-01-01.json",
) -> dict[str, str]:
"""B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`.
「10㎡당」 같은 기준을 원문에서 못 찾은 표다. 1 단위당으로 단정하면 곱셈이
10배·100배 틀리므로(떼채취가 실제로 100배였다) 그 표를 쓰는 공종은
**금액을 안 만든다**.
"""
import json
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(root, "resources", "data_work_item_master", file_name)
if not os.path.exists(path):
return {}
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
return {
str(item.get("pum_table_id")): str(item.get("section", ""))
for item in payload.get("items", [])
}
#: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과
#: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측:
#: 13-6-1·2 는 , 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다.
#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다
#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다).
#: 물결표 목록은 `B09_Estimation_ResourceAxis.RANGE_DASHES` 한 곳에서 온다 —
#: 붙임표(`-`·`–`)는 갈래 이름에 안 쓰이므로 물결표만 골라 쓴다.
_TILDE_CHARS = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐")
def normalize_variant_key(text: str) -> str:
"""갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다."""
tight = "".join(str(text).split())
return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight)
def find_variant_code(
work_item_code: str,
variant_value: str,
build: UnitPriceBuild | None = None,
) -> str | None:
"""B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다.
갈래 키는 품셈 원문에서 나오고 **그 원문을 읽는 쪽이 여기**다(2026-09-08 두 창 합의).
못 맞추면 `None` — **가까운 갈래를 임의로 고르지 않는다.**
"""
prices = build or cached_build()
wanted = normalize_variant_key(variant_value)
if not wanted:
return None
prefix = f"B-{work_item_code}#"
candidates = [code for code in prices.book.titles if code.startswith(prefix)]
for code in candidates:
if normalize_variant_key(code[len(prefix) :]) == wanted:
return code
# ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다**
# (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80].
numbers = _numbers_of(wanted)
if not numbers:
return None
hits = [
code
for code in candidates
if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers
]
if len(hits) == 1:
return hits[0]
if len(numbers) == 1:
# 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는
# 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다.
return _bracket_for(numbers[0], candidates, prefix)
return None
def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None:
"""그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다."""
best: tuple[Decimal, str] | None = None
for code in candidates:
label = normalize_variant_key(code[len(prefix) :])
bounds = _numbers_of(label)
if len(bounds) == 1:
if "이하" in label and value <= bounds[0]:
if best is None or bounds[0] < best[0]:
best = (bounds[0], code)
elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]:
width = bounds[1] - bounds[0]
if best is None or width < best[0]:
best = (width, code)
return best[1] if best else None
def _numbers_of(text: str) -> list[Decimal]:
"""그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80]."""
return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)]
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()
build.component_gaps = dict(axis.partial_items)
missing_basis = load_basis_missing()
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)
# ⚠ **자원 줄이 하나도 없어도 공식이 온전하면 세운다.** 기계만 쓰는 공종(층따기 9-18 ·
# 쇄석 부설 11-4)은 표에 인력 줄이 없어 자원 축이 비고, 그러면 아래 반복문이 그 공종을
# 아예 안 본다 — 「미판정」으로 남아 있던 것이 실은 **시공능력 공식표**였다
# (2026-09-08 미판정 77건을 훑다 발견).
for node in master.get("work_items", []):
code = node.get("work_item_code")
if not code or (code, "") in by_item:
continue
for table in node.get("tables", []):
if isinstance(extract_cycle_factors(code, table), CycleFactors):
by_item[(code, "")] = []
break
for (work_item_code, variant), rows in sorted(by_item.items()):
# 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로**
# (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해
# 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다.
variant_key = normalize_variant_key(variant)
title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key 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 and not _has_full_formula(master, work_item_code):
# 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지).
build.skipped.append(work_item_code)
continue
# ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다.
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section:
build.basis_missing[work_item_code] = section
break
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_key:
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))
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③).
# ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면
# 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다.
# ⚠ **「상한」이다** — 곱한 값 이하로 계상하는 값이라 산출근거에 그 사실을 적는다.
ratio = axis.overhead_ratio.get(work_item_code)
if ratio is not None and not variant_key.startswith("__"):
lower = ratio[1]
build.book.add_detail(
PriceDetail(
title_code,
title_code,
_ZERO,
note=f"제잡비 노무비의 {lower}% (상한, 물빼기 파이프 미설치 기준)",
percent_of_labor=lower,
)
)
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 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 으로 때우는 것과 같은 종류의 사고다.
# 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다.
if work_item_code in axis.partial_items:
build.partial_ratio.setdefault(work_item_code, _ZERO)
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 _has_full_formula(master: dict, work_item_code: str) -> bool:
"""그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용)."""
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 False
return any(
isinstance(extract_cycle_factors(work_item_code, table), CycleFactors)
for table in node.get("tables", [])
)
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()
@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,
)
# 화면용 조회(요약·목록·본표)는 700줄 제한으로 `_View` 파일로 옮겼다.
# **부르는 쪽이 어디서 오는지 신경 쓰지 않게** 여기서 다시 내보낸다.
from B09_Estimation.B09_Estimation_UnitPrice_View import ( # noqa: E402
build_summary,
detail_of,
list_unit_prices,
)
__all__ = [
"UnitPriceBuild",
"build_unit_prices",
"cached_build",
"build_summary",
"detail_of",
"list_unit_prices",
"direct_cost_from_quantities",
"cost_input_from_quantities",
"find_variant_code",
"normalize_variant_key",
]