"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`Engine_Handoff` 에서 갈라냄). ⚠ **왜 갈랐나** — `Engine_Handoff.py` 가 1,279줄로 700줄 제한의 두 배였다(2026-09-08). **인계 계약(줄의 모양)은 하나도 안 바뀐다** — 파일만 가른다. 부르는 쪽은 종전대로 `B08_Quantity_Engine_Handoff` 에서 그대로 가져다 쓴다(그쪽이 다시 내보낸다). 여기 있는 것 — 매핑표 읽기 · `WorkItemMapping` · 갈래표(돌쌓기·철근·유로폼·목재공작물) · 묶음 전개 · 줄의 출처·막힘 갈래 이름. """ from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from typing import Any DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" DATASET_PREFIX = "work_item_mapping_" #: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이 #: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다. REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" REBAR_PREFIX = "rebar_complexity_" #: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다). MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" MASONRY_PREFIX = "masonry_class_" #: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다. TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber" TIMBER_PREFIX = "timber_structure_class_" def load_timber_table(path: Path | None = None) -> dict[str, Any]: """목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" target = path if target is None: files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else [] target = files[-1] if files else None if target is None or not target.is_file(): return {} return json.loads(target.read_text(encoding="utf-8")) def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]: """(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.** ⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다. 밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③). """ found = table if table is not None else load_timber_table() for row in (found or {}).get("type_map") or []: if row.get("type_id") == type_id and row.get("class"): basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」" if row.get("provisional"): basis += f" · ⚠ 잠정 — {row.get('compare', '')}" return str(row["class"]), basis, bool(row.get("provisional")) return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False def load_masonry_table(path: Path | None = None) -> dict[str, Any]: """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" target = path if target is None: files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else [] target = files[-1] if files else None if target is None or not target.is_file(): return {} return json.loads(target.read_text(encoding="utf-8")) def masonry_class( options: dict[str, Any], table: dict[str, Any] | None = None ) -> tuple[str | None, str]: """돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다. ⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다. **저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다. """ found = table if table is not None else load_masonry_table() spec = (found or {}).get("back_length") or {} from B08_Quantity.B08_Quantity_Wording import option_missing option_key = str(spec.get("option_key") or "back_len_cm") raw = options.get(option_key) if raw is None: # ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지. # 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다. return None, option_missing(option_key) + " (단가 갈래를 못 고름)" try: value = float(raw) except (TypeError, ValueError): return None, f"뒷길이 값을 못 읽음({raw!r})" for row in spec.get("classes") or []: if value <= float(row["max_cm"]): return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간" return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" def normalize_kind_key(label: str) -> str: """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). 원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다. ⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을 여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다. """ return "".join(str(label).split()) #: 줄이 어디서 왔나 — 되짚을 때 쓴다. ORIGIN_EARTHWORK = "earthwork" ORIGIN_STRUCTURE = "structure" ORIGIN_SLOPE = "slope" ORIGIN_HAUL = "haul" ORIGIN_PREPARATION = "preparation" ORIGIN_PIPE = "pipe" #: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 #: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" #: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 — #: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이 #: 아니라 공종 이름이라 성분 목록에는 안 온다. REBAR_PREFIXES = ("이형철근", "원형철근", "철근") #: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 #: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다. BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. #: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다 #: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가 #: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다. from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS, ) SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. SUBTOTAL_GROUPS = frozenset({"보정량계"}) #: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측). #: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는 #: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이 #: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각). #: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총 #: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다. HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"}) NOTE_HAUL_IN_SUMMARY = ( "집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이" " 두 줄이 됨(검산용으로만 실림)" ) def _latest_dataset_path(directory: Path | None = None) -> Path | None: folder = directory or DATASET_DIR if not folder.is_dir(): return None files = sorted(folder.glob(DATASET_PREFIX + "*.json")) return files[-1] if files else None #: ⚠ **공종 수량 단위가 아닌 것** — 품셈 절 머리에 섞여 있는 모양들이다(2026-09-08 B09 전수). #: `(㎥/1대, 1일)` 는 **시공량**이라 「1대가 하루에 몇 ㎥」이고, 밑수(「1㎥ 에 얼마」)의 #: **역수**다. 그대로 밑수로 받으면 그 공종이 조용히 뒤집힌 단위를 갖는다. #: `(일당)`·`(조)`·`(인)` 은 **품의 단위**이지 공종 단위가 아니다. NON_QUANTITY_UNITS = frozenset({"일당", "조", "인", "일", "대", "인당"}) def is_quantity_unit(unit: str) -> bool: """그 글자가 **공종 수량의 밑수 단위**로 쓸 수 있는가. ⚠ **좁게 본다** — 애매하면 안 받는다. 안 받으면 대조가 없을 뿐이고, 잘못 받으면 **틀린 단위로 검사를 통과시킨다**(그쪽이 더 나쁘다). """ text = str(unit or "").strip() if not text or text in NON_QUANTITY_UNITS: return False # 「㎥/1대, 1일」처럼 나눗셈·쉼표가 있으면 시공량이거나 밑수가 둘이다. return "/" not in text and "," not in text @dataclass class WorkItemMapping: """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" effective_date: str = "" earthwork: list[dict[str, Any]] = field(default_factory=list) haul: list[dict[str, Any]] = field(default_factory=list) structure: list[dict[str, Any]] = field(default_factory=list) pending_user: dict[str, Any] = field(default_factory=dict) composite: dict[str, Any] = field(default_factory=dict) concrete_placing: dict[str, Any] = field(default_factory=dict) unit_conversion: dict[str, Any] = field(default_factory=dict) #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. pipe: dict[str, Any] = field(default_factory=dict) #: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로 #: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다). ground_aliases: dict[str, Any] = field(default_factory=dict) def declared_units(self) -> dict[str, str]: """공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다. ⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가 밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를 함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다. """ found: dict[str, str] = {} for row in (*self.earthwork, *self.haul, *self.structure): code, unit = row.get("work_item_code"), row.get("basis_unit") if code and unit and is_quantity_unit(str(unit)): found[str(code)] = str(unit) return found def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" exact = [ row for row in self.earthwork if row.get("group") == group and row.get("ground") == ground ] if exact: return exact[0] # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] return loose[0] if loose else None def for_haul(self, equipment: str) -> dict[str, Any] | None: for row in self.haul: if row.get("equipment") == equipment: return row return None def for_structure(self, type_id: str) -> dict[str, Any] | None: for row in self.structure: if row.get("type_id") == type_id: return row return None def composite_for(self, type_id: str) -> dict[str, Any] | None: """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. """ for row in self.composite.get("items") or []: if row.get("type_id") == type_id: return row return None def load_mapping(path: Path | None = None) -> WorkItemMapping: """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" target = path or _latest_dataset_path() if target is None or not target.is_file(): return WorkItemMapping() payload = json.loads(target.read_text(encoding="utf-8")) return WorkItemMapping( effective_date=str(payload.get("effective_date") or ""), earthwork=list(payload.get("earthwork") or []), haul=list(payload.get("haul") or []), structure=list(payload.get("structure") or []), pending_user=payload.get("pending_user") or {}, composite=payload.get("composite") or {}, concrete_placing=payload.get("concrete_placing") or {}, pipe=payload.get("pipe") or {}, unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, ground_aliases=payload.get("ground_aliases") or {}, ) def composite_quantities( structure: dict[str, Any], composite: dict[str, Any], mapping: WorkItemMapping, ) -> tuple[list[dict[str, Any]], list[str]]: """묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유). ⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로 못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다. ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. """ # ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면 # **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다. if not (structure.get("components") or []): note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" return [], [{"code": None, "reason": note}] amounts: dict[str, tuple[float, str]] = {} for component in structure.get("components") or []: name = str(component.get("name") or "").strip() amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) parts: list[dict[str, Any]] = [] missing: list[str] = [] for spec in composite.get("parts") or []: if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다 parts.append({"code": str(spec)}) continue sources = list(spec.get("from_components") or []) found = [name for name in sources if name in amounts] total = sum(amounts[name][0] for name in found) if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": total *= kg_to_ton kinds = { component.get("basis_kind") for component in structure.get("components") or [] if str(component.get("name") or "").strip() in found } suffix = spec.get("kind_suffix") entry: dict[str, Any] = { "code": spec.get("code"), "name": spec.get("name"), "unit": spec.get("unit"), "quantity": total if found else None, # 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다. "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), "from_components": sources, } if spec.get("incomplete_note"): # ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다. # 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다. entry["incomplete_note"] = spec["incomplete_note"] if suffix == "euroform_type": kind, why = euroform_type(str(structure.get("type_id") or "")) entry["kind"] = normalize_kind_key(kind) if kind else None entry["kind_label"] = kind # 원문 문구 그대로 entry["kind_basis"] = why if kind: entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}" else: entry["not_ready"] = True entry["why"] = why missing.append({"code": spec.get("code"), "reason": why}) if suffix == "rebar_complexity": # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. complexity, why = rebar_complexity( str(structure.get("type_id") or ""), structure.get("options") or {} ) entry["kind"] = normalize_kind_key(complexity) if complexity else None entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함) entry["kind_basis"] = why if complexity: entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}" else: entry["not_ready"] = True entry["why"] = why missing.append({"code": spec.get("code"), "reason": why}) if spec.get("not_ready") or not found: entry["not_ready"] = True entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") # ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다. missing.append({"code": spec.get("code"), "reason": entry["why"]}) parts.append(entry) return parts, missing def structure_kind(structure: dict[str, Any]) -> str: """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** 사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이 실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다. """ for component in structure.get("components") or []: name = str(component.get("name") or "").strip() if any(name.startswith(prefix) for prefix in REBAR_PREFIXES): return "철근구조물" return "무근구조물" def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]: """(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다.""" table = mapping.concrete_placing or {} codes = table.get("method_codes") or {} default = str(table.get("default_method") or "") if method in codes: return codes[method], False return codes.get(default), True def load_rebar_table(path: Path | None = None) -> dict[str, Any]: """철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다.""" target = path if target is None: files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else [] target = files[-1] if files else None if target is None or not target.is_file(): return {} return json.loads(target.read_text(encoding="utf-8")) def rebar_complexity( type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None ) -> tuple[str | None, str]: """(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`. 품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**· 교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」. 사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.** """ found = table if table is not None else load_rebar_table() form = options.get("form") fallback: dict[str, Any] | None = None for row in found.get("form_map") or []: if row.get("type_id") != type_id: continue if "form" not in row: fallback = row continue if row.get("form") == form: if row.get("class"): return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」" return None, str(row.get("why") or "원문 예시에 없음") if fallback and fallback.get("class"): return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" from B08_Quantity.B08_Quantity_Wording import type_label detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" return None, ( f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 " "철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다" ) def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: """유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래 (12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다. ⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고 1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다. """ from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table found = table if table is not None else load_formwork_table().euroform_type for row in (found or {}).get("type_map") or []: if row.get("type_id") == type_id and row.get("class"): return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」" from B08_Quantity.B08_Quantity_Wording import type_label return None, ( f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 " "정하지 못했습니다 — 임의로 고르지 않습니다" )