"""B09 원가계산 — 자원 축 **셀 거르기 · 물결표 목록 · 셀 정규화** (`_ResourceAxis` 보조). `B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 떼어 낸 파일이다(2026-09-14 순수 분리 — 내용은 옮기기만 함). 바깥 파일은 여태처럼 `B09_Estimation_ResourceAxis` 에서 가져간다. """ from __future__ import annotations import re #: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. #: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. #: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· #: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이 #: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음). #: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다. _NON_RESOURCE_WORDS = ( "구분", "합계", "소계", "계", "단위", "비고", "규격", "명칭", "품명", "종류", "항목", "적용", "기준", "산출", "비율", "할증", "할인", "직접노무비", "재료비", "경비", "위치", "면적", "수량", "공종", "작업", "내역", "총계", "인력", "장비", "기계", ) #: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」), #: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다. #: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로 #: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음). #: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.** #: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다. RANGE_DASHES = "∼~〜~-–‐" #: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든 #: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다. RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES) #: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄). #: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다. #: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다). _RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤" _RE_RANGE_CELL = re.compile( rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$" ) _RE_HANGUL = re.compile(r"[가-힣]") def is_non_resource_label(cell: str) -> bool: """표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가. 못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다. """ text = _normalize(cell) if not text: return True if text.startswith(("※", "<", "(", "-", "ㆍ", "·")): return True if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸 return True # 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다. if text[0].isdigit(): return True # 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다. if len(_RE_HANGUL.findall(text)) < 2: return True # ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석). 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: """표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것.""" return re.sub(r"\s+", "", str(text or "")).strip()