feat(dn): save inbox 3 line edits without posting
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
e65bfcdd72
commit
3ca3021d33
4 changed files with 372 additions and 0 deletions
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
103
src/lib/inbox-three/save-lines.test.ts
Normal file
103
src/lib/inbox-three/save-lines.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
117
src/lib/inbox-three/save-lines.ts
Normal file
117
src/lib/inbox-three/save-lines.ts
Normal 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) };
|
||||
}
|
||||
Loading…
Reference in a new issue