fix(B09): 넓은 필터가 정상 자원 70건을 지우고 있었음 — 조회 우선으로 바꿈
메인의 `막자갈`→배합 `자갈` 오탐 사례를 전해 듣고 내 필터를 재 봤더니 **같은 병이 있었음.**
- **실측** — `is_non_resource_label()` 이 머리글 낱말을 **부분일치**로 보고 있어
`건설기계운전사`·`일반기계운전사`·`작업반장`·`인력운반공`·`비계공`·`계장공` 등
**정상 자원 70 / 745** 를 「자원 아님」으로 지우고 있었음(「계」·「작업」·「인력」에 걸림).
그 탓에 **매칭 14건이 조용히 없어졌음**.
- **고침 둘**
① 머리글 판정을 **정확 일치 + 머리글 낱말 조합**(「단위작업별」·「위치및면적」)으로 좁힘.
② **카탈로그 조회를 필터보다 먼저** 함 — 카탈로그에 있는 이름은 **정의상 자원**이라,
필터가 넓어져도 정상 자원이 안 사라지는 구조가 됨.
- **결과** — 매칭 91 → **106**(노무 100 · 기종 6). 산출 파일 다시 냄.
**오탐 짝 시험을 함께 넣음** (「걸려야 한다」 + **「걸리면 안 된다」**)
- 머리글 필터 — 정상 자원 8종은 안 먹고 머리글 7종은 잡는지.
- ㉡ 무대 — 이름에 「운반」이 든 `도자운반`·`덤프운반` 줄은 **안 걸리는지**
(판정을 이름이 아니라 `equipment` 정확 일치로 하는 근거).
- ㉣ 효율 — 손료계수 0.0002085·조종원 0.125 처럼 **0~1 이지만 효율이 아닌 값**은
안 걸리는지(효율 자리로 들어올 때만 막는 근거).
**덤으로 잡은 것** — 상세가 하나도 안 붙는 일위대가가 **제목만 서서** 「상세 줄이 없어
단가를 못 조립」하는 상태가 있었음. **붙을 상세를 먼저 모으고 없으면 제목도 안 세움**
(일위대가 67 · 건너뜀 1). 전수 시험(`모든 일위대가 총액 > 0`)이 이걸 잡았음.
PLAN 9-6 에 ㉤(열 방향 검사, 이중계상 규칙이 아니라 표 정합 검사)과 이번 필터 교훈 기록.
자체검증 — `pytest tmp/tests/ -q` **123 passed** · ruff 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,8 +41,12 @@ _CATALOG_SUBPATH = ("resources", "data_cost_input_value")
|
||||
_RE_SPEC = re.compile(r"[((]([^))]+)[))]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))")
|
||||
_RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
|
||||
|
||||
#: 표 머리글·소계 행의 첫 칸에 흔히 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
||||
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
||||
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
|
||||
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
|
||||
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
|
||||
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
|
||||
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
|
||||
_NON_RESOURCE_WORDS = (
|
||||
"구분",
|
||||
"합계",
|
||||
@@ -149,7 +153,24 @@ def is_non_resource_label(cell: str) -> bool:
|
||||
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
|
||||
if len(_RE_HANGUL.findall(text)) < 2:
|
||||
return True
|
||||
return any(word in text for word in _NON_RESOURCE_WORDS)
|
||||
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
|
||||
if text in _NON_RESOURCE_WORDS:
|
||||
return True
|
||||
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
|
||||
return _is_header_composite(text)
|
||||
|
||||
|
||||
def _is_header_composite(text: str) -> bool:
|
||||
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
|
||||
|
||||
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
|
||||
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
|
||||
"""
|
||||
rest = text
|
||||
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
|
||||
rest = rest.replace(word, "")
|
||||
rest = rest.replace("및", "").replace("별", "").strip()
|
||||
return rest == ""
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
@@ -400,8 +421,6 @@ def match_table(
|
||||
if not cells:
|
||||
continue
|
||||
name_cell = cells[0]
|
||||
if is_non_resource_label(name_cell):
|
||||
continue
|
||||
|
||||
# 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다.
|
||||
amount_cell = next(
|
||||
@@ -410,8 +429,13 @@ def match_table(
|
||||
if amount_cell is None:
|
||||
continue
|
||||
|
||||
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
|
||||
# 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을
|
||||
# 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다.
|
||||
entry = _resolve_cell(catalog, name_cell, cells)
|
||||
if entry is None:
|
||||
if is_non_resource_label(name_cell):
|
||||
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
|
||||
name, spec = split_name_and_spec(name_cell)
|
||||
found = catalog.by_name(name)
|
||||
if len(found) > 1:
|
||||
|
||||
@@ -200,6 +200,18 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
title_code = f"B-{work_item_code}"
|
||||
if title_code in build.book.titles:
|
||||
continue
|
||||
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
|
||||
# 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다
|
||||
# (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음).
|
||||
attachable = [
|
||||
(row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}")
|
||||
for row in rows
|
||||
]
|
||||
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
|
||||
if not attachable:
|
||||
build.skipped.append(work_item_code)
|
||||
continue
|
||||
|
||||
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
|
||||
build.book.add_title(
|
||||
PriceTitle(
|
||||
@@ -210,15 +222,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
unit=unit,
|
||||
)
|
||||
)
|
||||
added = 0
|
||||
for row in rows:
|
||||
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}"
|
||||
if ref not in build.book.titles:
|
||||
continue
|
||||
for row, ref in attachable:
|
||||
build.book.add_detail(PriceDetail(title_code, ref, row.amount))
|
||||
added += 1
|
||||
if added == 0:
|
||||
build.skipped.append(work_item_code)
|
||||
return build
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user