· _match_two_row_table 이 표 밑수로 아예 안 나눠 제목 단위 ha 에 160ha·400ha 몫이 그대로 앉아 있었음 · 표 밑수가 한 벌뿐이라 갈래마다 다른 밑수(소형 160ha · 대형 400ha)도 못 가르던 병 → 갈래 이름이 제 밑수를 적으면 그것으로, 없으면 표 밑수로 · 그 길을 지나는 표 다섯 중 밑수가 1 이 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수가 다른 표도 8-6-1 하나 8-6-1 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951 · 나머지 제목 그대로 · 잴 시험 빨강→초록 · 끄기 빨강 · 전체 시험 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
642 lines
30 KiB
Python
642 lines
30 KiB
Python
"""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 (
|
||
RANGE_DASH_CLASS,
|
||
AxisResult,
|
||
ResourceCatalog,
|
||
ResourceRow,
|
||
UnmatchedRow,
|
||
_tidy_resource_name,
|
||
is_non_resource_label,
|
||
parse_amount,
|
||
parse_machine_cell,
|
||
split_name_and_spec,
|
||
)
|
||
|
||
#: 갈래 이름 자리에서 걸러 낼 말 — **합계 줄만**이다. 넓게 잡으면 등급이 지워진다.
|
||
_TOTAL_LABELS = ("계", "합계", "소계", "총계", "구분")
|
||
|
||
|
||
def _normalize_label(text: str) -> str:
|
||
return "".join(str(text).split())
|
||
|
||
|
||
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
|
||
_SUM_GROUP_LABELS = ("계", "합계", "소계", "총계")
|
||
#: 갈래 이름에 적힌 제 밑수 — 「대형헬기 (400ha당)」.
|
||
_RE_VARIANT_BASIS = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*(?:ha|㏊|㎡|㎥|m|본|개소)\s*당\s*\)")
|
||
|
||
|
||
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,
|
||
basis_quantity: Decimal | None = None,
|
||
) -> 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
|
||
# ⚠ 밑수로 나눔 — 갈래 이름이 「(400ha당)」 처럼 제 밑수를 적으면 그것으로(8-6-1 유인헬기가
|
||
# 160배·400배 부풀어 있던 자리 · 2026-09-15). 표 밑수가 한 벌뿐이라 갈래 밑수를 못 가르던 병.
|
||
found = _RE_VARIANT_BASIS.search(variant)
|
||
divisor = Decimal(found.group(1).replace(",", "")) if found else basis_quantity
|
||
for (order, entry, blocked), amount in zip(ordinal, numbers):
|
||
if blocked:
|
||
continue # 「계」 묶음 — 이미 더한 값이다
|
||
if divisor not in (None, 0, Decimal(1)):
|
||
amount = amount / divisor
|
||
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*[))]")
|
||
#: 「그 갈래엔 없음」 표시 칸.
|
||
_ABSENT_MARKS = frozenset({"-", "–", "·", "ㆍ", "-"})
|
||
|
||
|
||
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:
|
||
# ⚠ 행 읽기 길과 **같은 표기 맞추기**를 먼저 한다 — 이 길만 「굴 삭 기 (무한궤도)」를
|
||
# 「굴착기(무한궤도)」로 못 바꿔 큰돌쌓기 메쌓기(13-6-1) 장비 몫이 막혀 있었다(09-13).
|
||
# ⚠ **기종 이름이 실제로 바뀌고 형식(괄호)을 적었을 때만** 쓴다 — 공백만 지우면
|
||
# 「부착용 집게」가 카탈로그 앞머리 비교에서 빠져 돌쌓기(장비) 넷이 막혔고(같은 날 대조로
|
||
# 잡음), 형식 없는 「굴 삭 기」까지 바꾸면 무한궤도 **잠정** 우선순위로 새 단가가 선다
|
||
# (판정 Ⓒ 「형식이 둘이면 규격 미정」과 어긋남).
|
||
tidied = _tidy_resource_name(part)
|
||
if _normalize_label(tidied) != _normalize_label(part) and "(" in tidied:
|
||
part = tidied
|
||
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:
|
||
# 갈래를 이름에 품은 기종(「굴착기(무한궤도)」)은 **그 이름째** 옆 칸 규격으로 먼저 —
|
||
# 앞머리 「굴착기」로만 보면 무한궤도·타이어가 둘 다 걸려 잠정 우선순위에 기댄다.
|
||
machine_name, _ = parse_machine_cell(part)
|
||
if machine_name != base:
|
||
entry = _resolve_with_side_spec(catalog, machine_name, specs or [])
|
||
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(rf"^(\d+(?:\.\d+)?)[{RANGE_DASH_CLASS}](\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()}
|
||
# ⚠ 앞머리는 **공백을 지우고** 견준다 — 품셈은 「부착용집게」, 카탈로그는 「부착용 집게」라
|
||
# 원문 그대로 보면 단목베기 4-2-2 조합 줄이 못 풀려 공종이 막혔다(2026-09-13).
|
||
head = _normalize_label(name)
|
||
hits = []
|
||
for entry in catalog.entries:
|
||
if not _normalize_label(entry.name).startswith(head):
|
||
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
|
||
# ⚠ **라벨 줄에는 순수 숫자 칸이 없다.** 「35cm 이하」는 수를 품되 순수 수가 아니고,
|
||
# 「자재 | 종 자 | | kg | 0.025」는 순수 수(0.025)가 있는 **자료 줄**이다.
|
||
# 이 구분을 안 두면 씨앗뿜어붙이기 표를 라벨 줄로 오해해 표째 가로챈다(2026-09-08 회귀).
|
||
if any(parse_amount(cell) is not None for cell in rows[0]):
|
||
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
|
||
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
|
||
# 갈래마다 같은 값이 되풀이되므로 **첫 값 칸**만 본다. 「9(9) 3(3)」 = 윗단 9 · 아랫단 3.
|
||
if "제잡비" in _normalize_label(cells[0]).replace(" ", ""):
|
||
for cell in cells[1:]:
|
||
numbers = _packed_numbers(cell)
|
||
if numbers:
|
||
upper = numbers[0]
|
||
lower = numbers[1] if len(numbers) > 1 else numbers[0]
|
||
result.overhead_ratio[work_item_code] = (upper, lower)
|
||
break
|
||
continue
|
||
|
||
# 비고·합계 줄은 자원이 아니다 — 문장 속 수(「100m」·「30%」)를 값으로 보고 「못 풀었다」며
|
||
# 공종을 막고 있었다(단목베기 4-2-2 「비고」, 2026-09-13). 행 읽기 길과 같은 거름을 쓴다.
|
||
if is_non_resource_label(cells[0]):
|
||
continue
|
||
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
|
||
side_specs = [cell for cell in cells[1:3] if cell]
|
||
# ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에
|
||
# 공백이 있으면 쪼개서 보다가 통째로 못 푼다(2026-09-08: 찰쌓기 13-6-2 의
|
||
# 장비 몫이 그래서 빠지고 공종이 막혔다).
|
||
whole = _resolve_packed(catalog, _normalize_label(cells[0]), side_specs)
|
||
if whole:
|
||
names, resolved = [_normalize_label(cells[0])], [whole]
|
||
else:
|
||
names = _packed_names(cells[0])
|
||
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
|
||
|
||
# ⚠ 「-」·「·」 칸은 **그 갈래에 품이 없다**는 표시다 — 빈칸처럼 버리면 갈래 수가 모자라
|
||
# 표째 버려진다(산림복원용 흙막이 13-13-2 「특별인부 0.014 | -」). 자리만 지키고 안 셈.
|
||
groups = [
|
||
_packed_numbers(cell) or ([None] * len(names) if cell in _ABSENT_MARKS else [])
|
||
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):
|
||
if amount is None:
|
||
continue # 「-」 — 그 갈래에는 이 자원이 안 든다
|
||
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, basis_quantity
|
||
)
|
||
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
|