Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py
T
eomsangdonandClaude Opus 5 15bdf6bd64 feat(B09): 뭉친 이름+갈래 열 표 읽기 — 돌쌓기 계열 성립
메인이 `reference` 오판 41건을 되살려(bc4b6837) 돌쌓기(장비)가 마스터에
들어왔으나, 그 표는 **이름도 값도 뭉쳐 오고 열이 규격 갈래**인 또 다른 모양이라
여전히 안 섰음 (「석공 보통인부 | 0.09 0.05 | 0.08 0.04 | 0.07 0.03」)

- 뭉친 이름·값을 갈래 열에 짝지어 읽음. 개수가 어긋나면 표째 버림
- 「굴착기+부착용 집게」는 **두 기종 조합**으로 보아 같은 시간을 둘 다 붙임
  (TODO(미결) 해석 잠정 — 사용자 확인 대기)
- 규격이 **옆 칸**에 있고 갈래(무한궤도)가 안 적힌 기종은 규격 일치 + 무한궤도
  우선으로 잠정 채택. 카탈로그 규격이 범위(0.6∼0.8)면 그 안에 드는지로 판정
- ⚠ 「1.04(1.17)」 괄호값은 **조건 시공 시 대안값**임이 원문에서 확인됨
  (13-6-1 [주]② 흡출방지재 시공 시 ( ) 값). 기본은 괄호 밖, 대안값은
  `alternative_amount` 로 남겨 화면에 「시공 시 다름」으로 보이게 함
- 라벨 줄 판정을 **모든 칸**으로 — 첫 칸만 보면 기초잡석 12-25 를 라벨 줄로
  오해해 표째 가로챘음(그 탓에 기초잡석이 한때 다시 막혔음)
- 갈래 키를 **공백 없는 것**으로 통일(`#보통`), 원문 문구는 이름에 보존
  (두 창 합의)

결과: 자원 축 259 → 354, 일위대가 145 → 176
  돌쌓기(찰) 35cm이하 58,166.7 / 55cm이하 52,938.7 / 75cm이하 46,892.7 원/㎡
  메쌓기·찰쌓기·붙이기 계열도 직경 3갈래로 섬

검증: pytest 184 통과(신규 1). 화면 실측 — 지금 인계 자료에 구조물 줄이 없어
표에는 아직 안 뜸(B08 이 갈래를 골라 보내면 그대로 금액이 섬)

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

572 lines
25 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-08).
품셈 표에는 자원이 **행**이 아니라 **열 머리**에 오는 모양이 따로 있다 (39 표).
구 분 | 콘크리트공(인) | 보통인부(인)
무근구조물 | 0.12 | 0.15
철근구조물 | 0.14 | 0.16
행은 **규격 갈래**(무근·철근·소형구조물)이고 갈래마다 품이 다르다. 이 모양을 행-자원
표로 읽으면 통째로 안 맞는다 — 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다.
⚠ **자리 밀림을 고쳐 읽지 않는다.** 첫 칸이 병합된 표는 값이 한 칸씩 밀려 오는데
(목재틀흙막이가 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다),
밀린 행은 **버리고 `unmatched` 에 남긴다.** 어느 칸이 어느 자원인지 단정할 수 없다.
`B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 이 표 모양만 떼어 낸 파일이다.
"""
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,
)
#: 갈래 이름 자리에서 걸러 낼 말 — **합계 줄만**이다. 넓게 잡으면 등급이 지워진다.
_TOTAL_LABELS = ("계", "합계", "소계", "총계", "구분")
def _normalize_label(text: str) -> str:
return "".join(str(text).split())
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
_SUM_GROUP_LABELS = ("계", "합계", "소계", "총계")
def _sum_group_positions(headers: list, resource_count: int) -> set:
"""「계」 묶음이 차지하는 열 번호. 2단 머리에서 묶음 하나가 여러 열을 먹는다.
첫 줄이 `구조별 | 가공 | 조립 | 계` 이고 둘째 줄이 `철근공 | 보통인부` × 3 벌이면
묶음 하나가 **2열씩** 차지한다. 「계」 묶음의 열은 통째로 뺀다.
"""
groups = [str(h).strip() for h in headers[1:]]
if not groups or resource_count % len(groups) != 0:
return set()
per_group = resource_count // len(groups)
blocked = set()
for index, group in enumerate(groups):
if "".join(group.split()) in _SUM_GROUP_LABELS:
start = index * per_group
blocked.update(range(start, start + per_group))
return blocked
def second_row_columns(table: dict, catalog: ResourceCatalog):
"""**첫 자료 행이 진짜 열 머리**인 2단 표를 읽는다.
`condition_note` 가 공정(「가공·조립·계」)뿐이고 자원 이름이 그 아래 줄에 오는 표다
(철근 현장가공 및 조립 12-3). 자원을 못 찾으면 빈 목록을 돌려준다.
"""
rows = table.get("raw_row") or []
if not rows:
return [], 0
# ⚠ **첫 줄 머리가 되풀이되면 좌우 두 판짜리 표다** — 2단이라도 마찬가지다
# (뿌리돌림 05-2: `근원직경(㎝) | 수 량 | 근원직경(㎝) | 수 량`).
# 이걸 안 가르면 오른쪽 판의 **직경 100 이 「특별인부 100인」**으로 읽혀
# 단가가 2,436만원으로 선다(2026-09-08 실측). 공정 머리(가공·조립·계)는
# 되풀이가 없으므로 이 검사에 안 걸린다.
groups = ["".join(str(h).split()) for h in (table.get("condition_note") or [])[1:]]
if len(groups) != len(set(groups)):
return [], 0
header_row = [str(c).strip() for c in rows[0]]
found = []
for position, cell in enumerate(header_row):
if not cell:
continue
name, spec = split_name_and_spec(cell)
entry = catalog.resolve(name, spec)
if entry is None and not spec:
candidates = catalog.by_name(name)
entry = candidates[0] if len(candidates) == 1 else None
if entry is not None:
found.append((position, entry))
if len(found) < 2:
return [], 0
# ⚠ **머리 줄의 이름을 하나라도 못 풀면 자리를 맞출 수 없다.** 드론방제 08-6-2 는
# 「드론조종자·부조종자」가 카탈로그에 없어 넷 중 둘만 풀리는데, 그대로 두면
# 숫자 두 개짜리 행이 **엉뚱한 직종 둘**에 붙는다. 통째로 버린다.
named = [cell for cell in header_row if cell]
if len(found) != len(named):
return [], 0
# ⚠ 「계」 묶음은 뺀다 — 가공 + 조립을 이미 더한 값이라 같이 읽으면 두 번 센다.
# ⚠ **자리를 고정 보정으로 맞추지 않는다.** 라벨 칸이 하나인 표도 둘인 표도 있어
# (드론방제 08-6-2 는 `구 분 | 항 목` 둘) 「한 칸 밀림」으로 단정하면 값이 어긋난다
# — 실측에서 「특별인부 0.2352」 자리에 다른 직종 값이 붙었다.
# 대신 **자료 행의 숫자 칸을 순서대로** 맞추고, 개수가 다르면 그 행을 버린다.
blocked = _sum_group_positions(table.get("condition_note") or [], len(found))
return [(order, entry, order in blocked) for order, (_, entry) in enumerate(found)], 1
def transposed_columns(table: dict[str, Any], catalog: ResourceCatalog) -> list[tuple[int, Any]]:
"""열 머리에서 자원을 찾는다. `[(열 번호, 카탈로그 줄)]`.
첫 칸은 갈래 이름(「구 분」)이라 **1번 열부터** 본다. 카탈로그에 있는 이름만
자원으로 본다 — 필터로 거르지 않는다(넓은 필터가 정상 자원을 지운 전례).
"""
headers = table.get("condition_note") or []
found: list[tuple[int, Any]] = []
for position, header in enumerate(headers[1:], start=1):
name, spec = split_name_and_spec(str(header))
entry = catalog.resolve(name, spec)
if entry is None and not spec:
candidates = catalog.by_name(name)
entry = candidates[0] if len(candidates) == 1 else None
if entry is not None:
found.append((position, entry))
return found
def _match_two_row_table(
node: dict,
table: dict,
catalog: ResourceCatalog,
result: AxisResult,
unit: str,
ordinal: list,
skip_rows: int,
) -> bool:
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
개수가 다른 행은 **버린다.** 라벨 칸 수가 표마다 달라(하나 또는 둘) 자리를
단정할 수 없기 때문이다. 「계」 묶음에 든 자원은 맞춘 뒤 뺀다 —
가공 + 조립을 이미 더한 값이라 같이 세면 두 번이다.
"""
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
rows = (table.get("raw_row") or [])[skip_rows:]
labels = [str(row[0]).strip() for row in rows if row]
repeated = {label for label in labels if label and labels.count(label) > 1}
matched = False
for index, row in enumerate(rows):
cells = [str(c).strip() for c in row]
if not cells:
continue
variant = cells[0]
if not variant or _normalize_label(variant) in _TOTAL_LABELS or variant in repeated:
continue
numbers = [parse_amount(c) for c in cells[1:]]
numbers = [value for value in numbers if value is not None]
if len(numbers) != len(ordinal):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason=(
f"숫자 칸 {len(numbers)} 개가 자원 열 {len(ordinal)} 개와 안 맞아 "
"버렸습니다(자리 밀림 방지)."
),
)
)
continue
for (order, entry, blocked), amount in zip(ordinal, numbers):
if blocked:
continue # 「계」 묶음 — 이미 더한 값이다
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 + skip_rows,
variant=variant,
)
)
matched = True
return matched
#: 「석공 보통인부」처럼 **여러 자원이 한 칸에** 뭉쳐 오고, 값도 「0.09 0.05」로 뭉쳐 오며,
#: 열이 규격 갈래(35cm 이하 · 55cm 이하 · 75cm 이하)인 표. 돌쌓기 13-4 계열이 그 모양이다.
#:
#: ⚠ **개수가 하나라도 안 맞으면 표째 버린다** — 이름 2 개에 값 3 개면 어느 값이 누구
#: 것인지 단정할 수 없다. 오늘 여러 번 겪은 자리다.
_RE_PACKED_NUMBER = re.compile(r"\d+(?:\.\d+)?")
#: 「1.04(1.17)」의 괄호 값 — **조건 시공 시 대안값**이라 기본 수에 안 센다
#: (품셈 13-6-1 [주]②). 안 떼면 값이 하나 더 있는 것으로 보여 표가 통째로 버려진다.
_RE_ALTERNATIVE_TAIL = re.compile(r"(?<=\d)\s*[(]\s*\d+(?:\.\d+)?\s*[)]")
def _packed_numbers(cell: str) -> list:
text = _RE_ALTERNATIVE_TAIL.sub("", str(cell))
return [Decimal(t) for t in _RE_PACKED_NUMBER.findall(text)]
def _packed_names(cell: str) -> list[str]:
"""한 칸에 뭉친 이름들. 「굴착기+부착용 집게」는 **조합 기종**이라 통째로 둔다."""
text = " ".join(str(cell).split())
if "+" in text:
return [text]
return [part for part in text.split(" ") if part]
def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None = None) -> list:
"""이름 하나를 카탈로그 줄들로 푼다.
「굴착기+부착용 집게」처럼 **두 기종을 함께 쓰는 조합**은 둘 다 돌려준다 —
같은 시간을 둘이 함께 쓰므로 사용료도 둘 다 붙는다.
⚠ TODO(미결) 조합 표기의 해석은 잠정이다 — 사용자·메인 확인 대기(PLAN 9-6).
"""
parts = [part.strip() for part in str(name).split("+") if part.strip()]
found = []
for part in parts:
base, spec = split_name_and_spec(part)
entry = catalog.resolve(base, spec)
if entry is None and not spec:
candidates = catalog.by_name(base)
entry = candidates[0] if len(candidates) == 1 else None
if entry is None:
entry = _resolve_with_side_spec(catalog, base, specs or [])
if entry is None:
return []
found.append(entry)
return found
#: 갈래(무한궤도/타이어)를 안 적은 기종을 고를 때의 **잠정** 우선순위.
#: 품셈 13-4 는 「굴착기+부착용 집게 | 0.6㎥」로만 적는데 카탈로그는 갈래까지 나뉜다.
#: ⚠ TODO(미결) 임도 현장 표준이 무한궤도라 그쪽을 잠정 채택 — 사용자 확정 대기(PLAN 9-6).
_TRACK_PREFERENCE = ("무한궤도",)
_RE_SPEC_RANGE = re.compile(r"^(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)$")
def _spec_matches(catalog_spec: str, wanted: str) -> bool:
"""카탈로그 규격이 표의 규격을 담는가.
카탈로그가 **범위**로 적는 경우가 있다 — 「부착용 집게 0.6∼0.8」은 0.6㎥ 를 담는다.
범위 밖이면 안 고른다.
"""
if catalog_spec == wanted or wanted.startswith(catalog_spec):
return True
found = _RE_SPEC_RANGE.match(catalog_spec)
if not found:
return False
numbers = _RE_PACKED_NUMBER.findall(wanted)
if not numbers:
return False
value = Decimal(numbers[0])
return Decimal(found.group(1)) <= value <= Decimal(found.group(2))
def _resolve_with_side_spec(catalog: ResourceCatalog, name: str, specs: list[str]):
"""이름에 갈래가 없고 규격이 **옆 칸**에 있는 기종을 고른다.
이름이 카탈로그 이름의 앞머리이고 규격이 **정확히 같을 때만** 고른다 —
규격이 다르면 안 고른다(엉뚱한 기종이 붙으면 사용료가 통째로 틀린다).
"""
if not name or not specs:
return None
wanted = {"".join(str(s).split()) for s in specs if str(s).strip()}
hits = []
for entry in catalog.entries:
if not entry.name.startswith(name):
continue
spec = "".join(str(entry.spec).split())
if spec and any(_spec_matches(spec, w) for w in wanted):
hits.append(entry)
if not hits:
return None
if len(hits) == 1:
return hits[0]
for word in _TRACK_PREFERENCE:
preferred = [e for e in hits if word in e.name]
if len(preferred) == 1:
return preferred[0]
return None
def match_packed_rows(
node: dict,
table: dict,
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity,
unit: str,
) -> bool:
"""뭉친 이름 + 갈래 열 표를 읽는다. 그런 표가 아니면 `False`."""
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if len(rows) < 2:
return False
labels = [cell for cell in rows[0] if cell]
# ⚠ 라벨에 수가 들어 있을 수 있다 — 「35cm 이하」. 수의 유무로 가르면 안 된다.
# 자원으로 **안 풀리는** 줄이면 갈래 라벨 줄로 본다.
if not labels:
return False
# ⚠ **첫 줄의 어느 칸이라도 자원이면 라벨 줄이 아니다.** 첫 칸만 보면 기초잡석
# 12-25 처럼 「소할(30%) | 할석공(인) | 0.2 × 30%」인 표를 라벨 줄로 오해해
# 표째 가로챈다(2026-09-08: 그 탓에 기초잡석이 다시 막혔다).
for cell in rows[0]:
if not cell:
continue
if any(_resolve_packed(catalog, name, []) for name in _packed_names(cell)):
return False
# ⚠ **뭉친 표에만 쓴다.** 이 길이 넓으면 행-자원 표까지 가로채 자원 축이 줄어든다
# (2026-09-08 실측: 304 → 244 줄로 떨어졌다). 이름이 둘 이상 뭉쳤거나 값이 한 칸에
# 둘 이상 뭉친 줄이 **하나라도** 있어야 이 표로 본다.
packed = False
for cells in rows[1:]:
if not cells or not cells[0]:
continue
if len(_packed_names(cells[0])) > 1:
packed = True
break
if any(len(_packed_numbers(cell)) > 1 for cell in cells[1:]):
packed = True
break
if not packed:
return False
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
staged: list = []
for index, cells in enumerate(rows[1:], start=1):
if not cells or not cells[0]:
continue
names = _packed_names(cells[0])
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
side_specs = [cell for cell in cells[1:3] if cell]
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
if not all(resolved):
if _packed_numbers(" ".join(cells[1:])):
# 수가 있는데 이름을 못 풀었다 — 그 몫이 빠진 채 서면 안 된다.
result.partial_items[work_item_code] = f"{cells[0][:20]} 줄을 못 풀었습니다"
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.",
)
)
continue
groups = [_packed_numbers(cell) for cell in cells[1:]]
groups = [group for group in groups if group]
# 앞쪽에 규격·단위 칸이 낄 수 있다 — 「0.6㎥ | 시간 | 0.31 | 0.30 | 0.28」.
# 갈래 수만큼 **뒤에서** 잘라 쓴다.
if len(groups) > len(labels):
groups = groups[-len(labels) :]
if len(groups) != len(labels) or any(len(g) != len(names) for g in groups):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason=(
f"값 묶음 {len(groups)} 개가 갈래 {len(labels)} 개와 안 맞습니다 "
"— 자리를 단정할 수 없어 버렸습니다."
),
)
)
return True
for label, group in zip(labels, groups):
for entries, amount in zip(resolved, group):
value = amount
if basis_quantity not in (None, 0, Decimal(1)):
value = value / basis_quantity
for entry in entries:
staged.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=value,
amount_unit=unit,
raw_row_index=index,
variant=label,
)
)
if not staged:
return False
result.rows.extend(staged)
return True
def match_transposed_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity: Decimal | None,
unit: str,
) -> bool:
"""열이 자원인 표를 읽는다. 그런 표가 아니면 `False` 를 돌려 원래 길로 보낸다.
행마다 **규격 갈래 하나**가 되므로 `variant` 를 달아 둔다 — 「무근구조물」과
「철근구조물」은 품이 달라 **한 일위대가로 뭉치면 안 된다**.
"""
columns = transposed_columns(table, catalog)
skip_rows = 0
ordinal: list = []
if not columns:
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
ordinal, skip_rows = second_row_columns(table, catalog)
if ordinal:
return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows)
if not columns:
return False
# ⚠ **좌우로 두 판이 붙은 표는 통째로 버린다.** 열 머리가 되풀이되면
# (「거리 | 보통인부 | 거리 | 보통인부」) 오른쪽 판의 **거리값이 인원으로** 읽힌다
# — 2026-09-08 실측: 소운반이 「보통인부 60인」이 되어 단가가 1,553만원으로 섰다.
# 판 경계를 짐작해 읽지 않는다.
# ⚠ 2단 표에서는 **같은 직종이 공정마다 되풀이되는 것이 정상**이다
# (철근 12-3: 가공 철근공 + 조립 철근공 = 합쳐야 맞는 값). 되풀이 금지는
# **1단 표에만** 건다 — 거기서만 「두 판이 좌우로 붙은 표」를 뜻한다.
codes = [entry.code for _, entry in columns]
if skip_rows == 0 and len(codes) != len(set(codes)):
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(str(c) for c in (table.get("condition_note") or [])),
reason="열 머리가 되풀이되는 두 판 짜리 표 — 자리를 단정할 수 없어 버렸습니다.",
)
)
return True
# ⚠ **갈래 이름이 되풀이되면 그 줄들을 버린다.** 첫 칸이 병합된 표에서 상위 등급이
# 떨어져 나가면 「상」이 두 번 나오고, 그대로 두면 서로 다른 등급의 품이 **합산**된다
# (2026-09-08 실측: 목재틀흙막이 「상」이 8.760 + 13.767 = 22.5 인이 되어 단가가
# 667만원으로 섰다). 어느 등급인지 단정할 수 없으므로 고쳐 읽지 않는다.
labels = [str(row[0]).strip() for row in (table.get("raw_row") or [])[skip_rows:] if row]
repeated = {label for label in labels if label and labels.count(label) > 1}
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
matched_any = False
for index, row in enumerate(table.get("raw_row", [])):
if index < skip_rows:
continue # 그 줄은 자료가 아니라 **열 머리**다
cells = [str(c) for c in row]
if not cells:
continue
variant = cells[0].strip()
# ⚠ 첫 칸은 **갈래 이름**이지 자원 이름이 아니다 — 자원용 머리글 필터를 여기 쓰면
# 「중」·「상」 같은 정상 등급이 통째로 지워진다(2026-09-08 실측: 목재틀흙막이의
# 중·상 등급이 사라지고 상등구조만 남았다). 합계 줄만 걸러 낸다.
if not variant or _normalize_label(variant) in _TOTAL_LABELS:
continue
if variant in repeated:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason="같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.",
)
)
continue
# ⚠ **자리 밀림 검사** — 자원 열 가운데 하나라도 수가 아니면 그 행은 밀린 것이다.
# 첫 칸이 병합된 표에서 값이 한 칸씩 밀려 들어온다(2026-09-08 실측: 목재틀흙막이가
# 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다).
# **밀린 행은 고쳐 읽지 않고 버린다** — 어느 칸이 어느 자원인지 단정할 수 없다.
# ⚠ **숫자 칸이 자원 열보다 많으면 차원이 하나 더 있는 표다.**
# 뭉기기 13-12-1 은 행이 공정, 숫자 칸이 토질 3갈래인데 열 머리엔 자원이 하나뿐이라
# 그대로 두면 **첫 토질 값만 조용히 취한다**(보통토사 0.16 만 서고 나머지가 사라짐).
numeric_count = sum(1 for cell in cells[1:] if parse_amount(cell) is not None)
if numeric_count > len(columns):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason=(
f"숫자 칸 {numeric_count} 개가 자원 열 {len(columns)} 개보다 많습니다 — "
"갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다."
),
)
)
continue
readable = [
parse_amount(cells[position]) if position < len(cells) else None
for position, _ in columns
]
if any(value is None for value in readable) and any(
value is not None for value in readable
):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason="자원 열의 값이 한 칸 밀린 행 — 자리를 단정할 수 없어 버렸습니다.",
)
)
continue
for position, entry in columns:
if position >= len(cells):
# 칸이 모자란 행 — **자리를 밀어 읽지 않는다**. 밀려 읽으면 다른 직종의
# 품이 붙는다(기계경비 표에서 실제로 겪은 사고).
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=f"{variant} / {entry.name}",
reason="칸 수가 열 머리와 안 맞아 버렸습니다(자리 밀림 방지).",
)
)
continue
amount = parse_amount(cells[position])
if amount is None:
continue
if basis_quantity not in (None, 0, Decimal(1)):
amount = amount / basis_quantity
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,
)
)
matched_any = True
return matched_any