From 70c0784b18fea2f873dafa85b2aeab062f314cde Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 14 Oct 2026 07:08:32 +0200 Subject: [PATCH] feat(site-map): surveyor CSV import marks matched pegs checked --- .../sites/[id]/survey-imports/route.test.ts | 29 +++++++++++++++ .../api/sites/[id]/survey-imports/route.ts | 35 +++++++++++++++++++ src/lib/site-map/server.ts | 11 ++++++ src/lib/site-map/survey.test.ts | 21 +++++++++++ src/lib/site-map/survey.ts | 25 +++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 src/app/api/sites/[id]/survey-imports/route.test.ts create mode 100644 src/app/api/sites/[id]/survey-imports/route.ts create mode 100644 src/lib/site-map/survey.test.ts create mode 100644 src/lib/site-map/survey.ts diff --git a/src/app/api/sites/[id]/survey-imports/route.test.ts b/src/app/api/sites/[id]/survey-imports/route.test.ts new file mode 100644 index 0000000..e045f85 --- /dev/null +++ b/src/app/api/sites/[id]/survey-imports/route.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +const created = vi.hoisted(() => ({ imports: [] as unknown[], events: [] as unknown[] })); +vi.mock("@/lib/auth/session", () => ({ getCurrentUser: vi.fn(async () => ({ id: "u1", email: "", name: "Sam", role: "site_user", siteIds: ["hills"], mustChangePassword: false })) })); +vi.mock("@/lib/site-map/server", () => ({ + getActiveModel: vi.fn(async () => ({ id: "m1", files: [] })), + loadModelPegs: vi.fn(async () => [{ name: "BJ1", e: 0, n: 0, z: 1450, Y: 65000, X: 2860000 }]), + loadProgressState: vi.fn(async () => ({})), +})); +vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({ + collection: () => ({ create: async (row: unknown) => { created.imports.push(row); return { id: "imp1" }; } }), + createBatch: () => ({ collection: () => ({ create: (row: unknown) => created.events.push(row) }), send: async () => [] }), +})) })); +import { POST } from "./route"; + +describe("survey import", () => { + it("stores the file and marks matched pegs checked", async () => { + const form = new FormData(); + form.append("file", new File(["BJ1,65000.01,2860000,1450\n"], "pegs.csv", { type: "text/csv" })); + const r = await POST(new Request("http://x", { method: "POST", body: form }), { params: Promise.resolve({ id: "hills" }) }); + expect(r.status).toBe(200); + expect((await r.json()).matched).toBe(1); + expect(created.events).toEqual([expect.objectContaining({ key: "peg:BJ1", value: 2, source: "survey_import" })]); + }); + it("refuses files over 1 MB", async () => { + const form = new FormData(); + form.append("file", new File(["x".repeat(1_048_577)], "big.csv", { type: "text/csv" })); + expect((await POST(new Request("http://x", { method: "POST", body: form }), { params: Promise.resolve({ id: "hills" }) })).status).toBe(400); + }); +}); diff --git a/src/app/api/sites/[id]/survey-imports/route.ts b/src/app/api/sites/[id]/survey-imports/route.ts new file mode 100644 index 0000000..0b18bda --- /dev/null +++ b/src/app/api/sites/[id]/survey-imports/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import { requireSiteMapUser } from "@/lib/site-map/auth"; +import { getActiveModel, loadModelPegs, loadProgressState } from "@/lib/site-map/server"; +import { matchSurvey, parseSurveyCsv } from "@/lib/site-map/survey"; +import { pegKey } from "@/lib/site-map/keys"; + +const MAX_BYTES = 1_048_576; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await requireSiteMapUser(id, "edit"); + if (user instanceof Response) return user; + const form = await request.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) return NextResponse.json({ error: "validation", field: "file", message: "Attach a CSV file." }, { status: 400 }); + if (file.size > MAX_BYTES) return NextResponse.json({ error: "validation", field: "file", message: "The file must be 1 MB or smaller." }, { status: 400 }); + const pb = await pbAdmin(); + const model = await getActiveModel(pb, id); + if (!model) return NextResponse.json({ error: "no_model" }, { status: 404 }); + const { matched, unmatched } = matchSurvey(parseSurveyCsv(await file.text()), await loadModelPegs(pb, model)); + + const upload = new FormData(); + for (const [k, v] of Object.entries({ site: id, model: model.id, matched: String(matched.length), unmatched: JSON.stringify(unmatched), user: user.id, user_name: user.name })) upload.append(k, v); + upload.append("file", file, file.name); + await pb.collection("site_survey_imports").create(upload); + + const pegs = [...new Set(matched.map((m) => m.peg))]; + if (pegs.length) { + const batch = pb.createBatch(); + for (const peg of pegs) batch.collection("site_progress_events").create({ site: id, model: model.id, key: pegKey(peg), value: 2, user: user.id, user_name: user.name, source: "survey_import" }); + await batch.send(); + } + return NextResponse.json({ matched: matched.length, unmatched, state: await loadProgressState(pb, id) }); +} diff --git a/src/lib/site-map/server.ts b/src/lib/site-map/server.ts index 47cdf98..5ee8863 100644 --- a/src/lib/site-map/server.ts +++ b/src/lib/site-map/server.ts @@ -51,3 +51,14 @@ export async function loadProgressState(pb: PocketBase, siteId: string): Promise }); return latestState(rows); } +const pegCache = new Map(); +export async function loadModelPegs(pb: PocketBase, model: SiteModelRecord) { + let pegs = pegCache.get(model.id); + if (!pegs) { + const r = await fetchModelFile(pb, model, "bushveld"); + if (!r.ok) throw new Error(`model file bushveld.json: HTTP ${r.status}`); + pegs = (await r.json()).pegs; + pegCache.set(model.id, pegs!); + } + return pegs!; +} diff --git a/src/lib/site-map/survey.test.ts b/src/lib/site-map/survey.test.ts new file mode 100644 index 0000000..acb3008 --- /dev/null +++ b/src/lib/site-map/survey.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { matchSurvey, parseSurveyCsv } from "./survey"; + +const pegs = [{ name: "BJ1", e: 0, n: 0, z: 1450, Y: 65000, X: 2860000 }, { name: "BJ2", e: 10, n: 0, z: 1450, Y: 64990, X: 2860000 }]; +describe("parseSurveyCsv", () => { + it("handles BOM, header, semicolons, CRLF and blank lines", () => { + const pts = parseSurveyCsv("Name;Y;X;Z\r\nBJ1;65000.010;2860000.000;1450.01\r\n\r\nX9;64990.200;2860000;\r\n"); + expect(pts).toEqual([{ name: "BJ1", Y: 65000.01, X: 2860000, Z: 1450.01 }, { name: "X9", Y: 64990.2, X: 2860000, Z: null }]); + }); +}); +describe("matchSurvey", () => { + it("matches by name first, then nearest peg, within 30 mm", () => { + const { matched, unmatched } = matchSurvey([ + { name: "bj1", Y: 65000.02, X: 2860000, Z: null }, // name match (case-insensitive), 20 mm off + { name: "X9", Y: 64990.2, X: 2860000, Z: null }, // nearest BJ2, 200 mm off + ], pegs); + expect(matched.map((m) => m.peg)).toEqual(["BJ1"]); + expect(unmatched[0]).toMatchObject({ peg: "BJ2" }); + expect(unmatched[0].dist).toBeCloseTo(0.2); + }); +}); diff --git a/src/lib/site-map/survey.ts b/src/lib/site-map/survey.ts new file mode 100644 index 0000000..6ffa99d --- /dev/null +++ b/src/lib/site-map/survey.ts @@ -0,0 +1,25 @@ +import type { Peg } from "./model-types"; + +export interface SurveyPoint { name: string; Y: number; X: number; Z: number | null } +export interface SurveyMatch { point: SurveyPoint; peg: string; dist: number } + +export function parseSurveyCsv(text: string): SurveyPoint[] { + const out: SurveyPoint[] = []; + for (const line of text.replace(/^/, "").split(/\r?\n/)) { + const c = line.split(/[,;\t]/).map((s) => s.trim()); + if (c.length < 3 || !c[0] || c[1] === "" || c[2] === "" || isNaN(+c[1]) || isNaN(+c[2])) continue; // header / blank + out.push({ name: c[0], Y: +c[1], X: +c[2], Z: c[3] !== undefined && c[3] !== "" && !isNaN(+c[3]) ? +c[3] : null }); + } + return out; +} + +export function matchSurvey(points: SurveyPoint[], pegs: Peg[], tolerance = 0.03) { + const matched: SurveyMatch[] = [], unmatched: SurveyMatch[] = []; + for (const point of points) { + const peg = pegs.find((p) => p.name.toLowerCase() === point.name.toLowerCase()) + ?? pegs.reduce((b, p) => (Math.hypot(p.Y - point.Y, p.X - point.X) < Math.hypot(b.Y - point.Y, b.X - point.X) ? p : b)); + const m = { point, peg: peg.name, dist: Math.hypot(peg.Y - point.Y, peg.X - point.X) }; + (m.dist <= tolerance ? matched : unmatched).push(m); + } + return { matched, unmatched }; +}