auto: 2026-10-26 03:18 (ESD_LAPTOP)

This commit is contained in:
2026-10-26 03:18:32 +09:00
parent 7d08c04cb5
commit 08fc324e99
9 changed files with 89 additions and 13 deletions
+4 -1
View File
@@ -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}
+11 -2
View File
@@ -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) {
+2
View File
@@ -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 }>;
+6 -1
View File
@@ -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");
+12 -5
View File
@@ -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
),
}
+9 -2
View File
@@ -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");
+7 -1
View File
@@ -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:
+21 -1
View File
@@ -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