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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-30 22:08:19 +02:00
parent 91173edbcd
commit 6883971bb7
3 changed files with 176 additions and 0 deletions

View file

@ -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 });
}
}

View file

@ -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<unknown>) => 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<string, unknown>;
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"]);
});
});

View file

@ -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<ReadyDatesResult> {
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<Record<string, unknown>>({
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;
}