feat(508-3b · c B07 도면 지문 · 미리받기 · 평면도 저장본): 도면마다 입력 지문(목록 API fingerprint · 횡단 장 측점마다 stations) · 미리받기 = 페이지 밖 모듈(가벼운 것 먼저 · 평면도 끝 · 연 도면 맨 앞 · 페이지 옮겨도 이어 감 · 돌아오면 지문 바뀐 도면만 · 실패 한 번 다시 → 「다시 받기」 · 담는 장 40) · 고른 도면 받는 동안 다른 탭 · 페이지로 옮김(받는 중 CAD 시계 없음 · 고침 안 묻힘) · 평면도 응답 = _derived/b07_plan_<도면>/<지문>.json 그대로(최근 2벌 · 확정 저장본 · 지문 못 셈 = 안 씀) · 평면도 등고선 · 계류 0.1 mm 솎음 + 배경 좌표 1 µm · 값 꼴 · 종단 경고 글은 지문에서 뺌 — 요구 325
검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ export interface DesignDrawingItem {
|
||||
/** 계획평면도만 — 종류 id · 이름(PLAN 60장). */
|
||||
plan_kind?: string | null;
|
||||
plan_kind_name?: string | null;
|
||||
/** 입력 지문(508-3b) — 같으면 같은 그림 · 미리받기가 바뀐 도면만 다시 받음 */
|
||||
fingerprint?: string | null;
|
||||
/** 횡단 장 — 측점마다 지문 */
|
||||
stations?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface CadDrawing {
|
||||
|
||||
@@ -117,10 +117,12 @@ def map_area_mm() -> tuple[float, float]:
|
||||
|
||||
|
||||
def _thinned(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다)."""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
return list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords)
|
||||
"""종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다).
|
||||
|
||||
좌표는 1 µm 로 끊음 — 응답 글자 수만 줄고 그림은 같음(508-3c)."""
|
||||
if len(points) >= 3:
|
||||
points = list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords)
|
||||
return [(round(x, 3), round(y, 3)) for x, y in points]
|
||||
|
||||
|
||||
def _number(value: Any, fallback: float = 0.0) -> float:
|
||||
|
||||
@@ -25,6 +25,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import (
|
||||
BASIN_COLORS,
|
||||
_hatch_entity,
|
||||
_number_badge_entities,
|
||||
_thinned,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size
|
||||
@@ -249,10 +250,13 @@ def _piece_entities(
|
||||
out: list[dict[str, Any]] = []
|
||||
labels = labels if labels is not None else []
|
||||
|
||||
def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None:
|
||||
def line(
|
||||
points: Any, suffix: str, width: int = 1, dash: list[int] | None = None, thin: bool = False
|
||||
) -> None:
|
||||
on_paper = [paper(p) for p in points]
|
||||
entity = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(p) for p in points],
|
||||
_thinned(on_paper) if thin else on_paper,
|
||||
layer,
|
||||
color,
|
||||
suffix=suffix,
|
||||
@@ -301,7 +305,11 @@ def _piece_entities(
|
||||
},
|
||||
}
|
||||
)
|
||||
elif piece in ("contour", "stream", *_OUTLINE):
|
||||
elif piece in ("contour", "stream"):
|
||||
# 배경선 = 종이 해상도로 솎음(유역도와 같은 0.1 mm · 508-3c)
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}", thin=True)
|
||||
elif piece in _OUTLINE:
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}")
|
||||
elif piece == "centerline":
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_Prefetch.ts
|
||||
* 도면 미리받기 — **페이지 밖** 모듈(508-3b · 요구 325).
|
||||
*
|
||||
* 페이지를 옮겨도 받기는 이어 감 · B07 로 돌아오면 목록 지문(서버 `fingerprint`)이 바뀐 도면만
|
||||
* 버리고 다시 받음 · 차례 = 목록 차례 가벼운 것 → 무거운 것(평면도) 끝 · 사용자가 연 도면이 맨 앞 ·
|
||||
* B07 [저장] 중 쉼 · 실패 = 한 번 다시 → 그래도면 「다시 받기」(누르면 다시) ·
|
||||
* 담는 장 수 상한(`LIMIT` · 가까이 쓴 차례로 버림 — 버린 장은 열 때 다시 받음).
|
||||
* 화면 · CAD 는 만지지 않음 — 상태 바뀜은 `onFetchState` 한 칸으로만 알림(페이지가 다시 세우면 갈아 낌).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
fetchDesignDrawing,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import { isSaving } from "./B07_DesignDetail_UI_Save";
|
||||
|
||||
/** 담는 장 수 상한 — 유역도 한 장이 수십 MB 라 메모리를 막음. */
|
||||
export const LIMIT = 40;
|
||||
/** 무거운 도면(평면도 · 유역도) — 차례 끝 */
|
||||
const HEAVY = new Set<DesignDrawingItem["kind"]>(["plan"]);
|
||||
|
||||
export type FetchState = "none" | "loading" | "ready" | "error";
|
||||
|
||||
interface Slot {
|
||||
fingerprint: string | null | undefined;
|
||||
request?: Promise<DesignDrawingResponse>;
|
||||
response?: DesignDrawingResponse;
|
||||
/** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */
|
||||
failed?: boolean;
|
||||
/** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */
|
||||
evicted?: boolean;
|
||||
usedAt: number;
|
||||
}
|
||||
|
||||
let project: string | undefined;
|
||||
let items: DesignDrawingItem[] = [];
|
||||
const slots = new Map<string, Slot>();
|
||||
/** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */
|
||||
let urgent = 0;
|
||||
let pumping = false;
|
||||
let tick = 0;
|
||||
let notify: ((id: string, state: FetchState) => void) | undefined;
|
||||
|
||||
const wait = (ms: number) => new Promise((done) => window.setTimeout(done, ms));
|
||||
|
||||
/** 상태 바뀜을 받을 곳 하나(페이지가 세울 때마다 갈아 낌). */
|
||||
export function onFetchState(listener: ((id: string, state: FetchState) => void) | undefined) {
|
||||
notify = listener;
|
||||
}
|
||||
|
||||
export function fetchState(id: string): FetchState {
|
||||
const slot = slots.get(id);
|
||||
if (!slot) return "none";
|
||||
if (slot.response) return "ready";
|
||||
if (slot.request) return "loading";
|
||||
return slot.failed ? "error" : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록을 맞춤 — 다른 프로젝트면 모두 버림 · 지문이 바뀐(또는 목록에서 빠진) 도면만 버림 ·
|
||||
* 돌리기는 `startPrefetch`(페이지가 첫 장을 고른 뒤).
|
||||
*/
|
||||
export function syncDrawings(projectId: string, list: DesignDrawingItem[]): void {
|
||||
if (project !== projectId) {
|
||||
slots.clear();
|
||||
project = projectId;
|
||||
}
|
||||
items = list;
|
||||
const now = new Map(list.map((item) => [item.id, item]));
|
||||
for (const [id, slot] of slots) {
|
||||
const item = now.get(id);
|
||||
// 지문 없음(서버가 못 셈) = 늘 다시 받음
|
||||
if (!item?.fingerprint || item.fingerprint !== slot.fingerprint) slots.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 미리받기를 (다시) 돌림 — 이미 돌면 그대로(맞춘 목록을 이어 받음). */
|
||||
export function startPrefetch(): void {
|
||||
void pump();
|
||||
}
|
||||
|
||||
/** 저장 · 다시 그리기 뒤 — 다음 열기는 서버에서 */
|
||||
export function forgetDrawing(id: string): void {
|
||||
slots.delete(id);
|
||||
}
|
||||
|
||||
/** 도각을 고친 뒤 — 모두 버리고 다시 받음 */
|
||||
export function forgetAll(): void {
|
||||
slots.clear();
|
||||
void pump();
|
||||
}
|
||||
|
||||
/** 도면 하나(구조물까지 얹은 응답) — 겹쳐 부르면 같은 Promise · 실패한 장은 다시 받음. */
|
||||
function request(item: DesignDrawingItem, retries: number): Promise<DesignDrawingResponse> {
|
||||
const known = slots.get(item.id);
|
||||
if (known?.response) {
|
||||
known.usedAt = ++tick;
|
||||
return Promise.resolve(known.response);
|
||||
}
|
||||
if (known?.request) return known.request;
|
||||
const projectId = project as string;
|
||||
const slot: Slot = { fingerprint: item.fingerprint, usedAt: ++tick };
|
||||
slots.set(item.id, slot);
|
||||
const once = async (): Promise<DesignDrawingResponse> => {
|
||||
const response = await fetchDesignDrawing(projectId, item.id);
|
||||
// 구조물(배수관 · 기슭막이 · 세월교 · BOX · 물넘이포장)은 B06 산식이 화면에 있어 여기서 얹음
|
||||
// — 저장본(확정)은 이미 담겨 있어 그대로
|
||||
if (item.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(projectId, response.route_id, response.drawing);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
slot.request = (async () => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await once();
|
||||
} catch (error) {
|
||||
if (attempt >= retries) throw error;
|
||||
}
|
||||
}
|
||||
})();
|
||||
notify?.(item.id, "loading");
|
||||
slot.request.then(
|
||||
(response) => {
|
||||
if (slots.get(item.id) !== slot) return; // 그 사이 지문이 바뀌어 버림
|
||||
slot.response = response;
|
||||
slot.request = undefined;
|
||||
trim(item.id);
|
||||
notify?.(item.id, "ready");
|
||||
},
|
||||
() => {
|
||||
if (slots.get(item.id) !== slot) return;
|
||||
slot.request = undefined;
|
||||
slot.failed = true;
|
||||
notify?.(item.id, "error");
|
||||
},
|
||||
);
|
||||
return slot.request;
|
||||
}
|
||||
|
||||
/** 담은 장이 상한을 넘으면 가장 오래 안 쓴 것부터 버림(방금 받은 장 · 받는 중은 남김). */
|
||||
function trim(keep: string): void {
|
||||
const held = [...slots].filter(([, slot]) => slot.response);
|
||||
let excess = held.length - LIMIT;
|
||||
for (const [id, slot] of held.sort((a, b) => a[1].usedAt - b[1].usedAt)) {
|
||||
if (excess <= 0) break;
|
||||
if (id === keep) continue;
|
||||
slots.set(id, { fingerprint: slot.fingerprint, evicted: true, usedAt: slot.usedAt });
|
||||
notify?.(id, "none");
|
||||
excess--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 연 도면 — 미리받기 차례를 기다리지 않고 바로 받음(미리받기는 그동안 쉼) ·
|
||||
* 못 받았던 장 · 버린 장도 다시 받음(「다시 받기」).
|
||||
*/
|
||||
export async function openDrawing(item: DesignDrawingItem): Promise<DesignDrawingResponse> {
|
||||
const known = slots.get(item.id);
|
||||
if (known && !known.response && !known.request) slots.delete(item.id);
|
||||
urgent++;
|
||||
try {
|
||||
return await request(item, 1);
|
||||
} finally {
|
||||
urgent--;
|
||||
}
|
||||
}
|
||||
|
||||
/** 다음에 미리 받을 도면 — 가벼운 것(목록 차례) 먼저 · 무거운 것 끝 · 이미 받음 · 실패 · 버림은 건너뜀. */
|
||||
function nextItem(): DesignDrawingItem | undefined {
|
||||
const free = (item: DesignDrawingItem) => !slots.has(item.id);
|
||||
return items.find((item) => !HEAVY.has(item.kind) && free(item)) ?? items.find(free);
|
||||
}
|
||||
|
||||
/** 한 번에 하나씩 · 사용자가 연 도면 · [저장]이 도는 동안 쉼 · 상한이 차면 멈춤. */
|
||||
async function pump(): Promise<void> {
|
||||
if (pumping) return;
|
||||
pumping = true;
|
||||
try {
|
||||
for (;;) {
|
||||
while (urgent > 0 || isSaving()) await wait(120);
|
||||
const held = [...slots.values()].filter((slot) => slot.response || slot.request).length;
|
||||
const item = held < LIMIT ? nextItem() : undefined;
|
||||
if (!item || !project) return;
|
||||
try {
|
||||
await request(item, 1);
|
||||
} catch {
|
||||
/* 두 번 다 못 받음 — 「다시 받기」로 남기고 다음 장 */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pumping = false;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ from uuid import UUID
|
||||
|
||||
from aiomysql import DictCursor
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
@@ -24,7 +24,6 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_design,
|
||||
get_cross_section_designs,
|
||||
get_longitudinal_section,
|
||||
get_project_standard_cross_section,
|
||||
merge_cross_section_design_by_round,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID
|
||||
@@ -51,6 +50,13 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
read_cross_layout,
|
||||
write_cross_layout,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Fingerprint import (
|
||||
drawing_fingerprints,
|
||||
plan_copy_key,
|
||||
read_plan_copy,
|
||||
standard_cross_source,
|
||||
write_plan_copy,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import STRUCTURE_FRAME_LAYER
|
||||
@@ -284,6 +290,23 @@ async def get_design_drawing_list(
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
# 도면마다 입력 지문(508-3b) — 화면 미리받기가 바뀐 도면만 다시 받음
|
||||
use_project_templates(project_root)
|
||||
try:
|
||||
prints = await drawing_fingerprints(
|
||||
project_id,
|
||||
route_id,
|
||||
project_root,
|
||||
longitudinal_path,
|
||||
drawings,
|
||||
await title_block_fields(project_id),
|
||||
)
|
||||
except Exception: # 지문은 덤 — 못 세면 빈 지문(화면은 늘 다시 받음) · 목록은 그대로
|
||||
logger.warning("B07 도면 지문 실패: project_id=%s", project_id, exc_info=True)
|
||||
prints = {}
|
||||
for item in drawings:
|
||||
item.fingerprint = prints.get(item.id, {}).get("fingerprint")
|
||||
item.stations = prints.get(item.id, {}).get("stations")
|
||||
return DesignDrawingListResponse(
|
||||
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
|
||||
)
|
||||
@@ -309,11 +332,13 @@ async def get_design_drawing(
|
||||
# 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트).
|
||||
use_project_templates(project_root)
|
||||
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
||||
use_title_fields(await title_block_fields(project_id))
|
||||
title_fields = await title_block_fields(project_id)
|
||||
use_title_fields(title_fields)
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
design: dict[str, Any] | None = None
|
||||
source_design: Any = None
|
||||
overlays: dict[Any, Any] | None = None
|
||||
plan_key: str | None = None
|
||||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||||
if cross_match:
|
||||
pool = get_db_pool()
|
||||
@@ -334,19 +359,7 @@ async def get_design_drawing(
|
||||
source_design = ((section or {}).get("data") or {}).get("mass_haul")
|
||||
elif drawing_id == CROSS_STANDARD_ID:
|
||||
# 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값).
|
||||
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()
|
||||
source_design = (
|
||||
await get_project_standard_cross_section(connection, int(row[0]), project_id)
|
||||
if row
|
||||
else None
|
||||
)
|
||||
source_design = await standard_cross_source(project_id)
|
||||
elif DEVELOPMENT_ID.fullmatch(drawing_id):
|
||||
# 전개도 — 추가 흙막이 단 수 · 포장 빈 폭을 측점별 설계에서 읽음(377)
|
||||
source_design = await _designs_by_chainage(route_id)
|
||||
@@ -357,6 +370,16 @@ async def get_design_drawing(
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
# 파생 저장본(508-3c) — 지문이 같으면 그린 응답 그대로(확정 저장본은 manifest 길)
|
||||
plan_key = await plan_copy_key(
|
||||
project_id, route_id, project_root, longitudinal_path, drawing_id,
|
||||
title_fields, context, designs,
|
||||
) # fmt: skip
|
||||
stored = plan_key and await asyncio.to_thread(
|
||||
read_plan_copy, project_root, drawing_id, plan_key
|
||||
)
|
||||
if stored:
|
||||
return Response(content=stored, media_type="application/json")
|
||||
source_design = await asyncio.to_thread(
|
||||
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
|
||||
)
|
||||
@@ -368,7 +391,7 @@ async def get_design_drawing(
|
||||
source_design,
|
||||
overlays,
|
||||
)
|
||||
return DesignDrawingResponse(
|
||||
response = DesignDrawingResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=route_id,
|
||||
id=drawing_id,
|
||||
@@ -380,6 +403,11 @@ async def get_design_drawing(
|
||||
design=design,
|
||||
guide=_drawing_guide(drawing),
|
||||
)
|
||||
if plan_key and not confirmed:
|
||||
body = response.model_dump_json().encode("utf-8")
|
||||
await asyncio.to_thread(write_plan_copy, project_root, drawing_id, plan_key, body)
|
||||
return Response(content=body, media_type="application/json")
|
||||
return response
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"""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
|
||||
)
|
||||
@@ -52,12 +52,17 @@ def _extents_from(output: Any) -> dict[int | float, Extent]:
|
||||
return extents
|
||||
|
||||
|
||||
async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[int | float, Extent]:
|
||||
"""{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다."""
|
||||
async def cross_overlay_extents(
|
||||
project_id: UUID | str, route_id: int, response: Any = None
|
||||
) -> dict[int | float, Extent]:
|
||||
"""{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다.
|
||||
|
||||
`response` = 이미 받은 종횡단 상세(지문 · 508-3b) — 없으면 여기서 받음."""
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
try:
|
||||
response = await get_section_detail(UUID(str(project_id)), route_id)
|
||||
if response is None:
|
||||
response = await get_section_detail(UUID(str(project_id)), route_id)
|
||||
dump = getattr(response, "model_dump", None)
|
||||
if dump is None: # JSONResponse = 실패
|
||||
return {}
|
||||
|
||||
@@ -31,6 +31,9 @@ class DesignDrawingItem(BaseModel):
|
||||
# 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다.
|
||||
plan_kind: str | None = None
|
||||
plan_kind_name: str | None = None
|
||||
# 입력 지문(508-3b) — 같으면 같은 그림 · 횡단 장은 측점마다(`_Router_Support_Fingerprint`)
|
||||
fingerprint: str | None = None
|
||||
stations: dict[str, str] | None = None
|
||||
|
||||
|
||||
class DesignDrawingListResponse(BaseModel):
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
completeDesignStage,
|
||||
exportDrawing,
|
||||
fetchCrossLayout,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
@@ -52,11 +51,18 @@ import {
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import {
|
||||
fetchState,
|
||||
forgetAll,
|
||||
forgetDrawing,
|
||||
onFetchState,
|
||||
openDrawing,
|
||||
startPrefetch,
|
||||
syncDrawings,
|
||||
} from "./B07_DesignDetail_Prefetch";
|
||||
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
|
||||
import {
|
||||
hasRedrawable,
|
||||
isSaving,
|
||||
recoveryScopeOf,
|
||||
redrawFromSource,
|
||||
resetDrawings,
|
||||
@@ -125,7 +131,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
title: L("B07_Design_Title"),
|
||||
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
|
||||
onChanged: (dirty) => {
|
||||
if (frameEditor.isEditing()) return;
|
||||
// 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음
|
||||
if (frameEditor.isEditing() || pending) return;
|
||||
cadDirty = dirty;
|
||||
if (dirty) tabs.markDirty();
|
||||
},
|
||||
@@ -209,57 +216,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
quantityTable: response.quantity_table ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
|
||||
*
|
||||
* 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초,
|
||||
* 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다.
|
||||
* sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라
|
||||
* 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다.
|
||||
*/
|
||||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||||
|
||||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||||
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
|
||||
const cached = drawingCache.get(drawing.id);
|
||||
if (cached) return cached;
|
||||
const request = (async () => {
|
||||
const response = await fetchDesignDrawing(projectId as string, drawing.id);
|
||||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||||
if (drawing.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
|
||||
}
|
||||
return response;
|
||||
})().catch((error) => {
|
||||
drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다.
|
||||
throw error;
|
||||
});
|
||||
drawingCache.set(drawing.id, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다.
|
||||
*
|
||||
* ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리).
|
||||
* 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이
|
||||
* **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다.
|
||||
* 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다.
|
||||
*/
|
||||
const prefetchAllDrawings = async (): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
for (const item of drawings) {
|
||||
// 클릭 · [저장]이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
|
||||
while (loadInFlight || isSaving()) await new Promise((done) => window.setTimeout(done, 120));
|
||||
if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다
|
||||
try {
|
||||
await requestDrawing(item);
|
||||
} catch {
|
||||
/* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
||||
const mayDiscardEdits = (): boolean => {
|
||||
if (!cadDirty && !tabs.anyDirty()) return true;
|
||||
@@ -270,19 +226,29 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
};
|
||||
|
||||
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
|
||||
let loadInFlight = false;
|
||||
/**
|
||||
* 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은
|
||||
* 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘).
|
||||
*/
|
||||
let pending: { id: string; stash?: CadDrawing } | undefined;
|
||||
|
||||
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
|
||||
if (!projectId || loadInFlight) return;
|
||||
loadInFlight = true;
|
||||
if (!projectId) return;
|
||||
const mine = { id: drawing.id, stash };
|
||||
pending = mine;
|
||||
currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
|
||||
const ready = fetchState(drawing.id) === "ready";
|
||||
cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음
|
||||
if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info");
|
||||
try {
|
||||
const response = await requestDrawing(drawing);
|
||||
const response = await openDrawing(drawing);
|
||||
if (pending !== mine) return; // 그 사이 다른 도면을 고름
|
||||
pending = undefined;
|
||||
if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
|
||||
@@ -312,10 +278,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
if (pending !== mine) return;
|
||||
const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
||||
cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`);
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
if (button) button.dataset.loading = "false";
|
||||
}
|
||||
};
|
||||
@@ -326,16 +292,20 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
*/
|
||||
const tabs = createCadTabs<CadDrawing>({
|
||||
features: { close: true },
|
||||
capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }),
|
||||
// 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠
|
||||
capture: async () =>
|
||||
pending
|
||||
? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false }
|
||||
: { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty },
|
||||
canLeave: () => {
|
||||
if (!frameEditor.isEditing()) return !loadInFlight;
|
||||
if (!frameEditor.isEditing()) return true;
|
||||
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
|
||||
return false;
|
||||
},
|
||||
show: async (tab: CadTab<CadDrawing>) => {
|
||||
const index = drawings.findIndex((item) => item.id === tab.id);
|
||||
if (index >= 0)
|
||||
await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
|
||||
// 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김)
|
||||
if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
|
||||
},
|
||||
});
|
||||
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
|
||||
@@ -343,6 +313,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||||
if (drawing === currentDrawing) return;
|
||||
// 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음)
|
||||
if (pending?.id === drawing.id && fetchState(drawing.id) === "error") {
|
||||
void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash);
|
||||
return;
|
||||
}
|
||||
void openTab(drawing);
|
||||
};
|
||||
|
||||
@@ -358,7 +333,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
setCadClean: () => void (cadDirty = false),
|
||||
requestCad: requestCadDrawing,
|
||||
isFrameEditing: () => frameEditor.isEditing(),
|
||||
forget: (id: string) => void drawingCache.delete(id),
|
||||
forget: forgetDrawing,
|
||||
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
|
||||
};
|
||||
|
||||
@@ -407,7 +382,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
restoreDrawing: () => {
|
||||
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
|
||||
},
|
||||
onSaved: () => drawingCache.clear(),
|
||||
onSaved: forgetAll,
|
||||
currentDrawingInfo: () =>
|
||||
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
||||
});
|
||||
@@ -449,6 +424,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
folded,
|
||||
);
|
||||
drawingListEl = drawingPanel;
|
||||
// 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음)
|
||||
const markFetch = (id: string, state: string): void => {
|
||||
const button = findButton(id);
|
||||
if (button) button.dataset.fetch = state;
|
||||
};
|
||||
drawings.forEach((item) => markFetch(item.id, fetchState(item.id)));
|
||||
onFetchState(markFetch);
|
||||
const actions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
||||
@@ -544,6 +526,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
// 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
// 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감)
|
||||
if (projectId) syncDrawings(projectId, drawings);
|
||||
if (drawings.length > 0) {
|
||||
const crossSheets = drawings.filter(isCrossSheet);
|
||||
const reopenId = reopenCrossSheet
|
||||
@@ -555,9 +539,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 311 기억한 장이 없으면 표지
|
||||
const cover = drawings.findIndex((item) => item.kind === "cover");
|
||||
const first = reopen >= 0 ? reopen : Math.max(cover, 0);
|
||||
void openTab(drawings[first]).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
void prefetchAllDrawings();
|
||||
});
|
||||
// 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼).
|
||||
void openTab(drawings[first]).then(startPrefetch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,3 +542,13 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */
|
||||
.b07-drawing-button[data-fetch="error"] {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b07-drawing-button[data-fetch="error"] .b07-drawing-button__name::after {
|
||||
content: " · 다시 받기";
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -56,8 +56,9 @@ def test_1a_한도():
|
||||
|
||||
|
||||
def test_1a_미리받기_쉼():
|
||||
page = _read("B07_DesignDetail_UI_Page.ts")
|
||||
assert "while (loadInFlight || isSaving())" in page
|
||||
# 508-3b 미리받기 = 페이지 밖 모듈 · 연 도면 받는 중(urgent) · [저장] 중 쉼
|
||||
prefetch = _read("B07_DesignDetail_Prefetch.ts")
|
||||
assert "while (urgent > 0 || isSaving())" in prefetch
|
||||
|
||||
|
||||
def test_1a_저장_판정(out):
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""508-3b · c B07 도면 지문 · 평면도 파생 저장본(임시 폴더 · DB · 실제 마스터 안 씀).
|
||||
|
||||
- 지문 = 입력이 바뀔 때만 바뀜(같은 내용으로 다시 쓴 종단 = 그대로)
|
||||
- 횡단 측점 하나 바꾸면 그 장만 · 그 장 측점 지문도 그 측점만
|
||||
- B07 설정 · 도각 · 그 도면 manifest 칸 = 그 범위만 바뀜
|
||||
- 평면도 저장본 열쇠 = 지문 · 읽기는 안 지움 · 최근 2벌 · 해상도(종이 0.1 mm 솎음 · 1 µm 끊음)
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_fingerprint.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Router_Support_Fingerprint as fp # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _thinned # noqa: E402
|
||||
|
||||
SHEETS = [
|
||||
{"id": "cross_s00000m", "number": 1, "chainages": [0, 20], "grid": [4, 4]},
|
||||
{"id": "cross_s00040m", "number": 2, "chainages": [40, 60], "grid": [4, 4]},
|
||||
]
|
||||
|
||||
|
||||
def _item(drawing_id: str, kind: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=drawing_id, kind=kind)
|
||||
|
||||
|
||||
ITEMS = [
|
||||
_item("cover", "cover"),
|
||||
_item("longitudinal_1", "longitudinal"),
|
||||
_item("cross_s00000m", "cross"),
|
||||
_item("cross_s00040m", "cross"),
|
||||
_item("mass_haul", "mass_haul"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path) -> dict:
|
||||
root = tmp_path / "proj"
|
||||
longitudinal = root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
longitudinal.parent.mkdir(parents=True)
|
||||
longitudinal.write_text(json.dumps({"stations": [{"chainage_m": 0}]}), encoding="utf-8")
|
||||
(root / "B07_DesignDetail").mkdir()
|
||||
(root / "B07_DesignDetail" / "drawing_scales.json").write_text("{}", encoding="utf-8")
|
||||
(root / "templates" / "drawing").mkdir(parents=True)
|
||||
(root / "templates" / "drawing" / "00_template_A1.json").write_text("{}", encoding="utf-8")
|
||||
designs = {c: {"cut_slope_ratio": 0.4, "at": c} for c in (0, 20, 40, 60)}
|
||||
sources = {
|
||||
"title": {"공사명": "시험"},
|
||||
"designs": designs,
|
||||
"detail": {c: {"chainage_m": c, "design": designs[c]} for c in designs},
|
||||
"overlays": {},
|
||||
"elevations": {str(c): 100.0 for c in designs},
|
||||
"sheets": SHEETS,
|
||||
"mass_haul": {"points": [1, 2]},
|
||||
"cross_inputs": lambda c: {"chainage": c, "design": sources["designs"].get(c)},
|
||||
}
|
||||
return {"root": root, "longitudinal": longitudinal, "sources": sources}
|
||||
|
||||
|
||||
def _prints(project: dict) -> dict:
|
||||
return fp.compute_fingerprints(
|
||||
project["root"], project["longitudinal"], ITEMS, project["sources"]
|
||||
)
|
||||
|
||||
|
||||
def _changed(before: dict, after: dict) -> set[str]:
|
||||
return {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]}
|
||||
|
||||
|
||||
def test_같은_입력_같은_지문(project):
|
||||
first = _prints(project)
|
||||
assert first == _prints(project)
|
||||
assert all(len(value["fingerprint"]) == 16 for value in first.values())
|
||||
assert set(first["cross_s00000m"]["stations"]) == {"0", "20"}
|
||||
|
||||
|
||||
def test_같은_내용으로_다시_쓴_종단은_그대로(project):
|
||||
before = _prints(project)
|
||||
path: Path = project["longitudinal"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
time.sleep(0.02)
|
||||
path.write_text(text, encoding="utf-8") # B06 [저장]이 같은 내용으로 다시 씀
|
||||
os.utime(path, None)
|
||||
assert _changed(before, _prints(project)) == set()
|
||||
path.write_text(json.dumps({"stations": [{"chainage_m": 0}, {"chainage_m": 20}]}))
|
||||
assert _changed(before, _prints(project)) == {"longitudinal_1", "mass_haul"}
|
||||
|
||||
|
||||
def test_횡단_측점_하나_바꾸면_그_장만(project):
|
||||
before = _prints(project)
|
||||
sources = project["sources"]
|
||||
sources["designs"] = {**sources["designs"], 40: {"cut_slope_ratio": 0.5, "at": 40}}
|
||||
sources["detail"] = {**sources["detail"], 40: {"chainage_m": 40, "design": {"x": 1}}}
|
||||
after = _prints(project)
|
||||
assert _changed(before, after) == {"cross_s00040m"}
|
||||
stations_before = before["cross_s00040m"]["stations"]
|
||||
stations_after = after["cross_s00040m"]["stations"]
|
||||
assert stations_before["60"] == stations_after["60"]
|
||||
assert stations_before["40"] != stations_after["40"]
|
||||
|
||||
|
||||
def test_덧그림_범위만_바뀌어도_그_장(project):
|
||||
before = _prints(project)
|
||||
project["sources"]["overlays"] = {20: (-5.0, 5.0, 90.0, 110.0)}
|
||||
assert _changed(before, _prints(project)) == {"cross_s00000m"}
|
||||
|
||||
|
||||
def test_설정_도각_manifest_범위(project):
|
||||
root: Path = project["root"]
|
||||
before = _prints(project)
|
||||
(root / "B07_DesignDetail" / "drawing_scales.json").write_text('{"cross": {"x": 200}}')
|
||||
after = _prints(project)
|
||||
assert _changed(before, after) == {item.id for item in ITEMS} # 설정 = 모든 도면
|
||||
|
||||
(root / "templates" / "drawing" / "00_template_A1.json").write_text('{"a": 1}')
|
||||
again = _prints(project)
|
||||
assert _changed(after, again) == {item.id for item in ITEMS} # 도각 = 모든 도면
|
||||
|
||||
manifest = {"drawings": {"cover": {"number": 1}}}
|
||||
(root / "B07_DesignDetail" / "manifest.json").write_text(json.dumps(manifest))
|
||||
last = _prints(project)
|
||||
assert _changed(again, last) == {"cover"} # 그 도면 칸만 · manifest 파일은 설정이 아님
|
||||
|
||||
# 그린 결과 폴더(plan_pieces)는 입력 아님
|
||||
(root / "B07_DesignDetail" / "plan_pieces" / "1").mkdir(parents=True)
|
||||
(root / "B07_DesignDetail" / "plan_pieces" / "1" / "contour.json").write_text("[]")
|
||||
assert _changed(last, _prints(project)) == set()
|
||||
|
||||
|
||||
def test_DB_유토만_바뀌면_토적도만(project):
|
||||
before = _prints(project)
|
||||
project["sources"]["mass_haul"] = {"points": [1, 3]}
|
||||
assert _changed(before, _prints(project)) == {"mass_haul"}
|
||||
|
||||
|
||||
def test_파일_도장_내용_대_시각(tmp_path, monkeypatch):
|
||||
small = tmp_path / "a.json"
|
||||
small.write_text("1")
|
||||
first = fp.stamp(small)
|
||||
os.utime(small, (1, 1))
|
||||
assert fp.stamp(small) == first # 작은 파일 = 내용
|
||||
monkeypatch.setattr(fp, "_CONTENT_MAX", 0)
|
||||
assert fp.stamp(small) == "1000000000:1" # 큰 파일 = 시각:크기
|
||||
assert fp.stamp(tmp_path / "없음") is None
|
||||
|
||||
|
||||
def test_평면도_저장본_열쇠_읽기는_안_지움(tmp_path):
|
||||
root = tmp_path / "proj"
|
||||
assert fp.read_plan_copy(root, "plan_terrain", "k1") is None
|
||||
fp.write_plan_copy(root, "plan_terrain", "k1", b'{"a":1}')
|
||||
fp.write_plan_copy(root, "plan_route", "r1", b'{"r":1}')
|
||||
folder = root / "_derived" / "b07_plan_plan_terrain"
|
||||
assert fp.read_plan_copy(root, "plan_terrain", "k1") == b'{"a":1}'
|
||||
assert fp.read_plan_copy(root, "plan_terrain", "다른") is None # 지문 다름 = 없음
|
||||
assert (folder / "k1.json").is_file() # 읽기 · 빗나감 모두 안 지움
|
||||
time.sleep(0.02)
|
||||
fp.write_plan_copy(root, "plan_terrain", "k2", b'{"a":2}')
|
||||
time.sleep(0.02)
|
||||
fp.read_plan_copy(root, "plan_terrain", "k1") # 꺼낸 것 = 최근
|
||||
time.sleep(0.02)
|
||||
fp.write_plan_copy(root, "plan_terrain", "k3", b'{"a":3}')
|
||||
assert sorted(p.name for p in folder.glob("*.json")) == ["k1.json", "k3.json"] # 최근 2벌
|
||||
assert (root / "_derived" / "b07_plan_plan_route" / "r1.json").is_file() # 다른 도면 그대로
|
||||
assert not list(folder.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_해상도_종이_솎음(tmp_path):
|
||||
# 종이 mm 직선 위 점 100 개 + 0.05 mm 흔들림 = 양 끝만 · 좌표 1 µm
|
||||
points = [(i * 0.5 + 0.0000123, (0.05 if i % 2 else 0.0)) for i in range(100)]
|
||||
thinned = _thinned(points)
|
||||
assert len(thinned) == 2
|
||||
assert all(round(v, 3) == v for xy in thinned for v in xy)
|
||||
bend = [(0.0, 0.0), (10.0, 0.5), (20.0, 0.0)] # 0.5 mm 꺾임은 남김
|
||||
assert len(_thinned(bend)) == 3
|
||||
|
||||
|
||||
def test_평면도_등고선_솎음_적용():
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
|
||||
scale = 1200
|
||||
line = [(i * 0.6, 0.00001 * (i % 2)) for i in range(200)] # 실좌표 m — 종이에선 곧은 선
|
||||
box = (0.0, -10.0, 120.0, 10.0)
|
||||
pieces = {"contour": {"lines": [line]}, "centerline": {"lines": [line]}}
|
||||
drawing = build_plan_kind_drawing("plan_t", "지형", box, pieces, scale)
|
||||
lines = {
|
||||
entity["layerId"]: entity
|
||||
for entity in drawing["entities"]
|
||||
if entity.get("type") == "PolyLine"
|
||||
}
|
||||
contour = next(value for key, value in lines.items() if "contour" in key)
|
||||
center = next(value for key, value in lines.items() if "center" in key)
|
||||
assert len(contour["children"]) == 1 # 배경선 = 솎음
|
||||
assert len(center["children"]) == 199 # 주제선(중심선)은 그대로
|
||||
|
||||
|
||||
def test_값_꼴_경고_글만_바뀐_저장은_그대로(project):
|
||||
# 옛 판 자료의 첫 B06 [저장] = 4.0 → 4 · 경고 글 꼴만 바뀜(2026-10-22 실측) — 그림은 같음
|
||||
path: Path = project["longitudinal"]
|
||||
path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}], "warnings": ["a"]}))
|
||||
sources = project["sources"]
|
||||
sources["designs"] = {c: {**d, "width": 4.0} for c, d in sources["designs"].items()}
|
||||
sources["detail"] = {c: {"chainage_m": c, "design": d} for c, d in sources["designs"].items()}
|
||||
before = _prints(project)
|
||||
path.write_text(json.dumps({"stations": [{"chainage_m": 0}], "warnings": ["a", "b"]}))
|
||||
sources["designs"] = {c: {**d, "width": 4} for c, d in sources["designs"].items()}
|
||||
sources["detail"] = {
|
||||
c: {"chainage_m": float(c), "design": d} for c, d in sources["designs"].items()
|
||||
}
|
||||
assert _changed(before, _prints(project)) == set()
|
||||
@@ -0,0 +1,332 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""508-3b · c 화면 — 자기 포트(5177) Playwright 머리 없는 브라우저 · 검증_sub2 · `AISLO_LIVE_DB=1` 일 때만.
|
||||
|
||||
- B07 열기 → 미리받기 차례 = 가벼운 것 먼저 · 평면도(무거운 것) 끝
|
||||
- B06 으로 가 첫 측점 깎기 비탈 고치고 [저장] → B07 돌아옴 → 지문 바뀐 도면만 다시 받음
|
||||
(횡단 = 고친 측점 든 장 하나 · 측점 지문도 그 측점만) · 페이지 옮겨도 받은 것은 그대로
|
||||
- 안 받은 평면도(유역도 · 저장본 지움) 열기 → 「받는 중」 → 받은 뒤 보임
|
||||
- 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음
|
||||
- 못 받은 도면 = 「다시 받기」 → 누르면 다시 받음(밝음 · 어두움 한 장씩 · tmp/shots_508_3)
|
||||
- 단계 행 · projects · 노선 · 종 · 횡단 행 · B05 노선 · B06 · B07 storage 를 먼저 떠 두고 끝에 되돌림
|
||||
|
||||
AISLO_LIVE_DB=1 ./venv/Scripts/python.exe -m pytest resources/tester/test_508_3_b07_prefetch_browser.py -q -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from helper_verify_project import own_ports # noqa: E402
|
||||
|
||||
BASE = own_ports()[1] # 화면 서버 = OWNERS.md 이 폴더 줄
|
||||
PROJECT = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(OWNERS.md)
|
||||
SHOTS = ROOT / "tmp" / "shots_508_3"
|
||||
STAGE_COLS = (
|
||||
"state",
|
||||
"progress_percent",
|
||||
"params",
|
||||
"message",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"updated_at",
|
||||
)
|
||||
KEEP_DIRS = ("B05_Profile/route", "B06_Section", "B07_DesignDetail")
|
||||
|
||||
|
||||
def _up() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("AISLO_LIVE_DB") != "1" or not _up(),
|
||||
reason="AISLO_LIVE_DB=1 · 5177 서버 있을 때만(검증_sub2 storage · DB 행을 씀)",
|
||||
)
|
||||
|
||||
|
||||
async def _keep(mode: str, held: dict) -> None:
|
||||
"""단계 · projects · routes · 종 · 횡단 행 + storage 폴더 떠 두기(save) · 되돌리기(restore)."""
|
||||
import aiomysql
|
||||
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
|
||||
|
||||
conn = await aiomysql.connect(
|
||||
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME,
|
||||
charset="utf8mb4", autocommit=False,
|
||||
) # fmt: skip
|
||||
try:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
if mode == "save":
|
||||
await cur.execute("SELECT storage_path FROM projects WHERE id=%s", (PROJECT,))
|
||||
held["root"] = Path(
|
||||
resolve_stored_project_path((await cur.fetchone())["storage_path"])
|
||||
)
|
||||
for name, sql in (
|
||||
("stages", "SELECT * FROM project_workflow_stages WHERE project_id=%s"),
|
||||
("project", "SELECT status, updated_at FROM projects WHERE id=%s"),
|
||||
("routes", "SELECT id, status FROM routes WHERE project_id=%s"),
|
||||
("long", "SELECT id, data, status FROM longitudinal_sections WHERE project_id=%s"),
|
||||
("cross", "SELECT * FROM cross_sections WHERE project_id=%s"),
|
||||
): # fmt: skip
|
||||
await cur.execute(sql, (PROJECT,))
|
||||
held[name] = await cur.fetchall()
|
||||
held["copy"] = ROOT / "tmp" / f"pt_508_3_keep_{int(time.time())}"
|
||||
for rel in KEEP_DIRS:
|
||||
shutil.copytree(held["root"] / rel, held["copy"] / rel)
|
||||
return
|
||||
for row in held["stages"]:
|
||||
await cur.execute(
|
||||
f"UPDATE project_workflow_stages SET {', '.join(f'{k}=%s' for k in STAGE_COLS)} "
|
||||
"WHERE id=%s",
|
||||
(*[row[k] for k in STAGE_COLS], row["id"]),
|
||||
)
|
||||
project = held["project"][0]
|
||||
await cur.execute(
|
||||
"UPDATE projects SET status=%s, updated_at=%s WHERE id=%s",
|
||||
(project["status"], project["updated_at"], PROJECT),
|
||||
)
|
||||
for row in held["routes"]:
|
||||
await cur.execute(
|
||||
"UPDATE routes SET status=%s WHERE id=%s", (row["status"], row["id"])
|
||||
)
|
||||
for row in held["long"]:
|
||||
await cur.execute(
|
||||
"UPDATE longitudinal_sections SET data=%s, status=%s WHERE id=%s",
|
||||
(row["data"], row["status"], row["id"]),
|
||||
)
|
||||
await cur.execute("DELETE FROM cross_sections WHERE project_id=%s", (PROJECT,))
|
||||
for row in held["cross"]:
|
||||
cols = list(row)
|
||||
await cur.execute(
|
||||
f"INSERT INTO cross_sections ({', '.join(cols)}) "
|
||||
f"VALUES ({', '.join(['%s'] * len(cols))})",
|
||||
[row[c] for c in cols],
|
||||
)
|
||||
await conn.commit()
|
||||
for rel in KEEP_DIRS:
|
||||
shutil.rmtree(held["root"] / rel)
|
||||
shutil.copytree(held["copy"] / rel, held["root"] / rel)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ctx():
|
||||
sync = pytest.importorskip("playwright.sync_api")
|
||||
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
held: dict = {}
|
||||
asyncio.run(_keep("save", held))
|
||||
try:
|
||||
with sync.sync_playwright() as play:
|
||||
chrome = play.chromium.launch(channel="msedge", headless=True)
|
||||
context = chrome.new_context(viewport={"width": 1600, "height": 950})
|
||||
login = {k: cred[k] for k in ("email", "password")}
|
||||
r = context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000)
|
||||
assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음
|
||||
context.add_init_script(f"localStorage.setItem('frd_current_project_id', '{PROJECT}')")
|
||||
context.held = held # type: ignore[attr-defined]
|
||||
yield context
|
||||
chrome.close()
|
||||
finally:
|
||||
asyncio.run(_keep("restore", held))
|
||||
print("되돌림 사본 자리", held.get("copy"))
|
||||
|
||||
|
||||
def _list(ctx) -> dict:
|
||||
body = ctx.request.get(f"{BASE}/api/projects/{PROJECT}/design-drawings", timeout=120000)
|
||||
return {item["id"]: item for item in body.json()["drawings"]}
|
||||
|
||||
|
||||
def _watch(page) -> list[tuple[float, str]]:
|
||||
gets: list[tuple[float, str]] = []
|
||||
page.on(
|
||||
"requestfinished",
|
||||
lambda q: (
|
||||
q.method == "GET"
|
||||
and "/design-drawings/" in q.url
|
||||
and gets.append((time.time(), q.url.rsplit("/", 1)[-1]))
|
||||
),
|
||||
)
|
||||
return gets
|
||||
|
||||
|
||||
def _settle(page, gets: list, want: set[str], start: int = 0, timeout_s: float = 240) -> None:
|
||||
"""`start` 뒤로 원하는 도면을 다 받고 5 s 동안 새 GET 이 없을 때까지."""
|
||||
end = time.time() + timeout_s
|
||||
while time.time() < end:
|
||||
got = {name for _t, name in gets[start:]}
|
||||
quiet = len(gets) == start or time.time() - gets[-1][0] > 5
|
||||
if want <= got and quiet:
|
||||
return
|
||||
page.wait_for_timeout(500)
|
||||
raise AssertionError(f"못 받음 {sorted(want - {name for _t, name in gets[start:]})}")
|
||||
|
||||
|
||||
def _b07(page) -> None:
|
||||
page.wait_for_selector(".b07-drawing-button[data-active='true']", timeout=90000)
|
||||
|
||||
|
||||
def _open_group(page, kind: str) -> None:
|
||||
"""접힌 목록 묶음을 폄."""
|
||||
group = page.locator(f".b07-drawing-group[data-kind='{kind}']")
|
||||
if "is-collapsed" in (group.get_attribute("class") or ""):
|
||||
group.locator(".ui-collapsible__title").first.click()
|
||||
|
||||
|
||||
def _cut_slope_and_save(page, value: str) -> str:
|
||||
"""B06 첫 횡단 카드 깎기 비탈 → [저장] · 카드 머리 글을 돌려줌."""
|
||||
box = page.locator(".b06-design__cutslope-input").first
|
||||
box.wait_for(timeout=120000)
|
||||
page.wait_for_timeout(3000)
|
||||
title = page.locator("article.b06-cross-card").first.inner_text().split("\n")[0]
|
||||
box.fill(value)
|
||||
box.press("Enter")
|
||||
page.wait_for_timeout(800)
|
||||
with page.expect_response(
|
||||
lambda r: "/design/save" in r.url and r.request.method == "POST", timeout=180000
|
||||
) as saved:
|
||||
page.locator("button.ui-btn", has_text="저장").last.click()
|
||||
assert saved.value.ok, f"B06 저장 실패 {saved.value.status}"
|
||||
page.wait_for_timeout(2000)
|
||||
return title
|
||||
|
||||
|
||||
def test_b07_미리받기_지문_화면(ctx):
|
||||
root: Path = ctx.held["root"]
|
||||
SHOTS.mkdir(parents=True, exist_ok=True)
|
||||
page = ctx.new_page()
|
||||
gets = _watch(page)
|
||||
# 옛 판 자료는 첫 B06 [저장]이 모든 측점 설계에 새 칸을 붙임(한 번만 · 실제 자료 바뀜) — 먼저 한 번
|
||||
page.goto(f"{BASE}/#/b06-section", wait_until="domcontentloaded")
|
||||
_cut_slope_and_save(page, "0.45")
|
||||
before = _list(ctx)
|
||||
mark = len(gets)
|
||||
page.evaluate("location.hash = '#/b07-design-detail'")
|
||||
_b07(page)
|
||||
_settle(page, gets, set(before), mark)
|
||||
order = [name for _t, name in gets[mark:]]
|
||||
heavy = [i for i, name in enumerate(order) if before[name]["kind"] == "plan"]
|
||||
light = [i for i, name in enumerate(order) if before[name]["kind"] != "plan"]
|
||||
print("미리받기 차례", order)
|
||||
assert len(order) == len(set(order)) == len(before) # 한 장 한 번
|
||||
assert max(light) < min(heavy) # 무거운 것 끝
|
||||
|
||||
# B06 으로 가 측점 하나 고치고 [저장] → B07 로 돌아옴(같은 창 · 페이지 밖 모듈 그대로)
|
||||
page.evaluate("location.hash = '#/b06-section'")
|
||||
card = _cut_slope_and_save(page, "0.55")
|
||||
after = _list(ctx)
|
||||
changed = {key for key in before if before[key]["fingerprint"] != after[key]["fingerprint"]}
|
||||
sheets = {key for key in changed if before[key]["kind"] == "cross"}
|
||||
stations = {
|
||||
(key, c)
|
||||
for key in sheets
|
||||
for c, value in (after[key]["stations"] or {}).items()
|
||||
if before[key]["stations"].get(c) != value
|
||||
}
|
||||
print(f"고친 카드 「{card}」 · 지문 바뀐 도면 {sorted(changed)} · 측점 {sorted(stations)}")
|
||||
assert len(sheets) == 1 and len(stations) == 1 # 그 장만 · 그 측점만
|
||||
mark = len(gets)
|
||||
page.evaluate("location.hash = '#/b07-design-detail'")
|
||||
_b07(page)
|
||||
_settle(page, gets, changed, mark)
|
||||
again = [name for _t, name in gets[mark:]]
|
||||
print("돌아와 다시 받음", again)
|
||||
assert set(again) == changed and len(again) == len(changed)
|
||||
|
||||
# 되돌림(화면 조작으로 바꾼 값 = 원래 값) — 행 · 파일은 끝에 통째로 되돌림
|
||||
page.evaluate("location.hash = '#/b06-section'")
|
||||
_cut_slope_and_save(page, "0.40")
|
||||
page.close()
|
||||
|
||||
# 평면도 둘째 열기 = 서버 저장본(시간 전 · 후) · GET 이 지우는 파일 없음
|
||||
shutil.rmtree(root / "_derived" / "b07_plan_plan_watershed", ignore_errors=True)
|
||||
shutil.rmtree(root / "_derived" / "b07_plan_plan_terrain", ignore_errors=True)
|
||||
files = {p for p in root.rglob("*") if p.is_file() and "B05_Profile" not in p.parts}
|
||||
url = f"{BASE}/api/projects/{PROJECT}/design-drawings/plan_terrain"
|
||||
spent = []
|
||||
for _ in range(2):
|
||||
start = time.time()
|
||||
assert ctx.request.get(url, timeout=120000).ok
|
||||
spent.append(time.time() - start)
|
||||
print(f"평면도(지형) 첫 열기 {spent[0]:.2f} s → 둘째(저장본) {spent[1]:.2f} s")
|
||||
assert spent[1] < spent[0]
|
||||
gone = {p for p in files if not p.exists()}
|
||||
assert not gone, f"GET 이 지움 {sorted(map(str, gone))[:5]}"
|
||||
assert list((root / "_derived" / "b07_plan_plan_terrain").glob("*.json"))
|
||||
|
||||
# 안 받은 평면도(유역도 · 저장본 없음) 열기 → 「받는 중」 → 받은 뒤 보임
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded")
|
||||
_b07(page)
|
||||
_open_group(page, "plan")
|
||||
start = time.time()
|
||||
page.locator(".b07-drawing-button[data-drawing-id='plan_watershed']").click()
|
||||
page.wait_for_selector(".ui-toast:has-text('받는 중')", timeout=10000)
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('.b07-drawing-button[data-drawing-id=plan_watershed]')"
|
||||
"?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'",
|
||||
timeout=120000,
|
||||
)
|
||||
print(f"유역도 받는 중 → 보임 {time.time() - start:.2f} s")
|
||||
page.screenshot(path=str(SHOTS / "watershed_light.png"))
|
||||
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
||||
page.wait_for_timeout(500)
|
||||
page.screenshot(path=str(SHOTS / "watershed_dark.png"))
|
||||
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_b07_다시_받기(ctx):
|
||||
page = ctx.new_page()
|
||||
target = "mass_haul"
|
||||
fails = {"n": 0}
|
||||
|
||||
def fail(route):
|
||||
fails["n"] += 1
|
||||
route.abort()
|
||||
|
||||
page.route(f"**/design-drawings/{target}", fail)
|
||||
page.goto(f"{BASE}/#/b07-design-detail", wait_until="domcontentloaded")
|
||||
_b07(page)
|
||||
button = page.locator(f".b07-drawing-button[data-drawing-id='{target}']")
|
||||
page.wait_for_function(
|
||||
f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')"
|
||||
"?.dataset.fetch === 'error'",
|
||||
timeout=120000,
|
||||
)
|
||||
print("미리받기 실패 횟수(한 번 다시 포함)", fails["n"])
|
||||
assert fails["n"] == 2
|
||||
text = page.evaluate(
|
||||
f"getComputedStyle(document.querySelector('.b07-drawing-button[data-drawing-id={target}]"
|
||||
" .b07-drawing-button__name'), '::after').content"
|
||||
)
|
||||
assert "다시 받기" in text
|
||||
panel = button.locator("xpath=..")
|
||||
panel.screenshot(path=str(SHOTS / "retry_light.png"))
|
||||
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
||||
panel.screenshot(path=str(SHOTS / "retry_dark.png"))
|
||||
page.evaluate("document.documentElement.setAttribute('data-theme', 'light')")
|
||||
page.unroute(f"**/design-drawings/{target}")
|
||||
button.click()
|
||||
page.wait_for_function(
|
||||
f"() => document.querySelector('.b07-drawing-button[data-drawing-id={target}]')"
|
||||
"?.dataset.fetch === 'ready' && document.querySelector('.cad-host')?.dataset.loading === 'false'",
|
||||
timeout=60000,
|
||||
)
|
||||
page.screenshot(path=str(SHOTS / "retry_after.png"))
|
||||
page.close()
|
||||
@@ -105,8 +105,9 @@ export interface CadLoadExtra {
|
||||
export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
|
||||
element: HTMLElement;
|
||||
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */
|
||||
beginLoading: () => void;
|
||||
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다.
|
||||
* `watch` 거짓 = 아직 서버에서 받는 중 — 표시만 · 시계 없음(받은 뒤 다시 부름). */
|
||||
beginLoading: (watch?: boolean) => void;
|
||||
/** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */
|
||||
fail: (detail: string) => void;
|
||||
/** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다.
|
||||
@@ -353,10 +354,12 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
|
||||
return {
|
||||
element,
|
||||
beginLoading: () => {
|
||||
beginLoading: (watch = true) => {
|
||||
element.dataset.loading = "true";
|
||||
element.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
||||
// 저쪽이 말이 없으면 여기서 끊는다 · 거짓 = 아직 서버에서 받는 중(시계 없음 · 508-3b)
|
||||
if (watch) startLoadWatchdog();
|
||||
else stopLoadWatchdog();
|
||||
},
|
||||
fail: (detail) => {
|
||||
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
||||
|
||||
Reference in New Issue
Block a user