feat(B08 거리이정표): 연결 없이 소재지 넣기 · 구간 길 선 저장 · 시점 미연결 · 녹색 점 크게 (373)

경로 탭 지도 누름 · [소재지] = 거래처 등록 창 · 길 선 legs[0].path(소수 5자리 · 점 줄임 · 상한 500) 캐시 → [저장] ·
다시 이음/실패 = 옛 거리 · 선 지움 · 수기 = 선 지움 · 연결 점 r 9 · 불투명도 .85.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-17 12:06:35 +09:00
co-authored by Claude Sonnet 5.5
parent 98396f9939
commit 9172d730ff
14 changed files with 285 additions and 52 deletions
+3 -1
View File
@@ -125,8 +125,10 @@ export interface Leg {
road: Road | null;
/** 사용자 값 — 있으면 우선 */
km: number | null;
/** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */
/** [길찾기] 자동 값 */
auto_km?: number | null;
/** 마지막 [길찾기] 길 선 [위도, 경도](373 · 소수 5자리 · 점 줄임 · 구간 첫 줄만) — 수기 · 다시 이음 = 없음 */
path?: Array<[number, number]> | null;
}
export interface MilestoneRow {
@@ -284,6 +284,15 @@ export function chainOf(data: MilestoneData, to: string): MilestoneBase[] {
return out;
}
/** 시점까지 이어짐(373) — from(거래처 · 기준점 id)이 이어졌고 사슬 끝 기준점이 시점에 닿음 */
export function reachesSite(data: MilestoneData, from: string): boolean {
const owner = ownerOf(data, from);
if (!owner || owner.linked === false) return false;
if (owner.to === TO_SITE) return true;
const chain = chainOf(data, owner.to);
return chain.length > 0 && chain[chain.length - 1].to === TO_SITE;
}
/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */
export function makesLoop(data: MilestoneData, from: string, to: string): boolean {
const seen = new Set<string>();
+59 -5
View File
@@ -18,7 +18,8 @@
지금 중점 측점(244-12 · 상자 작은 글씨)
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 소수 5자리 · 점 줄임 ·
373 화면이 구간 `legs[].path` 로 캐시 → [저장])
GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) →
VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null)
GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 ·
@@ -60,7 +61,7 @@ from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, ValidationError, model_validator
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
@@ -97,6 +98,7 @@ IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀
PATH_MAX = 500 # 구간 길 선 점 수 상한(점 줄인 뒤)
Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13)
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
@@ -104,7 +106,18 @@ Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
class Leg(BaseModel):
road: Road | None = "2차포장" # None = 사용자가 안 고름(244-7 · 표에 「도로 구분 고르기」)
km: float | None = Field(default=None, ge=0, le=10000) # 사용자 값 — 있으면 우선
auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값(km 만 저장)
auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값
# 373 마지막 [길찾기] 길 선 [위도, 경도](소수 5자리 · 점 줄임 · 구간 첫 줄만) — 수기 · 다시 이음 = 비움
path: list[tuple[float, float]] | None = Field(default=None, max_length=PATH_MAX)
@field_validator("path")
@classmethod
def _path(cls, value: list[tuple[float, float]] | None) -> list[tuple[float, float]] | None:
if not value:
return None
if any(not (-90 <= lat <= 90 and -180 <= lon <= 180) for lat, lon in value):
raise ValueError("길 선 좌표 범위")
return [(round(lat, 5), round(lon, 5)) for lat, lon in value]
class Row(BaseModel):
@@ -616,10 +629,51 @@ def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]:
return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path}
def _rdp(points: list[tuple[float, float]], eps: float) -> list[tuple[float, float]]:
"""Douglas-Peucker — 두 끝은 남기고 허용 오차(도) 안 점을 뺌."""
keep = [False] * len(points)
keep[0] = keep[-1] = True
stack = [(0, len(points) - 1)]
while stack:
a, b = stack.pop()
(ay, ax), (by, bx) = points[a], points[b]
dx, dy = bx - ax, by - ay
norm = math.hypot(dx, dy)
far, at = -1.0, -1
for i in range(a + 1, b):
py, px = points[i]
d = (
abs(dy * (px - ax) - dx * (py - ay)) / norm
if norm
else math.hypot(px - ax, py - ay)
)
if d > far:
far, at = d, i
if at >= 0 and far > eps:
keep[at] = True
stack += [(a, at), (at, b)]
return [p for p, k in zip(points, keep) if k]
def thin_path(path: list[list[float]], limit: int = PATH_MAX) -> list[list[float]]:
"""길 선 점 줄임 — 소수 5자리 · 같은 점 이어짐 뺌 · 상한 넘으면 허용 오차를 2배씩 키워 줄임."""
points: list[tuple[float, float]] = []
for lat, lon in path:
p = (round(lat, 5), round(lon, 5))
if not points or points[-1] != p:
points.append(p)
eps = 1e-5
while len(points) > limit:
points = _rdp(points, eps)
eps *= 2
return [[lat, lon] for lat, lon in points]
@router.post(PATH + "/directions")
async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]:
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음)."""
return await asyncio.to_thread(directions_km, body.origin, body.destination)
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음) · 길 선은 점 줄여 돌려줌(373)."""
got = await asyncio.to_thread(directions_km, body.origin, body.destination)
return {**got, "path": thin_path(got["path"])} if got.get("path") else got
KAKAO_JS = "KAKAO_JS_KEY"
+1 -1
View File
@@ -261,7 +261,7 @@
stroke: var(--color-surface);
stroke-width: 2;
cursor: crosshair;
opacity: 0.55;
opacity: 0.85;
}
.b08-milestone__box:hover .b08-milestone__connect {
@@ -212,9 +212,9 @@ export function createDiagram(
* (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기
* (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
const cx = box.x + 10;
const cy = box.y + 10;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 });
const cx = box.x + 12;
const cy = box.y + 12;
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 9 });
dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결"));
dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸
dot.addEventListener("pointerdown", (down) => {
@@ -5,7 +5,7 @@
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
* 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
* 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 저장된 길 선 `legs[0].path` · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
@@ -51,6 +51,7 @@ export function legsEditor(
road: HTMLSelectElement;
km: HTMLInputElement;
auto: number | null;
path: Leg["path"]; // 길 선은 칸에 안 보임 · 그대로 가져감
orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음)
shown: string;
}> = [];
@@ -68,7 +69,14 @@ export function legsEditor(
dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
dist.input.title = `자동 값 ${fmtKm(auto)} km`;
}
const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value };
const ref = {
road: road.select,
km: dist.input,
auto,
path: leg.path,
orig: leg.km,
shown: dist.input.value,
};
rows.push(ref);
const drop = createButton({
label: "×",
@@ -96,10 +104,11 @@ export function legsEditor(
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km: dist, auto, orig, shown }) => ({
rows.map(({ road, km: dist, auto, path, orig, shown }) => ({
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: dist.value === shown ? orig : km(dist.value),
auto_km: auto,
...(path ? { path } : {}),
})),
};
}
@@ -199,7 +208,7 @@ export function openLegInfo(projectId: string, from: string): void {
return out;
};
const drawMap = (): void => {
const path = segmentPath(from);
const path = segmentPath(projectId, from);
map.set(pins(), path.length >= 2 ? [path] : []);
};
const run = async (image = true): Promise<void> => {
@@ -268,7 +277,9 @@ export function openLegInfo(projectId: string, from: string): void {
const fresh = liveMilestone(projectId);
const target = owner(fresh);
if (!target) return;
target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l));
target.legs = legs
.value()
.map((l) => (manual.input.checked ? { ...l, auto_km: null, path: null } : l));
target.manual = manual.input.checked;
putMilestone(projectId, fresh);
redrawLegs();
@@ -279,7 +290,8 @@ export function openLegInfo(projectId: string, from: string): void {
showMode();
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함)
if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false);
if (!manual.input.checked && !segmentPath(projectId, from).length && seg.from && seg.to)
void run(false);
},
});
}
@@ -5,6 +5,8 @@
*
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
* 373 연결 없어도 소재지 넣기 — 지도 누름 = 그 자리 소재지 · 머리 줄 [소재지] = 등록 창(`openPlaceModal`) ·
* 운반 자동 상자(주소 · 선 · 좌표 없음)도 · 길 선 = 저장된 구간 `path` · 안 이은 기준점에 이음 = 「시점 미연결」
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
* ========================================================================== */
@@ -16,9 +18,10 @@ import {
} from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout";
import { chainOf, ownerOf, reachesSite } from "./B08_Quantity_Milestone_Layout";
import { DEST_TEXT } from "./B08_Quantity_Milestone_Table";
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { openPlaceModal } from "./B08_Quantity_UI_Milestone_Modal";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
capturePlace,
@@ -122,6 +125,13 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
),
});
reset.dataset.b08MilestoneRevert = "";
// 373 [소재지] — 연결 없이도 주소 넣기(거래처 등록 창 그대로)
const place = createButton({
label: "소재지",
variant: "ghost",
onClick: () => openPlaceModal(projectId, { add: false, id: routeId }),
});
place.dataset.b08MilestonePlaceOpen = "";
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
@@ -142,12 +152,16 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
return [routeId, ...chain].map((id) => segmentPath(projectId, id)).filter((p) => p.length >= 2);
};
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
const status = el("p", { className: "b08-options__hint" });
status.dataset.b08MilestoneRouteStatus = "";
status.hidden = true;
// 373 안 이은 기준점에 이음 = 시점까지 안 닿음 안내(이은 거래처만)
const open = el("p", { className: "b08-options__hint" });
open.dataset.b08MilestoneRouteOpen = "";
open.hidden = true;
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
const refind = async (ids: string[]): Promise<void> => {
const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false)));
@@ -172,8 +186,10 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
await refind(touchingSegments(fresh, key));
};
const map = editMap(projectId, {
hint: "",
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
onClick: (lon, lat) => void moveTo(routeId, lon, lat),
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
center: () => startPoint(liveMilestone(projectId)),
});
const drawMap = (): void => map.set(pins(), lines());
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
@@ -183,7 +199,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length);
const missing = [routeId, ...chain].filter((id) => !segmentPath(projectId, id).length);
if (missing.length) void refind(missing);
};
@@ -195,10 +211,11 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
className: "b08-milestone__route-head",
children: [
el("div", { className: "b08-milestone__route-title", children: [name, address] }),
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
el("div", { className: "b08-milestone__bar", children: [place, change, reset, file] }),
],
}),
status,
open,
img,
map.root,
],
@@ -210,6 +227,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
if (!r) return;
name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`;
address.textContent = r.address || "";
open.hidden = r.linked === false || reachesSite(d, routeId);
open.textContent = open.hidden ? "" : "시점 미연결 — 이은 기준점이 시점에 이어지지 않았습니다.";
loadImage();
const user = hasUserImage(routeId);
reset.hidden = !user;
+45 -20
View File
@@ -8,9 +8,10 @@
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
* [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 ·
* 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음)
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 좌표(244-9 · 373) = 마지막 [길찾기] 길 선을 구간 첫 줄 `legs[0].path` 에(캐시 → [저장] 정본 ·
* 다시 이으면 옛 거리 · 선 지우고 새 값 · 실패 = 빈칸 + 까닭 · 수기 = 지움)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭)
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
@@ -29,7 +30,13 @@ import {
type MilestoneData,
type SegKind,
} from "./B08_Quantity_Milestone_Calc";
import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout";
import {
mapMarks,
ownerOf,
putAutoKm,
reachesSite,
segmentEnds,
} from "./B08_Quantity_Milestone_Layout";
import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
export interface SiteInfo {
@@ -278,12 +285,30 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string
return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r }));
}
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<[number, number]>>();
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
paths.has(routeId) ? [paths.get(routeId)!] : [];
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(캐시 자료의 구간 첫 줄) */
export const segmentPath = (projectId: string, from: string): Array<[number, number]> =>
ownerOf(liveMilestone(projectId), from)?.legs[0]?.path ?? [];
/** 찾은 값을 구간 첫 줄에 — km · path 가 null 이면 옛 값 지움(옛 거리 · 선 안 남김) · 바뀔 때만 캐시에 */
function putFound(
projectId: string,
from: string,
km: number | null,
path: Array<[number, number]> | null,
): void {
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
const owner = ownerOf(fresh, from);
const before = JSON.stringify(owner?.legs[0] ?? null);
if (km != null) putAutoKm(fresh, from, km);
const leg = owner?.legs[0];
if (leg) {
if (km == null && leg.auto_km != null) leg.auto_km = null;
if (path) leg.path = path;
else delete leg.path;
}
if (JSON.stringify(leg ?? null) !== before) putMilestone(projectId, fresh);
else changes.dispatchEvent(new Event("change"));
}
/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) ·
* 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */
@@ -299,6 +324,7 @@ export async function findSegmentKm(
if (!seg) return "";
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
putFound(projectId, from, null, null);
return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`;
}
try {
@@ -313,18 +339,17 @@ export async function findSegmentKm(
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
});
if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!);
else paths.delete(from);
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) {
putAutoKm(fresh, from, res.km);
putMilestone(projectId, fresh);
} else changes.dispatchEvent(new Event("change"));
if (image && seg.key === "route" && paths.has(from))
void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림
return `${seg.text} ${res.km} km`;
if (res.km == null) {
putFound(projectId, from, null, null);
return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
}
const path = (res.path?.length ?? 0) >= 2 ? res.path! : null;
putFound(projectId, from, res.km, path);
if (image && seg.key === "route" && path) void autoRouteImage(projectId, from, [path]); // 뒤에서 · km 알림 안 기다림
const open = reachesSite(liveMilestone(projectId), from) ? "" : " — 시점 미연결";
return `${seg.text} ${res.km} km${open}`;
} catch (error) {
putFound(projectId, from, null, null);
return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`;
}
}
@@ -264,8 +264,10 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동():
assert "캡처는 localhost" not in text, name
state = read("B08_Quantity_UI_Milestone_State.ts")
# 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림
assert 'if (image && seg.key === "route" && paths.has(from))' in state
assert "void autoRouteImage(projectId, from, routePaths(from));" in state
assert (
'if (image && seg.key === "route" && path) void autoRouteImage(projectId, from, [path]);'
in state
)
assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state
assert "user_only=true" in state and "showToast(reason," in state
route = read("B08_Quantity_UI_Milestone_Route.ts")
@@ -245,8 +245,8 @@ const writes = WRITES.n;
await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀
const live = liveMilestone("p1");
console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n,
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("b1"),
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("p1", "g"),
routeLines: [segmentPath("p1", "g")], baseLines: [segmentPath("p1", "b1")],
start: await findSegmentKm("p1", "start") }}));"""
got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS)
assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km"
@@ -268,6 +268,8 @@ console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, a
assert got["routeLines"] == [got["path"]] and got["baseLines"] == [
got["path"]
] # 구간마다 길 선
assert got["route"][0]["path"] == [[35.68, 127.9], [35.66, 127.85]] # 373 길 선 = 구간 자료에
assert got["base"][0]["path"] == got["path"]
assert got["start"] == "" # 시점 → 중점 = 길찾기 없음
@@ -302,7 +304,7 @@ def test_화면_등록_모달_연결선_모달():
assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid
assert 'if (from === "start") return openMidLeg(projectId);' in leg
assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg
assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg
assert "!manual.input.checked && !segmentPath(projectId, from).length" in leg
for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts",
B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC,
B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts",
@@ -11,8 +11,10 @@ import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
@@ -93,12 +95,15 @@ def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch):
def test_좌표_저장_안_함(tmp_path):
route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]}
route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도
route["path"] = [
[35.6866, 127.9095],
[35.66, 127.85],
] # 화면이 잘못 실어 보내도(거래처 칸 = 안 받음)
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]}))
files = [p for p in tmp_path.rglob("*") if p.is_file()]
assert [p.name for p in files] == ["milestone.json"]
text = files[0].read_text(encoding="utf-8")
assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text
assert '"path": null' in text and "35.6866" not in text and '"auto_km": 10.5' in text
def test_JS_키_길(monkeypatch):
@@ -229,10 +234,34 @@ def test_화면_자리_캡처_키_서버만():
assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route
assert "routeMapView" not in kmap
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
assert "putFound(projectId, from, res.km, path);" in state # 373 구간 자료(캐시 → [저장])
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만
text = rel.read_text(encoding="utf-8")
assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel
for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT,
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700
def test_373_구간_길_선_저장_소수_5자리_점_줄임(tmp_path):
line = [[35.6866123, 127.9095456], [35.686612, 127.909546], [35.66, 127.85]]
leg = {"road": None, "auto_km": 10.5, "path": line}
ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [leg]}]}))
saved = ms.read_data(tmp_path)["routes"][0]["legs"][0]["path"]
assert [list(p) for p in saved] == [
[35.68661, 127.90955],
[35.68661, 127.90955],
[35.66, 127.85],
]
with pytest.raises(ValidationError):
ms.Milestone.model_validate({"routes": [{"id": "r1", "legs": [{"path": [[95.0, 127.0]]}]}]})
with pytest.raises(ValidationError): # 상한
ms.Milestone.model_validate(
{"routes": [{"id": "r1", "legs": [{"path": [[35.0, 127.0]] * (ms.PATH_MAX + 1)}]}]}
)
# 길찾기 응답 줄이기 — 같은 점 이어짐 뺌 · 상한 안 · 두 끝 유지
thin = ms.thin_path([[35.0, 127.0], [35.0, 127.0], [35.00001, 127.0]])
assert thin == [[35.0, 127.0], [35.00001, 127.0]]
wavy = [[35 + i * 1e-4, 127 + (i % 2) * 1e-5] for i in range(2000)]
small = ms.thin_path(wavy)
assert len(small) <= ms.PATH_MAX and small[0] == [35.0, 127.0] and small[-1][0] == wavy[-1][0]
@@ -113,8 +113,8 @@ def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소():
ui = _read(B08 + "UI_Milestone_Diagram.ts")
dot = ui[ui.index("const connectDot") :]
dot = dot[: dot.index("return dot;")]
# 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안
assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot
# 왼쪽 위 안쪽 — 가운데 (12, 12) · 반지름 9 + 테두리 2 = 상자 안(373)
assert "const cx = box.x + 12;" in dot and "const cy = box.y + 12;" in dot and "r: 9" in dot
assert (
'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui
)
@@ -113,7 +113,9 @@ def test_경로_탭_끌기_다시_길찾기():
assert "fresh.site.start_pin = { lon, lat };" in route
assert "await refind(touchingSegments(fresh, key));" in route
assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음
assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간
assert (
"[routeId, ...chain].filter((id) => !segmentPath(projectId, id).length)" in route
) # 열 때 모든 구간
assert "status.hidden = !bad.length;" in route # 실패 안내
calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")
assert "start_pin?: { lon: number; lat: number } | null;" in calc
@@ -0,0 +1,77 @@
"""373 B08 거리이정표 — 연결 없이 소재지 넣기 · 구간 길 선 저장(캐시 → [저장]) · 다시 이음 · 시점 미연결 · 녹색 점.
화면 코드는 소스 확인 + node 로 실제 TS(`findSegmentKm` · `reachesSite`)를 돌림. 서버 쪽 `path` 칸은
`test_244_9_b08_milestone_route_map.py`.
"""
from resources.tester.test_244_13_b08_milestone_place_leg import (
CALC,
LAYOUT,
STATE,
STATE_STUBS,
_node,
_read,
)
B08 = "B08_Quantity/B08_Quantity_"
ROW = [{"item": "철근", "dest": "start", "note": ""}]
DATA = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"bases": [
{"id": "b1", "name": "병곡리", "address": "", "dir": None, "lon": 127.85, "lat": 35.66,
"legs": [{"road": None, "km": None}], "to": "site", "linked": False},
],
"routes": [
{"id": "g", "name": "g", "address": "", "to": "b1", "dir": None, "lon": 127.9,
"lat": 35.68, "linked": True, "legs": [{"road": "2차포장", "km": 23.8}], "rows": ROW},
],
} # fmt: skip
FAIL_STUBS = STATE_STUBS.replace(
'if (url.includes("/directions")) return {',
'if (url.includes("/directions") && (globalThis as any).FAIL)'
' return { km: null, reason: "카카오 실패", path: [] };\n if (url.includes("/directions")) return {',
)
def test_373_안_이은_기준점에_이으면_시점_미연결_실패면_옛_거리_선_지움():
script = f"""const DATA = {__import__("json").dumps(DATA)};
await loadMilestone("p1");
const a = await findSegmentKm("p1", "g", false);
const first = liveMilestone("p1");
const open = reachesSite(first, "g");
first.bases[0].linked = true;
const closed = reachesSite(first, "g");
(globalThis as any).FAIL = true;
const b = await findSegmentKm("p1", "g", false);
const after = liveMilestone("p1").routes[0].legs;
console.log(JSON.stringify({{ a, b, open, closed, first: first.routes[0].legs, after,
line: segmentPath("p1", "g") }}));"""
got = _node([CALC, LAYOUT, STATE], script, FAIL_STUBS)
assert got["a"] == "g → 병곡리 10.5 km — 시점 미연결" and got["open"] is False
assert got["closed"] is True
assert got["first"][0]["path"] == [[35.68, 127.9], [35.66, 127.85]] # 구간 자료에 길 선
assert got["b"] == "g → 병곡리 — 카카오 실패"
assert got["after"] == [{"road": "2차포장", "km": 23.8, "auto_km": None}] # 사용자 km 만 남음
assert got["line"] == []
def test_373_화면_소재지_지도_누름_저장된_선_녹색_점():
route = _read(B08 + "UI_Milestone_Route.ts")
assert (
'label: "소재지"' in route
and "openPlaceModal(projectId, { add: false, id: routeId })" in route
)
assert "onClick: (lon, lat) => void moveTo(routeId, lon, lat)," in route # 연결 없어도 누름
assert "center: () => startPoint(liveMilestone(projectId))," in route # 점 없으면 시점 쪽
assert "reachesSite(d, routeId)" in route and "시점 미연결" in route
leg = _read(B08 + "UI_Milestone_LegModal.ts")
assert "path: leg.path," in leg and "...(path ? { path } : {})" in leg # 모달이 선 안 지움
assert "{ ...l, auto_km: null, path: null }" in leg # 수기 = 선 지움
diagram = _read(B08 + "UI_Milestone_Diagram.ts")
assert "route.legs = [{ road: null, km: null }];" in diagram # 다시 이음 = 옛 거리 · 선 지움
state = _read(B08 + "UI_Milestone_State.ts")
assert "paths" not in state.replace("path:", "").replace("segmentPath", "") # 메모리 표 없음
assert "path?: Array<[number, number]> | null;" in _read(CALC)
css = _read(B08 + "UI_Milestone.css")
dot = css[css.index(".b08-milestone__connect {") :]
assert "opacity: 0.85;" in dot[: dot.index("}")]