랩탑 서브·안티그래비티가 원문 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
338 lines
16 KiB
Python
338 lines
16 KiB
Python
"""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
|