Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_ThreeAxis.py
T
eomsangdonandClaude Opus 5 803c17163e fix(b09): 원문이 줄마다 풀린 표를 못 읽어 금액이 떨어지던 것 — 직접공사비 5,983,724 복귀
랩탑 서브·안티그래비티가 원문 md 의 뭉친 줄을 줄마다 푸는 중인데(2026-09-18), 그 모양을
읽는 길이 없어 **호표가 첫 갈래 값만 남거나 표째 사라졌다.** 값이 바뀐 것이 아니라
**적는 모양만 바뀐 것**이라, 풀린 줄을 도로 뭉쳐 같은 길로 읽게 했다.

- `_ThreeAxis._glue_rows` — 세로축 값이 줄마다 갈린 표(11-1 콘테이너 · 13-5-1 돌붙임)를
  한 줄로 되뭉침. ⚠ 칸 수가 다른 줄이 섞이거나 자원 이름 줄이 안 남으면 안 뭉침
- `_ThreeAxis` 는 **두 단 머리 표(13-4-1·13-4-4)에서 손을 뗌** — 그 표는 제 길이 따로 있고,
  여기서 가로채면 대·소분류가 열 수로 안 나뉘어 표째 막힘(갈래 80 이 사라졌었음)
- `_Transposed.match_packed_rows` — 뭉침이 없어도 **값 칸이 갈래 수만큼 따로 선 줄**이면
  같은 표로 읽음(돌쌓기 13-4-2·13-4-5 · 13-5-2)
- 산림 할증률 표 31 — 자료에 박아 둔 원문 줄 번호를 지금 자리로 다시 잡음(`coef_2026.json`).
  ⚠ 그 번호는 B09 가 표 아래 [주] 원문을 읽는 데도 쓰므로 밀린 채 두면 엉뚱한 글이 붙음
- 합판 전용률 대조는 **열 이름으로** 칸을 찾게 함 — 원문 앞에 빈 칸이 늘어도 안 깨짐
-  금액 — 4,932,972 로 떨어졌던 직접공사비가 **5,983,724** 로 복귀 · 합계 10,124,000
- 덤 — 6-7-1 교목 시비 갈래 다섯 · 13-10-2 나무 말뚝박기 두 표가 새로 섬(사라진 갈래 0)
- 못박은 수 갱신 — 산림 갈래 332 → 337 · 공종 축 620 → 624 · 건설 1,388 → 1,484 ·
  밑수 미확보 80 → 81 · 이름표 갈래 66 → 67 · 건설 갈래 195 → 207 · 열쇠 지문

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-18 10:44:58 +09:00

338 lines
16 KiB
Python
Raw 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 _value_row(row: list[Any]) -> bool:
"""세로축 값 하나 + 값 칸들로 된 **풀린 줄**인가(`| 25 | 0.15 | 0.12 | …`).
⚠ 꼬리의 설명 칸(「H=2.6M」)은 값이 아니어도 된다 — 그 자리는 뒤에서 떼어 낸다.
"""
cells = [_clean(cell) for cell in row if _clean(cell)]
if len(cells) < 3 or len(_tokens(cells[0])) != 1 or not re.match(r"^\d", cells[0]):
return False
numeric = [c for c in cells[1:] if _NUMBER.match(c) or c in _ABSENT]
return len(numeric) >= 2
def _glue_rows(raw_rows: list[list[Any]]) -> list[list[Any]]:
"""**줄마다 풀린 표를 한 줄로 도로 뭉친다** — 읽는 길은 하나로 둔다.
원문 md 가 손질되며 「25 30 35 …」로 뭉쳐 있던 값줄이 줄마다 갈렸다(2026-09-18).
값이 달라진 것이 아니라 **적는 모양만 바뀐 것**이라, 뭉친 꼴로 되돌려 같은 길로 읽는다.
⚠ 칸 수가 다른 줄이 섞이면 뭉치지 않는다 — 자리를 짐작해 맞추지 않는다.
"""
block: list[list[Any]] = []
for row in reversed(raw_rows):
if not _value_row(row) or (block and len(row) != len(block[0])):
break
block.append(row)
block.reverse()
# ⚠ 값줄 위에 **자원 이름 줄이 남아야** 한다 — 표가 통째로 값줄이면 뭉치지 않는다.
if len(block) < 2 or len(block) >= len(raw_rows):
return raw_rows
width = len(block[0])
glued = [" ".join(_clean(row[i]) for row in block if _clean(row[i])) for i in range(width)]
return raw_rows[: len(raw_rows) - len(block)] + [glued]
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
# ⚠ **두 단 머리 표(13-4-1·13-4-4)는 제 길이 따로 있다** — 여기서 뭉쳐 읽으면
# 대·소분류가 열 수로 안 나뉘어 표째 막히고, 그 길이 못 돌아 갈래 80 이 사라진다.
from B09_Estimation.B09_Estimation_ResourceAxis_TwoLevelHeader import TWO_LEVEL_HEADER
if str(node.get("work_item_code") or "") in TWO_LEVEL_HEADER:
return False
raw_rows = _glue_rows(raw_rows)
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