Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1

This commit is contained in:
2026-10-27 20:12:54 +09:00
15 changed files with 287 additions and 266 deletions
+15 -3
View File
@@ -3,6 +3,8 @@
* D 예정노선 · E 안정성평가 단계 묶음(요구 345 · PLAN 529-1) — B05~B09 틀(`createWorkflowLayout`)을
* 단계 목록 · 아이콘 · 주소 인자로 그대로 씀. 빈 단계 = 틀 · 좌측 패널([?] [<] [>]) · 단계 막대만.
* 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움.
* 떠남(560-4) = `beforeLeave` 가 true 일 때만 — 단계 아이콘 · [<] [>] 는 기다린 뒤 이동 ·
* 머리 메뉴 · 뒤로 가기는 같은 함수를 `setLeaveGuard` 로(false = 머묾).
* ========================================================================== */
import {
@@ -15,7 +17,7 @@ import {
import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { navigateTo, type PageRenderer } from "./router";
import { navigateTo, setLeaveGuard, type PageRenderer } from "./router";
interface GroupFlow {
routes: readonly RoutePath[];
@@ -55,11 +57,18 @@ export function groupFlowOf(
export function mountGroupStep(
root: HTMLElement,
route: RoutePath,
parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement } = {},
parts: {
mainContent?: HTMLElement;
leftPanel?: HTMLElement;
/** 떠나기 전 — 저장 등 · false = 머묾 */
beforeLeave?: () => Promise<boolean>;
} = {},
): void {
const flow = FLOWS.find((f) => f.routes.includes(route));
if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`);
const active = flow.routes.indexOf(route);
const leave = parts.beforeLeave;
if (leave) setLeaveGuard(async () => !root.isConnected || (await leave()));
const layout = createWorkflowLayout({
title: t(flow.labels[active]),
steps: flow.labels.map((key) => t(key)),
@@ -69,7 +78,10 @@ export function mountGroupStep(
leftPanel: parts.leftPanel,
mainContent:
parts.mainContent ?? el("p", { className: "app-placeholder", text: t("DE_Step_Empty") }),
onStepClick: (_index, next) => navigateTo(next as RoutePath),
onStepClick: (_index, next) => {
if (!leave) return navigateTo(next as RoutePath);
void leave().then((ok) => ok && navigateTo(next as RoutePath));
},
});
root.append(layout.root);
}
+6 -2
View File
@@ -1,4 +1,8 @@
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2)."""
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).
화면은 이름 · 시작 연도 · 사업 지역만 보냄(요구서 389 3장 1 · 3) — 끝 연도 = 시작 · 임도 종류 없음
= 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로).
"""
from typing import Any
@@ -21,7 +25,7 @@ class CreatePlanRequest(BaseModel):
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 = Field(..., pattern="^(main|fire|work)$")
road_type: str | None = Field(default=None, pattern="^(main|fire|work)$")
design_speed_kph: int | None = None
start_chainage_m: float | None = Field(default=None, ge=0)
end_chainage_m: float | None = Field(default=None, ge=0)
+10 -89
View File
@@ -1,51 +1,25 @@
/* =============================================================================
* D01_PlanRegister_UI_Page.ts
* D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도
* (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름
* 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리.
* D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」)
* · 사업연도 끝 · 임도 종류 · 설계속도 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본).
* [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import {
createButton,
createCard,
createInputField,
createSelectField,
} from "@ui/ui_template_elements";
import { createButton, createCard, createInputField } from "@ui/ui_template_elements";
import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { createText } from "@ui/ui_template_text";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria";
/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */
const GRADE_CLASS: Record<string, string> = { main: "trunk", fire: "fire", work: "work" };
/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */
const STATION_INTERVAL_M = 20;
const numberOrNull = (text: string): number | null =>
text.trim() === "" ? null : Number.parseFloat(text);
export function renderD01PlanRegister(root: HTMLElement): void {
const year = new Date().getFullYear();
const yearStart = createInputField({
label: t("DE_Reg_YearStart"),
type: "number",
value: String(year),
min: 2000,
max: 2100,
});
const yearEnd = createInputField({
label: t("DE_Reg_YearEnd"),
type: "number",
value: String(year),
value: String(new Date().getFullYear()),
min: 2000,
max: 2100,
});
@@ -54,75 +28,29 @@ export function renderD01PlanRegister(root: HTMLElement): void {
placeholder: t("B02_Proj_Field_Region_Placeholder"),
required: true,
});
const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") });
kind.input.readOnly = true;
const roadType = createSelectField({
label: t("B02_Proj_Field_RoadType"),
options: [
{ value: "main", text: t("B02_Proj_RoadType_Main") },
{ value: "fire", text: t("B02_Proj_RoadType_Fire") },
{ value: "work", text: t("B02_Proj_RoadType_Work") },
],
});
const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] });
const name = createInputField({ label: t("DE_Reg_Name"), required: true });
const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 });
const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 });
// 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로)
for (const field of [startM, endM]) {
const shown = createText("", "muted");
shown.dataset.station = "";
field.root.insertBefore(shown, field.root.lastElementChild);
field.input.addEventListener("input", () => {
const value = numberOrNull(field.input.value);
shown.textContent =
value !== null && Number.isFinite(value) && value >= 0
? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}`
: "";
});
}
// 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20)
const syncSpeeds = (): void => {
const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20];
speed.setOptions(
choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })),
String(Math.min(...choices)),
);
};
// 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움)
const composer = createNameComposer();
const years = (): string => {
const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()];
return b && b !== a ? `${a}~${b}` : a;
};
const syncName = (): void => {
name.input.value = composer.update({
year: years(),
year: yearStart.input.value.trim(),
region: region.input.value,
type: roadType.select.selectedOptions[0]?.textContent ?? "",
type: t("DE_Reg_KindPlan"),
});
};
name.input.addEventListener("input", () => composer.edit(name.input.value));
for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName);
roadType.select.addEventListener("change", () => (syncSpeeds(), syncName()));
syncSpeeds();
for (const field of [yearStart, region]) field.input.addEventListener("input", syncName);
syncName();
const submit = (): void => {
const fields = [yearStart, yearEnd, region, name, startM, endM];
const fields = [yearStart, region, name];
fields.forEach((field) => field.setError());
const y0 = Number.parseInt(yearStart.input.value, 10);
const y1 = Number.parseInt(yearEnd.input.value, 10);
const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)];
const errors: Array<[(typeof fields)[number], string | false]> = [
[yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")],
[yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")],
[region, !region.input.value.trim() && t("B02_Proj_Error_Required")],
[name, !name.input.value.trim() && t("B02_Proj_Error_Required")],
[startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")],
[endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")],
];
const bad = errors.filter(([, message]) => message);
bad.forEach(([field, message]) => field.setError(message as string));
@@ -132,20 +60,13 @@ export function renderD01PlanRegister(root: HTMLElement): void {
{
project_name: name.input.value.trim(),
project_year: y0,
project_year_end: y1,
region: region.input.value.trim(),
road_type: roadType.select.value,
design_speed_kph: Number(speed.select.value),
start_chainage_m: s,
end_chainage_m: e,
},
ROUTES.D02_PLAN_BASE,
);
};
const grid = createFormGrid(
[yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root),
);
const grid = createFormGrid([yearStart, region, name].map((f) => f.root));
const actions = document.createElement("div");
actions.className = "ui-general-block__actions ui-general-block__actions--end";
actions.append(
+62 -42
View File
@@ -1,16 +1,17 @@
"""D 예정노선 1단계 기준 지정 — 연도별 시점 · 종점 정본 API (요구 345 · 요구서 3-2 · PLAN 529-3).
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2).
- `GET /api/projects/{id}/plan/points` — 사업연도 연도마다 시점 · 종점(경위도 + TM) · 빈 = null
- `PUT …/plan/points` — 경위도를 받아 서버가 TM 을 셈 → `plan_route_years` 행
- `GET /api/projects/{id}/plan/points` — {epsg, start, end} · 점 = {lon, lat, x, y} · 빈 = null
- `PUT …/plan/points` — {start: {lon, lat}, end: {lon, lat}} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "start": {"x", "y"}, "end": {"x", "y"}}` · 좌표계 = 두 점 평균 경도의 TM 띠)
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
규칙: 직전 연도 종점 = 다음 연도 시점(서버가 맞춤 · 보낸 시점은 첫 연도만 씀) ·
범위 밖 · 빈 연도 거절 · 좌표계 = 모든 지점 평균 경도의 TM 띠 한 벌 ·
지점이 바뀐 연도는 분석 노선(route_json · fingerprint · confirmed_at) 비움 ·
고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
연도 잇기 꼴(`chain_years` · `with_tm` · `changed` · `_store`)은 D03 [저장]이 씀.
"""
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
@@ -38,8 +39,9 @@ class YearPoints(BaseModel):
end: LonLat | None = None
class PointsRequest(BaseModel):
years: list[YearPoints]
class BaseRequest(BaseModel):
start: LonLat | None = None
end: LonLat | None = None
def chain_years(year_start: int, year_end: int, items: list[YearPoints]) -> list[dict[str, Any]]:
@@ -94,7 +96,7 @@ async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, A
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT project_kind, project_year, project_year_end FROM projects"
"SELECT project_kind, project_year, project_year_end, plan_base_json FROM projects"
" WHERE id = %s AND deleted_at IS NULL",
(str(project_id),),
)
@@ -157,50 +159,68 @@ def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)}
def _view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
years = []
for year in range(start, end + 1):
row = rows.get(year) or {}
years.append(
{
"year": year,
"start": _point(row.get("start_x"), row.get("start_y"), row.get("epsg")),
"end": _point(row.get("end_x"), row.get("end_y"), row.get("epsg")),
}
)
epsgs = {int(r["epsg"]) for r in rows.values() if r.get("epsg") is not None}
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""{epsg, start, end} — 정본 칸 · 비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
stored = project.get("plan_base_json")
if isinstance(stored, str):
stored = json.loads(stored)
if stored:
epsg, start, end = stored.get("epsg"), stored.get("start"), stored.get("end")
elif rows:
first, last = rows[min(rows)], rows[max(rows)]
epsg = first.get("epsg")
start = {"x": first.get("start_x"), "y": first.get("start_y")}
end = {"x": last.get("end_x"), "y": last.get("end_y")}
else:
epsg, start, end = None, None, None
return {
"status": "success",
"year_start": start,
"year_end": end,
"epsg": epsgs.pop() if len(epsgs) == 1 else None,
"years": years,
"epsg": epsg,
"start": _point(start["x"], start["y"], epsg) if start else None,
"end": _point(end["x"], end["y"], epsg) if end else None,
}
async def load_base(project_id: UUID) -> dict[str, Any]:
"""전체 시점 · 종점 — D03 이 읽는 길."""
project, rows = await _load(project_id)
return base_view(project, rows)
def stored_base(payload: BaseRequest) -> dict[str, Any]:
"""PUT 몸 → 정본 칸 꼴(TM m)."""
given = [p for p in (payload.start, payload.end) if p]
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given)) if given else None
def tm(p: LonLat | None) -> dict[str, float] | None:
if p is None or epsg is None:
return None
x, y = lonlat_to_tm(p.lon, p.lat, epsg)
return {"x": x, "y": y}
return {"epsg": epsg, "start": tm(payload.start), "end": tm(payload.end)}
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None:
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET plan_base_json = %s WHERE id = %s",
(json.dumps(base), str(project_id)),
)
@router.get("/{project_id}/plan/points", response_model=None)
async def get_plan_points(project_id: UUID) -> dict[str, Any]:
project, rows = await _load(project_id)
return _view(project, rows)
return {"status": "success", **await load_base(project_id)}
@router.put("/{project_id}/plan/points", response_model=None)
async def put_plan_points(
project_id: UUID,
payload: PointsRequest,
payload: BaseRequest,
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
if not is_forest_service(session):
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
project, old = await _load(project_id)
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
epsg, rows = with_tm(chain_years(start, end, payload.years))
writes = [row for row in rows if changed(row, old.get(row["year"]), epsg)]
drops = [year for year in old if not start <= year <= end]
if writes or drops:
await _store(project_id, epsg, writes, drops)
_, now = await _load(project_id)
return {**_view(project, now), "changed_years": [row["year"] for row in writes]}
await _load(project_id) # 없음 · 예정노선 아님 거절
await _store_base(project_id, stored_base(payload))
return {"status": "success", **await load_base(project_id)}
+60 -58
View File
@@ -1,10 +1,12 @@
/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표.
* 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로.
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장).
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03.
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 저장 뒤 · 실패면 머묾(560-4)
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥.
* 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음).
* 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억.
* ========================================================================== */
import "./D02_PlanBase_UI_Style.css";
@@ -19,11 +21,12 @@ import {
} from "@ui/ui_template_elements";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createText } from "@ui/ui_template_text";
import { editMap, type MapPin } from "@ui/ui_template_kakao_map";
import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map";
import { createSegment } from "@ui/ui_template_segment";
import { t } from "@ui/ui_template_locale";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { navigateTo } from "../A00_Common/router";
interface LonLat {
lon: number;
@@ -34,15 +37,14 @@ interface ServerPoint extends LonLat {
y: number;
}
interface PointsView {
year_start: number;
year_end: number;
epsg: number | null;
years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>;
start: ServerPoint | null;
end: ServerPoint | null;
}
const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"];
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
const PREF_MAP_TYPE = "aislo:pref:d02.map.type";
const cacheKey = (projectId: string): string => `d02_points_${projectId}`;
@@ -58,10 +60,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return data as T;
}
/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */
/** 서버 두 점 → [시점, 종점] */
function boundaryPoints(view: PointsView): Array<LonLat | null> {
const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null);
return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))];
return [pick(view.start), pick(view.end)];
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
@@ -81,26 +83,26 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
});
const actions = el("div", { className: "ui-sidebar-actions", children: [save] });
const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] });
mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left });
mountGroupStep(root, ROUTES.D02_PLAN_BASE, {
mainContent: mapHost,
leftPanel: left,
beforeLeave: async () => !editable || !dirty() || store(),
});
let view: PointsView | null = null;
let points: Array<LonLat | null> = [];
let points: Array<LonLat | null> = [null, null];
let active: number | null = null; // 지도 누름이 놓을 지점
let editable = false; // 산림청만 고침
const years = (): number[] => (view ? view.years.map((y) => y.year) : []);
const pointName = (i: number): string => {
const ys = years();
if (i === 0) return `${ys[0]} ${t("D02_Start")}`;
if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`;
return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`;
};
const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End");
const firstEmpty = (): number | null => {
const i = points.findIndex((p) => !p);
return i < 0 ? null : i;
};
const serverPoints = (): Array<LonLat | null> => (view ? boundaryPoints(view) : [null, null]);
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ points }));
sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ base: serverPoints(), points }));
const map = editMap({
hint: t("D02_Map_Hint"),
@@ -112,7 +114,22 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
place(active, { lon, lat });
},
});
mapHost.append(map.root);
const mapType = (readByKey(PREF_MAP_TYPE) === "HYBRID" ? "HYBRID" : "ROADMAP") as MapType;
map.setType(mapType);
const typePick = createSegment({
options: [
{ value: "ROADMAP", label: t("D02_Map_Normal") },
{ value: "HYBRID", label: t("D02_Map_Satellite") },
],
value: mapType,
ariaLabel: t("D02_Map_Type"),
onChange: (value) => {
map.setType(value as MapType);
writeByKey(PREF_MAP_TYPE, value);
},
});
typePick.root.classList.add("d02-map__type");
mapHost.append(map.root, typePick.root);
function place(index: number, at: LonLat): void {
points[index] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
@@ -126,19 +143,17 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const pins: MapPin[] = points.flatMap((p, i) =>
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [],
);
const lines: Array<Array<[number, number]>> = [];
const colors: string[] = [];
years().forEach((_, i) => {
const a = points[i];
const b = points[i + 1];
if (!a || !b) return;
lines.push([
[a.lat, a.lon],
[b.lat, b.lon],
]);
colors.push(YEAR_COLORS[i % YEAR_COLORS.length]);
});
map.set(pins, lines, colors, focus);
const [a, b] = points;
const lines: Array<Array<[number, number]>> =
a && b
? [
[
[a.lat, a.lon],
[b.lat, b.lon],
],
]
: [];
map.set(pins, lines, undefined, focus);
}
function drawTable(): void {
@@ -186,17 +201,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
lat,
],
});
const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length];
row.style.setProperty("--d02-year-color", color);
return row;
});
table.replaceChildren(...rows);
const saved = view?.years.find((y) => y.start);
const saved = view?.start;
info.textContent = view?.epsg
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
(saved?.start
? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}`
: "")
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "")
: t("D02_Not_Saved");
}
@@ -211,12 +222,9 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
}
async function store(): Promise<boolean> {
if (!view) return false;
showLoadingOverlay();
try {
const body = {
years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })),
};
const body = { start: points[0], end: points[1] };
adopt(
await request<PointsView>(`/projects/${projectId}/plan/points`, {
method: "PUT",
@@ -236,14 +244,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
}
}
// [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾
left.addEventListener("ui-step-nav", (event) => {
if ((event as CustomEvent<number>).detail !== 1) return;
event.stopPropagation();
if (!editable) return navigateTo(ROUTES.D03_PLAN_ANALYSIS);
void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS));
});
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
save.hidden = !editable;
@@ -254,12 +254,14 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
}
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
base?: Array<LonLat | null>;
points?: Array<LonLat | null>;
} | null;
if (editable && cached?.points?.length === points.length) points = cached.points;
active = firstEmpty();
paint();
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
if (editable && sameBase && cached?.points?.length === 2) points = cached.points;
} catch (error) {
table.replaceChildren(createText(error instanceof Error ? error.message : "", "muted"));
showToast(error instanceof Error ? error.message : String(error), "error");
}
active = firstEmpty();
paint();
}
+11 -2
View File
@@ -1,8 +1,17 @@
/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */
/* D02 기준 지정(529-3 · 560-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 · 종점 표 */
.d02-map {
position: relative;
height: calc(100dvh - var(--app-header-h));
}
/* 560-3 바탕 [일반][위성] — 지도 위 오른쪽 위 */
.d02-map__type {
position: absolute;
top: var(--spacing-8);
right: var(--spacing-8);
z-index: 2;
}
.d02-map .ui-kakao-map,
.d02-map .ui-kakao-map__box {
height: 100%;
@@ -20,7 +29,7 @@
gap: 4px;
align-items: center;
padding: 4px 6px;
border-left: 4px solid var(--d02-year-color);
border-left: 4px solid var(--draw-primary);
border-radius: 4px;
}
+4 -1
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import hashlib
import json
import re
import shutil
import tempfile
from pathlib import Path
@@ -250,10 +251,12 @@ async def import_route_files(
async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str:
"""예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수)."""
body = _parsed(assignee["route_json"]) or {}
# 「2026 홍천 노선 계획」 꼴 = 앞 연도를 그 연도로 갈아 끼움(연도 두 번 안 붙음) · 그 밖 = 뒤에 연도
name, dated = re.subn(r"^\d{4}(?:~\d{4})?(?=\s)", str(assignee["year"]), assignee["name"])
created = await create_project(
user_id=int(session["user_id"]),
company_id=int(session["company_id"]),
name=f"{assignee['name']} {assignee['year']}",
name=name if dated else f"{assignee['name']} {assignee['year']}",
region=assignee["region"] or "",
road_type=assignee["road_type"] or "work",
project_year=int(assignee["year"]),
+7
View File
@@ -0,0 +1,7 @@
-- 021_plan_base_json.sql
-- 예정노선 전체 시점 · 종점 두 점 (요구서 389 3장 1 · PLAN 560-2)
-- {"epsg": int, "start": {"x", "y"}, "end": {"x", "y"}} · TM m · 비면 옛 연도 행을 접어 보임(D02 서버)
USE aislo_db;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS plan_base_json JSON NULL COMMENT '예정노선 전체 시점 · 종점(D02)';
@@ -124,6 +124,8 @@ def test_d01_plan_created(monkeypatch):
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 만
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 임도 종류 안 받음
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
+47 -59
View File
@@ -1,10 +1,13 @@
"""D02 기준 지정(PLAN 529-3) — 경위도 ↔ TM 변환 · 지점 정본 API(잇기 · 빈 연도 · 권한).
"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기
· 연도 잇기(D03 [저장]이 씀).
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
"""
import json
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_company
@@ -59,21 +62,16 @@ def api(monkeypatch):
"project": {"project_kind": "PLAN", "project_year": 2026, "project_year_end": 2027},
"rows": {},
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
"stores": [],
}
async def load(project_id):
return state["project"], {y: dict(r) for y, r in state["rows"].items()}
async def store(project_id, epsg, writes, drops):
state["stores"].append((epsg, writes, drops))
for row in writes:
state["rows"][row["year"]] = {**row, "epsg": epsg}
for year in drops:
state["rows"].pop(year)
async def store_base(project_id, base):
state["project"] = {**state["project"], "plan_base_json": json.dumps(base)}
monkeypatch.setattr(router_mod, "_load", load)
monkeypatch.setattr(router_mod, "_store", store)
monkeypatch.setattr(router_mod, "_store_base", store_base)
app = FastAPI()
app.include_router(router_mod.router)
app.dependency_overrides[require_company] = lambda: state["session"]
@@ -82,68 +80,58 @@ def api(monkeypatch):
return client
def test_put_chains_years(api) -> None:
# 2027 시점을 엉뚱하게 보내도 서버가 2026 종점으로 맞춤
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": A, "end": C}]}
got = api.put(URL, json=body)
def test_put_two_points(api) -> None:
got = api.put(URL, json={"start": A, "end": C})
assert got.status_code == 200
data = got.json()
assert data["epsg"] == 5186 and data["changed_years"] == [2026, 2027]
y26, y27 = data["years"]
assert y27["start"]["x"] == y26["end"]["x"] and y27["start"]["y"] == y26["end"]["y"]
assert abs(y27["start"]["lon"] - B["lon"]) < 1e-8 and abs(y27["start"]["lat"] - B["lat"]) < 1e-8
assert (y26["start"]["x"], y26["start"]["y"]) == pytest.approx(
assert data["epsg"] == 5186
assert (data["start"]["x"], data["start"]["y"]) == pytest.approx(
lonlat_to_tm(127.10, 37.50, 5186)
)
assert abs(data["end"]["lon"] - C["lon"]) < 1e-8 and abs(data["end"]["lat"] - C["lat"]) < 1e-8
stored = json.loads(api.state["project"]["plan_base_json"])
assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"}
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
def test_put_rejects_empty_and_out_of_range(api) -> None:
only_first = {"years": [{"year": 2026, "start": A, "end": B}]}
got = api.put(URL, json=only_first)
assert got.status_code == 400 and "2027년 종점" in got.json()["detail"]
no_start = {"years": [{"year": 2026, "end": B}, {"year": 2027, "end": C}]}
assert "2026년 시점" in api.put(URL, json=no_start).json()["detail"]
outside = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2028, "end": C}]}
assert api.put(URL, json=outside).status_code == 400
assert api.state["stores"] == []
def test_put_one_point_and_empty(api) -> None:
data = api.put(URL, json={"start": A}).json()
assert data["start"] and data["end"] is None and data["epsg"] == 5186
data = api.put(URL, json={}).json()
assert data == {"status": "success", "epsg": None, "start": None, "end": None}
def test_put_forest_only(api) -> None:
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
assert api.put(URL, json=body).status_code == 403
assert api.put(URL, json={"start": A, "end": B}).status_code == 403
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
assert api.put(URL, json=body).status_code == 200
assert api.put(URL, json={"start": A, "end": B}).status_code == 200
def test_put_clears_only_changed_years(api) -> None:
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.put(URL, json=body)
# 같은 지점 다시 저장 = 쓰기 없음(분석 노선 그대로) · 2027 종점만 옮기면 2027 만
assert api.put(URL, json=body).json()["changed_years"] == []
moved = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": A}]}
assert api.put(URL, json=moved).json()["changed_years"] == [2027]
assert len(api.state["stores"]) == 2
def test_get_lists_every_year_and_drops_outside(api) -> None:
api.state["rows"][2030] = {
"year": 2030,
"start_x": 1,
"start_y": 1,
"end_x": 2,
"end_y": 2,
"epsg": 5186,
}
def test_get_folds_old_year_rows(api) -> None:
epsg = 5186
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], epsg) for p in (A, B, C))
for year, s, e in ((2026, a, b), (2027, b, c)):
api.state["rows"][year] = {
"year": year,
**{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]},
"epsg": epsg,
}
data = api.get(URL).json()
assert [y["year"] for y in data["years"]] == [2026, 2027]
assert data["years"][0]["start"] is None
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.put(URL, json=body)
assert api.state["stores"][-1][2] == [2030] and 2030 not in api.state["rows"]
assert data["epsg"] == epsg
assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0]))
def test_not_plan_project(api) -> None:
api.state["project"] = {"project_kind": "ROAD", "project_year": 2026, "project_year_end": None}
# _load 대역이 그대로 돌려주므로 진짜 _load 의 거절은 서버 시험(ORCA)에서
assert api.get(URL).status_code == 200
def test_get_empty_new_project(api) -> None:
assert api.get(URL).json() == {"status": "success", "epsg": None, "start": None, "end": None}
def test_chain_years_for_d03() -> None:
items = [
router_mod.YearPoints(year=2026, start=A, end=B),
router_mod.YearPoints(year=2027, start=A, end=C),
]
rows = router_mod.chain_years(2026, 2027, items)
assert rows[1]["start"].lon == B["lon"] # 시점 = 직전 종점
with pytest.raises(HTTPException):
router_mod.chain_years(2026, 2027, items[:1])
+14
View File
@@ -264,6 +264,20 @@ def test_new_mode_creates_road_project(env, monkeypatch):
assert made["company_id"] == 5 and len(env["rows"]) == 5
def test_new_mode_name_year_once(env, monkeypatch):
"""560-1 — 「<시작 연도> <지역> 노선 계획」 이름 = 앞 연도를 그 연도로(두 번 안 붙음)."""
made = {}
async def create_project(**kw):
made.update(kw)
return {"project_id": ROAD_ID}
monkeypatch.setattr(link, "create_project", create_project)
_use(monkeypatch, _assignee(name="2025 홍천 노선 계획"))
_link(mode="new", project_id=None)
assert made["name"] == "2026 홍천 노선 계획"
def _stage2_writes(log):
return [
args
-7
View File
@@ -16,13 +16,6 @@ def _no_hex(path: str) -> None:
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색"
def test_d01_station_label_is_text_part():
ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
assert "style.cssText" not in ts
assert 'createText("", "muted")' in ts
assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음
def test_d02_parts():
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css")
+27
View File
@@ -0,0 +1,27 @@
"""560-4 D 단계 이동 = 저장 뒤 이동 지킴 시험 — 서버 · 화면 없이 소스만 본다.
단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _src(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_group_step_waits_before_leave():
ts = _src("A00_Common/group_workflow.ts")
assert "beforeLeave?: () => Promise<boolean>" in ts
assert "setLeaveGuard(" in ts # 머리 메뉴 · 뒤로
assert "leave().then((ok) => ok && navigateTo(" in ts # 단계 아이콘 · [<] [>]
def test_d02_leave_saves_and_builds_map_on_fail():
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
assert "beforeLeave:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음
tail = ts[ts.rindex(" try {") :]
# 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움
assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail)
assert "sameBase" in ts # 옛 캐시는 그때 서버 값이 지금과 같을 때만
+16
View File
@@ -6,6 +6,7 @@
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
* 길 선 = 부르는 쪽이 준 [위도, 경도] 목록 · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로)
* (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
* (560-3) 바탕 종류 = `setType("ROADMAP" | "HYBRID")`(위성 + 지명) · 단추는 부르는 쪽 부품
* ========================================================================== */
import "./ui_template_kakao_map.css";
@@ -29,6 +30,7 @@ interface KMap {
relayout(): void;
getBounds(): { contain(p: LatLng): boolean };
panTo(p: LatLng): void;
setMapTypeId(id: number): void;
}
interface KOverlay {
setMap(map: KMap | null): void;
@@ -52,8 +54,12 @@ interface KakaoMaps {
addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void;
};
CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay;
MapTypeId: Record<MapType, number>;
}
/** 바탕 지도 종류 — 일반 · 위성 + 지명 */
export type MapType = "ROADMAP" | "HYBRID";
let sdk: Promise<KakaoMaps> | null = null;
/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */
@@ -111,6 +117,8 @@ export interface EditMap {
colors?: string[],
focus?: string,
) => void;
/** 바탕 종류(지도가 아직 없으면 만들 때 씀) */
setType: (type: MapType) => void;
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
@@ -126,6 +134,8 @@ export function editMap(opts: {
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로
let type: MapType = "ROADMAP";
let mapTypes: KakaoMaps["MapTypeId"] | null = null;
const set = (
pins: MapPin[],
@@ -147,6 +157,8 @@ export function editMap(opts: {
box.hidden = false;
if (!map) {
map = new maps.Map(box, { center: first, level: 7 });
mapTypes = maps.MapTypeId;
map.setMapTypeId(mapTypes[type]);
if (opts.onClick)
maps.event.addListener(map, "click", (e) =>
opts.onClick!(e.latLng.getLng(), e.latLng.getLat()),
@@ -222,5 +234,9 @@ export function editMap(opts: {
return {
root: el("div", { className: "ui-kakao-map", children: [hint, box] }),
set,
setType: (next) => {
type = next;
if (map && mapTypes) map.setMapTypeId(mapTypes[next]);
},
};
}
+6 -3
View File
@@ -118,10 +118,10 @@ export const ui_locales_de = {
DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."],
/* --- D02 기준 지정(529-3) --- */
D02_Points_Title: ["연도 지점", "Yearly Points"],
D02_Points_Title: ["전체 시점 · 종점", "Overall Start · End"],
D02_Points_Hint: [
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 직전 종점 = 다음 시점.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Each end is the next start.",
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.",
],
D02_Start: ["시점", "start"],
D02_End: ["종점", "end"],
@@ -129,6 +129,9 @@ export const ui_locales_de = {
D02_Lat: ["위도", "Lat"],
D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"],
D02_Map_Hint: ["지도를 눌러 지점을 놓습니다.", "Click the map to place a point."],
D02_Map_Type: ["바탕 지도", "Base map"],
D02_Map_Normal: ["일반", "Map"],
D02_Map_Satellite: ["위성", "Satellite"],
D02_View_Only: [
"보기 전용 — 지점은 산림청만 고칩니다.",
"View only — only the Forest Service edits points.",