feat(b09): 규격이 열로 선 표(관부설 12-11) — 규격 칸·원문 비고를 읽어 줄은 서고 못 넣는 줄은 까닭

- 원문 비고 「별산」·「별도계산」·「설계수량」(〃·병합 칸 포함)·「필요시적용」은 안 넣는 줄로 까닭을 남김
- 규격 칸(1:2)과 관경 열(∅800mm)을 조인 키로 — 접합몰탈·고무링이 자재 줄로 섬(단가 층은 아직 없음)
- 크레인은 기종이 여럿이라 고르지 않음 — 「규격 미정 · 원문 규격 10ton/5ton」(브레인 판정 대기)
- 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64(기계 운전 자료가 없어 층은 못 섬 — 사유로 드러남)
- 카탈로그 밖 자원 목록에 고무링 ∅800/1000/1200mm · 지수활제 보탬(원문 F0346 · 단가 없음)
- 범위 81공종 상태 전후 같음(못 붙은 줄의 까닭만 바뀜) · 시험 4건 · 전체 1749 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 08:03:12 +09:00
co-authored by Claude Opus 5
parent 808cf0f289
commit f3630ab1e7
4 changed files with 173 additions and 17 deletions
@@ -269,34 +269,75 @@ def match_spec_column_table(
if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs):
return False
from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason
unit = table.get("basis_unit") or ""
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
made = 0
previous_note = ""
def unmatched(cell: str, reason: str) -> None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason
)
)
for index, row in enumerate(rows[1:], start=1):
cells = [_clean(cell) for cell in row]
if not cells:
if not cells or not cells[0]:
continue
name, spec_text = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "")
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.",
)
# 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리)
values = [
value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None
]
note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else ""
note = previous_note if note == "〃" else note
spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else ""
if not values and not note and previous_note.replace(" ", "") == "설계수량":
note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집)
previous_note = note
tight_note = note.replace(" ", "")
# ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김.
if tight_note in ("별산", "별도계산"):
unmatched(
cells[0], f"「{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음"
)
continue
values = [_cell_amount(cell) for cell in cells[1:]]
values = [value for value in values if value is not None]
if len(values) < len(specs):
# 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다.
if tight_note == "설계수량":
unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음")
continue
for spec_name, amount in zip(specs, values[-len(specs) :]):
if len(values) < len(specs):
why = f"「{note}」 — " if note else ""
unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음")
continue
name, inline_spec = split_name_and_spec(cells[0])
row_specs = [text for text in (spec_cell, inline_spec) if text]
entries: list[CatalogEntry] = []
for spec_name in specs:
entry = (
next(
(
found
for text in (*row_specs, spec_name)
if (found := catalog.resolve(name, text)) is not None
),
None,
)
or catalog.resolve(name, "")
or resolve_family(catalog, cells[0], cells)
)
if entry is None:
break
entries.append(entry)
if len(entries) < len(specs):
origin = f" · 원문 규격 {spec_cell}" if spec_cell else ""
unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)")
continue
for spec_name, amount, entry in zip(specs, values[-len(specs) :], entries):
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
@@ -168,7 +168,8 @@ def spec_candidates(cells: list[str]) -> list[str]:
if spec:
found.append(spec)
continue
token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³"))
# 「40.64㎝」(절단기 12-11-2) — 카탈로그는 수만 적음(7620-0003 절단기 40.64).
token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³㎝"))
if token:
found.append(token.group(0))
return found