feat(site-map): surveyor CSV import marks matched pegs checked

This commit is contained in:
DEls 2026-10-14 07:08:32 +02:00
parent a2db3a2b13
commit 70c0784b18
5 changed files with 121 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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) });
}

View file

@ -51,3 +51,14 @@ export async function loadProgressState(pb: PocketBase, siteId: string): Promise
});
return latestState(rows);
}
const pegCache = new Map<string, import("./model-types").Peg[]>();
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!;
}

View file

@ -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);
});
});

View file

@ -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 };
}