feat(D01): 예정노선 등록 — 설계속도 지움 · 카카오 지도 기준점 하나 + 주소(VWorld · 출처 표기) · 사업 지역 채움 (PLAN 591-1 · 요구 452 · 453)

- 등록 화면 = 공용 카카오 지도(마우스 설정 따름 · 일반/위성) 누름 · 끌기로 기준점 하나 · 주소 글 + 출처
- 사업 지역 = 주소의 「시군구 읍면」 채움 · 손으로 고친 값은 안 덮음
- 서버 = 기준점 필수(경위도 범위) · 주소는 서버가 VWorld 로 다시 찾아 저장 · 설계속도 칸 안 씀(B05 기본 20)
- 새 길 GET /api/map/address — 지번 먼저(산 번지) · region · 출처 VWorld · B08 한 줄 주소 그대로
- DB 024 = projects 기준점 칸 셋 추가 · PLAN 설계속도 비움 · 옛 PLAN 기준점 = 지점 가운데(024_plan_site_fill.py · 적용 완료)
- 시험 528 = 설계속도 단언을 기준점 단언으로 · 새 지킴 시험 591_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 09:41:31 +09:00
co-authored by Claude Opus 5.5
parent 1c0c44f9e2
commit 3d7f6d0b70
10 changed files with 388 additions and 33 deletions
+28 -5
View File
@@ -116,8 +116,8 @@ async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
def _address_items(lon: float, lat: float) -> dict[str, dict[str, Any]]:
"""좌표 → VWorld getAddress 결과(종류 road · parcel → 한 칸) · 실패 = 빈 dict."""
query = urllib.parse.urlencode(
{
"service": "address",
@@ -133,11 +133,34 @@ def _reverse_sync(lon: float, lat: float) -> str | None:
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
return None
return {}
items = (body.get("response") or {}).get("result") or []
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
return texts.get("road") or texts.get("parcel") or None
return {item.get("type"): item for item in items if isinstance(item, dict)}
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(도로명 먼저 · 없으면 지번)."""
items = _address_items(lon, lat)
return next(
(items[k]["text"] for k in ("road", "parcel") if items.get(k, {}).get("text")), None
)
def _place_sync(lon: float, lat: float) -> dict[str, str | None]:
"""좌표 → {address, region}(591-1 기준점) — 산은 지번 먼저 · region = 「시군구 읍면」."""
items = _address_items(lon, lat)
item = next((items[k] for k in ("parcel", "road") if items.get(k, {}).get("text")), None)
if not item:
return {"address": None, "region": None}
part = item.get("structure") or {}
town = "level4L" if item.get("type") == "parcel" else "level4A" # 도로명은 4L = 길 이름
region = " ".join(v for k in ("level2", "level3", town) if (v := part.get(k)))
return {"address": item["text"], "region": region or None}
async def reverse_geocode(lon: float, lat: float) -> str | None:
return await asyncio.to_thread(_reverse_sync, lon, lat)
async def reverse_place(lon: float, lat: float) -> dict[str, str | None]:
return await asyncio.to_thread(_place_sync, lon, lat)
+13 -7
View File
@@ -1,7 +1,8 @@
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).
화면은 이름 · 시작 연도 · 사업 지역만 보냄(요구서 389 3장 1 · 3) — 끝 연도 = 시작 · 임도 종류 없음
= 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로).
화면은 이름 · 시작 연도 · 사업 지역 · 기준점(경위도) 보냄(요구서 389 3장 1 · 3 · 452 · PLAN 591-1) —
끝 연도 = 시작 · 임도 종류 · 설계속도 없음(B 로 넘길 때 B05 기본) · 측점 없음(DB 칸 그대로).
기준점 = D03 처음 자료 받는 기준 · 주소 = 서버가 VWorld 로 다시 찾아 저장(못 찾음 = 빈 값).
"""
import json
@@ -11,8 +12,7 @@ import aiomysql
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed
from B05_Profile.B05_Profile_PageSettings import grade_class_of
from B01_Dashboard.B01_Dashboard_Map import reverse_place
from common_util.common_util_auth import is_forest_service, require_company
from common_util.common_util_project_kind import create_kind_project
from config.config_db import get_db_pool
@@ -21,13 +21,18 @@ router = APIRouter(prefix="/api/d01", tags=["D01_PlanRegister"])
dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D01_PlanRegister"])
class Site(BaseModel):
lon: float = Field(..., ge=120, le=135)
lat: float = Field(..., ge=30, le=45)
class CreatePlanRequest(BaseModel):
project_name: str = Field(..., min_length=1, max_length=255)
project_year: int = Field(..., ge=2000, le=2100)
project_year_end: int | None = Field(default=None, ge=2000, le=2100)
region: str = Field(..., min_length=1, max_length=100)
road_type: str | None = Field(default=None, pattern="^(main|fire|work)$")
design_speed_kph: int | None = None
site: Site
start_chainage_m: float | None = Field(default=None, ge=0)
end_chainage_m: float | None = Field(default=None, ge=0)
@@ -51,7 +56,8 @@ async def post_plan_project(
raise HTTPException(
status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다."
)
speed = resolve_design_speed(grade_class_of(payload.road_type, {}), payload.design_speed_kph)
site = payload.site
address = (await reverse_place(site.lon, site.lat))["address"]
project_id = await create_kind_project(
request=request,
user_id=int(session["user_id"]),
@@ -62,7 +68,7 @@ async def post_plan_project(
road_type=payload.road_type,
project_year=payload.project_year,
project_year_end=year_end,
design_speed_kph=speed,
site=(site.lon, site.lat, address),
route_start_m=start_m,
route_end_m=end_m,
)
+98 -5
View File
@@ -2,19 +2,98 @@
* D01_PlanRegister_UI_Page.ts
* D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」)
* · 사업연도 끝 · 임도 종류 · 설계속도 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본).
* [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로.
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D02 로.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createButton, createCard, createInputField } from "@ui/ui_template_elements";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createCard, createInputField, showToast } from "@ui/ui_template_elements";
import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { editMap, type MapType } from "@ui/ui_template_kakao_map";
import { t } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
const PREF_MAP_TYPE = "aislo:pref:d01.map.type";
interface Place {
address: string | null;
region: string | null;
source: string;
}
/** 기준점 고르기 — 지도 누름 · 끌기 = 한 점 · 주소(VWorld) · onRegion = 「시군구 읍면」 */
function sitePicker(onRegion: (region: string) => void): {
root: HTMLElement;
value: () => [number, number] | null;
} {
let site: [number, number] | null = null;
let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게
const where = createText(t("DE_Reg_SiteEmpty"), "muted", 14);
where.dataset.d01Site = "address";
const map = editMap({
hint: t("DE_Reg_SiteHint"),
center: () => KOREA_CENTER,
onClick: (lon, lat) => void moveTo(lon, lat),
onMove: (_key, lon, lat) => void moveTo(lon, lat),
});
const mapType = (readByKey(PREF_MAP_TYPE) === "HYBRID" ? "HYBRID" : "ROADMAP") as MapType;
map.setType(mapType);
const typePick = createSegment({
options: [
{ value: "ROADMAP", label: t("DE_Reg_MapNormal") },
{ value: "HYBRID", label: t("DE_Reg_MapSatellite") },
],
value: mapType,
ariaLabel: t("DE_Reg_MapType"),
onChange: (value) => {
map.setType(value as MapType);
writeByKey(PREF_MAP_TYPE, value);
},
});
const draw = (): void =>
map.set(
site
? [{ key: "site", name: t("DE_Reg_Site"), lon: site[0], lat: site[1], movable: true }]
: [],
[],
);
async function moveTo(lon: number, lat: number): Promise<void> {
const mine = ++seq;
site = [+lon.toFixed(DIGITS), +lat.toFixed(DIGITS)];
where.textContent = t("DE_Reg_SiteFinding");
draw();
const place = await fetch(`${API_BASE_URL}/map/address?lon=${site[0]}&lat=${site[1]}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<Place>) : null))
.catch(() => null);
if (mine !== seq) return;
const text = place?.address || `${t("DE_Reg_SiteNoAddress")} (${site[1]}, ${site[0]})`;
where.textContent = `${t("DE_Reg_SiteAddress")}${text}${place ? ` · ${t("DE_Reg_SiteSource")} ${place.source}` : ""}`;
if (place?.region) onRegion(place.region);
}
draw();
const head = document.createElement("div");
head.className = "ui-general-block__actions";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root);
const root = document.createElement("div");
root.dataset.d01Site = "";
root.append(head, map.root, where);
return { root, value: () => site };
}
export function renderD01PlanRegister(root: HTMLElement): void {
const yearStart = createInputField({
label: t("DE_Reg_YearStart"),
@@ -43,6 +122,17 @@ export function renderD01PlanRegister(root: HTMLElement): void {
for (const field of [yearStart, region]) field.input.addEventListener("input", syncName);
syncName();
// 사업 지역 = 주소의 「시군구 읍면」 — 비었거나 지난번 채운 그대로일 때만(손으로 고친 값은 둠)
let autoRegion = "";
const site = sitePicker((value) => {
const now = region.input.value.trim();
if (now && now !== autoRegion) return;
autoRegion = value;
region.input.value = value;
region.setError();
syncName();
});
const submit = (): void => {
const fields = [yearStart, region, name];
fields.forEach((field) => field.setError());
@@ -55,12 +145,15 @@ export function renderD01PlanRegister(root: HTMLElement): void {
const bad = errors.filter(([, message]) => message);
bad.forEach(([field, message]) => field.setError(message as string));
if (bad.length) return;
const at = site.value();
if (!at) return showToast(t("DE_Reg_SiteRequired"), "error");
void createGroupProject(
"/d01/project",
{
project_name: name.input.value.trim(),
project_year: y0,
region: region.input.value.trim(),
site: { lon: at[0], lat: at[1] },
},
ROUTES.D02_PLAN_BASE,
);
@@ -82,7 +175,7 @@ export function renderD01PlanRegister(root: HTMLElement): void {
narrow: true,
title: t("DE_Reg_PlanTitle"),
subtitle: t("DE_Reg_PlanSubtitle"),
content: createCard({ body: [grid, actions], raised: true }),
content: createCard({ body: [grid, site.root, actions], raised: true }),
});
root.append(layout.root);
}
+14 -2
View File
@@ -1,6 +1,7 @@
"""카카오 지도 JavaScript 키 공용 길(529-2) — B08 거리이정표 · D 예정노선 지도가 씀.
"""카카오 지도 JS 키 · 좌표 → 주소 공용 길(529-2 · 591-1) — B08 거리이정표 · D 예정노선이 씀.
GET /api/map/kakao-key {key} — 공개용(도메인 등록으로 막힘) · .env KAKAO_JS_KEY · 없으면 null
GET /api/map/address {address, region, source} — VWorld(B08 과 같은 길) · 지번 먼저 · 없음 null
"""
from __future__ import annotations
@@ -8,14 +9,25 @@ from __future__ import annotations
import os
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query
from B01_Dashboard.B01_Dashboard_Map import reverse_place
router = APIRouter(prefix="/api/map", tags=["map"])
KAKAO_JS = "KAKAO_JS_KEY"
ADDRESS_SOURCE = "VWorld"
@router.get("/kakao-key")
async def kakao_map_key() -> dict[str, Any]:
"""없으면 null = 화면 안내 한 줄."""
return {"key": os.environ.get(KAKAO_JS, "").strip() or None}
@router.get("/address")
async def map_address(
lon: float = Query(..., ge=120, le=135), lat: float = Query(..., ge=30, le=45)
) -> dict[str, Any]:
"""좌표 → 주소 · 「시군구 읍면」(사업 지역 채움) · 출처."""
return {**await reverse_place(lon, lat), "source": ADDRESS_SOURCE}
+8 -5
View File
@@ -47,12 +47,15 @@ async def create_kind_project(
road_type: str | None,
project_year: int | None,
project_year_end: int | None = None,
design_speed_kph: int | None = None,
site: tuple[float, float, str | None] | None = None,
route_start_m: float | None = None,
route_end_m: float | None = None,
source_project_id: str | None = None,
) -> str:
"""projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id."""
"""projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id.
site = 예정노선 기준점(경도, 위도, 주소 · 591-1) · 설계속도 칸은 비워 둠(B05 기본).
"""
project_id = str(uuid4())
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
now = datetime.utcnow()
@@ -64,11 +67,11 @@ async def create_kind_project(
"""
INSERT INTO projects (
id, user_id, company_id, name, region, road_type, project_kind,
project_year, project_year_end, design_speed_kph,
project_year, project_year_end, site_lon, site_lat, site_address,
route_start_m, route_end_m, status, crs_epsg, storage_path,
source_project_id, source_copied_at, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s,
%s, %s, %s, %s)
""",
(
@@ -81,7 +84,7 @@ async def create_kind_project(
kind,
project_year,
project_year_end,
design_speed_kph,
*(site or (None, None, None)),
route_start_m,
route_end_m,
storage_path,
+14
View File
@@ -0,0 +1,14 @@
-- 024_plan_site.sql
-- 예정노선 등록 기준점 하나 + 주소 · 설계속도 비움 (요구 452 · 453 · PLAN 591-1)
--
-- 추가만 함(칸 셋) · 옛 PLAN 기준점 = 지점 가운데 → 이 파일 뒤 `024_plan_site_fill.py` 한 번
-- 설계속도 칸은 남김(옛 코드 대시보드가 SELECT) · 예정노선 값만 비움 = B 로 넘길 때 B05 기본 20
USE aislo_db;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS site_lon DOUBLE NULL COMMENT '예정노선 기준점 경도(D03 처음 자료 받는 기준)',
ADD COLUMN IF NOT EXISTS site_lat DOUBLE NULL COMMENT '예정노선 기준점 위도',
ADD COLUMN IF NOT EXISTS site_address VARCHAR(255) NULL COMMENT '기준점 주소(VWorld)';
UPDATE projects SET design_speed_kph = NULL WHERE project_kind = 'PLAN';
+74
View File
@@ -0,0 +1,74 @@
"""옛 예정노선 기준점 채움 — 지점 상자 가운데 · 주소 VWorld (PLAN 591-1 · SQL 024 뒤 한 번).
기준점 칸이 빈 PLAN 만 · 지점 = `plan_base_json`(새 꼴 points · 옛 start/end · starts/ends) ·
지점이 없으면 건너뜀(알림 한 줄) · 다시 돌려도 같음(채운 행은 안 건드림).
쓰는 법
./venv/Scripts/python.exe db_management/024_plan_site_fill.py
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from typing import Any
sys.path.append(str(Path(__file__).resolve().parent.parent))
from dotenv import load_dotenv # noqa: E402
load_dotenv()
import aiomysql # noqa: E402
from B01_Dashboard.B01_Dashboard_Map import reverse_place # noqa: E402
from common_util.common_util_crs import tm_to_lonlat # noqa: E402
from config.config_db import close_db_pool, init_db_pool # noqa: E402
def base_center(base: dict[str, Any] | None) -> tuple[float, float] | None:
"""지점 상자 가운데(TM m) — 꼴 셋을 모두 읽음 · 지점 없음 = None."""
base = base or {}
points = [
*base.get("points", []),
*base.get("starts", []),
*base.get("ends", []),
*(base[k] for k in ("start", "end") if base.get(k)),
]
xs = [float(p["x"]) for p in points if p]
ys = [float(p["y"]) for p in points if p]
if not xs:
return None
return (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
async def main() -> None:
pool = await init_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT id, name, plan_base_json FROM projects"
" WHERE project_kind = 'PLAN' AND site_lon IS NULL"
)
for row in await cursor.fetchall():
raw = row["plan_base_json"]
base = json.loads(raw) if isinstance(raw, str) else raw
center = base_center(base)
if not center or not (base or {}).get("epsg"):
print(f"건너뜀(지점 없음) {row['id']} {row['name']}")
continue
lon, lat = tm_to_lonlat(*center, int(base["epsg"]))
address = (await reverse_place(lon, lat))["address"]
await cursor.execute(
"UPDATE projects SET site_lon = %s, site_lat = %s, site_address = %s"
" WHERE id = %s AND site_lon IS NULL",
(lon, lat, address, row["id"]),
)
print(f"채움 {row['name']} {lon:.6f} {lat:.6f} {address}")
await connection.commit()
await close_db_pool()
if __name__ == "__main__":
asyncio.run(main())
+15 -8
View File
@@ -1,7 +1,7 @@
"""528-1 · 3 · 5 신규 프로젝트 그룹 공통(요구 345 · 347-8).
- 산림청 판정 — *.go.kr(대소문자 · 하위 도메인) · 시스템관리자 · 아닌 메일
- D01 생성 — 일반 사용자 403 · 산림청 = PLAN · 설계속도 기본 · 연도 끝 기본
- D01 생성 — 일반 사용자 403 · 산림청 = PLAN · 설계속도 없음 · 기준점(경위도 + 서버 주소) · 연도 끝 기본
- E01 생성 — 원본 B06 미완료 400 · 남의 것 404 · 완료 = STABILITY + 원본 칸
- 공유 판정 — 지정 회사 GET 200 · PUT 403 · 미지정 403 · 같은 회사 그대로
- 가입 — go.kr = 회사 「산림청」 · ACTIVE · 화면이 보낸 회사 값 무시 · 다른 메일 = NO_COMPANY
@@ -103,7 +103,13 @@ def test_forest_service():
def _plan(**kw):
body = {"project_name": "예정", "project_year": 2026, "region": "홍천", "road_type": "fire"}
body = {
"project_name": "예정",
"project_year": 2026,
"region": "홍천",
"road_type": "fire",
"site": {"lon": 128.6, "lat": 37.7},
}
return d01.CreatePlanRequest(**{**body, **kw})
@@ -120,17 +126,18 @@ def test_d01_plan_created(monkeypatch):
seen.update(kw)
return "new-id"
async def fake_place(lon, lat):
return {"address": f"주소 {lon} {lat}", "region": "평창군 진부면"}
monkeypatch.setattr(d01, "create_kind_project", fake_create)
monkeypatch.setattr(d01, "reverse_place", fake_place)
out = asyncio.run(d01.post_plan_project(None, _plan(start_chainage_m=0), FOREST))
assert out == {"project_id": "new-id"}
assert seen["kind"] == "PLAN" and seen["company_id"] == 4
assert seen["design_speed_kph"] == 20 and seen["project_year_end"] == 2026
asyncio.run(d01.post_plan_project(None, _plan(design_speed_kph=40), ADMIN))
assert seen["design_speed_kph"] == 40
asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN))
assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만
assert "design_speed_kph" not in seen and seen["project_year_end"] == 2026 # 591-1 지움
assert seen["site"] == (128.6, 37.7, "주소 128.6 37.7") # 주소 = 서버가 다시 찾음
asyncio.run(d01.post_plan_project(None, _plan(road_type=None), ADMIN))
assert seen["road_type"] is None and seen["design_speed_kph"] == 20 # 560-1 임도 종류 안 받음
assert seen["road_type"] is None # 560-1 임도 종류 안 받음
with pytest.raises(HTTPException) as e:
asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST))
assert e.value.status_code == 400
+107
View File
@@ -0,0 +1,107 @@
"""591-1 예정노선 등록 기준점 · 주소 · 설계속도 지움(요구 452 · 453).
- 좌표 → {address, region} — 지번 먼저(산 번지) · region = 「시군구 (구) 읍면」 · 도로명만 = 행정동 · 실패 = 빈 값
- B08 한 줄 주소(도로명 먼저)는 그대로
- GET /api/map/address — 출처 VWorld
- 등록 몸 — 기준점 필수 · 경위도 범위 · 설계속도 칸 없음
- 옛 PLAN 기준점 = 지점 상자 가운데(꼴 셋) · 행 만들기 = 기준점 칸 셋 · 설계속도 칸 안 씀
- 화면 — 지도 · 기준점 필수 · 출처 표기 · 설계속도 글 없음
"""
import asyncio
import importlib.util
import json
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402
from common_util import common_util_map_router as map_router # noqa: E402
from D01_PlanRegister import D01_PlanRegister_Router as d01 # noqa: E402
def _fill_tool():
spec = importlib.util.spec_from_file_location(
"fill024", ROOT / "db_management" / "024_plan_site_fill.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
PARCEL = {"type": "parcel", "text": "경상북도 울진군 금강송면 광회리 산 47",
"structure": {"level1": "경상북도", "level2": "울진군", "level3": "",
"level4L": "금강송면", "level4A": ""}} # fmt: skip
ROAD = {"type": "road", "text": "경기도 수원시 장안구 정조로 1",
"structure": {"level2": "수원시", "level3": "장안구", "level4L": "정조로",
"level4A": "파장동"}} # fmt: skip
def _body(*items):
return json.dumps({"response": {"status": "OK", "result": list(items)}}).encode()
def test_좌표_주소_지번_먼저(monkeypatch):
monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: _body(ROAD, PARCEL))
assert vmap._place_sync(129.1, 36.95) == {
"address": "경상북도 울진군 금강송면 광회리 산 47",
"region": "울진군 금강송면",
}
assert vmap._reverse_sync(129.1, 36.95) == "경기도 수원시 장안구 정조로 1" # B08 그대로
monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: _body(ROAD))
assert vmap._place_sync(127.0, 37.3)["region"] == "수원시 장안구 파장동"
monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: b"not json")
assert vmap._place_sync(127.0, 37.3) == {"address": None, "region": None}
def test_주소_길_출처(monkeypatch):
async def place(lon, lat):
return {"address": "주소", "region": "울진군 금강송면"}
monkeypatch.setattr(map_router, "reverse_place", place)
out = asyncio.run(map_router.map_address(lon=129.1, lat=36.95))
assert out == {"address": "주소", "region": "울진군 금강송면", "source": "VWorld"}
def test_등록_몸_기준점_필수():
base = {"project_name": "예정", "project_year": 2026, "region": "울진"}
with pytest.raises(ValidationError):
d01.CreatePlanRequest(**base)
with pytest.raises(ValidationError):
d01.CreatePlanRequest(**base, site={"lon": 200, "lat": 36})
ok = d01.CreatePlanRequest(**base, site={"lon": 129.1, "lat": 36.95})
assert "design_speed_kph" not in d01.CreatePlanRequest.model_fields
assert (ok.site.lon, ok.site.lat) == (129.1, 36.95)
def test_옛_기준점_지점_가운데():
center = _fill_tool().base_center
assert center({"epsg": 5186, "start": {"x": 0, "y": 10}, "end": {"x": 100, "y": 30}}) == (
50,
20,
)
pts = [{"x": 0, "y": 0}, {"x": 40, "y": 10}, {"x": 20, "y": 100}]
assert center({"points": pts}) == (20, 50)
assert center({"starts": [{"x": 0, "y": 0}], "ends": [{"x": 10, "y": 10}]}) == (5, 5)
assert center(None) is None and center({"points": []}) is None
def test_행_만들기_기준점_칸():
src = (ROOT / "common_util" / "common_util_project_kind.py").read_text(encoding="utf-8")
assert "site_lon, site_lat, site_address" in src and "design_speed_kph" not in src
sql = (ROOT / "db_management" / "024_plan_site.sql").read_text(encoding="utf-8")
assert "site_lon DOUBLE" in sql and "site_address VARCHAR(255)" in sql
assert "SET design_speed_kph = NULL WHERE project_kind = 'PLAN'" in sql
def test_화면_지도_출처():
page = (ROOT / "D01_PlanRegister" / "D01_PlanRegister_UI_Page.ts").read_text(encoding="utf-8")
assert "editMap(" in page and "/map/address" in page and "DE_Reg_SiteSource" in page
assert "site: { lon: at[0], lat: at[1] }" in page and "DE_Reg_SiteRequired" in page
assert "DesignSpeed" not in page
+17 -1
View File
@@ -89,7 +89,23 @@ export const ui_locales_de = {
],
DE_Reg_Kind: ["종류", "Kind"],
DE_Reg_KindPlan: ["노선 계획", "Route plan"],
DE_Reg_DesignSpeed: ["설계속도", "Design speed"],
DE_Reg_Site: ["기준점", "Reference point"],
DE_Reg_SiteHint: [
"지도를 눌러 기준점 하나를 찍습니다 — 노선분석이 처음 자료를 받는 기준(노선과 무관) · 표지를 끌어 옮깁니다.",
"Click the map to set one reference point — where route analysis first loads data (not part of the route). Drag the marker to move it.",
],
DE_Reg_SiteEmpty: [
"주소 — 지도를 눌러 기준점을 찍으세요.",
"Address — click the map to set the point.",
],
DE_Reg_SiteFinding: ["주소 — 찾는 중…", "Address — looking up…"],
DE_Reg_SiteAddress: ["주소 — ", "Address — "],
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
DE_Reg_SiteSource: ["출처", "Source"],
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."],
DE_Reg_MapType: ["바탕 지도", "Base map"],
DE_Reg_MapNormal: ["일반", "Map"],
DE_Reg_MapSatellite: ["위성", "Satellite"],
DE_Reg_Name: ["프로젝트명", "Project name"],
DE_Reg_StartM: ["노선 시작 측점 (누가거리 m 입력)", "Route start station (enter chainage m)"],
DE_Reg_EndM: ["노선 종료 측점 (누가거리 m 입력)", "Route end station (enter chainage m)"],