Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_ThreeAxis.py
eomsangdonandClaude Opus 5 b2ad1952f1 feat(B09): 축이 셋인 품셈 표 읽기 — 가설창고·돌붙임이 처음으로 섬 (확정 ⑬)
값 한 칸에 세로축 값이 공백으로 뭉쳐 든 표를 행-자원으로도 열-자원으로도 못 읽어
두 표가 한 줄도 안 서고 있었음.

- 11-1 콘테이너형 가설건축물 F0325: 0 → 40 줄 (길이 4 × 폭 5 × 직종 2)
  `reference` 로 찍혀 형태 필터에 걸려 버려지고 있었음
- 13-5-1 돌붙임(인력) F0416: 0 → 80 줄 (메·찰 × 돌 3종 × 뒷길이 7 × 직종 2)
  「뭉친 자원 줄의 이름을 못 풀었습니다」로 버려지고 있었음

읽는 법 — 맨 아랫줄이 값, 그 위가 자원 이름, 더 위가 묶음 이름. 칸 수와 숫자 개수가
딱 나뉘지 않으면 한 줄도 세우지 않고 unmatched 로 보냄.

짓다 잡은 함정 넷
- 꼬리의 「비고」 칸(「H=2.6M 기준 …」)을 값으로 세면 표 전체를 못 읽음 — 꼬리만 떼어 냄
- 자원 줄에 줄머리가 있는 표와 없는 표가 섞여 있음 — 무조건 앞칸을 버리면 자원 하나가
  통째로 사라짐(11-1 이 7 대 8 로 어긋났음). 있는 그대로 세고 안 맞으면 앞칸을 떼어 봄
- 묶음 줄은 반대로 앞칸(축 이름)을 먼저 떼야 함 — 안 떼면 「구 분」이 묶음으로 서서
  메·찰이 통째로 사라짐
- 「구 분」·「종 별」은 자간을 벌린 한 낱말임 — 축 둘로 읽으면 갈래가 「구 메 붙 임」이 됨
- 「-」 는 그 규격이 없다는 뜻 — 0 으로 세우면 공짜 공종이 됨(야면석 70㎝ 넷)

가로채기 방지 — 값줄 위가 단위 줄인 4축 표(10-6-3)는 자원 이름이 하나도 안 풀리므로
내 표가 아니라고 보고 손대지 않음. 전체 대조로 늘어난 표가 F0325·F0416 둘뿐이고
줄이 줄어든 표는 없음을 시험으로 못 박음.

검증: 자원 축 419 → 539 줄 · 일위대가 갈래 60개 신설 · pytest 272 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:09:46 +09:00

298 lines
14 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-09).
품셈에는 한 표 안에 **가로축 · 세로축 · 자원**이 함께 든 모양이 있다. 값 한 칸에
세로축 값 여러 개가 **공백으로 뭉쳐** 들어 있어, 행-자원으로도 열-자원으로도 안 읽힌다.
11-1. 콘테이너형 가설건축물 ← 확정 ⑬ 이 걸려 있던 표
| 길이 폭 | 3M | 6M | … | 비고 |
| | 비계공 특별인부 | 비계공 특별인부 | … | |
| 2.4M 3.0M 3.5M 4.8M 6.0M | 0.29 0.33 … | 0.14 0.17 … | … |
13-5-1. 돌붙임(인력) ← 덤으로 같이 서는 표
| 구 분 | 메 붙 임 | 찰 붙 임 |
| 종 별 | 깬돌 | 깬잡석 | 야면석 | 깬돌 | 깬잡석 | 야면석 |
| 뒷길이(㎝) | 석공 | 보통인부 | … |
| 25 30 35 … | 0.15 0.22 … | … |
**둘 다 지금 한 줄도 안 서고 있었다** — 하나는 `reference` 로 걸러졌고(F0325), 하나는
「뭉친 자원 줄의 이름을 못 풀었습니다」로 버려졌다(F0416).
읽는 법 — **맨 아랫줄이 값이고, 그 위가 자원 이름이고, 더 위가 묶음 이름**이다.
① 값줄 첫 칸을 쪼갠다 → 세로축 값 N 개 (「2.4M 3.0M …」 → 5 개)
② 값줄 나머지 칸은 저마다 **N 개의 숫자**를 들고 있어야 한다 — 아니면 통째로 버린다
③ 값줄 바로 위가 자원 이름 줄, 그 위(들)가 묶음 이름 줄
④ 칸 i 의 j 번째 숫자 = (묶음 라벨 … · 세로축 j 번째 값) 갈래의 자원 i 소요량
⚠ **자리를 짐작해 맞추지 않는다.** 칸 수·숫자 개수가 딱 나뉘지 않으면 **한 줄도 세우지
않고** `unmatched` 로 보낸다. 뭉친 값 표는 한 칸만 밀려도 **다른 규격의 품**이 붙는다.
⚠ **「-」 는 값이 없는 것**이다. 0 으로 때우지 않고 그 갈래만 건너뛴다
(품셈 13-5-1 야면석 70㎝ 자리가 그렇다 — 그 규격이 없다는 뜻이다).
"""
from __future__ import annotations
import re
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,
)
#: 값 칸으로 인정하는 글자 — 숫자와 「-」(없음)뿐이다.
_NUMBER = re.compile(r"^\d+(?:\.\d+)?$")
_ABSENT = ("-", "", "", "", "—", "ㆍ", "·")
#: 묶음 이름 자리에서 뺄 말. 「비고」 열은 값이 아니라 설명이다.
_NOTE_LABELS = ("비고", "적요", "참고")
#: 세로축 이름이 안 적힌 표를 위한 자리표시 — **지어낸 이름을 쓰지 않는다.**
_UNNAMED_AXIS = "구분"
def _clean(text: Any) -> str:
return " ".join(str(text or "").split())
def _tokens(cell: Any) -> list[str]:
return _clean(cell).split()
def _is_value_cell(cell: Any, count: int) -> bool:
"""숫자(또는 「-」)만 `count` 개 든 칸인가."""
parts = _tokens(cell)
if len(parts) != count:
return False
return all(_NUMBER.match(p) or p in _ABSENT for p in parts)
def _axis_values(cell: Any) -> list[str]:
"""세로축 값들. 「2.4M 3.0M …」·「25 30 35 …」처럼 한 칸에 뭉쳐 있다."""
parts = _tokens(cell)
if len(parts) < 2:
return []
# 값 축이어야 한다 — 이름이 뭉친 줄(자원 이름 여럿)을 값으로 오해하면 안 된다.
if not all(re.match(r"^\d", p) for p in parts):
return []
return parts
def _labelled_cells(row: list[Any], width: int) -> list[str] | None:
"""줄에서 **값 칸에 대응하는 칸들**만 골라 낸다. 못 고르면 `None`.
⚠ 줄머리(축 이름) 칸이 **있는 줄과 없는 줄이 섞여 있다** — 11-1 의 자원 줄은
「비계공」으로 바로 시작하고, 13-5-1 의 자원 줄은 「뒷길이 (㎝)」로 시작한다.
앞칸을 무조건 버리면 자원 하나가 통째로 사라진다(11-1 이 그래서 7 대 8 로 어긋났다).
그래서 **있는 그대로 세어 보고, 안 맞으면 앞칸 하나를 줄머리로 보고 다시 센다.**
"""
cells = [_clean(cell) for cell in row if _clean(cell)]
cells = [cell for cell in cells if cell not in _NOTE_LABELS]
if not cells:
return None
if len(cells) == width or (width and len(cells) % width == 0):
return cells
if len(cells) - 1 == width or (width and (len(cells) - 1) % width == 0):
return cells[1:]
return None
def _group_labels(row: list[Any], width: int, prefix: str = "") -> list[str] | None:
"""묶음 줄 하나를 **열 개수만큼** 펼친다. 딱 나뉘지 않으면 `None`.
「메 붙 임 | 찰 붙 임」이 열 열두 개를 반씩 먹는 모양을 여기서 편다.
⚠ **묶음 줄은 첫 칸을 먼저 떼고 센다** — 그 자리는 축 이름(「구 분」·「종 별」·
「길이 폭」)이지 묶음이 아니다. 안 떼면 「구 분」이 묶음 하나로 서서 **메·찰이
통째로 사라진다**(2026-09-09 실측). 떼고도 안 나뉘면 그때 통째로 세어 본다.
"""
labels = [_clean(cell) for cell in row if _clean(cell)]
labels = [label for label in labels if label not in _NOTE_LABELS]
for candidate in (labels[1:], labels):
if candidate and width % len(candidate) == 0:
span = width // len(candidate)
spread: list[str] = []
for label in candidate:
spread.extend([f"{prefix} {label}".strip() if prefix else label] * span)
return spread
return None
def _axis_name(rows: list[list[Any]], header: list[Any], resource_row_index: int) -> str:
"""세로축 이름 — 값줄 바로 위 첫 칸(「뒷길이 (㎝)」)이 먼저다.
그 자리가 자원 이름이면(11-1 은 「비계공」이 온다) 표 머리 첫 칸의 **끝 낱말**을 쓴다
(「길이 폭」의 「폭」 — 가로축 이름이 앞, 세로축 이름이 뒤인 품셈 표 머리 관례).
"""
if resource_row_index > 0:
candidate = _clean(rows[resource_row_index][0])
if candidate and not _tokens(candidate)[0].isdigit():
return candidate # 「뒷길이 (㎝)」 — 단위는 값 옆으로 옮겨 붙인다
names = _split_axis_names(header[0] if header else "")
if names:
return names[1]
return _UNNAMED_AXIS
def _split_axis_names(cell: Any) -> tuple[str, str] | None:
"""모서리 칸이 **축 이름 둘**인가 — 「길이 폭」이면 (길이, 폭), 「구 분」이면 아니다.
⚠ 품셈 표 머리에는 **자간을 벌린 한 낱말**이 흔하다(「구 분」·「종 별」·「종 류」).
낱말 하나를 축 둘로 읽으면 갈래 이름이 「구 메 붙 임」처럼 망가진다(2026-09-09 실측).
가르는 자리는 **글자 수**다 — 벌려 쓴 낱말은 토막이 모두 한 글자다.
"""
parts = _tokens(cell)
if len(parts) != 2:
return None
if all(len(part) == 1 for part in parts):
return None
return parts[0], parts[1]
def _axis_label(axis_name: str, value: str) -> str:
"""「뒷길이 (㎝)」 + 「25」 → 「뒷길이 25㎝」. 이름에 딸린 단위를 값 옆으로 옮긴다."""
match = re.match(r"^(.*?)\s*[(]\s*([^)]+?)\s*[)]\s*$", axis_name)
if match:
return f"{match.group(1).strip()} {value}{match.group(2).strip()}"
return f"{axis_name} {value}"
def match_three_axis_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""축이 셋인 표를 읽는다. 그런 표가 아니면 `False` — 원래 길로 보낸다.
⚠ 형태만 보고 가른다. **공종 코드를 박아 두지 않는다** — 품셈이 개정되면
표 번호가 움직이므로 코드로 잡으면 조용히 놓친다.
"""
raw_rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
if len(raw_rows) < 2:
return False
header = list(table.get("condition_note") or [])
data_row = raw_rows[-1]
axis_values = _axis_values(data_row[0] if data_row else "")
if not axis_values:
return False
# 값 칸 — 세로축 값 개수만큼 숫자를 든 칸만 값으로 본다.
# ⚠ **꼬리의 설명 칸은 떼어 낸다** — 「비고」 열에 「H=2.6M 기준 용도: 사무실, 창고」
# 같은 글이 온다(11-1). 그 칸까지 값으로 세면 표 전체를 못 읽는다. 다만 **떼는 것은
# 꼬리뿐**이다 — 가운데가 값이 아니면 자리를 단정할 수 없으므로 통째로 버린다.
cells = [cell for cell in data_row[1:] if _clean(cell)]
while cells and not _is_value_cell(cells[-1], len(axis_values)):
cells.pop()
value_cells = cells
if len(value_cells) < 2 or not all(_is_value_cell(c, len(axis_values)) for c in value_cells):
return False
# 자원 이름 줄 — 값줄 바로 위. 빈 칸은 표 끝의 여백이라 버린다.
resource_row = raw_rows[-2]
names = _labelled_cells(resource_row, len(value_cells)) or []
if len(names) != len(value_cells):
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(_clean(c) for c in resource_row),
reason=(
f"축이 셋인 표인데 자원 이름 {len(names)} 개와 값 칸 {len(value_cells)} 개가 "
"맞지 않습니다 — 자리를 단정할 수 없어 한 줄도 세우지 않았습니다."
),
)
)
return True
# 묶음 줄 — 표 머리(condition_note)와 자원 줄 위의 raw_row 들. 위에서 아래 차례로 쌓는다.
group_rows: list[list[Any]] = []
if header:
group_rows.append(header)
group_rows.extend(raw_rows[: len(raw_rows) - 2])
# 가로축 이름 — 「길이 폭」의 앞 낱말. 열 라벨이 「3M」뿐이라 이름이 없으면
# 갈래가 「3M」으로만 남아 무엇의 3M 인지 안 보인다.
head_names = _split_axis_names(header[0] if header else "")
column_axis = head_names[0] if head_names else ""
spreads: list[list[str]] = []
for index, row in enumerate(group_rows):
prefix = column_axis if (index == 0 and header and row is header) else ""
spread = _group_labels(row, len(value_cells), prefix)
if spread is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(_clean(c) for c in row),
reason=(
"축이 셋인 표인데 묶음 이름이 열 개수로 딱 나뉘지 않습니다 — "
"짐작해 맞추지 않고 한 줄도 세우지 않았습니다."
),
)
)
return True
spreads.append(spread)
axis_name = _axis_name(raw_rows, header, len(raw_rows) - 2)
unit = table.get("basis_unit") or ""
form = str(table.get("pum_form", ""))
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
# ⚠ **자원 줄이 정말 자원 줄인지 먼저 본다.** 축이 넷인 표(10-6-3 기타 임업자재)는
# 값줄 바로 위가 **단위 줄**(「인/㎥」·「인/100속」)이라 자원 이름이 하나도 안 풀린다.
# 그런 표는 **내 표가 아니다** — 못 맞춤에 적지 않고 원래 길로 돌려보낸다.
resolved = [catalog.resolve(*split_name_and_spec(cell)) for cell in names]
if not any(entry is not None for entry in resolved):
return False
made = 0
for column, (name_cell, value_cell) in enumerate(zip(names, value_cells)):
entry = resolved[column]
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=name_cell,
reason="자원 이름을 카탈로그에서 못 찾았습니다 — 0 으로 때우지 않습니다.",
)
)
continue
labels = [spread[column] for spread in spreads]
for index, token in enumerate(_tokens(value_cell)):
if token in _ABSENT:
# 「-」 는 **그 규격이 없다는 뜻** — 0 으로 세우면 공짜 공종이 된다.
continue
amount = parse_amount(token)
if amount is None:
continue
variant = " · ".join([*labels, _axis_label(axis_name, axis_values[index])])
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=len(raw_rows) - 1,
variant=variant,
)
)
made += 1
return made > 0