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:
parent
91173edbcd
commit
6883971bb7
3 changed files with 176 additions and 0 deletions
26
src/app/api/v1/collection-pool/ready-dates/tick/route.ts
Normal file
26
src/app/api/v1/collection-pool/ready-dates/tick/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
77
src/lib/server/collection-ready-dates-service.test.ts
Normal file
77
src/lib/server/collection-ready-dates-service.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
73
src/lib/server/collection-ready-dates-service.ts
Normal file
73
src/lib/server/collection-ready-dates-service.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue