"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09). 품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 **둘 다 더하면 장비 두 대와 인부 두 몫이 서서 대략 두 배**가 된다. 9-21 제근 | 종 류 | 명 칭 | 단위 | 소 | 중 | 밀 | | 굴착기(무한궤도) | 굴착기(무한궤도,0.2㎥) | hr | 0.80 | 1.01 | 1.22 | ┐ 0.2㎥ 블록 | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ | 굴착기(무한궤도,0.7㎥) | hr | 0.46 | 0.58 | 0.70 | ┐ 0.7㎥ 블록 | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ 2026-09-09 실측: 제근 단가가 **128,039원**으로 서 있었다 — 굴착기 0.2·0.7 이 둘 다 붙고 보통인부도 두 번 붙은 값이다. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, **밑수가 정해지는 날 조용히 두 배로 설 자리**였다. 읽는 법 — **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다. 갈래 = 「굴착기(무한궤도) 0.2㎥ · 소」 … 「굴착기(무한궤도) 0.7㎥ · 밀」 (2 × 3 = 6) ⚠ **「고르는 표」인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 다 고르는 표가 아니다 — 9-13 암절취는 「깨기(대형브레이커)」와 「들어내기(백호우)」가 **함께 드는** 표다. 가르는 자국은 **같은 기계 이름에 규격만 다른 것**이다(굴착기 0.2 vs 0.7). 품셈도 그렇게 말한다 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 …적용계수를 달리 적용하도록 한다」. ⚠ **밑수는 여기서 만들지 않는다.** 9-21 은 표 머리·제목·[주] 어디에도 밑수가 없다 (2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 빈 채로 둔다. """ from __future__ import annotations import re from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, CatalogEntry, ResourceCatalog, ResourceRow, UnmatchedRow, parse_amount, split_name_and_spec, ) _NUMBER = re.compile(r"^\d+(?:\.\d+)?$") #: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다. _NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격") def _clean(cell: Any) -> str: return " ".join(str(cell or "").split()) def _column_labels(header: list[Any]) -> list[str]: """표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」.""" labels = [_clean(cell) for cell in header] return [ label for label in labels if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN} ] def _machine_of(cells: list[str], catalog: ResourceCatalog): """그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`. ⚠ 자원 카탈로그의 이름·규격 짝으로는 안 풀린다 — 품셈이 「굴착기(무한궤도,0.2㎥)」처럼 **규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 그 모양을 아는 쪽에 맡긴다. """ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine machines = load_machine_catalog().machines for index, cell in enumerate(cells): found = resolve_machine(cell) if found is None: continue machine = machines.get(found[0]) if machine is None: continue entry = CatalogEntry( code=found[0], name=machine.name, kind="machine", spec=str(machine.specification) ) return index, entry, cell return None def _values_of(cells: list[str], count: int) -> list[Decimal] | None: """그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다.""" numbers = [cell for cell in cells if _NUMBER.match(cell)] if len(numbers) < count: return None picked = [parse_amount(cell) for cell in numbers[-count:]] return None if any(value is None for value in picked) else picked # type: ignore[return-value] def match_choose_one_machine_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, ) -> bool: """「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`. ⚠ 같은 기계 이름에 **규격만 다른** 블록이 둘 이상일 때만 내 표로 본다. """ # ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽 # 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다 # (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.** if not any(entry.kind == "machine" for entry in catalog.entries): return False rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] header = list(table.get("condition_note") or []) labels = _column_labels(header) if len(rows) < 2 or len(labels) < 2: return False blocks: list[dict[str, Any]] = [] for row in rows: cells = [_clean(cell) for cell in row] if not cells: continue found = _machine_of(cells, catalog) if found is not None: _index, entry, raw_cell = found values = _values_of(cells, len(labels)) if values is None: return False blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values}) continue if not blocks: continue name, spec = split_name_and_spec(cells[0]) entry = catalog.resolve(name, spec) values = _values_of(cells, len(labels)) if entry is None or values is None: continue blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]}) if len(blocks) < 2: return False # ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다. names = {block["entry"].name for block in blocks} specs = {block["entry"].spec for block in blocks} if len(names) != 1 or len(specs) != len(blocks): return False work_item_code = str(node.get("work_item_code", "")) table_id = str(table.get("pum_table_id", "")) form = str(table.get("pum_form", "")) unit = table.get("basis_unit") or "" # ⚠ **빌려 온 밑수의 배수를 여기서 나눈다** — 「1,000㎡당」 표를 ㎡당으로 싣는다. # 안 나누면 금액이 **천 배**로 선다(2026-09-09 확정 5차 6번, 제근). from B09_Estimation.B09_Estimation_WorkItemUnit import borrowed_basis_per per = Decimal(borrowed_basis_per(str(node.get("work_item_code", "")))) made = 0 for block in blocks: machine = block["entry"] for column, label in enumerate(labels): variant = f"{machine.name} {machine.spec} · {label}".strip() entries = [(machine, block["values"][column], block["cell"])] entries += [ (item["entry"], item["values"][column], item["cell"]) for item in block["rows"] ] for entry, amount, cell in entries: result.rows.append( ResourceRow( work_item_code=work_item_code, pum_table_id=table_id, pum_form=form, resource_kind=entry.kind, resource_code=entry.code, resource_name=entry.name, resource_spec=entry.spec, amount=amount / per, amount_unit=unit, raw_row_index=0, variant=variant, ) ) made += 1 del cell if made == 0: return False result.unmatched.append( UnmatchedRow( work_item_code=work_item_code, pum_table_id=table_id, cell=" | ".join(_clean(cell) for cell in header), reason=( f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — " "표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. " "⚠ 밑수(무엇당)는 원문에 없습니다." ), ) ) return True # --------------------------------------------------------------------------- # 규격이 **열**로 선 표 (2026-09-09) — 관부설 12-11-1·2·3 # --------------------------------------------------------------------------- # # | 구 분 | 규격 | 단위 | 관 경 별 적 용 | # | ∅800mm | ∅1000mm | ∅1200mm | ← 첫 줄이 **규격 이름만** 늘어선 줄 # | 크레인 | 10ton | hr | 0.62/2.5 | 0.76/2.5 | 0.90/2.5 | # | 배관공 | | 인 | 0.26/2.5 | 0.35/2.5 | 0.46/2.5 | # # ⚠ 값이 **나눗셈 식**이다 — 「0.62/2.5」는 「관 2.5m 한 개당 0.62시간」이라는 뜻이라 # **m 당으로 환산된 값**이다. 그대로 두면 자원 줄이 하나도 안 서서 배수관이 통째로 # 금액을 못 냈다(2026-09-09 실측: 아홉 줄 중 다섯이 길이까지 있는데 단가가 없었다). # # ⚠ **값이 빈 칸은 건너뛴다** — 기초콘크리트·거푸집·모래부설은 「별산」 자리다(계획서 9-9). _SPEC_HEAD = re.compile(r"^[∅Ø⌀]\s*\d") _FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)((?:\s*/\s*\d+(?:\.\d+)?)+)\s*$") def _fraction_value(cell: str) -> Decimal | None: """「0.62/2.5」·「0.016/2.5/2」를 수로. 나눗셈이 아니면 `None`.""" matched = _FRACTION.match(cell) if not matched: return None value = Decimal(matched.group(1)) for part in matched.group(2).split("/"): part = part.strip() if not part: continue divisor = Decimal(part) if divisor == 0: return None value = value / divisor return value def _cell_amount(cell: str) -> Decimal | None: """값 칸 하나 — 숫자 그대로이거나 나눗셈 식.""" text = _clean(cell) if not text: return None if _NUMBER.match(text): return parse_amount(text) return _fraction_value(text) def match_spec_column_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, ) -> bool: """규격이 **열**로 선 표를 읽는다. 그런 표가 아니면 `False`. ⚠ 첫 줄이 **규격 이름만** 늘어선 줄일 때만 내 표로 본다 — 「∅800mm ∅1000mm …」. """ rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] if len(rows) < 2: return False specs = [_clean(cell) for cell in rows[0] if _clean(cell)] if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs): return False from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason unit = table.get("basis_unit") or "" work_item_code = str(node.get("work_item_code", "")) table_id = str(table.get("pum_table_id", "")) form = str(table.get("pum_form", "")) made = 0 previous_note = "" def unmatched(cell: str, reason: str) -> None: result.unmatched.append( UnmatchedRow( work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason ) ) for index, row in enumerate(rows[1:], start=1): cells = [_clean(cell) for cell in row] if not cells or not cells[0]: continue # 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리) values = [ value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None ] note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else "" note = previous_note if note == "〃" else note spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else "" if not values and not note and previous_note.replace(" ", "") == "설계수량": note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집) previous_note = note tight_note = note.replace(" ", "") # ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김. if tight_note in ("별산", "별도계산"): unmatched( cells[0], f"「{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음" ) continue if tight_note == "설계수량": unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음") continue if len(values) < len(specs): why = f"「{note}」 — " if note else "" unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음") continue name, inline_spec = split_name_and_spec(cells[0]) row_specs = [text for text in (spec_cell, inline_spec) if text] entries: list[CatalogEntry] = [] for spec_name in specs: entry = ( next( ( found for text in (*row_specs, spec_name) if (found := catalog.resolve(name, text)) is not None ), None, ) or catalog.resolve(name, "") or resolve_family(catalog, cells[0], cells) ) if entry is None: break entries.append(entry) if len(entries) < len(specs): origin = f" · 원문 규격 {spec_cell}" if spec_cell else "" unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)") continue for spec_name, amount, entry in zip(specs, values[-len(specs) :], entries): result.rows.append( ResourceRow( work_item_code=work_item_code, pum_table_id=table_id, pum_form=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=spec_name, ) ) made += 1 return made > 0