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>
This commit is contained in:
2026-09-08 02:04:42 +09:00
co-authored by Claude Opus 5
parent 17e24e2789
commit 15bdf6bd64
5 changed files with 6912 additions and 784 deletions
+39 -3
View File
@@ -350,6 +350,24 @@ def parse_amount_expression(cell: str) -> Decimal | None:
return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100) return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100)
#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값.
#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」
#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]).
#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기).
#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다.
_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[(](\d+(?:\.\d+)?)[)]$")
def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None:
"""「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`."""
text = _normalize(cell)
found = _RE_ALTERNATIVE.match(text)
if found:
return Decimal(found.group(1)), Decimal(found.group(2))
plain = parse_amount(text)
return None if plain is None else (plain, None)
def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal: def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal:
"""표 형태에 맞춰 소요량으로 환산한다. """표 형태에 맞춰 소요량으로 환산한다.
@@ -383,6 +401,9 @@ class ResourceRow:
amount: Decimal amount: Decimal
amount_unit: str amount_unit: str
raw_row_index: int raw_row_index: int
#: 조건 시공 시 쓰는 대안값 — 「1.04(1.17)」의 1.17 (품셈 13-6-1 [주]②).
#: **기본값은 `amount`(괄호 밖)** 이고 이 값은 화면에 「시공 시 다름」으로 보인다.
alternative_amount: Decimal | None = None
#: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열. #: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열.
#: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.** #: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.**
variant: str = "" variant: str = ""
@@ -404,6 +425,9 @@ class ResourceRow:
"amount_unit": self.amount_unit, "amount_unit": self.amount_unit,
"raw_row_index": self.raw_row_index, "raw_row_index": self.raw_row_index,
"variant": self.variant, "variant": self.variant,
"alternative_amount": (
None if self.alternative_amount is None else str(self.alternative_amount)
),
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct), "group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
} }
@@ -523,6 +547,13 @@ def match_table(
if match_transposed_table(node, table, catalog, result, basis_quantity, unit): if match_transposed_table(node, table, catalog, result, basis_quantity, unit):
return return
# 「석공 보통인부 | 0.09 0.05 | …」처럼 **이름도 값도 뭉쳐 오고 열이 갈래**인 표
# (돌쌓기 13-4 계열). 행-자원으로는 첫 이름조차 안 풀린다.
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_packed_rows
if match_packed_rows(node, table, catalog, result, basis_quantity, unit):
return
for index, row in enumerate(table.get("raw_row", [])): for index, row in enumerate(table.get("raw_row", [])):
cells = [str(c) for c in row] cells = [str(c) for c in row]
if not cells: if not cells:
@@ -556,9 +587,13 @@ def match_table(
continue continue
# 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다. # 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다.
amount_cell = next( alternative: Decimal | None = None
(parse_amount(c) for c in value_cells if parse_amount(c) is not None), None amount_cell = None
) for cell in value_cells:
pair = parse_amount_pair(cell)
if pair is not None:
amount_cell, alternative = pair
break
if amount_cell is None: if amount_cell is None:
# 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다. # 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다.
expression = next( expression = next(
@@ -638,6 +673,7 @@ def match_table(
amount_unit=unit, amount_unit=unit,
raw_row_index=index, raw_row_index=index,
group_ratio_pct=group_ratio, group_ratio_pct=group_ratio,
alternative_amount=alternative,
) )
) )
@@ -18,6 +18,8 @@
from __future__ import annotations from __future__ import annotations
import re
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -196,6 +198,226 @@ def _match_two_row_table(
return matched 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( def match_transposed_table(
node: dict[str, Any], node: dict[str, Any],
table: dict[str, Any], table: dict[str, Any],
+6 -2
View File
@@ -251,7 +251,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row) by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row)
for (work_item_code, variant), rows in sorted(by_item.items()): for (work_item_code, variant), rows in sorted(by_item.items()):
title_code = f"B-{work_item_code}" + (f"#{variant}" if variant else "") # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로**
# (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해
# 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다.
variant_key = "".join(variant.split())
title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "")
if title_code in build.book.titles: if title_code in build.book.titles:
continue continue
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
@@ -284,7 +288,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
unit=unit, unit=unit,
) )
) )
if variant: if variant_key:
build.variants.setdefault(work_item_code, []).append(variant) build.variants.setdefault(work_item_code, []).append(variant)
# 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다. # 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다.
for row, ref in attachable: for row, ref in attachable:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff