diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index c8e16357..bf0136ca 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -37,7 +37,7 @@ WITHHELD_FORMS: dict[str, str] = { "콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 " "— 벽 두께·저판이 정본에 없어 물량이 서지 않음" ), - "돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"), + "돌망태": ("돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"), "통나무·목재틀": ( "목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 " "— 각재·판재 자재가 카탈로그에 없어 값이 모자람" @@ -161,7 +161,7 @@ EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"}) #: 나머지 형식과 **왜 없는지**. 지어내지 않는다. EROSION_CHECK_WITHHELD: dict[str, str] = { - "돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보", + "돌망태": "돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보", "콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음", "통나무": ( "통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보" diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index c430fba5..1ef60584 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -20,11 +20,10 @@ from __future__ import annotations -import hashlib import json import os import re -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import Decimal, InvalidOperation from typing import Any @@ -110,6 +109,9 @@ class CatalogEntry: name: str kind: str spec: str = "" + #: ⚠ **규격이 조인 키인 항목**(`AR-` 자원 목록) — 후보가 하나여도 규격이 같아야 고른다 + #: (명세 §11). + strict_spec: bool = False @dataclass @@ -118,8 +120,12 @@ class ResourceCatalog: entries: list[CatalogEntry] = field(default_factory=list) aliases: dict[str, str] = field(default_factory=dict) + #: 범위 별칭 `{from, to, scope, pum_edition}` — 그 공종 범위 안에서만 쓴다(명세 5장). + scoped_aliases: list[dict[str, str]] = field(default_factory=list) #: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다. _index: dict[str, list[CatalogEntry]] | None = None + #: 괄호 앞 이름 → 항목 색인(형식 계열). `_Join.family_members` 가 채운다. + _family: dict[str, list[CatalogEntry]] | None = None def by_name(self, name: str) -> list[CatalogEntry]: if self._index is None: @@ -134,6 +140,8 @@ class ResourceCatalog: found = self.by_name(name) if not found: return None + if any(entry.strict_spec for entry in found): + return pick_by_spec(found, spec) # 한 건뿐이어도 규격이 안 맞으면 None(판정 Ⓑ) if len(found) == 1: return found[0] # 이름이 여럿이면 규격이 있어야 고를 수 있다. @@ -341,13 +349,20 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]): # 이름은 맞는데 규격이 없어 못 고른 경우 — 옆 칸에서 규격을 찾는다. for name in (machine_name, plain_name): - if len(catalog.by_name(name)) <= 1: + found = catalog.by_name(name) + strict = any(entry.strict_spec for entry in found) + if len(found) <= 1 and not strict: continue - for candidate in spec_candidates(cells[1:]): + # 조인 키 항목은 **글자 규격**(「복합비료」·「∅200mm」)도 본다 — 기존 항목 길은 그대로. + for candidate in ( + *spec_candidates(cells[1:]), + *(text_spec_candidates(cells[1:]) if strict else ()), + ): entry = catalog.resolve(name, candidate) if entry is not None: return entry - return None + # 이름이 **형식 하나뿐인 계열**이면 규격으로 고른다(「공기압축기(3.5㎥/min)」 → 이동식 3.5). + return resolve_family(catalog, name_cell, cells) #: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다. @@ -542,18 +557,14 @@ def match_table( # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. + # 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공). + name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"]) 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 = "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)" + # 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다. + reason = unmatched_reason(catalog, name_cell) result.unmatched.append( UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason) ) @@ -633,6 +644,11 @@ def _group_ratio_of(cell: str) -> Decimal | None: def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult: """공종 축 전체를 훑어 자원 축을 만든다.""" + # ⚠ 별칭 범위(`FP-*`)는 **품셈 판에 묶인다** — 판이 다른 줄은 쓰지 않는다(명세 17장). + edition = str(master.get("effective_date", "")) + if any(row.get("pum_edition") != edition for row in catalog.scoped_aliases): + kept = [row for row in catalog.scoped_aliases if row.get("pum_edition") == edition] + catalog = replace(catalog, scoped_aliases=kept, _index=None, _family=None) result = AxisResult() for node in master.get("work_items", []): for table in node.get("tables", []): @@ -640,78 +656,8 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi 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} +# 조사용 덤프(`write_resource_axis`)는 700줄 제한으로 `_Dump` 파일로 옮겼다(2026-09-13). +# ⚠ 그 파일이 쓰는 JSON 은 **정본이 아니다** — 정본은 매번 메모리에서 새로 돈 값이다. # 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다. @@ -730,3 +676,12 @@ from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402 spec_candidates, split_name_and_spec, ) + +# 조인 키 규칙(규격 · 형식 계열 · 범위 별칭)은 `_Join` 에 둔다(2026-09-13 축 C 1장). +from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( # noqa: E402 + apply_scoped_alias, + pick_by_spec, + resolve_family, + text_spec_candidates, + unmatched_reason, +) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Dump.py b/B09_Estimation/B09_Estimation_ResourceAxis_Dump.py new file mode 100644 index 00000000..3c106022 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Dump.py @@ -0,0 +1,94 @@ +"""B09 원가계산 — 자원 축 **조사용 덤프** (`B09_Estimation_ResourceAxis` 에서 갈라냄). + +⚠ **정본이 아니다.** 자원 축의 정본은 `build_unit_prices` 가 부를 때마다 + **메모리에서 새로 돈 값**이고, 이 파일이 쓰는 JSON 은 시험·조사용 자취일 뿐이다 + (명세 1장 2026-09-13 정정 — 옛 덤프 418/497 을 정본으로 읽어 추산이 틀렸던 자리). + 부르는 곳이 없어도 지우지 않는다. +⚠ **왜 갈랐나** — 본 파일이 700줄 제한을 넘어(732줄) 조인 키 규칙을 더하기 전에 뗐다. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + _MASTER_SUBPATH, + AxisResult, + _project_root, +) + +#: 자원 축 산출물이 나가는 자리 — **메인의 `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} diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py new file mode 100644 index 00000000..443f17ab --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py @@ -0,0 +1,248 @@ +"""B09 원가계산 — 자원 축 **조인 키 규칙** (`_ResourceAxis` 보조 · 2026-09-13 축 C 1장). + +세 가지를 한 자리에 둔다. + ① **규격이 조인 키인 항목** — `resources/data_resource_catalog/` 의 `AR-` 자원 목록 + (기존 카탈로그에 없는 자원). 이름이 맞아도 **규격이 같아야** 고르고, + **후보가 하나여도 자동 채택하지 않는다**(명세 §11 · 판정 Ⓑ). + 품셈 칸이 규격을 안 주면 「규격 미정 — 후보 N」으로 드러낸다. + ② **형식이 하나뿐인 계열 이름** — 「공기압축기(3.5㎥/min)」 → 「공기압축기(이동식)」 3.5. + 형식이 둘 이상이면 고르지 않는다(공압식·전기식은 다른 장비 · 판정 Ⓒ). + ③ **범위 별칭** `{from, to, scope, pum_edition}` — 공종 코드 범위 안에서만 바꾼다(명세 5장). + scope 없는 줄 · 겹친 범위에서 두 코드로 가는 이름은 **읽을 때 오류**로 세운다. + ⚠ 대체(대응이 아예 없어 갈음)는 별칭이 아니다 — 여기 넣지 않는다. + +⚠ 단가는 다루지 않는다 — 코드·이름·규격만 선다(7장 단가 보류). +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + RANGE_DASHES, + CatalogEntry, + ResourceAxisError, + ResourceCatalog, + _normalize, + _project_root, +) +from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( + parse_amount, + parse_machine_cell, + split_name_and_spec, +) + +_EXT_SUBPATH = ("resources", "data_resource_catalog") +EXT_CATALOG_FILE = "resource_catalog_ext_2026-01-01.json" +ALIASES_FILE = "aliases_2026-01-01.json" + +#: 명세 §2 ③ — `AR--<8자리 소문자 16진>`. 종류 글자는 PriceKind 글자 그대로. +_RE_AR_CODE = re.compile(r"^AR-([MLX])-[0-9a-f]{8}$") +_KIND_LETTER = {"material": "M", "labor": "L", "machine": "X"} + +#: 규격 키 — 공백을 지우고 물결표만 한 종류로 모은다(`normalize_variant_key` 와 같은 두 규칙). +_TILDES = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐") +#: 옆 칸에서 글자 규격을 찾을 때 **단위 칸**은 건너뛴다. +_UNIT_WORDS = frozenset( + {"kg", "㎏", "g", "t", "ton", "톤", "㎥", "m3", "㎡", "m2", "m", "㎝", "㎜", "mm", "개", "본", + "매", "EA", "ea", "개소", "인", "hr", "h", "시간", "시간(h)", "대", "회", "L", "ℓ", "%", "식"} +) # fmt: skip +#: 「0.016/2.5/2」 — 나눗셈으로 적힌 **값 칸**. 여기서 멈춘다. +_RE_QUOTIENT = re.compile(r"^\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)+$") +#: 규격 첫 수 — 「5,500ℓ」 처럼 천 단위 쉼표가 든 것도 한 수로 읽는다. +_RE_NUMBER_TOKEN = re.compile(r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?") + + +def _read_optional(file_name: str) -> dict[str, Any]: + """파일이 없으면 빈 벌 — 목록이 없던 때와 같이 돈다.""" + path = os.path.join(_project_root(), *_EXT_SUBPATH, file_name) + if not os.path.isfile(path): + return {} + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + +def parse_ext_entries(rows: list[dict[str, Any]]) -> list[CatalogEntry]: + """`AR-` 자원 줄 → 조인 키 항목. 코드 모양·종류 글자·겹침을 **읽을 때** 막는다.""" + entries: list[CatalogEntry] = [] + for row in rows: + code, kind = str(row.get("code") or ""), str(row.get("kind") or "") + found = _RE_AR_CODE.match(code) + if found is None or found.group(1) != _KIND_LETTER.get(kind): + raise ResourceAxisError(f"자원 코드 모양이 규약과 다릅니다: {code!r} ({kind})") + if not str(row.get("name") or "").strip() or not str(row.get("unit") or "").strip(): + raise ResourceAxisError(f"자원 줄에 이름·단위가 있어야 합니다: {code}") + entries.append( + CatalogEntry( + code=code, + name=str(row["name"]), + kind=kind, + spec=str(row.get("spec") or ""), + strict_spec=True, + ) + ) + if len({entry.code for entry in entries}) != len(entries): + raise ResourceAxisError("자원 코드가 겹칩니다 — 난수 8자리를 다시 뽑을 것") + return entries + + +def load_ext_entries(file_name: str = EXT_CATALOG_FILE) -> list[CatalogEntry]: + """기존 카탈로그에 없는 자원(`AR-`).""" + return parse_ext_entries(list(_read_optional(file_name).get("entries") or [])) + + +def in_scope(work_item_code: str, scope: str) -> bool: + """`FP-*` 범위 — 그 코드 자신이거나 그 아래. ⚠ `AX`·`AR` 에는 계층이 없어 쓰지 않는다.""" + return work_item_code == scope or work_item_code.startswith(scope + "-") + + +def parse_scoped_aliases(rows: list[dict[str, Any]]) -> list[dict[str, str]]: + """자원 축 별칭만 골라 검사한다. **scope 없는 줄 · 겹친 범위의 두 코드는 오류.**""" + aliases: list[dict[str, str]] = [] + for row in rows: + if row.get("axis") != "resource": + continue + picked = { + key: str(row.get(key) or "").strip() for key in ("from", "to", "scope", "pum_edition") + } + if not all(picked.values()): + raise ResourceAxisError( + f"별칭 줄에 from·to·scope·pum_edition 이 다 있어야 합니다: {row}" + ) + aliases.append(picked) + for index, left in enumerate(aliases): + for right in aliases[index + 1 :]: + if ( + _normalize(left["from"]) == _normalize(right["from"]) + and left["to"] != right["to"] + and left["pum_edition"] == right["pum_edition"] + and ( + in_scope(left["scope"], right["scope"]) + or in_scope(right["scope"], left["scope"]) + ) + ): + raise ResourceAxisError( + f"같은 이름 「{left['from']}」이 겹친 범위에서 두 코드로 갑니다: " + f"{left['to']} · {right['to']}" + ) + return aliases + + +def load_scoped_aliases(file_name: str = ALIASES_FILE) -> list[dict[str, str]]: + return parse_scoped_aliases(list(_read_optional(file_name).get("aliases") or [])) + + +def apply_scoped_alias(catalog: ResourceCatalog, name_cell: str, work_item_code: str) -> str: + """범위 안이면 카탈로그 쪽 **이름**으로 바꾼다. 범위 밖이거나 대상 코드가 없으면 원문 그대로.""" + wanted = _normalize(name_cell) + for row in catalog.scoped_aliases: + if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): + continue + target = next((entry for entry in catalog.entries if entry.code == row["to"]), None) + if target is not None: + return target.name + return name_cell + + +def spec_key(text: str) -> str: + tight = _normalize(text) + return "".join("~" if ch in _TILDES else ch for ch in tight) + + +def same_spec(catalog_spec: str, text: str) -> bool: + """규격이 같은가. 카탈로그가 **수만** 적은 규격(「3.5」)은 품셈 첫 수(「3.5㎥/min」)로 본다.""" + left, right = spec_key(catalog_spec), spec_key(text) + if not left or not right: + return False + if left == right: + return True + number = parse_amount(left) + if number is None: + return False + found = _RE_NUMBER_TOKEN.search(right) + return found is not None and parse_amount(found.group(0)) == number + + +def text_spec_candidates(cells: list[str]) -> list[str]: + """옆 칸의 **글자 규격** — 「복합비료」·「∅200mm」·「직경4~6㎝, 길이120㎝ 기준」. + + 값 칸(수·나눗셈)을 만나면 멈춘다 — 그 뒤는 소요량·비고라 규격이 아니다. 단위 칸은 건너뛴다. + """ + found: list[str] = [] + for cell in cells: + text = _normalize(cell) + if not text: + continue + if parse_amount(text) is not None or _RE_QUOTIENT.match(text): + break + if text in _UNIT_WORDS or len(text) > 40: + continue + found.append(str(cell).strip()) + return found + + +def pick_by_spec(found: list[CatalogEntry], spec: str) -> CatalogEntry | None: + """조인 키 항목 고르기 — 규격이 같은 것이 **정확히 한 건**일 때만.""" + hits = [entry for entry in found if same_spec(entry.spec, spec)] + return hits[0] if len(hits) == 1 else None + + +def _family_base(name: str) -> str: + return _normalize(name).split("(")[0].split("(")[0] + + +def family_members(catalog: ResourceCatalog, name_cell: str) -> list[CatalogEntry]: + """괄호 앞 이름이 같은 카탈로그 항목(「공기압축기」 → 「공기압축기(이동식)」 여섯 규격).""" + if catalog._family is None: + index: dict[str, list[CatalogEntry]] = {} + for entry in catalog.entries: + if "(" in entry.name or "(" in entry.name: + index.setdefault(_family_base(entry.name), []).append(entry) + catalog._family = index + name, _ = parse_machine_cell(name_cell) + return catalog._family.get(_family_base(name), []) + + +def resolve_family( + catalog: ResourceCatalog, name_cell: str, cells: list[str] +) -> CatalogEntry | None: + """카탈로그에 그 이름이 없고 **형식이 하나뿐인 계열**일 때만 규격으로 고른다.""" + name, spec = parse_machine_cell(name_cell) + if catalog.by_name(name): + return None # 이름이 그대로 있으면 계열로 넓히지 않는다 + members = family_members(catalog, name_cell) + if len({_normalize(entry.name) for entry in members}) != 1: + return None + specs = [text for text in (spec, *text_spec_candidates(cells[1:])) if text] + hits = [entry for entry in members if any(same_spec(entry.spec, text) for text in specs)] + return hits[0] if len(hits) == 1 else None + + +def unmatched_reason(catalog: ResourceCatalog, name_cell: str) -> str: + """못 맞춘 까닭 — 「규격 미정」·「같은 이름 여럿」·「카탈로그에 없는 이름」을 가른다.""" + name, _ = split_name_and_spec(name_cell) + found = catalog.by_name(name) + if any(entry.strict_spec for entry in found): + pool = found + else: + pool = [] if found else family_members(catalog, name_cell) + if pool: + labels = sorted( + { + f"{entry.name} {entry.spec}".strip()[:30] + for entry in pool + if entry.spec or not entry.strict_spec + } + ) + if not labels: + codes = ", ".join(sorted(entry.code for entry in pool)[:2]) + return f"규격 미정 — 규격 목록 미확보(코드 {codes} 만 섬) · 설계·라이브러리가 고름" + shown = " / ".join(labels[:3]) + (" …" if len(labels) > 3 else "") + return f"규격 미정 — 후보 {len(labels)}건 ({shown}) · 설계·라이브러리가 고름" + if len(found) > 1: + return "규격이 없어 같은 이름 여럿 중 고를 수 없음" + # 기계·자재는 카탈로그가 없어 못 맞추는 것이라 사유를 갈라 적는다 — 「이름이 틀림」과 다르다. + return "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)" diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py index 69f3c23f..c15d7c09 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py @@ -78,15 +78,26 @@ def load_material_catalog_entries( def load_combined_catalog() -> ResourceCatalog: - """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" + """노임 + 기종 + **관급 자재** + **카탈로그 밖 자원(`AR-`)** 을 한 벌로. + + 사급 자재 원천(물가지)은 아직 없다 — `AR-` 목록은 **이름·규격·단위만** 싣고 단가는 없다 + (2026-09-13 축 C 1장). 범위 별칭도 여기서 함께 싣는다. + """ + from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( + load_ext_entries, + load_scoped_aliases, + ) + labor = load_labor_catalog() return ResourceCatalog( entries=[ *labor.entries, *load_machine_catalog_entries(), *load_material_catalog_entries(), + *load_ext_entries(), ], aliases=labor.aliases, + scoped_aliases=load_scoped_aliases(), ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 233974fc..237792c6 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -742,6 +742,22 @@ def build_unit_prices( for row in rows ] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] + # ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히 + # 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C). + # 자재는 종전처럼 드러내기만 한다(자재 단가 층은 7장 수동입력이 선 뒤). + attached_ids = {id(row) for row, _ in attachable} + for row in rows: + if id(row) in attached_ids: + continue + label = f"{row.resource_name} {row.resource_spec}".strip() + labels = build.unattached.setdefault(work_item_code, []) + if label not in labels: + labels.append(label) + if row.resource_kind == "machine": + build.component_gaps.setdefault( + work_item_code, f"{label} 줄이 맞춰졌으나 기계 단가 층이 없습니다" + ) + build.partial_ratio.setdefault(work_item_code, _ZERO) if ( not attachable and not capacity_rows.get(work_item_code) diff --git a/resources/data_resource_catalog/aliases_2026-01-01.json b/resources/data_resource_catalog/aliases_2026-01-01.json new file mode 100644 index 00000000..a17b202b --- /dev/null +++ b/resources/data_resource_catalog/aliases_2026-01-01.json @@ -0,0 +1,15 @@ +{ + "schema_version": "1.0", + "dataset_id": "aliases", + "note": "별칭 한 벌(명세 5장). 한 줄 = {axis, from, to, scope, pum_edition, basis}. scope 없는 줄은 오류 · 같은 이름이 겹친 범위에서 두 코드로 가면 오류. 대체(대응이 아예 없어 갈음)는 여기 넣지 않음. scope 는 품셈 판에 묶임 — 판이 다르면 적용 안 함(명세 17장).", + "aliases": [ + { + "axis": "resource", + "from": "화약공", + "to": "1016", + "scope": "FP-09-05", + "pum_edition": "2026-01-01", + "basis": "노임 표준 직종명은 「화약취급공」(노임 카탈로그 1016) — 품셈 9-5 표가 줄여 적음. 2026-09-13 사용자 위임 판정(브레인)" + } + ] +} diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json new file mode 100644 index 00000000..cd7c679b --- /dev/null +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -0,0 +1,264 @@ +{ + "schema_version": "1.0", + "dataset_id": "resource_catalog_ext", + "effective_date": "2026-01-01", + "note": "기존 카탈로그(노임·기계·관급 자재)에 없는 자원의 이름·규격·단위. 단가 칸 없음(명세 §2③·7장 보류). 규격은 조인 키 — 품셈 칸이 규격을 안 주면 매칭 성공이 아님(「규격 미정 — 후보 N」). STmate 코드는 교차 확인용 별칭일 뿐 우리 코드가 아님(명세 §11).", + "policy": { + "code_shape": "AR--<8자리 소문자 16진>", + "code_compare": "equality_only", + "spec_is_join_key": true, + "auto_adopt_single_candidate": false, + "no_price": true + }, + "entries": [ + { + "code": "AR-M-b0853497", + "kind": "material", + "name": "비료", + "spec": "복합비료", + "unit": "kg", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0135", + "F0136" + ] + } + }, + { + "code": "AR-M-61df616d", + "kind": "material", + "name": "피복제", + "spec": "화이버", + "unit": "kg", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0135", + "F0136" + ] + } + }, + { + "code": "AR-M-86ddf05f", + "kind": "material", + "name": "침식안정제", + "spec": "합성접착제", + "unit": "kg", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0135", + "F0136" + ] + } + }, + { + "code": "AR-M-ae538556", + "kind": "material", + "name": "색소", + "spec": "색소", + "unit": "kg", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0135", + "F0136" + ] + } + }, + { + "code": "AR-M-dce99e46", + "kind": "material", + "name": "말뚝", + "spec": "직경4~6㎝, 길이120㎝ 기준", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0120" + ] + } + }, + { + "code": "AR-M-3f3d8612", + "kind": "material", + "name": "브레이드", + "spec": "320~400mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0256" + ] + } + }, + { + "code": "AR-M-369b67f2", + "kind": "material", + "name": "유공관", + "spec": "∅200mm", + "unit": "m", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0345" + ] + } + }, + { + "code": "AR-M-d2c51025", + "kind": "material", + "name": "접합몰탈", + "spec": "1:2", + "unit": "㎥", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0347" + ] + } + }, + { + "code": "AR-M-f89c57d4", + "kind": "material", + "name": "종자", + "spec": "생태복원형,초본류", + "unit": "kg", + "source": { + "stmate_codes": [ + "M00086", + "M00661" + ] + } + }, + { + "code": "AR-M-d52b8fc1", + "kind": "material", + "name": "종자", + "spec": "생태복원형,목본류", + "unit": "kg", + "source": { + "stmate_codes": [ + "M00087", + "M00671" + ] + } + }, + { + "code": "AR-M-8f852514", + "kind": "material", + "name": "종자", + "spec": "초본류", + "unit": "kg", + "source": { + "stmate_codes": [ + "M01225" + ] + } + }, + { + "code": "AR-M-e39ee36e", + "kind": "material", + "name": "종자", + "spec": "목본류", + "unit": "kg", + "source": { + "stmate_codes": [ + "M01226" + ] + } + }, + { + "code": "AR-M-6fc2930f", + "kind": "material", + "name": "폭약", + "spec": "28㎜(메가마이트Ⅰ)", + "unit": "kg", + "source": { + "stmate_codes": [ + "M00815" + ] + } + }, + { + "code": "AR-M-0965627d", + "kind": "material", + "name": "뇌관", + "spec": "", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0243" + ] + } + }, + { + "code": "AR-M-a1a18ec4", + "kind": "material", + "name": "비트", + "spec": "", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0243" + ] + } + }, + { + "code": "AR-M-5257ad1e", + "kind": "material", + "name": "철망태", + "spec": "", + "unit": "㎥", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0436" + ] + } + }, + { + "code": "AR-M-7d8a0fbc", + "kind": "material", + "name": "채움재", + "spec": "", + "unit": "㎥", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0436" + ] + } + }, + { + "code": "AR-X-79f64ab7", + "kind": "machine", + "name": "종자살포기", + "spec": "2,500-3,000ℓ", + "unit": "시간", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0135", + "F0136" + ] + } + }, + { + "code": "AR-X-95cb3904", + "kind": "machine", + "name": "착암기", + "spec": "2.7㎥/min", + "unit": "hr", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0243" + ] + } + } + ] +} diff --git a/resources/tester/test_b09_resource_axis_join.py b/resources/tester/test_b09_resource_axis_join.py new file mode 100644 index 00000000..74253c29 --- /dev/null +++ b/resources/tester/test_b09_resource_axis_join.py @@ -0,0 +1,188 @@ +"""자원 축 조인 키 규칙 (2026-09-13 축 C 1장 · 명세 §2③·§5·§11·§17). + +지키는 것 + ① `AR-` 자원 코드 모양 · 종류 글자 · 겹침 — 읽을 때 막는다 + ② **규격이 조인 키** — 후보가 하나여도 규격이 안 맞으면 고르지 않는다(판정 Ⓑ) + ③ **형식이 하나뿐인 계열**만 규격으로 고른다 — 형식이 둘이면 「규격 미정」(판정 Ⓒ) + ④ **범위 별칭** — 범위 밖·판 다름이면 안 쓴다 · scope 없는 줄과 겹친 두 코드는 오류 + ⑤ 맞췄으나 단가 층이 없는 기계 줄은 일위대가에서 **조용히 안 빠지고 막힌다** +""" + +from __future__ import annotations + +import re + +import pytest + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + CatalogEntry, + ResourceAxisError, + ResourceCatalog, + build_resource_axis, + load_combined_catalog, + load_work_item_master, + match_table, +) +from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( + _read_optional, + parse_ext_entries, + parse_scoped_aliases, + resolve_family, + unmatched_reason, +) + +ALIAS = { + "axis": "resource", + "from": "화약공", + "to": "1016", + "scope": "FP-09-05", + "pum_edition": "2026-01-01", +} + + +def _table(*rows: list[str]) -> dict: + return { + "pum_table_id": "T-시험", + "pum_form": "requirement", + "basis_quantity": None, + "basis_unit": "㎥", + "raw_row": [list(row) for row in rows], + } + + +def test_자원_목록_코드_모양과_겹침(): + entries = parse_ext_entries(_read_optional("resource_catalog_ext_2026-01-01.json")["entries"]) + assert entries, "자원 목록이 비었음" + for entry in entries: + assert re.fullmatch(r"AR-[MLX]-[0-9a-f]{8}", entry.code) + assert entry.strict_spec + assert len({e.code for e in entries}) == len(entries) + with pytest.raises(ResourceAxisError): + parse_ext_entries( + [{"code": "AR-X-0000000a", "kind": "material", "name": "비료", "unit": "kg"}] + ) + with pytest.raises(ResourceAxisError): + parse_ext_entries([{"code": "M00042", "kind": "material", "name": "비료", "unit": "kg"}]) + + +def test_규격이_조인_키_후보_하나여도_자동_채택_안_함(): + catalog = ResourceCatalog( + entries=[CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True)] + ) + assert catalog.resolve("비료", "") is None + assert catalog.resolve("비료", "요소") is None + assert catalog.resolve("비료", "복합 비료").code == "AR-M-0000000a" + + +def test_옆_칸_글자_규격으로_자재가_선다_규격_없으면_규격_미정(): + catalog = ResourceCatalog( + entries=[ + CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True), + CatalogEntry("AR-M-0000000b", "종자", "material", "초본류", strict_spec=True), + ] + ) + result = AxisResult() + node = {"work_item_code": "FP-05-24-01"} + match_table( + node, + _table(["자재", "종 자", "", "kg", "0.025"], ["비 료", "복합비료", "kg", "0.1"]), + catalog, + result, + ) + assert [(r.resource_code, str(r.amount)) for r in result.rows] == [("AR-M-0000000a", "0.1")] + assert len(result.unmatched) == 1 + assert result.unmatched[0].reason.startswith("규격 미정 — 후보 1건") + + +def test_형식이_하나뿐인_계열만_규격으로_고른다(): + catalog = ResourceCatalog( + entries=[ + CatalogEntry("5205-0035", "공기압축기(이동식)", "machine", "3.5"), + CatalogEntry("5205-0103", "공기압축기(이동식)", "machine", "10.3"), + CatalogEntry("5205-5500", "물탱크(살수차)", "machine", "5,500"), + CatalogEntry("5210-0010", "소형브레이커(공압식)", "machine", "1.0㎥/min"), + CatalogEntry("5220-0015", "소형브레이커(전기식)", "machine", "1.0㎥/min"), + ] + ) + assert resolve_family(catalog, "공기압축기(3.5㎥/min)", ["x"]).code == "5205-0035" + assert ( + resolve_family(catalog, "공기압축기", ["공기압축기", "10.3㎥/min", "hr", "0.074"]).code + == "5205-0103" + ) + assert ( + resolve_family(catalog, "물탱크", ["물탱크", "5,500ℓ", "시간", "0.0036"]).code + == "5205-5500" + ) + assert resolve_family(catalog, "공기압축기(99㎥/min)", ["x"]) is None + # 형식이 둘 — 공압식·전기식은 다른 장비라 규격이 같아도 고르지 않는다 + assert ( + resolve_family(catalog, "소형브레이커", ["소형브레이커", "1.0㎥/min", "hr", "0.2"]) is None + ) + assert unmatched_reason(catalog, "소형브레이커").startswith("규격 미정 — 후보 2건") + + +def test_범위_별칭은_범위_안에서만_판이_다르면_안_씀(): + catalog = ResourceCatalog( + entries=[CatalogEntry("1016", "화약취급공", "labor")], + scoped_aliases=parse_scoped_aliases([ALIAS]), + ) + row = ["인력", "화 약 공", "", "인", "0.041"] + + inside = AxisResult() + match_table({"work_item_code": "FP-09-05-01"}, _table(row), catalog, inside) + assert [r.resource_code for r in inside.rows] == ["1016"] + + outside = AxisResult() + match_table({"work_item_code": "FP-12-10"}, _table(row), catalog, outside) + assert not outside.rows and outside.unmatched + + node = {"work_item_code": "FP-09-05-01", "tables": [_table(row)]} + other_edition = build_resource_axis( + {"effective_date": "2027-01-01", "work_items": [node]}, catalog + ) + assert not other_edition.rows, "판이 다른 별칭 범위를 썼음(명세 17장)" + same_edition = build_resource_axis( + {"effective_date": "2026-01-01", "work_items": [node]}, catalog + ) + assert [r.resource_code for r in same_edition.rows] == ["1016"] + + +def test_별칭_scope_없음과_겹친_두_코드는_오류(): + with pytest.raises(ResourceAxisError): + parse_scoped_aliases([{**ALIAS, "scope": ""}]) + with pytest.raises(ResourceAxisError): + parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-09-05-01"}]) + # 범위가 안 겹치면 같은 이름이 다른 코드로 가도 된다 + assert len(parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-12-10"}])) == 2 + + +@pytest.fixture(scope="module") +def real_axis(): + return build_resource_axis(load_work_item_master(), load_combined_catalog()) + + +def test_실데이터_자재가_서고_규격_없는_종자는_규격_미정(real_axis): + materials = { + (r.work_item_code, r.resource_name) for r in real_axis.rows if r.resource_kind == "material" + } + assert ("FP-05-24-01", "비료") in materials + assert ("FP-12-10", "유공관") in materials + seed = [u for u in real_axis.unmatched if u.work_item_code == "FP-05-24-01" and "종" in u.cell] + assert seed and seed[0].reason.startswith("규격 미정") + blasting = { + (r.resource_code, r.resource_kind) + for r in real_axis.rows + if r.work_item_code == "FP-09-05-01" + } + assert ("1016", "labor") in blasting + + +def test_단가_층_없는_기계_줄은_조용히_안_빠지고_막힌다(): + from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices + + build = build_unit_prices() + # 착암기는 카탈로그 밖(AR-X) — 맞췄으나 기계 층이 없어 단가를 막아야 한다 + assert "착암기" in (build.component_gaps.get("FP-09-05-01") or "") + assert "FP-09-05-01" in build.partial_ratio + assert any("유공관" in label for label in build.unattached.get("FP-12-10", []))