feat(site-map): surveyor CSV import marks matched pegs checked
This commit is contained in:
parent
a2db3a2b13
commit
70c0784b18
5 changed files with 121 additions and 0 deletions
29
src/app/api/sites/[id]/survey-imports/route.test.ts
Normal file
29
src/app/api/sites/[id]/survey-imports/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/app/api/sites/[id]/survey-imports/route.ts
Normal file
35
src/app/api/sites/[id]/survey-imports/route.ts
Normal 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) });
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
21
src/lib/site-map/survey.test.ts
Normal file
21
src/lib/site-map/survey.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
src/lib/site-map/survey.ts
Normal file
25
src/lib/site-map/survey.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue