auto: 2026-10-26 03:18 (ESD_LAPTOP)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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<void> {
|
||||
let view: PointsView | null = null;
|
||||
let points: Array<LonLat | null> = [];
|
||||
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<void> {
|
||||
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<void> {
|
||||
/** 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<Array<[number, number]>> = [];
|
||||
const colors: string[] = [];
|
||||
@@ -142,6 +146,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
},
|
||||
});
|
||||
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<void> {
|
||||
left.addEventListener("ui-step-nav", (event) => {
|
||||
if ((event as CustomEvent<number>).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<PointsView>(`/projects/${projectId}/plan/points`));
|
||||
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
|
||||
points?: Array<LonLat | null>;
|
||||
} | 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) {
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let nodes: Vertex[] = [];
|
||||
let choices: Record<number, string> = {};
|
||||
let startM = 0;
|
||||
let targetM: number | null = null; // 등록 목표 끝 누가
|
||||
let analysis: PlanAnalysisView | null = null;
|
||||
let surfaces = new Map<number, YearSurface>();
|
||||
let rows: ChainRow[] = [];
|
||||
@@ -251,9 +252,24 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
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<void> {
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel });
|
||||
|
||||
let summary: Summary = { name: "", years: [], assignees: [] };
|
||||
const draft = (): Assignee[] | null => readState<Assignee[]>("d04-assignees", projectId);
|
||||
let editable = false; // 산림청만 고침
|
||||
const draft = (): Assignee[] | null =>
|
||||
editable ? readState<Assignee[]>("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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user