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:
2026-10-10 22:11:36 +09:00
co-authored by Claude Opus 5.5
parent e299e1cdb8
commit 09b57398b7
11 changed files with 578 additions and 56 deletions
+18 -4
View File
@@ -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
+22 -6
View File
@@ -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,
});
});
}
+69 -10
View File
@@ -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);
});
}
+75 -2
View File
@@ -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);
}
+13 -3
View File
@@ -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);
+72 -26
View File
@@ -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")