"""B09 원가계산 — **작업조 + 시공량** 표 읽기 (2026-09-08). 품셈에는 소요량을 직접 안 주고 **「작업조 몇 인이 하루 몇 ㎡」** 로 주는 표가 있다. 구 분 | 단 위 | 수 량 | 시 공 량 (㎡) | | | 복잡 보통 간단 형틀목공 | 인 | 4 | 25 35 40 보통인부 | 인 | 1 | 1단위당 품 = 인원 ÷ 시공량 (유로폼 12-38-3: 형틀목공 4 ÷ 35 = 0.1143 인/㎡) **유형은 우리가 안 고른다.** 품셈 12-38-3 [유형] 표가 「보통 : 측구, 수로, 옹벽 …」로 정해 두었으므로 **갈래(`#복잡`·`#보통`·`#간단`)로 세워 두고 고르는 것은 B08**에 맡긴다 (철근 12-3 [주]① 과 같은 모양). ⚠ **뭉쳐 온 칸을 짝지어 읽는다.** 이름·단위·인원이 한 칸에 붙어 온다 (`형틀목공 보통인부` | `인 인` | `4 1`). **개수가 안 맞으면 그 표를 통째로 버린다** — 자리를 밀어 읽으면 다른 직종의 품이 붙는다(오늘 여러 번 겪은 자리). """ from __future__ import annotations import re from dataclasses import dataclass from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, ResourceCatalog, ResourceRow, UnmatchedRow, parse_amount, split_name_and_spec, ) _RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$") #: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다. _OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량") @dataclass(frozen=True) class CrewMember: """작업조 한 사람(또는 한 대).""" code: str name: str spec: str kind: str count: Decimal @dataclass(frozen=True) class CrewTable: """작업조 표 한 장. `outputs` 는 유형별 시공량 — 갈래가 하나면 이름이 빈 문자열.""" members: tuple[CrewMember, ...] outputs: tuple[tuple[str, Decimal], ...] def amount_of(self, member: CrewMember, output: Decimal) -> Decimal: """1단위당 품 = 인원 ÷ 시공량.""" return member.count / output def _normalize(text: str) -> str: return " ".join(str(text).split()) def _is_output_header(cell: str) -> bool: tight = "".join(str(cell).split()) return any(word in tight for word in _OUTPUT_WORDS) def _numbers_in(cell: str) -> list[Decimal]: """한 칸에 뭉쳐 온 수들 — 「4 1」 → [4, 1].""" found: list[Decimal] = [] for token in _normalize(cell).split(" "): if _RE_NUMBER.match(token): found.append(Decimal(token.replace(",", ""))) return found def output_columns(table: dict[str, Any]) -> list[int]: """시공량 열의 번호. 없으면 빈 목록 — 작업조 표가 아니다.""" headers = table.get("condition_note") or [] return [index for index, cell in enumerate(headers) if _is_output_header(cell)] def parse_crew_table( table: dict[str, Any], catalog: ResourceCatalog, ) -> CrewTable | str: """작업조 표를 읽는다. 못 읽으면 **사유 문자열**을 돌려준다(빈 표를 만들지 않는다).""" if not output_columns(table): return "시공량 열이 없습니다" rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] if not rows: return "빈 표입니다" members: list[CrewMember] = [] outputs: list[Decimal] = [] labels: list[str] = [] for row in rows: if not row or not row[0]: continue head = row[0] # 첫 칸에 자원 이름이 뭉쳐 올 수 있다 — 「형틀목공 보통인부」. # ⚠ 이름 안에 공백이 든 표가 있다 — 「조 경 공」·「비 계 공」. 공백을 구분자로만 # 보면 그런 줄이 통째로 안 풀린다. **한 이름으로 먼저 시도**하고, 안 되면 쪼갠다. tight = "".join(_normalize(head).split(" ")) single = _resolve(catalog, tight) if single is not None: names, resolved = [tight], [single] else: names = [part for part in _normalize(head).split(" ") if part] resolved = [_resolve(catalog, name) for name in names] if not all(resolved): # 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」. if not _numbers_in(" ".join(row)): if not members: labels = [_normalize(cell) for cell in row if _normalize(cell)] continue # ⚠ **수가 있는데 이름을 못 푼 줄은 작업조의 한 몫**이다 — 조용히 건너뛰면 # 그 몫이 빠진 채 단가가 선다(2026-09-08: 평떼 시비에서 「트럭 2.5ton 1대」가 # 빠지고 노무만으로 28.6원/㎡ 이 섰다). 표를 통째로 버린다. return f"작업조 줄 「{_normalize(head)[:20]}」을 못 풀었습니다" counts = _counts_of(row, len(resolved)) if counts is None: return f"인원 수가 이름 {len(resolved)} 개와 안 맞습니다" for entry, count in zip(resolved, counts): members.append( CrewMember( code=entry.code, name=entry.name, spec=entry.spec, kind=entry.kind, count=count, ) ) # 시공량은 보통 **첫 작업조 줄**에 붙어 온다. if not outputs: outputs = _outputs_of(row, counts) if not members: return "작업조 줄을 못 찾았습니다" if not outputs: return "시공량 값을 못 찾았습니다" if labels and len(labels) != len(outputs): # 라벨과 값의 개수가 다르면 **어느 유형인지 단정할 수 없다** — 읽지 않는다. return f"유형 {len(labels)} 개와 시공량 {len(outputs)} 개가 안 맞습니다" named = tuple((labels[index] if labels else "", value) for index, value in enumerate(outputs)) return CrewTable(members=tuple(members), outputs=named) def _resolve(catalog: ResourceCatalog, name_cell: str): name, spec = split_name_and_spec(name_cell) entry = catalog.resolve(name, spec) if entry is not None: return entry if spec: return None found = catalog.by_name(name) return found[0] if len(found) == 1 else None def _counts_of(row: list[str], wanted: int) -> list[Decimal] | None: """이름 개수와 같은 만큼의 인원 수를 가진 칸을 찾는다. 없으면 `None`.""" for cell in row[1:]: numbers = _numbers_in(cell) if len(numbers) == wanted: return numbers return None def _outputs_of(row: list[str], counts: list[Decimal]) -> list[Decimal]: """인원 칸 **뒤**에 오는 수들이 시공량이다.""" seen_counts = False values: list[Decimal] = [] for cell in row[1:]: numbers = _numbers_in(cell) if not numbers: continue if not seen_counts and numbers == counts: seen_counts = True continue if seen_counts: values.extend(numbers) return values def match_crew_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, unit: str, ) -> bool: """작업조 표를 자원 축 줄로 바꾼다. 그런 표가 아니면 `False`.""" if not output_columns(table): return False work_item_code = node.get("work_item_code", "") table_id = str(table.get("pum_table_id", "")) form = str(table.get("pum_form", "")) parsed = parse_crew_table(table, catalog) if isinstance(parsed, str): result.unmatched.append( UnmatchedRow( work_item_code=work_item_code, pum_table_id=table_id, cell=_normalize(" | ".join(str(c) for c in (table.get("condition_note") or []))), reason=f"작업조 표를 못 읽었습니다 — {parsed}", ) ) return True # 다른 길로 보내지 않는다 — 행-자원으로 읽으면 인원을 소요량으로 오해한다 for index, (label, output) in enumerate(parsed.outputs): if output <= 0: continue for member in parsed.members: result.rows.append( ResourceRow( work_item_code=work_item_code, pum_table_id=table_id, pum_form=form, resource_kind=member.kind, resource_code=member.code, resource_name=member.name, resource_spec=member.spec, amount=parsed.amount_of(member, output), amount_unit=unit, raw_row_index=index, variant=label, ) ) return True