옮긴 글 원문과 같음 · 자원 축 출력·일위대가 462 제목 금액 전후 같음 · 린트 종전과 같음 · 전체 1909 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
114 lines
4.9 KiB
Python
114 lines
4.9 KiB
Python
"""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()
|