diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index a1851495..b24a0e0d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -40,1084 +40,55 @@ from __future__ import annotations -import json -from dataclasses import dataclass, field -from pathlib import Path from typing import Any, Iterable -from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( - normalize_unit, - unit_for_code, - verify_unit_matches_basis, +from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis + +# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게 +# 여기서 다시 내보낸다(2026-09-08 분리). +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401 + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + REBAR_PREFIXES, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + euroform_type, + load_mapping, + load_masonry_table, + load_rebar_table, + load_timber_table, + masonry_class, + normalize_kind_key, + placing_code, + rebar_complexity, + structure_kind, + timber_class, ) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import ( # noqa: F401 + PLACING_TARGET_NAMES, + _earthwork_rows, + _haul_rows, + _length_rows, + _material_rows, + _pipe_rows, + _placing_rows, + _preparation_rows, + _structure_rows, + blocked_of, ) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, -) -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY from common_util.common_util_quantity_spread import spread_by_unit -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}㎝ 를 덮는 갈래가 표에 없음" - - -from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label - - -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" # 수량 산출식 자체가 없음 - -#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. -SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) - -#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. -SUBTOTAL_GROUPS = frozenset({"보정량계"}) - - -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 - - -@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) - - 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: - 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 {}, - ) - - -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 [주]④ 예시에 없어 유로폼 유형을 " - "정하지 못했습니다 — 임의로 고르지 않습니다" - ) - - -def _spec_detail(structure: dict[str, Any]) -> str: - """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" - parts: list[str] = [] - height = structure.get("height_m") - length = structure.get("length_m") - if height: - parts.append(f"H={height:g}") - if length: - parts.append(f"L={length:g}m") - return "·".join(parts) - - -def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: - """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. - - 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. - 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. - """ - if ground is None or ground == "토사": - return ground, "" - method = methods.get(ground) - mapped = METHOD_TO_GROUND.get(method or "") - if mapped: - return mapped, "" - return None, NOTE_METHOD_MISSING - - -def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: - """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. - - ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, - **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 - `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — - 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. - ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. - """ - declared = str((entry or {}).get("basis_unit") or "") - if not declared or not unit: - return None - if normalize_unit(unit) == normalize_unit(declared): - return None - return ( - declared, - str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), - str((entry or {}).get("mismatch_reason") or ""), - ) - - -def _earthwork_rows( - summary_table: dict[str, Any], - mapping: WorkItemMapping, - methods: dict[str, str | None], -) -> tuple[list[dict[str, Any]], list[str]]: - """토공집계표 줄을 내역 줄로 옮긴다. - - ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. - 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. - """ - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for row in summary_table.get("rows") or []: - group = str(row.get("group") or "") - if not group: - continue - ground = row.get("item") or None - origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK - is_subtotal = group in SUBTOTAL_GROUPS - lookup_ground, method_note = _mapping_ground(ground, methods) - entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None - code = (entry or {}).get("work_item_code") - # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). - # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. - unit = str(row.get("unit") or "㎥") - amount = float(row.get("amount") or 0.0) - mismatch = _basis_mismatch(entry, unit) if code else None - spec_detail = "" - if mismatch is not None: - spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" - unit, amount = mismatch[0], 0.0 - if code is None and not is_subtotal: - label = f"{group}({ground})" if ground else group - unmatched.append(f"{label} — {method_note}" if method_note else label) - rows.append( - { - "work_item_code": code, - "name": group, - "spec": str(row.get("spec") or ""), - "unit": unit, - "quantity": amount, - # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. - "quantity_gross": row.get("amount_gross"), - "application_ratio_pct": row.get("application_ratio_pct"), - # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. - "application_ratio_breakdown": row.get("application_ratio_breakdown"), - "quantity_breakdown": row.get("quantity_breakdown"), - "ground_class": ground, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "spec_detail": spec_detail, - "composite_parts": None, - "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": "", - # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. - "blocked_kind": mismatch[1] if mismatch else None, - "blocked_reason": mismatch[2] if mismatch else "", - "composite_not_ready": None, - # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. - "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, - "excavation_method": methods.get(ground) if ground else None, - "in_bill_reason": "집계 합계 줄 — 검산용" - if is_subtotal - else str(row.get("note") or ""), - "origin": origin, - } - ) - return rows, unmatched - - -def _haul_rows( - haul_table: dict[str, Any], mapping: WorkItemMapping -) -> tuple[list[dict[str, Any]], list[str]]: - """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for row in haul_table.get("rows") or []: - equipment = str(row.get("equipment") or "") - entry = mapping.for_haul(equipment) or {} - code = entry.get("work_item_code") - in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) - if code is None and in_bill: - unmatched.append(f"운반({equipment})") - rows.append( - { - "work_item_code": code, - "name": f"{equipment} 운반", - "spec": str(row.get("ground") or ""), - "unit": "㎥", - "quantity": float(row.get("volume_m3") or 0.0), - # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": row.get("ground") or None, - "haul_distance_m": float(row.get("average_distance_m") or 0.0), - "haul_equipment": equipment, - "excavation_method": None, - "station_from": None, - "station_to": None, - "spec_detail": "", - "composite_parts": None, - "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": "", - "blocked_kind": None, - "blocked_reason": "", - "composite_not_ready": None, - "in_bill": in_bill, - "in_bill_reason": str(entry.get("reason") or ""), - "origin": ORIGIN_HAUL, - } - ) - return rows, unmatched - - -def blocked_of( - structure: dict[str, Any], class_basis: str = "", has_code: bool = False -) -> tuple[str | None, str]: - """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. - - ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. - 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. - - ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). - B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 - **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 - 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. - **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** - """ - notes = [str(note) for note in structure.get("notes") or []] - if has_code and float(structure.get("length_m") or 0.0) > 0: - return None, "" - if structure.get("components"): - # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). - if class_basis and "입력되지 않았습니다" in class_basis: - return BLOCKED_INPUT_MISSING, class_basis - return None, "" - for note in notes: - if "입력되지 않았습니다" in note: - return BLOCKED_INPUT_MISSING, note - if "자료에 없습니다" in note or "표준 물량 자료" in note: - return BLOCKED_UNIT_DATA_MISSING, note - if "산출식이 아직 없습니다" in note: - return BLOCKED_FORMULA_MISSING, note - if notes: - return BLOCKED_UNIT_DATA_MISSING, notes[0] - return None, "" - - -def _component_billing( - structure: dict[str, Any], entry: dict[str, Any] -) -> tuple[str, float] | None: - """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. - - ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × - √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. - ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 - 집는다. - """ - name = str(entry.get("billing_component") or "") - if not name: - return None - for component in structure.get("components") or []: - if str(component.get("name") or "") != name: - continue - unit = str(component.get("unit") or "") - amount = float(component.get("amount") or 0.0) - if unit and amount > 0: - return unit, amount - return None - - -def _structure_rows( - unit_quantity_table: dict[str, Any], mapping: WorkItemMapping -) -> tuple[list[dict[str, Any]], list[str]]: - """구조물 줄 — **작업 공종 하나**로 선다. - - ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, - 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). - """ - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for structure in unit_quantity_table.get("structures") or []: - type_id = str(structure.get("type_id") or "") - entry = mapping.for_structure(type_id) or {} - code = entry.get("work_item_code") - # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — - # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). - class_key: str | None = None - class_basis = "" - if entry.get("class_from") == "bond": - bond = str((structure.get("options") or {}).get("bond") or "").strip() - bond_codes = entry.get("bond_codes") or {} - if bond in bond_codes: - # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. - code = bond_codes[bond] - class_key = bond - class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" - else: - class_basis = ( - "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " - "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" - ) - if code and entry.get("class_from") == "back_length": - class_key, class_basis = masonry_class(structure.get("options") or {}) - - # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 - # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 - # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. - billing = _component_billing(structure, entry) - composite = mapping.composite_for(type_id) if code is None else None - kind = structure_kind(structure) if composite else None - parts: list[dict[str, Any]] | None = None - parts_missing: list[dict[str, Any]] = [] - if composite: - parts, parts_missing = composite_quantities(structure, composite, mapping) - # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — - # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. - counts_by_component = bool(entry.get("billing_component")) - blocked_kind, blocked_reason = blocked_of( - structure, - class_basis, - has_code=bool(code) and (billing is not None or not counts_by_component), - ) - # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. - variant_axis = str(entry.get("variant_axis") or "") or None - variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None - secondary_axes = [ - {"axis": axis, "value": (structure.get("options") or {}).get(axis)} - for axis in entry.get("secondary_axes") or [] - ] - if code is None and composite is None: - unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") - elif entry.get("class_from") in ("back_length", "bond") and class_key is None: - unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") - length = float(structure.get("length_m") or 0.0) - # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — - # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). - # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. - if billing is not None: - bill_unit, bill_quantity = billing - elif counts_by_component: - # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 - # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). - bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 - elif structure.get("billing_unit"): - bill_unit = str(structure["billing_unit"]) - bill_quantity = float(structure.get("billing_quantity") or 0.0) - else: - bill_unit, bill_quantity = "m", length - rows.append( - { - "work_item_code": code, - "name": str(structure.get("name") or type_id), - "spec": _spec_detail(structure), - "unit": bill_unit, - "quantity": bill_quantity, - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": structure.get("start_m"), - "station_to": structure.get("end_m"), - "excavation_method": None, - "spec_detail": _spec_detail(structure), - # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. - "composite_parts": parts, - # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. - "structure_kind": kind, - # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. - "blocked_kind": blocked_kind, - "blocked_reason": blocked_reason, - # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. - # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). - # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 - # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 - # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. - "variant_axis": variant_axis, - "variant_value": variant_value, - # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. - # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). - "secondary_axes": secondary_axes or None, - "spec_class": class_key, - "spec_class_basis": class_basis, - # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. - "composite_not_ready": parts_missing or None, - "in_bill": True, - "in_bill_reason": (composite or {}).get("why", ""), - "origin": ORIGIN_STRUCTURE, - } - ) - return rows, unmatched - - -#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. -#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. -#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 -#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. -#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) -PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) - - -def _placing_rows( - unit_quantity_table: dict[str, Any], - mapping: WorkItemMapping, - method: str | None, -) -> tuple[list[dict[str, Any]], list[str]]: - """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. - - ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 - 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 - `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. - ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. - - ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — - 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). - """ - code, used_default = placing_code(mapping, method) - if code is None: - return [], [] - buckets: dict[str, float] = {} - for structure in unit_quantity_table.get("structures") or []: - # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** - # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` - # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. - # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — - # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** - # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. - if mapping.composite_for(str(structure.get("type_id") or "")): - continue - volume = sum( - float(component.get("amount") or 0.0) - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES - and component.get("unit") == "㎥" - ) - if volume <= 0: - continue - buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume - rows = [ - { - "work_item_code": code, - "name": "콘크리트 타설", - "spec": kind, - "unit": "㎥", - "quantity": volume, - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "excavation_method": None, - "spec_detail": kind, - "composite_parts": None, - "structure_kind": kind, - "blocked_kind": None, - "blocked_reason": "", - "variant_axis": "structure_kind", - "variant_value": kind, - "secondary_axes": None, - "spec_class": kind, - "spec_class_basis": ( - "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" - ), - "composite_not_ready": None, - "in_bill": True, - "in_bill_reason": "", - "origin": ORIGIN_STRUCTURE, - } - for kind, volume in sorted(buckets.items()) - ] - notes: list[str] = [] - if rows and used_default: - notes.append( - "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " - "산출 조건에서 정하면 이 공종의 단가가 달라집니다" - ) - return rows, notes - - -def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: - """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. - - ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). - 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. - 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — - **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** - - ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 - 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. - """ - rows: list[dict[str, Any]] = [] - for row in preparation_table.get("rows") or []: - status = str(row.get("status") or "") - amount = row.get("amount") - ready = status == PREP_READY and amount is not None - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": str(row.get("item") or ""), - "spec": str(row.get("group") or ""), - "unit": str(row.get("unit") or ""), - "quantity": float(amount or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "excavation_method": None, - "spec_detail": str(row.get("group") or ""), - "composite_parts": None, - "structure_kind": None, - # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. - "blocked_kind": None if ready else _prep_blocked_kind(status), - "blocked_reason": "" if ready else str(row.get("reason") or status), - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("reason") or ""), - "composite_not_ready": None, - # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("reason") or status), - "origin": ORIGIN_PREPARATION, - } - ) - return rows - - -def _prep_blocked_kind(status: str) -> str | None: - """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" - if status == PREP_PENDING: - return BLOCKED_INPUT_MISSING - if status == PREP_COUNTED_ELSEWHERE: - # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. - return None - if status == PREP_NOT_APPLICABLE: - return None - return BLOCKED_UNIT_DATA_MISSING - - -def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: - """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). - - ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 - (B09 ㉡ 가드와 같은 자리). - """ - rows: list[dict[str, Any]] = [] - for row in pipe_table.get("rows") or []: - ready = bool(row.get("in_bill")) - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": f"배수관({row.get('kind')})", - "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "unit": str(row.get("unit") or "m"), - "quantity": float(row.get("quantity") or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": row.get("chainage_m"), - "station_to": row.get("chainage_m"), - "excavation_method": None, - "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": row.get("blocked_kind"), - "blocked_reason": str(row.get("blocked_reason") or ""), - # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. - "variant_axis": row.get("variant_axis"), - "variant_value": row.get("variant_value"), - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("blocked_reason") or ""), - "composite_not_ready": None, - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), - "origin": ORIGIN_PIPE, - } - ) - return rows - - -def _length_rows( - length_table: list[dict[str, Any]], mapping: WorkItemMapping -) -> list[dict[str, Any]]: - """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). - - ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** - 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. - 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 - **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). - - ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, - C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. - 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. - - ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** - """ - rows: list[dict[str, Any]] = [] - for entry in length_table or []: - type_id = str(entry.get("type_id") or "") - found = mapping.for_structure(type_id) - code = (found or {}).get("work_item_code") - length = float(entry.get("length_m") or 0.0) - raw = float(entry.get("raw_length_m") or length) - # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 - # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). - # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. - spans = [ - span - for span in (entry.get("spans") or []) - if span.get("start_m") is not None and span.get("end_m") is not None - ] - span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) - note = f"구간 {span_note}" if span_note else "" - if abs(raw - length) > 1e-9: - 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" - note = f"{note} · {겹침}" if note else 겹침 - # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 - # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). - reason = "" - if code is None: - reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" - elif length <= 0: - reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" - rows.append( - { - "work_item_code": code, - "name": str(entry.get("name") or type_id), - "spec": f"{entry.get('count')}개소", - "unit": "m", - "quantity": length, - "quantity_gross": raw if note else None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. - "station_from": spans[0]["start_m"] if spans else None, - "station_to": spans[-1]["end_m"] if spans else None, - "excavation_method": None, - "spec_detail": f"{entry.get('count')}개소", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, - "blocked_reason": reason, - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": note, - "composite_not_ready": None, - "in_bill": bool(code and length > 0), - "in_bill_reason": "" if (code and length > 0) else reason, - "origin": ORIGIN_STRUCTURE, - } - ) - return rows - - -def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: - """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" - rows: list[dict[str, Any]] = [] - for row in material_table.get("rows") or []: - rows.append( - { - "material_name": row.get("name"), - "spec": row.get("spec") or "", - "unit": row.get("unit"), - "net_amount": row.get("net_amount"), - "total_amount": row.get("total_amount"), - "surcharge_pct": row.get("surcharge_pct"), - "surcharge_note": row.get("note") or "", - "supply_type": row.get("supply"), - "install_by": row.get("install_by"), - "source_structure": row.get("sources") or [], - } - ) - return rows - def build_handoff( *, diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py new file mode 100644 index 00000000..7bdc944e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -0,0 +1,410 @@ +"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`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" # 수량 산출식 자체가 없음 + +#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) + +#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. +SUBTOTAL_GROUPS = frozenset({"보정량계"}) + + +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 + + +@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) + + 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: + 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 {}, + ) + + +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 [주]④ 예시에 없어 유로폼 유형을 " + "정하지 못했습니다 — 임의로 고르지 않습니다" + ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py new file mode 100644 index 00000000..37db599b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -0,0 +1,702 @@ +"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`Engine_Handoff` 에서 갈라냄). + +⚠ **왜 갈랐나** — 위와 같다(700줄 제한). **줄의 모양은 하나도 안 바뀐다.** +⚠ **줄 빌더가 여덟이라 칸을 하나 늘리면 여덟 곳을 함께 고쳐야 한다** — 계약 시험 + (`tmp/tests/test_b08_handoff_contract.py`)이 「모든 줄이 같은 칸을 갖는가」로 그것을 지킨다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + masonry_class, + placing_code, + structure_kind, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY +from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label + + +def _spec_detail(structure: dict[str, Any]) -> str: + """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" + parts: list[str] = [] + height = structure.get("height_m") + length = structure.get("length_m") + if height: + parts.append(f"H={height:g}") + if length: + parts.append(f"L={length:g}m") + return "·".join(parts) + + +def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: + """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. + + 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. + 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. + """ + if ground is None or ground == "토사": + return ground, "" + method = methods.get(ground) + mapped = METHOD_TO_GROUND.get(method or "") + if mapped: + return mapped, "" + return None, NOTE_METHOD_MISSING + + +def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: + """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. + + ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, + **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 + `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — + 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. + ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. + """ + declared = str((entry or {}).get("basis_unit") or "") + if not declared or not unit: + return None + if normalize_unit(unit) == normalize_unit(declared): + return None + return ( + declared, + str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), + str((entry or {}).get("mismatch_reason") or ""), + ) + + +def _earthwork_rows( + summary_table: dict[str, Any], + mapping: WorkItemMapping, + methods: dict[str, str | None], +) -> tuple[list[dict[str, Any]], list[str]]: + """토공집계표 줄을 내역 줄로 옮긴다. + + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. + 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in summary_table.get("rows") or []: + group = str(row.get("group") or "") + if not group: + continue + ground = row.get("item") or None + origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK + is_subtotal = group in SUBTOTAL_GROUPS + lookup_ground, method_note = _mapping_ground(ground, methods) + entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None + code = (entry or {}).get("work_item_code") + # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). + # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. + unit = str(row.get("unit") or "㎥") + amount = float(row.get("amount") or 0.0) + mismatch = _basis_mismatch(entry, unit) if code else None + spec_detail = "" + if mismatch is not None: + spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" + unit, amount = mismatch[0], 0.0 + if code is None and not is_subtotal: + label = f"{group}({ground})" if ground else group + unmatched.append(f"{label} — {method_note}" if method_note else label) + rows.append( + { + "work_item_code": code, + "name": group, + "spec": str(row.get("spec") or ""), + "unit": unit, + "quantity": amount, + # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. + "quantity_gross": row.get("amount_gross"), + "application_ratio_pct": row.get("application_ratio_pct"), + # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. + "application_ratio_breakdown": row.get("application_ratio_breakdown"), + "quantity_breakdown": row.get("quantity_breakdown"), + "ground_class": ground, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "spec_detail": spec_detail, + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. + "blocked_kind": mismatch[1] if mismatch else None, + "blocked_reason": mismatch[2] if mismatch else "", + "composite_not_ready": None, + # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, + "excavation_method": methods.get(ground) if ground else None, + "in_bill_reason": "집계 합계 줄 — 검산용" + if is_subtotal + else str(row.get("note") or ""), + "origin": origin, + } + ) + return rows, unmatched + + +def _haul_rows( + haul_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in haul_table.get("rows") or []: + equipment = str(row.get("equipment") or "") + entry = mapping.for_haul(equipment) or {} + code = entry.get("work_item_code") + in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) + if code is None and in_bill: + unmatched.append(f"운반({equipment})") + rows.append( + { + "work_item_code": code, + "name": f"{equipment} 운반", + "spec": str(row.get("ground") or ""), + "unit": "㎥", + "quantity": float(row.get("volume_m3") or 0.0), + # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": row.get("ground") or None, + "haul_distance_m": float(row.get("average_distance_m") or 0.0), + "haul_equipment": equipment, + "excavation_method": None, + "station_from": None, + "station_to": None, + "spec_detail": "", + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + "blocked_kind": None, + "blocked_reason": "", + "composite_not_ready": None, + "in_bill": in_bill, + "in_bill_reason": str(entry.get("reason") or ""), + "origin": ORIGIN_HAUL, + } + ) + return rows, unmatched + + +def blocked_of( + structure: dict[str, Any], class_basis: str = "", has_code: bool = False +) -> tuple[str | None, str]: + """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. + + ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. + 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. + + ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). + B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 + **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 + 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. + **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** + """ + notes = [str(note) for note in structure.get("notes") or []] + if has_code and float(structure.get("length_m") or 0.0) > 0: + return None, "" + if structure.get("components"): + # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). + if class_basis and "입력되지 않았습니다" in class_basis: + return BLOCKED_INPUT_MISSING, class_basis + return None, "" + for note in notes: + if "입력되지 않았습니다" in note: + return BLOCKED_INPUT_MISSING, note + if "자료에 없습니다" in note or "표준 물량 자료" in note: + return BLOCKED_UNIT_DATA_MISSING, note + if "산출식이 아직 없습니다" in note: + return BLOCKED_FORMULA_MISSING, note + if notes: + return BLOCKED_UNIT_DATA_MISSING, notes[0] + return None, "" + + +def _component_billing( + structure: dict[str, Any], entry: dict[str, Any] +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. + + ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × + √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. + ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 + 집는다. + """ + name = str(entry.get("billing_component") or "") + if not name: + return None + for component in structure.get("components") or []: + if str(component.get("name") or "") != name: + continue + unit = str(component.get("unit") or "") + amount = float(component.get("amount") or 0.0) + if unit and amount > 0: + return unit, amount + return None + + +def _structure_rows( + unit_quantity_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """구조물 줄 — **작업 공종 하나**로 선다. + + ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, + 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for structure in unit_quantity_table.get("structures") or []: + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + code = entry.get("work_item_code") + # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — + # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). + class_key: str | None = None + class_basis = "" + if entry.get("class_from") == "bond": + bond = str((structure.get("options") or {}).get("bond") or "").strip() + bond_codes = entry.get("bond_codes") or {} + if bond in bond_codes: + # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. + code = bond_codes[bond] + class_key = bond + class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" + else: + class_basis = ( + "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" + ) + if code and entry.get("class_from") == "back_length": + class_key, class_basis = masonry_class(structure.get("options") or {}) + + # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 + # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 + # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. + billing = _component_billing(structure, entry) + composite = mapping.composite_for(type_id) if code is None else None + kind = structure_kind(structure) if composite else None + parts: list[dict[str, Any]] | None = None + parts_missing: list[dict[str, Any]] = [] + if composite: + parts, parts_missing = composite_quantities(structure, composite, mapping) + # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — + # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. + counts_by_component = bool(entry.get("billing_component")) + blocked_kind, blocked_reason = blocked_of( + structure, + class_basis, + has_code=bool(code) and (billing is not None or not counts_by_component), + ) + # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. + variant_axis = str(entry.get("variant_axis") or "") or None + variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None + secondary_axes = [ + {"axis": axis, "value": (structure.get("options") or {}).get(axis)} + for axis in entry.get("secondary_axes") or [] + ] + if code is None and composite is None: + unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") + elif entry.get("class_from") in ("back_length", "bond") and class_key is None: + unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") + length = float(structure.get("length_m") or 0.0) + # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — + # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). + # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. + if billing is not None: + bill_unit, bill_quantity = billing + elif counts_by_component: + # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 + # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). + bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 + elif structure.get("billing_unit"): + bill_unit = str(structure["billing_unit"]) + bill_quantity = float(structure.get("billing_quantity") or 0.0) + else: + bill_unit, bill_quantity = "m", length + rows.append( + { + "work_item_code": code, + "name": str(structure.get("name") or type_id), + "spec": _spec_detail(structure), + "unit": bill_unit, + "quantity": bill_quantity, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": structure.get("start_m"), + "station_to": structure.get("end_m"), + "excavation_method": None, + "spec_detail": _spec_detail(structure), + # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. + "composite_parts": parts, + # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. + "structure_kind": kind, + # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason, + # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. + # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). + # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 + # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 + # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. + "variant_axis": variant_axis, + "variant_value": variant_value, + # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. + # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). + "secondary_axes": secondary_axes or None, + "spec_class": class_key, + "spec_class_basis": class_basis, + # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. + "composite_not_ready": parts_missing or None, + "in_bill": True, + "in_bill_reason": (composite or {}).get("why", ""), + "origin": ORIGIN_STRUCTURE, + } + ) + return rows, unmatched + + +#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. +#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. +#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 +#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. +#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) +PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) + + +def _placing_rows( + unit_quantity_table: dict[str, Any], + mapping: WorkItemMapping, + method: str | None, +) -> tuple[list[dict[str, Any]], list[str]]: + """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. + + ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 + 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 + `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. + ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. + + ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — + 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). + """ + code, used_default = placing_code(mapping, method) + if code is None: + return [], [] + buckets: dict[str, float] = {} + for structure in unit_quantity_table.get("structures") or []: + # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** + # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` + # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. + # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — + # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** + # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. + if mapping.composite_for(str(structure.get("type_id") or "")): + continue + volume = sum( + float(component.get("amount") or 0.0) + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES + and component.get("unit") == "㎥" + ) + if volume <= 0: + continue + buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume + rows = [ + { + "work_item_code": code, + "name": "콘크리트 타설", + "spec": kind, + "unit": "㎥", + "quantity": volume, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": kind, + "composite_parts": None, + "structure_kind": kind, + "blocked_kind": None, + "blocked_reason": "", + "variant_axis": "structure_kind", + "variant_value": kind, + "secondary_axes": None, + "spec_class": kind, + "spec_class_basis": ( + "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" + ), + "composite_not_ready": None, + "in_bill": True, + "in_bill_reason": "", + "origin": ORIGIN_STRUCTURE, + } + for kind, volume in sorted(buckets.items()) + ] + notes: list[str] = [] + if rows and used_default: + notes.append( + "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " + "산출 조건에서 정하면 이 공종의 단가가 달라집니다" + ) + return rows, notes + + +def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: + """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. + + ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). + 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. + 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — + **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** + + ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 + 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. + """ + rows: list[dict[str, Any]] = [] + for row in preparation_table.get("rows") or []: + status = str(row.get("status") or "") + amount = row.get("amount") + ready = status == PREP_READY and amount is not None + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": str(row.get("item") or ""), + "spec": str(row.get("group") or ""), + "unit": str(row.get("unit") or ""), + "quantity": float(amount or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": str(row.get("group") or ""), + "composite_parts": None, + "structure_kind": None, + # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. + "blocked_kind": None if ready else _prep_blocked_kind(status), + "blocked_reason": "" if ready else str(row.get("reason") or status), + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("reason") or ""), + "composite_not_ready": None, + # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("reason") or status), + "origin": ORIGIN_PREPARATION, + } + ) + return rows + + +def _prep_blocked_kind(status: str) -> str | None: + """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" + if status == PREP_PENDING: + return BLOCKED_INPUT_MISSING + if status == PREP_COUNTED_ELSEWHERE: + # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. + return None + if status == PREP_NOT_APPLICABLE: + return None + return BLOCKED_UNIT_DATA_MISSING + + +def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: + """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). + + ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 + (B09 ㉡ 가드와 같은 자리). + """ + rows: list[dict[str, Any]] = [] + for row in pipe_table.get("rows") or []: + ready = bool(row.get("in_bill")) + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": f"배수관({row.get('kind')})", + "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "unit": str(row.get("unit") or "m"), + "quantity": float(row.get("quantity") or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": row.get("chainage_m"), + "station_to": row.get("chainage_m"), + "excavation_method": None, + "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": row.get("blocked_kind"), + "blocked_reason": str(row.get("blocked_reason") or ""), + # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("blocked_reason") or ""), + "composite_not_ready": None, + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), + "origin": ORIGIN_PIPE, + } + ) + return rows + + +def _length_rows( + length_table: list[dict[str, Any]], mapping: WorkItemMapping +) -> list[dict[str, Any]]: + """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). + + ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** + 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. + 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 + **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). + + ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, + C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. + 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. + + ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** + """ + rows: list[dict[str, Any]] = [] + for entry in length_table or []: + type_id = str(entry.get("type_id") or "") + found = mapping.for_structure(type_id) + code = (found or {}).get("work_item_code") + length = float(entry.get("length_m") or 0.0) + raw = float(entry.get("raw_length_m") or length) + # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 + # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). + # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. + spans = [ + span + for span in (entry.get("spans") or []) + if span.get("start_m") is not None and span.get("end_m") is not None + ] + span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) + note = f"구간 {span_note}" if span_note else "" + if abs(raw - length) > 1e-9: + 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" + note = f"{note} · {겹침}" if note else 겹침 + # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 + # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). + reason = "" + if code is None: + reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" + elif length <= 0: + reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" + rows.append( + { + "work_item_code": code, + "name": str(entry.get("name") or type_id), + "spec": f"{entry.get('count')}개소", + "unit": "m", + "quantity": length, + "quantity_gross": raw if note else None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. + "station_from": spans[0]["start_m"] if spans else None, + "station_to": spans[-1]["end_m"] if spans else None, + "excavation_method": None, + "spec_detail": f"{entry.get('count')}개소", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": note, + "composite_not_ready": None, + "in_bill": bool(code and length > 0), + "in_bill_reason": "" if (code and length > 0) else reason, + "origin": ORIGIN_STRUCTURE, + } + ) + return rows + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows