diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 5e05318f..46d2e785 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -55,6 +55,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( load_labor_catalog, load_work_item_master, ) +from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") @@ -390,6 +391,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: break unit = next((r.amount_unit for r in rows if r.amount_unit), "") + if not unit: + # ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가 + # 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만 + # 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다. + unit = work_item_unit(work_item_code) or "" base_name = names.get(work_item_code) or work_item_code build.book.add_title( PriceTitle( diff --git a/B09_Estimation/B09_Estimation_WorkItemUnit.py b/B09_Estimation/B09_Estimation_WorkItemUnit.py new file mode 100644 index 00000000..cdb8ecfb --- /dev/null +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -0,0 +1,139 @@ +"""B09 — **공종 단가의 기준 단위**를 품셈 원문에서 읽는다 (2026-09-08). + +**왜 있는가** — 일위대가 211개 중 **73개가 단위를 못 달고** 있었다. 단위가 없으면 +내역서의 **단위 불일치 검사가 못 걸린다** — 실제로 층따기가 「㎡ 수량 × ㎥당 단가」로 +**4,102,708원**을 내고 있었다(돌쌓기가 「m × ㎡당」이던 것과 같은 병). + +단위가 적힌 자리는 원문에 **둘**이다. + + ① 절 머리 아래 한 줄 — 「### 13-4-5. 찰쌓기(장비)」 다음의 「(단위: ㎡당)」 + ② 공식형 표의 [주] — 「Q1=3600×q×K×f×E/㎝= **㎥/시간**」 (층따기 9-18) + +⚠ **「인 당」·「일 당」은 공종 단위가 아니다.** 그것은 **품의 단위**(사람·날)라, 공종 +단위로 쓰면 「몇 인짜리 공종」이라는 뜻이 된다. 그래서 **물리 수량 단위만** 받는다. + +⚠ **없으면 비워 둔다.** 마스터 `basis_unit` 도 대개 비어 있고(73개 중 대부분 `None`), +못 찾은 것을 짐작으로 채우면 **틀린 단위로 검사를 통과**시켜 오히려 더 나쁘다. +「미확보」로 두면 화면이 「기준 단위가 표에 없습니다」로 드러낸다. +""" + +from __future__ import annotations + +import os +import re +from functools import lru_cache + +#: 산림사업 표준품셈 원문 (FP-* 공종의 출처). +_FOREST_SPEC = ( + "resources", + "knowledge", + "original", + "행정규칙", + "임도 품셈 적용기준 (현 산림사업 표준품셈)", + "첨부", + "(산림청고시 제2025-82호) 산림사업 표준품셈.md", +) + +#: **공종 수량 단위로 인정하는 것.** 여기 없는 표기(인·일·회·시간)는 품의 단위이지 +#: 공종의 단위가 아니다 — 받으면 「몇 인짜리 공종」이 된다. +_QUANTITY_UNITS = frozenset( + { + "㎡", + "㎥", + "m", + "㎞", + "개", + "개소", + "본", + "주", + "ton", + "톤", + "㏊", + "ha", + "kg", + "㎏", + "매", + "장", + "식", + } +) + +_RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$") +#: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다. +_RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[::]\s*(.+?)\s*\)\s*$") +#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. +_RE_FORMULA_UNIT = re.compile(r"=\s*([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b") + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _clean_unit(text: str) -> str | None: + """「100㎡당」 → 「㎡」. 공종 단위가 아니면 `None`.""" + tight = "".join(str(text).split()) + tight = re.sub(r"^\d+(?:,\d{3})*(?:\.\d+)?", "", tight) # 밑수 수량은 뗀다 + tight = tight[:-1] if tight.endswith("당") else tight + return tight if tight in _QUANTITY_UNITS else None + + +@lru_cache(maxsize=1) +def section_units() -> dict[str, str]: + """절 번호 → 공종 단위. **원문에 적힌 것만** 담는다.""" + path = os.path.join(_project_root(), *_FOREST_SPEC) + try: + lines = open(path, encoding="utf-8").read().splitlines() + except OSError: + return {} + + units: dict[str, str] = {} + section: str | None = None + for raw in lines: + line = raw.strip() + heading = _RE_HEADING.match(line) + if heading: + section = heading.group(1) + # ⚠ 절 제목 자체가 식을 품는 경우가 있다 — 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」. + found = _RE_FORMULA_UNIT.search(heading.group(2)) + if found and section not in units: + units[section] = found.group(1) + continue + if section is None or section in units: + continue + unit_line = _RE_UNIT_LINE.match(line) + if unit_line: + cleaned = _clean_unit(unit_line.group(1)) + if cleaned: + units[section] = cleaned + continue + found = _RE_FORMULA_UNIT.search(line) + if found: + units[section] = found.group(1) + return units + + +def section_of(work_item_code: str) -> str: + """공종 코드 → 절 번호. `FP-13-04-05` → `13-4-5`.""" + parts = str(work_item_code or "").split("-") + if not parts or parts[0] != "FP": + return "" + numbers = [] + for part in parts[1:]: + if not part.isdigit(): + return "" + numbers.append(str(int(part))) + return "-".join(numbers) + + +def unit_of(work_item_code: str) -> str | None: + """그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다.""" + section = section_of(work_item_code) + if not section: + return None + units = section_units() + # 하위 절에 안 적혀 있으면 **한 단계 위 절**을 본다 — 「13-4-5」가 없으면 「13-4」. + # ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다. + if section in units: + return units[section] + parent = section.rsplit("-", 1)[0] + return units.get(parent) if parent != section and "-" in section else None