"""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: """노임 + 기종 + **관급 자재** + **카탈로그 밖 자원(`AR-`)** 을 한 벌로. 사급 자재 원천(물가지)은 아직 없다 — `AR-` 목록은 **이름·규격·단위만** 싣고 단가는 없다 (2026-09-13 축 C 1장). 범위 별칭도 여기서 함께 싣는다. """ from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( load_ext_entries, load_scoped_aliases, ) labor = load_labor_catalog() return ResourceCatalog( entries=[ *labor.entries, *load_machine_catalog_entries(), *load_material_catalog_entries(), *load_ext_entries(), ], aliases=labor.aliases, scoped_aliases=load_scoped_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}")