"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③). 산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 …)의 1대 1일 소모(연료·잡품·오일)와 손료계수를 표로 주고, 각 공종 표는 **인원**만 적음(「벌목부」·「특별인부 (체인톱 사용)」). 대수 규칙이 인원에 붙어 장비 몫을 세우는데 그 길이 없어 공종이 인력만으로 싸게 서 있었음(㉯ 셈 7 · 4-2-2 는 부모 표에만 장비 말). 장비 한 벌(`Equipment`) = 소요재료 표 줄 · 손료 표 · 고르는 줄(체인오일 일반/친환경) · 쓰는 공종과 인원 줄 값은 표에서 읽음(연료 ℓ/대/일 · 잡품 % · 오일 ℓ · 손료계수) — 여기엔 **어느 줄을 읽을지**만 적음 `X-` 1대 1일 호표 = 연료 × ℓ + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면) 공종 제목에 장비 호표 × 인원 줄 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 쪽 하나 × 인원 × ℓ ⚠ 인원 줄은 **표가 이름으로 밝힌 줄만**(③) — 「비슷한 인원 줄」로 넓히지 않음. ⚠ 구입가·오일 단가는 카탈로그가 없어 「자재 단가」 칸 + 사유(②) · 휘발유는 유가 판으로 바로 섬. """ from __future__ import annotations import re from dataclasses import dataclass, field from decimal import Decimal from typing import Any @dataclass(frozen=True) class Equipment: key: str machine: str # AR-X — 1대 1일 호표 price: str # AR-M — 구입가(손료 밑수) 칸 fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름) loss: tuple[str, str] # (2-2 공종, 표) choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름) users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름 basis: str = "" EQUIPMENTS: tuple[Equipment, ...] = ( Equipment( key="체인톱", machine="AR-X-61d1681d", price="AR-M-5649cf3f", fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"), loss=("FP-02-02-01", "F0064"), choices=( ("AR-M-fa7fbf6d", "체인오일 (일반오일)"), ("AR-M-de2fe662", "체인오일 (친환경오일)"), ), # 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중 # 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③). users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"}, basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료", ), ) _RE_NUMBER = re.compile(r"\d+(?:\.\d+)?") def _tight(text: Any) -> str: return re.sub(r"\s", "", str(text or "")) def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]: node = nodes.get(code) or {} table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {}) return [[str(c) for c in row] for row in table.get("raw_row") or []] def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]: """그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀).""" row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None) if row is None: return [] return [ Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%"))) ] def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str: from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots code = f"{FUEL_CODE_PREFIX}{kind}" if code not in book.titles: price, meta = load_fuel_price(region=fuel_region, kind=kind) spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가" book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price))) return code def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]: """`X-` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들.""" from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots book = build.book code = f"X-{eq.machine}" fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) numbers = _row_numbers(fuel_rows, eq.fuel[2]) loss = _row_numbers(_table_rows(nodes, *eq.loss), _table_rows(nodes, *eq.loss)[0][0]) if len(numbers) < 2 or not loss: return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"] if code in book.titles: return [] kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유" liters, misc = numbers[0], numbers[1] book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일")) book.add_detail( PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}") ) book.add_detail(_misc_row(code, misc)) reasons = [] if eq.price in book.titles: base = f"S-{eq.machine}" price = book.titles[eq.price].slots[-1] book.add_title( PriceTitle( base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0]) ) ) book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}")) else: reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음") return reasons def attach_consumables( build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None ) -> None: """장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임.""" from B09_Estimation.B09_Estimation_PriceBook import PriceDetail book = build.book for eq in EQUIPMENTS: for material in (eq.price, *(c for c, _ in eq.choices)): uses = build.material_uses.setdefault(material, []) uses.extend(c for c in eq.users if c not in uses) machine_reasons = _machine_title(build, nodes, eq, fuel_region) fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) picked = [(c, name) for c, name in eq.choices if c in book.titles] for work_item, user_row in eq.users.items(): node_rows = [ r for t in (nodes.get(work_item) or {}).get("tables") or [] for r in t.get("raw_row") or [] ] # 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③). if not any( r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split()) for r in node_rows ): continue labor_name = _tight(user_row).split("(")[0] reasons = list(machine_reasons) for title in [ t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#") ]: if f"X-{eq.machine}" not in book.titles: break users = [ r for r in book.details.get(title, []) if book.titles.get(r.ref_code) and _tight(book.titles[r.ref_code].name) == labor_name ] if not users: continue count = users[0].quantity note = f"{eq.key} {count}대(인원 「{user_row}」 × 100%) — {eq.basis}" book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note)) if len(picked) == 1: code, name = picked[0] liters = _row_numbers(fuel_rows, name)[0] book.add_detail( PriceDetail(title, code, count * liters, note=f"{name} {liters}ℓ/대/일") ) if len(picked) > 1: reasons.append( f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것" ) elif not picked and eq.choices: reasons.append( f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음" ) labels = build.unattached.setdefault(work_item, []) labels.extend(r for r in reasons if r not in labels)