Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_TwoLevelHeader.py
eomsangdonandClaude Opus 5 1c79fc288c fix(axis): 표 읽기 — 콘크리트 포장 12-6 두께별 작업조 · 인력 돌쌓기 13-4-1/4 두 단 머리
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
2026-09-13 21:42:23 +09:00

127 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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