"""B09 원가계산 — 자원 축 매칭 (PLAN 8-6 · 9-3). 메인 창이 낸 **공종 축**(`resources/data_work_item_master/`)을 **읽기만** 하고, 그 위에 **자원 축**(`resource_kind`·`resource_code`·`resource_spec`·`amount`·`amount_unit`)을 붙여 **별도 파일**로 낸다. 원본은 고치지 않는다 — 메인이 품셈을 다시 돌리면 덮이므로 그 안에 섞으면 사라진다. 지켜야 할 것 1. **`pum_form` 을 먼저 본다.** `productivity`(생산량형) = **1 ÷ 값**, `requirement`(소요량형) = **값 ÷ basis_quantity**. ⚠ **뒤집으면 20배 틀린다.** 직종 이름부터 보면 「작업능력(㎥/hr)」 표의 비고란 「보통인부 1인/일」에 끌려 생산량형을 소요량형으로 읽는다(메인이 실제로 한 번 뒤집혔다가 잡은 자리). 2. **`coefficient` · `reference` 는 공종이 아니다.** 일위대가 항목으로 세우지 않는다. `undetermined` 는 **값을 쓰지 않는다.** 3. **규격(`resource_spec`)이 없으면 매칭 성공으로 치지 않는다.** 「굴착기」와 「굴착기 0.7㎥」는 단가가 다르다 — 이름만 맞추면 조용히 틀린 단가가 붙는다. 4. **못 맞춘 것은 빈칸이 아니라 `unmatched` 목록**으로 낸다. 5. **자재는 할증 전 값**이다 (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳뿐이다. """ from __future__ import annotations import hashlib import json import os import re from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation from typing import Any #: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다. USABLE_FORMS = frozenset({"productivity", "requirement"}) #: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다. NON_WORK_ITEM_FORMS = frozenset({"coefficient", "reference"}) #: 형태 판정이 안 된 표 — 값을 쓰지 않는다. UNUSABLE_FORMS = frozenset({"undetermined"}) _MASTER_SUBPATH = ("resources", "data_work_item_master") _CATALOG_SUBPATH = ("resources", "data_cost_input_value") #: 규격이 이름 안에 붙어 있는 흔한 모양 — 「굴착기(0.7㎥)」·「덤프트럭 15톤」. _RE_SPEC = re.compile(r"[((]([^))]+)[))]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))") _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$") #: 첫 칸이 **분류 딱지**이고 이름이 둘째 칸에 오는 표가 있다. #: 예 — `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', …]`. #: 이 표를 첫 칸만 보고 읽으면 **자재·장비가 통째로 빠진다**(2026-09-07 실측 — #: 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고 #: 보통인부 한 줄만 남았다). _GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계") #: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. #: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. #: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· #: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이 #: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음). #: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다. _NON_RESOURCE_WORDS = ( "구분", "합계", "소계", "계", "단위", "비고", "규격", "명칭", "품명", "종류", "항목", "적용", "기준", "산출", "비율", "할증", "할인", "직접노무비", "재료비", "경비", "위치", "면적", "수량", "공종", "작업", "내역", "총계", "인력", "장비", "기계", ) class ResourceAxisError(ValueError): """자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다.""" def _project_root() -> str: return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _read_json(*parts: str) -> dict[str, Any]: with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle: return json.load(handle) @dataclass(frozen=True) class CatalogEntry: """단가 카탈로그 한 줄 — 매칭 대상.""" code: str name: str kind: str spec: str = "" @dataclass class ResourceCatalog: """이름 → 코드. **같은 이름에 규격이 여럿이면 규격 없이는 못 고른다.**""" entries: list[CatalogEntry] = field(default_factory=list) aliases: dict[str, str] = field(default_factory=dict) #: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다. _index: dict[str, list[CatalogEntry]] | None = None def by_name(self, name: str) -> list[CatalogEntry]: if self._index is None: index: dict[str, list[CatalogEntry]] = {} for entry in self.entries: index.setdefault(_normalize(entry.name), []).append(entry) self._index = index return self._index.get(_normalize(name), []) def resolve(self, name: str, spec: str) -> CatalogEntry | None: """이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다.""" found = self.by_name(name) if not found: return None if len(found) == 1: return found[0] # 이름이 여럿이면 규격이 있어야 고를 수 있다. if not spec: return None narrowed = [e for e in found if _normalize(e.spec) == _normalize(spec)] 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: """자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나.""" work_item_code: str pum_table_id: str pum_form: str resource_kind: str resource_code: str resource_name: str resource_spec: str amount: Decimal amount_unit: str raw_row_index: int #: 조건 시공 시 쓰는 대안값 — 「1.04(1.17)」의 1.17 (품셈 13-6-1 [주]②). #: **기본값은 `amount`(괄호 밖)** 이고 이 값은 화면에 「시공 시 다름」으로 보인다. alternative_amount: Decimal | None = None #: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열. #: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.** variant: str = "" #: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`. #: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다 #: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다). group_ratio_pct: Decimal | None = None def as_dict(self) -> dict[str, Any]: return { "work_item_code": self.work_item_code, "pum_table_id": self.pum_table_id, "pum_form": self.pum_form, "resource_kind": self.resource_kind, "resource_code": self.resource_code, "resource_name": self.resource_name, "resource_spec": self.resource_spec, "amount": str(self.amount), "amount_unit": self.amount_unit, "raw_row_index": self.raw_row_index, "variant": self.variant, "alternative_amount": ( None if self.alternative_amount is None else str(self.alternative_amount) ), "group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct), } @dataclass class UnmatchedRow: """못 맞춘 것 — **빈칸으로 두지 않고 여기 모은다**.""" work_item_code: str pum_table_id: str cell: str reason: str def as_dict(self) -> dict[str, Any]: return { "work_item_code": self.work_item_code, "pum_table_id": self.pum_table_id, "cell": self.cell, "reason": self.reason, } @dataclass class AxisResult: rows: list[ResourceRow] = field(default_factory=list) unmatched: list[UnmatchedRow] = field(default_factory=list) skipped_forms: dict[str, int] = field(default_factory=dict) #: 제잡비 비율(%) — `{공종코드: (윗단, 아랫단)}`. 윗단은 물빼기 파이프 설치, #: 아랫단은 미설치 (품셈 13-6-2 [주]③). 값이 하나뿐이면 둘이 같다. overhead_ratio: dict = field(default_factory=dict) #: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다. #: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로 #: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.** partial_items: dict[str, str] = field(default_factory=dict) def _tidy_resource_name(cell: str) -> str: """이름 표기를 카탈로그 쪽으로 맞춘다 — **뜻을 바꾸지 않는 표기 차이만.** ① 이름 안 공백 제거 (「굴 삭 기 (무한궤도)」 → 「굴삭기(무한궤도)」) ② 같은 기종의 다른 이름 (「굴삭기」·「유압식백호우」 → 「굴착기」) ⚠ 규격은 안 건드린다 — 규격을 맞추려 들면 엉뚱한 기종이 붙는다. """ from B09_Estimation.B09_Estimation_MachineProductivity import MACHINE_NAME_ALIASES text = str(cell) head, sep, tail = text.partition("(") tight = "".join(head.split()) for wrong, right in MACHINE_NAME_ALIASES.items(): if tight == wrong: tight = right break return tight + sep + tail #: 장비 줄의 단위 — 시간·대수로 센다. 자재는 kg·매·㎥ 로 센다. _MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간") def _is_machine_like_row(cells: list[str]) -> bool: """그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다.""" for cell in cells: text = _normalize(cell) if text and any(text == unit or text.replace(" ", "") == unit for unit in _MACHINE_UNITS): return True return False def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]): """셀 하나를 카탈로그 한 줄로 푼다 — 세 가지 모양을 차례로 시도한다. ① 셀 전체가 곧 이름 (「보통인부」) ② 기종 셀 (「굴착기(무한궤도, 0.7㎥)」 → 이름 + 규격) ③ 규격이 **옆 칸**에 있는 표 (「굴착기 (무한궤도)」 | 「0.7㎥」) """ # ⚠ **이름 안 공백·표기 차이를 먼저 없앤다.** 품셈은 같은 기종을 「굴 삭 기」· # 「굴착기」·「유압식백호우」로 섞어 적는다(2026-09-08: 메쌓기 13-6-1 의 # 「굴 삭 기 (무한궤도)」가 안 붙어 그 공종 장비 몫이 통째로 빠졌다). name_cell = _tidy_resource_name(name_cell) machine_name, machine_spec = parse_machine_cell(name_cell) plain_name, plain_spec = split_name_and_spec(name_cell) for name, spec in ((machine_name, machine_spec), (plain_name, plain_spec)): if not name: continue entry = catalog.resolve(name, spec) if entry is not None: return entry # 이름은 맞는데 규격이 없어 못 고른 경우 — 옆 칸에서 규격을 찾는다. for name in (machine_name, plain_name): if len(catalog.by_name(name)) <= 1: continue for candidate in spec_candidates(cells[1:]): entry = catalog.resolve(name, candidate) if entry is not None: return entry return None #: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다. _NOT_A_WORK_ITEM_UNIT = frozenset({"인", "인당", "일", "일당", "인/일"}) def match_table( node: dict[str, Any], table: dict[str, Any], catalog: ResourceCatalog, result: AxisResult, ) -> None: """표 하나에 자원 축을 붙인다. 값이 안 서면 `unmatched` 로 보낸다.""" # 예시 서식(「ha당 …단가산출서(예시)」)은 **B08 이 마스터 원천에서 거른다**(8건). # 두 곳에서 같은 것을 거르면 **나중에 한쪽만 고쳐진다** — 원천이 이긴다. # ⚠ 짝 시험은 남겨 둔다(예시 서식이 자원으로 안 서는지) — 원천이 바뀌면 그것이 알려 준다. from B09_Estimation.B09_Estimation_CrewOutput import match_crew_table # ⚠ **작업조 표는 형태 판정보다 먼저 가른다.** 「형틀목공 4인 / 시공량 35㎡」 표는 # 마스터에서 `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(유로폼 12-38-3). # 그 표는 모양이 스스로를 말한다 — 시공량 열 + 작업조 줄이 있으면 그것이다. # 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부푼다. if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""): return # ⚠ **축이 셋인 표도 형태 판정보다 먼저 가른다.** 값 한 칸에 세로축 값이 여럿 # 뭉쳐 있어 행-자원으로도 열-자원으로도 안 읽히고, 콘테이너형 가설건축물(11-1)은 # `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(확정 ⑬ 이 걸린 표). from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import match_three_axis_table if match_three_axis_table(node, table, catalog, result): return # ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해 # 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원). from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import ( match_choose_one_machine_table, ) if match_choose_one_machine_table(node, table, catalog, result): return # ⚠ **규격이 열로 선 표**(관부설 12-11)도 여기서 가른다 — 값이 「0.62/2.5」 같은 # 나눗셈이라 보통 길로는 한 줄도 안 선다. from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import match_spec_column_table if match_spec_column_table(node, table, catalog, result): return form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 return basis = table.get("basis_quantity") basis_quantity = None if basis is None else Decimal(str(basis)) unit = table.get("basis_unit") or "" # ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로 # 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리 # 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다. # ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.** # 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다. if unit.strip() in _NOT_A_WORK_ITEM_UNIT: unit = "" # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 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 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — # 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다. from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table if match_transposed_table(node, table, catalog, result, basis_quantity, unit): return # 「석공 보통인부 | 0.09 0.05 | …」처럼 **이름도 값도 뭉쳐 오고 열이 갈래**인 표 # (돌쌓기 13-4 계열). 행-자원으로는 첫 이름조차 안 풀린다. from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_packed_rows if match_packed_rows(node, table, catalog, result, basis_quantity, unit): return # ⚠ **묶음 배분율은 다음 줄로 이어진다.** 품셈 표는 묶음 머리를 **병합해** 적는다 — # 「인력(10%) | 할석공 2.0」 다음 줄이 「보통인부 1.0」이라 그 줄엔 딱지가 없다. # 이어 주지 않으면 그 줄만 **100%로 서서** 조용히 열 배가 된다(2026-09-09 실측: # 구조물터파기 보통인부가 0.1 대신 1.0 으로 서 단가가 224,305원/㎥ 이었다). carried_ratio: Decimal | None = None for index, row in enumerate(table.get("raw_row", [])): cells = [str(c) for c in row] if not cells: continue # 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다. # # ⚠ 딱지 목록만으로는 모자란다 — 첫 칸이 **공정 이름**인 표가 따로 있다 # (기초잡석 12-25: `소할(30%) | 할석공(인) | 0.2 × 30%`). 목록에 없는 말이라 # 통째로 못 맞추고 있었다. 그래서 **딱지 목록에 없더라도 첫 칸이 자원으로 안 풀리고 # 둘째 칸이 풀리면** 이름을 둘째 칸에서 읽는다 — 판정을 낱말이 아니라 # **풀리는지**로 한다. 배분율 꼬리표(「(30%)」)는 어느 쪽이든 첫 칸에서 읽는다. name_cell = cells[0] value_cells = cells[1:] group_ratio = None if len(cells) > 1 and ( _group_label_of(name_cell) is not None or ( _resolve_cell(catalog, name_cell, cells) is None and _resolve_cell(catalog, cells[1], cells[1:]) is not None ) ): group_ratio = _group_ratio_of(name_cell) name_cell = cells[1] value_cells = cells[2:] # 새 묶음 머리를 만났다 — 여기서부터 이 배분율이 이어진다(없으면 끊는다). carried_ratio = group_ratio else: group_ratio = carried_ratio # 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③). if "제잡비" in _normalize(name_cell): ratios = [ parse_amount(_normalize(token)) for cell in value_cells for token in _RE_ALTERNATIVE.sub(r"", _normalize(cell)).split(" ") ] ratios = [x for x in ratios if x is not None] if ratios: upper = ratios[0] lower = ratios[1] if len(ratios) > 1 else ratios[0] result.overhead_ratio[node["work_item_code"]] = (upper, lower) continue # ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼 # 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가 # 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에 # 「굴착기 0.81」 줄이 새로 생겨 발견). 그 줄은 시공능력 공식 쪽에서 쓴다. if any(_normalize(c).lower() in ("k", "f", "e") for c in value_cells): continue # 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다. alternative: Decimal | None = None amount_cell = None for cell in value_cells: pair = parse_amount_pair(cell) if pair is not None: amount_cell, alternative = pair break if amount_cell is None: # 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다. expression = next( ( parse_amount_expression(c) for c in value_cells if parse_amount_expression(c) is not None ), None, ) if expression is not None: amount_cell = expression group_ratio = None # ⚠ 이미 값 안에 들어 있다 — 또 곱하면 두 번이다 if amount_cell is None: # ⚠ **이름은 자원인데 값을 못 읽은 줄**은 다르다 — 그 공종 단가는 성분이 # 빠진 채 서게 된다. 조용히 넘기지 않고 「일부만 섬」으로 표시한다. # ⚠ **숫자가 아예 없는 줄은 머리 줄**이다 — 자원 이름만 나열된 줄 # (「특별인부 | 벌목부 | 보통인부」). 그것까지 「못 읽은 값」으로 세면 # 정상 공종이 무더기로 막힌다(2026-09-08: 28건 중 대부분이 이 오탐이었다). # 숫자가 **있는데** 못 읽은 줄만 성분 빠짐으로 본다. has_digit = any(ch.isdigit() for cell in value_cells for ch in cell) if ( has_digit and _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) is not None ): result.partial_items[node.get("work_item_code", "")] = ( f"{name_cell} 줄의 값을 못 읽었습니다" ) result.unmatched.append( UnmatchedRow( work_item_code=node.get("work_item_code", ""), pum_table_id=str(table.get("pum_table_id", "")), cell=" | ".join(cells[:3]), reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", ) ) continue # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) if entry is None: if is_non_resource_label(name_cell): continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 name, spec = split_name_and_spec(name_cell) found = catalog.by_name(name) if len(found) > 1: reason = "규격이 없어 같은 이름 여럿 중 고를 수 없음" else: # 지금 가진 카탈로그는 노임뿐이다. 기계·자재는 카탈로그 자체가 없어 # 못 맞추는 것이므로 사유를 갈라 적는다 — 「이름이 틀림」과 다르다. reason = "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)" result.unmatched.append( UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason) ) # ⚠ **시간·대수로 세는 줄은 장비 몫**이다 — 못 맞추면 그 공종 단가가 # 노무만으로 서서 조용히 싸진다(2026-09-08: 초본류 시비가 「트럭(2.5t) # 2.6시간」을 빼고 33.1원/㎡ 로 섰다). 자재 줄(kg·매)은 이미 알려진 # 미결이라 막지 않고 드러내기만 한다. if _is_machine_like_row(value_cells): result.partial_items[node["work_item_code"]] = ( f"{_normalize(name_cell)[:20]} (장비 줄)을 못 맞췄습니다" ) continue try: amount = convert_amount(amount_cell, pum_form=form, basis_quantity=basis_quantity) except ResourceAxisError as error: result.unmatched.append( UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, str(error)) ) continue result.rows.append( ResourceRow( work_item_code=node["work_item_code"], pum_table_id=table["pum_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, group_ratio_pct=group_ratio, alternative_amount=alternative, ) ) #: 분류 딱지가 **비율을 달고 오는** 모양 — 「인력(10%)」·「장비(90%)」. #: 2026-09-08 실측: 측구터파기(FP-09-12-01) 표가 이 모양이라 딱지 판정이 빗나가 #: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다). #: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는 #: 떼면 안 되므로 넓게 잡지 않는다. #: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값. #: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」 #: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]). #: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기). #: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다. _RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$") _RE_RATIO_SUFFIX = re.compile(r"[((][\d.\s]*%?[))]$") def _group_label_of(cell: str) -> str | None: """첫 칸이 분류 딱지면 그 딱지를, 아니면 `None` 을 돌려준다. ⚠ 딱지 목록은 **정확 일치**를 유지한다(부분일치가 정상 자원을 지운 전례 — `_NON_RESOURCE_WORDS` 주석). 비율 꼬리표만 떼고 다시 정확 일치로 본다. """ text = _normalize(cell) if text in _GROUP_LABELS: return text stripped = _normalize(_RE_RATIO_SUFFIX.sub("", text)) return stripped if stripped in _GROUP_LABELS else None def _group_ratio_of(cell: str) -> Decimal | None: """분류 딱지에 붙은 배분율. 「인력(10%)」 → `10`, 「자재」 → `None`.""" found = _RE_RATIO_SUFFIX.search(_normalize(cell)) if found is None: return None digits = found.group(0).strip("()()%").strip() return Decimal(digits) if digits else None def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult: """공종 축 전체를 훑어 자원 축을 만든다.""" result = AxisResult() for node in master.get("work_items", []): for table in node.get("tables", []): match_table(node, table, catalog, result) return result #: 자원 축 산출물이 나가는 자리 — **메인의 `data_work_item_master/` 안에 넣지 않는다.** #: 메인이 품셈을 다시 돌리면 그 폴더가 덮이므로 섞으면 사라진다. OUTPUT_SUBPATH = ("resources", "data_cost_resource_axis") def _master_file_fingerprint(master: dict[str, Any]) -> dict[str, str]: """공종 마스터 **파일 자체**의 지문. 낡은 파생물을 드러내는 유일한 근거다. `dataset_version`(품셈 원판 지문)은 마스터가 다시 생성돼도 그대로라, 그것만 적어 두면 「내 자원 축이 옛 마스터에서 나왔다」는 사실이 안 보인다. """ effective_date = master.get("effective_date", "") file_name = f"work_item_master_{effective_date}.json" path = os.path.join(_project_root(), *_MASTER_SUBPATH, file_name) try: with open(path, "rb") as handle: digest = hashlib.sha256(handle.read()).hexdigest() except OSError: return {"file": file_name, "sha256": ""} return {"file": file_name, "sha256": digest} def write_resource_axis( result: AxisResult, master: dict[str, Any], *, output_dir: str | None = None, ) -> dict[str, str]: """자원 축과 못 맞춘 목록을 파일로 낸다. 만든 파일 경로를 돌려준다.""" directory = output_dir or os.path.join(_project_root(), *OUTPUT_SUBPATH) os.makedirs(directory, exist_ok=True) effective_date = master.get("effective_date", "") axis_path = os.path.join(directory, f"resource_axis_{effective_date}.json") unmatched_path = os.path.join(directory, f"unmatched_{effective_date}.json") axis_payload = { "schema_version": "1.0", "dataset_id": "resource_axis_forest", "effective_date": effective_date, # 어느 공종 축 판에 붙인 것인지 — 세 쪽을 그대로 옮겨 적는다(PLAN 9-2). "source_dataset_version": master.get("dataset_version", {}), # ⚠ 위 지문은 **품셈 원판**의 것이라 B08 이 마스터를 다시 생성해도 안 움직인다. # 낡음을 실제로 드러내려면 **마스터 파일 자체의 지문**이 있어야 한다. "source_master_file": _master_file_fingerprint(master), "policy": { "axis": "resource_only", "work_item_axis_owner": "B08", "material_amounts_are_before_surcharge": True, }, "stats": { "rows": len(result.rows), "unmatched": len(result.unmatched), "skipped_forms": result.skipped_forms, }, "rows": [r.as_dict() for r in result.rows], } unmatched_payload = { "schema_version": "1.0", "effective_date": effective_date, "note": ( "못 맞춘 자원 이름. 빈칸으로 두지 않고 여기 모은다. " "기계·자재 카탈로그가 아직 없어 그 계열은 전부 여기로 온다." ), "rows": [u.as_dict() for u in result.unmatched], } for path, payload in ((axis_path, axis_payload), (unmatched_path, unmatched_payload)): with open(path, "w", encoding="utf-8", newline="\n") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) return {"resource_axis": axis_path, "unmatched": unmatched_path} # 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다. # 가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402 convert_amount, load_combined_catalog, load_labor_catalog, load_machine_catalog_entries, load_material_catalog_entries, load_work_item_master, parse_amount, parse_amount_expression, parse_amount_pair, parse_machine_cell, spec_candidates, split_name_and_spec, )