diff --git a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts new file mode 100644 index 0000000..9a05cad --- /dev/null +++ b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.test.ts @@ -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; +let p: ReturnType; +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"); +}); diff --git a/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts new file mode 100644 index 0000000..891fed1 --- /dev/null +++ b/src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts @@ -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 }, + ); + } +} diff --git a/src/lib/inbox-three/save-lines.test.ts b/src/lib/inbox-three/save-lines.test.ts new file mode 100644 index 0000000..a37c988 --- /dev/null +++ b/src/lib/inbox-three/save-lines.test.ts @@ -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 => ({ + 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"); + }); +}); diff --git a/src/lib/inbox-three/save-lines.ts b/src/lib/inbox-three/save-lines.ts new file mode 100644 index 0000000..643fedd --- /dev/null +++ b/src/lib/inbox-three/save-lines.ts @@ -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 | null; readyNow: number[] } { + let working = context; + let current = preview; + let remembered: Record | 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) }; +}