"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3). [저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: - 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사 (`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM · `자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`) - 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트 값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로 - 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) 원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것. """ from __future__ import annotations import hashlib import json import logging import shutil from datetime import datetime from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import snapshot_folder_name from M02_MasterTemplete import M02_Template_Layers as layers logger = logging.getLogger(__name__) ITEMS_DIRNAME = "items" MANIFEST_NAME = "manifest.json" TABLE_NAME = "구조물집계표" UM_FILE = "자재조합.json" UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`) #: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) def items_dir(project_root: str | Path) -> Path: return layers.project_dir(project_root) / ITEMS_DIRNAME def layer_root(item: dict[str, Any]) -> Path: """항목 원본 층 뿌리 — 시스템 · 회사 · 개인(21-2 층 폴더 함수가 오면 그리로).""" if item["layer"] == "system": return layers.system_dir() if item["layer"] == "company": return layers.company_dir(item["owner"]) company, user = str(item["owner"]).split("/") return layers.personal_dir(company, user) def _read(path: Path) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None def _row(document: Any, key: str) -> dict[str, Any] | None: rows = document.get("줄") if isinstance(document, dict) else None for row in rows or []: if isinstance(row, dict) and row.get("키") == key: return row return None def _labor_rows(ua_key: str) -> dict[str, Any] | None: """UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`).""" from M01_MasterData import M01_MasterData_Store as m01 ua = _row(_read(Path(m01.FOLDER) / UA_FILE), ua_key) if ua is None: return None wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")] found: dict[str, dict[str, Any]] = {} raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음 for key in wanted: try: file = m01.logic(key)["file"] except m01.StoreError: continue if file not in raw: raw[file] = _read(Path(m01.FOLDER) / file) row = _row(raw[file], key) if row is not None: found[key] = row return { "줄": ua, "로직": [found[key] for key in wanted if key in found], "빠진로직": [key for key in wanted if key not in found], } def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: """원본 층에서 항목 내용을 모음 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" root = layer_root(item) row = _row(_read(root / layers.COMBO_FILE), item["key"]) if row is None: raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음") column_id = str(row.get("열") or "") table = _read(root / "table" / f"{TABLE_NAME}.json") or {} column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None) files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}} for kind, name in PART_FILES: part = _read(root / kind / f"{column_id}.json") if column_id else None if part is not None: files[name] = part if row.get("자재조합"): um = _row(_read(root / UM_FILE), str(row["자재조합"])) if um is not None: files["자재조합.json"] = um if row.get("일위대가"): labor = _labor_rows(str(row["일위대가"])) if labor is not None: files["일위대가.json"] = labor return files def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]: """`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`).""" found: dict[str, tuple[dict[str, Any], list[str]]] = {} def add(item: dict[str, Any] | None, owner_id: str) -> None: if item: found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id) _, structures = load_structures(str(project_root)) for index, structure in enumerate(structures): add(structure.summary_item, structure.structure_id or f"#{index}") for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}") return found def _version(files: dict[str, Any]) -> str: raw = json.dumps(files, ensure_ascii=False, sort_keys=True).encode("utf-8") return hashlib.sha256(raw).hexdigest()[:16] def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: """지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름.""" base = items_dir(project_root) wanted = assignments(project_root) made: list[str] = [] failed: list[str] = [] for folder, (item, users) in sorted(wanted.items()): target = base / folder manifest = _read(target / MANIFEST_NAME) if isinstance(manifest, dict): if manifest.get("구조물") != users: atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users}) continue try: files = build_snapshot(item) except (LookupError, ValueError) as error: logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error) failed.append(folder) continue target.mkdir(parents=True, exist_ok=True) for name, content in files.items(): atomic_write_json(target / name, content) # manifest 는 마지막 — 있으면 다 쓴 폴더(도중에 끊기면 다음 저장이 다시 씀) atomic_write_json( target / MANIFEST_NAME, { "원본": item, "판": _version(files), "적용일": datetime.now().isoformat(timespec="seconds"), "구조물": users, }, ) made.append(folder) removed: list[str] = [] if base.is_dir(): for child in sorted(base.iterdir()): if child.is_dir() and child.name not in wanted: shutil.rmtree(child) removed.append(child.name) return {"만듦": made, "지움": removed, "못만듦": failed} def safe_sync(project_root: str | Path) -> None: """저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤).""" try: result = sync_snapshots(project_root) if any(result.values()): logger.info("집계 항목 스냅샷: %s", result) except Exception: logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root) def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None: """프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None.""" data = _read(items_dir(project_root) / snapshot_folder_name(item) / "집계.json") return data if isinstance(data, dict) else None