feat(b08): 거리이정표 km 자동 = 카카오 길찾기 REST · 서버 단독 · 사용자 km 우선 · 도로 구분 고르기 (244-7)
- 서버 새 길 POST …/quantity/milestone/directions — 두 좌표 → 카카오 길찾기 → km 소수 1자리 · 키 없음 · 실패 = 까닭 한 줄 · 키 서버만 · 응답 원문 · 경로 좌표 안 돌려줌 - 거래처 모달 [길찾기] · [등록] 뒤 한 번 — 거래처 → 기준점 · 기준점 → 시점(기준점 없으면 거래처 → 시점) 자동 km(auto_km 만 저장) - 사용자 km 우선 · 다르면 호버 「자동 값 ○○ km」 · 도로 구분 안 고름 = 합에 안 듦 · 표 합계 칸 「도로 구분 고르기」 - 표 합 파이썬 · TS 짝 같이 고침 · 새 시험 test_244_7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, "s2" | "s1" | "un"> = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" };
|
||||
@@ -79,11 +87,18 @@ const KEYS: Record<string, "s2" | "s1" | "un"> = { "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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -159,3 +159,8 @@
|
||||
fill: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */
|
||||
.b08-milestone .is-pick-road {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -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<MilestoneRoute> = {}):
|
||||
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<MilestoneRoute> = {}):
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string[]> {
|
||||
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",
|
||||
|
||||
@@ -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<string[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user