feat(buyer-tasks): listBuyerTasks service with grouping
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
fe1af217e0
commit
463917d755
2 changed files with 350 additions and 0 deletions
239
src/lib/server/buyer-tasks-service.test.ts
Normal file
239
src/lib/server/buyer-tasks-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
src/lib/server/buyer-tasks-service.ts
Normal file
111
src/lib/server/buyer-tasks-service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue