diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py index c83868057..9919509ec 100644 --- a/D01_PlanRegister/D01_PlanRegister_Router.py +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -1,4 +1,8 @@ -"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).""" +"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2). + +화면은 이름 · 시작 연도 · 사업 지역만 보냄(요구서 389 3장 1 · 3) — 끝 연도 = 시작 · 임도 종류 없음 += 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로). +""" from typing import Any @@ -21,7 +25,7 @@ class CreatePlanRequest(BaseModel): project_year: int = Field(..., ge=2000, le=2100) project_year_end: int | None = Field(default=None, ge=2000, le=2100) region: str = Field(..., min_length=1, max_length=100) - road_type: str = Field(..., pattern="^(main|fire|work)$") + road_type: str | None = Field(default=None, pattern="^(main|fire|work)$") design_speed_kph: int | None = None start_chainage_m: float | None = Field(default=None, ge=0) end_chainage_m: float | None = Field(default=None, ge=0) diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 38345f802..06ddb0822 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -1,51 +1,25 @@ /* ============================================================================= * D01_PlanRegister_UI_Page.ts - * D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음. - * 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도 - * (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름 - * 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리. + * D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음. + * 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」) + * · 사업연도 끝 · 임도 종류 · 설계속도 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본). * [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로. * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; -import { - createButton, - createCard, - createInputField, - createSelectField, -} from "@ui/ui_template_elements"; +import { createButton, createCard, createInputField } from "@ui/ui_template_elements"; import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout"; import { createHelpButton } from "@ui/ui_template_help"; -import { createText } from "@ui/ui_template_text"; import { t } from "@ui/ui_template_locale"; -import { stationLabel } from "../common_util/common_util_station"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; -import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria"; - -/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */ -const GRADE_CLASS: Record = { main: "trunk", fire: "fire", work: "work" }; - -/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */ -const STATION_INTERVAL_M = 20; - -const numberOrNull = (text: string): number | null => - text.trim() === "" ? null : Number.parseFloat(text); export function renderD01PlanRegister(root: HTMLElement): void { - const year = new Date().getFullYear(); const yearStart = createInputField({ label: t("DE_Reg_YearStart"), type: "number", - value: String(year), - min: 2000, - max: 2100, - }); - const yearEnd = createInputField({ - label: t("DE_Reg_YearEnd"), - type: "number", - value: String(year), + value: String(new Date().getFullYear()), min: 2000, max: 2100, }); @@ -54,75 +28,29 @@ export function renderD01PlanRegister(root: HTMLElement): void { placeholder: t("B02_Proj_Field_Region_Placeholder"), required: true, }); - const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") }); - kind.input.readOnly = true; - const roadType = createSelectField({ - label: t("B02_Proj_Field_RoadType"), - options: [ - { value: "main", text: t("B02_Proj_RoadType_Main") }, - { value: "fire", text: t("B02_Proj_RoadType_Fire") }, - { value: "work", text: t("B02_Proj_RoadType_Work") }, - ], - }); - const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] }); const name = createInputField({ label: t("DE_Reg_Name"), required: true }); - const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 }); - const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 }); - // 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로) - for (const field of [startM, endM]) { - const shown = createText("", "muted"); - shown.dataset.station = ""; - field.root.insertBefore(shown, field.root.lastElementChild); - field.input.addEventListener("input", () => { - const value = numberOrNull(field.input.value); - shown.textContent = - value !== null && Number.isFinite(value) && value >= 0 - ? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}` - : ""; - }); - } - - // 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20) - const syncSpeeds = (): void => { - const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20]; - speed.setOptions( - choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })), - String(Math.min(...choices)), - ); - }; // 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움) const composer = createNameComposer(); - const years = (): string => { - const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()]; - return b && b !== a ? `${a}~${b}` : a; - }; const syncName = (): void => { name.input.value = composer.update({ - year: years(), + year: yearStart.input.value.trim(), region: region.input.value, - type: roadType.select.selectedOptions[0]?.textContent ?? "", + type: t("DE_Reg_KindPlan"), }); }; name.input.addEventListener("input", () => composer.edit(name.input.value)); - for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName); - roadType.select.addEventListener("change", () => (syncSpeeds(), syncName())); - syncSpeeds(); + for (const field of [yearStart, region]) field.input.addEventListener("input", syncName); syncName(); const submit = (): void => { - const fields = [yearStart, yearEnd, region, name, startM, endM]; + const fields = [yearStart, region, name]; fields.forEach((field) => field.setError()); const y0 = Number.parseInt(yearStart.input.value, 10); - const y1 = Number.parseInt(yearEnd.input.value, 10); - const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)]; const errors: Array<[(typeof fields)[number], string | false]> = [ [yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")], - [yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")], [region, !region.input.value.trim() && t("B02_Proj_Error_Required")], [name, !name.input.value.trim() && t("B02_Proj_Error_Required")], - [startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")], - [endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")], ]; const bad = errors.filter(([, message]) => message); bad.forEach(([field, message]) => field.setError(message as string)); @@ -132,20 +60,13 @@ export function renderD01PlanRegister(root: HTMLElement): void { { project_name: name.input.value.trim(), project_year: y0, - project_year_end: y1, region: region.input.value.trim(), - road_type: roadType.select.value, - design_speed_kph: Number(speed.select.value), - start_chainage_m: s, - end_chainage_m: e, }, ROUTES.D02_PLAN_BASE, ); }; - const grid = createFormGrid( - [yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root), - ); + const grid = createFormGrid([yearStart, region, name].map((f) => f.root)); const actions = document.createElement("div"); actions.className = "ui-general-block__actions ui-general-block__actions--end"; actions.append( diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py index ed03bac9f..e6ae64914 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Link.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import hashlib import json +import re import shutil import tempfile from pathlib import Path @@ -250,10 +251,12 @@ async def import_route_files( async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str: """예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수).""" body = _parsed(assignee["route_json"]) or {} + # 「2026 홍천 노선 계획」 꼴 = 앞 연도를 그 연도로 갈아 끼움(연도 두 번 안 붙음) · 그 밖 = 뒤에 연도 + name, dated = re.subn(r"^\d{4}(?:~\d{4})?(?=\s)", str(assignee["year"]), assignee["name"]) created = await create_project( user_id=int(session["user_id"]), company_id=int(session["company_id"]), - name=f"{assignee['name']} {assignee['year']}", + name=name if dated else f"{assignee['name']} {assignee['year']}", region=assignee["region"] or "", road_type=assignee["road_type"] or "work", project_year=int(assignee["year"]), diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index e0a3dbbff..ef7c04817 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) - * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage + * · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음). * ========================================================================== */ import "./E04_Stab3D_UI_Style.css"; @@ -44,6 +45,7 @@ interface SavedSummary { f_min: number; volume: number; method: string; + condition: string; stale: boolean; } @@ -67,7 +69,10 @@ interface Progress { } const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; -const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const CONDITIONS = ["dry", "wet"] as const; +const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const sectionKey = (start: number, end: number, condition: string): string => + `${rangeKey(start, end)}_${condition}`; async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -218,16 +223,11 @@ export function renderE04Stab3D(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E04_Analyze"), variant: "filled", - onClick: () => void analyze(false), + onClick: () => void analyze(), }); - const reanalyzeButton = createButton({ - label: t("E04_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), - }); - analyzeButton.disabled = reanalyzeButton.disabled = true; + analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(reanalyzeButton, analyzeButton); + actions.append(analyzeButton); const readOnly = note(t("E04_ReadOnly"), "e04-hint"); readOnly.hidden = true; panel.append( @@ -252,40 +252,46 @@ export function renderE04Stab3D(root: HTMLElement): void { let shownKey = ""; settings.onChange(() => { remember(); - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); map.setRange(start_m, end_m); paintSections(); - if (shownKey !== sectionKey(start_m, end_m)) void openResult(); + if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintSections(): void { if (!overview) return; - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); const savedOf = new Map(overview.saved.map((s) => [s.key, s])); - const custom = overview.saved.filter( - (s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key), - ); - const items = [ - ...overview.sections, - ...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })), - ]; + const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x])); + for (const s of overview.saved) { + const key = rangeKey(s.start_m, s.end_m); + if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m }); + } sectionList.replaceChildren( - ...items.map((section) => { - const key = sectionKey(section.start_m, section.end_m); - const saved = savedOf.get(key); + ...[...items].map(([key, section]) => { + const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`)); + const saved = savedOf.get(`${key}_${condition}`); const row = createListRow({ label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`, - trailing: saved - ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` - : "", + trailing: byCondition + .flatMap((x) => + x + ? [ + `${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${ + x.stale ? ` ${t("E04_Stale")}` : "" + }`, + ] + : [], + ) + .join(" · "), stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent", - active: key === sectionKey(start_m, end_m), + active: key === rangeKey(start_m, end_m), onClick: () => settings.setRange(section.start_m, section.end_m), }); row.classList.add("e04-section"); @@ -364,15 +370,19 @@ export function renderE04Stab3D(root: HTMLElement): void { resultBody.replaceChildren(list, ...notes); } + const currentKey = (): string => { + const { start_m, end_m, condition } = settings.read(); + return sectionKey(start_m, end_m, condition); + }; + async function openResult(): Promise { - const { start_m, end_m } = settings.read(); - const key = sectionKey(start_m, end_m); + const key = currentKey(); shownKey = key; const saved = overview?.saved.some((s) => s.key === key); result = saved ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) : null; - if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 + if (key !== currentKey()) return; // 그새 바뀜 map.setResult(result); if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); paintResult(); @@ -405,13 +415,13 @@ export function renderE04Stab3D(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { const values = settings.read(); const { start_m, end_m, condition, ...search } = values; try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { method: "POST", - body: JSON.stringify({ start_m, end_m, condition, search, force }), + body: JSON.stringify({ start_m, end_m, condition, search }), }); if (body.status === "cached") { showToast(t("E04_Cached"), "info"); @@ -446,7 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ...ov.defaults, start_m: first?.start_m ?? 0, end_m: first?.end_m ?? 100, - condition: "dry", + condition: ov.saved[0]?.condition ?? "dry", }, ); map.setRoute(ov.line, ov.stations); @@ -455,6 +465,12 @@ export function renderE04Stab3D(root: HTMLElement): void { paintSections(); stations2d = await fetch2d(projectId); await openResult(); + // 캐시가 없으면 저장된 결과의 탐색 설정을 되살림 + const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기) + if (!cached && opened?.settings) { + settings.write(opened.settings as Partial); + remember(); + } const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); if (p?.running) void poll(); })(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 215201303..e27573cfd 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -98,8 +98,8 @@ export function buildSettingsCards() { variant: "ghost", size: "sm", onClick: () => { - const range = { start_m: read().start_m, end_m: read().end_m }; - write({ ...read(), ...defaults, ...range, condition: "dry" }); + const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로 + write({ ...read(), ...defaults, start_m, end_m, condition }); changed(); }, }), diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index 62968e798..9f76ff31f 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -124,6 +124,8 @@ def test_d01_plan_created(monkeypatch): assert seen["design_speed_kph"] == 40 asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN)) assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만 + asyncio.run(d01.post_plan_project(None, _plan(road_type=None), ADMIN)) + assert seen["road_type"] is None and seen["design_speed_kph"] == 20 # 560-1 임도 종류 안 받음 with pytest.raises(HTTPException) as e: asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST)) assert e.value.status_code == 400 diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index 20097ac81..746c07746 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -264,6 +264,20 @@ def test_new_mode_creates_road_project(env, monkeypatch): assert made["company_id"] == 5 and len(env["rows"]) == 5 +def test_new_mode_name_year_once(env, monkeypatch): + """560-1 — 「<시작 연도> <지역> 노선 계획」 이름 = 앞 연도를 그 연도로(두 번 안 붙음).""" + made = {} + + async def create_project(**kw): + made.update(kw) + return {"project_id": ROAD_ID} + + monkeypatch.setattr(link, "create_project", create_project) + _use(monkeypatch, _assignee(name="2025 홍천 노선 계획")) + _link(mode="new", project_id=None) + assert made["name"] == "2026 홍천 노선 계획" + + def _stage2_writes(log): return [ args diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 2a79b490f..0de454c09 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -16,13 +16,6 @@ def _no_hex(path: str) -> None: assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색" -def test_d01_station_label_is_text_part(): - ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") - assert "style.cssText" not in ts - assert 'createText("", "muted")' in ts - assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음 - - def test_d02_parts(): ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css") diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index 49c898651..73d26b435 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -39,7 +39,6 @@ export const ui_locales_e04 = { E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"], E04_Defaults: ["기본값", "Defaults"], E04_Analyze: ["해석", "Analyze"], - E04_Reanalyze: ["다시 해석", "Re-analyze"], E04_Cancel: ["끊기", "Stop"], E04_Running: ["해석 중", "Analyzing"], E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."], @@ -48,6 +47,8 @@ export const ui_locales_e04 = { E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."], E04_Saved: ["결과 있음", "Saved"], E04_Stale: ["옛 결과", "Outdated"], + E04_Short_dry: ["건기", "Dry"], + E04_Short_wet: ["우기", "Wet"], /* --- 가운데 지도 · 3D --- */ E04_View_Plan: ["평면 지도", "Plan map"],