diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 3d4ae22c6..58cc57df4 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -8,6 +8,8 @@ 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 +pick = 참(화면 「도로 구분 고르기」). """ from __future__ import annotations @@ -24,11 +26,22 @@ def r3(value: float) -> float: return math.floor(value * 1000 + 0.5) / 1000 -def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> None: +def leg_km(leg: dict[str, Any]) -> float | None: + """구간 km — 사용자 값 우선 · 없으면 자동 값.""" + km = leg.get("km") + return leg.get("auto_km") if km is None else km + + +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool: + """도로 구분마다 더함 — 안 고른 구간이 있으면 참.""" + pick = False for leg in legs or []: key = KEYS.get(leg.get("road")) if key: - sums[key] += float(leg.get("km") or 0) + sums[key] += float(leg_km(leg) or 0) + else: + pick = True + return pick def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: @@ -48,9 +61,9 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} - add_legs(sums, route.get("legs")) + pick = add_legs(sums, route.get("legs")) if base.get("enabled") and route.get("via_base", True): - add_legs(sums, base.get("legs")) + pick = add_legs(sums, base.get("legs")) or pick if row.get("dest") == "mid": add_legs(sums, [mid]) paved = sums["s2"] + sums["s1"] @@ -68,6 +81,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "unpaved": r3(sums["un"]), "total": r3(paved + sums["un"]), "note": row.get("note") or "", + "pick": pick, } ) return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 4856fc234..481d56391 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -9,6 +9,8 @@ * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 + * pick = 참(화면 「도로 구분 고르기」) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "비포장"] as const; @@ -19,8 +21,12 @@ export type Dir = (typeof DIRS)[number]; export type Dest = "start" | "mid"; export interface Leg { - road: Road; + /** null = 사용자가 안 고름(자동 판정 없음 · 244-7) */ + road: Road | null; + /** 사용자 값 — 있으면 우선 */ km: number | null; + /** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */ + auto_km?: number | null; } export interface MilestoneRow { @@ -72,6 +78,8 @@ export interface MilestoneTableRow { unpaved: number; total: number; note: string; + /** 도로 구분 안 고른 구간이 있음 */ + pick: boolean; } const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; @@ -79,11 +87,18 @@ const KEYS: Record = { "2차포장": "s2", "1차포 /** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; -function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): void { +/** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ +export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ +function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): boolean { + let pick = false; for (const leg of legs ?? []) { - const key = KEYS[leg.road]; - if (key) sums[key] += Number(leg.km ?? 0); + const key = leg.road ? KEYS[leg.road] : undefined; + if (key) sums[key] += Number(legKm(leg) ?? 0); + else pick = true; } + return pick; } /** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ @@ -103,8 +118,8 @@ export function computeMilestone( for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, un: 0 }; - addLegs(sums, route.legs); - if (base?.enabled && route.via_base !== false) addLegs(sums, base.legs); + let pick = addLegs(sums, route.legs); + if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; if (row.dest === "mid") addLegs(sums, [mid]); const paved = sums.s2 + sums.s1; out.push({ @@ -120,6 +135,7 @@ export function computeMilestone( unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", + pick, }); }); } diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 9dfc0bae7..f687db05a 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -10,6 +10,7 @@ * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) + * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -34,6 +35,8 @@ export interface DiagramLine { x2: number; y2: number; labels: string[]; + /** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */ + tips: string[]; } export interface DiagramLayout { @@ -81,7 +84,67 @@ export function placeDeg( } export const legText = (legs: Leg[]): string[] => - legs.map((leg) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`); + legs.map((leg) => { + const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) + return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + }); + +/** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ +export const legTip = (leg: Leg): string => + leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km + ? `자동 값 ${leg.auto_km} km` + : ""; + +/** [길찾기] 자동 km → 그 구간 첫 줄(없으면 도로 구분 안 고른 줄 하나) · 사용자 km 는 그대로 */ +export function setAutoKm(legs: Leg[], km: number): Leg[] { + if (!legs.length) legs.push({ road: null, km: null, auto_km: km }); + else legs[0].auto_km = km; + return legs; +} + +export interface KmSegment { + /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + key: "route" | "base"; + text: string; + from: [number, number] | null; + to: [number, number] | null; +} + +const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => + p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + +/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 + * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ +export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { + const route = data.routes.find((r) => r.id === routeId); + if (!route) return []; + const viaBase = data.base.enabled && route.via_base; + const baseName = data.base.name || "기준점"; + const from = pointOf(route); + const first: KmSegment = { + key: "route", + text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, + from, + to: viaBase ? pointOf(data.base) : (start ?? null), + }; + if (!viaBase) return [first]; + return [ + first, + { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }, + ]; +} + +/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ +export function putAutoKm( + data: MilestoneData, + routeId: string, + key: KmSegment["key"], + km: number, +): MilestoneData { + const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; + if (legs) setAutoKm(legs, km); + return data; +} const unit = (deg: number): [number, number] => { const rad = (deg * Math.PI) / 180; @@ -100,10 +163,10 @@ export function layoutMilestone( boxes.push(box); center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); }; - const line = (from: string, to: string, labels: string[]): void => { + const line = (from: string, to: string, legs: Leg[]): void => { const [x1, y1] = center.get(from)!; const [x2, y2] = center.get(to)!; - lines.push({ from, to, x1, y1, x2, y2, labels }); + lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) }); }; // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) @@ -134,11 +197,7 @@ export function layoutMilestone( h: NODE.h, label: "중점", }); - line( - "start", - "mid", - legText([{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]), - ); + line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; const useBase = data.base.enabled; @@ -159,7 +218,7 @@ export function layoutMilestone( h: BOX.h, label: data.base.name || "기준점", }); - line("base", "start", legText(data.base.legs)); + line("base", "start", data.base.legs); } // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 @@ -212,7 +271,7 @@ export function layoutMilestone( label: g.label, items: g.items, }); - line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); + line(g.id, g.anchor === "base" ? "base" : "start", g.legs); }); } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index a9fd66fb7..9697f2997 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -7,6 +7,8 @@ DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) + POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 + 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -20,6 +22,11 @@ from __future__ import annotations import asyncio import json +import math +import os +import urllib.error +import urllib.parse +import urllib.request from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -55,8 +62,9 @@ Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] class Leg(BaseModel): - road: Road = "2차포장" - km: float | None = Field(default=None, ge=0, le=10000) + 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 만 저장) class Row(BaseModel): @@ -282,3 +290,68 @@ async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> d for old in route_images(root, route_id): old.unlink(missing_ok=True) return {"name": None} + + +KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" +KAKAO_KEY = "KAKAO_REST_API_KEY" + + +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +class Directions(BaseModel): + origin: LonLat + destination: LonLat + + +def _kakao_open(request: urllib.request.Request) -> bytes: + with urllib.request.urlopen(request, timeout=8) as response: + return response.read() + + +def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: + """카카오 길찾기 → {km, reason} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄. + 키 · 응답 원문 · 경로 좌표는 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" + key = os.environ.get(KAKAO_KEY, "").strip() + if not key: + return { + "km": None, + "reason": f"카카오 길찾기 키가 없습니다(.env {KAKAO_KEY}) — km 를 손으로 넣으세요.", + } + query = urllib.parse.urlencode( + { + "origin": f"{origin.lon},{origin.lat}", + "destination": f"{destination.lon},{destination.lat}", + "priority": "RECOMMEND", + } + ) + request = urllib.request.Request( + f"{KAKAO_URL}?{query}", headers={"Authorization": f"KakaoAK {key}"} + ) + try: + body = json.loads(_kakao_open(request).decode("utf-8")) + except urllib.error.HTTPError as error: + reason = { + 401: "카카오가 키를 받지 않았습니다(키 · 앱 설정 확인)", + 403: "카카오가 길찾기를 막았습니다(앱 · 권한 확인)", + 429: "오늘 카카오 길찾기 요청 수를 넘었습니다", + }.get(error.code, f"카카오 길찾기 응답 오류(HTTP {error.code})") + return {"km": None, "reason": f"{reason} — km 를 손으로 넣으세요."} + except ( + Exception + ): # 끊김 · 시간 넘김 · 깨진 응답 — 원문 · 예외 글은 내지 않음(키가 섞일 수 있음) + return {"km": None, "reason": "카카오 길찾기에 닿지 못했습니다 — km 를 손으로 넣으세요."} + route = ((body or {}).get("routes") or [{}])[0] if isinstance(body, dict) else {} + distance = (route.get("summary") or {}).get("distance") + if route.get("result_code") != 0 or not isinstance(distance, (int, float)): + msg = str(route.get("result_msg") or "결과 없음")[:80] + return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."} + return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None} + + +@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) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index ffabeca3f..3e0baafe2 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -159,3 +159,8 @@ fill: var(--color-text-secondary); font-size: 12px; } + +/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ +.b08-milestone .is-pick-road { + color: var(--color-danger); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 8984b5099..2a9aaea35 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -41,7 +41,7 @@ const COLUMNS: DataColumn[] = [ { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, - { key: "total", label: "합계", group: ["계 (km)"], ...KM }, + { key: "total", label: "합계", group: ["계 (km)"], ...KM, width: 108 }, { key: "note", label: "비고", width: 160 }, ]; @@ -55,7 +55,7 @@ export function addRoute(projectId: string, init: Partial = {}): address: "", via_base: true, dir: null, - legs: [{ road: "2차포장", km: null }], + legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], ...init, }); @@ -67,12 +67,14 @@ export function addRoute(projectId: string, init: Partial = {}): function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; + let pickRows: boolean[] = []; const table = createDataTable({ columns: COLUMNS, rows: [], caption: "거리이정표", empty: "", editOnDouble: true, + cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), editable: (_i, key) => key === "item" || key === "company" || key === "note", onEdit: (i, key, text) => { const at = keys[i]; @@ -105,8 +107,16 @@ function tableView(projectId: string): MilestoneView { const refresh = (): void => { const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); keys = rows.map(({ route, index }) => ({ route, index })); + pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; - table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) + table.setRows( + rows.map((r) => ({ + ...r, + dest: `${DEST_TEXT[r.dest]} ▾`, + total: r.pick ? "도로 구분 고르기" : r.total, + })), + ); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e22e9b69c..c4e7cd91b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -8,11 +8,12 @@ * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc"; import { layoutMilestone, legAt, @@ -28,6 +29,7 @@ import { openSiteInfo, } from "./B08_Quantity_UI_Milestone_Modal"; import { + legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -58,14 +60,22 @@ function editLegAt( const leg = legAt(liveMilestone(projectId), from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); - const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); - road.select.value = leg.road; + const road = createSelectField({ + label: "도로 구분", + options: from === "start" ? roadOptions : legRoadOptions, // 시점 → 중점 = 늘 고름 + compact: true, + }); + road.select.value = leg.road ?? ""; const km = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, }); km.value = leg.km == null ? "" : String(leg.km); km.setAttribute("aria-label", "km"); + if (leg.auto_km != null) { + km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값 + km.title = `자동 값 ${leg.auto_km} km`; + } const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); box.dataset.b08MilestoneLegEdit = `${from}:${i}`; const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 @@ -80,7 +90,7 @@ function editLegAt( const n = Number(km.value); const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; const patch = { - road: road.select.value as typeof leg.road, + road: (road.select.value || null) as Road | null, ...(km.value.trim() && value === null ? {} : { km: value }), }; putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); @@ -208,6 +218,7 @@ export function createDiagram( ln.labels.forEach((label, i) => { const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); span.addEventListener("dblclick", (e) => { e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 editLegAt(projectId, span, ln.from, i, mk); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index d3cd4d9e1..8eb55eba1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -8,6 +8,8 @@ * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 + * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · + * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -37,6 +39,8 @@ import { } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { + findRouteKm, + legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -51,16 +55,21 @@ const DEST_OPTIONS = [ const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */ -function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } { +/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · km(빈칸 = 자동 값) · [×] · [+ 구간] */ +function legsEditor(initial: Leg[]): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = []; + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; const add = (leg: Leg): void => { - const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + const road = createSelectField({ label: "도로 구분", options: legRoadOptions, compact: true }); const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); - road.select.value = leg.road; + road.select.value = leg.road ?? ""; km.input.value = leg.km == null ? "" : String(leg.km); - const ref = { road: road.select, km: km.input }; + const auto = leg.auto_km ?? null; + if (auto != null) { + km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + km.input.title = `자동 값 ${auto} km`; + } + const ref = { road: road.select, km: km.input, auto }; rows.push(ref); const drop = createButton({ label: "×", @@ -80,16 +89,17 @@ function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () label: "+ 구간", size: "sm", variant: "ghost", - onClick: () => add({ road: fresh, km: null }), + onClick: () => add({ road: null, km: null }), }); return { root: el("div", { children: [list, more] }), value: () => - rows.map(({ road, km }) => { + rows.map(({ road, km, auto }) => { const n = Number(km.value); return { - road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh, + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, + auto_km: auto, }; }), }; @@ -150,6 +160,17 @@ function placeOf( const head = (text: string): HTMLElement => el("h4", { text }); +/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */ +async function routeKm(projectId: string, routeId: string): Promise { + const lines = await findRouteKm(projectId, routeId); + if (lines.length) + showToast( + `길찾기 — ${lines.join(" / ")}`, + lines.some((l) => l.includes(" — ")) ? "warning" : "success", + ); + return lines; +} + /** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ export function openAddItem( projectId: string, @@ -176,12 +197,14 @@ export function openAddItem( const place = placeOf(address, { address: "" }); const data = liveMilestone(projectId); const same = data.routes.find((r) => r.name.trim() === name); + let id = same?.id; if (same) { same.rows.push(row); if (place.lon != null) Object.assign(same, place); putMilestone(projectId, data); - } else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); + } else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); close(); + if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번 }, }); actions.append(ok); @@ -213,24 +236,49 @@ export function openRouteInfo(projectId: string, routeId: string): void { via.root.hidden = !data.base.enabled; const rows = rowsEditor(route.rows); const address = buildAddressField(route.address || null); - const legs = legsEditor(route.legs, "2차포장"); + let legs = legsEditor(route.legs); + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneInfo = "status"; + /** 모달 값 → 캐시(고침 · 길찾기 앞) */ + const apply = (): boolean => { + const fresh = liveMilestone(projectId); + const r = fresh.routes.find((x) => x.id === routeId); + if (!r) return false; + Object.assign(r, placeOf(address, r), { + name: name.input.value.trim() || r.name, + via_base: via.input.checked, + rows: rows.value(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + return true; + }; const save = createButton({ label: "고침", variant: "filled", onClick: () => { - const fresh = liveMilestone(projectId); - const r = fresh.routes.find((x) => x.id === routeId); - if (!r) return close(); - Object.assign(r, placeOf(address, r), { - name: name.input.value.trim() || r.name, - via_base: via.input.checked, - rows: rows.value(), - legs: legs.value(), - }); - putMilestone(projectId, fresh); + apply(); close(); }, }); + // [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림 + const find = createButton({ + label: "길찾기", + variant: "ghost", + onClick: async () => { + if (!apply()) return close(); + find.disabled = true; + status.textContent = "길찾기 중…"; + const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false)); + status.textContent = lines.join(" / "); + const r = liveMilestone(projectId).routes.find((x) => x.id === routeId); + if (!r) return; + const next = legsEditor(r.legs); + legs.root.replaceWith(next.root); + legs = next; + }, + }); + find.dataset.b08MilestoneInfo = "find"; const remove = createButton({ label: "삭제", variant: "danger", @@ -250,7 +298,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { }); save.dataset.b08MilestoneInfo = "save"; remove.dataset.b08MilestoneInfo = "delete"; - actions.append(remove, save); + actions.append(remove, find, save); body.append( name.root, via.root, @@ -260,6 +308,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, ), legs.root, + status, head("주소 · 지도"), address.root, ); @@ -277,10 +326,7 @@ export function openBaseInfo(projectId: string, create = false): void { const name = createInputField({ label: "기준점 이름", compact: true }); name.input.value = base.name; const address = buildAddressField(base.address || null); - const legs = legsEditor( - base.legs.length ? base.legs : [{ road: "1차포장", km: null }], - "1차포장", - ); + const legs = legsEditor(base.legs.length ? base.legs : [{ road: null, km: null }]); const save = createButton({ label: create ? "등록" : "고침", variant: "filled", diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 8ccf1c96f..4ce05a5ca 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -6,12 +6,15 @@ * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). + * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · + * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { kmSegments, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; export interface SiteInfo { length_m: number | null; @@ -126,3 +129,48 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); + +/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ +export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; + +/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · + * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ +export async function findRouteKm(projectId: string, routeId: string): Promise { + const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + const out: string[] = []; + const got: Array<[KmSegment["key"], number]> = []; + for (const seg of segments) { + if (!seg.from || !seg.to) { + const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; + out.push( + `${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`, + ); + continue; + } + try { + const res = await request<{ km: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/directions`, + { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }, + ); + if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); + else { + out.push(`${seg.text} ${res.km} km`); + got.push([seg.key, res.km]); + } + } catch (error) { + out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); + } + } + if (got.length) { + const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 + for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); + putMilestone(projectId, fresh); + } + return out; +} diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 9b8958ed6..767a771f0 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -159,7 +159,7 @@ def test_화면_모달_팬줌_그림탭(): info = modal[ modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo") ] - for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs, "2차포장")', + for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs)', "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip assert text in info diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py new file mode 100644 index 000000000..2ea0966a8 --- /dev/null +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -0,0 +1,240 @@ +"""244-7 B08 거리이정표 — 카카오 길찾기 REST 로 km 자동. + +서버 길 = 가짜 응답으로 km 셈(10463 m → 10.5 km) · 키 없음 안내 · 키 값이 응답 · 로그에 안 나옴 · +구간 채움(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) · 사용자 km 우선 · +도로 구분 안 고름 = pick(합에 안 듦) · 실제 카카오 한 번(AISLO_KAKAO_LIVE=1 일 때만). +""" + +import io +import json +import logging +import os +import re +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +FAKE_KEY = "fake-kakao-key-0123456789" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} # 거창군청 +BYEONGGOK = {"lon": 127.85, "lat": 35.66} # 병곡리 +OK_BODY = { + "trans_id": "t", + "routes": [ + { + "result_code": 0, + "result_msg": "길찾기 성공", + "summary": {"distance": 10463, "duration": 902}, + "sections": [{"roads": [{"vertexes": [127.9, 35.6]}]}], + } + ], +} + + +def _node(src_rel, expr): + src = (ROOT / src_rel).read_text(encoding="utf-8") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _client(): + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def _post(client, origin=GEOCHANG, destination=BYEONGGOK): + return client.post( + "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone/directions", + json={"origin": origin, "destination": destination}, + ) + + +def test_가짜_응답_km_소수_한자리(monkeypatch): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + seen = {} + + def fake(request): + seen["url"] = request.full_url + seen["auth"] = request.get_header("Authorization") + return json.dumps(OK_BODY).encode() + + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(_client()) + assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None} + # 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK + assert seen["url"].startswith(ms.KAKAO_URL + "?") + assert "origin=127.9095%2C35.6866" in seen["url"] + assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"] + assert seen["auth"] == f"KakaoAK {FAKE_KEY}" + # 경로 좌표 · 응답 원문은 안 돌려줌(km 만) + assert "vertexes" not in res.text and "sections" not in res.text + # 끝수 = 소수 1자리 반올림 + for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)): + body = {"routes": [{"result_code": 0, "summary": {"distance": meters}}]} + monkeypatch.setattr(ms, "_kakao_open", lambda r, b=body: json.dumps(b).encode()) + assert ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK))["km"] == km + + +def test_키_없음_안내(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + called = [] + monkeypatch.setattr(ms, "_kakao_open", lambda r: called.append(r)) + body = _post(_client()).json() + assert body["km"] is None and "키가 없습니다" in body["reason"] and "손으로" in body["reason"] + assert not called # 키 없으면 카카오를 부르지 않음 + monkeypatch.setenv(ms.KAKAO_KEY, " ") + assert "키가 없습니다" in _post(_client()).json()["reason"] + + +def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + caplog.set_level(logging.DEBUG) + client = _client() + + def http(code): + def fake(request): + raise urllib.error.HTTPError( + request.full_url, code, f"bad {FAKE_KEY}", {}, io.BytesIO(FAKE_KEY.encode()) + ) + + return fake + + def boom(request): + raise OSError(f"끊김 Authorization: KakaoAK {FAKE_KEY}") + + cases = [ + (http(401), "키를 받지 않았습니다"), + (http(429), "요청 수를 넘었습니다"), + (http(500), "HTTP 500"), + (boom, "닿지 못했습니다"), + (lambda r: b"{not json " + FAKE_KEY.encode(), "닿지 못했습니다"), + (lambda r: json.dumps({"routes": [{"result_code": 104, "result_msg": + "출발지와 도착지가 5 m 이내"}]}).encode(), "5 m 이내"), + ] # fmt: skip + for fake, want in cases: + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(client) + assert res.status_code == 200 and res.json()["km"] is None + assert want in res.json()["reason"], res.json() + assert FAKE_KEY not in res.text + assert FAKE_KEY not in caplog.text + # 잘못된 좌표 = 422(카카오 안 부름) + assert _post(client, origin={"lon": 999, "lat": 0}).status_code == 422 + assert FAKE_KEY not in ms.__doc__ and "KakaoAK" not in (ms.__doc__ or "") + + +def _data(base_enabled=True, base_ll=(127.85, 35.66)): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, + "lon": base_ll[0], "lat": base_ll[1], "legs": []}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "lon": 127.9095, "lat": 35.6866, "legs": [{"road": "2차포장", "km": 23.8}], + "rows": [{"item": "장비", "dest": "mid", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "lon": None, "lat": None, "legs": [{"road": None, "km": None}], + "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + + +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +START = [127.81, 35.75] + + +def test_구간_채움(): + d = json.dumps(_data()) + seg = _node(LAYOUT, f"kmSegments({d}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"]) for s in seg] == [ + ("route", "거창군청 → 병곡리 968-31"), + ("base", "병곡리 968-31 → 시점"), + ] + assert seg[0]["from"] == [127.9095, 35.6866] and seg[0]["to"] == [127.85, 35.66] + assert seg[1]["from"] == [127.85, 35.66] and seg[1]["to"] == START + # 기준점 없음 = 거래처 → 시점 하나 · 기준점 안 거침 · 거래처 좌표 없음 = from null + off = json.dumps(_data(base_enabled=False)) + one = _node(LAYOUT, f"kmSegments({off}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"], s["to"]) for s in one] == [("route", "거창군청 → 시점", START)] + two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") + assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] + # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] + assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 + lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") + line = {ln["from"]: ln for ln in lay["lines"]} + assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + + +def test_사용자_값_우선_고르기_표시(): + data = _data() + data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] + data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + a, b = compute_milestone(data, 1500.0) + # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 + assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) + # 도로 구분 안 고름 = 합에 안 듦 · pick + assert (b["total"], b["pick"]) == (0, True) + data["base"]["legs"][0]["road"] = None + a2, _ = compute_milestone(data, 1500.0) + assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + # TS 짝 거울 + for d in (data, _data()): + ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", + f"computeMilestone({json.dumps(d)}, 1500)") # fmt: skip + assert ts == compute_milestone(d, 1500.0) + # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 + saved = ms.Milestone.model_validate(data).model_dump() + assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 + assert saved["base"]["legs"][0]["road"] is None + assert ms.Leg.model_validate({"km": 1}).road == "2차포장" + + +def test_화면_길찾기_단추_키_화면에_없음(): + read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤 + assert "/quantity/milestone/directions" in state and "kmSegments(" in state + assert '"도로 구분 고르기"' in table and "r.pick" in table + assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 + # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 + for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): + text = rel.read_text(encoding="utf-8") + assert "KAKAO_REST_API_KEY" not in text and "KakaoAK" not in text, rel + + +@pytest.mark.skipif( + os.environ.get("AISLO_KAKAO_LIVE") != "1", reason="실제 카카오 = 한 번만 손으로" +) +def test_실제_카카오_한번(): + from dotenv import load_dotenv + + load_dotenv(ROOT / ".env") + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) + assert got["reason"] is None and isinstance(got["km"], float) and 5 < got["km"] < 20 + print(f"\n실제 카카오 거창군청 → 병곡리 = {got['km']} km")