From 3d7f6d0b70e13e0ddea71277181edf2415904da5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 09:41:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(D01):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=93=B1=EB=A1=9D=20=E2=80=94=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EC=A7=80=EB=8F=84=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=A0=90=20=ED=95=98=EB=82=98=20+=20=EC=A3=BC=EC=86=8C(VWorld?= =?UTF-8?q?=20=C2=B7=20=EC=B6=9C=EC=B2=98=20=ED=91=9C=EA=B8=B0)=20=C2=B7?= =?UTF-8?q?=20=EC=82=AC=EC=97=85=20=EC=A7=80=EC=97=AD=20=EC=B1=84=EC=9B=80?= =?UTF-8?q?=20(PLAN=20591-1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20452=20=C2=B7?= =?UTF-8?q?=20453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 화면 = 공용 카카오 지도(마우스 설정 따름 · 일반/위성) 누름 · 끌기로 기준점 하나 · 주소 글 + 출처 - 사업 지역 = 주소의 「시군구 읍면」 채움 · 손으로 고친 값은 안 덮음 - 서버 = 기준점 필수(경위도 범위) · 주소는 서버가 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 --- B01_Dashboard/B01_Dashboard_Map.py | 33 +++++- D01_PlanRegister/D01_PlanRegister_Router.py | 20 ++-- D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 103 +++++++++++++++++- common_util/common_util_map_router.py | 16 ++- common_util/common_util_project_kind.py | 13 ++- db_management/024_plan_site.sql | 14 +++ db_management/024_plan_site_fill.py | 74 +++++++++++++ resources/tester/test_528_project_groups.py | 23 ++-- resources/tester/test_591_1_plan_site.py | 107 +++++++++++++++++++ ui_template/ui_template_locale_de.ts | 18 +++- 10 files changed, 388 insertions(+), 33 deletions(-) create mode 100644 db_management/024_plan_site.sql create mode 100644 db_management/024_plan_site_fill.py create mode 100644 resources/tester/test_591_1_plan_site.py diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index 5897561a3..a692c3c6c 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -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) diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py index 031072192..edd62efdd 100644 --- a/D01_PlanRegister/D01_PlanRegister_Router.py +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -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, ) diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 06ddb0822..63e1e2f0d 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -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 { + 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) : 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); } diff --git a/common_util/common_util_map_router.py b/common_util/common_util_map_router.py index 96c51ea66..76ef2b420 100644 --- a/common_util/common_util_map_router.py +++ b/common_util/common_util_map_router.py @@ -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} diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index e09facfbe..85cc4568c 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -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, diff --git a/db_management/024_plan_site.sql b/db_management/024_plan_site.sql new file mode 100644 index 000000000..99886553a --- /dev/null +++ b/db_management/024_plan_site.sql @@ -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'; diff --git a/db_management/024_plan_site_fill.py b/db_management/024_plan_site_fill.py new file mode 100644 index 000000000..bf8a9b777 --- /dev/null +++ b/db_management/024_plan_site_fill.py @@ -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()) diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index 907fe4cce..928e94d01 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -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 diff --git a/resources/tester/test_591_1_plan_site.py b/resources/tester/test_591_1_plan_site.py new file mode 100644 index 000000000..5fbcc8098 --- /dev/null +++ b/resources/tester/test_591_1_plan_site.py @@ -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 diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index deac576cf..e7bd4603e 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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)"],