diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index bc790a7f..2455f881 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -379,6 +379,14 @@ def match_table( basis_quantity = None if basis is None else Decimal(str(basis)) unit = table.get("basis_unit") or "" + # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열). + # 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로 + # 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다. + from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table + + if match_unit_basis_table(node, table, catalog, result): + return + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py new file mode 100644 index 00000000..f594434b --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py @@ -0,0 +1,151 @@ +"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견). + +품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다. + + 13-2-2 막돌 채집 (단위: ㎥당) + 보통인부 | ㎡당 | 0.17 + | ㎥당 | 0.64 + + 13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60 + 인부 | ㎡당 0.11 0.17 0.22 0.28 0.36 + | ㎥당 0.60 0.64 0.67 0.70 0.80 + +⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을 +쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예 +안 섰다. 둘 다 조용히 틀린 자리다. + +**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의 +**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**. + +⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고, +넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라 +#: 밑수(basis_quantity) 자리라 건드리지 않는다. +_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$") + +#: 밑수 표기 → 그 갈래가 갖는 단위. +_BASIS_UNIT = {"㏊": "ha", "톤": "ton", "㎏": "kg"} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def _basis_of(cell: str) -> str | None: + """그 칸이 밑수 표기면 단위, 아니면 `None`.""" + found = _RE_BASIS.match(_tight(cell)) + if not found: + return None + unit = found.group(1) + return _BASIS_UNIT.get(unit, unit) + + +def _column_labels(table: dict[str, Any]) -> list[str]: + """열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록.""" + headers = [_tight(c) for c in (table.get("condition_note") or [])] + labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None] + return labels + + +def match_unit_basis_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`.""" + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return False + + # 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다. + seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))} + if len(seen_basis) < 2: + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + labels = _column_labels(table) + + entry = None + made = 0 + for index, cells in enumerate(rows): + basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None) + if basis_at is None: + continue + if basis_at > 0: + # 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다. + found = _resolve(catalog, cells[0]) + if found is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0][:30], + reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다", + ) + ) + return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다 + entry = found + if entry is None: + continue + + unit = _basis_of(cells[basis_at]) + values = [parse_amount(cell) for cell in cells[basis_at + 1 :]] + values = [v for v in values if v is not None] + if not values: + continue + + for position, amount in enumerate(values): + if labels and position >= len(labels): + break # 비고 칸까지 값으로 읽지 않는다 + label = labels[position] if labels else "" + # ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로 + # 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다). + variant = f"{label}㎝·{unit}당" if label else f"{unit}당" + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + ) + made += 1 + return made > 0 + + +def _resolve(catalog: ResourceCatalog, name_cell: str): + name, spec = split_name_and_spec(name_cell) + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + if spec: + return None + found = catalog.by_name(name) + return found[0] if len(found) == 1 else None 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..ec05eb27 --- /dev/null +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -0,0 +1,168 @@ +"""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*$") +#: 「단위:」를 빼고 **괄호만** 적는 절이 있다 — 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」. +#: 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 안 읽히고 있었다(2026-09-08). +#: +#: ⚠ **앞의 「인/」은 품의 단위이고 뒤가 공종 단위다** — 「인/㎡당」은 「㎡당 몇 인」이라 +#: 공종 단위가 ㎡ 다. 그래서 그것만 건너뛴다. +#: ⚠ **「㎥/1대, 1일」·「대/ton」 같은 것은 안 받는다** — 그건 소요량이 아니라 +#: **시공량**(1대가 하루에 몇 ㎥)이라, 공종 단위로 읽으면 뜻이 뒤집힌다. +#: 쉼표가 들어간 것(「1ha당, 100본당」)도 밑수가 둘이라 안 받는다. +_RE_BARE_UNIT_LINE = re.compile(r"^\(\s*(?:인\s*/\s*)?(?:[\d.,]+\s*)?([^\s/,()]+?)\s*당?\s*\)\s*$") +#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. +#: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐). +#: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다. +_RE_FORMULA_UNIT = re.compile(r"=\s*(?:[\d.,]+\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) or _RE_BARE_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] + + # ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「9-5. 발파암」은 + # 빈 제목이고 9-5-1·9-5-2·9-5-3 이 각각 ㎥ 를 밝힌다(암절취 9-4·노체 9-16·덤프운반 + # 10-12 도 같다). **하위가 모두 같은 단위일 때만** 그 단위를 상위에 준다 — + # 갈리면 안 준다(한 단위로 뭉뚱그리면 어느 하위가 틀렸는지 못 가린다). + # + # ⚠ **둘 이상이 말할 때만 듣는다.** 하나만 말한 것을 상위에 주면 장(章) 전체가 + # 그 하나의 단위가 된다 — 실제로 「13. 돌공사」가 하위 한 절 때문에 **ton** 으로 + # 나왔다(2026-09-08). 그래서 ㉠ 절 번호에 하이픈이 있고(장 자체가 아니고) + # ㉡ **말한 하위가 둘 이상**이며 ㉢ 그 값이 하나로 모일 때만 준다. + declared = [ + value + for key, value in units.items() + if key.startswith(f"{section}-") and key.count("-") == section.count("-") + 1 + ] + if "-" in section and len(declared) >= 2 and len(set(declared)) == 1: + return declared[0] + + parent = section.rsplit("-", 1)[0] + return units.get(parent) if parent != section and "-" in section else None