Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-25 20:56:58 +09:00
9 changed files with 223 additions and 15 deletions
@@ -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,
);
+103
View File
@@ -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<string | null> {
// 「LAS 없이 설계」는 B03 화면 기억값(localStorage) — 기존 프로젝트만
const lasFree =
target.mode === "existing" && localStorage.getItem(`b03_las_free_${target.project_id}`) === "1";
const send = (confirmReplace: boolean): Promise<Response> =>
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<void> {
// 사용자 프로젝트 목록 = 회사 프로젝트(서버 범위) — 그중 임도만
const roads = (await fetchUserProjects().catch(() => [])).filter(
(project) => (project.project_kind ?? "ROAD") === "ROAD",
);
const run = async (target: LinkTarget, close: () => void): Promise<void> => {
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,
);
},
});
}
+8 -1
View File
@@ -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):
+1 -1
View File
@@ -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
+22 -6
View File
@@ -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
+2 -1
View File
@@ -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<void> {
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)] }),
],
});
+15
View File
@@ -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", " ");
}
+47
View File
@@ -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]
+4
View File
@@ -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"],