From 9d8c3a6068d77be3a5a2d65f00337fe9678f8c9d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 16:44:53 +0900 Subject: [PATCH 1/2] auto: 2026-09-14 16:44 (EOMSANGDON-HOME) --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index ad281e75..45f4e487 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,7 @@ from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router +from B08_Quantity.B08_Quantity_Router_StmateLibrary import router as b08_stmate_library_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router @@ -644,6 +645,7 @@ app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_structure_sheet_router, dependencies=protected_with_company) +app.include_router(b08_stmate_library_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) app.include_router(b09_contract_router, dependencies=protected_with_company) From ef03daa4821ffd749fcd93907ef47105de146908 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 16:49:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(b09):=20ResourceAxis.py=20713?= =?UTF-8?q?=E2=86=92620=EC=A4=84=20=E2=80=94=20=EC=85=80=20=EA=B1=B0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=C2=B7=EB=AC=BC=EA=B2=B0=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=85=80=20=EC=A0=95=EA=B7=9C=ED=99=94=EB=A5=BC?= =?UTF-8?q?=20=5FLabels=20=EB=A1=9C=20=EC=88=9C=EC=88=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC(=EC=98=AE=EA=B8=B0=EA=B8=B0=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B0=94=EA=B9=A5=20=EC=9D=B4=EB=A6=84=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EB=82=B4=EB=B3=B4=EB=83=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 옮긴 글 원문과 같음 · 자원 축 출력·일위대가 462 제목 금액 전후 같음 · 린트 종전과 같음 · 전체 1909 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_ResourceAxis.py | 115 ++---------------- .../B09_Estimation_ResourceAxis_Labels.py | 113 +++++++++++++++++ 2 files changed, 124 insertions(+), 104 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_Labels.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 761ee7f0..d1412520 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace from decimal import Decimal, InvalidOperation from typing import Any +# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14). +# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다. +from B09_Estimation.B09_Estimation_ResourceAxis_Labels import ( + _RE_RANGE_CELL, # noqa: F401 + RANGE_DASH_CLASS, # noqa: F401 + RANGE_DASHES, # noqa: F401 + _normalize, + is_non_resource_label, +) + #: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다. USABLE_FORMS = frozenset({"productivity", "requirement"}) #: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다. @@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$") #: 보통인부 한 줄만 남았다). _GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계") -#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. -#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. -#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· -#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이 -#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음). -#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다. -_NON_RESOURCE_WORDS = ( - "구분", - "합계", - "소계", - "계", - "단위", - "비고", - "규격", - "명칭", - "품명", - "종류", - "항목", - "적용", - "기준", - "산출", - "비율", - "할증", - "할인", - "직접노무비", - "재료비", - "경비", - "위치", - "면적", - "수량", - "공종", - "작업", - "내역", - "총계", - "인력", - "장비", - "기계", -) - class ResourceAxisError(ValueError): """자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다.""" @@ -151,71 +122,6 @@ class ResourceCatalog: return narrowed[0] if len(narrowed) == 1 else None -#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「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() - - @dataclass class ResourceRow: """자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나.""" @@ -442,6 +348,7 @@ def match_table( # 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만. from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table + if match_judged_table(node, table, catalog, result, basis_quantity, unit): return diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Labels.py b/B09_Estimation/B09_Estimation_ResourceAxis_Labels.py new file mode 100644 index 00000000..b52f1d29 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Labels.py @@ -0,0 +1,113 @@ +"""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()