From 6883971bb70f7998e7389a9523053bf05db0544d Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 30 Oct 2026 22:08:19 +0200 Subject: [PATCH] feat(collection-pool): pending stops become ready on their expected date A scheduled check (n8n, every 2 hours, POST /api/v1/collection-pool/ready-dates/tick) marks pending customer stops ready once their expected-ready date is today or earlier (South African time). The stop stays unassigned, keeps its pool age, and gets an audit note. Stops with a driver problem flag are left alone. Stock lines are not covered: they have no date field. Co-Authored-By: Claude Sonnet 5.5 --- .../collection-pool/ready-dates/tick/route.ts | 26 +++++++ .../collection-ready-dates-service.test.ts | 77 +++++++++++++++++++ .../server/collection-ready-dates-service.ts | 73 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/app/api/v1/collection-pool/ready-dates/tick/route.ts create mode 100644 src/lib/server/collection-ready-dates-service.test.ts create mode 100644 src/lib/server/collection-ready-dates-service.ts diff --git a/src/app/api/v1/collection-pool/ready-dates/tick/route.ts b/src/app/api/v1/collection-pool/ready-dates/tick/route.ts new file mode 100644 index 0000000..7c8fce9 --- /dev/null +++ b/src/app/api/v1/collection-pool/ready-dates/tick/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { markDueLinesReady } from "@/lib/server/collection-ready-dates-service"; + +/** + * POST /api/v1/collection-pool/ready-dates/tick + * + * n8n calls this every two hours. Pending collection-pool stops whose + * expected-ready date has arrived are marked ready (not assigned). + * + * Auth: `assertAgentAuth` (Bearer `AGENT_API_KEY`). + * Response: `{ today, marked: [...], errors: [...] }` + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + try { + assertAgentAuth(req); + return NextResponse.json(await markDueLinesReady()); + } catch (err) { + const message = err instanceof Error ? err.message : "Ready-dates tick failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/lib/server/collection-ready-dates-service.test.ts b/src/lib/server/collection-ready-dates-service.test.ts new file mode 100644 index 0000000..37d5d34 --- /dev/null +++ b/src/lib/server/collection-ready-dates-service.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getFullList = vi.fn(); +const update = vi.fn(); +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: async () => ({ collection: () => ({ getFullList, update }) }), +})); +vi.mock("@/lib/server/orders-service", () => ({ getOrderItemsCollectionName: () => "order_items" })); +vi.mock("@/lib/server/audit-context", () => ({ + runWithAuditActor: async (_a: unknown, fn: () => Promise) => fn(), +})); + +import { markDueLinesReady, todayInSouthAfrica } from "./collection-ready-dates-service"; +import { parseStoredNotes } from "@/lib/note-feed"; + +beforeEach(() => { + getFullList.mockReset(); + update.mockReset(); +}); + +describe("todayInSouthAfrica", () => { + it("rolls to the next day at 22:00 UTC", () => { + expect(todayInSouthAfrica(new Date("2026-10-30T21:59:00Z"))).toBe("2026-10-30"); + expect(todayInSouthAfrica(new Date("2026-10-30T22:00:00Z"))).toBe("2026-10-31"); + }); +}); + +describe("markDueLinesReady", () => { + const now = new Date("2026-10-30T10:00:00Z"); + + it("asks only for pending, unflagged, dated stops due up to today", async () => { + getFullList.mockResolvedValueOnce([]); + await markDueLinesReady(now); + const filter = getFullList.mock.calls[0][0].filter as string; + expect(filter).toContain('item_status = "collection_pool"'); + expect(filter).toContain("ready_for_collection = false"); + expect(filter).toContain("problem_flag != true"); + expect(filter).toContain('collection_date <= "2026-10-30 23:59:59"'); + }); + + it("marks each due stop ready with a note, and assigns nobody", async () => { + getFullList.mockResolvedValueOnce([ + { + id: "a", + description: "Pin", + collection_date: "2026-10-28 00:00:00.000Z", + notes: "", + expand: { purchase_order: { po_number: "PO1" } }, + }, + ]); + update.mockResolvedValue({}); + + const r = await markDueLinesReady(now); + + expect(r.marked).toEqual([{ id: "a", po_number: "PO1", description: "Pin", date: "2026-10-28" }]); + const patch = update.mock.calls[0][1] as Record; + expect(patch.ready_for_collection).toBe(true); + expect(patch).not.toHaveProperty("drv_id"); + expect(patch).not.toHaveProperty("item_status"); + expect(patch).not.toHaveProperty("collection_date"); + expect(patch).not.toHaveProperty("pool_entered_at"); + expect(parseStoredNotes(patch.notes as string).at(-1)?.body).toBe( + "Expected-ready date reached, marked ready automatically. Expected 2026-10-28.", + ); + }); + + it("keeps going when one update fails", async () => { + getFullList.mockResolvedValueOnce([ + { id: "a", collection_date: "2026-10-28 00:00:00.000Z" }, + { id: "b", collection_date: "2026-10-29 00:00:00.000Z" }, + ]); + update.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({}); + const r = await markDueLinesReady(now); + expect(r.errors).toEqual(["a: boom"]); + expect(r.marked.map((m) => m.id)).toEqual(["b"]); + }); +}); diff --git a/src/lib/server/collection-ready-dates-service.ts b/src/lib/server/collection-ready-dates-service.ts new file mode 100644 index 0000000..99c67b6 --- /dev/null +++ b/src/lib/server/collection-ready-dates-service.ts @@ -0,0 +1,73 @@ +import { runWithAuditActor } from "@/lib/server/audit-context"; +import { appendNote } from "@/lib/note-feed"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; + +/** + * A pending collection-pool stop with an expected-ready date moves to ready + * on that date. Marks it ready only: no driver is assigned, the age clock + * (`pool_entered_at`) is untouched, and the date stays on the line, where it + * now reads as the confirmed collection day. + * + * Customer stops only: `stock_purchase_items` has no collection date field. + * A stop the driver flagged with a problem (out of stock, ...) is left alone. + * + * Called by an n8n timer every two hours. + */ + +const ACTOR = { id: "collection-date-automation", name: "Collection date automation" }; +const NOTE = "Expected-ready date reached, marked ready automatically."; + +/** Today's date (YYYY-MM-DD) in South African time, where the dates are entered. */ +export function todayInSouthAfrica(now: Date): string { + return now.toLocaleDateString("en-CA", { timeZone: "Africa/Johannesburg" }); +} + +export interface ReadyDatesResult { + today: string; + marked: Array<{ id: string; po_number: string; description: string; date: string }>; + errors: string[]; +} + +export async function markDueLinesReady(now: Date = new Date()): Promise { + const today = todayInSouthAfrica(now); + const result: ReadyDatesResult = { today, marked: [], errors: [] }; + const pb = await getAdminPocketBase(); + const collection = getOrderItemsCollectionName(); + + // Dated up to and including today; PB stores dates as "YYYY-MM-DD 00:00:00.000Z". + const due = await pb.collection(collection).getFullList>({ + filter: + 'item_status = "collection_pool" && ready_for_collection = false && problem_flag != true' + + ` && collection_date != "" && collection_date <= "${today} 23:59:59"`, + expand: "purchase_order", + }); + + await runWithAuditActor({ name: ACTOR.name, source: "agent", ignoreRequestUser: true }, async () => { + for (const row of due) { + const id = String(row.id); + const date = String(row.collection_date ?? "").slice(0, 10); + try { + await pb.collection(collection).update(id, { + ready_for_collection: true, + notes: appendNote(row.notes as string | undefined, { + ts: now.toISOString(), + actor: ACTOR, + category: "status", + body: `${NOTE} Expected ${date}.`, + }), + }); + const po = (row.expand as { purchase_order?: { po_number?: string } } | undefined)?.purchase_order; + result.marked.push({ + id, + po_number: po?.po_number ?? "", + description: String(row.description ?? ""), + date, + }); + } catch (e) { + result.errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`); + } + } + }); + return result; +}