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:
2026-10-18 21:37:54 +09:00
co-authored by Claude Opus 5.5
parent d0779af302
commit e5dcb695e6
24 changed files with 1237 additions and 31 deletions
+2
View File
@@ -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" },
+10
View File
@@ -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 칸(저장 노선에
+6 -1
View File
@@ -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"),
+24
View File
@@ -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);
}
+127
View File
@@ -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
+1
View File
@@ -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,
+85 -3
View File
@@ -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,
+2
View File
@@ -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)
+46
View File
@@ -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):
"""계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다)."""
+6 -3
View File
@@ -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) : {}),
};
}
+54 -22
View File
@@ -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))
+31
View File
@@ -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 추가 측점 — 계획고 앞