"""구조물도 **양식형 항목** 읽기 — 양식 파일 + 구조물 제원 → 식 풀이기에 넘길 장 한 벌. 양식은 `resources/library_structure/.json`(4장 프로그램 기본 자리 · 명세 13장 식 칸 계약). 풀이는 여기 없음 — `B08_Quantity_Engine_Formula.evaluate_sheets`(TS 한 벌을 Node 로)가 풂. ⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이 수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」). ⚠ 사용자가 고친 식(PLAN 3장 ⑤)은 **양식 + 프로젝트 단위** — 산출 조건 `quantity` 구획의 `structure_formula_overrides` = `{양식 type_id: {차례: {"formula": 식}}}`. 고친 줄은 출처 `user`. 장 이름(제원 조합)에 묶지 않음 — 높이를 고치면 식이 사라짐. 제원별로 달리 쓰려면 `when` 칸. """ from __future__ import annotations import json import re from pathlib import Path from typing import Any from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length ROOT = Path(__file__).resolve().parents[1] #: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌). STANDARD_BACK_LENGTH = "standard_back_length" TEMPLATE_DIR = ROOT / "resources" / "library_structure" #: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`). OVERRIDES_KEY = "structure_formula_overrides" def load_template(type_id: str) -> dict[str, Any] | None: """프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.""" path = TEMPLATE_DIR / f"{type_id}.json" if not path.is_file(): return None return json.loads(path.read_text(encoding="utf-8")) def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None: """프로젝트에 박힌 양식(4장 가져오기) → 없으면 프로그램 기본. ⛔ 개인·회사 단은 여기서 안 읽음 — 가져온 것만 `templates` 로 옴(여는 사람마다 안 갈리게). """ return (templates or {}).get(type_id) or load_template(type_id) def _typed(value: Any, default: Any) -> Any: """제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글.""" if isinstance(default, (int, float)) and not isinstance(default, bool): try: return float(value) except (TypeError, ValueError): return default return str(value) def template_vars( template: dict[str, Any], structure: dict[str, Any], judged_slope: float, settings: dict[str, Any] | None = None, ) -> dict[str, Any]: """양식이 적은 제원 칸을 구조물·산출 조건에서 채움. 빈 칸은 양식 기본값. ⚠ 전면 기울기는 **전개가 판정한 값**을 받음 — 표준경사 판정(성절토·직고)은 식 언어 밖이라 판정 한 벌(`face_slope_ratio`)을 그대로 씀. """ options = structure.get("options") or {} values: dict[str, Any] = {} for name, spec in (template.get("vars") or {}).items(): default = spec.get("default") source = spec.get("source") if source == "judged_face_slope": values[name] = float(judged_slope) continue if source: values[name] = float(structure.get(source) or 0.0) continue if "option" in spec: raw = options.get(spec["option"]) else: raw = (settings or {}).get(spec.get("setting")) if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH: # 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮). wet = template.get("type_id") != "masonry_dry" height = float(structure.get("height_m") or 0.0) values[name] = float(standard_back_length(wet=wet, height_m=height)[0]) continue values[name] = default if raw in (None, "") else _typed(raw, default) return values #: 반올림 갈래 — 엑셀 대응(명세 13장 대응표). 사유·근거 문구에 엑셀 이름으로 보임. ROUNDING_WORDS = { "none": "안 함", "floor": "INT", "trunc": "ROUNDDOWN", "round": "ROUND", "ceil_away": "ROUNDUP", "ceil": "위로(엑셀 없음)", "round_half_even": "짝수 반올림(엑셀 없음)", } def rounding_key(rounding: dict[str, Any] | None) -> tuple[str, int]: """같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나.""" mode = str((rounding or {}).get("mode") or "none") return (mode, 0) if mode == "none" else (mode, int((rounding or {}).get("digits") or 0)) def _edit_of(row: dict[str, Any], edit: dict[str, Any]) -> dict[str, Any]: """저장된 고침 한 줄에서 **양식과 다른 칸만** — 식·반올림. 같으면 뺌(「고친 적 없음」).""" changes: dict[str, Any] = {} formula = str(edit.get("formula") or "").strip() if formula and formula != row.get("formula"): changes["formula"] = formula rounding = edit.get("rounding") if rounding and rounding_key(rounding) != rounding_key(row.get("rounding")): mode, digits = rounding_key(rounding) changes["rounding"] = {"mode": mode, "digits": digits} return changes def overridden_rows( template: dict[str, Any], overrides: dict[str, Any] | None ) -> list[dict[str, Any]]: """양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `user`, 원래 값은 따로.""" rows: list[dict[str, Any]] = [] for row in template.get("rows") or []: changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {}) if changes: row = { **row, **changes, "source": "user", "default_formula": row.get("formula"), "default_rounding": row.get("rounding"), } rows.append(row) return rows def template_sheet( template: dict[str, Any], values: dict[str, Any], overrides: dict[str, Any] | None = None ) -> dict[str, Any]: """식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로(고친 식은 얹음), 제원만 끼움.""" return { "rows": overridden_rows(template, overrides), "vars": values, "tables": template.get("tables") or {}, } #: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례). _PER_LENGTH_UNITS = frozenset({"m"}) #: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음). #: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(−6,037,712원 실측). #: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음). #: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) · #: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것. KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석") KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)" def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]: """목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로.""" names = {str(row.get("name") or "") for row in template_rows} return [ row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names ] #: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름. KEPT_NOTE_HEAD = "(남긴 전개 줄) " MISMATCH_HEAD = "⚠ 규격 다름" #: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님). _SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)") def _is_fixed(template: dict[str, Any]) -> bool: """고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름).""" rows = template.get("rows") or [] return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows) def fixed_notes( template: dict[str, Any], engine_notes: list[str], height: Any ) -> tuple[list[str], dict[str, float] | None]: """㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞). 전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을 말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함. """ item = [part for part in str(template.get("note") or "").split(" · ") if part] kept: list[str] = [] for note in engine_notes: if note in item or note.startswith(MISMATCH_HEAD): continue if note.startswith(KEPT_NOTE_HEAD): kept.append(note) elif any(name in note for name in KEEP_ENGINE_COMPONENTS): kept.append(KEPT_NOTE_HEAD + note) notes = list(dict.fromkeys(item + kept)) found = _SPEC_HEIGHT.search(str(template.get("name") or "")) if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6: return notes, None item_h, sheet_h = float(found.group(1)), float(height) notes.insert( 0, f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값" f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈", ) return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h} def _library_rows( body: dict[str, Any], solved: list[dict[str, Any]], billing: float ) -> list[dict[str, Any]]: """풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬·출처까지 실음(명세 13장).""" by_seq = {row["seq"]: row for row in body.get("rows") or []} rows: list[dict[str, Any]] = [] for result in solved: source = by_seq.get(result["seq"]) or {} unit_amount = float(result["amount"]) if result.get("amount") is not None else None rows.append( { "no": result["seq"], "name": result["name"], "spec": result.get("spec") or "", "basis": source.get("formula_text") or "", "formula": source.get("formula") or "", # 되돌릴 자리 — 고친 줄만 원래 양식 식이 따로 옴. "default_formula": source.get("default_formula") or source.get("formula") or "", "unit_amount": unit_amount, "amount": None if unit_amount is None else unit_amount * billing, "unit": source.get("unit") or "", "basis_kind": "derived", "source": source.get("source") or "library", "destination": source.get("destination") or "", "rounding": source.get("rounding"), "default_rounding": source.get("default_rounding", source.get("rounding")), "skipped": bool(result.get("skipped")), "reason": result.get("reason") or "", "error": result.get("error") or "", } ) return rows def _user_basis(row: dict[str, Any]) -> str: """사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로.""" parts = [] if row.get("formula") != row.get("default_formula"): parts.append(f"사용자 식 = {row.get('formula')} (양식 식 {row.get('default_formula')})") if rounding_key(row.get("rounding")) != rounding_key(row.get("default_rounding")): parts.append( f"사용자 반올림 = {_rounding_words(row.get('rounding'))}" f" (양식 {_rounding_words(row.get('default_rounding'))})" ) return " · ".join(parts) def _rounding_words(rounding: dict[str, Any] | None) -> str: mode, digits = rounding_key(rounding) word = ROUNDING_WORDS.get(mode, mode) return word if mode == "none" else f"{word} {digits}자리" def replace_with_templates( quantities: list[Any], inputs: list[dict[str, Any]], section_modes: dict[float, str] | None, rubble_base_thickness_m: float | None, formula_overrides: dict[str, Any] | None = None, templates: dict[str, Any] | None = None, ) -> None: """`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서). ⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄. ⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게. ⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠. ⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게. ⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그 반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림. """ from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at from common_util.common_util_structure_face_role import structure_face_role_of settings = {"rubble_base_thickness_m": rubble_base_thickness_m} targets: list[tuple[Any, dict[str, Any]]] = [] for quantity, item in zip(quantities, inputs): template = template_of(quantity.type_id, templates) if template is None or not quantity.components: continue # 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`). if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS: continue options = item.get("options") or {} face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options) sheet = { "type_id": quantity.type_id, "height_m": quantity.height_m, "options": options, "face": face, "face_reason": face_reason, } structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options} values = template_vars(template, structure, slope_of(sheet)[0], settings) overrides = (formula_overrides or {}).get(quantity.type_id) targets.append((quantity, template_sheet(template, values, overrides))) if not targets: return solved = evaluate_sheets([body for _quantity, body in targets]) for index, (quantity, body) in enumerate(targets): if solved is None: quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음") continue template = template_of(quantity.type_id, templates) or {} by_seq = {row["seq"]: row for row in body["rows"]} # 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로. # 양식이냐는 구조물 단위(`library_item`)로 따로 둠. engine_source = {c.name: c.source for c in quantity.components} # 근거 문구도 전개 것을 씀 — 「강도 210 — 안 정해 기본값」·「판정 1:0.3 성토」처럼 # **그 구조물에서 왜 그 값인지**가 들어 있어 양식의 붙박이 설명보다 많이 말함(값은 같음). # ⚠ 사용자가 식을 고친 줄은 전개 문구가 틀리므로 「사용자 식」 문구로 감. engine_basis = {c.name: c.basis for c in quantity.components} components = [] for result in solved[index]: if result.get("skipped"): continue source = by_seq.get(result["seq"]) or {} if result.get("error"): quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}") continue # 돌 줄도 이름 「돌」 + 규격에 종류 그대로(명세 13장 Ⓒ) — 자재총괄이 규격까지 보고 묶음. name = str(result["name"]) user = source.get("source") == "user" basis = ( _user_basis(source) if user else engine_basis.get(name) or str(source.get("formula_text") or "") ) components.append( Component( name, str(source.get("unit") or ""), float(result["amount"]) * quantity.length_m, str(source.get("destination") or ""), basis, source="user" if user else engine_source.get(name, ""), spec=str(result.get("spec") or ""), ) ) quantity.components = components + _kept( quantity.components, body["rows"], lambda component: component.name ) if _is_fixed(template): quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m) # 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음. quantity.library_item = str(template.get("name") or quantity.type_id) def apply_templates( payload: dict[str, Any], settings: dict[str, Any] | None = None, templates: dict[str, Any] | None = None, ) -> None: """구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침). ⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장). ⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음. ⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례. ⚠ 화면이 조작 중 **왕복 없이** 다시 풀 수 있게 풀이 장 한 벌(`formula_sheet`)을 함께 실음. """ from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of all_overrides = (settings or {}).get(OVERRIDES_KEY) or {} targets: list[tuple[dict[str, Any], dict[str, Any]]] = [] for sheet in payload.get("sheets") or []: template = template_of(str(sheet.get("type_id") or ""), templates) if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS: for row in sheet.get("rows") or []: row.setdefault("formula", "") row["source"] = row.get("source") or "auto" continue structure = { "height_m": sheet.get("height_m"), "length_m": 1.0, "options": sheet.get("options"), } values = template_vars(template, structure, slope_of(sheet)[0], settings) body = template_sheet(template, values, all_overrides.get(sheet.get("type_id"))) targets.append((sheet, body)) if not targets: return solved = evaluate_sheets([body for _sheet, body in targets]) for index, (sheet, body) in enumerate(targets): template = template_of(str(sheet.get("type_id") or ""), templates) or {} if solved is None: sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음") continue members = sheet.get("members") or [] billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0 library_rows = _library_rows(body, solved[index], billing) start = max((row["no"] for row in library_rows), default=0) kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name")) sheet["rows"] = library_rows + [ { **row, "no": start + offset, "formula": "", "default_formula": "", "rounding": None, "default_rounding": None, "skipped": False, "reason": KEPT_ROW_REASON, "error": "", } for offset, row in enumerate(kept, start=1) ] sheet["unpriced_rows"] = [ row["name"] for row in sheet["rows"] if row["unit_amount"] is None and not row["skipped"] ] if _is_fixed(template): # ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음). sheet["notes"], sheet["spec_mismatch"] = fixed_notes( template, sheet.get("notes") or [], sheet.get("height_m") ) # 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전). sheet["library_item"] = { "type_id": template.get("type_id"), "name": template.get("name"), "code": template.get("code"), "imported_from": template.get("imported_from"), } sheet["formula_sheet"] = body # 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ). sheet["var_candidates"] = [ { "name": name, "label": spec.get("label") or name, "value": body["vars"].get(name), "candidates": spec["candidates"], } for name, spec in (template.get("vars") or {}).items() if spec.get("candidates") ] def save_sheet_overrides( current: dict[str, Any] | None, key: str, template: dict[str, Any], edits: list[dict[str, Any]], ) -> tuple[dict[str, Any], int]: """양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수. 한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로. ⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면 양식이 바뀌어도 그 장만 옛 값으로 남음. ⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게. """ known = {row["seq"]: row for row in template.get("rows") or []} merged = {name: dict(rows) for name, rows in (current or {}).items()} sheet = merged.get(key, {}) changed = 0 for edit in edits: seq = int(edit["seq"]) if seq not in known: raise ValueError(f"양식에 없는 줄 차례: {seq}") entry = _edit_of(known[seq], edit) before = sheet.get(str(seq)) if not entry: if sheet.pop(str(seq), None) is not None: changed += 1 continue if before != entry: changed += 1 sheet[str(seq)] = entry if sheet: merged[key] = sheet else: merged.pop(key, None) return merged, changed