feat(delivery-notes): dedup by SHA-256 at upload time

Mirrors the existing /api/purchase-orders/upload pattern onto the
delivery-note path so byte-identical re-uploads short-circuit with
`200 + { duplicate: true, existing_dn_id, ... }` instead of
creating a second delivery_notes row.

Concretely:
- Hash the bytes with SHA-256 before any PB round-trip.
- Query `data_import_batches` for any processed batch with the same
  hash inside a 30-day window (the same window the PO route uses; the
  underlying `idx_batches_file_hash` index already covers it).
- On hit, return the existing DN + batch ids and skip creation. The
  n8n OCR pipeline is never invoked for a duplicate slip, so a
  double-clicking operator costs us nothing.
- On miss, stamp `file_hash` on the new batch row so subsequent
  re-uploads can dedup against it.
- Wire the response through `UploadDeliveryNoteButton` as a new
  'duplicate' panel showing the existing DN id plus a quick link to
  /dashboard/purchasing where the awaiting-PO inbox lives.

pb-types: adds file_hash?: string to DataImportBatchRecord so the
column the PB schema already has is reflected in the TS shape.
This commit is contained in:
DEls 2026-09-07 14:49:33 +02:00
parent 08ed89d9c8
commit 78fa8da05c
4 changed files with 553 additions and 22 deletions

View file

@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mirror of src/app/api/purchase-orders/upload/route.test.ts, adapted
// for the delivery-notes path. Same pbAdmin mock surface (getFullList
// for the dedup query, create for the new DN + batch rows, plus getOne
// for the site lookup).
const getFullListMock = vi.fn();
const createMock = vi.fn();
const getOneMock = vi.fn();
vi.mock("@/lib/pb", () => ({
pbAdmin: vi.fn(async () => ({
collection: (name: string) => {
if (name === "data_import_batches") {
return { create: createMock, getFullList: getFullListMock };
}
if (name === "delivery_notes") {
return { create: createMock };
}
if (name === "sites") {
return { getOne: getOneMock };
}
throw new Error(`unexpected collection in test: ${name}`);
},
})),
}));
import { POST } from "./route";
function makeRequest(form: FormData): Request {
return new Request("http://x/api/delivery-notes/upload", {
method: "POST",
body: form,
});
}
function slip(
bytes = 128,
type = "image/jpeg",
name = "dn-5678.jpg",
): File {
return new File([new Uint8Array(bytes)], name, { type });
}
const DEDUP_WINDOW_DAYS = 30;
function daysAgoIso(days: number): string {
return new Date(Date.now() - days * 86400_000).toISOString();
}
function makeValidForm(file?: File, site = "siteabc"): FormData {
const form = new FormData();
form.append("file", file ?? slip());
form.append("site", site);
return form;
}
describe("POST /api/delivery-notes/upload", () => {
beforeEach(() => {
createMock.mockReset();
getFullListMock.mockReset();
getOneMock.mockReset();
// Default: dedup misses, site lookup returns project_code.
getFullListMock.mockResolvedValue([]);
getOneMock.mockResolvedValue({ project_code: "P-001" });
});
it("returns 400 when the body is not multipart form data", async () => {
const res = await POST(
new Request("http://x/api/delivery-notes/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nope: true }),
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "invalid_form_data" });
expect(createMock).not.toHaveBeenCalled();
expect(getFullListMock).not.toHaveBeenCalled();
});
it("returns 400 when no file field is present", async () => {
const form = new FormData();
form.append("site", "siteabc");
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "missing_file" });
});
it("returns 400 for an empty file", async () => {
const form = new FormData();
form.append("file", slip(0));
form.append("site", "siteabc");
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "empty_file" });
});
it("returns 400 when the file exceeds the size cap (20 MB)", async () => {
// Build a real oversized file so we don't rely on the request body
// actually being 21 MB on the wire — the size check happens before
// any hashing, and we don't want this test to allocate that much.
const big = slip(128);
Object.defineProperty(big, "size", { value: 21 * 1024 * 1024 });
const form = makeValidForm(big);
const res = await POST({
formData: async () => form,
} as unknown as Request);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("file_too_large");
expect(body.max_bytes).toBe(20 * 1024 * 1024);
});
it("returns 400 for an unsupported MIME type", async () => {
const form = new FormData();
form.append(
"file",
new File([new Uint8Array(16)], "dn.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}),
);
form.append("site", "siteabc");
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("unsupported_type");
expect(body.got).toContain("wordprocessingml");
});
it("returns 400 when site is missing", async () => {
const form = new FormData();
form.append("file", slip());
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "missing_site" });
});
it("returns 400 when the site lookup fails", async () => {
getOneMock.mockRejectedValueOnce(new Error("not found"));
const res = await POST(makeRequest(makeValidForm()));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Site not found" });
});
it("creates a DN + batch row, returns 201 with the new id", async () => {
createMock
.mockResolvedValueOnce({
id: "dnnew1234567890",
created: "2026-09-07T10:00:00Z",
}) // delivery_notes.create
.mockResolvedValueOnce({ id: "batchabcdefghijkl" }); // batches.create
const form = makeValidForm();
form.append("delivery_note_number", "DN-5678");
const res = await POST(makeRequest(form));
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
id: "dnnew1234567890",
deliveryNoteNumber: "DN-5678",
siteId: "siteabc",
projectCode: "P-001",
processingStatus: "pending",
created: "2026-09-07T10:00:00Z",
});
expect(createMock).toHaveBeenCalledTimes(2);
// First create: delivery_notes
const dnPayload = createMock.mock.calls[0][0];
expect(dnPayload.delivery_note_number).toBe("DN-5678");
expect(dnPayload.site).toBe("siteabc");
expect(dnPayload.purchase_order).toBeNull();
expect(dnPayload.processing_status).toBe("pending");
expect(dnPayload.is_verified).toBe(false);
expect(dnPayload.received_by).toBe("web-upload");
expect(dnPayload.slip_image).toBeInstanceOf(File);
// Second create: data_import_batches with file_hash
const batchPayload = createMock.mock.calls[1][0];
expect(batchPayload.batch_type).toBe("delivery_notes");
expect(batchPayload.status).toBe("pending");
expect(batchPayload.attempts).toBe(0);
expect(batchPayload.uploaded_by).toBe("web-upload");
expect(batchPayload.processed_record_id).toBe("dnnew1234567890");
expect(batchPayload.file_hash).toMatch(/^[a-f0-9]{64}$/);
});
it("computes the same hash for byte-identical files (so re-uploads dedup)", async () => {
createMock.mockResolvedValue({ id: "dnidxxxxxxxxxxxx" });
const form1 = makeValidForm(slip(64));
const form2 = makeValidForm(slip(64));
const r1 = await POST(makeRequest(form1));
expect(r1.status).toBe(201);
const r2 = await POST(makeRequest(form2));
// Second upload: dedup still misses (mocked getFullList returns [])
// so a second set of rows is created — but with the SAME file_hash.
expect(r2.status).toBe(201);
const hash1 = createMock.mock.calls[1][0].file_hash;
const hash2 = createMock.mock.calls[3][0].file_hash;
expect(hash1).toBe(hash2);
});
it("returns 200 with duplicate=true when a recent processed batch has the same hash", async () => {
getFullListMock.mockResolvedValueOnce([
{
id: "priorbatch1",
processed_record_id: "priordn1",
},
]);
const form = makeValidForm(slip());
const res = await POST(makeRequest(form));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.duplicate).toBe(true);
expect(body.status).toBe("duplicate");
expect(body.existing_batch_id).toBe("priorbatch1");
expect(body.existing_dn_id).toBe("priordn1");
expect(body.filename).toBe("dn-5678.jpg");
// No new DN or batch row created on a dedup hit.
expect(createMock).not.toHaveBeenCalled();
expect(getOneMock).not.toHaveBeenCalled();
// Verify the dedup query used the hash + status=processed + date cutoff.
const filter = getFullListMock.mock.calls[0][0].filter;
expect(filter).toContain('status = "processed"');
expect(filter).toMatch(/file_hash = "[a-f0-9]{64}"/);
expect(filter).toContain("date_uploaded >=");
});
it("does not match a batch that is still pending (only 'processed' counts)", async () => {
// If only pending batches match, dedup misses and a normal create runs.
getFullListMock.mockResolvedValueOnce([]); // empty as if no processed match
createMock.mockResolvedValueOnce({ id: "dnnew1" });
createMock.mockResolvedValueOnce({ id: "batch1" });
const res = await POST(makeRequest(makeValidForm()));
expect(res.status).toBe(201);
const filter = getFullListMock.mock.calls[0][0].filter;
expect(filter).toContain('status = "processed"');
});
it("uses the 30-day cutoff in the dedup query", async () => {
// Don't actually return a match — we only care about the filter shape.
getFullListMock.mockResolvedValueOnce([]);
createMock.mockResolvedValueOnce({ id: "dnx" });
createMock.mockResolvedValueOnce({ id: "batchx" });
await POST(makeRequest(makeValidForm()));
const filter = getFullListMock.mock.calls[0][0].filter;
const cutoffMatch = filter.match(/date_uploaded >= "([^"]+)"/);
expect(cutoffMatch).not.toBeNull();
const cutoff = new Date(cutoffMatch![1]).getTime();
const expected = Date.now() - DEDUP_WINDOW_DAYS * 86400_000;
expect(Math.abs(cutoff - expected)).toBeLessThan(5_000);
// Sanity-check we're not accidentally using daysAgoIso(0).
expect(daysAgoIso(0)).not.toBe(cutoffMatch![1]);
});
it("falls through to create when the dedup query throws", async () => {
getFullListMock.mockRejectedValueOnce(new Error("pb dedup down"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
createMock.mockResolvedValueOnce({ id: "dnz" });
createMock.mockResolvedValueOnce({ id: "batchz" });
const res = await POST(makeRequest(makeValidForm()));
expect(res.status).toBe(201);
expect(createMock).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("still creates a DN when the batch-tracking row fails (OCR can still pick it up)", async () => {
getOneMock.mockResolvedValue({ project_code: "P-001" });
createMock.mockResolvedValueOnce({ id: "dnonlyone1" }); // DN succeeds
createMock.mockRejectedValueOnce(new Error("batches broken")); // batches fails
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const res = await POST(makeRequest(makeValidForm()));
expect(res.status).toBe(201);
expect(await res.json()).toMatchObject({ id: "dnonlyone1" });
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("returns 500 when the DN row cannot be created", async () => {
createMock.mockRejectedValueOnce(new Error("pb down"));
const res = await POST(makeRequest(makeValidForm()));
expect(res.status).toBe(500);
const body = await res.json();
// The route surfaces the underlying PB error so the operator can see
// why the create failed; we just assert it's the 500 payload.
expect(typeof body.error).toBe("string");
expect(body.error.length).toBeGreaterThan(0);
});
});

View file

@ -1,6 +1,23 @@
import { NextResponse } from "next/server";
import { createHash } from "node:crypto";
import { pbAdmin } from "@/lib/pb";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ALLOWED_MIMES = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
] as const;
const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB
// Dedup window for byte-identical re-uploads. Matches the PO upload
// route's window. Anything older than this is ignored — the OCR
// pipeline's `delivery_note` relation is the source of truth for those.
const DEDUP_WINDOW_DAYS = 30;
/**
* POST /api/delivery-notes/upload
*
@ -19,22 +36,110 @@ import { pbAdmin } from "@/lib/pb";
* - processing_status = "pending" so the n8n OCR pipeline picks it up
* - purchase_order = null (filled in by OCR webhook on match)
* - date_received = upload time
*
* Hash dedup: before creating rows, we SHA-256 the bytes and look for a
* recent (last 30 days) `data_import_batches` row with the same hash that
* already reached `status=processed`. If we find one, we short-circuit
* with `{ duplicate: true, existing_batch_id, existing_dn_id, ... }` —
* no new DN, no new batch row, no wasted OCR tokens. Mirrors the
* behaviour of /api/purchase-orders/upload so the operator gets the
* same experience on both upload paths.
*/
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file");
const siteId = (form.get("site") as string | null)?.trim();
const deliveryNoteNumber = (form.get("delivery_note_number") as string | null)?.trim();
const start = Date.now();
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
let form: FormData;
try {
form = await request.formData();
} catch {
return NextResponse.json({ error: "invalid_form_data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing_file" }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "empty_file" }, { status: 400 });
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: "file_too_large", max_bytes: MAX_FILE_BYTES },
{ status: 400 },
);
}
if (!ALLOWED_MIMES.includes(file.type as (typeof ALLOWED_MIMES)[number])) {
return NextResponse.json(
{
error: "unsupported_type",
got: file.type,
allowed: [...ALLOWED_MIMES],
},
{ status: 400 },
);
}
const siteId = (form.get("site") as string | null)?.trim();
const deliveryNoteNumber = (
form.get("delivery_note_number") as string | null
)?.trim();
if (!siteId) {
return NextResponse.json({ error: "Missing site" }, { status: 400 });
return NextResponse.json({ error: "missing_site" }, { status: 400 });
}
// Hash the file bytes. Cheap enough to do before the PB round-trip
// (20 MB worst case, but in practice slips are a few hundred KB).
let file_hash: string;
try {
const bytes = Buffer.from(await file.arrayBuffer());
file_hash = createHash("sha256").update(bytes).digest("hex");
} catch (err) {
console.error("[dn-upload] hash_failed", { err: String(err) });
return NextResponse.json({ error: "hash_failed" }, { status: 500 });
}
const pb = await pbAdmin();
// Hash dedup: look for a recent processed batch with the same hash.
// Uses the idx_batches_file_hash index on (file_hash, status).
let existing: Array<{ id: string; processed_record_id?: string }> = [];
try {
const cutoff = new Date(
Date.now() - DEDUP_WINDOW_DAYS * 86400_000,
).toISOString();
existing = await pb.collection("data_import_batches").getFullList({
filter: `file_hash = "${file_hash}" && status = "processed" && date_uploaded >= "${cutoff}"`,
sort: "-date_uploaded",
fields: "id,processed_record_id",
});
} catch (err) {
// Dedup query failing is non-fatal — log and proceed to create the
// DN. Worst case is we miss the dedup and the operator will spot
// it manually on the awaiting-PO assignment inbox.
console.warn("[dn-upload] dedup_query_failed", { err: String(err) });
}
if (existing.length > 0) {
const prior = existing[0];
console.log("[dn-upload] duplicate_by_hash", {
file_hash,
existing_batch_id: prior.id,
existing_dn_id: prior.processed_record_id ?? null,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{
duplicate: true,
status: "duplicate",
existing_batch_id: prior.id,
existing_dn_id: prior.processed_record_id ?? null,
filename: file.name,
},
{ status: 200 },
);
}
// Resolve site → project_code for the DN's project_code field (used by
// older reports that filter by text rather than the relation).
let resolvedProject = "";
@ -44,10 +149,7 @@ export async function POST(request: Request) {
});
resolvedProject = site.project_code ?? "";
} catch {
return NextResponse.json(
{ error: "Site not found" },
{ status: 400 },
);
return NextResponse.json({ error: "Site not found" }, { status: 400 });
}
try {
@ -68,7 +170,8 @@ export async function POST(request: Request) {
// the dashboard's "imports" view) can track this upload through
// pending → processed/failed/dead. The row is linked back to the
// delivery_note via `processed_record_id` so the OCR result route
// can find it without a relation field on delivery_notes.
// can find it without a relation field on delivery_notes. We also
// stamp `file_hash` so a future re-upload can dedup against it.
try {
await pb.collection("data_import_batches").create({
batch_type: "delivery_notes",
@ -78,6 +181,7 @@ export async function POST(request: Request) {
date_uploaded: new Date().toISOString(),
uploaded_by: "web-upload",
original_file: file,
file_hash,
processed_record_id: dn.id,
});
} catch (batchErr) {
@ -91,14 +195,17 @@ export async function POST(request: Request) {
);
}
return NextResponse.json({
id: dn.id,
deliveryNoteNumber: deliveryNoteNumber || "",
siteId,
projectCode: resolvedProject,
processingStatus: "pending",
created: dn.created,
});
return NextResponse.json(
{
id: dn.id,
deliveryNoteNumber: deliveryNoteNumber || "",
siteId,
projectCode: resolvedProject,
processingStatus: "pending",
created: dn.created,
},
{ status: 201 },
);
} catch (err) {
return NextResponse.json(
{

View file

@ -1,7 +1,15 @@
"use client";
import * as React from "react";
import { FileSpreadsheet, Loader2, Upload, X } from "lucide-react";
import {
AlertTriangle,
CheckCircle2,
Copy,
FileSpreadsheet,
Loader2,
Upload,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -21,7 +29,7 @@ import {
import { useFileDropZone } from "@/lib/use-file-drop-zone";
import { cn } from "@/lib/utils";
type Phase = "idle" | "uploading" | "done" | "error";
type Phase = "idle" | "uploading" | "done" | "duplicate" | "error";
interface UploadResult {
id: string;
@ -32,6 +40,19 @@ interface UploadResult {
created: string;
}
/**
* Returned by /api/delivery-notes/upload when SHA-256 dedup found a
* matching file within the configured window. We don't create a new
* DN — the operator should switch to the existing one. Shape mirrors
* the PO upload `duplicate` payload, just with `existing_dn_id`
* instead of `existing_po_id`.
*/
interface DuplicateInfo {
existingDnId: string | null;
existingBatchId: string | null;
filename: string;
}
export interface SiteOption {
id: string;
name: string;
@ -57,6 +78,7 @@ export function UploadDeliveryNoteButton({
const [file, setFile] = React.useState<File | null>(null);
const [phase, setPhase] = React.useState<Phase>("idle");
const [result, setResult] = React.useState<UploadResult | null>(null);
const [duplicate, setDuplicate] = React.useState<DuplicateInfo | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
@ -66,6 +88,7 @@ export function UploadDeliveryNoteButton({
setFile(null);
setPhase("idle");
setResult(null);
setDuplicate(null);
setErrorMsg(null);
if (inputRef.current) inputRef.current.value = "";
}, []);
@ -79,6 +102,7 @@ export function UploadDeliveryNoteButton({
if (!file || !siteId) return;
setPhase("uploading");
setErrorMsg(null);
setDuplicate(null);
try {
const form = new FormData();
form.append("file", file);
@ -89,6 +113,25 @@ export function UploadDeliveryNoteButton({
body: form,
});
const data = await res.json();
// 200 + duplicate:true = server caught a byte-identical recent
// upload. No new DN was created — route the operator to the
// existing one instead.
if (res.status === 200 && data && data.duplicate === true) {
setDuplicate({
existingDnId:
typeof data.existing_dn_id === "string"
? data.existing_dn_id
: null,
existingBatchId:
typeof data.existing_batch_id === "string"
? data.existing_batch_id
: null,
filename:
typeof data.filename === "string" ? data.filename : file.name,
});
setPhase("duplicate");
return;
}
if (!res.ok) {
setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed");
setPhase("error");
@ -154,6 +197,8 @@ export function UploadDeliveryNoteButton({
{phase === "done" && result ? (
<DonePanel result={result} onClose={close} />
) : phase === "duplicate" && duplicate ? (
<DuplicatePanel duplicate={duplicate} onClose={close} />
) : (
<div className="space-y-4">
<div className="space-y-1.5">
@ -333,3 +378,69 @@ function DonePanel({
</div>
);
}
/**
* Shown when the upload route returns 200 + `duplicate: true`. The
* server hashed the bytes and matched a recent processed batch — we did
* NOT create a new DN, so the operator's options are: (a) leave and
* look up the existing row themselves, (b) re-upload anyway by first
* editing the existing DN (we don't expose that flow here on purpose),
* or (c) open the existing one via the purchasing page.
*/
function DuplicatePanel({
duplicate,
onClose,
}: {
duplicate: DuplicateInfo;
onClose: () => void;
}) {
const { existingDnId, existingBatchId, filename } = duplicate;
return (
<div className="space-y-4">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
<p className="flex items-center gap-2 font-medium text-amber-700 dark:text-amber-300">
<Copy className="h-4 w-4" />
This slip has already been uploaded
</p>
<p className="mt-2 text-xs text-muted-foreground">
The file <span className="font-mono text-foreground">{filename}</span>{" "}
matched a delivery note that's already in the system. No new
record was created, and no OCR was spent on this attempt — we
stopped at the hash check to avoid a duplicate.
</p>
{existingDnId && (
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">Existing DN:</span>
<span className="font-mono font-semibold text-foreground">
{existingDnId}
</span>
</p>
)}
{existingBatchId && (
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">Batch row:</span>
<span className="font-mono text-foreground">{existingBatchId}</span>
</p>
)}
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<p>
If the original upload had OCR problems (no PO auto-matched,
missing DN number, etc.), open the row in the awaiting-PO
inbox to fix it rather than re-uploading.
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<a
href="/dashboard/purchasing"
className="inline-flex items-center gap-1.5 text-xs text-[#F2B165] hover:underline"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Open purchasing page
</a>
<Button onClick={onClose}>Close</Button>
</div>
</div>
);
}

View file

@ -144,6 +144,15 @@ export interface DataImportBatchRecord extends RecordModel {
next_retry_at?: string;
processed_at?: string;
processed_record_id?: string;
/**
* Lowercase hex SHA-256 of the uploaded file's bytes. Populated by the
* upload routes so byte-identical re-uploads can dedup within the
* configured window (currently 30 days). Indexed as
* `idx_batches_file_hash` on the `data_import_batches` collection for
* the dedup query — see `src/app/api/purchase-orders/upload/route.ts`
* and `src/app/api/delivery-notes/upload/route.ts`.
*/
file_hash?: string;
}
export interface PurchaseOrderRecord extends RecordModel {