12-6 은 두께 칸이 작업조 칸과 엇갈려 병합된 표라 두께 줄을 시공량 갈래로 읽음(20·30·40㎝, ㎥당). 후진 진입·경운기 칸은 「50%까지 감」 범위라 안 세움. 공종 단위가 빈 작업조 표는 시공량 열 머리 단위를 씀. 13-4-1·13-4-4 는 hwpx 병합대로 앞 세 돌 골·켜 두 잎 + 호박돌 및 야면석 한 잎으로 갈래를 세움(각 40 갈래). 13-4-4 는 같은 절의 표준경사 표 오판정으로 아직 막힘 — 형태 판정 일감으로 넘김. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
127 lines
5.6 KiB
Python
127 lines
5.6 KiB
Python
"""B09 원가계산 — **머리가 두 단으로 병합된 표** 읽기 (자원 축 보조, 2026-09-13).
|
||
|
||
인력 돌쌓기 13-4-1 메쌓기 · 13-4-4 찰쌓기 — hwpx 원본의 병합 칸으로 머리 모양을 확인함.
|
||
|
||
뒷길이(㎝) | 견치돌(4칸) | 깬돌(4칸) | 깬잡석(4칸) | 호박돌 및 야면석(2칸·두 줄 병합)
|
||
| 골쌓기 켜쌓기 | 골쌓기 켜쌓기 | 골쌓기 켜쌓기 |
|
||
| 석공 | 보통인부 × 7 벌
|
||
35 | 0.50 | 0.40 | 0.55 | 0.44 | … | 0.16 | 0.14
|
||
|
||
마스터는 병합을 풀어 칸만 늘어놓아 **어느 값이 어느 돌·쌓기인지**가 사라졌다 — 행 읽기 길은
|
||
「값 묶음 0 개가 갈래 6 개와 안 맞습니다」로 표째 버렸다.
|
||
|
||
읽는 법 — 자원 줄의 되풀이 폭(석공·보통인부 = 2)으로 **잎 칸 수**(14 ÷ 2 = 7)를 얻고,
|
||
소분류 줄의 되풀이(골·켜 × 3)가 **앞의 대분류 셋**을 두 잎씩 먹고, 남은 대분류가 한 잎씩 먹는다.
|
||
⚠ 「앞에서부터」는 hwpx 병합으로 확인한 두 표에만 참이다 — **적어 둔 공종만** 읽고,
|
||
칸 수가 딱 맞지 않으면 한 줄도 안 세우고 막는다.
|
||
"""
|
||
|
||
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,
|
||
)
|
||
|
||
#: 공종 → 확인 근거. 여기 없는 공종은 이 길로 안 읽는다.
|
||
TWO_LEVEL_HEADER: dict[str, str] = {
|
||
"FP-13-04-01": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)",
|
||
"FP-13-04-04": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)",
|
||
}
|
||
_ABSENT = ("-", "-", "–", "—")
|
||
|
||
|
||
def _label(text: Any) -> str:
|
||
"""「견 치 돌」처럼 벌려 쓴 한 낱말은 붙이고, 「호박돌 및 야면석」은 그대로."""
|
||
parts = str(text or "").split()
|
||
return "".join(parts) if parts and all(len(p) == 1 for p in parts) else " ".join(parts)
|
||
|
||
|
||
def _period(items: list[str]) -> int:
|
||
"""되풀이 폭 — 딱 나뉘는 가장 짧은 주기. 없으면 전체 길이."""
|
||
for width in range(1, len(items) + 1):
|
||
if len(items) % width == 0 and items == items[:width] * (len(items) // width):
|
||
return width
|
||
return len(items)
|
||
|
||
|
||
def match_two_level_header_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_CrewOutput import _resolve
|
||
from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import _axis_label
|
||
|
||
code = str(node.get("work_item_code") or "")
|
||
if code not in TWO_LEVEL_HEADER:
|
||
return False
|
||
table_id = str(table.get("pum_table_id", ""))
|
||
head = list(table.get("condition_note") or [])
|
||
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, " | ".join(head), why))
|
||
result.partial_items[code] = why
|
||
return True
|
||
|
||
names = [c for c in rows[1] if c] if len(rows) >= 3 else []
|
||
entries = [_resolve(catalog, "".join(name.split())) for name in names]
|
||
if len(head) < 3 or len(names) < 2 or not all(entries):
|
||
# 같은 절에 딸린 다른 표(뒷길이 표준·표준경사)다 — 원래 길로 보낸다.
|
||
return False
|
||
if basis_quantity in (None, 0):
|
||
return block("두 단 머리 표에 밑수가 없습니다")
|
||
groups = [_label(g) for g in head[1:]]
|
||
subs = [_label(c) for c in rows[0] if c]
|
||
width = _period([e.code for e in entries])
|
||
leaves = len(names) // width
|
||
cycle = _period(subs)
|
||
split = len(subs) // cycle
|
||
if split > len(groups) or split * cycle + (len(groups) - split) != leaves:
|
||
return block(f"대분류 {len(groups)} · 소분류 {len(subs)} · 잎 {leaves} 칸이 딱 안 나뉩니다")
|
||
leaf_labels = [[g, s] for g in groups[:split] for s in subs[:cycle]]
|
||
leaf_labels += [[g] for g in groups[split:]]
|
||
|
||
staged: list[ResourceRow] = []
|
||
for index, cells in enumerate(rows[2:], start=2):
|
||
values = [c for c in cells[1:] if c]
|
||
if not cells or not cells[0]:
|
||
continue
|
||
readable = all(v in _ABSENT or parse_amount(v) is not None for v in values)
|
||
if len(values) != len(names) or not readable:
|
||
return block(
|
||
f"뒷길이 {cells[0]} 줄의 값 {len(values)} 칸이 자원 {len(names)} 칸과 안 맞습니다"
|
||
)
|
||
for position, (value, entry) in enumerate(zip(values, entries)):
|
||
if value in _ABSENT:
|
||
continue # 그 돌·뒷길이엔 품이 없다
|
||
labels = leaf_labels[position // width]
|
||
staged.append(
|
||
ResourceRow(
|
||
work_item_code=code,
|
||
pum_table_id=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=parse_amount(value) / basis_quantity,
|
||
amount_unit=unit,
|
||
raw_row_index=index,
|
||
variant=" · ".join([*labels, _axis_label(_label(head[0]), cells[0])]),
|
||
)
|
||
)
|
||
result.rows.extend(staged)
|
||
return True
|