feat: add rep-scoped dashboard data aggregation service
This commit is contained in:
parent
f7b5de4c85
commit
3637eef83a
2 changed files with 462 additions and 0 deletions
222
src/lib/server/rep-dashboard-service.test.ts
Normal file
222
src/lib/server/rep-dashboard-service.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { getRepDashboardData } from "./rep-dashboard-service";
|
||||
|
||||
// Local mock — same shape as dashboard-service.test.ts's, extended to
|
||||
// treat dotted field names (e.g. "customer.rep", "order.customer.rep")
|
||||
// as literal keys on the flat fixture objects. This doesn't model real
|
||||
// PB relation traversal; it validates that the service builds the
|
||||
// right filter clauses and aggregates correctly, with fixtures that
|
||||
// pre-flatten the relation chain onto each row (e.g. an order_items
|
||||
// fixture carries an "order.customer.rep" key directly).
|
||||
type StoreMap = Map<string, Record<string, unknown>>;
|
||||
const store: Record<string, StoreMap> = {};
|
||||
|
||||
function seed(collection: string, rows: Record<string, unknown>[]) {
|
||||
const map: StoreMap = new Map();
|
||||
for (const row of rows) map.set(row.id as string, row);
|
||||
store[collection] = map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-aware filter evaluator. Unlike a naive `split("||")` /
|
||||
* `split("&&")`, this respects parens (so `A && (B || C)` is parsed
|
||||
* as AND(A, OR(B, C)), not mis-split into three top-level OR
|
||||
* branches) and skips operators inside quoted string literals.
|
||||
* Needed because this plan's queries mix top-level `&&` with a
|
||||
* parenthesized `||` group (e.g. the outstanding-quotes/orders
|
||||
* filters) — a naive splitter silently mis-evaluates that shape and
|
||||
* would let a wrong-rep row match via a malformed leaf that "fails
|
||||
* open" (see the comment on `evalLeaf` below).
|
||||
*/
|
||||
function splitTopLevel(expr: string, op: "||" | "&&"): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < expr.length; i++) {
|
||||
const c = expr[i];
|
||||
if (c === '"') {
|
||||
i++;
|
||||
while (i < expr.length && expr[i] !== '"') i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")") depth--;
|
||||
else if (depth === 0 && expr.startsWith(op, i)) {
|
||||
parts.push(expr.slice(start, i).trim());
|
||||
i += op.length - 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function stripParens(expr: string): string {
|
||||
const e = expr.trim();
|
||||
if (!e.startsWith("(") || !e.endsWith(")")) return e;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < e.length; i++) {
|
||||
if (e[i] === "(") depth++;
|
||||
else if (e[i] === ")") {
|
||||
depth--;
|
||||
if (depth === 0 && i !== e.length - 1) return e; // not a single wrapping pair
|
||||
}
|
||||
}
|
||||
return e.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
function evalLeaf(item: Record<string, unknown>, leaf: string): boolean {
|
||||
const m = leaf.match(/^([\w.]+)\s*(>=|<=|!=|=|<|>)\s*(?:"([^"]*)"|(\S+))$/);
|
||||
if (!m) return true; // unrecognised leaf — fail open (matches everything)
|
||||
const [, field, op, quoted, unquoted] = m;
|
||||
const expected = quoted ?? unquoted ?? "";
|
||||
const actual = String(item[field] ?? "");
|
||||
switch (op) {
|
||||
case "=": return actual === expected;
|
||||
case "!=": return actual !== expected;
|
||||
case ">=": return actual >= expected;
|
||||
case "<=": return actual <= expected;
|
||||
case ">": return actual > expected;
|
||||
case "<": return actual < expected;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function evalOr(item: Record<string, unknown>, expr: string): boolean {
|
||||
const parts = splitTopLevel(expr, "||");
|
||||
if (parts.length > 1) return parts.some((p) => evalOr(item, p));
|
||||
return evalAnd(item, expr);
|
||||
}
|
||||
|
||||
function evalAnd(item: Record<string, unknown>, expr: string): boolean {
|
||||
const parts = splitTopLevel(expr, "&&");
|
||||
if (parts.length > 1) return parts.every((p) => evalOr(item, p));
|
||||
const stripped = stripParens(expr);
|
||||
if (stripped !== expr.trim()) return evalOr(item, stripped);
|
||||
return evalLeaf(item, stripped);
|
||||
}
|
||||
|
||||
function where(collection: string, filter: string): Record<string, unknown>[] {
|
||||
const items = Array.from((store[collection] ?? new Map()).values());
|
||||
if (!filter || !filter.trim()) return items;
|
||||
return items.filter((item) => evalOr(item, filter.trim()));
|
||||
}
|
||||
|
||||
const pbMock = {
|
||||
autoCancellation: () => undefined,
|
||||
collection: (name: string) => ({
|
||||
async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
|
||||
const all = where(name, opts.filter || "");
|
||||
const totalItems = all.length;
|
||||
const start = (page - 1) * perPage;
|
||||
return {
|
||||
items: all.slice(start, start + perPage),
|
||||
totalItems,
|
||||
totalPages: Math.max(1, Math.ceil(totalItems / perPage)),
|
||||
page,
|
||||
perPage,
|
||||
};
|
||||
},
|
||||
async getFullList(opts: { filter?: string } = {}) {
|
||||
return where(name, opts.filter || "");
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("./pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn(() => Promise.resolve(hoisted.pbMock)),
|
||||
}));
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const store: Record<string, StoreMap> = {};
|
||||
return { store };
|
||||
});
|
||||
|
||||
const REP_ID = "rep1";
|
||||
const OTHER_REP_ID = "rep2";
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z"); // a Thursday
|
||||
|
||||
describe("getRepDashboardData", () => {
|
||||
it("scopes quote counts to the given rep and computes day/week/month buckets", async () => {
|
||||
seed("quotes", [
|
||||
{ id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q1" }, // today
|
||||
{ id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-19T09:00:00.000Z", status: "new", quote_number: "Q2" }, // yesterday
|
||||
{ id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-15T09:00:00.000Z", status: "new", quote_number: "Q3" }, // a prior week — not asserted on directly, just adds noise the filters must exclude from today/yesterday
|
||||
{ id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q4" }, // different rep
|
||||
{ id: "q5", "customer.rep": REP_ID, is_template: true, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q5" }, // template, excluded
|
||||
]);
|
||||
seed("order_items", []);
|
||||
seed("orders", []);
|
||||
|
||||
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
|
||||
|
||||
expect(data.quotesLoaded.today).toBe(1);
|
||||
expect(data.quotesLoaded.yesterday).toBe(1);
|
||||
expect(data.quoteVolumeTrend).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("only counts outstanding quotes in new/claimed status for this rep", async () => {
|
||||
seed("quotes", [
|
||||
{ id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q1" },
|
||||
{ id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "claimed", quote_number: "Q2" },
|
||||
{ id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "complete", quote_number: "Q3" },
|
||||
{ id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q4" },
|
||||
]);
|
||||
seed("order_items", []);
|
||||
seed("orders", []);
|
||||
|
||||
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
|
||||
|
||||
expect(data.outstandingQuotes.total).toBe(2);
|
||||
expect(data.outstandingQuotes.items.map((i) => i.quote_number).sort()).toEqual(["Q1", "Q2"]);
|
||||
});
|
||||
|
||||
it("counts outstanding orders as distinct orders with a 'new'-tab item for this rep", async () => {
|
||||
seed("quotes", []);
|
||||
seed("order_items", [
|
||||
{ id: "i1", order: "o1", "order.customer.rep": REP_ID, item_status: "pending" },
|
||||
{ id: "i2", order: "o1", "order.customer.rep": REP_ID, item_status: "processed" }, // same order, not new-tab — still counted once via i1
|
||||
{ id: "i3", order: "o2", "order.customer.rep": REP_ID, item_status: "pending" },
|
||||
{ id: "i4", order: "o3", "order.customer.rep": OTHER_REP_ID, item_status: "pending" }, // different rep
|
||||
]);
|
||||
seed("orders", [
|
||||
{ id: "o1", order_number: "ORD1" },
|
||||
{ id: "o2", order_number: "ORD2" },
|
||||
]);
|
||||
|
||||
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
|
||||
|
||||
expect(data.outstandingOrders.total).toBe(2);
|
||||
expect(data.outstandingOrders.items.map((i) => i.order_number).sort()).toEqual(["ORD1", "ORD2"]);
|
||||
});
|
||||
|
||||
it("sums selling/cost and computes markup % for this month's order items", async () => {
|
||||
seed("quotes", []);
|
||||
seed("orders", []);
|
||||
seed("order_items", [
|
||||
{ id: "i1", "order.customer.rep": REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 2, selling_price: 100, cost_price: 50 },
|
||||
{ id: "i2", "order.customer.rep": REP_ID, created: "2026-08-10T00:00:00.000Z", quantity: 1, selling_price: 40, cost_price: 20 },
|
||||
{ id: "i3", "order.customer.rep": REP_ID, created: "2026-07-31T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // last month, excluded
|
||||
{ id: "i4", "order.customer.rep": OTHER_REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // different rep
|
||||
]);
|
||||
|
||||
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
|
||||
|
||||
// selling = 2*100 + 1*40 = 240; cost = 2*50 + 1*20 = 120
|
||||
expect(data.monthRevenue.selling).toBe(240);
|
||||
expect(data.monthRevenue.cost).toBe(120);
|
||||
expect(data.monthRevenue.markupPct).toBe(100); // (240-120)/120 * 100
|
||||
});
|
||||
|
||||
it("returns markupPct: null when there's no cost this month (no div-by-zero)", async () => {
|
||||
seed("quotes", []);
|
||||
seed("orders", []);
|
||||
seed("order_items", []);
|
||||
|
||||
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
|
||||
|
||||
expect(data.monthRevenue.selling).toBe(0);
|
||||
expect(data.monthRevenue.cost).toBe(0);
|
||||
expect(data.monthRevenue.markupPct).toBeNull();
|
||||
});
|
||||
});
|
||||
240
src/lib/server/rep-dashboard-service.ts
Normal file
240
src/lib/server/rep-dashboard-service.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { getOrderItemsCollectionName } from "./orders-service";
|
||||
import { tabGroupFilter } from "@/lib/item-status-ui";
|
||||
|
||||
const QUOTES_COLLECTION = "quotes";
|
||||
const ORDERS_COLLECTION = "orders";
|
||||
|
||||
const QUOTE_VOLUME_WEEKS = 8;
|
||||
const OUTSTANDING_LIST_CAP = 8;
|
||||
const MONTH_ORDER_ITEM_LIMIT = 1000;
|
||||
|
||||
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
||||
|
||||
export interface RepDashboardData {
|
||||
quotesLoaded: {
|
||||
today: number;
|
||||
yesterday: number;
|
||||
thisWeek: number;
|
||||
lastWeek: number;
|
||||
thisMonth: number;
|
||||
lastMonth: number;
|
||||
};
|
||||
quoteVolumeTrend: { weekStart: string; count: number }[];
|
||||
outstandingQuotes: {
|
||||
total: number;
|
||||
items: { id: string; quote_number: string; customer_name: string | null }[];
|
||||
};
|
||||
outstandingOrders: {
|
||||
total: number;
|
||||
items: { id: string; order_number: string; customer_name: string | null }[];
|
||||
};
|
||||
monthRevenue: {
|
||||
selling: number;
|
||||
cost: number;
|
||||
markupPct: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- date helpers (Monday-anchored weeks, UTC) ---------- */
|
||||
|
||||
function startOfDayUtc(d: Date): Date {
|
||||
const out = new Date(d);
|
||||
out.setUTCHours(0, 0, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function addDaysUtc(d: Date, days: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setUTCDate(out.getUTCDate() + days);
|
||||
return out;
|
||||
}
|
||||
|
||||
function startOfWeekUtc(d: Date): Date {
|
||||
const out = startOfDayUtc(d);
|
||||
const day = out.getUTCDay();
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week
|
||||
out.setUTCDate(out.getUTCDate() + diff);
|
||||
return out;
|
||||
}
|
||||
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
function addMonthsUtc(d: Date, months: number): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1));
|
||||
}
|
||||
|
||||
/** Escapes a value for embedding inside a PB filter string literal. */
|
||||
function esc(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function rangeFilter(field: string, start: Date, end: Date): string {
|
||||
return `${field} >= "${start.toISOString()}" && ${field} < "${end.toISOString()}"`;
|
||||
}
|
||||
|
||||
/** Count-only query — reads PB's own `totalItems` via a `perPage=1` page. */
|
||||
async function countWhere(
|
||||
pb: AdminPocketBase,
|
||||
collection: string,
|
||||
filter: string,
|
||||
): Promise<number> {
|
||||
const result = await pb.collection(collection).getList(1, 1, {
|
||||
filter,
|
||||
fields: "id",
|
||||
});
|
||||
return result.totalItems || 0;
|
||||
}
|
||||
|
||||
export async function getRepDashboardData(
|
||||
repId: string,
|
||||
now: Date = new Date(),
|
||||
pbArg?: AdminPocketBase,
|
||||
): Promise<RepDashboardData> {
|
||||
const pb = pbArg ?? (await getAdminPocketBase());
|
||||
pb.autoCancellation(false);
|
||||
|
||||
const repFilter = `customer.rep = "${esc(repId)}"`;
|
||||
const orderRepFilter = `order.customer.rep = "${esc(repId)}"`;
|
||||
|
||||
/* ---------- quotesLoaded (6 count-only queries) ---------- */
|
||||
const todayStart = startOfDayUtc(now);
|
||||
const yesterdayStart = addDaysUtc(todayStart, -1);
|
||||
const tomorrowStart = addDaysUtc(todayStart, 1);
|
||||
const thisWeekStart = startOfWeekUtc(now);
|
||||
const lastWeekStart = addDaysUtc(thisWeekStart, -7);
|
||||
const nextWeekStart = addDaysUtc(thisWeekStart, 7);
|
||||
const thisMonthStart = startOfMonthUtc(now);
|
||||
const lastMonthStart = addMonthsUtc(thisMonthStart, -1);
|
||||
const nextMonthStart = addMonthsUtc(thisMonthStart, 1);
|
||||
|
||||
const quoteBaseFilter = `${repFilter} && is_template != true`;
|
||||
|
||||
const [
|
||||
todayCount,
|
||||
yesterdayCount,
|
||||
thisWeekCount,
|
||||
lastWeekCount,
|
||||
thisMonthCount,
|
||||
lastMonthCount,
|
||||
] = await Promise.all([
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", todayStart, tomorrowStart)}`),
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", yesterdayStart, todayStart)}`),
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisWeekStart, nextWeekStart)}`),
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastWeekStart, thisWeekStart)}`),
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`),
|
||||
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastMonthStart, thisMonthStart)}`),
|
||||
]);
|
||||
|
||||
/* ---------- quoteVolumeTrend (8 weekly count-only queries) ---------- */
|
||||
const weekStarts: Date[] = [];
|
||||
for (let i = QUOTE_VOLUME_WEEKS - 1; i >= 0; i--) {
|
||||
weekStarts.push(addDaysUtc(thisWeekStart, -7 * i));
|
||||
}
|
||||
const quoteVolumeTrend = await Promise.all(
|
||||
weekStarts.map(async (weekStart) => {
|
||||
const weekEnd = addDaysUtc(weekStart, 7);
|
||||
const count = await countWhere(
|
||||
pb,
|
||||
QUOTES_COLLECTION,
|
||||
`${quoteBaseFilter} && ${rangeFilter("created", weekStart, weekEnd)}`,
|
||||
);
|
||||
return { weekStart: weekStart.toISOString().slice(0, 10), count };
|
||||
}),
|
||||
);
|
||||
|
||||
/* ---------- outstandingQuotes ---------- */
|
||||
const outstandingQuotesResult = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getList(1, OUTSTANDING_LIST_CAP, {
|
||||
filter: `${repFilter} && (status = "new" || status = "claimed") && is_template != true`,
|
||||
sort: "-created",
|
||||
fields: "id,quote_number",
|
||||
expand: "customer",
|
||||
});
|
||||
const outstandingQuotes = {
|
||||
total: outstandingQuotesResult.totalItems || 0,
|
||||
items: (outstandingQuotesResult.items as unknown as Array<{
|
||||
id: string;
|
||||
quote_number: string;
|
||||
expand?: { customer?: { customer?: string } };
|
||||
}>).map((q) => ({
|
||||
id: q.id,
|
||||
quote_number: q.quote_number,
|
||||
customer_name: q.expand?.customer?.customer ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
/* ---------- outstandingOrders ---------- */
|
||||
const outstandingOrderItems = await pb
|
||||
.collection(getOrderItemsCollectionName())
|
||||
.getFullList<{ order?: string }>({
|
||||
filter: `${orderRepFilter} && (${tabGroupFilter("new")})`,
|
||||
fields: "order",
|
||||
});
|
||||
const outstandingOrderIds = Array.from(
|
||||
new Set(outstandingOrderItems.map((it) => it.order).filter((id): id is string => !!id)),
|
||||
);
|
||||
let outstandingOrdersItems: { id: string; order_number: string; customer_name: string | null }[] = [];
|
||||
if (outstandingOrderIds.length > 0) {
|
||||
const capped = outstandingOrderIds.slice(0, OUTSTANDING_LIST_CAP);
|
||||
const idsFilter = capped.map((id) => `id = "${esc(id)}"`).join(" || ");
|
||||
const ordersResult = await pb.collection(ORDERS_COLLECTION).getFullList<{
|
||||
id: string;
|
||||
order_number: string;
|
||||
expand?: { customer?: { customer?: string } };
|
||||
}>({
|
||||
filter: idsFilter,
|
||||
sort: "-created",
|
||||
fields: "id,order_number",
|
||||
expand: "customer",
|
||||
});
|
||||
outstandingOrdersItems = ordersResult.map((o) => ({
|
||||
id: o.id,
|
||||
order_number: o.order_number,
|
||||
customer_name: o.expand?.customer?.customer ?? null,
|
||||
}));
|
||||
}
|
||||
const outstandingOrders = {
|
||||
total: outstandingOrderIds.length,
|
||||
items: outstandingOrdersItems,
|
||||
};
|
||||
|
||||
/* ---------- monthRevenue ---------- */
|
||||
const monthOrderItems = await pb
|
||||
.collection(getOrderItemsCollectionName())
|
||||
.getList(1, MONTH_ORDER_ITEM_LIMIT, {
|
||||
filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`,
|
||||
fields: "id,quantity,order_qty,selling_price,cost_price",
|
||||
});
|
||||
let selling = 0;
|
||||
let cost = 0;
|
||||
for (const it of monthOrderItems.items as unknown as Array<{
|
||||
quantity?: number | null;
|
||||
order_qty?: number | null;
|
||||
selling_price?: number | null;
|
||||
cost_price?: number | null;
|
||||
}>) {
|
||||
const qty = Number(it.quantity ?? it.order_qty ?? 0);
|
||||
selling += Number(it.selling_price ?? 0) * qty;
|
||||
cost += Number(it.cost_price ?? 0) * qty;
|
||||
}
|
||||
const markupPct = cost > 0 ? ((selling - cost) / cost) * 100 : null;
|
||||
|
||||
return {
|
||||
quotesLoaded: {
|
||||
today: todayCount,
|
||||
yesterday: yesterdayCount,
|
||||
thisWeek: thisWeekCount,
|
||||
lastWeek: lastWeekCount,
|
||||
thisMonth: thisMonthCount,
|
||||
lastMonth: lastMonthCount,
|
||||
},
|
||||
quoteVolumeTrend,
|
||||
outstandingQuotes,
|
||||
outstandingOrders,
|
||||
monthRevenue: { selling, cost, markupPct },
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue