diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py
index 7a3b50975..0ae895ca1 100644
--- a/B08_Quantity/B08_Quantity_Milestone_Capture.py
+++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
+장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
"""
from __future__ import annotations
@@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — [그림 변경]으로 넣으세요."
+PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
@@ -45,17 +47,22 @@ class CaptureError(Exception):
"""까닭 한 줄(키 · 예외 원문 없음)."""
-def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
- """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
+def page_html(
+ key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
+) -> str:
+ """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
+ view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
+ w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
+ level = view["level"] if view else 7
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
return f"""
-
+
"""
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
"""자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}."""
key = os.environ.get(KAKAO_JS, "").strip()
try:
- png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
+ png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
except CaptureError as error:
return {"saved": False, "reason": str(error)}
out_dir = Path(job["out"])
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
def make_route_image(
- out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
+ out_dir: Path,
+ route_id: str,
+ lines: list[Any],
+ marks: list[dict[str, Any]],
+ view: dict[str, int] | None = None,
+ what: str = "경로 그림",
+ tail: str = TAIL,
) -> dict[str, Any]:
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
key = os.environ.get(KAKAO_JS, "").strip()
if not key:
return {
"saved": False,
- "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
+ "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
}
if not any(lines) and not marks:
- return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
+ return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
- job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
+ job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
with _lock:
got = spawn({**job, "origin": origin})
if not got.get("saved"):
- return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
+ return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
return {"saved": True, "reason": None}
+def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
+ """장소 그림(256-1) — 작은 지도 · 표지 하나 → `/<상자 id>.png`."""
+ return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
+
+
if __name__ == "__main__":
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py
index e68cccdbc..9b883c87c 100644
--- a/B08_Quantity/B08_Quantity_Router_Milestone.py
+++ b/B08_Quantity/B08_Quantity_Router_Milestone.py
@@ -9,6 +9,10 @@
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
+ POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
+ 이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
+ GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/.png`
+ · `auto/.png` · `.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
지금 중점 측점(244-12 · 상자 작은 글씨)
@@ -24,6 +28,10 @@
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
+장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
+다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
+GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms}
+(캐시 깨기).
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
@@ -32,9 +40,11 @@
from __future__ import annotations
import asyncio
+import hashlib
import json
import math
import os
+import threading
import urllib.error
import urllib.parse
import urllib.request
@@ -42,7 +52,7 @@ from pathlib import Path
from typing import Annotated, Any, Literal
from uuid import UUID
-from fastapi import APIRouter, File, HTTPException, Query, UploadFile
+from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, ValidationError, model_validator
@@ -51,7 +61,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
-from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
+from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -69,6 +79,8 @@ PATH = "/{project_id}/quantity/milestone"
FOLDER = Path("B08_Quantity") / "milestone"
DATA_FILE = "milestone.json"
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
+PLACE = "place" # 장소 그림 하위 폴더(256-1)
+PRINTS = ".prints.json" # 그림 지문 {"place/": 해시} — 바뀐 것만 다시 캡처
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
@@ -212,15 +224,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]:
}
+def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]:
+ """좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = "b1"."""
+ # ponytail: 단일 기준점 — 254-1 bases[] 합친 뒤 bases[].id 로
+ boxes = [(r["id"], r.get("name") or "", r.get("lon"), r.get("lat")) for r in data["routes"]]
+ base = data.get("base") or {}
+ boxes.append(("b1", base.get("name") or "기준점", base.get("lon"), base.get("lat")))
+ return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None]
+
+
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
site = site_info(root, data["site"]["mid_station_m"])
auto = images(root, AUTO)
+ place = images(root, PLACE)
return {
"data": data,
"site": site,
"table": compute_milestone(data, site["mid_default_m"]),
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
"auto_images": auto,
+ "place_images": place,
+ "place_times": {
+ stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items()
+ },
}
@@ -230,13 +256,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
atomic_write_json(path / DATA_FILE, data)
keep = {route["id"] for route in data["routes"]}
- for sub in ("", AUTO):
+ for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})):
for stem, name in images(root, sub).items():
- if stem not in keep:
+ if stem not in ids:
(path / name).unlink(missing_ok=True)
return payload(root, data)
+def _print(*parts: Any) -> str:
+ """지문 — 좌표 · 이름 해시(좌표 원문은 안 남김)."""
+ return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16]
+
+
+def _read_prints(root: str | Path) -> dict[str, str]:
+ try:
+ return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return {}
+
+
+def _set_print(root: str | Path, key: str, value: str | None) -> None:
+ """지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처)."""
+ with _prints_lock:
+ prints = _read_prints(root)
+ if prints.get(key) == value:
+ return
+ if value is None:
+ prints.pop(key, None)
+ else:
+ prints[key] = value
+ atomic_write_json(folder(root) / PRINTS, prints)
+
+
+_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나
+
+
+def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict:
+ """장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}."""
+ got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat})
+ if got["saved"]:
+ _set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat))
+ return got
+
+
+def refresh_images(root: str | Path) -> None:
+ """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
+ 실패 = 다음 [저장]에 다시."""
+ data = read_data(root)
+ prints = _read_prints(root)
+ have = images(root, PLACE)
+ for place_id, name, lon, lat in places(data):
+ if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
+ place_image(root, place_id, name, lon, lat)
+
+
def _route_id(route: str) -> str:
try:
return Route(id=route).id
@@ -260,9 +333,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]:
@router.put(PATH)
-async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
+async def milestone_save(
+ project_id: UUID, body: Milestone, tasks: BackgroundTasks
+) -> dict[str, Any]:
root = await _project_root(project_id)
- return await asyncio.to_thread(save_data, root, body)
+ out = await asyncio.to_thread(save_data, root, body)
+ tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
+ return out
@router.get(PATH + "/site-address")
@@ -375,6 +452,32 @@ async def milestone_image_auto(
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
+FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$"
+
+
+@router.post(PATH + "/place-image")
+async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict:
+ """장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/.png · 좌표는 저장 안 함."""
+ place_id = _route_id(id)
+ root = await _project_root(project_id)
+ got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat)
+ if not got["saved"]:
+ return {"name": None, "time": None, "reason": got["reason"]}
+ name = f"{PLACE}/{place_id}.png"
+ time_ms = int((folder(root) / name).stat().st_mtime * 1000)
+ return {"name": name, "time": time_ms, "reason": None}
+
+
+@router.get(PATH + "/file")
+async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)):
+ """그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음)."""
+ root = await _project_root(project_id)
+ path = folder(root) / name
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="그림이 없습니다.")
+ return FileResponse(path)
+
+
KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
KAKAO_KEY = "KAKAO_REST_API_KEY"
diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
index 456a44b57..4909cf736 100644
--- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
+++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
@@ -13,6 +13,8 @@
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
+ * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
+ * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -35,6 +37,8 @@ interface Payload {
site: SiteInfo;
images: Record;
auto_images: Record;
+ place_images: Record;
+ place_times: Record;
}
export const EMPTY: MilestoneData = {
@@ -204,6 +208,43 @@ export async function autoRouteImage(
changes.dispatchEvent(new Event("change"));
}
+/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */
+export const placeImageKey = (placeId: string): string | null => {
+ const name = saved?.place_images?.[placeId];
+ return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null;
+};
+
+/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */
+export async function capturePlace(
+ projectId: string,
+ placeId: string,
+ mark: { name: string; lon: number; lat: number },
+): Promise {
+ let reason: string | null;
+ try {
+ const res = await request<{ name: string | null; time: number | null; reason: string | null }>(
+ `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`,
+ { method: "POST", body: JSON.stringify(mark) },
+ );
+ reason = res.reason;
+ if (res.name && saved) {
+ saved.place_images = { ...saved.place_images, [placeId]: res.name };
+ saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() };
+ }
+ } catch (error) {
+ reason = `장소 그림 캡처 요청 실패(${String(error)})`;
+ }
+ if (reason) showToast(reason, "warning");
+ changes.dispatchEvent(new Event("change"));
+}
+
+/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */
+export async function placeImageBlobUrl(projectId: string, placeId: string): Promise {
+ const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`;
+ const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`;
+ return URL.createObjectURL(await (await call(url)).blob());
+}
+
/** 그림 → 화면 주소(blob · 쿠키 포함) */
export async function imageBlobUrl(projectId: string, routeId: string): Promise {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py
index 2e1948356..3f303ed2f 100644
--- a/resources/tester/test_249_1_b08_milestone_capture_child.py
+++ b/resources/tester/test_249_1_b08_milestone_capture_child.py
@@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
- cap.page_html = lambda key, lines, marks: HTML
+ cap.page_html = lambda key, lines, marks, view=None: HTML
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
sys.exit(0)
diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py
new file mode 100644
index 000000000..53be8f62e
--- /dev/null
+++ b/resources/tester/test_256_1_b08_milestone_place_image.py
@@ -0,0 +1,146 @@
+"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
+
+캡처는 가짜(make_place_image 자리)로:
+① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
+② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/.png ·
+ GET 답 place_images · place_times
+③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음
+④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음)
+"""
+
+import sys
+from pathlib import Path
+from uuid import uuid4
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402
+from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
+
+PNG = b"\x89PNG\r\n\x1a\nfake-place"
+DATA = {
+ "base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68},
+ "routes": [
+ {"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866},
+ {"id": "r2", "name": "좌표 없음"},
+ ],
+}
+
+
+class Fake:
+ """가짜 캡처 — 부른 상자 기록 · ok=False 면 실패."""
+
+ def __init__(self):
+ self.calls = []
+ self.ok = True
+
+ def __call__(self, out_dir, place_id, mark):
+ self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"]))
+ if not self.ok:
+ return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"}
+ out_dir.mkdir(parents=True, exist_ok=True)
+ (out_dir / f"{place_id}.png").write_bytes(PNG)
+ return {"saved": True, "reason": None}
+
+
+def _save(root, data):
+ return ms.save_data(root, ms.Milestone.model_validate(data))
+
+
+def _fake(monkeypatch):
+ fake = Fake()
+ monkeypatch.setattr(ms, "make_place_image", fake)
+ return fake
+
+
+def test_장소_그림_페이지_작은_지도():
+ html = cap.page_html(
+ "k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW
+ )
+ assert "width:480px;height:320px" in html and "level: 4" in html
+ assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌
+ assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로
+
+
+def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch):
+ fake = _fake(monkeypatch)
+ _save(tmp_path, DATA)
+ ms.refresh_images(tmp_path)
+ assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
+ got = ms.payload(tmp_path, ms.read_data(tmp_path))
+ assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"}
+ assert set(got["place_times"]) == {"b1", "r1"}
+ assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values())
+ assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG
+ prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8")
+ assert "127.9095" not in prints and "35.6866" not in prints # 해시만
+
+
+def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch):
+ fake = _fake(monkeypatch)
+ _save(tmp_path, DATA)
+ ms.refresh_images(tmp_path)
+ fake.calls.clear()
+ ms.refresh_images(tmp_path)
+ assert fake.calls == [] # 안 바뀜 = 캡처 0
+ moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]}
+ _save(tmp_path, moved)
+ ms.refresh_images(tmp_path)
+ assert [c[0] for c in fake.calls] == ["r1"]
+ fake.calls.clear()
+ (tmp_path / "B08_Quantity/milestone/place/b1.png").unlink()
+ ms.refresh_images(tmp_path)
+ assert [c[0] for c in fake.calls] == ["b1"]
+ # 실패 = 지문 안 적음 → 다음에 다시
+ fake.calls.clear()
+ fake.ok = False
+ _save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}})
+ ms.refresh_images(tmp_path)
+ ms.refresh_images(tmp_path)
+ assert [c[0] for c in fake.calls] == ["b1", "b1"]
+
+
+def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch):
+ _fake(monkeypatch)
+ _save(tmp_path, DATA)
+ ms.refresh_images(tmp_path)
+ got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움
+ assert got["place_images"] == {} and got["place_times"] == {}
+ assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir())
+
+
+def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
+ fake = _fake(monkeypatch)
+
+ async def root(project_id):
+ return tmp_path
+
+ monkeypatch.setattr(ms, "_project_root", root)
+ app = FastAPI()
+ app.include_router(ms.router)
+ client = TestClient(app)
+ base = f"/api/projects/{uuid4()}/quantity/milestone"
+ # [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄)
+ assert client.put(base, json=DATA).status_code == 200
+ assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
+ assert client.get(base).json()["place_images"]["r1"] == "place/r1.png"
+ # 등록 · 끌기 뒤 화면이 부름
+ fake.calls.clear()
+ mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5}
+ res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json()
+ assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0
+ assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)]
+ fake.ok = False
+ bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json()
+ assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"}
+ assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400
+ # 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음
+ assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG
+ assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
+ for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
+ assert client.get(base + "/file", params={"name": name}).status_code == 422, name