From 08fc324e997d98b0e1c4ab5b589a36158b9ec3f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 03:18:32 +0900 Subject: [PATCH] auto: 2026-10-26 03:18 (ESD_LAPTOP) --- D01_PlanRegister/D01_PlanRegister_Router.py | 5 ++++- D02_PlanBase/D02_PlanBase_UI_Page.ts | 13 ++++++++++-- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 2 ++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 7 ++++++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 17 +++++++++++++++ D04_PlanConfirm/D04_PlanConfirm_Link.py | 17 ++++++++++----- D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts | 11 ++++++++-- resources/tester/test_530_2_plan_router.py | 8 ++++++- resources/tester/test_531_3_plan_link.py | 22 +++++++++++++++++++- 9 files changed, 89 insertions(+), 13 deletions(-) diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py index 43be603f2..c83868057 100644 --- a/D01_PlanRegister/D01_PlanRegister_Router.py +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -92,5 +92,8 @@ async def assigned_plan_routes( routes = [] for row in rows: route_json, fingerprint = row.pop("route_json"), row.pop("source_fingerprint") - routes.append({**row, "update_available": update_available(route_json, fingerprint)}) + confirmed = row["confirmed_at"] is not None + routes.append( + {**row, "update_available": update_available(route_json, fingerprint, confirmed)} + ) return {"status": "success", "routes": routes} diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 65e8533e9..16749229c 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -4,6 +4,7 @@ * 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로. * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) * · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장). + * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03. * ========================================================================== */ import "./D02_PlanBase_UI_Style.css"; @@ -19,6 +20,7 @@ import { createPanelCard } from "@ui/ui_template_collapsible"; import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { t } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; interface LonLat { @@ -79,6 +81,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { let view: PointsView | null = null; let points: Array = []; let active: number | null = null; // 지도 누름이 놓을 지점 + let editable = false; // 산림청만 고침 const years = (): number[] => (view ? view.years.map((y) => y.year) : []); const pointName = (i: number): string => { @@ -99,6 +102,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { center: () => KOREA_CENTER, onMove: (key, lon, lat) => place(Number(key), { lon, lat }), onClick: (lon, lat) => { + if (!editable) return; if (active === null) return showToast(t("D02_Pick_Point"), "info"); place(active, { lon, lat }); }, @@ -115,7 +119,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { /** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */ function drawMap(focus = ""): void { const pins: MapPin[] = points.flatMap((p, i) => - p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: true }] : [], + p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [], ); const lines: Array> = []; const colors: string[] = []; @@ -142,6 +146,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { lat.placeholder = t("D02_Lat"); lon.value = p ? String(p.lon) : ""; lat.value = p ? String(p.lat) : ""; + lon.disabled = lat.disabled = !editable; lon.dataset.point = lat.dataset.point = String(i); lon.dataset.axis = "lon"; lat.dataset.axis = "lat"; @@ -164,6 +169,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { }, }); pick.title = pick.ariaLabel = t("D02_Pick_Point"); + pick.hidden = !editable; const row = el("div", { className: `d02-points__row${active === i ? " is-active" : ""}`, children: [ @@ -227,15 +233,18 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).detail !== 1) return; event.stopPropagation(); + if (!editable) return navigateTo(ROUTES.D03_PLAN_ANALYSIS); void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS)); }); try { + editable = !!(await fetchSessionUser())?.is_forest_service; + save.hidden = !editable; adopt(await request(`/projects/${projectId}/plan/points`)); const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { points?: Array; } | null; - if (cached?.points?.length === points.length) points = cached.points; + if (editable && cached?.points?.length === points.length) points = cached.points; active = firstEmpty(); paint(); } catch (error) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index f3255c199..99284c25b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -33,6 +33,8 @@ export interface PlanAnalysisView { chain: ChainRow[] | null; /** 등록 노선 시작 누가(m). */ start_chainage_m: number; + /** 등록 노선 종료 누가(계획 목표 · m) — 없으면 null. */ + end_chainage_target_m?: number | null; } export type PlanPoints = Array<{ year: number; start: Vertex; end: Vertex }>; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index f4a424e08..5c5b76a73 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -83,7 +83,8 @@ async def _plan_inputs( stored = await get_project_storage_relative_path(connection, project_id) async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - "SELECT road_type, design_speed_kph, route_start_m FROM projects WHERE id = %s", + "SELECT road_type, design_speed_kph, route_start_m, route_end_m FROM projects" + " WHERE id = %s", (str(project_id),), ) project = await cursor.fetchone() @@ -256,6 +257,10 @@ async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str "chain": rows, "line": line, "start_chainage_m": float(project["route_start_m"] or 0.0), + # 등록 목표 끝 누가(요구서 3-1) — 화면이 이은 끝 누가와 차이를 보임 + "end_chainage_target_m": ( + float(project["route_end_m"]) if project["route_end_m"] is not None else None + ), } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index c2d77ff32..87741a4b8 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -133,6 +133,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let nodes: Vertex[] = []; let choices: Record = {}; let startM = 0; + let targetM: number | null = null; // 등록 목표 끝 누가 let analysis: PlanAnalysisView | null = null; let surfaces = new Map(); let rows: ChainRow[] = []; @@ -251,9 +252,24 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } return box; }), + ...targetLine(), ); } + /** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */ + function targetLine(): HTMLElement[] { + const last = rows[rows.length - 1]; + if (targetM === null || !last || rows.some((row) => !row.mode)) return []; + const diff = last.end_chainage_m - targetM; + const line = document.createElement("div"); + line.className = "d03-plan__year-head d03-plan__target"; + line.textContent = + `끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` + + `목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` + + `차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`; + return [line]; + } + function onDrag(index: number, point: Vertex): void { nodes = nodes.map((node, i) => (i === index ? point : node)); map.update({ nodes, reach: reach() }); @@ -340,6 +356,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { try { analysis = await fetchPlanAnalysis(projectId, sentPoints()); startM = analysis.start_chainage_m; + targetM = analysis.end_chainage_target_m ?? null; } catch (error) { analysis = null; showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error"); diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py index 1d4728d9b..ed03bac9f 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Link.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -81,10 +81,15 @@ def route_fingerprint(route_json: Any) -> str | None: return hashlib.sha1(json.dumps(points).encode()).hexdigest() -def update_available(route_json: Any, source_fingerprint: str | None) -> bool: - """연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음.""" +def update_available( + route_json: Any, source_fingerprint: str | None, confirmed: bool = True +) -> bool: + """연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음. + + 확정이 풀린 연도(D02 지점 바뀜 뒤 다시 [저장]만)도 알리지 않음 — 가져오기가 409 라서. + """ now = route_fingerprint(route_json) - return bool(source_fingerprint) and now is not None and now != source_fingerprint + return confirmed and bool(source_fingerprint) and now is not None and now != source_fingerprint async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None: @@ -319,7 +324,7 @@ async def plan_source(project_id: UUID) -> dict[str, Any]: """임도 프로젝트가 이어 받은 예정노선(B03 머리 띠) — 없으면 linked False.""" row = await _fetch_one( "SELECT p.source_fingerprint, s.name AS plan_name, a.id AS assignee_id, a.year," - " y.route_json" + " y.route_json, y.confirmed_at" " FROM projects p" " JOIN projects s ON s.id = p.source_project_id AND s.project_kind = 'PLAN'" " JOIN plan_route_assignees a ON a.linked_project_id = p.id" @@ -335,5 +340,7 @@ async def plan_source(project_id: UUID) -> dict[str, Any]: "assignee_id": row["assignee_id"], "plan_name": row["plan_name"], "year": row["year"], - "update_available": update_available(row["route_json"], row["source_fingerprint"]), + "update_available": update_available( + row["route_json"], row["source_fingerprint"], row["confirmed_at"] is not None + ), } diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index 320eaf749..c67dd5a28 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -6,6 +6,7 @@ * 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장]. * 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤) → 캐시 비움 · * [확정] = (캐시 있으면 저장 먼저) POST …/plan/finalize → 잠금판 · [내보내기] = GET …/plan/export zip. + * 산림청이 아니면(지정 회사 = 보기만) 업체 고침 · 연도 고르기 · [확정] · [저장] 없음 · [내보내기]만. * ========================================================================== */ import "./D04_PlanConfirm_UI_Style.css"; @@ -24,6 +25,7 @@ import { createPanelCard } from "@ui/ui_template_collapsible"; import { t } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { searchCompanies } from "../A07_Register/A07_Register_Api_Fetch"; interface EndPoint { @@ -95,7 +97,9 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel }); let summary: Summary = { name: "", years: [], assignees: [] }; - const draft = (): Assignee[] | null => readState("d04-assignees", projectId); + let editable = false; // 산림청만 고침 + const draft = (): Assignee[] | null => + editable ? readState("d04-assignees", projectId) : null; const current = (): Assignee[] => draft() ?? summary.assignees; const edit = (next: Assignee[]): void => { writeState("d04-assignees", next, projectId); @@ -194,6 +198,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { item.notified_at ? createTag(t("D04_Sent"), "success") : null, item.linked_project_id ? createTag(t("D04_Linked"), "success") : null, ].filter((tag): tag is HTMLSpanElement => tag !== null); + remove.hidden = !editable; return el("div", { className: "d04-confirm__firm", children: [el("span", { text: label }), ...tags, remove], @@ -227,7 +232,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { el("td", { text: where(row.end) }), el("td", { text: chain }), el("td", { text: kstText(row.confirmed_at) }), - el("td", { children: [...firms, addForm(row.year)] }), + el("td", { children: editable ? [...firms, addForm(row.year)] : firms }), ], }); }); @@ -317,6 +322,8 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { actions.append(exportButton, finalizeButton, saveButton); try { + editable = !!(await fetchSessionUser())?.is_forest_service; + pickCard.hidden = finalizeButton.hidden = saveButton.hidden = !editable; await load(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 2e82813b0..276291a45 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -86,7 +86,12 @@ def client(tmp_path, monkeypatch): "routes": routes, }, ) - project = {"road_type": "work", "design_speed_kph": None, "route_start_m": 1000.0} + project = { + "road_type": "work", + "design_speed_kph": None, + "route_start_m": 1000.0, + "route_end_m": 2500.0, + } async def inputs(project_id, points=None): return tmp_path, project, router_mod._override(segments, points) if points else segments @@ -119,6 +124,7 @@ def test_get_chains_saved_choice(client) -> None: assert chain[1]["start_chainage_m"] == chain[0]["end_chainage_m"] == 1530.0 assert chain[1]["end_chainage_m"] == 2170.0 assert body["line"] == [[0, 0], [100, 200], [300, 400], [600, 500], [900, 400]] + assert body["end_chainage_target_m"] == 2500.0 # 등록 목표 — 화면이 차이를 보임 def test_put_saves_server_routes(client) -> None: diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index 10adc7879..4f4869270 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -318,6 +318,10 @@ def test_fingerprint_update_and_revert(): assert link.update_available(json.dumps(_route(moved)), linked) is False assert link.update_available(None, linked) is False # 지점 바뀌어 노선 빔 → 알림 없음 assert link.update_available(_route(P2026), None) is False # 연결 안 함 + # 확정 풀림(D02 지점 바뀜 뒤 다시 [저장]만) → 가져오기 409 라 알리지 않음 + assert link.update_available(json.dumps(_route(moved)), linked, False) is False + moved[1][0] += 0.5 + assert link.update_available(json.dumps(_route(moved)), linked, False) is False def test_assigned_list_flags_update(monkeypatch): @@ -333,6 +337,18 @@ def test_assigned_list_flags_update(monkeypatch): "linked_project_name": "임도", "source_fingerprint": linked, "route_json": json.dumps(_route(moved)), + "confirmed_at": "2026-10-25 12:00:00", + }, + { + "assignee_id": 9, + "project_id": PLAN_ID, + "project_name": "시험", + "year": 2028, + "linked_project_id": ROAD_ID, + "linked_project_name": "임도", + "source_fingerprint": linked, + "route_json": json.dumps(_route(moved)), + "confirmed_at": None, }, { "assignee_id": 8, @@ -343,12 +359,13 @@ def test_assigned_list_flags_update(monkeypatch): "linked_project_name": None, "source_fingerprint": None, "route_json": json.dumps(_route(P2027)), + "confirmed_at": "2026-10-25 12:00:00", }, ] log = [] monkeypatch.setattr(d01, "get_db_pool", lambda: FakeConn(log, rows)) out = asyncio.run(d01.assigned_plan_routes(SESSION))["routes"] - assert [r["update_available"] for r in out] == [True, False] + assert [r["update_available"] for r in out] == [True, False, False] assert "route_json" not in out[0] and out[0]["assignee_id"] == 7 assert "confirmed_at IS NOT NULL" in log[0][0] @@ -361,6 +378,7 @@ def test_plan_source(monkeypatch): "assignee_id": 7, "year": 2026, "route_json": json.dumps(_route(P2026)), + "confirmed_at": "2026-10-25 12:00:00", } async def one(sql, args): @@ -371,6 +389,8 @@ def test_plan_source(monkeypatch): assert out["linked"] and out["update_available"] is False and out["year"] == 2026 row["route_json"] = json.dumps(_route([[x, y + 2.0] for x, y in P2026])) assert asyncio.run(link.plan_source(ROAD_ID))["update_available"] is True + row["confirmed_at"] = None # 확정 풀림 → 알림 없음 + assert asyncio.run(link.plan_source(ROAD_ID))["update_available"] is False async def none(sql, args): return None