"""B09 원가계산 — **공정 줄을 더해 한 품이 되는 표** 읽기 (자원 축 보조, 2026-09-13). 공정별 | 보통인부(인) | 비고 보통토사 | 경질ㆍ고사점토 및 자갈섞인 점토 | 호박돌 섞인 토사 절 취 | 2.4 | 3.3 | 5.4 수평잡기 및 단정리 | 0.34 | 0.34 | 0.34 … 합계 | 2.03 | 2.09 | 2.23 행은 공정, 열은 토질 갈래, 자원은 머리 한 칸. 갈래마다 공정 줄을 **더한 것**이 한 품이다. 지금은 「숫자 칸이 자원 열보다 많다」로 표째 버려져 단끊기 5-16-1 이 한 줄도 안 섰다. ⚠ **모양만으로는 「더하는 표」인지 못 가른다** — 뭉기기 13-12-1 은 같은 모양인데 줄마다 단위가 달라(절취 ㎥ · 면고르기 시간/㎡) 더하면 틀린다. 그래서 **사람이 적은 공종만** 더한다. ⚠ 원문 합계 줄은 안 읽는다 — 줄 합과 다르면 **까닭을 적어 둔 공종만** 서고, 아니면 막는다. """ from __future__ import annotations from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, ResourceCatalog, ResourceRow, UnmatchedRow, parse_amount, ) _TOTAL_LABELS = ("계", "합계", "소계", "총계") #: 공종 → 근거 · 원문 합계 줄과 다를 때의 까닭 · [주]가 더하라는 직종(코드, 보통인부 몇 인당 1인). PROCESS_SUM: dict[str, dict[str, Any]] = { "FP-05-16-01": { "basis": ( "품셈 5-16-1 표 머리 (인/100m당) · [주]④ 「수평잡기 및 단정리, 잡석 및 뿌리정리," " 성토면고르기, 고르기품은 100m당 품」 · [주]③ 절취량 0.15㎥/m 기준" ), "total_mismatch": ( "원문 합계 줄(2.03·2.09·2.23)은 절취를 ㎥당(2.4÷15=0.16)으로 더한 값이라 줄 합과 다름" " — 100m당 줄을 더함(STmate 단끊기 산출과 같음)" ), "per_workers": ("1001", Decimal(20), "[주]⑤ 작업반장 1인/보통인부 20인 가산"), }, } def _tight(text: Any) -> str: return "".join(str(text or "").split()) def match_process_sum_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, basis_quantity: Decimal | None, unit: str, ) -> bool: """적어 둔 공종의 공정 합산 표를 읽는다. 그 공종이 아니면 `False`.""" from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import transposed_columns code = str(node.get("work_item_code") or "") spec = PROCESS_SUM.get(code) if spec is None: return False table_id = str(table.get("pum_table_id", "")) rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] columns = transposed_columns(table, catalog) def block(cell: str, why: str) -> bool: result.unmatched.append(UnmatchedRow(code, table_id, cell, why)) result.partial_items[code] = why return True labels = [cell for cell in (rows[0] if rows else []) if cell] if len(columns) != 1 or len(labels) < 2 or basis_quantity in (None, 0): return block(code, "공정 합산 표 모양이 아닙니다(자원 한 칸 · 갈래 줄 · 밑수)") sums = [Decimal(0)] * len(labels) totals: list[Decimal] | None = None for cells in rows[1:]: numbers = [value for value in (parse_amount(c) for c in cells[1:]) if value is not None] if not cells or not cells[0] or not numbers: continue if len(numbers) != len(labels): return block(cells[0], f"값 {len(numbers)} 개가 갈래 {len(labels)} 개와 안 맞습니다") if _tight(cells[0]) in _TOTAL_LABELS: totals = numbers continue sums = [total + value for total, value in zip(sums, numbers)] if totals is not None and totals != sums and not spec.get("total_mismatch"): return block("합계", f"원문 합계 {totals} 가 줄 합 {sums} 와 다릅니다") entry = columns[0][1] members = [(entry, Decimal(1))] if spec.get("per_workers"): chief_code, workers, _note = spec["per_workers"] chief = next((e for e in catalog.entries if e.code == chief_code), None) if chief is None: return block(chief_code, "[주]가 더하라는 직종이 카탈로그에 없습니다") members.append((chief, Decimal(1) / workers)) for label, total in zip(labels, sums): for member, ratio in members: result.rows.append( ResourceRow( work_item_code=code, pum_table_id=table_id, pum_form=str(table.get("pum_form", "")), resource_kind=member.kind, resource_code=member.code, resource_name=member.name, resource_spec=member.spec, amount=total * ratio / basis_quantity, amount_unit=unit, raw_row_index=0, variant=label, ) ) return True