"""B07 도면 지문(508-3b) — 도면마다 입력 지문을 **그리기 없이** 싸게 센다. 지문이 같으면 같은 그림 — 화면 미리받기가 바뀐 도면만 다시 받고(횡단 장 = 측점 지문 모음), 평면도 GET 이 서버 파생 저장본(`_derived/b07_plan_<도면 id>/`)을 그대로 낸다. ⚠ 입력 하나라도 빠지면 옛 그림 — 도면 조립(`_Router_Support._read_drawing`)이 읽는 것을 고치면 여기도. 파일 도장 = 작은 파일은 **내용** 해시(B06 [저장]이 같은 내용으로 다시 쓰는 종단 · 횡단 파일) · 큰 파일(지형 · 모델)은 시각:크기. """ from __future__ import annotations import hashlib import json import logging from functools import cache, lru_cache from pathlib import Path from typing import Any from uuid import UUID from B05_Profile.B05_Profile_Engine_Sections import cross_station, find_cross_file from B05_Profile.B05_Profile_Structures_Repository import structures_file_path from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import LEGACY_PIECES from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import CROSS_SHEET_ID from B07_DesignDetail.B07_DesignDetail_Engine_Template import system_template_path from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _design_root, _read_json, _read_manifest, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( PLAN_KIND_ID, WATERSHED_LAYOUT, list_kinds, ) from common_util.common_util_derived_store import ( derived_dir, derived_key, file_stamp, prune_derived, ) from common_util.common_util_drainage_pipes import ( detail_basins_path, pipe_points_path_in, route_signature, ) from M02_MasterTemplete.M02_Template_Layers import project_dir logger = logging.getLogger(__name__) #: 판 번호 — 지문 짜임을 바꾸면 올림(옛 저장본 · 화면 기억을 한 번에 버림) FINGERPRINT_VERSION = 1 _CONTENT_MAX = 8 * 1024 * 1024 _SLOPE_PIECES = {*LEGACY_PIECES["slope_outline"], "slope_ticks"} _MASS_HAUL_ID = "mass_haul" _STANDARD_ID = "cross_standard" @lru_cache(maxsize=4096) def _content(path_str: str, mtime_ns: int, size: int) -> str: return hashlib.blake2b(Path(path_str).read_bytes(), digest_size=12).hexdigest() def _canon(value: Any) -> Any: """값 꼴만 다른 것은 같게 — 4.0 = 4(브라우저를 거친 저장이 정수로 바꿈) · 열쇠는 글.""" if isinstance(value, float) and value.is_integer(): return int(value) if isinstance(value, dict): return {str(_canon(key)): _canon(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_canon(item) for item in value] return value def _key(*parts: Any) -> str: return derived_key(*_canon(list(parts))) def _without_warnings(value: Any) -> Any: """경고 글(`warnings`)은 B07 이 안 그림 — 다시 셈 · 글 꼴만 바뀌어도 종단 지문이 안 흔들리게.""" if isinstance(value, dict): return {k: _without_warnings(v) for k, v in value.items() if k != "warnings"} if isinstance(value, list): return [_without_warnings(item) for item in value] return value @lru_cache(maxsize=64) def _longitudinal(path_str: str, mtime_ns: int, size: int) -> str: data = json.loads(Path(path_str).read_text(encoding="utf-8")) return _key(_without_warnings(data)) def longitudinal_stamp_of(path: Path) -> str | None: """종단 파일 지문 — 내용(경고 글 뺌 · 값 꼴 맞춤) · 없거나 깨졌으면 None.""" try: info = path.stat() return _longitudinal(str(path), info.st_mtime_ns, info.st_size) except (OSError, ValueError): return None def stamp(path: Path | str | None) -> str | None: """파일 도장 — 8 MB 이하 = 내용 해시(시각 · 크기로 기억) · 넘으면 시각:크기 · 없으면 None.""" if path is None: return None try: info = Path(path).stat() except OSError: return None if info.st_size > _CONTENT_MAX: return file_stamp(Path(path)) try: return _content(str(path), info.st_mtime_ns, info.st_size) except OSError: return None def tree_stamp(folder: Path, skip: tuple[str, ...] = ()) -> dict[str, str | None]: """폴더 아래 파일 전부 {상대 경로: 도장}(없으면 빈 dict).""" if not folder.is_dir(): return {} return { path.relative_to(folder).as_posix(): stamp(path) for path in sorted(folder.rglob("*")) if path.is_file() and not path.name.endswith(".tmp") and path.name not in skip } @cache def code_stamp() -> str: """B07 엔진 코드 도장 — 배포 · 고침이 옛 저장본을 그대로 내지 않게(프로세스마다 한 번).""" folder = Path(__file__).resolve().parent return derived_key({path.name: file_stamp(path) for path in sorted(folder.glob("*.py"))}) def _settings(project_root: Path) -> dict[str, Any]: """B07 설정 파일(축척 · 장 배치 · 표식 · 평면도 종류) — manifest · 그린 결과 폴더는 뺌.""" root = _design_root(project_root) files = sorted(root.glob("*.json")) if root.is_dir() else [] return { "files": {path.name: stamp(path) for path in files if path.name != "manifest.json"}, "plan_kinds": tree_stamp(root / "plan_kinds"), } def _facilities(project_root: Path) -> dict[str, str | None]: """시설 정본 — 구조물 · 관 지점.""" return { "structures": stamp(structures_file_path(str(project_root))), "pipes": stamp(pipe_points_path_in(project_root)), } def _plan_world(project_root: Path, context: Any, longitudinal_stamp: str | None) -> str: """평면도 · 유역도가 함께 기대는 노선 · 지형 · 배경 — 종류 · 장마다 같음.""" return _key( route_signature(list(context.vertices)), context.crs, context.surface_params, tree_stamp(project_root / "B03_FileInput" / "input"), tree_stamp(project_root / "B04_PreProcess" / "processed"), tree_stamp(project_root / "B04_PreProcess" / "models"), tree_stamp(project_root / "B05_Profile" / "route"), stamp(detail_basins_path(context.stored_path)), _facilities(project_root), longitudinal_stamp, ) def _neighbors(chainages: list[int | float], start: float, end: float) -> list[int | float]: """구간 안 측점 + 앞뒤 하나(사면 선이 이웃 측점까지 읽음).""" inside = [index for index, value in enumerate(chainages) if start <= value <= end] if not inside: return [] return chainages[max(inside[0] - 1, 0) : inside[-1] + 2] def compute_fingerprints( project_root: Path, longitudinal_path: Path, drawings: list[Any], sources: dict[str, Any], ) -> dict[str, dict[str, Any]]: """{도면 id: {"fingerprint", ["stations"]}} — 동기 · 파일만 읽음(DB 값은 `sources`). `sources` = 라우터가 모은 DB · 문맥 값 — title(표제란) · designs({측점: 설계}) · detail({측점: 종횡단 상세 칸}) · overlays({측점: 덧그림 범위}) · context(배수 문맥) · mass_haul · standard · sheets(횡단 장 계획) · elevations({측점: 계획고}) · cross_inputs(측점 → 장 입력 조각 함수). 고르지 않은 종류에 쓰는 값은 없어도 됨. """ manifest = _read_manifest(project_root)["drawings"] longitudinal_stamp = longitudinal_stamp_of(longitudinal_path) templates = { "project": tree_stamp(project_dir(project_root)), # 도각 · 구조물 사본 "system": tree_stamp(system_template_path().parent), } common = _key( FINGERPRINT_VERSION, code_stamp(), templates, _settings(project_root), sources.get("title") or {}, ) designs = sources.get("designs") or {} out: dict[str, dict[str, Any]] = {} sheets: dict[str, dict[str, Any]] = { sheet["id"]: sheet for sheet in sources.get("sheets") or [] } kinds: dict[str, dict[str, Any]] | None = None world: str | None = None for item in drawings: drawing_id = item.id entry = manifest.get(drawing_id) or {} saved = _design_root(project_root) / "drawings" / f"{drawing_id}.json" head = (common, entry, stamp(saved) if entry.get("confirmed") else None) extra: Any = None stations: dict[str, str] | None = None if drawing_id == _MASS_HAUL_ID: extra = (longitudinal_stamp, sources.get("mass_haul")) elif drawing_id == _STANDARD_ID: extra = sources.get("standard") elif item.kind == "longitudinal": extra = longitudinal_stamp elif item.kind == "structure": extra = _facilities(project_root) elif DEVELOPMENT_ID.fullmatch(drawing_id): extra = (longitudinal_stamp, designs, _facilities(project_root)) elif CROSS_SHEET_ID.fullmatch(drawing_id) and drawing_id in sheets: sheet = sheets[drawing_id] stations = { str(chainage): _station_fingerprint(sources, chainage) for chainage in sheet["chainages"] } extra = (sheet, sources.get("elevations"), stations) elif PLAN_KIND_ID.fullmatch(drawing_id) and sources.get("context") is not None: if kinds is None: kinds = {kind["id"]: kind for kind in list_kinds(project_root)} world = _plan_world(project_root, sources["context"], longitudinal_stamp) extra = _plan_extra(project_root, longitudinal_path, drawing_id, kinds, world, designs) out[drawing_id] = {"fingerprint": _key(*head, item.kind, extra)[:16]} if stations is not None: out[drawing_id]["stations"] = stations return out def _station_fingerprint(sources: dict[str, Any], chainage: int | float) -> str: """측점 하나 — 장 입력 조각(원본 · 설계 · 계획선 · 표 · 글) · 상세 칸(화면 구조물) · 덧그림.""" return _key( sources["cross_inputs"](chainage), (sources.get("detail") or {}).get(chainage), (sources.get("overlays") or {}).get(chainage), )[:16] def _plan_extra( project_root: Path, longitudinal_path: Path, drawing_id: str, kinds: dict[str, dict[str, Any]], world: str | None, designs: dict[int | float, Any], ) -> Any: """평면도 한 장 — 종류 정의 · 장 번호 + 노선 세계 + (사면 조각이면) 그 장 측점 설계 · 원본.""" match = PLAN_KIND_ID.fullmatch(drawing_id) kind = kinds.get(match.group(1)) if match else None if kind is None: return (world, None) number = int(match.group(2)) if match.group(2) else 1 if kind["layout"] == WATERSHED_LAYOUT or not _SLOPE_PIECES & set(kind["pieces"]): return (world, kind, number) longitudinal = _read_json(longitudinal_path) stations = [ (float(row["chainage_m"]), float(row.get("center_x", 0)), float(row.get("center_y", 0))) for row in longitudinal.get("stations") or [] if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float)) ] chunk = next( (item for item in plan_chunks(stations, kind["scale"]) if item["number"] == number), None ) if chunk is None: return (world, kind, number) chainages = sorted({cross_station(value) for value, _x, _y in stations}) near = _neighbors(chainages, float(chunk["start_m"]), float(chunk["end_m"])) cross_dir = longitudinal_path.parent.parent / "cross_sections" return ( world, kind, number, {str(c): (designs.get(c), stamp(find_cross_file(cross_dir, c))) for c in near}, ) #: 평면도 파생 저장본 종류 접두 — `_derived/b07_plan_<도면 id>/<지문>.json`(응답 그대로 · 최근 2벌) PLAN_DERIVED = "b07_plan_" def read_plan_copy(project_root: Path, drawing_id: str, key: str) -> bytes | None: """지문이 같은 평면도 응답(JSON 글 그대로) — 없으면 None · 읽기만(지우지 않음).""" path = derived_dir(project_root, PLAN_DERIVED + drawing_id) / f"{key}.json" try: body = path.read_bytes() path.touch() # 최근에 쓴 것 = 정리 때 남김 except OSError: return None return body or None def write_plan_copy(project_root: Path, drawing_id: str, key: str, body: bytes) -> None: """원자 쓰기 뒤 그 도면의 옛 지문을 정리(최근 2벌).""" folder = derived_dir(project_root, PLAN_DERIVED + drawing_id) folder.mkdir(parents=True, exist_ok=True) temporary = folder / f".{key}.tmp" temporary.write_bytes(body) temporary.replace(folder / f"{key}.json") prune_derived(project_root, PLAN_DERIVED + drawing_id) async def plan_copy_key( project_id: UUID, route_id: int, project_root: Path, longitudinal_path: Path, drawing_id: str, title: dict[str, str], context: Any, designs: dict[int | float, Any], ) -> str | None: """평면도 저장본 열쇠 = 그 장 지문 — 확정 저장본(manifest 길) · 못 셈 = None(저장본 안 씀).""" from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem if _read_manifest(project_root)["drawings"].get(drawing_id, {}).get("confirmed"): return None item = DesignDrawingItem(id=drawing_id, kind="plan", label=drawing_id) try: prints = await drawing_fingerprints( project_id, route_id, project_root, longitudinal_path, [item], title, context=context, designs=designs, ) # fmt: skip except Exception: logger.warning("B07 평면도 지문 실패 — 저장본 없이 그림: %s", drawing_id, exc_info=True) return None return prints[drawing_id]["fingerprint"] async def standard_cross_source(project_id: UUID) -> Any: """표준 횡단면 설정(B06) — GET 과 지문이 같은 값을 씀.""" from B06_Section.B06_Section_Repository import get_project_standard_cross_section from config.config_db import get_db_pool pool = get_db_pool() async with pool.acquire() as connection: async with connection.cursor() as cursor: await cursor.execute( "SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL", (str(project_id),), ) row = await cursor.fetchone() if not row: return None return await get_project_standard_cross_section(connection, int(row[0]), project_id) async def drawing_fingerprints( project_id: UUID, route_id: int, project_root: Path, longitudinal_path: Path, drawings: list[Any], title: dict[str, str], context: Any = None, designs: dict[int | float, Any] | None = None, ) -> dict[str, dict[str, Any]]: """고른 도면만 지문 — 쓰는 종류의 DB · 문맥 값만 모아 `compute_fingerprints` 로. `context` · `designs` = 단건 GET 이 이미 읽은 값(다시 안 읽음). """ import asyncio from B06_Section.B06_Section_Repository import get_longitudinal_section from B07_DesignDetail.B07_DesignDetail_Router import _designs_by_chainage from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _cross_section_input, _cross_sheet_plan, load_note_sources, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from common_util.common_util_drainage_context import load_drainage_context from config.config_db import get_db_pool ids = {item.id for item in drawings} has_sheet = any(CROSS_SHEET_ID.fullmatch(drawing_id) for drawing_id in ids) has_plan = any(PLAN_KIND_ID.fullmatch(drawing_id) for drawing_id in ids) sources: dict[str, Any] = {"title": title, "designs": designs or {}} # DB 가 원격 — 서로 안 기다리는 것끼리 겹쳐 보냄(값은 같고 왕복만 겹침) async def read_designs() -> None: if designs is None and ( has_sheet or has_plan or any(DEVELOPMENT_ID.fullmatch(i) for i in ids) ): sources["designs"] = await _designs_by_chainage(route_id) async def read_context() -> None: if has_plan: sources["context"] = context or (await load_drainage_context(project_id))[0] async def read_mass_haul() -> None: if _MASS_HAUL_ID in ids: pool = get_db_pool() async with pool.acquire() as connection: section = await get_longitudinal_section(connection, project_id, route_id) sources["mass_haul"] = ((section or {}).get("data") or {}).get("mass_haul") async def read_standard() -> None: if _STANDARD_ID in ids: sources["standard"] = await standard_cross_source(project_id) async def read_cross() -> None: if not has_sheet: return from B06_Section.B06_Section_Router import get_section_detail response = await get_section_detail(project_id, route_id) dump = getattr(response, "model_dump", None) rows = dump(mode="json").get("cross_sections") or [] if dump else [] detail = {cross_station(row["chainage_m"]): row for row in rows if "chainage_m" in row} sources["detail"] = detail sources["overlays"] = await cross_overlay_extents( project_id, route_id, response if dump else None ) sources["elevations"] = { str(chainage): (row.get("design") or {}).get("design_elevation_m") for chainage, row in detail.items() } async def read_files() -> None: if has_sheet: sources["sheets"], sources["longitudinal"], sources["pipes"] = await asyncio.gather( asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path), asyncio.to_thread(_read_json, longitudinal_path), asyncio.to_thread(load_note_sources, project_root), ) await asyncio.gather( read_designs(), read_context(), read_mass_haul(), read_standard(), read_cross(), read_files(), ) if has_sheet: sources["cross_inputs"] = lambda chainage: _cross_section_input( longitudinal_path, sources["longitudinal"], chainage, sources["designs"].get(chainage), sources["overlays"].get(chainage), sources["pipes"], ) return await asyncio.to_thread( compute_fingerprints, project_root, longitudinal_path, drawings, sources )