fix(B09): 한 표에 밑수가 둘인 표를 밑수마다 갈라 세움 — 막돌 채집 3.8배 오류

- 품셈 13-2 채집 계열은 같은 품을 「㎡당 0.17 / ㎥당 0.64」 두 벌로 줌.
  행-자원으로 읽어 앞줄만 잡고 뒷줄을 버려, 막돌 채집이 ㎡당 값을 ㎥ 단위로
  달고 있었음 (29,251.5원/㎥ ← 실제 ㎥당은 110,123.5원).
- 밑수마다 따로 세우고 갈래 이름에 밑수를 넣음(#㎡당·#㎥당). 갈래의 단위가
  그 밑수라 내역서 단위 불일치 검사가 엉뚱한 밑수를 걸러 냄.
- 밑수 표기가 하나뿐인 표는 안 건드림 — 넓게 잡으면 멀쩡한 표가 쪼개짐.
- 열이 갈래인 표는 갈래 × 밑수로 폄(「45㎝·㎥당」).
- 이름을 못 풀면 표째 버림 — 앞줄만 서는 반쪽 단가를 만들지 않음.

⚠ 야면석 채집(13-2-4)은 아직 안 섬 — 직종을 「인 부」로만 적는데 그런 직종이
노임 카탈로그에 없음(품셈 전체에서 이 표 하나뿐). 보통인부로 단정하지 않고
사용자 확정 대기로 둠.

시험 5건 추가, 243건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 12:45:22 +09:00
co-authored by Claude Opus 5
parent c8cdf8ead2
commit 4e59afb0f2
2 changed files with 159 additions and 0 deletions
@@ -379,6 +379,14 @@ def match_table(
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
# 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다.
from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table
if match_unit_basis_table(node, table, catalog, result):
return
# ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표).
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
@@ -0,0 +1,151 @@
"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견).
품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다.
13-2-2 막돌 채집 (단위: ㎥당)
보통인부 | ㎡당 | 0.17
| ㎥당 | 0.64
13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60
인부 | ㎡당 0.11 0.17 0.22 0.28 0.36
| ㎥당 0.60 0.64 0.67 0.70 0.80
⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을
쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예
안 섰다. 둘 다 조용히 틀린 자리다.
**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의
**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**.
⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고,
넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다.
"""
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,
)
#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라
#: 밑수(basis_quantity) 자리라 건드리지 않는다.
_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$")
#: 밑수 표기 → 그 갈래가 갖는 단위.
_BASIS_UNIT = {"": "ha", "": "ton", "": "kg"}
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def _basis_of(cell: str) -> str | None:
"""그 칸이 밑수 표기면 단위, 아니면 `None`."""
found = _RE_BASIS.match(_tight(cell))
if not found:
return None
unit = found.group(1)
return _BASIS_UNIT.get(unit, unit)
def _column_labels(table: dict[str, Any]) -> list[str]:
"""열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록."""
headers = [_tight(c) for c in (table.get("condition_note") or [])]
labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None]
return labels
def match_unit_basis_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`."""
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if not rows:
return False
# 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다.
seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))}
if len(seen_basis) < 2:
return False
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
labels = _column_labels(table)
entry = None
made = 0
for index, cells in enumerate(rows):
basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None)
if basis_at is None:
continue
if basis_at > 0:
# 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다.
found = _resolve(catalog, cells[0])
if found is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0][:30],
reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다",
)
)
return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다
entry = found
if entry is None:
continue
unit = _basis_of(cells[basis_at])
values = [parse_amount(cell) for cell in cells[basis_at + 1 :]]
values = [v for v in values if v is not None]
if not values:
continue
for position, amount in enumerate(values):
if labels and position >= len(labels):
break # 비고 칸까지 값으로 읽지 않는다
label = labels[position] if labels else ""
# ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로
# 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다).
variant = f"{label}㎝·{unit}" if label else f"{unit}"
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=index,
variant=variant,
)
)
made += 1
return made > 0
def _resolve(catalog: ResourceCatalog, name_cell: str):
name, spec = split_name_and_spec(name_cell)
entry = catalog.resolve(name, spec)
if entry is not None:
return entry
if spec:
return None
found = catalog.by_name(name)
return found[0] if len(found) == 1 else None