feat(B09): 2단 열 머리 표 읽기 + 「계」 열 이중계상 차단
- 자원 이름이 **둘째 줄**에 오는 표를 읽음 (철근 12-3: `구조별 | 가공 | 조립 | 계` 아래에 `철근공(인) | 보통인부(인)` 세 벌) - ⚠ 「계」 열은 가공 + 조립을 이미 더한 값이라 **통째로 뺌** — 같이 읽으면 두 번 셈(㉤ 열 방향 사고 자리). 검산: 간단 = 1.07 + 1.69 = 2.76 으로 품셈 「계」와 일치 - 머리 줄에는 갈래 칸이 없어 자료 행과 한 칸 어긋나는 것 보정 - 열 머리 되풀이 금지는 **1단 표에만** 적용. 2단 표에서 같은 직종이 공정마다 되풀이되는 것은 정상이라, 한 규칙으로 뭉치면 철근이 통째로 버려짐 - 내역서 후보 목록에 **규격 갈래**(`#무근구조물`)도 넣음 — B08 이 갈래 없는 코드를 보내면 후보로 보이고 임의로 안 고름 철근 현장가공 및 조립 4갈래 성립 (간단 919,146.7 … 매우복잡 1,278,375.3) 검증: pytest 165 통과(신규 2 — 「계」 열 제외 확인 + 되풀이 규칙 짝 시험) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -383,10 +383,16 @@ def _leaf_row(
|
||||
if price_code not in unit_prices.book.titles:
|
||||
# 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다
|
||||
# (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도).
|
||||
# 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다.
|
||||
children = sorted(
|
||||
code
|
||||
for code in unit_prices.book.titles
|
||||
if code.startswith(f"{price_code}-") and code.count("-") == price_code.count("-") + 1
|
||||
if (
|
||||
code.startswith(f"{price_code}-")
|
||||
and code.count("-") == price_code.count("-") + 1
|
||||
and "#" not in code
|
||||
)
|
||||
or code.startswith(f"{price_code}#")
|
||||
)
|
||||
if children:
|
||||
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
|
||||
|
||||
@@ -38,6 +38,64 @@ 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
|
||||
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
|
||||
|
||||
# ⚠ 「계」 묶음은 뺀다 — 가공 + 조립을 이미 더한 값이라 같이 읽으면 두 번 센다.
|
||||
blocked = _sum_group_positions(table.get("condition_note") or [], len(found))
|
||||
# ⚠ 머리 줄에는 **갈래 칸이 없다** — 자료 행은 첫 칸이 갈래 이름이라 한 칸 밀려 있다.
|
||||
# 이 보정을 빼면 갈래 이름 칸을 자원 값으로 읽는다.
|
||||
kept = [
|
||||
(position + 1, entry)
|
||||
for order, (position, entry) in enumerate(found)
|
||||
if order not in blocked
|
||||
]
|
||||
return kept, 1
|
||||
|
||||
|
||||
def transposed_columns(table: dict[str, Any], catalog: ResourceCatalog) -> list[tuple[int, Any]]:
|
||||
"""열 머리에서 자원을 찾는다. `[(열 번호, 카탈로그 줄)]`.
|
||||
|
||||
@@ -71,6 +129,10 @@ def match_transposed_table(
|
||||
「철근구조물」은 품이 달라 **한 일위대가로 뭉치면 안 된다**.
|
||||
"""
|
||||
columns = transposed_columns(table, catalog)
|
||||
skip_rows = 0
|
||||
if not columns:
|
||||
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
|
||||
columns, skip_rows = second_row_columns(table, catalog)
|
||||
if not columns:
|
||||
return False
|
||||
|
||||
@@ -78,8 +140,11 @@ def match_transposed_table(
|
||||
# (「거리 | 보통인부 | 거리 | 보통인부」) 오른쪽 판의 **거리값이 인원으로** 읽힌다
|
||||
# — 2026-09-08 실측: 소운반이 「보통인부 60인」이 되어 단가가 1,553만원으로 섰다.
|
||||
# 판 경계를 짐작해 읽지 않는다.
|
||||
# ⚠ 2단 표에서는 **같은 직종이 공정마다 되풀이되는 것이 정상**이다
|
||||
# (철근 12-3: 가공 철근공 + 조립 철근공 = 합쳐야 맞는 값). 되풀이 금지는
|
||||
# **1단 표에만** 건다 — 거기서만 「두 판이 좌우로 붙은 표」를 뜻한다.
|
||||
codes = [entry.code for _, entry in columns]
|
||||
if len(codes) != len(set(codes)):
|
||||
if skip_rows == 0 and len(codes) != len(set(codes)):
|
||||
result.unmatched.append(
|
||||
UnmatchedRow(
|
||||
work_item_code=node.get("work_item_code", ""),
|
||||
@@ -94,7 +159,7 @@ def match_transposed_table(
|
||||
# 떨어져 나가면 「상」이 두 번 나오고, 그대로 두면 서로 다른 등급의 품이 **합산**된다
|
||||
# (2026-09-08 실측: 목재틀흙막이 「상」이 8.760 + 13.767 = 22.5 인이 되어 단가가
|
||||
# 667만원으로 섰다). 어느 등급인지 단정할 수 없으므로 고쳐 읽지 않는다.
|
||||
labels = [str(row[0]).strip() for row in (table.get("raw_row") or []) if row]
|
||||
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", "")
|
||||
@@ -103,6 +168,8 @@ def match_transposed_table(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user