"""B09 원가계산 — 자원 축 **자료 적재·셀 파싱** (`B09_Estimation_ResourceAxis` 보조).
가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. 700줄 제한(CLAUDE.md
4장)에 걸려 나눴고, 부르는 쪽은 종전대로 `B09_Estimation_ResourceAxis` 에서 가져다 쓴다.
⚠ **셀을 억지로 읽지 않는다** — 범위(「0.55∼0.45」)·참조(「육상과동일」)·반복부호(「〃」)는
확정값이 아니므로 `None` 을 돌려주고, 그 사실이 위쪽에서 드러난다.
"""
from __future__ import annotations
import json
import os
import re
from decimal import Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
CatalogEntry,
RANGE_DASHES,
ResourceAxisError,
ResourceCatalog,
_normalize,
_project_root,
_read_json,
_RE_NUMBER,
_RE_RANGE_CELL,
_RE_SPEC,
_RE_ALTERNATIVE,
)
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
_MASTER_SUBPATH = ("resources", "data_work_item_master")
def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog:
"""노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`."""
payload = _read_json(*_CATALOG_SUBPATH, file_name)
variables = payload["variables"]
records = variables["labor_rate"]["records"]
entries = [
CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor")
for r in records
]
return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {})))
def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]:
"""기종 카탈로그 613건을 매칭용 항목으로 편다.
⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는
한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
catalog = load_machine_catalog(file_name)
return [
CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification)
for m in catalog.machines.values()
]
def load_material_catalog_entries(
file_name: str = "mat_price_public_2026-08-14.json",
) -> list[CatalogEntry]:
"""관급 자재 6,999건을 매칭용 항목으로 편다.
⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323).
**규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다.
"""
from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog
catalog = load_material_catalog(file_name)
return [
CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification)
for m in catalog.items.values()
]
def load_combined_catalog() -> ResourceCatalog:
"""노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다."""
labor = load_labor_catalog()
return ResourceCatalog(
entries=[
*labor.entries,
*load_machine_catalog_entries(),
*load_material_catalog_entries(),
],
aliases=labor.aliases,
)
def load_work_item_master(
file_name: str = "work_item_master_2026-01-01.json",
) -> dict[str, Any]:
"""메인 창 산출물 — **읽기 전용**."""
return _read_json(*_MASTER_SUBPATH, file_name)
def split_name_and_spec(cell: str) -> tuple[str, str]:
"""셀 문자열에서 이름과 규격을 가른다.
「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`)
「덤프트럭 15톤」 → (`덤프트럭`, `15톤`)
"""
text = str(cell or "").strip()
match = _RE_SPEC.search(text)
if not match:
return text, ""
spec = (match.group(1) or match.group(2) or "").strip()
name = (text[: match.start()] + text[match.end() :]).strip(" ,()()")
return name, spec
#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 —
#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다.
#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다.
_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]")
_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?")
def parse_machine_cell(cell: str) -> tuple[str, str]:
"""기종 셀을 카탈로그 이름과 규격으로 가른다.
「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`)
「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다
괄호가 없으면 원문 그대로 돌려준다.
"""
text = _normalize(cell)
match = _RE_MACHINE.match(text)
if not match:
return text, ""
base = match.group("base")
parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p]
form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)]
size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)]
name = f"{base}({','.join(form_parts)})" if form_parts else base
spec = ""
if size_parts:
found = _RE_SIZE_TOKEN.search(size_parts[0])
spec = found.group(0) if found else ""
return name, spec
def spec_candidates(cells: list[str]) -> list[str]:
"""규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다.
실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` ·
`['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]`
"""
found: list[str] = []
for cell in cells:
text = _normalize(cell)
if not text or len(text) > 30:
continue
_, spec = parse_machine_cell(text)
if spec:
found.append(spec)
continue
token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³"))
if token:
found.append(token.group(0))
return found
def parse_amount(cell: str) -> Decimal | None:
"""숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다."""
text = _normalize(cell)
if not _RE_NUMBER.match(text):
return None
try:
return Decimal(text.replace(",", ""))
except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다
return None
#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25).
#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다.
_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$")
def parse_amount_expression(cell: str) -> Decimal | None:
"""「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**.
식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은
「값 × 비율%」 한 모양뿐이다.
"""
found = _RE_RATIO_EXPRESSION.match(_normalize(cell))
if found is None:
return None
return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100)
def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None:
"""「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`."""
text = _normalize(cell)
found = _RE_ALTERNATIVE.match(text)
if found:
return Decimal(found.group(1)), Decimal(found.group(2))
plain = parse_amount(text)
return None if plain is None else (plain, None)
def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal:
"""표 형태에 맞춰 소요량으로 환산한다.
⚠ **여기가 20배 틀리는 자리다.**
- `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값**
- `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity**
"""
if pum_form == "productivity":
if raw == 0:
raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다")
return Decimal(1) / raw
if pum_form == "requirement":
divisor = basis_quantity if basis_quantity else Decimal(1)
if divisor == 0:
raise ResourceAxisError("기준 수량이 0 입니다")
return raw / divisor
raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}")