From 9172d730ff17c9e275cfaa3f10d4fd1c9d719fe6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:06:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B08=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C):=20=EC=97=B0=EA=B2=B0=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=EC=86=8C=EC=9E=AC=EC=A7=80=20=EB=84=A3=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EA=B8=B8=20=EC=84=A0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=EC=A0=90=20=EB=AF=B8=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=C2=B7=20=EB=85=B9=EC=83=89=20=EC=A0=90=20=ED=81=AC=EA=B2=8C=20?= =?UTF-8?q?(373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경로 탭 지도 누름 · [소재지] = 거래처 등록 창 · 길 선 legs[0].path(소수 5자리 · 점 줄임 · 상한 500) 캐시 → [저장] · 다시 이음/실패 = 옛 거리 · 선 지움 · 수기 = 선 지움 · 연결 점 r 9 · 불투명도 .85. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 4 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 9 +++ B08_Quantity/B08_Quantity_Router_Milestone.py | 64 +++++++++++++-- B08_Quantity/B08_Quantity_UI_Milestone.css | 2 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 6 +- .../B08_Quantity_UI_Milestone_LegModal.ts | 24 ++++-- .../B08_Quantity_UI_Milestone_Route.ts | 29 +++++-- .../B08_Quantity_UI_Milestone_State.ts | 65 +++++++++++----- .../test_244_11_b08_milestone_auto_capture.py | 6 +- .../test_244_13_b08_milestone_place_leg.py | 8 +- .../test_244_9_b08_milestone_route_map.py | 35 ++++++++- .../test_254_2_b08_milestone_diagram.py | 4 +- .../test_255_2_b08_milestone_start_pin.py | 4 +- .../test_373_b08_milestone_place_path.py | 77 +++++++++++++++++++ 14 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 resources/tester/test_373_b08_milestone_place_path.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index ece7882e5..8266f7ce6 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -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 { diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index a07c8f8c5..e7af11443 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -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(); diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 793e45621..0020a4706 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -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" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 09b24921d..7ba848e6b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -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 { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index ef0fa6f26..620055f02 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -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) => { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 881dd86ca..2d873d60a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -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 => { @@ -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); }, }); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index cc02d0d09..40526113d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -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 => { 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; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 6d87b31cc..7827c40b7 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -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>(); -export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; -/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ -export const routePaths = (routeId: string): Array> => - 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 를 손으로 넣으세요.`; } } diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 4efdac4de..fbed19636 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -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") diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index ea87c7a73..b6a019bc9 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -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", diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index df41ff5d7..fe54091ec 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -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] diff --git a/resources/tester/test_254_2_b08_milestone_diagram.py b/resources/tester/test_254_2_b08_milestone_diagram.py index 73f092158..7fdcf1df6 100644 --- a/resources/tester/test_254_2_b08_milestone_diagram.py +++ b/resources/tester/test_254_2_b08_milestone_diagram.py @@ -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 ) diff --git a/resources/tester/test_255_2_b08_milestone_start_pin.py b/resources/tester/test_255_2_b08_milestone_start_pin.py index bf149c7e4..8c6644a26 100644 --- a/resources/tester/test_255_2_b08_milestone_start_pin.py +++ b/resources/tester/test_255_2_b08_milestone_start_pin.py @@ -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 diff --git a/resources/tester/test_373_b08_milestone_place_path.py b/resources/tester/test_373_b08_milestone_place_path.py new file mode 100644 index 000000000..b10fad415 --- /dev/null +++ b/resources/tester/test_373_b08_milestone_place_path.py @@ -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("}")] From 474ead51b494b038ce3e79341e4685d2ce836677 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:07:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?style(B08):=20373=20=EC=A4=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B08_Quantity/B08_Quantity_Router_Milestone.py | 2 +- resources/tester/test_373_b08_milestone_place_path.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 0020a4706..1d6ec27d5 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -107,7 +107,7 @@ 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) # [길찾기] 자동 값 - # 373 마지막 [길찾기] 길 선 [위도, 경도](소수 5자리 · 점 줄임 · 구간 첫 줄만) — 수기 · 다시 이음 = 비움 + # 373 마지막 [길찾기] 길 선 [위도, 경도](소수 5자리 · 점 줄임) — 구간 첫 줄만 · 수기 = 비움 path: list[tuple[float, float]] | None = Field(default=None, max_length=PATH_MAX) @field_validator("path") diff --git a/resources/tester/test_373_b08_milestone_place_path.py b/resources/tester/test_373_b08_milestone_place_path.py index b10fad415..dae94ac6d 100644 --- a/resources/tester/test_373_b08_milestone_place_path.py +++ b/resources/tester/test_373_b08_milestone_place_path.py @@ -1,4 +1,4 @@ -"""373 B08 거리이정표 — 연결 없이 소재지 넣기 · 구간 길 선 저장(캐시 → [저장]) · 다시 이음 · 시점 미연결 · 녹색 점. +"""373 B08 거리이정표 — 연결 없이 소재지 넣기 · 구간 길 선 저장 · 다시 이음 · 시점 미연결 · 녹색 점. 화면 코드는 소스 확인 + node 로 실제 TS(`findSegmentKm` · `reachesSite`)를 돌림. 서버 쪽 `path` 칸은 `test_244_9_b08_milestone_route_map.py`. @@ -29,7 +29,8 @@ DATA = { 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 {', + ' return { km: null, reason: "카카오 실패", path: [] };\n' + ' if (url.includes("/directions")) return {', )