feat(b08): 거리이정표 경로 그림 = 전체 경로(거래처 → 기준점 사슬 → 시점) · 저장 뒤 지문 바뀐 경로만 캡처 (256-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
@@ -32,6 +32,10 @@
|
||||
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
|
||||
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
|
||||
(캐시 깨기).
|
||||
경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 ·
|
||||
구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png`
|
||||
· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음
|
||||
[저장]에 전체로.
|
||||
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
|
||||
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
|
||||
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
|
||||
@@ -62,7 +66,13 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
|
||||
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 SITE, compute_milestone, migrate_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import (
|
||||
SITE,
|
||||
base_chain,
|
||||
compute_milestone,
|
||||
migrate_milestone,
|
||||
r3,
|
||||
)
|
||||
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
|
||||
@@ -314,6 +324,36 @@ def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: flo
|
||||
return got
|
||||
|
||||
|
||||
def route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list:
|
||||
"""경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 ·
|
||||
좌표 없는 상자 뺌."""
|
||||
owners = [(route.get("name") or "거래처", route)]
|
||||
if route.get("linked", True) is not False:
|
||||
owners += [
|
||||
(b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE)
|
||||
]
|
||||
out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners]
|
||||
if start:
|
||||
out.append(("시점", start[0], start[1], False))
|
||||
return [c for c in out if c[1] is not None and c[2] is not None]
|
||||
|
||||
|
||||
def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]:
|
||||
"""경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음."""
|
||||
lines = []
|
||||
for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]):
|
||||
path = []
|
||||
if not manual:
|
||||
got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2))
|
||||
path = got.get("path") or []
|
||||
lines.append(path or [[lat1, lon1], [lat2, lon2]])
|
||||
marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain]
|
||||
got = make_route_image(folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]:
|
||||
_set_print(root, f"{AUTO}/{route_id}", _print(chain))
|
||||
return got
|
||||
|
||||
|
||||
def refresh_images(root: str | Path) -> None:
|
||||
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
|
||||
실패 = 다음 [저장]에 다시."""
|
||||
@@ -323,6 +363,15 @@ def refresh_images(root: str | Path) -> None:
|
||||
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)
|
||||
start = site_info(root, None)["start_lonlat"]
|
||||
have = images(root, AUTO)
|
||||
for route in data["routes"]:
|
||||
chain = route_chain(data, route, start)
|
||||
key = f"{AUTO}/{route['id']}"
|
||||
if route.get("linked", True) is False or len(chain) < 2:
|
||||
continue # 안 이은 거래처 = 경로 그림 없음
|
||||
if prints.get(key) != _print(chain) or route["id"] not in have:
|
||||
route_image(root, route["id"], chain)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
@@ -353,7 +402,10 @@ async def milestone_save(
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
out = await asyncio.to_thread(save_data, root, body)
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
if not os.environ.get(
|
||||
"PYTEST_CURRENT_TEST"
|
||||
): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
return out
|
||||
|
||||
|
||||
@@ -464,6 +516,8 @@ async def milestone_image_auto(
|
||||
lines = [[list(p) for p in line] for line in body.lines]
|
||||
marks = [m.model_dump() for m in body.marks]
|
||||
got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2)
|
||||
await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None)
|
||||
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def _save(root, data):
|
||||
def _fake(monkeypatch):
|
||||
fake = Fake()
|
||||
monkeypatch.setattr(ms, "make_place_image", fake)
|
||||
monkeypatch.setattr(ms, "route_image", lambda *a: {"saved": False}) # 경로 그림 = 256-2 시험
|
||||
return fake
|
||||
|
||||
|
||||
@@ -121,6 +122,7 @@ def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""256-2 B08 거리이정표 경로 그림 = 전체 경로(사용자 162-7).
|
||||
|
||||
캡처 · 길찾기는 가짜로:
|
||||
① 사슬 = 거래처 → 기준점 사슬(254 bases · to) → 시점 · 화면 `mapMarks` 차례 · 좌표 없는 상자 뺌
|
||||
② 구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · auto/<경로 id>.png
|
||||
③ [저장] 뒤 지문 비교 — 안 바뀜 = 0 · 기준점 · 시점 옮김 = 그 사슬 경로만 · 안 이은 거래처 = 없음 ·
|
||||
화면 [길찾기] 그림(거래처 구간만)이 들어오면 다음 [저장]에 전체로
|
||||
"""
|
||||
|
||||
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_Router_Milestone as ms # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nfake-route"
|
||||
START = [127.5, 35.4]
|
||||
BASES = [
|
||||
{"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"},
|
||||
{"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52, "manual": True},
|
||||
]
|
||||
ROUTES = [
|
||||
{"id": "r1", "name": "거창레미콘", "lon": 127.95, "lat": 35.70, "to": "b1"},
|
||||
{"id": "r2", "name": "직송", "lon": 127.60, "lat": 35.45},
|
||||
{"id": "r3", "name": "안 이음", "lon": 127.61, "lat": 35.46, "linked": False},
|
||||
{"id": "r4", "name": "좌표 없음", "to": "b2"},
|
||||
]
|
||||
DATA = {"bases": BASES, "routes": ROUTES}
|
||||
|
||||
|
||||
class Fake:
|
||||
def __init__(self):
|
||||
self.shots = [] # (경로 id, lines, marks)
|
||||
self.asked = [] # 길찾기 구간
|
||||
|
||||
def shoot(self, out_dir, route_id, lines, marks):
|
||||
self.shots.append((route_id, lines, marks))
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"{route_id}.png").write_bytes(PNG)
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
def directions(self, origin, destination):
|
||||
self.asked.append((origin.lon, destination.lon))
|
||||
if origin.lon == 127.60: # r2 → 시점 = 길찾기 실패
|
||||
return {"km": None, "reason": "실패"}
|
||||
return {"km": 1.0, "reason": None, "path": [[origin.lat, origin.lon], [0, 0]]}
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, start=START):
|
||||
fake = Fake()
|
||||
box = {"start": start}
|
||||
monkeypatch.setattr(ms, "make_route_image", fake.shoot)
|
||||
monkeypatch.setattr(ms, "directions_km", fake.directions)
|
||||
monkeypatch.setattr(ms, "make_place_image", lambda *a: {"saved": False}) # 256-1 시험
|
||||
monkeypatch.setattr(
|
||||
ms, "site_info", lambda root, mid: {"start_lonlat": box["start"], "mid_default_m": None}
|
||||
)
|
||||
return fake, box
|
||||
|
||||
|
||||
def _save(root, data):
|
||||
ms.save_data(root, ms.Milestone.model_validate(data))
|
||||
ms.refresh_images(root)
|
||||
|
||||
|
||||
def test_사슬_화면_차례():
|
||||
data = ms.Milestone.model_validate(DATA).model_dump()
|
||||
chain = ms.route_chain(data, data["routes"][0], START)
|
||||
assert chain == [
|
||||
("거창레미콘", 127.95, 35.70, False),
|
||||
("거창IC", 127.91, 35.68, False),
|
||||
("함양JC", 127.72, 35.52, True),
|
||||
("시점", 127.5, 35.4, False),
|
||||
]
|
||||
assert [c[0] for c in ms.route_chain(data, data["routes"][3], START)] == ["함양JC", "시점"]
|
||||
assert [c[0] for c in ms.route_chain(data, data["routes"][1], None)] == ["직송"]
|
||||
|
||||
|
||||
def test_전체_경로_그림_구간_선(tmp_path, monkeypatch):
|
||||
fake, _ = _setup(tmp_path, monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
shots = {rid: (lines, marks) for rid, lines, marks in fake.shots}
|
||||
assert sorted(shots) == ["r1", "r2", "r4"] # r3 안 이음 = 없음
|
||||
lines, marks = shots["r1"]
|
||||
assert [m["name"] for m in marks] == ["거창레미콘", "거창IC", "함양JC", "시점"]
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == [[35.70, 127.95], [0, 0]] # 카카오 길 선
|
||||
assert lines[2] == [[35.52, 127.72], [35.4, 127.5]] # 수기 기준점 = 곧은 선 · 길찾기 안 함
|
||||
assert (127.72, 127.5) not in fake.asked
|
||||
assert shots["r2"][0] == [[[35.45, 127.60], [35.4, 127.5]]] # 길찾기 실패 = 곧은 선
|
||||
assert (tmp_path / "B08_Quantity/milestone/auto/r1.png").read_bytes() == PNG
|
||||
|
||||
|
||||
def test_바뀐_경로만_다시(tmp_path, monkeypatch):
|
||||
fake, box = _setup(tmp_path, monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
fake.shots.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert fake.shots == [] # 안 바뀜
|
||||
moved = {**DATA, "bases": [BASES[0], {**BASES[1], "lat": 35.55}]}
|
||||
_save(tmp_path, moved)
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r4"] # b2 를 지나는 경로만
|
||||
fake.shots.clear()
|
||||
box["start"] = [127.4, 35.3] # 시점 옮김(노선 수정) = 모두
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"]
|
||||
fake.shots.clear()
|
||||
(tmp_path / "B08_Quantity/milestone/auto/r2.png").unlink() # 그림 없음 = 다시
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [s[0] for s in fake.shots] == ["r2"]
|
||||
|
||||
|
||||
def test_길찾기_그림_뒤엔_다음_저장에_전체로(tmp_path, monkeypatch):
|
||||
fake, _ = _setup(tmp_path, monkeypatch)
|
||||
|
||||
async def root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
base = f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
assert client.put(base, json=DATA).status_code == 200 # 답 뒤 캡처
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"]
|
||||
fake.shots.clear()
|
||||
part = {"lines": [[[35.70, 127.95], [35.68, 127.91]]], "marks": []}
|
||||
assert client.post(base + "/image/auto", params={"route": "r1"}, json=part).status_code == 200
|
||||
assert [s[0] for s in fake.shots] == ["r1"] # 화면 길찾기 그림(거래처 구간만)
|
||||
fake.shots.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [(s[0], len(s[2])) for s in fake.shots] == [("r1", 4)] # 전체 경로로 다시
|
||||
Reference in New Issue
Block a user