"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ). 9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음. F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만 · 「·」 = 그 토질엔 그 자원 없음 F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴 ⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말). **적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음. ⚠ 규격은 범위 별칭(`data_aliases` scope FP-09-19-01 · [주]①)이 코드로 이음 — 칸이 규격을 적었으면 별칭이 안 덮음(`scoped_alias_entry`). 성토면 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정(Ⓑ). """ from __future__ import annotations import re from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, ResourceCatalog, ResourceRow, UnmatchedRow, _resolve_cell, parse_amount, ) from B09_Estimation.B09_Estimation_ResourceAxis_Join import scoped_alias_entry, unmatched_reason #: 표 번호 → (공종 · 모양 · 갈래 앞말 · 판정 근거). 여기 없는 표는 이 길로 안 읽음. JUDGED_TABLES: dict[str, dict[str, Any]] = { "F0288": { "code": "FP-09-19-01", "shape": "header_row", "prefix": "절토면", "why": "원문 L5414 「1. 절토면 고르기 (단위: 10㎡당)」 — 자원 머리가 첫 자료 줄", }, "F0289": { "code": "FP-09-19-01", "shape": "merged_first", "prefix": "성토면", # 원문 L5430 표 — 「인력시공」 칸이 두 줄 병합이라 둘째 줄(모래 또는 사질토)이 앞당겨 옴. "merged_rows": (1,), # 기계시공 「굴착기 | 0.6㎥」 — 원문이 형식을 안 적음. 카탈로그 0.6 은 무한궤도·타이어 # 둘이라 형식마다 갈래 · 고르기는 B08 칸(제안 무한궤도 — 영월 「06M3 B/H」 · 브레인 ②). "form_split": {"굴착기": ("무한궤도", "타이어")}, "why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김", }, } #: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님. _UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$") _ABSENT = frozenset({"·", "ㆍ", "-", "-", "–"}) def _entry(catalog: ResourceCatalog, name_cell: str, code: str, side: tuple[str, ...] = ()): return scoped_alias_entry(catalog, name_cell, code, side) or _resolve_cell( catalog, name_cell, [name_cell, *side] ) def _row( code: str, table: dict[str, Any], entry, amount: Decimal, unit: str, index: int, variant: str ): return ResourceRow( work_item_code=code, pum_table_id=str(table.get("pum_table_id", "")), pum_form=str(table.get("pum_form", "")), resource_kind=entry.kind, resource_code=entry.code, resource_name=entry.name, resource_spec=entry.spec, amount=amount, amount_unit=unit, raw_row_index=index, variant=variant, ) def match_judged_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, basis_quantity: Decimal | None, unit: str, ) -> bool: """적어 둔 표를 판정대로 읽음. 그 표가 아니면 `False`.""" code = str(node.get("work_item_code") or "") table_id = str(table.get("pum_table_id", "")) judged = JUDGED_TABLES.get(table_id) if judged is None or judged["code"] != code: return False rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] def block(why: str) -> bool: result.unmatched.append(UnmatchedRow(code, table_id, judged["prefix"], why)) result.partial_items[code] = why return True if basis_quantity in (None, 0): return block("판정 표에 밑수가 없습니다") if judged["shape"] == "header_row": staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit) else: staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit) if isinstance(staged, str): return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음") for item in staged: if isinstance(item, UnmatchedRow): result.unmatched.append(item) else: result.rows.append(item) return True def _header_row(code, table, judged, rows, catalog, basis, unit) -> list | str: """첫 줄 = 자원 머리 · 이후 줄 = [토질, 값 …]. 머리 자원을 하나라도 못 풀면 표째 막음.""" names = [_UNIT_TAIL.sub("", cell) for cell in (rows[0] if rows else []) if cell] entries = [_entry(catalog, name, code) for name in names] if len(names) < 2 or not all(entries): missing = [name for name, entry in zip(names, entries) if entry is None] return f"자원 머리 {missing or names} 을 못 풂" staged: list = [] for index, cells in enumerate(rows[1:], start=1): values, tail = cells[1 : 1 + len(names)], cells[1 + len(names) :] readable = all(v in _ABSENT or parse_amount(v) is not None for v in values) if not cells[0] or len(values) != len(names) or any(tail) or not readable: return f"{index}째 줄 「{cells[0] if cells else ''}」 값 칸" variant = f"{judged['prefix']} · {cells[0]}" for value, entry in zip(values, entries): if value in _ABSENT: continue # 그 토질엔 이 자원이 안 듦 staged.append( _row(code, table, entry, parse_amount(value) / basis, unit, index, variant) ) return staged def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str: """[시공, 토질, 구분, 규격, 단위, 수량] — 병합 줄은 앞 줄 시공을 이어받아 한 칸 되돌림.""" width = len(table.get("condition_note") or []) if width != 6: return f"머리 {width} 칸" lines: list[list[str]] = [] for index, cells in enumerate(rows): if index in judged["merged_rows"]: if not lines or cells[-1]: return f"{index}째 줄 병합 칸" cells = [lines[-1][0], *cells[:-1]] lines.append(cells) methods = [cells[0] for cells in lines] staged: list = [] for index, cells in enumerate(lines): if len(cells) != width: return f"{index}째 줄 칸 {len(cells)}" method, soil, name, size, _unit_word, value = cells amount = parse_amount(value) if not method or not name or amount is None: return f"{index}째 줄 「{method}」 값 칸" short = method.removesuffix("시공") variant = " · ".join( [judged["prefix"], short, *([soil] if methods.count(method) > 1 else [])] ) side = (size,) if size else () forms = judged.get("form_split", {}).get(name) for form in forms or (None,): entry = _entry(catalog, f"{name}({form})" if form else name, code, side) if entry is None: # 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로. label = f"{name} {size}".strip() reason = unmatched_reason(catalog, name) staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason)) continue named = f"{variant} · {form}" if form else variant staged.append(_row(code, table, entry, amount / basis, unit, index, named)) return staged