Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)] }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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", " ");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user