feat(dn): save inbox 3 line edits without posting

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 19:09:58 +02:00
parent e65bfcdd72
commit 3ca3021d33
4 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,68 @@
import { beforeEach, it, expect, vi } from "vitest";
import { context } from "@/lib/dn-pipeline/preview-fixture";
import { buildPreview } from "@/lib/dn-pipeline/ai-preview";
import { decisionHash } from "@/lib/dn-pipeline/decision-hash";
const m = vi.hoisted(() => ({ admin: vi.fn(), user: vi.fn(), site: vi.fn(), lease: vi.fn(), load: vi.fn(), batch: vi.fn() }));
vi.mock("@/lib/pb", () => ({ pbAdmin: m.admin }));
vi.mock("@/lib/auth/guard", () => ({ requireApiUser: m.user, requireSiteAccess: m.site }));
vi.mock("@/lib/dn-pipeline/lock", () => ({ withBatchLease: m.lease }));
vi.mock("@/lib/dn-pipeline/ai-preview-context", () => ({ loadPreviewContext: m.load }));
import { POST } from "./route";
const batch = "batchabcdefghij";
const note = "noteabcdefghijk";
const params = { params: Promise.resolve({ batchId: batch, noteId: note }) };
const req = (body: object) => new Request("http://localhost", { method: "POST", body: JSON.stringify(body) });
let c: ReturnType<typeof context>;
let p: ReturnType<typeof buildPreview>;
const edit = { lines: [0], poItemId: "itemabcdefghijk", slipQty: 11, slipUnit: "ea", factor: 1, price: null, usePoRate: true, reason: "11 pipes on the order." };
const body = () => ({ edits: [edit], snapshotHash: p.snapshotHash, decisionHash: decisionHash(p) });
beforeEach(() => {
vi.resetAllMocks();
c = context();
p = buildPreview(c, { allocations: [{ sourceLineIds: ["pipe"], purchaseOrderItemId: "itemabcdefghijk", quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Pipe", reviewQuestion: null }] });
m.load.mockResolvedValue({ context: c, stored: p });
m.user.mockResolvedValue({ id: "user", role: "office_user", modules: ["financial"] });
m.site.mockReturnValue(null);
m.admin.mockResolvedValue({ collection: (name: string) => ({ getOne: async () => (name === "dn_batches" ? { site: "site" } : { batch, supplier: "supplierabcdefg", purchase_order: "poabcdefghijklm" }) }) });
m.lease.mockImplementation(async (_p, _b, _w, fn) => ({ acquired: true, value: await fn({ batch: m.batch }) }));
});
it("keeps the site manager out of the office's screen", async () => {
m.user.mockResolvedValue({ id: "site", role: "site_user" });
expect((await POST(req(body()), params)).status).toBe(403);
expect(m.lease).not.toHaveBeenCalled();
});
it("saves the line edits and the audit without posting anything", async () => {
const res = await POST(req(body()), params);
expect(res.status).toBe(200);
const saved = await res.json();
expect(saved.rows[0]).toMatchObject({ numbers: [1], poItemId: "itemabcdefghijk" });
expect(saved.readyNow).toEqual([0]);
expect(m.batch.mock.calls[0][0].map((v: { collection: string }) => v.collection)).toEqual(["dn_batch_notes", "dn_review_events"]);
expect(m.batch.mock.calls[0][0][1]).toMatchObject({ collection: "dn_review_events", data: { action: "inbox_three_save", actor: "user" } });
});
it("refuses an edit made against a match that has moved on", async () => {
const old = decisionHash(p);
p.allocations[0].quantity = 99;
const res = await POST(req({ edits: [edit], snapshotHash: p.snapshotHash, decisionHash: old }), params);
expect(res.status).toBe(409);
expect(m.batch).not.toHaveBeenCalled();
});
it("writes nothing when one edit in the batch fails", async () => {
const res = await POST(req({ edits: [edit, { ...edit, poItemId: "foreignitem12345" }], snapshotHash: p.snapshotHash, decisionHash: decisionHash(p) }), params);
expect(res.status).toBe(409);
expect(m.batch).not.toHaveBeenCalled();
});
it("never reaches for the posting function", async () => {
const source = await import("node:fs/promises").then((fs) => fs.readFile(new URL("./route.ts", import.meta.url), "utf8"));
expect(source).not.toContain("postLineWithinLease");
});

View file

@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { canSeeInboxThree } from "@/lib/auth/access";
import { withBatchLease } from "@/lib/dn-pipeline/lock";
import { loadPreviewContext } from "@/lib/dn-pipeline/ai-preview-context";
import { decisionHash } from "@/lib/dn-pipeline/decision-hash";
import { buildLineRows } from "@/lib/inbox-three/line-rows";
import { saveLineEdits } from "@/lib/inbox-three/save-lines";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* Save the office's line edits.
*
* This route never posts a receipt. It writes the decisions the clerk made
* and moves the note to Ready to post when a line has nothing left to ask.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ batchId: string; noteId: string }> },
) {
const user = await requireApiUser("financial");
if (user instanceof Response) return user;
// Inbox 3 is the office's screen while it is being tested.
if (!canSeeInboxThree(user)) return NextResponse.json({ error: "not_allowed" }, { status: 403 });
const { batchId, noteId } = await params;
if (![batchId, noteId].every((value) => /^[a-z0-9]{15}$/i.test(value)))
return NextResponse.json({ error: "invalid_id" }, { status: 400 });
const body = await request.json().catch(() => null);
const edits = body && typeof body === "object" && Array.isArray((body as { edits?: unknown }).edits) ? (body as { edits: unknown[] }).edits : null;
if (!edits) return NextResponse.json({ error: "invalid_body" }, { status: 400 });
const pb = await pbAdmin();
const [batch, note] = await Promise.all([
pb.collection("dn_batches").getOne(batchId).catch(() => null),
pb.collection("dn_batch_notes").getOne(noteId).catch(() => null),
]);
if (!batch || !note || note.batch !== batchId) return NextResponse.json({ error: "not_found" }, { status: 404 });
const denied = requireSiteAccess(user, String(batch.site ?? ""));
if (denied) return denied;
try {
const result = await withBatchLease(pb, batchId, "inbox-three-lines", async (lease) => {
const { context, stored } = await loadPreviewContext(pb, batchId, noteId);
if (!stored) throw Error("There is no saved match to edit. Run matching again.");
// The clerk edited what is on screen. If the match moved under them,
// their edit is against a different thing.
if (body.snapshotHash !== stored.snapshotHash || body.decisionHash !== decisionHash(stored))
throw Error("Matching result changed; refresh before continuing");
// Every edit is applied to a copy: a batch that fails halfway leaves
// the saved match exactly as it was.
const { preview, remembered, readyNow } = saveLineEdits(context, stored, edits as never, user.id);
await lease.batch([
...(remembered
? [{ collection: "suppliers", method: "update" as const, id: String(context.supplier.id), data: { reconciliation_rules: remembered } }]
: []),
{ collection: "dn_batch_notes", method: "update", id: noteId, data: {
reconciliation_preview: preview,
status: readyNow.length ? "candidate" : "blocked",
stage: readyNow.length ? "ready_to_post" : "needs_review",
} },
{ collection: "dn_review_events", method: "create", data: {
batch: batchId,
note: noteId,
actor: user.id,
action: "inbox_three_save",
reason: "Saved Inbox 3 line edits; no receipt posted.",
after: { edits },
} },
]);
return { rows: buildLineRows(context, preview), readyNow };
});
return result.acquired
? NextResponse.json(result.value)
: NextResponse.json({ error: "locked", detail: "This delivery is being updated. Try again shortly." }, { status: 409 });
} catch (error) {
return NextResponse.json(
{ error: "lines_not_saved", detail: `${error instanceof Error ? error.message : "Unable to save the line edits"}. Nothing was changed.` },
{ status: 409 },
);
}
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { buildPreview, type PreviewContext } from "@/lib/dn-pipeline/ai-preview";
import { saveLineEdits } from "./save-lines";
import type { LineEdit } from "./line-rows";
const MESH_ITEM = "meshitem1234567";
const BAILING_ITEM = "bailingitem12345";
/** An order line from another purchase order. */
const FOREIGN_ITEM = "foreignitem12345";
const context = (): PreviewContext => ({
note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "0007075669", items: [] },
pages: [],
supplier: { id: "supplierabcdefg", name: "Vula" },
po: { id: "poabcdefghijklm", purchase_order_number: "MEGCPO9448", total_value: 11748.7 },
poItems: [
{ id: MESH_ITEM, description: "MESH DIAMOND 600 x 2.5 ROLL", unit: "m", qty: 2, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
{ id: BAILING_ITEM, description: "1.6mm Bailing Wire", unit: "ea", qty: 100, rate: 95, disc_percent: 0, vat_percent: 15 },
],
lines: [
{ sourceLineId: "mesh", description: "MESH DIAMOND … ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null },
{ sourceLineId: "kerb-a", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null },
{ sourceLineId: "kerb-b", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null },
{ sourceLineId: "kerb-c", description: "KERB 150", qty: 4, unit: "ea", unit_price: 95, line_amount: 380, product_code: null },
{ sourceLineId: "bailing", description: "1.6mm Bailing Wire", qty: 25, unit: "ea", unit_price: 95, line_amount: 2375, product_code: null },
],
priorDeliveries: [],
priorItems: [],
});
const preview = (c: PreviewContext) => buildPreview(c, { allocations: [
{ sourceLineIds: ["mesh"], purchaseOrderItemId: MESH_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Mesh diamond roll", reviewQuestion: null },
{ sourceLineIds: ["kerb-a"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["kerb-b"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["kerb-c"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["bailing"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Bailing wire", reviewQuestion: null },
] });
const edit = (over: Partial<LineEdit> = {}): LineEdit => ({
lines: [4],
poItemId: BAILING_ITEM,
slipQty: 25,
slipUnit: "ea",
factor: 1,
price: null,
usePoRate: true,
reason: "This is the bailing wire on the order.",
...over,
});
describe("saveLineEdits", () => {
it("fixes a line onto the right order line and readies it", () => {
const c = context();
const result = saveLineEdits(c, preview(c), [edit()], "operator");
expect(result.readyNow).toEqual([4]);
expect(result.preview.allocations[4]).toMatchObject({ purchaseOrderItemId: BAILING_ITEM, status: "proposed" });
});
it("refuses an order line from another purchase order and changes nothing", () => {
const c = context();
const before = preview(c);
const untouched = structuredClone(before);
expect(() => saveLineEdits(c, before, [edit({ poItemId: FOREIGN_ITEM })], "operator")).toThrow(/PO item does not belong to this purchase order/);
expect(before).toEqual(untouched);
});
it("refuses to combine lines that are already posted", () => {
const c = context();
const p = preview(c);
p.allocations[3].posted = true;
expect(() => saveLineEdits(c, p, [edit({ lines: [2, 3], poItemId: BAILING_ITEM })], "operator")).toThrow(/Line 4 is already posted/);
});
it("remembers the receiving connection behind a roll to metre factor", () => {
const c = context();
const result = saveLineEdits(c, preview(c), [edit({
lines: [0],
poItemId: MESH_ITEM,
slipQty: 2,
slipUnit: "roll",
factor: 30,
reason: "One roll is 30 m.",
})], "operator");
const rules = (result.remembered as { rules?: { receiving_match?: { factor?: number } }[] } | null)?.rules ?? [];
expect(rules.some((rule) => rule.receiving_match?.factor === 30)).toBe(true);
});
it("saves nothing when one edit in the batch fails", () => {
const c = context();
const before = preview(c);
const untouched = structuredClone(before);
expect(() => saveLineEdits(c, before, [
edit(),
edit({ lines: [0], poItemId: FOREIGN_ITEM }),
], "operator")).toThrow(/PO item does not belong to this purchase order/);
expect(before).toEqual(untouched);
});
it("never reaches for the posting function", async () => {
const source = await import("node:fs/promises").then((fs) => fs.readFile(new URL("./save-lines.ts", import.meta.url), "utf8"));
expect(source).not.toContain("postLineWithinLease");
});
});

View file

@ -0,0 +1,117 @@
/**
* Saving the office's line edits.
*
* The clerk edits a row — the order line, the factor, the price — and this
* turns those edits into the decisions the preview holds. It never posts a
* receipt: the office still presses Confirm, and the stage only says
* "ready to post" when a line has nothing left to ask.
*
* Every edit is applied to a copy, so a batch of edits that fails halfway
* leaves the saved match exactly as it was.
*/
import { buildPreview, snapshotHash, type PreviewContext, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview";
import { resolveMatch } from "@/lib/dn-pipeline/matching";
import { rememberConnection } from "@/lib/dn-pipeline/remembered-match";
import { stableSourceKey } from "@/lib/dn-pipeline/line-booking";
import { readyIndexes } from "@/lib/inbox-three/readiness";
import { buildLineRows, editToConfirmation, type LineEdit } from "./line-rows";
const sameLines = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && [...a].sort().join("|") === [...b].sort().join("|");
/** Has this slip line already been booked? */
function isPosted(context: PreviewContext, preview: ProcessingPreview, sourceLineId: string): boolean {
if (preview.allocations.some((allocation) => allocation.posted && allocation.sourceLineIds.includes(sourceLineId))) return true;
const state = context.note.line_posting as { bookings?: { sourceKeys?: string[] }[] } | undefined;
const key = stableSourceKey(sourceLineId);
return (state?.bookings ?? []).some((booking) => (booking.sourceKeys ?? []).includes(key));
}
/**
* Apply the office's edits to the saved match.
*
* `edits` carry 0-based line numbers. The result is a new preview, the
* supplier guidance a remembered connection produced (if any), and the
* lines that are ready to confirm now.
*/
export function saveLineEdits(
context: PreviewContext,
preview: ProcessingPreview,
edits: LineEdit[],
actor: string,
): { preview: ProcessingPreview; remembered: Record<string, unknown> | null; readyNow: number[] } {
let working = context;
let current = preview;
let remembered: Record<string, unknown> | null = null;
for (const edit of edits) {
if (!edit.lines.length) throw Error("Choose the line this edit is for.");
const indexes = [...edit.lines].sort((a, b) => a - b);
for (const index of indexes) {
if (!Number.isInteger(index) || index < 0 || index >= working.lines.length) throw Error("A line is not on this delivery.");
}
const sourceLineIds = indexes.map((index) => working.lines[index].sourceLineId);
// A posted line is evidence. It cannot be edited.
const posted = indexes.find((index) => isPosted(working, current, working.lines[index].sourceLineId));
if (posted !== undefined) throw Error(`Line ${posted + 1} is already posted`);
// The order line has to belong to this order.
if (edit.poItemId && !working.poItems.some((item) => item.id === edit.poItemId)) {
throw Error("PO item does not belong to this purchase order");
}
// The allocation this edit belongs to: the one covering exactly these
// lines, or a rebuild when the clerk combined lines that were apart.
let next = current;
let index = next.allocations.findIndex((allocation) => sameLines(allocation.sourceLineIds, sourceLineIds));
if (index < 0) {
const untouched = next.allocations.filter((allocation) => !allocation.sourceLineIds.some((id) => sourceLineIds.includes(id)));
const factor = edit.factor ?? 1;
next = buildPreview(working, { allocations: [...untouched, {
sourceLineIds,
purchaseOrderItemId: edit.poItemId,
quantityBasis: sourceLineIds.length > 1 ? "kit" : factor === 1 ? "direct" : "converted",
factor: factor === 1 ? null : factor,
amountBasis: "po_rate",
sourceAmountBasis: "unknown",
ruleKeys: [],
explanation: `Combined by the office: ${sourceLineIds.length} slip lines`,
reviewQuestion: null,
}] });
index = next.allocations.findIndex((allocation) => sameLines(allocation.sourceLineIds, sourceLineIds));
if (index < 0) throw Error("The edited lines could not be matched to the purchase order.");
}
const row = buildLineRows(working, next)[index];
const confirmation = editToConfirmation(row, edit, edit.poItemId ? row.poUnit : null);
try {
current = resolveMatch(working, next, { index, sourceLineIds, snapshotHash: next.snapshotHash, confirmation });
} catch (error) {
// The clerk confirmed a quantity that is over what the order has left.
// Their reason is the acknowledgement; without it the line stays open.
if (!(error instanceof Error) || !/Acknowledge the cumulative excess/.test(error.message)) throw error;
current = resolveMatch(working, next, { index, sourceLineIds, snapshotHash: next.snapshotHash, confirmation: { ...confirmation, acceptExcess: true } });
}
// A remembered receiving connection is the supplier's rule, not the
// receipt's: it is kept so the next slip of the same goods converts
// itself. It only makes sense for a real unit conversion.
const item = edit.poItemId ? working.poItems.find((candidate) => candidate.id === edit.poItemId) : null;
const targetUnit = item && typeof item.unit === "string" ? item.unit : null;
if (targetUnit && edit.factor !== null && edit.factor !== 1 && edit.slipUnit.trim().toLowerCase() !== targetUnit.trim().toLowerCase()) {
const guidance = rememberConnection(
{ ...working, supplier: { ...working.supplier, reconciliation_rules: remembered ?? working.supplier.reconciliation_rules } },
current,
current.allocations.findIndex((allocation) => sameLines(allocation.sourceLineIds, sourceLineIds)),
actor,
);
remembered = guidance;
working = { ...working, supplier: { ...working.supplier, reconciliation_rules: guidance } };
current = { ...current, snapshotHash: snapshotHash(working) };
}
}
return { preview: current, remembered, readyNow: readyIndexes(working, current) };
}