feat(448 B05 페이지 설정): [저장] · [확정] 영구저장 — 세션 초안 · settings 차례 · 경고 기준 갈아 끼움
- 세션 칸 pagesettings(설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점) · 열 때 최신 응답 위에 얹음 - 저장 차례 settings = 노선 뒤 · 관 앞(기본 창구 · 노선 바꾼 [저장]에도 돎) - PUT /route/page-settings — stage 2 params 에 사용자 값만 병합(상태 · STALE 그대로) · 종단 정책 · criteria 갱신 뒤 위반 · 요약 다시 셈(기하 그대로) - 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(끝점 ▲▼ 와 같은 길) · 샘플 간격 두 칸 읽기 전용 - E1 재설계 요청이 설계속도 · 최소고 강제 · 시작 측점 옮김 · E2 B06 재생성 지형 기본 특수 · E3 초기값 스냅샷 stage 2 params 뜸 · 되돌림 - 노선 추천 · 평면 R 설정 읽기가 params.options 를 봄(옛 맨 위 칸 폴백) - 시험 test_442_2_page_settings · test_442_2_grade_limit_mirror · design_store_save 차례 갱신 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,6 +209,8 @@ export const STATE_REGISTRY = {
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */
|
||||
"alignment-draft": { bucket: "draft", scope: "route" },
|
||||
/** B05 페이지 설정(448) — 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점. [저장]의 `settings` 차례가 내보냄. */
|
||||
pagesettings: { bucket: "draft", scope: "project" },
|
||||
/** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`).
|
||||
* 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */
|
||||
"route-draft": { bucket: "draft", scope: "project" },
|
||||
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
toAlignmentBase,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
endOffsetsOf,
|
||||
withEndOffset,
|
||||
type ProfileEnd,
|
||||
} from "../../B05_Profile/logic/B05_Profile_EndOffsets";
|
||||
import {
|
||||
structureGroupMenuItems,
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
@@ -607,6 +612,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** 시 · 종점 계획고 조정(448) — 첫 · 끝 측점 계획고 편집(m) · 기준이 없으면 null. */
|
||||
endOffsets: () => (base ? endOffsetsOf(base, store.edits()) : null),
|
||||
/** 시 · 종점 계획고 조정 칸 → 그 측점 편집(끝점 ▲▼ 와 같은 길 · 가드 거침). false = 막힘. */
|
||||
setEndOffset: (end: ProfileEnd, value: number): boolean =>
|
||||
!!base && applyEdits(withEndOffset(base, store.edits(), end, value)),
|
||||
/** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */
|
||||
pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }),
|
||||
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
* 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
|
||||
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
pendingAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings";
|
||||
import { clearState, stateKey } from "../b_page_state";
|
||||
import {
|
||||
carryAfterRouteSave,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"route",
|
||||
"settings",
|
||||
"pipes",
|
||||
"structures",
|
||||
"plusStations",
|
||||
@@ -92,6 +94,7 @@ export function savingDesign(projectId: string | null): boolean {
|
||||
* 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
|
||||
const STEP_LABEL: Record<DesignSaveStep, string> = {
|
||||
route: "노선",
|
||||
settings: "페이지 설정",
|
||||
pipes: "배수관",
|
||||
structures: "구조물",
|
||||
plusStations: "추가 측점",
|
||||
@@ -148,6 +151,8 @@ function defaultStep(
|
||||
fromRouteId: number = routeId,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448)
|
||||
return () => flushPageSettings(projectId);
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
case "structures":
|
||||
|
||||
@@ -416,6 +416,10 @@ def redesign_solve_request(
|
||||
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
|
||||
paved=bool(options.get("paved", False)),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
# 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1)
|
||||
design_speed_kph=options.get("design_speed_kph"),
|
||||
enforce_pipe_clearance=params.get("enforce_pipe_clearance"),
|
||||
station_display=params.get("station_display"),
|
||||
main_direction=str(options.get("main_direction") or "auto"),
|
||||
min_curve_radius_m=options.get("min_curve_radius_m"),
|
||||
max_uphill_grade=options.get("max_uphill_grade"),
|
||||
|
||||
@@ -147,6 +147,8 @@ export interface RouteLatestResponse {
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
enforce_pipe_clearance?: boolean | null;
|
||||
/** 시작 측점 · 누가거리 표시(448) — B05 라벨만. */
|
||||
station_display?: { station: number; cumulative: number } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -244,6 +246,28 @@ export async function saveProfileAlignment(
|
||||
);
|
||||
}
|
||||
|
||||
/** B05 페이지 설정(448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값). */
|
||||
export interface PageSettingsBody {
|
||||
design_speed_kph: 20 | 30 | 40;
|
||||
terrain_type: "normal" | "special";
|
||||
max_grade_pct: number | null;
|
||||
min_vertical_radius_m: number | null;
|
||||
min_tangent_length_m: number | null;
|
||||
enforce_pipe_clearance: boolean;
|
||||
station_display: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/** 페이지 설정을 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). */
|
||||
export async function savePageSettings(
|
||||
projectId: string,
|
||||
body: PageSettingsBody,
|
||||
): Promise<{ warnings: string[]; profile_updated: boolean }> {
|
||||
return requestJson(`/projects/${projectId}/route/page-settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */
|
||||
export interface RouteConfirmRequest {
|
||||
filter_key?: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_PageSettings.ts
|
||||
* B05 「페이지 설정」 초안(448) — 세션 칸 `pagesettings` · [저장]의 `settings` 차례(노선 뒤 · 계획고 앞).
|
||||
*
|
||||
* 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점을 조작 즉시 세션에 쌓고 [저장] · [확정]에서만
|
||||
* 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로).
|
||||
* 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀.
|
||||
* 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
readRouteLatestCache,
|
||||
savePageSettings,
|
||||
writeRouteLatestCache,
|
||||
type PageSettingsBody,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
export type PageSettingsDraft = PageSettingsBody;
|
||||
|
||||
/** 좌측 폼 값 → 초안(사용자가 넣은 값 그대로 · 빈 칸 = null). */
|
||||
export const pageSettingsOf = (values: RoutePanelValues): PageSettingsDraft => ({
|
||||
design_speed_kph: values.designSpeed,
|
||||
terrain_type: values.terrainType,
|
||||
max_grade_pct: values.maxGradePct,
|
||||
min_vertical_radius_m: values.minVerticalRadius,
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
enforce_pipe_clearance: values.enforcePipeClearance,
|
||||
station_display: values.stationDisplay,
|
||||
});
|
||||
|
||||
/** 초안 → 좌측 폼 되살리기 값(최신 응답 위에 얹음). */
|
||||
export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial<RoutePanelValues> => ({
|
||||
designSpeed: draft.design_speed_kph,
|
||||
terrainType: draft.terrain_type,
|
||||
maxGradePct: draft.max_grade_pct,
|
||||
minVerticalRadius: draft.min_vertical_radius_m,
|
||||
minTangentLength: draft.min_tangent_length_m,
|
||||
enforcePipeClearance: draft.enforce_pipe_clearance,
|
||||
stationDisplay: draft.station_display,
|
||||
});
|
||||
|
||||
export const readPageSettings = (projectId: string): PageSettingsDraft | null =>
|
||||
readState<PageSettingsDraft>("pagesettings", projectId);
|
||||
|
||||
export const writePageSettings = (projectId: string, value: PageSettingsDraft): void =>
|
||||
writeState("pagesettings", value, projectId);
|
||||
|
||||
/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움
|
||||
* (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */
|
||||
export async function flushPageSettings(projectId: string): Promise<void> {
|
||||
const draft = readPageSettings(projectId);
|
||||
if (!draft) return;
|
||||
await savePageSettings(projectId, draft);
|
||||
const latest = readRouteLatestCache(projectId);
|
||||
if (latest?.route_params) {
|
||||
const { design_speed_kph, terrain_type, ...top } = draft;
|
||||
latest.route_params = {
|
||||
...latest.route_params,
|
||||
...top,
|
||||
options: { ...latest.route_params.options, design_speed_kph, terrain_type },
|
||||
};
|
||||
writeRouteLatestCache(projectId, latest);
|
||||
}
|
||||
clearState("pagesettings", projectId);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""B05 페이지 설정 저장(448) — stage 2 params 에 얹을 값 · 종단 기준 갈아 끼우기 · 옛 스냅샷 걷기.
|
||||
|
||||
설계속도 · 지형 · 기준값은 **경고 기준만** 바꾼다 — 기하(base_pvi · 편집)는 그대로 · 종횡단 다시
|
||||
만들기 없음. params 에는 사용자가 넣은 원값만 둔다(해석값 저장 금지 — `resolve_grade_options`).
|
||||
시작 측점은 B05 표시 전용 — 저장만 하고 측점 열쇠 · 다른 페이지 라벨에 쓰지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
GradeDesignOptions,
|
||||
legal_plan_radius_min_m,
|
||||
resolve_grade_options,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
#: params["options"] 안의 페이지 설정 칸
|
||||
PAGE_OPTION_KEYS = ("design_speed_kph", "terrain_type")
|
||||
#: params 맨 위의 페이지 설정 칸(빈 값 = 법정값)
|
||||
PAGE_TOP_KEYS = (
|
||||
"max_grade_pct",
|
||||
"min_vertical_radius_m",
|
||||
"min_tangent_length_m",
|
||||
"enforce_pipe_clearance",
|
||||
"station_display",
|
||||
)
|
||||
_STORED_KEYS = ("max_grade_pct", "min_vertical_radius_m", "min_tangent_length_m")
|
||||
|
||||
|
||||
def params_patch(request: Any) -> dict[str, Any]:
|
||||
"""요청 → stage 2 params 에 얹을 값(원값 그대로)."""
|
||||
return {
|
||||
"options": {key: getattr(request, key) for key in PAGE_OPTION_KEYS},
|
||||
**{key: getattr(request, key) for key in PAGE_TOP_KEYS},
|
||||
}
|
||||
|
||||
|
||||
def strip_page_settings(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""페이지 설정 칸만 걷어 기본값으로 — stage 2 params 를 안 뜬 옛 스냅샷의 [초기화]."""
|
||||
if not isinstance(params, dict):
|
||||
return params
|
||||
stripped = {key: value for key, value in params.items() if key not in PAGE_TOP_KEYS}
|
||||
options = params.get("options")
|
||||
if isinstance(options, dict):
|
||||
stripped["options"] = {k: v for k, v in options.items() if k not in PAGE_OPTION_KEYS}
|
||||
return stripped
|
||||
|
||||
|
||||
def grade_class_of(road_type: str | None, params: dict[str, Any]) -> str:
|
||||
"""기준 등급 — 노선 풀기가 남긴 값 · 없으면 B02 임도 종류(main → trunk · 그 밖 = work)."""
|
||||
stored = (params.get("options") or {}).get("grade_class")
|
||||
if stored:
|
||||
return str(stored)
|
||||
if road_type in ("main", "trunk"):
|
||||
return "trunk"
|
||||
return "fire" if road_type == "fire" else "work"
|
||||
|
||||
|
||||
def grade_options_of(road_type: str | None, params: dict[str, Any]) -> GradeDesignOptions:
|
||||
"""params(사용자 원값) → 해석된 기준(작업임도 30 → 20 · 빈 칸 = 법정값)."""
|
||||
options = params.get("options") or {}
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
grade_class_of(road_type, params),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
main_direction=str(options.get("main_direction") or "auto"),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored={key: params[key] for key in _STORED_KEYS if params.get(key) is not None}
|
||||
| {"enforce_pipe_clearance": params.get("enforce_pipe_clearance")},
|
||||
)
|
||||
|
||||
|
||||
def retarget_longitudinal(
|
||||
longitudinal: dict[str, Any], options: GradeDesignOptions
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""종단 정본의 정책 · 기준을 새 기준으로 — 저장 편집으로 다시 얹어 위반 · 요약만 새로.
|
||||
|
||||
정책 칸 = 최대 종단기울기 · 종단곡선 최소 R · 평면 최소 R(+ 있던 최소 L). 기하 칸은 그대로.
|
||||
반환 = (선형, 요약 `{id, ...}`) · `longitudinal` 은 제자리에서 바뀐다.
|
||||
"""
|
||||
stored = longitudinal.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
raise ValueError("저장된 계획선 선형이 없어 기준을 바꿀 수 없습니다.")
|
||||
policy = dict(stored.get("policy") or {})
|
||||
policy["max_grade_pct"] = options.max_grade_pct
|
||||
policy["min_vertical_radius_m"] = options.min_vertical_radius_m
|
||||
policy["min_plan_radius_m"] = legal_plan_radius_min_m(
|
||||
options.design_speed_kph, options.terrain_type
|
||||
)
|
||||
if policy.get("min_curve_length_m"):
|
||||
policy["min_curve_length_m"] = options.min_curve_length_m
|
||||
stored["policy"] = policy
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
previous = profiles[0] if isinstance(profiles, list) and profiles else {}
|
||||
direction = (previous.get("criteria") or {}).get("resolved_main_direction", "none")
|
||||
previous["criteria"] = {**options.as_dict(), "resolved_main_direction": direction}
|
||||
longitudinal["design_profiles"] = [previous, *(profiles or [])[1:]]
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, stored.get("edits"))
|
||||
# `AlignmentPolicy.from_dict` 가 평면 최소 R 을 안 읽음 — 다시 셈한 정책에 되얹음
|
||||
alignment["policy"]["min_plan_radius_m"] = policy["min_plan_radius_m"]
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"][0] = profile
|
||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
||||
|
||||
|
||||
def retarget_file(
|
||||
project_root: Path, longitudinal_file_path: str, options: GradeDesignOptions
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 정본 파일에 새 기준 — 요약을 돌려줌 · 계획선 선형이 없으면 None(파일 그대로)."""
|
||||
root = Path(project_root).resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
if root not in path.parents:
|
||||
raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.")
|
||||
if not path.is_file():
|
||||
return None
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not (longitudinal.get("profile_alignment") or {}).get("base_pvi"):
|
||||
return None
|
||||
_, summary = retarget_longitudinal(longitudinal, options)
|
||||
atomic_write_json(path, longitudinal)
|
||||
return summary
|
||||
@@ -157,6 +157,7 @@ async def solve_route_record(
|
||||
"cross_sample_interval_m": request.cross_sample_interval_m,
|
||||
"long_sample_interval_m": request.long_sample_interval_m,
|
||||
**request.grade_options(),
|
||||
"station_display": request.station_display, # B05 표시 전용(448)
|
||||
}
|
||||
log_b05_debug(
|
||||
logger,
|
||||
|
||||
@@ -11,22 +11,34 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||
from B05_Profile.B05_Profile_Repository import confirm_route, get_latest_route
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m
|
||||
from B05_Profile.B05_Profile_PageSettings import grade_options_of, params_patch, retarget_file
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
confirm_route,
|
||||
get_latest_route,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_append_irregular_cross_sections,
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
sync_uphill_overrides_into_designs,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse
|
||||
from B05_Profile.B05_Profile_Schema import (
|
||||
PageSettingsRequest,
|
||||
PageSettingsResponse,
|
||||
RouteConfirmRequest,
|
||||
RouteConfirmResponse,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from common_util.common_util_auth import get_optional_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from common_util.common_util_workflow_state import complete_stage, merge_stage_params, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -149,6 +161,76 @@ async def confirm_latest_route(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/page-settings", response_model=PageSettingsResponse)
|
||||
async def save_page_settings(
|
||||
project_id: UUID, request: PageSettingsRequest
|
||||
) -> PageSettingsResponse | JSONResponse:
|
||||
"""B05 페이지 설정 [저장](448) — stage 2 params 에 사용자 값만 얹고(상태 · STALE 그대로)
|
||||
최신 종단 정본의 경고 기준을 갈아 끼워 위반 · 요약을 다시 셈. 기하 · 종횡단은 그대로."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT road_type FROM projects WHERE id = %s", (str(project_id),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
params = await merge_stage_params(
|
||||
cursor, str(project_id), 2, params_patch(request)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
options = grade_options_of(row[0] if row else None, params)
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
longitudinal = (
|
||||
await get_longitudinal_section(connection, project_id, latest["id"])
|
||||
if latest
|
||||
else None
|
||||
)
|
||||
summary = None
|
||||
if longitudinal:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
summary = await asyncio.to_thread(
|
||||
retarget_file,
|
||||
Path(resolve_stored_project_path(stored_path)),
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
options,
|
||||
)
|
||||
if summary is not None:
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=latest["id"], grade_summary=summary
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return PageSettingsResponse(
|
||||
project_id=str(project_id),
|
||||
design_speed_kph=options.design_speed_kph,
|
||||
terrain_type=options.terrain_type,
|
||||
max_grade_pct=options.max_grade_pct,
|
||||
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||
min_plan_radius_m=legal_plan_radius_min_m(
|
||||
options.design_speed_kph, options.terrain_type
|
||||
),
|
||||
warnings=list(options.warnings),
|
||||
profile_updated=summary is not None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 페이지 설정 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "페이지 설정 저장 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/reset")
|
||||
async def reset_route_design(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -187,6 +187,8 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
params = (stage2 or {}).get("params") or {}
|
||||
# 노선 풀기 · 페이지 설정 저장(448)은 `options` 안에 둠 — 옛 맨 위 칸은 폴백
|
||||
params = {**params, **{k: v for k, v in (params.get("options") or {}).items() if v}}
|
||||
speed = params.get("design_speed_kph")
|
||||
if isinstance(speed, (int, float)):
|
||||
design_speed = int(speed)
|
||||
|
||||
@@ -117,6 +117,8 @@ class RouteSolveRequest(BaseModel):
|
||||
enforce_pipe_clearance: bool | None = Field(
|
||||
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
|
||||
)
|
||||
# 시작 측점 · 누가거리 표시(448) — B05 라벨만 · 다시 풀어도 params 에 그대로 옮김.
|
||||
station_display: dict[str, float] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
@@ -217,6 +219,50 @@ class ProfileAlignmentSaveRequest(BaseModel):
|
||||
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
||||
|
||||
|
||||
class PageSettingsRequest(BaseModel):
|
||||
"""B05 페이지 설정 [저장](448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
design_speed_kph: int = 20
|
||||
terrain_type: str = "special"
|
||||
max_grade_pct: float | None = Field(default=None, gt=0)
|
||||
min_vertical_radius_m: float | None = Field(default=None, gt=0)
|
||||
min_tangent_length_m: float | None = Field(default=None, gt=0)
|
||||
enforce_pipe_clearance: bool = False
|
||||
#: 시작 측점 · 누가거리 — B05 표시 전용(측점 열쇠 · 다른 페이지 라벨에 안 씀)
|
||||
station_display: dict[str, float] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "PageSettingsRequest":
|
||||
if self.design_speed_kph not in (20, 30, 40):
|
||||
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")
|
||||
if self.terrain_type not in GRADE_TERRAIN_TYPES:
|
||||
raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.")
|
||||
if self.station_display is not None:
|
||||
station = self.station_display.get("station", 0)
|
||||
self.station_display = {
|
||||
"station": float(max(0, round(station))),
|
||||
"cumulative": float(self.station_display.get("cumulative", 0)),
|
||||
}
|
||||
return self
|
||||
|
||||
|
||||
class PageSettingsResponse(BaseModel):
|
||||
"""페이지 설정 저장 결과 — 해석된 기준(작업임도 30 → 20 등) · 경고."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
design_speed_kph: int
|
||||
terrain_type: str
|
||||
max_grade_pct: float
|
||||
min_vertical_radius_m: float
|
||||
min_plan_radius_m: float
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
#: 종단 정본의 기준을 갈아 끼우고 위반 · 요약을 다시 셈했나(종단이 없으면 false)
|
||||
profile_updated: bool = False
|
||||
|
||||
|
||||
class ProfileAlignmentSaveResponse(BaseModel):
|
||||
"""계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다)."""
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeRouteLatestCache,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { panelValuesOfDraft, readPageSettings } from "./B05_Profile_Api_PageSettings";
|
||||
import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
|
||||
|
||||
/**
|
||||
@@ -63,9 +64,11 @@ export function createLatestSource(projectId: string) {
|
||||
return { read, write, load, verify };
|
||||
}
|
||||
|
||||
/** 저장된 노선 설정값 → 좌측 폼 되살리기 값. */
|
||||
/** 저장된 노선 설정값 → 좌측 폼 되살리기 값 — 세션 페이지 설정 초안이 있으면 그 위에(448).
|
||||
* 시 · 종점 계획고 조정은 종단 편집이 비추므로 여기서 안 채움(옛 params 값은 자동 선형에 이미 듦). */
|
||||
export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValues> {
|
||||
const options = next.route_params?.options ?? {};
|
||||
const draft = readPageSettings(next.project_id);
|
||||
return {
|
||||
contourInterval: next.surface_params.contour_interval_m,
|
||||
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
|
||||
@@ -77,9 +80,9 @@ export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValu
|
||||
maxGradePct: next.route_params?.max_grade_pct ?? undefined,
|
||||
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
|
||||
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
|
||||
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
|
||||
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
|
||||
enforcePipeClearance:
|
||||
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined,
|
||||
stationDisplay: next.route_params?.station_display ?? undefined,
|
||||
...(draft ? panelValuesOfDraft(draft) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { createRecommendPart } from "./B05_Profile_UI_Panel_Recommend";
|
||||
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
import { createViewControls } from "./B05_Profile_UI_Panel_View";
|
||||
import { pageSettingsOf, writePageSettings } from "./B05_Profile_Api_PageSettings";
|
||||
|
||||
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
@@ -71,7 +72,16 @@ interface PanelCallbacks {
|
||||
|
||||
export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) {
|
||||
const { viewControls, setCorridorPending } = createViewControls(callbacks);
|
||||
const settings = createSettingsPart(callbacks);
|
||||
const settings = createSettingsPart({
|
||||
...callbacks,
|
||||
onStationDisplayChange: (offset) => {
|
||||
callbacks.onStationDisplayChange(offset);
|
||||
rememberSettings();
|
||||
},
|
||||
// 시 · 종점 계획고 조정 = 종단도 끝 측점 편집(448) — 종단도는 한 벌(`shared.profile`)
|
||||
endOffsets: () => shared.profile?.endOffsets() ?? null,
|
||||
onEndOffsetChange: (end, value) => void shared.profile?.setEndOffset(end, value),
|
||||
});
|
||||
const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4)
|
||||
const {
|
||||
contourInterval,
|
||||
@@ -118,6 +128,37 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
enforcePipeClearance,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
// 페이지 설정 초안(448) — 고친 즉시 세션에 · [저장]이 내보냄(시 · 종점 조정은 계획고 편집이라 뺌)
|
||||
[
|
||||
designSpeed,
|
||||
terrainType,
|
||||
maxGradePct,
|
||||
minVerticalRadius,
|
||||
minTangentLength,
|
||||
enforcePipeClearance,
|
||||
].forEach((input) => input.addEventListener("change", rememberSettings));
|
||||
function rememberSettings(): void {
|
||||
writePageSettings(shared.projectId, pageSettingsOf(values()));
|
||||
}
|
||||
|
||||
function values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
designSpeed: settings.currentSpeed(),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
enforcePipeClearance: enforcePipeClearance.checked,
|
||||
stationDisplay: settings.stationDisplayOffset(),
|
||||
};
|
||||
}
|
||||
|
||||
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
|
||||
// [확정]은 한 벌이 두 보기 몫을 모아 낸다.
|
||||
@@ -136,23 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
gradeLimitPct: settings.gradeLimitPct,
|
||||
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
|
||||
setCorridorPending,
|
||||
values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
designSpeed: settings.currentSpeed(),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
enforcePipeClearance: enforcePipeClearance.checked,
|
||||
};
|
||||
},
|
||||
values,
|
||||
restore(values: Partial<RoutePanelValues>) {
|
||||
if (values.contourInterval != null) contourInterval.value = String(values.contourInterval);
|
||||
if (values.gradeClass) gradeClass.value = values.gradeClass;
|
||||
@@ -167,17 +192,24 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
}
|
||||
if (values.designSpeed) designSpeed.value = String(values.designSpeed);
|
||||
if (values.terrainType) terrainType.value = values.terrainType;
|
||||
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
|
||||
if (values.minVerticalRadius != null)
|
||||
minVerticalRadius.value = String(values.minVerticalRadius);
|
||||
if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength);
|
||||
// 기준값 — null = 사용자가 비운 칸(초안 · 448) · undefined = 그대로
|
||||
const typed: Array<[HTMLInputElement, number | null | undefined]> = [
|
||||
[maxGradePct, values.maxGradePct],
|
||||
[minVerticalRadius, values.minVerticalRadius],
|
||||
[minTangentLength, values.minTangentLength],
|
||||
];
|
||||
typed.forEach(([input, value]) => {
|
||||
if (value !== undefined) input.value = value === null ? "" : String(value);
|
||||
});
|
||||
if (values.startElevationOffset != null)
|
||||
startElevationOffset.value = String(values.startElevationOffset);
|
||||
if (values.endElevationOffset != null)
|
||||
endElevationOffset.value = String(values.endElevationOffset);
|
||||
if (values.enforcePipeClearance != null)
|
||||
enforcePipeClearance.checked = values.enforcePipeClearance;
|
||||
if (values.stationDisplay) settings.setStationDisplay(values.stationDisplay);
|
||||
settings.syncCriteria();
|
||||
settings.showEndOffsets();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface RoutePanelValues {
|
||||
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
|
||||
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
|
||||
enforcePipeClearance: boolean;
|
||||
/** 시작 측점 · 누가거리 표시(448 저장) — B05 라벨만 · 측점 열쇠 · 다른 페이지에 안 씀. */
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface SettingsPartCallbacks {
|
||||
onContourApply: (interval: number) => void | Promise<void>;
|
||||
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||
/** 시 · 종점 계획고 조정(448) = 첫 · 끝 측점 계획고 편집 — 지금 값(기준 없으면 null). */
|
||||
endOffsets?: () => { start: number; end: number } | null;
|
||||
/** 칸을 바꿈 → 그 끝 측점 편집으로(빈 칸 = 0). */
|
||||
onEndOffsetChange?: (end: "start" | "end", value: number) => void;
|
||||
}
|
||||
|
||||
export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
@@ -73,6 +77,11 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
// 읽기 전용(448) — 바꾸면 종횡단을 다시 표본해야 해서 여기서 고치지 않음.
|
||||
[crossSampleInterval, longSampleInterval].forEach((input) => {
|
||||
input.disabled = true;
|
||||
input.title = "종횡단 표본 간격 — 바꾸려면 종횡단을 다시 만들어야 합니다(읽기 전용).";
|
||||
});
|
||||
// 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시).
|
||||
const sampleRow = document.createElement("div");
|
||||
sampleRow.className = "b05-route__field-row";
|
||||
@@ -102,11 +111,33 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
const startElevationOffset = numberField("시점 계획고 조정 (m)");
|
||||
const endElevationOffset = numberField("종점 계획고 조정 (m)");
|
||||
// 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(448) — 칸은 종단 편집을 비추고 바꾸면 그 편집으로.
|
||||
const endFields = { start: startElevationOffset, end: endElevationOffset };
|
||||
function showEndOffsets(): void {
|
||||
const offsets = callbacks.endOffsets?.();
|
||||
(["start", "end"] as const).forEach((end) => {
|
||||
endFields[end].disabled = !offsets;
|
||||
if (offsets && document.activeElement !== endFields[end])
|
||||
endFields[end].value = offsets[end] ? String(Number(offsets[end].toFixed(3))) : "";
|
||||
});
|
||||
}
|
||||
(["start", "end"] as const).forEach((end) => {
|
||||
endFields[end].placeholder = "0";
|
||||
endFields[end].title =
|
||||
"자동 계획선 대비 이 끝 측점의 계획고 편집(m) — 종단도 끝점 ▲▼ 와 같은 값.";
|
||||
endFields[end].addEventListener("change", () => {
|
||||
callbacks.onEndOffsetChange?.(end, parseOptional(endFields[end]) ?? 0);
|
||||
showEndOffsets(); // 막힌 편집(최소고 가드)이면 옛 값으로 돌아옴
|
||||
});
|
||||
});
|
||||
const criteriaNote = document.createElement("p");
|
||||
criteriaNote.className = "b05-route__note";
|
||||
const gradeAdvanced = document.createElement("details");
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
// 칸을 펼 때 · 칸 위로 올 때 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤)
|
||||
gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets());
|
||||
gradeAdvanced.addEventListener("pointerenter", showEndOffsets);
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
@@ -224,6 +255,12 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
enforcePipeClearance,
|
||||
},
|
||||
stationDisplayOffset,
|
||||
/** 저장된 시작 측점 · 누가거리를 칸에 되살림(알림 없음). */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
startStation.value = String(next.station);
|
||||
startCumulative.value = String(next.cumulative);
|
||||
},
|
||||
showEndOffsets,
|
||||
gradeLimitPct,
|
||||
currentSpeed,
|
||||
syncSpeedChoices,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* logic/B05_Profile_EndOffsets.ts
|
||||
* 「페이지 설정」 시 · 종점 계획고 조정(448) = 첫 · 끝 측점의 계획고 편집(`station_offsets`) 한 칸.
|
||||
* 칸 값 = 자동 선형 대비 그 측점 편집(m) · 바꾸면 끝점 ▲▼ 와 같은 길(`adjustStation`)로 얹음 —
|
||||
* 계산 줄 즉시 · [저장] `alignment` 차례가 서버 정본 · [초기화]는 종단 파일과 함께 0.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
adjustStation,
|
||||
chainageKey,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
export type ProfileEnd = "start" | "end";
|
||||
|
||||
function endChainage(base: AlignmentBase, end: ProfileEnd): number | undefined {
|
||||
const nodes = base.basePvi;
|
||||
return (end === "start" ? nodes[0] : nodes[nodes.length - 1])?.chainage_m;
|
||||
}
|
||||
|
||||
/** 첫 · 끝 측점 계획고 편집(m) — 편집이 없으면 0. */
|
||||
export function endOffsetsOf(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
): Record<ProfileEnd, number> {
|
||||
const at = (end: ProfileEnd): number => {
|
||||
const chainage = endChainage(base, end);
|
||||
return chainage === undefined ? 0 : (edits.station_offsets[chainageKey(chainage)] ?? 0);
|
||||
};
|
||||
return { start: at("start"), end: at("end") };
|
||||
}
|
||||
|
||||
/** 그 끝 측점 계획고 편집을 `value`(m)로 — 기준이 없으면 편집 그대로. */
|
||||
export function withEndOffset(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
end: ProfileEnd,
|
||||
value: number,
|
||||
): AlignmentEdits {
|
||||
const chainage = endChainage(base, end);
|
||||
if (chainage === undefined || !Number.isFinite(value)) return edits;
|
||||
return adjustStation(base, edits, chainage, value - endOffsetsOf(base, edits)[end]);
|
||||
}
|
||||
@@ -39,11 +39,11 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
|
||||
)
|
||||
if stage_params.get(key) is not None
|
||||
}
|
||||
# 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20).
|
||||
# 설계속도 · 지형은 B05가 저장해 둔 값 — 없으면 종류별 기본(20) · B05 기본 특수(448 E2).
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
str(grade_class),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored=stored,
|
||||
|
||||
@@ -218,6 +218,8 @@ async def save_initial_snapshot(
|
||||
dump: dict[str, Any] = {"routes": _json_safe([dict(route)])}
|
||||
for table in _CHILD_TABLES:
|
||||
dump[table] = _json_safe(await _dump_rows(connection, table, route_id))
|
||||
# stage 2 params(제어점 · 페이지 설정)도 — [초기화]가 함께 되돌림(448 E3)
|
||||
dump["stage2_params"] = await _stage2_params(connection, str(route["project_id"]))
|
||||
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@@ -322,9 +324,40 @@ async def restore_initial_snapshot(
|
||||
if "project_id" in child:
|
||||
child["project_id"] = project_id
|
||||
await _insert_row(connection, table, child)
|
||||
await _restore_stage2_params(connection, project_id, dump)
|
||||
return new_id
|
||||
|
||||
|
||||
async def _stage2_params(connection: aiomysql.Connection, project_id: str) -> Any:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT params FROM project_workflow_stages WHERE project_id = %s AND stage_no = 2",
|
||||
(project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
raw = row[0] if row else None
|
||||
return json.loads(raw) if isinstance(raw, str) else raw
|
||||
|
||||
|
||||
async def _restore_stage2_params(
|
||||
connection: aiomysql.Connection, project_id: str, dump: dict[str, Any]
|
||||
) -> None:
|
||||
"""뜬 stage 2 params 로 되씀 — 안 뜬 옛 스냅샷은 페이지 설정 칸만 걷어 기본값(448 E3)."""
|
||||
from B05_Profile.B05_Profile_PageSettings import strip_page_settings
|
||||
|
||||
if "stage2_params" in dump:
|
||||
params = dump["stage2_params"]
|
||||
else:
|
||||
params = strip_page_settings(await _stage2_params(connection, project_id))
|
||||
if params is None:
|
||||
return
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = 2",
|
||||
(json.dumps(params, ensure_ascii=False), project_id),
|
||||
)
|
||||
|
||||
|
||||
async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int:
|
||||
columns = list(row.keys())
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
|
||||
@@ -88,6 +88,37 @@ async def start_stage(
|
||||
)
|
||||
|
||||
|
||||
def merged_params(params: Dict[str, Any] | None, patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""params 에 patch 를 얹는다 — `options` 는 칸끼리 합침 · 나머지는 갈음(448)."""
|
||||
merged = dict(params or {})
|
||||
for key, value in patch.items():
|
||||
if key == "options" and isinstance(value, dict):
|
||||
merged["options"] = {**(merged.get("options") or {}), **value}
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
async def merge_stage_params(
|
||||
cursor: aiomysql.DictCursor, project_id: str, stage_no: int, patch: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""단계 params 에 patch 만 얹는다 — 상태 · 이후 단계(STALE)는 그대로(448 페이지 설정)."""
|
||||
await cursor.execute(
|
||||
"SELECT params FROM project_workflow_stages "
|
||||
"WHERE project_id = %s AND stage_no = %s FOR UPDATE",
|
||||
(project_id, stage_no),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
raw = (row["params"] if isinstance(row, dict) else row[0]) if row else None
|
||||
params = json.loads(raw) if isinstance(raw, str) else raw
|
||||
merged = merged_params(params if isinstance(params, dict) else None, patch)
|
||||
await cursor.execute(
|
||||
"UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = %s",
|
||||
(json.dumps(merged, ensure_ascii=False), project_id, stage_no),
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None:
|
||||
"""단계를 완료 상태로 전환한다."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료.
|
||||
* ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎
|
||||
* ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
|
||||
* ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets
|
||||
* ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
globalThis.window = { setTimeout, clearTimeout };
|
||||
|
||||
const anything = () =>
|
||||
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
|
||||
|
||||
function load(file, stubs) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything());
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
/** 상대 경로 import 를 실제 파일로 따라 읽는 로더(순수 logic 묶음용) · 그 밖은 가짜. */
|
||||
const cache = new Map();
|
||||
function loadReal(file) {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const req = (r) => {
|
||||
if (!r.startsWith(".")) return anything();
|
||||
const target = path.resolve(path.dirname(file), r);
|
||||
return loadReal(fs.existsSync(`${target}.ts`) ? `${target}.ts` : target);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(file, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const out = {};
|
||||
|
||||
// ── ① 저장 차례 ──
|
||||
const calls = [];
|
||||
const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), {
|
||||
"../../B05_Profile/B05_Profile_Api_PageSettings": {
|
||||
flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": {
|
||||
pendingRouteSave: async () => ({ draft: 1 }),
|
||||
saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }),
|
||||
carryAfterRouteSave: async () => [],
|
||||
routeSaveSummary: () => "",
|
||||
rebuildAfterRouteSave: async () => undefined,
|
||||
},
|
||||
});
|
||||
out.order = [...save.DESIGN_SAVE_ORDER];
|
||||
const done = await save.saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 3,
|
||||
steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀
|
||||
cross: async () => calls.push("cross"),
|
||||
failed: (error) => calls.push(`failed:${error.message}`),
|
||||
});
|
||||
out.routeSaveDone = done;
|
||||
out.routeSaveCalls = calls;
|
||||
|
||||
// ── ② 초안 내보내기 ──
|
||||
const session = new Map();
|
||||
const puts = [];
|
||||
let mode = "ok";
|
||||
let latest = {
|
||||
route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 },
|
||||
};
|
||||
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), {
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null),
|
||||
writeState: (name, value, p) => session.set(`${name}:${p}`, value),
|
||||
clearState: (name, p) => session.delete(`${name}:${p}`),
|
||||
},
|
||||
"./B05_Profile_Api_Fetch": {
|
||||
savePageSettings: async (projectId, body) => {
|
||||
puts.push(body);
|
||||
if (mode === "boom") throw new Error("서버 500");
|
||||
return { warnings: [], profile_updated: true };
|
||||
},
|
||||
readRouteLatestCache: () => latest,
|
||||
writeRouteLatestCache: (_p, value) => (latest = value),
|
||||
},
|
||||
});
|
||||
await api.flushPageSettings("p1");
|
||||
out.putsWithoutDraft = puts.length;
|
||||
const draft = api.pageSettingsOf({
|
||||
designSpeed: 30,
|
||||
terrainType: "normal",
|
||||
maxGradePct: null,
|
||||
minVerticalRadius: 300,
|
||||
minTangentLength: null,
|
||||
enforcePipeClearance: true,
|
||||
stationDisplay: { station: 12, cumulative: 240 },
|
||||
});
|
||||
out.draft = draft;
|
||||
api.writePageSettings("p1", draft);
|
||||
out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1"));
|
||||
mode = "boom";
|
||||
out.boom = await api.flushPageSettings("p1").then(
|
||||
() => "ok",
|
||||
(error) => error.message,
|
||||
);
|
||||
out.draftAfterBoom = api.readPageSettings("p1");
|
||||
mode = "ok";
|
||||
await api.flushPageSettings("p1");
|
||||
out.draftAfterOk = api.readPageSettings("p1");
|
||||
out.latestAfterOk = latest;
|
||||
out.puts = puts.length;
|
||||
|
||||
// ── ③ 끝 측점 계획고 편집 ──
|
||||
const logic = path.join(ROOT, "B05_Profile", "logic");
|
||||
const alignment = loadReal(path.join(logic, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const ends = loadReal(path.join(logic, "B05_Profile_EndOffsets.ts"));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 5,
|
||||
default_curve_length_m: 20,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: true,
|
||||
balance_tolerance_percent: 5,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 5,
|
||||
paved: false,
|
||||
},
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: [
|
||||
{ chainage_m: 0, elevation_m: 100 },
|
||||
{ chainage_m: 100, elevation_m: 105 },
|
||||
{ chainage_m: 200, elevation_m: 108 },
|
||||
],
|
||||
chainage: [0, 100, 200],
|
||||
ground: [100, 105, 108],
|
||||
stations: [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200].map((s) => ({
|
||||
station_id: `s${s}`,
|
||||
chainage_m: s,
|
||||
})),
|
||||
};
|
||||
const empty = { station_offsets: {}, curve_radii: {} };
|
||||
out.endsEmpty = ends.endOffsetsOf(base, empty);
|
||||
const raised = ends.withEndOffset(base, empty, "start", 1.25);
|
||||
out.endsRaised = ends.endOffsetsOf(base, raised);
|
||||
const both = ends.withEndOffset(base, raised, "end", -0.5);
|
||||
out.endsBoth = ends.endOffsetsOf(base, both);
|
||||
const plan = alignment.buildAlignment(base, both);
|
||||
out.planStart = plan.samples[0].elevation_m;
|
||||
out.planEnd = plan.samples[plan.samples.length - 1].elevation_m;
|
||||
out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0));
|
||||
|
||||
// ── ④ 기준표 ──
|
||||
const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {});
|
||||
out.criteria = fields.PROFILE_CRITERIA;
|
||||
out.speedChoices = fields.SPEED_CHOICES;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(String(error.stack || error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -74,6 +74,8 @@ const STUBS = {
|
||||
"../../B06_Section/B06_Section_Api_Culvert_Options": {
|
||||
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
|
||||
},
|
||||
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
|
||||
"../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined },
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수.
|
||||
|
||||
TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙
|
||||
(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬
|
||||
`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 ×
|
||||
지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_grade_limit_mirror.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
|
||||
from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA as PY
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_tables():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_442_2_page_settings.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
return out["criteria"], out["speedChoices"]
|
||||
|
||||
|
||||
def test_속도별_기울기_반경_표가_같음(ts_tables):
|
||||
criteria, _ = ts_tables
|
||||
assert sorted(int(speed) for speed in criteria) == sorted(PY["design_speed"])
|
||||
for speed, legal in PY["design_speed"].items():
|
||||
ts = criteria[str(speed)]
|
||||
assert ts["grade"] == legal["max_grade_pct"]
|
||||
assert ts["radius"] == legal["min_vertical_radius_m"]
|
||||
|
||||
|
||||
def test_종류별_속도_선택지가_같음(ts_tables):
|
||||
_, choices = ts_tables
|
||||
for road, allowed in choices.items():
|
||||
assert tuple(allowed) == tuple(PY["selectable_design_speeds"].get(road, (20,)))
|
||||
|
||||
|
||||
def _ts_limit(criteria, choices, road, speed, terrain, typed):
|
||||
allowed = choices.get(road, [20])
|
||||
speed = speed if speed in allowed else allowed[0] # syncSpeedChoices
|
||||
if typed is not None and typed > 0:
|
||||
return float(typed)
|
||||
return float(criteria[str(speed)]["grade"][terrain])
|
||||
|
||||
|
||||
def test_기울기_상한_전수(ts_tables):
|
||||
criteria, choices = ts_tables
|
||||
cases = itertools.product(
|
||||
["trunk", "fire", "work", "branch"], [20, 30, 40], ["normal", "special"], [None, 11.5]
|
||||
)
|
||||
for road, speed, terrain, typed in cases:
|
||||
py = resolve_grade_options(
|
||||
road,
|
||||
terrain_type=terrain,
|
||||
design_speed_kph=speed,
|
||||
stored={} if typed is None else {"max_grade_pct": typed},
|
||||
)
|
||||
assert _ts_limit(criteria, choices, road, speed, terrain, typed) == py.max_grade_pct, (
|
||||
road,
|
||||
speed,
|
||||
terrain,
|
||||
typed,
|
||||
)
|
||||
@@ -0,0 +1,396 @@
|
||||
"""PLAN 448(442-2 · 요구 269-1) — B05 페이지 설정 [저장] · [확정] 영구저장.
|
||||
|
||||
- 병합 — stage 2 params 에 사용자 값만 얹음(`options` 칸끼리 · 상태 · STALE 안 건드림)
|
||||
- 정책 갱신 — 종단 경고 기준(최대 기울기 · 종단곡선 R · 평면 R)만 · 기하(base_pvi · 편집) 그대로
|
||||
- 작업임도 30 → 20 해석 · params 엔 원값
|
||||
- E1 — 노선 바꾼 저장 · B04 재확정 요청이 설계속도 · 최소고 강제 · 시작 측점을 옮김
|
||||
- E2 — B06 재생성 지형 기본 = 특수(B05 기본)
|
||||
- E3 — 초기값 스냅샷이 stage 2 params 를 뜨고 [초기화]가 되돌림 · 옛 스냅샷은 설정 칸만 걷음
|
||||
- 저장 차례 · 초안 내보내기 · 끝 측점 편집 — Node 헬퍼 `helper_442_2_page_settings.cjs`
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_page_settings.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import redesign_solve_request
|
||||
from B05_Profile import B05_Profile_PageSettings as page
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import design_alignment_profile
|
||||
from B05_Profile.B05_Profile_Schema import PageSettingsRequest
|
||||
from B06_Section.B06_Section_Router_Regenerate_Options import _regeneration_grade_options
|
||||
from common_util import common_util_initial_snapshot as snapshot
|
||||
from common_util.common_util_workflow_state import merge_stage_params, merged_params
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_442_2_page_settings.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
PARAMS = {
|
||||
"points": {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}},
|
||||
"options": {"grade_class": "trunk", "terrain_type": "special", "paved": False},
|
||||
"station_interval_m": 20,
|
||||
"max_grade_pct": 13.0,
|
||||
"start_elevation_offset_m": 0.4,
|
||||
}
|
||||
|
||||
|
||||
# ── 병합 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_병합_options_칸끼리_나머지_갈음():
|
||||
request = PageSettingsRequest(
|
||||
design_speed_kph=30,
|
||||
terrain_type="normal",
|
||||
max_grade_pct=None,
|
||||
min_vertical_radius_m=300,
|
||||
station_display={"station": 12.4, "cumulative": 240},
|
||||
)
|
||||
merged = merged_params(PARAMS, page.params_patch(request))
|
||||
assert merged["options"] == {
|
||||
"grade_class": "trunk",
|
||||
"terrain_type": "normal",
|
||||
"paved": False,
|
||||
"design_speed_kph": 30,
|
||||
}
|
||||
assert merged["max_grade_pct"] is None # 비운 칸 = 법정값
|
||||
assert merged["min_vertical_radius_m"] == 300
|
||||
assert merged["station_display"] == {"station": 12.0, "cumulative": 240.0}
|
||||
assert merged["points"] == PARAMS["points"] and merged["start_elevation_offset_m"] == 0.4
|
||||
assert PARAMS["options"]["terrain_type"] == "special" # 원본 안 바뀜
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, params):
|
||||
self.params = params
|
||||
self.sql: list[str] = []
|
||||
|
||||
async def execute(self, sql, args=None):
|
||||
self.sql.append(" ".join(sql.split()))
|
||||
if sql.strip().upper().startswith("UPDATE"):
|
||||
self.params = args[0]
|
||||
|
||||
async def fetchone(self):
|
||||
return {"params": self.params}
|
||||
|
||||
|
||||
def test_merge_stage_params_상태_안_건드림():
|
||||
cursor = _Cursor(json.dumps(PARAMS))
|
||||
merged = asyncio.run(merge_stage_params(cursor, "p1", 2, {"options": {"design_speed_kph": 40}}))
|
||||
assert merged["options"]["design_speed_kph"] == 40
|
||||
assert json.loads(cursor.params)["options"]["grade_class"] == "trunk"
|
||||
assert "FOR UPDATE" in cursor.sql[0]
|
||||
assert len(cursor.sql) == 2 and cursor.sql[1].startswith("UPDATE project_workflow_stages SET")
|
||||
assert all("state" not in sql.lower() and "projects" not in sql for sql in cursor.sql[1:])
|
||||
|
||||
|
||||
def test_요청_검증():
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(design_speed_kph=50)
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(terrain_type="flat")
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(max_grade_pct=0)
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(extra=1)
|
||||
|
||||
|
||||
def test_작업임도_30은_20으로_params_는_원값():
|
||||
request = PageSettingsRequest(design_speed_kph=30, terrain_type="normal")
|
||||
params = merged_params({"options": {}}, page.params_patch(request))
|
||||
assert params["options"]["design_speed_kph"] == 30 # 원값 저장(해석값 금지)
|
||||
work = page.grade_options_of("work", params)
|
||||
assert (work.design_speed_kph, work.max_grade_pct, work.min_vertical_radius_m) == (20, 9, 100)
|
||||
trunk = page.grade_options_of("main", params) # B02 main = 간선
|
||||
assert (trunk.design_speed_kph, trunk.max_grade_pct, trunk.min_vertical_radius_m) == (
|
||||
30,
|
||||
8,
|
||||
250,
|
||||
)
|
||||
typed = page.grade_options_of("main", {**params, "max_grade_pct": 11.0})
|
||||
assert typed.max_grade_pct == 11.0 and typed.warnings # 법정 8 초과 = 경고(막지 않음)
|
||||
|
||||
|
||||
# ── 정책 갱신 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _longitudinal() -> dict:
|
||||
"""앞 200 m 11 % 오르막 · 뒤 200 m 2 % — 특수 20(14 %)엔 위반 없음 · 일반 30(8 %)엔 위반."""
|
||||
chainage = np.arange(0.0, 400.5, 1.0)
|
||||
ground = np.where(chainage <= 200, 100 + 0.11 * chainage, 122 + 0.02 * (chainage - 200))
|
||||
longitudinal = {
|
||||
"samples": [
|
||||
{"chainage_m": float(s), "elevation_m": float(z)} for s, z in zip(chainage, ground)
|
||||
],
|
||||
"stations": [
|
||||
{"station_id": f"s{i}", "chainage_m": float(s)}
|
||||
for i, s in enumerate(np.arange(0, 401, 20))
|
||||
],
|
||||
}
|
||||
options = resolve_grade_options("trunk", terrain_type="special", design_speed_kph=20)
|
||||
alignment, profile = design_alignment_profile(longitudinal, options)
|
||||
edits = {"station_offsets": {"100.000": 0.8}, "curve_radii": {}}
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
return longitudinal
|
||||
|
||||
|
||||
def test_정책_갱신_기하_그대로():
|
||||
before = _longitudinal()
|
||||
after = copy.deepcopy(before)
|
||||
strict = resolve_grade_options("trunk", terrain_type="normal", design_speed_kph=30)
|
||||
alignment, summary = page.retarget_longitudinal(after, strict)
|
||||
policy = alignment["policy"]
|
||||
assert (policy["max_grade_pct"], policy["min_vertical_radius_m"]) == (8, 250)
|
||||
assert policy["min_plan_radius_m"] == 30 # 30 km/h 일반지형
|
||||
criteria = after["design_profiles"][0]["criteria"]
|
||||
assert (criteria["design_speed_kph"], criteria["terrain_type"]) == (30, "normal")
|
||||
assert not before["profile_alignment"]["violations"]
|
||||
assert after["profile_alignment"]["violations"] # 같은 선 · 엄한 기준 = 위반 뜸
|
||||
assert summary["id"] == after["design_profiles"][0]["id"]
|
||||
# 기하 그대로 — 자동 선형 · 편집 · 계획고 표본
|
||||
assert alignment["base_pvi"] == before["profile_alignment"]["base_pvi"]
|
||||
assert alignment["edits"] == before["profile_alignment"]["edits"]
|
||||
z0 = [s["elevation_m"] for s in before["profile_alignment"]["samples"]]
|
||||
z1 = [s["elevation_m"] for s in alignment["samples"]]
|
||||
assert np.allclose(z0, z1)
|
||||
|
||||
|
||||
def test_retarget_file_선형_없으면_그대로(tmp_path):
|
||||
options = resolve_grade_options("trunk", terrain_type="normal")
|
||||
rel = "B06_Section/longitudinal/longitudinal.json"
|
||||
path = tmp_path / rel
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"samples": []}), encoding="utf-8")
|
||||
assert page.retarget_file(tmp_path, rel, options) is None
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"samples": []}
|
||||
assert page.retarget_file(tmp_path, "nope.json", options) is None
|
||||
with pytest.raises(ValueError):
|
||||
page.retarget_file(tmp_path, "../outside.json", options)
|
||||
path.write_text(json.dumps(_longitudinal()), encoding="utf-8")
|
||||
summary = page.retarget_file(tmp_path, rel, options)
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert summary is not None and stored["profile_alignment"]["policy"]["max_grade_pct"] == 9
|
||||
|
||||
|
||||
# ── E1 · E2 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_E1_재설계_요청이_설정을_옮김():
|
||||
params = {
|
||||
**PARAMS,
|
||||
"options": {**PARAMS["options"], "design_speed_kph": 30},
|
||||
"enforce_pipe_clearance": True,
|
||||
"station_display": {"station": 12, "cumulative": 240},
|
||||
}
|
||||
request = redesign_solve_request(params, {"source_filter": "csf"}, 5)
|
||||
assert request.design_speed_kph == 30
|
||||
assert request.enforce_pipe_clearance is True
|
||||
assert request.station_display == {"station": 12, "cumulative": 240}
|
||||
assert request.terrain_type == "special"
|
||||
|
||||
|
||||
def test_E2_B06_재생성_지형_기본_특수():
|
||||
no_terrain = _regeneration_grade_options({"options": {"grade_class": "trunk"}})
|
||||
assert no_terrain.terrain_type == "special" and no_terrain.max_grade_pct == 14
|
||||
saved = _regeneration_grade_options(
|
||||
{"options": {"grade_class": "trunk", "terrain_type": "normal", "design_speed_kph": 30}}
|
||||
)
|
||||
assert (saved.terrain_type, saved.max_grade_pct) == ("normal", 8)
|
||||
|
||||
|
||||
def test_노선_추천_설정은_options_에서_읽음(monkeypatch):
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
|
||||
stage = {
|
||||
"stage_no": 2,
|
||||
"params": {"options": {"design_speed_kph": 30, "terrain_type": "normal"}},
|
||||
}
|
||||
|
||||
class _Cur:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, *_):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return ("main",)
|
||||
|
||||
class _Conn:
|
||||
def cursor(self, *_):
|
||||
return _Cur()
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return _Conn()
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(replan, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
|
||||
|
||||
async def fake_state(*_):
|
||||
return {"stages": [stage]}
|
||||
|
||||
import common_util.common_util_workflow_state as workflow
|
||||
|
||||
monkeypatch.setattr(workflow, "get_workflow_state", fake_state)
|
||||
assert asyncio.run(replan._road_settings("p1")) == ("main", 30, "normal")
|
||||
|
||||
|
||||
# ── E3 스냅샷 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _SnapCursor:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=None):
|
||||
self.state["sql"].append(sql)
|
||||
if sql.strip().upper().startswith("UPDATE"):
|
||||
self.state["params"] = args[0]
|
||||
|
||||
async def fetchone(self):
|
||||
return (self.state["params"],)
|
||||
|
||||
|
||||
class _SnapConn:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
|
||||
def cursor(self, *_):
|
||||
return _SnapCursor(self.state)
|
||||
|
||||
|
||||
def test_E3_스냅샷_stage2_왕복():
|
||||
initial = {**PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 20}}
|
||||
state = {"params": json.dumps(initial), "sql": []}
|
||||
dumped = asyncio.run(snapshot._stage2_params(_SnapConn(state), "p1"))
|
||||
assert dumped == initial
|
||||
state["params"] = json.dumps(
|
||||
{**initial, "max_grade_pct": 7, "options": {"design_speed_kph": 40}}
|
||||
)
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": dumped}))
|
||||
assert json.loads(state["params"]) == initial
|
||||
|
||||
|
||||
def test_E3_옛_스냅샷은_설정_칸만_걷음():
|
||||
edited = {
|
||||
**PARAMS,
|
||||
"options": {**PARAMS["options"], "design_speed_kph": 40, "terrain_type": "normal"},
|
||||
"min_vertical_radius_m": 500,
|
||||
"enforce_pipe_clearance": True,
|
||||
"station_display": {"station": 3, "cumulative": 60},
|
||||
}
|
||||
state = {"params": json.dumps(edited), "sql": []}
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"routes": []}))
|
||||
restored = json.loads(state["params"])
|
||||
assert restored["options"] == {"grade_class": "trunk", "paved": False}
|
||||
for key in page.PAGE_TOP_KEYS:
|
||||
assert key not in restored
|
||||
assert restored["points"] == PARAMS["points"] and restored["start_elevation_offset_m"] == 0.4
|
||||
|
||||
|
||||
def test_E3_뜰_params_없으면_안_씀():
|
||||
state = {"params": None, "sql": []}
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": None}))
|
||||
assert not any(sql.strip().upper().startswith("UPDATE") for sql in state["sql"])
|
||||
|
||||
|
||||
# ── 화면(Node) ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_저장_차례_settings_는_노선_뒤_관_앞(out):
|
||||
order = out["order"]
|
||||
assert order.index("route") + 1 == order.index("settings") < order.index("alignment")
|
||||
|
||||
|
||||
def test_노선_바꾼_저장에도_settings_기본_창구(out):
|
||||
assert out["routeSaveDone"] is True
|
||||
assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구
|
||||
|
||||
|
||||
def test_초안_내보내기(out):
|
||||
assert out["putsWithoutDraft"] == 0
|
||||
assert out["boom"] == "서버 500" and out["draftAfterBoom"] == out["draft"]
|
||||
assert out["draftAfterOk"] is None and out["puts"] == 2
|
||||
params = out["latestAfterOk"]["route_params"]
|
||||
assert params["options"] == {
|
||||
"grade_class": "trunk",
|
||||
"design_speed_kph": 30,
|
||||
"terrain_type": "normal",
|
||||
}
|
||||
assert params["max_grade_pct"] is None and params["min_vertical_radius_m"] == 300
|
||||
assert params["station_display"] == {"station": 12, "cumulative": 240}
|
||||
|
||||
|
||||
def test_초안_되살림_값(out):
|
||||
restore = out["restoreFromDraft"]
|
||||
assert restore["designSpeed"] == 30 and restore["maxGradePct"] is None
|
||||
assert restore["stationDisplay"] == {"station": 12, "cumulative": 240}
|
||||
|
||||
|
||||
def test_시종점_조정은_끝_측점_계획고_편집(out):
|
||||
assert out["endsEmpty"] == {"start": 0, "end": 0}
|
||||
assert out["endsRaised"] == {"start": 1.25, "end": 0}
|
||||
assert out["endsBoth"] == {"start": 1.25, "end": -0.5}
|
||||
assert out["planStart"] == pytest.approx(101.25) and out["planEnd"] == pytest.approx(107.5)
|
||||
assert out["backToZero"]["start"] == 0
|
||||
|
||||
|
||||
def test_시작_측점은_열쇠_다른_페이지에_안_섞임():
|
||||
root = HERE.parents[1]
|
||||
hits = subprocess.run(
|
||||
["git", "grep", "-l", "station_display", "--", "*.py", "*.ts"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=root,
|
||||
encoding="utf-8",
|
||||
).stdout.split()
|
||||
allowed = {
|
||||
"B03_FileInput/B03_FileInput_Service_Chain.py", # E1 옮김
|
||||
"B05_Profile/B05_Profile_Api_Fetch.ts",
|
||||
"B05_Profile/B05_Profile_Api_PageSettings.ts",
|
||||
"B05_Profile/B05_Profile_PageSettings.py",
|
||||
"B05_Profile/B05_Profile_Router.py",
|
||||
"B05_Profile/B05_Profile_Schema.py",
|
||||
"B05_Profile/B05_Profile_UI_Page_Latest.ts",
|
||||
}
|
||||
assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed
|
||||
@@ -50,6 +50,7 @@ def test_계획고_가게_한_벌(out):
|
||||
def test_저장_차례(out):
|
||||
assert out["order"] == [
|
||||
"route",
|
||||
"settings", # 448 페이지 설정 — 노선 뒤
|
||||
"pipes",
|
||||
"structures",
|
||||
"plusStations", # 368-2 추가 측점 — 계획고 앞
|
||||
|
||||
Reference in New Issue
Block a user