From 22e3797ab85b9a34d0c13aa17b4ed5865343417f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:55:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(531-4):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=B0=9B=EC=9D=80=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20[=EC=97=B0=EA=B2=B0]=20=EB=AA=A8=EB=8B=AC=20=C2=B7?= =?UTF-8?q?=20=EC=83=88=20=EC=9E=84=EB=8F=84=20=EC=84=A4=EA=B3=84=EC=86=8D?= =?UTF-8?q?=EB=8F=84=20=E2=86=92=20=EB=8B=A8=EA=B3=84=202=20params=20?= =?UTF-8?q?=C2=B7=20=ED=99=95=EC=A0=95=20=EC=8B=9C=EA=B0=81=20KST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B01 받은 예정노선 [연결] = 모달(새 임도 프로젝트로 · 기존 프로젝트에 연결) → link API · 409 확인 → 다시 · 끝나면 B03 이동 · 확정(KST) 열 - 연결 new 때 예정노선 설계속도(20/30/40)를 단계 2 params options.design_speed_kph 에 · existing 은 안 건드림 - B03 자동설계 체인이 단계 2 params 의 설계속도를 이어 넘김(없으면 None = 그대로 · 브레인 허락) - 받은 예정노선 목록에 confirmed_at · D04 표 확정 시각 KST 표시(서버 UTC 그대로) - 시험: new 연결 설계속도 기록 · existing · 범위 밖 값 안 넣음 Co-Authored-By: Claude Opus 5.5 --- .../B01_Dashboard_UI_AssignedPlans.ts | 27 ++++- B01_Dashboard/B01_Dashboard_UI_PlanLink.ts | 103 ++++++++++++++++++ B03_FileInput/B03_FileInput_Service_Chain.py | 9 +- D01_PlanRegister/D01_PlanRegister_Router.py | 2 +- D04_PlanConfirm/D04_PlanConfirm_Link.py | 28 ++++- D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts | 3 +- common_util/common_util_time_kst.ts | 15 +++ resources/tester/test_531_3_plan_link.py | 47 ++++++++ ui_template/ui_template_locale_de.ts | 4 + 9 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_PlanLink.ts create mode 100644 common_util/common_util_time_kst.ts diff --git a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts index f5ab0a7ad..a5915c0e6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts @@ -1,21 +1,26 @@ /* ============================================================================= * B01_Dashboard_UI_AssignedPlans.ts * 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도. - * 열 = 예정노선 · 연도 · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결] - * [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 자리만(동작은 531-3) + * 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결] + * [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`) * ========================================================================== */ import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, createTag } from "@ui/ui_template_elements"; import { table, text } from "@ui/ui_template_general_blocks"; import { goToWorkflowStage } from "../A00_Common/b_workflow_nav"; +import { kstText } from "../common_util/common_util_time_kst"; import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common"; +import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink"; export interface AssignedPlan { + assignee_id: number; project_id: string; project_name: string; year: number; linked_project_id: string | null; + /** 연도 확정 시각 — 서버 UTC */ + confirmed_at: string | null; update_available: boolean; } @@ -46,15 +51,19 @@ export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement { [ L("DE_New_Plan"), L("DE_Assigned_Year"), + L("D04_Col_Confirmed"), L("DE_Assigned_State"), L("B01_Dashboard_Table_Action"), ], plans.map((plan) => { const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; - const link = createButton({ label: L("DE_Assigned_Link"), variant: "ghost" }); - link.disabled = true; - link.title = L("DE_New_Soon"); + const link = createButton({ + label: L("DE_Assigned_Link"), + variant: "ghost", + onClick: () => + void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`), + }); actions.append( createButton({ label: L("DE_Assigned_View"), @@ -63,7 +72,13 @@ export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement { }), link, ); - return [text(plan.project_name), text(plan.year), linkState(plan), actions]; + return [ + text(plan.project_name), + text(plan.year), + text(kstText(plan.confirmed_at)), + linkState(plan), + actions, + ]; }), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_PlanLink.ts b/B01_Dashboard/B01_Dashboard_UI_PlanLink.ts new file mode 100644 index 000000000..fab1a81ae --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_PlanLink.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * B01_Dashboard_UI_PlanLink.ts + * 받은 예정노선 [연결] 모달(요구서 345 3-5 · PLAN 531-4) — 본보기 `B03_FileInput_UI_PlanImport.ts`. + * ① 새 임도 프로젝트로 ② 기존 프로젝트에 연결(우리 회사 ROAD 프로젝트 고르기) → + * POST /api/dashboard/plan-routes/{assignee}/link · 지울 결과 409 confirm → 확인 창 → 다시 · + * 끝나면 그 임도 프로젝트 B03 으로 이동. + * ========================================================================== */ + +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; +import { + createButton, + createSelectField, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { goToWorkflowStage } from "../A00_Common/b_workflow_nav"; +import { fetchUserProjects } from "./B01_Dashboard_Api_Fetch"; +import { L } from "./B01_Dashboard_UI_Common"; + +type LinkTarget = { mode: "new" } | { mode: "existing"; project_id: string }; + +/** 연결 — 지울 결과가 있으면 한 번 묻고 다시 · 성공 = 임도 프로젝트 id */ +async function postLink(assigneeId: number, target: LinkTarget): Promise { + // 「LAS 없이 설계」는 B03 화면 기억값(localStorage) — 기존 프로젝트만 + const lasFree = + target.mode === "existing" && localStorage.getItem(`b03_las_free_${target.project_id}`) === "1"; + const send = (confirmReplace: boolean): Promise => + fetch(`${API_BASE_URL}/dashboard/plan-routes/${assigneeId}/link`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...target, confirm_replace: confirmReplace, las_free: lasFree }), + }); + showLoadingOverlay(); + try { + let response = await send(false); + let data = (await response.json().catch(() => ({}))) as { + confirm?: string; + message?: string; + detail?: unknown; + project_id?: string; + }; + if (response.status === 409 && data.confirm === "replace_outputs") { + hideLoadingOverlay(); + const agreed = await showConfirmDialog(data.message ?? "", L("DE_Assigned_Link")); + showLoadingOverlay(); + if (!agreed) return null; + response = await send(true); + data = (await response.json().catch(() => ({}))) as typeof data; + } + if (!response.ok || !data.project_id) { + throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`); + } + showToast(L("D04_Import_Done"), "success"); + return data.project_id; + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + return null; + } finally { + hideLoadingOverlay(); + } +} + +export async function openPlanLinkModal(assigneeId: number, title: string): Promise { + // 사용자 프로젝트 목록 = 회사 프로젝트(서버 범위) — 그중 임도만 + const roads = (await fetchUserProjects().catch(() => [])).filter( + (project) => (project.project_kind ?? "ROAD") === "ROAD", + ); + const run = async (target: LinkTarget, close: () => void): Promise => { + close(); + const projectId = await postLink(assigneeId, target); + if (projectId) await goToWorkflowStage(projectId, ROUTES.B03_FILE_INPUT); + }; + openModal({ + title: `${L("DE_Link_Title")} — ${title}`, + closeLabel: "×", + mount: (body, close) => { + const pick = createSelectField({ + label: L("DE_Link_Existing"), + options: roads.map((project) => ({ value: project.id, text: project.name })), + }); + const existing = createButton({ + label: L("DE_Link_Existing"), + variant: "ghost", + onClick: () => void run({ mode: "existing", project_id: pick.select.value }, close), + }); + existing.disabled = !roads.length; + body.append( + createButton({ + label: L("DE_Link_New"), + variant: "filled", + onClick: () => void run({ mode: "new" }, close), + }), + roads.length ? pick.root : el("p", { text: L("DE_Link_NoRoad") }), + existing, + ); + }, + }); +} diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 778377ddf..236ddeaab 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -215,12 +215,18 @@ async def run_auto_design_chain( # 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시). # 비어 있으면 전 구간 — 지금까지와 같다. async with connection.cursor() as cursor: + # 설계속도 = 단계 2 params 에 먼저 들어온 값(예정노선 연결 · 531-4) — 없으면 None. await cursor.execute( - "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", + "SELECT route_start_m, route_end_m," + " (SELECT JSON_VALUE(s.params, '$.options.design_speed_kph')" + " FROM project_workflow_stages s" + " WHERE s.project_id = projects.id AND s.stage_no = 2)" + " FROM projects WHERE id = %s", (str(project_id),), ) range_row = await cursor.fetchone() route_range = (range_row[0], range_row[1]) if range_row else None + seeded_speed = range_row[2] if range_row else None points = _planned_route_points_in_project_crs(project_root, defaults, route_range) if not points: @@ -264,6 +270,7 @@ async def run_auto_design_chain( RoutePoint(**point, order=index) for index, point in enumerate(points[1:-1], start=1) ], + design_speed_kph=int(seeded_speed) if seeded_speed else None, ) solve_result: Any = await solve_route(project_id, request) if isinstance(solve_result, JSONResponse): diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py index 62df091cc..43be603f2 100644 --- a/D01_PlanRegister/D01_PlanRegister_Router.py +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -78,7 +78,7 @@ async def assigned_plan_routes( await cursor.execute( """SELECT a.id AS assignee_id, a.project_id, p.name AS project_name, a.year, l.id AS linked_project_id, l.name AS linked_project_name, - l.source_fingerprint, y.route_json + l.source_fingerprint, y.route_json, y.confirmed_at FROM plan_route_assignees a JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py index 966a53fab..1d4728d9b 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Link.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -47,7 +47,7 @@ from B03_FileInput.B03_FileInput_Schema import UploadedFileResult from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email from common_util.common_util_auth import require_company from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import is_analysis_running +from common_util.common_util_workflow_state import is_analysis_running, merge_stage_params from config.config_db import get_db_pool from D04_PlanConfirm.D04_PlanConfirm_Export import build_export from D04_PlanConfirm.D04_PlanConfirm_Router import year_routes_from_rows @@ -56,6 +56,7 @@ router = APIRouter(prefix="/api/projects", tags=["D04 Plan Link"]) dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D04 Plan Link"]) ROUTE_DIR = "B03_FileInput/input/shp" +DESIGN_SPEEDS = (20, 30, 40) # B05 RouteSolveRequest 가 받는 값 class LinkRequest(BaseModel): @@ -257,6 +258,21 @@ async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) - return str(created["project_id"]) +async def seed_design_speed(project_id: str, speed: Any) -> None: + """새 임도만 — 예정노선 설계속도를 단계 2(B05) params `options.design_speed_kph` 에. + + B05 페이지 설정 · 자동설계 체인(`B03_FileInput_Service_Chain`)이 읽는 자리 · 값만 넣음. + """ + if speed is None or int(speed) not in DESIGN_SPEEDS: + return + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await merge_stage_params( + cursor, project_id, 2, {"options": {"design_speed_kph": int(speed)}} + ) + await connection.commit() + + @dashboard_router.post("/plan-routes/{assignee_id}/link", response_model=None) async def link_plan_route( assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company) @@ -280,11 +296,11 @@ async def link_plan_route( if project["project_kind"] != "ROAD": raise HTTPException(status_code=400, detail="임도설계 프로젝트만 연결할 수 있습니다.") files = await asyncio.to_thread(year_files, assignee) - project_id = ( - str(payload.project_id) - if payload.mode == "existing" - else await _new_road_project(assignee, session) - ) + if payload.mode == "existing": + project_id = str(payload.project_id) + else: + project_id = await _new_road_project(assignee, session) + await seed_design_speed(project_id, assignee["design_speed_kph"]) try: result = await import_route_files( project_id, files, session, payload.confirm_replace, payload.las_free diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index 16c52343b..320eaf749 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -10,6 +10,7 @@ import "./D04_PlanConfirm_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { kstText } from "../common_util/common_util_time_kst"; import { createButton, createTag, @@ -225,7 +226,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { el("td", { text: where(row.start) }), el("td", { text: where(row.end) }), el("td", { text: chain }), - el("td", { text: row.confirmed_at ? row.confirmed_at.slice(0, 16) : "—" }), + el("td", { text: kstText(row.confirmed_at) }), el("td", { children: [...firms, addForm(row.year)] }), ], }); diff --git a/common_util/common_util_time_kst.ts b/common_util/common_util_time_kst.ts new file mode 100644 index 000000000..7615c24b8 --- /dev/null +++ b/common_util/common_util_time_kst.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * common_util_time_kst.ts + * 서버 UTC 시각(DB 세션 time_zone +00:00) → 화면 KST(UTC+9) 글자 — 서버 값은 UTC 그대로. + * ========================================================================== */ + +const KST_OFFSET_MS = 9 * 3600 * 1000; + +/** "YYYY-MM-DD HH:MM:SS" · ISO(UTC) → "YYYY-MM-DD HH:MM" KST · 빈 값 = "—" · 못 읽으면 원글 */ +export function kstText(utc: string | null | undefined): string { + if (!utc) return "—"; + const iso = utc.trim().replace(" ", "T"); + const ms = Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`); + if (Number.isNaN(ms)) return utc; + return new Date(ms + KST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " "); +} diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index 6cc662383..10adc7879 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -89,6 +89,9 @@ class FakeCursor: async def fetchall(self): return self.rows + async def fetchone(self): + return None + async def __aenter__(self): return self @@ -261,6 +264,50 @@ def test_new_mode_creates_road_project(env, monkeypatch): assert made["company_id"] == 5 and len(env["rows"]) == 5 +def _stage2_writes(log): + return [ + args + for sql, args in log + if isinstance(sql, str) and "UPDATE project_workflow_stages" in sql + ] + + +def test_new_mode_seeds_b05_design_speed(env, monkeypatch): + """531-4 — 새 임도 = 예정노선 설계속도가 단계 2 params options 에 · B05 가 그 값을 씀.""" + from B05_Profile.B05_Profile_PageSettings import grade_options_of + + _use(monkeypatch, _assignee(design_speed_kph=30, road_type="main")) + + async def create_project(**kw): + return {"project_id": ROAD_ID} + + monkeypatch.setattr(link, "create_project", create_project) + _link(mode="new", project_id=None) + (args,) = _stage2_writes(env["log"]) + params = json.loads(args[0]) + assert args[1:] == (ROAD_ID, 2) and params == {"options": {"design_speed_kph": 30}} + # 자동설계 체인이 grade_class trunk(기본)로 params 를 다시 써도 이 값을 이어 넘김 + chained = {"options": {"grade_class": "trunk", "design_speed_kph": 30}} + assert grade_options_of("main", chained).design_speed_kph == 30 + chain = (ROOT / "B03_FileInput/B03_FileInput_Service_Chain.py").read_text("utf-8") + assert "'$.options.design_speed_kph'" in chain and "design_speed_kph=int(seeded_speed)" in chain + + +def test_existing_and_odd_speed_leave_stage_params(env, monkeypatch): + """기존 프로젝트는 사용자 값 보존 · 20/30/40 밖 값은 안 넣음.""" + _use(monkeypatch, _assignee(design_speed_kph=30), ROAD) + _link() + assert _stage2_writes(env["log"]) == [] + + async def create_project(**kw): + return {"project_id": ROAD_ID} + + monkeypatch.setattr(link, "create_project", create_project) + _use(monkeypatch, _assignee(design_speed_kph=25)) + _link(mode="new", project_id=None) + assert _stage2_writes(env["log"]) == [] + + def test_fingerprint_update_and_revert(): linked = link.route_fingerprint(_route(P2026)) moved = [p[:] for p in P2026] diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 2703fd996..a08abd22d 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -110,6 +110,10 @@ export const ui_locales_de = { DE_Assigned_Update: ["업데이트 있음", "Update available"], DE_Assigned_View: ["보기", "View"], DE_Assigned_Link: ["연결", "Link"], + DE_Link_Title: ["예정노선 연결", "Link planned route"], + DE_Link_New: ["새 임도 프로젝트로", "As a new road project"], + DE_Link_Existing: ["기존 프로젝트에 연결", "Link to an existing project"], + DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."], /* --- D02 기준 지정(529-3) --- */ D02_Points_Title: ["연도 지점", "Yearly Points"],