feat(buyer-tasks): listBuyerTasks service with grouping

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 19:52:41 +02:00
parent fe1af217e0
commit 463917d755
2 changed files with 350 additions and 0 deletions

View file

@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listBuyerTasks } from "./buyer-tasks-service";
const baseCompany = {
id: "comp-1",
company: "Acme",
};
const baseCustomer = {
id: "cust-1",
customer: "J. Smith",
company: "comp-1",
};
const baseOrder = {
id: "order-1",
order_number: "ORD-001",
customer: "cust-1",
company: "comp-1",
expand: {
customer: baseCustomer,
company: baseCompany,
},
};
const baseSupplier = {
id: "sup-1",
suppliername: "Steel Co",
};
const basePo = {
id: "po-1",
status: "draft",
};
const baseItem = (overrides: Record<string, unknown> = {}) => ({
id: "item-1",
order: "order-1",
supplier: "sup-1",
purchase_order: "",
item_status: "processed",
description: "HSS drill",
quantity: 2,
cost_price: 25,
selling_price: 50,
expand: {
order: baseOrder,
supplier: baseSupplier,
purchase_order: undefined,
},
...overrides,
});
describe("listBuyerTasks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns groups for items in needs_po state", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [baseItem()],
totalItems: 1,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("needs_po");
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({
orderId: "order-1",
supplierId: "sup-1",
supplierName: "Steel Co",
customerName: "J. Smith",
companyName: "Acme",
itemCount: 1,
totalCost: 50,
step: "needs_po",
});
});
it("groups multiple items from the same (order, supplier) pair", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem(),
baseItem({ id: "item-2", quantity: 3, cost_price: 10 }),
],
totalItems: 2,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("needs_po");
expect(groups).toHaveLength(1);
expect(groups[0].itemCount).toBe(2);
expect(groups[0].totalCost).toBe(80); // 2*25 + 3*10 = 80
});
it("skips items with no supplier", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem(),
baseItem({ id: "item-2", supplier: "", expand: { order: baseOrder, supplier: undefined, purchase_order: undefined } }),
],
totalItems: 2,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("needs_po");
expect(groups).toHaveLength(1);
expect(groups[0].itemCount).toBe(1);
});
it("returns groups filtered by po_draft tab", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem({
purchase_order: "po-1",
item_status: "po_generated",
expand: {
order: baseOrder,
supplier: baseSupplier,
purchase_order: basePo,
},
}),
],
totalItems: 1,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("po_draft");
expect(groups).toHaveLength(1);
expect(groups[0].step).toBe("po_draft");
expect(groups[0].purchaseOrderId).toBe("po-1");
expect(groups[0].poStatus).toBe("draft");
});
it("excludes sent/cancelled POs from po_draft tab", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem({
purchase_order: "po-1",
item_status: "po_generated",
expand: {
order: baseOrder,
supplier: baseSupplier,
purchase_order: { id: "po-1", status: "sent" },
},
}),
],
totalItems: 1,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("po_draft");
expect(groups).toHaveLength(0);
});
it("returns groups for po_sent tab", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem({
purchase_order: "po-1",
item_status: "po_generated",
expand: {
order: baseOrder,
supplier: baseSupplier,
purchase_order: { id: "po-1", status: "sent" },
},
}),
],
totalItems: 1,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("po_sent");
expect(groups).toHaveLength(1);
expect(groups[0].step).toBe("po_sent");
});
it("excludes ready_delivery items from po_sent tab", async () => {
const admin = {
collection: vi.fn().mockReturnValue({
getList: vi.fn().mockResolvedValue({
items: [
baseItem({
purchase_order: "po-1",
item_status: "ready_delivery",
expand: {
order: baseOrder,
supplier: baseSupplier,
purchase_order: { id: "po-1", status: "sent" },
},
}),
],
totalItems: 1,
}),
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const groups = await listBuyerTasks("po_sent");
expect(groups).toHaveLength(0);
});
});

View file

@ -0,0 +1,111 @@
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
export type BuyerTaskGroup = {
groupId: string;
orderId: string;
orderNumber: string;
supplierId: string;
supplierName: string;
customerName: string;
companyName: string;
items: Array<{
id: string;
description: string;
quantity: number;
costPrice: number;
sellingPrice: number;
}>;
itemCount: number;
totalCost: number;
purchaseOrderId: string | null;
poStatus: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled" | null;
step: BuyerTaskTab;
};
/**
* List buyer task groups for the given tab.
*
* Filter strategy: PB filters can't traverse relations, so we fetch
* candidate items with the broadest filter we can build from the
* item_status / purchase_order fields, then post-filter by PO
* status (which lives on the related purchase_orders record).
*/
export async function listBuyerTasks(tab: BuyerTaskTab): Promise<BuyerTaskGroup[]> {
const admin = await getAdminPocketBase();
// Item-level filter narrows the candidates. PO-status filtering
// happens post-fetch because the field lives on a related record.
const itemFilter = tab === "needs_po"
? `item_status = "processed" && purchase_order = ""`
: `purchase_order != "" && item_status != "ready_delivery"`;
const itemsRes = await admin.collection("order_items").getList(1, 500, {
filter: itemFilter,
expand: "order,order.customer,order.customer.company,order.company,supplier,purchase_order",
});
// Group by (orderId, supplierId); skip items with no supplier.
const groups = new Map<string, BuyerTaskGroup>();
for (const item of itemsRes.items) {
const orderId = item.order;
const supplierId = item.supplier;
if (!orderId || !supplierId) continue;
const po = item.expand?.purchase_order as
| { id: string; status: BuyerTaskGroup["poStatus"] }
| undefined;
// Post-filter by PO status (tab-specific).
if (tab === "po_draft" && po?.status !== "draft") continue;
if (tab === "po_sent" && !["sent", "sending"].includes(po?.status ?? "")) continue;
// Exclude ready_delivery items from po_sent (already delivered).
if (tab === "po_sent" && item.item_status === "ready_delivery") continue;
const groupId = `${orderId}::${supplierId}`;
let group = groups.get(groupId);
if (!group) {
const order = item.expand?.order as { order_number?: string } | undefined;
const supplier = item.expand?.supplier as { suppliername?: string } | undefined;
const customer = (item.expand?.order as { customer?: unknown } | undefined)
?.expand?.customer as { customer?: string } | undefined;
const company = (customer?.expand?.company as { company?: string } | undefined)
?? (item.expand?.order as { expand?: { company?: { company?: string } } })?.expand?.company;
group = {
groupId,
orderId,
orderNumber: order?.order_number ?? "",
supplierId,
supplierName: supplier?.suppliername ?? "",
customerName: customer?.customer ?? "",
companyName: company?.company ?? "",
items: [],
itemCount: 0,
totalCost: 0,
purchaseOrderId: po?.id ?? null,
poStatus: po?.status ?? null,
step: tab,
};
groups.set(groupId, group);
}
const quantity = Number(item.quantity) || 0;
const cost = Number(item.cost_price) || 0;
group.items.push({
id: item.id,
description: item.description ?? "",
quantity,
costPrice: cost,
sellingPrice: Number(item.selling_price) || 0,
});
group.itemCount += 1;
group.totalCost += quantity * cost;
}
return Array.from(groups.values()).sort((a, b) => {
if (a.orderNumber !== b.orderNumber) return a.orderNumber.localeCompare(b.orderNumber);
return a.supplierName.localeCompare(b.supplierName);
});
}