Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,78 @@
|
||||
"""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("}")]
|
||||
Reference in New Issue
Block a user