"""B09 원가계산 — **풀베기 묘목찾기 = 조림목 본수 칸** (2026-09-15 브레인 차례 ② 판정 ㉠ · 지금 칸을 만듦). 산림 6-2-2 줄베기 · 6-2-3 모두베기 [주]④⑤ 「묘목찾기와 줄베기(모두베기) 품을 **합하여** 적용」 — 묘목찾기는 「낫 | 인/100본 | 0.07 | 보통인부」 라 조림목 본수(본/ha · [주]② 둘레베기 참고 · 설계 입력)가 있어야 ha당이 섬. ⇒ 본수가 들면 갈래마다 보통인부 0.07 × 본수 ÷ 100 · 비면 「일부만」 으로 막고 사유(반만 선 틀린 값보다 안 선 값). ⚠ 제안값 없음 — 부록 예시 2,700본/ha 는 같은 부록이 품도 표와 다르게 적은 자료(묘목찾기 0.10 ↔ 표 0.07). """ from __future__ import annotations import re from decimal import Decimal, InvalidOperation from typing import Any #: 공종 → 표 번호(`_JudgedTable` 도구 줄 표 · 인/100본 줄이 있는 둘). SEEDLING_TABLES = {"FP-06-02-02": "F0155", "FP-06-02-03": "F0156"} NAME = "묘목찾기" MISSING = ( "조림목 본수(본/ha) 미입력 — 원문 [주]④⑤ 가 묘목찾기({amount}인/100본)와 합하여 적용하라 함" " · 「산출 조건」 에서 넣으면 섬" ) def parse_seedlings_per_ha(text: str | None) -> Decimal | None: """조림목 본수 칸 — 빈 칸은 `None` · 0 이하·수가 아닌 값은 `ValueError`(조용히 접지 않음).""" cleaned = re.sub(r"[\s,]", "", str(text or "")) if not cleaned: return None try: value = Decimal(cleaned) except InvalidOperation as exc: raise ValueError(f"조림목 본수는 수로 넣어야 합니다: {text}") from exc if value <= 0: raise ValueError(f"조림목 본수는 0 보다 커야 합니다: {text}") return value def _per_hundred(node: dict[str, Any], table_id: str) -> tuple[Decimal, str] | None: """(인/100본 값, 인력 이름) — 표에서 읽음. 칸이 달라지면 `None`.""" from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {}) for row in table.get("raw_row") or []: cells = ["".join(str(c).split()) for c in row] if "인/100본" in cells: unit = cells.index("인/100본") amount = parse_amount(cells[unit + 1]) if len(cells) > unit + 2 else None if amount is not None and cells[unit + 2]: return amount, cells[unit + 2] return None def attach_seedling_finding( build: Any, nodes: dict[str, dict[str, Any]], seedlings_per_ha: Decimal | None ) -> None: """본수가 들면 갈래 제목마다 묘목찾기 인력을 붙이고, 없으면 일부만 + 사유.""" from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind book = build.book for code, table_id in SEEDLING_TABLES.items(): found = _per_hundred(nodes.get(code) or {}, table_id) titles = [t for t in book.titles if t.startswith(f"B-{code}#")] labels = [ label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME ] labor = next( ( c for c, t in book.titles.items() if found and t.kind is PriceKind.LABOR and "#" not in c and t.name == found[1] ), None, ) if found is None or labor is None or not titles: build.component_gaps[code] = "묘목찾기 줄 칸이 달라져 못 읽음" build.partial_ratio.setdefault(code, Decimal(0)) continue amount, name = found if seedlings_per_ha is None: reason = MISSING.format(amount=amount) build.component_gaps[code] = reason build.partial_ratio.setdefault(code, Decimal(0)) labels.append(reason) else: note = f"묘목찾기 {amount}인/100본 × 조림목 {seedlings_per_ha}본/ha ÷ 100 — [주]④⑤ 합하여 적용" for title in titles: book.add_detail( PriceDetail(title, labor, amount * seedlings_per_ha / 100, note=note) ) build.unattached[code] = labels