feat: add rep-scoped dashboard data aggregation service

This commit is contained in:
Dave 2026-09-01 01:36:31 +02:00
parent f7b5de4c85
commit 3637eef83a
2 changed files with 462 additions and 0 deletions

View 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();
});
});

View 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 },
};
}