437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
|
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Server-side dashboard read model for `/dashboard`.
|
|||
|
|
*
|
|||
|
|
* Why this exists:
|
|||
|
|
*
|
|||
|
|
* The page used to fire 11 parallel PocketBase calls straight from the browser,
|
|||
|
|
* each pulling the full record shape (`email_body`, `migration_meta`,
|
|||
|
|
* `qb_invoice_pdf`, etc.) even though the dashboard only consumes a few
|
|||
|
|
* fields per row. On the live PB (`pocketbase-gnqc88suy8fqk6wyryjjdpvl…
|
|||
|
|
* sslip.io`) that cost ~720ms cold wall-clock and ~500KB of useless payload
|
|||
|
|
* just to draw charts.
|
|||
|
|
*
|
|||
|
|
* This service collapses the dashboard into a single server round-trip that
|
|||
|
|
* returns a small, fixed-shape payload (~10KB) and applies all the
|
|||
|
|
* bucketing / aggregation work on the server side using `Promise.all`.
|
|||
|
|
*
|
|||
|
|
* The page can then render from one request, and realtime re-fires are
|
|||
|
|
* debounced on the client so a burst of changes doesn't re-pull 11 queries
|
|||
|
|
* back-to-back.
|
|||
|
|
*
|
|||
|
|
* Hard rules:
|
|||
|
|
*
|
|||
|
|
* - Every list query uses `fields=` projection to drop the heavy blobs.
|
|||
|
|
* If you add a field to the page, add it here too — never widen a query
|
|||
|
|
* to "all fields by default".
|
|||
|
|
* - Status buckets + top customers are computed from the SAME 500-row
|
|||
|
|
* sample the historical charts use, with a `sampled` flag so the page
|
|||
|
|
* can show "n of N" when the buckets are incomplete. The previous
|
|||
|
|
* dashboard showed 6,076 active quotes on the KPI while the donut
|
|||
|
|
* showed 97 approved — a clear mismatch we're fixing in one place.
|
|||
|
|
* - Counts use `getList(1, 1)` with a filter and the `id` field
|
|||
|
|
* projected, never `getFullList`. `getFullList` defaults to 500
|
|||
|
|
* records and transfers them all just to compute the count.
|
|||
|
|
* - Follow-ups use a tight `perPage=50` ceiling (we only display 8 cards).
|
|||
|
|
* - Order-item weeks use `fields="created,quantity,order_qty,selling_price,item_status"`
|
|||
|
|
* so each row is ~70 bytes instead of ~1KB.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const CUSTOMERS_COLLECTION = "customers";
|
|||
|
|
const QUOTES_COLLECTION = "quotes";
|
|||
|
|
const ORDERS_COLLECTION = "orders";
|
|||
|
|
const ORDER_ITEMS_COLLECTION = "order_items";
|
|||
|
|
const STOCK_ITEMS_COLLECTION = "stock_items";
|
|||
|
|
|
|||
|
|
const SAMPLE_WINDOW_DAYS = 84; // 12 weeks
|
|||
|
|
const WEEK_BUCKETS = 12;
|
|||
|
|
const ORDERS_BUCKETS = 8;
|
|||
|
|
const QUOTE_SAMPLE_LIMIT = 500;
|
|||
|
|
const ORDER_SAMPLE_LIMIT = 500;
|
|||
|
|
const CUSTOMER_SAMPLE_LIMIT = 500;
|
|||
|
|
const ORDER_ITEM_SAMPLE_LIMIT = 500;
|
|||
|
|
const ORDER_ITEM_MONTH_LIMIT = 500;
|
|||
|
|
const FOLLOW_UP_LIMIT = 50;
|
|||
|
|
const TOP_CUSTOMERS = 6;
|
|||
|
|
|
|||
|
|
export interface DashboardStats {
|
|||
|
|
totalCustomers: number;
|
|||
|
|
activeQuotes: number;
|
|||
|
|
ordersThisMonth: number;
|
|||
|
|
totalRevenue: number;
|
|||
|
|
/** True if the 500-row sample was a subset of a larger matching set. */
|
|||
|
|
sampled: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface WeekPoint {
|
|||
|
|
weekStart: string; // ISO date (YYYY-MM-DD)
|
|||
|
|
count: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface RevenuePoint {
|
|||
|
|
weekStart: string;
|
|||
|
|
count: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface QuoteStatusBucket {
|
|||
|
|
status: string;
|
|||
|
|
label: string;
|
|||
|
|
count: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface TopCustomer {
|
|||
|
|
name: string;
|
|||
|
|
count: number;
|
|||
|
|
color: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface StockSummary {
|
|||
|
|
totalItems: number;
|
|||
|
|
totalQty: number;
|
|||
|
|
lowStockCount: number;
|
|||
|
|
outOfStockCount: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FollowUpQuote {
|
|||
|
|
id: string;
|
|||
|
|
quote_number: string;
|
|||
|
|
follow_up_due: string;
|
|||
|
|
customer_name: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface DashboardData {
|
|||
|
|
stats: DashboardStats;
|
|||
|
|
/** 12-week customer creation count, oldest → newest. */
|
|||
|
|
customersByWeek: WeekPoint[];
|
|||
|
|
/** 8-week order creation count, oldest → newest. */
|
|||
|
|
ordersByWeek: WeekPoint[];
|
|||
|
|
/** 8-week fulfilled item count (delivered/invoiced/completed/checked_in). */
|
|||
|
|
fulfilledByWeek: WeekPoint[];
|
|||
|
|
/** 12-week quote creation count, oldest → newest. */
|
|||
|
|
quotesByWeek: WeekPoint[];
|
|||
|
|
/** 12-week revenue (selling_price × qty), oldest → newest. */
|
|||
|
|
revenueByWeek: RevenuePoint[];
|
|||
|
|
/** Sorted by count desc, capped at 6. */
|
|||
|
|
quoteStatusBuckets: QuoteStatusBucket[];
|
|||
|
|
/** Top customers by quote count (within the 12-week sample). */
|
|||
|
|
topCustomers: TopCustomer[];
|
|||
|
|
stockSummary: StockSummary;
|
|||
|
|
followUps: FollowUpQuote[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ============================================================================
|
|||
|
|
* Status metadata (kept in sync with the page render)
|
|||
|
|
* ========================================================================== */
|
|||
|
|
const QUOTE_STATUS_META: Record<string, { label: string }> = {
|
|||
|
|
new: { label: "New" },
|
|||
|
|
claimed: { label: "Claimed" },
|
|||
|
|
processed: { label: "Processed" },
|
|||
|
|
emailed: { label: "Emailed" },
|
|||
|
|
approval_pending: { label: "Approval pending" },
|
|||
|
|
pending_approvals: { label: "Approval pending" },
|
|||
|
|
approved: { label: "Approved" },
|
|||
|
|
complete: { label: "Complete" },
|
|||
|
|
rejected: { label: "Rejected" },
|
|||
|
|
cancelled: { label: "Cancelled" },
|
|||
|
|
problem: { label: "Problem" },
|
|||
|
|
email_failed: { label: "Email failed" },
|
|||
|
|
follow_up: { label: "Follow-up" },
|
|||
|
|
template: { label: "Template" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TOP_PALETTE = [
|
|||
|
|
"#06B6D4", // cyan
|
|||
|
|
"#EC4899", // pink
|
|||
|
|
"#F59E0B", // gold
|
|||
|
|
"#F97316", // orange
|
|||
|
|
"#A855F7", // purple
|
|||
|
|
"#10B981", // green
|
|||
|
|
"#3B82F6", // blue
|
|||
|
|
"#EF4444", // red
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const FULFILLED_STATUSES = new Set([
|
|||
|
|
"delivered",
|
|||
|
|
"invoiced",
|
|||
|
|
"completed",
|
|||
|
|
"checked_in",
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
/* ============================================================================
|
|||
|
|
* Week helpers
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function startOfWeekUtc(d: Date): Date {
|
|||
|
|
const out = new Date(d);
|
|||
|
|
const day = out.getUTCDay();
|
|||
|
|
const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week
|
|||
|
|
out.setUTCDate(out.getUTCDate() + diff);
|
|||
|
|
out.setUTCHours(0, 0, 0, 0);
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isoWeekKey(d: Date): string {
|
|||
|
|
return startOfWeekUtc(d).toISOString().slice(0, 10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildWeekBuckets(n: number, now: Date): Record<string, number> {
|
|||
|
|
const nowWeek = startOfWeekUtc(now);
|
|||
|
|
const out: Record<string, number> = {};
|
|||
|
|
for (let i = n - 1; i >= 0; i--) {
|
|||
|
|
const w = new Date(nowWeek);
|
|||
|
|
w.setUTCDate(w.getUTCDate() - i * 7);
|
|||
|
|
out[isoWeekKey(w)] = 0;
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bucketsToWeekPoints(b: Record<string, number>): WeekPoint[] {
|
|||
|
|
return Object.entries(b)
|
|||
|
|
.sort(([a], [z]) => a.localeCompare(z))
|
|||
|
|
.map(([weekStart, count]) => ({ weekStart, count }));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Accept the optional `pb` from the caller so the test suite can inject a
|
|||
|
|
* mock without touching the admin auth cache. Production callers omit it.
|
|||
|
|
*/
|
|||
|
|
export async function getDashboardData(
|
|||
|
|
now: Date = new Date(),
|
|||
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|||
|
|
): Promise<DashboardData> {
|
|||
|
|
const pb = pbArg ?? (await getAdminPocketBase());
|
|||
|
|
pb.autoCancellation(false);
|
|||
|
|
|
|||
|
|
const startOfMonth = new Date(
|
|||
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
|
|||
|
|
).toISOString();
|
|||
|
|
const sampleSince = new Date(
|
|||
|
|
now.getTime() - SAMPLE_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
|||
|
|
).toISOString();
|
|||
|
|
|
|||
|
|
const [
|
|||
|
|
customersTotal,
|
|||
|
|
activeQuotesCount,
|
|||
|
|
ordersMonthCount,
|
|||
|
|
quotesAllSample,
|
|||
|
|
ordersSample,
|
|||
|
|
customersSample,
|
|||
|
|
orderItemsMonth,
|
|||
|
|
orderItemsSample,
|
|||
|
|
quotesSample,
|
|||
|
|
stockItems,
|
|||
|
|
followUps,
|
|||
|
|
] = await Promise.all([
|
|||
|
|
// KPI counts — `fields="id"` so the response is one id + totalItems,
|
|||
|
|
// never the full record.
|
|||
|
|
pb.collection(CUSTOMERS_COLLECTION).getList(1, 1, { fields: "id" }),
|
|||
|
|
pb.collection(QUOTES_COLLECTION).getList(1, 1, {
|
|||
|
|
filter:
|
|||
|
|
'(status = "new" || status = "claimed" || status = "processed" || status = "emailed" || status = "approval_pending" || status = "pending_approvals" || status = "follow_up" || status = "problem") && is_template != true',
|
|||
|
|
fields: "id",
|
|||
|
|
}),
|
|||
|
|
pb.collection(ORDERS_COLLECTION).getList(1, 1, {
|
|||
|
|
filter: `created >= "${startOfMonth}"`,
|
|||
|
|
fields: "id",
|
|||
|
|
}),
|
|||
|
|
// Sampled status buckets + top-customers source.
|
|||
|
|
// `customer.expand` only needs the customer's display name — projects
|
|||
|
|
// the customer's `customer` field so the full customer record isn't
|
|||
|
|
// returned.
|
|||
|
|
pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, {
|
|||
|
|
filter: "is_template != true",
|
|||
|
|
fields: "id,status,created",
|
|||
|
|
expand: "customer.customer",
|
|||
|
|
}),
|
|||
|
|
pb.collection(ORDERS_COLLECTION).getList(1, ORDER_SAMPLE_LIMIT, {
|
|||
|
|
sort: "-created",
|
|||
|
|
filter: `created >= "${sampleSince}"`,
|
|||
|
|
fields: "id,created",
|
|||
|
|
}),
|
|||
|
|
pb.collection(CUSTOMERS_COLLECTION).getList(1, CUSTOMER_SAMPLE_LIMIT, {
|
|||
|
|
sort: "-created",
|
|||
|
|
filter: `created >= "${sampleSince}"`,
|
|||
|
|
fields: "id,created",
|
|||
|
|
}),
|
|||
|
|
pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_MONTH_LIMIT, {
|
|||
|
|
filter: `created >= "${startOfMonth}"`,
|
|||
|
|
fields: "id,quantity,order_qty,selling_price",
|
|||
|
|
}),
|
|||
|
|
pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_SAMPLE_LIMIT, {
|
|||
|
|
filter: `created >= "${sampleSince}"`,
|
|||
|
|
fields: "id,created,item_status,quantity,order_qty,selling_price",
|
|||
|
|
}),
|
|||
|
|
pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, {
|
|||
|
|
filter: `created >= "${sampleSince}" && is_template != true`,
|
|||
|
|
fields: "id,created",
|
|||
|
|
}),
|
|||
|
|
// Stock items: 115 rows on live, but use fields projection so we don't
|
|||
|
|
// ever ship the `cost_history` blob (which can grow over time).
|
|||
|
|
pb.collection(STOCK_ITEMS_COLLECTION).getList(1, 200, {
|
|||
|
|
filter: "is_active = true",
|
|||
|
|
fields:
|
|||
|
|
"id,sku,name,description,location,current_qty,min_qty,target_qty,is_active",
|
|||
|
|
}),
|
|||
|
|
pb.collection(QUOTES_COLLECTION).getList(1, FOLLOW_UP_LIMIT, {
|
|||
|
|
sort: "follow_up_due",
|
|||
|
|
filter: `follow_up_due != "" && follow_up_due <= "${now.toISOString()}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`,
|
|||
|
|
fields: "id,quote_number,follow_up_due",
|
|||
|
|
expand: "customer.customer",
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
/* ---------- Month revenue + sampled flag ---------- */
|
|||
|
|
let monthRevenue = 0;
|
|||
|
|
for (const it of orderItemsMonth.items as Array<{
|
|||
|
|
selling_price?: number | null;
|
|||
|
|
quantity?: number | null;
|
|||
|
|
order_qty?: number | null;
|
|||
|
|
}>) {
|
|||
|
|
monthRevenue +=
|
|||
|
|
Number(it.selling_price ?? 0) * Number(it.quantity ?? it.order_qty ?? 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const totalQuotesInSample = (quotesAllSample.items as Array<unknown>).length;
|
|||
|
|
const sampleLimitReached =
|
|||
|
|
totalQuotesInSample >= QUOTE_SAMPLE_LIMIT &&
|
|||
|
|
(quotesAllSample.totalItems ?? 0) > QUOTE_SAMPLE_LIMIT;
|
|||
|
|
|
|||
|
|
/* ---------- Week buckets ---------- */
|
|||
|
|
const customerWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
|
|||
|
|
for (const c of customersSample.items as unknown as Array<{ created: string }>) {
|
|||
|
|
const k = isoWeekKey(new Date(c.created));
|
|||
|
|
if (k in customerWeeks) customerWeeks[k] += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const orderWeeks = buildWeekBuckets(ORDERS_BUCKETS, now);
|
|||
|
|
for (const o of ordersSample.items as unknown as Array<{ created: string }>) {
|
|||
|
|
const k = isoWeekKey(new Date(o.created));
|
|||
|
|
if (k in orderWeeks) orderWeeks[k] += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fulfilledWeeks = buildWeekBuckets(ORDERS_BUCKETS, now);
|
|||
|
|
const revenueWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
|
|||
|
|
for (const it of orderItemsSample.items as unknown as Array<{
|
|||
|
|
created: string;
|
|||
|
|
item_status?: string;
|
|||
|
|
selling_price?: number | null;
|
|||
|
|
quantity?: number | null;
|
|||
|
|
order_qty?: number | null;
|
|||
|
|
}>) {
|
|||
|
|
const k = isoWeekKey(new Date(it.created));
|
|||
|
|
if (FULFILLED_STATUSES.has(it.item_status ?? "") && k in fulfilledWeeks) {
|
|||
|
|
fulfilledWeeks[k] += 1;
|
|||
|
|
}
|
|||
|
|
if (k in revenueWeeks) {
|
|||
|
|
revenueWeeks[k] +=
|
|||
|
|
Number(it.selling_price ?? 0) *
|
|||
|
|
Number(it.quantity ?? it.order_qty ?? 0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const quoteWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
|
|||
|
|
for (const q of quotesSample.items as unknown as Array<{ created: string }>) {
|
|||
|
|
const k = isoWeekKey(new Date(q.created));
|
|||
|
|
if (k in quoteWeeks) quoteWeeks[k] += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------- Status buckets + top customers ---------- */
|
|||
|
|
const statusCounts: Record<string, number> = {};
|
|||
|
|
const customerQuoteCount: Record<string, number> = {};
|
|||
|
|
for (const q of quotesAllSample.items as unknown as Array<{
|
|||
|
|
status: string;
|
|||
|
|
expand?: { customer?: { customer?: string } };
|
|||
|
|
}>) {
|
|||
|
|
statusCounts[q.status] = (statusCounts[q.status] ?? 0) + 1;
|
|||
|
|
const name = q.expand?.customer?.customer;
|
|||
|
|
if (name) {
|
|||
|
|
customerQuoteCount[name] = (customerQuoteCount[name] ?? 0) + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const quoteStatusBuckets: QuoteStatusBucket[] = Object.entries(statusCounts)
|
|||
|
|
.map(([status, count]) => ({
|
|||
|
|
status,
|
|||
|
|
label: QUOTE_STATUS_META[status]?.label ?? status,
|
|||
|
|
count,
|
|||
|
|
}))
|
|||
|
|
.sort((a, b) => b.count - a.count);
|
|||
|
|
|
|||
|
|
const topCustomers: TopCustomer[] = Object.entries(customerQuoteCount)
|
|||
|
|
.map(([name, count], i) => ({
|
|||
|
|
name,
|
|||
|
|
count,
|
|||
|
|
color: TOP_PALETTE[i % TOP_PALETTE.length],
|
|||
|
|
}))
|
|||
|
|
.sort((a, b) => b.count - a.count)
|
|||
|
|
.slice(0, TOP_CUSTOMERS);
|
|||
|
|
|
|||
|
|
/* ---------- Stock summary ---------- */
|
|||
|
|
let totalQty = 0;
|
|||
|
|
let outOfStockCount = 0;
|
|||
|
|
let lowStockCount = 0;
|
|||
|
|
for (const s of stockItems.items as unknown as Array<{
|
|||
|
|
current_qty?: number | null;
|
|||
|
|
min_qty?: number | null;
|
|||
|
|
}>) {
|
|||
|
|
totalQty += Number(s.current_qty ?? 0);
|
|||
|
|
const cur = Number(s.current_qty ?? 0);
|
|||
|
|
const min = Number(s.min_qty ?? 0);
|
|||
|
|
if (cur <= 0) outOfStockCount += 1;
|
|||
|
|
else if (cur <= min) lowStockCount += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------- Follow-ups ---------- */
|
|||
|
|
const followUpsList: FollowUpQuote[] = (followUps.items as unknown as Array<{
|
|||
|
|
id: string;
|
|||
|
|
quote_number: string;
|
|||
|
|
follow_up_due: string;
|
|||
|
|
expand?: { customer?: { customer?: string } };
|
|||
|
|
}>).map((q) => ({
|
|||
|
|
id: q.id,
|
|||
|
|
quote_number: q.quote_number,
|
|||
|
|
follow_up_due: q.follow_up_due,
|
|||
|
|
customer_name: q.expand?.customer?.customer ?? null,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
stats: {
|
|||
|
|
totalCustomers: customersTotal.totalItems,
|
|||
|
|
activeQuotes: activeQuotesCount.totalItems,
|
|||
|
|
ordersThisMonth: ordersMonthCount.totalItems,
|
|||
|
|
totalRevenue: Math.round(monthRevenue * 100) / 100,
|
|||
|
|
sampled: sampleLimitReached,
|
|||
|
|
},
|
|||
|
|
customersByWeek: bucketsToWeekPoints(customerWeeks),
|
|||
|
|
ordersByWeek: bucketsToWeekPoints(orderWeeks),
|
|||
|
|
fulfilledByWeek: bucketsToWeekPoints(fulfilledWeeks),
|
|||
|
|
quotesByWeek: bucketsToWeekPoints(quoteWeeks),
|
|||
|
|
revenueByWeek: bucketsToWeekPoints(revenueWeeks).map((p) => ({
|
|||
|
|
weekStart: p.weekStart,
|
|||
|
|
count: Math.round(p.count * 100) / 100,
|
|||
|
|
})),
|
|||
|
|
quoteStatusBuckets,
|
|||
|
|
topCustomers,
|
|||
|
|
stockSummary: {
|
|||
|
|
totalItems: stockItems.totalItems,
|
|||
|
|
totalQty,
|
|||
|
|
lowStockCount,
|
|||
|
|
outOfStockCount,
|
|||
|
|
},
|
|||
|
|
followUps: followUpsList,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Internal helper for tests + the daily cron that warms the cache. Not
|
|||
|
|
* exposed via the API yet — included so future cache-aware callers don't
|
|||
|
|
* reinvent it.
|
|||
|
|
*/
|
|||
|
|
export const _internals = {
|
|||
|
|
startOfWeekUtc,
|
|||
|
|
isoWeekKey,
|
|||
|
|
buildWeekBuckets,
|
|||
|
|
bucketsToWeekPoints,
|
|||
|
|
QUOTE_STATUS_META,
|
|||
|
|
FULFILLED_STATUSES,
|
|||
|
|
};
|