This commit adds the complete v2/v3 stock management functionality: - add new stock-specific item statuses and update the status visual map - implement full stock purchase API suite (create, list, retrieve, send, receive) - reorganize sidebar navigation to group stock-related pages together - add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic - add database migration for the `stock_items.target_qty` field - update type definitions and add comprehensive tests for all new stock components
1618 lines
58 KiB
TypeScript
1618 lines
58 KiB
TypeScript
import getPocketBase from "./pocketbase";
|
||
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem } from "./types";
|
||
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
|
||
import { buildPriceListSearchFilter } from "./price-list-search";
|
||
import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match";
|
||
|
||
export async function getCustomers(
|
||
page = 1,
|
||
perPage = 50,
|
||
search = "",
|
||
sort = "-created",
|
||
expand?: string
|
||
) {
|
||
const pb = getPocketBase();
|
||
// Note: `company` was converted from text to a relation; the old
|
||
// `company ~ "${search}"` filter clause no longer makes sense (it's
|
||
// matching against the relation id, not a name). Search now hits
|
||
// name / email / phone only. To find a customer by company name,
|
||
// start from the Companies page.
|
||
const filter = search
|
||
? `customer ~ "${search}" || email ~ "${search}" || number ~ "${search}"`
|
||
: "";
|
||
return pb.collection("customers").getList<Customer>(page, perPage, {
|
||
filter,
|
||
sort,
|
||
expand,
|
||
});
|
||
}
|
||
|
||
export async function getCustomer(id: string, expand?: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("customers").getOne<Customer>(id, { expand });
|
||
}
|
||
|
||
export async function getAllCustomers(expand?: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("customers").getFullList<Customer>({
|
||
sort: "-created",
|
||
expand,
|
||
});
|
||
}
|
||
|
||
export async function getCompanies(page = 1, perPage = 50, search = "", sort = "-created") {
|
||
const pb = getPocketBase();
|
||
const filter = search
|
||
? `company ~ "${search}" || vendor ~ "${search}" || company_email ~ "${search}" || company_tel ~ "${search}"`
|
||
: "";
|
||
return pb.collection("companies").getList<Company>(page, perPage, { filter, sort });
|
||
}
|
||
|
||
export async function getCompany(id: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("companies").getOne<Company>(id);
|
||
}
|
||
|
||
export async function getAllCompanies() {
|
||
const pb = getPocketBase();
|
||
return pb.collection("companies").getFullList<Company>({ sort: "-created" });
|
||
}
|
||
|
||
export async function getSuppliers(page = 1, perPage = 50, search = "", sort = "-created") {
|
||
const pb = getPocketBase();
|
||
const filter = search
|
||
? `suppliername ~ "${search}" || email ~ "${search}" || suppliernumber ~ "${search}" || supply_tags ~ "${search}"`
|
||
: "";
|
||
return pb.collection("suppliers").getList<Supplier>(page, perPage, { filter, sort });
|
||
}
|
||
|
||
export async function getSupplier(id: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("suppliers").getOne<Supplier>(id);
|
||
}
|
||
|
||
export async function getAllSuppliers() {
|
||
const pb = getPocketBase();
|
||
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
|
||
}
|
||
|
||
/**
|
||
* Fields the supplier-map actually renders. Kept narrow so a 5k-row
|
||
* supplier table doesn't ship `closingtime` and the rest of the blob
|
||
* over the wire just to show pins on a Leaflet map.
|
||
*/
|
||
const SUPPLIER_MAP_FIELDS =
|
||
"id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
|
||
|
||
/**
|
||
* Suppliers that can actually be placed on the map — only rows with
|
||
* non-empty `coordinates`, and only the fields the map renders.
|
||
*
|
||
* Distinct from `getAllSuppliers` (which the quote/order forms still
|
||
* want full records from). Introduced when the supplier-map was
|
||
* choking on zoom-in with a few thousand suppliers: shipping the
|
||
* whole table was the biggest single contributor.
|
||
*/
|
||
export async function getMappableSuppliers() {
|
||
const pb = getPocketBase();
|
||
return pb.collection("suppliers").getFullList<Supplier>({
|
||
sort: "-created",
|
||
filter: 'coordinates != ""',
|
||
fields: SUPPLIER_MAP_FIELDS,
|
||
});
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Stock items — added 2026-07-14 (Stream E: v3 stock-out flow).
|
||
*
|
||
* `getActiveStockItems` is what the order-entry pages call to feed
|
||
* the "Fulfill from stock" picker. Filters by `is_active=true` so
|
||
* deactivated items don't pollute the dropdown — same convention as
|
||
* the supplier map's `coordinates != ""` filter.
|
||
*
|
||
* `getStockItem` is the single-record variant used by the
|
||
* /api/v1/stock/fulfill route when it needs to re-read current_qty
|
||
* just before debiting (defensive — catches the race where the
|
||
* picker was filled with an earlier `current_qty` than the current
|
||
* row holds).
|
||
* ========================================================================== */
|
||
|
||
export async function getActiveStockItems() {
|
||
const pb = getPocketBase();
|
||
return pb.collection("stock_items").getFullList<StockItem>({
|
||
sort: "name",
|
||
filter: "is_active = true",
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
|
||
export async function getStockItem(id: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("stock_items").getOne<StockItem>(id, {
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
|
||
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
|
||
* fields that are added in v5 by importing `pb_schema (1).json`.
|
||
*
|
||
* Until the schema is imported, PB rejects the filter with HTTP 400,
|
||
* which spams the browser console. We try the new filter first; on
|
||
* failure we fall back to the legacy unfiltered query and log a
|
||
* one-time reminder. After schema import everything works as designed.
|
||
*/
|
||
function is400(err: unknown): boolean {
|
||
if (!err || typeof err !== "object") return false;
|
||
const status = (err as { status?: number }).status;
|
||
return status === 400;
|
||
}
|
||
|
||
let warnedSchemaMissing = false;
|
||
function warnSchemaMissing(what: string) {
|
||
if (warnedSchemaMissing) return;
|
||
warnedSchemaMissing = true;
|
||
console.warn(
|
||
`[pb-helpers] Quote query falling back to legacy filter (${what}). ` +
|
||
"Import the new fields from pb_schema (1).json into PocketBase to enable the new filter."
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fetch a page of quotes, optionally filtered by one or more statuses.
|
||
*
|
||
* @param statuses One or more `quotes.status` values to OR-chain into a
|
||
* `status = "x" || status = "y" || …` filter. PB 0.26
|
||
* doesn't accept the `IN` operator, so we build the
|
||
* OR-chain ourselves (see MEMORY.md / PocketBase 0.26:
|
||
* no `IN` operator in raw filter strings). Pass `[]`
|
||
* (default) to skip the status filter.
|
||
*
|
||
* The Templates tab historically passed a single `status = "template"`
|
||
* string; that flow is now handled by passing `["template"]` instead.
|
||
*/
|
||
export async function getQuotes(
|
||
page = 1,
|
||
perPage = 50,
|
||
search = "",
|
||
sort = "-created",
|
||
statuses: string[] = []
|
||
) {
|
||
const pb = getPocketBase();
|
||
// Hide templates from every non-template tab so they don't pollute the
|
||
// workflow view. The Templates tab passes ["template"] explicitly —
|
||
// `statuses.includes("template")` lets it through.
|
||
const filters: string[] = [];
|
||
if (!statuses.includes("template")) filters.push("is_template != true");
|
||
if (search) {
|
||
filters.push(`quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}"`);
|
||
}
|
||
if (statuses.length > 0) {
|
||
const statusClause = statuses.map((s) => `status = "${s}"`).join(" || ");
|
||
filters.push(`(${statusClause})`);
|
||
}
|
||
const newFilter = filters.join(" && ");
|
||
try {
|
||
return await pb.collection("quotes").getList<Quote>(page, perPage, {
|
||
filter: newFilter,
|
||
sort,
|
||
expand: "customer,customer.company,customer.rep,company",
|
||
});
|
||
} catch (err) {
|
||
if (!is400(err)) throw err;
|
||
warnSchemaMissing("is_template");
|
||
// Drop just the is_template clause; keep the rest.
|
||
const legacyFilters = filters.filter((f) => f !== "is_template != true");
|
||
return pb.collection("quotes").getList<Quote>(page, perPage, {
|
||
filter: legacyFilters.join(" && "),
|
||
sort,
|
||
expand: "customer,customer.company,customer.rep,company",
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch only template quotes — for the templates page / dashboard widget.
|
||
* Sorted with the most-recently-created first.
|
||
*
|
||
* Returns an empty list (with a console warning) if the `is_template`
|
||
* field doesn't exist in PB yet, since this is brand-new functionality
|
||
* that fundamentally needs the field.
|
||
*/
|
||
export async function getTemplates(page = 1, perPage = 50) {
|
||
const pb = getPocketBase();
|
||
try {
|
||
return await pb.collection("quotes").getList<Quote>(page, perPage, {
|
||
filter: "is_template = true",
|
||
sort: "-created",
|
||
expand: "customer",
|
||
});
|
||
} catch (err) {
|
||
if (!is400(err)) throw err;
|
||
warnSchemaMissing("is_template (templates)");
|
||
// Minimal shape — only callers destructure .items, the rest is
|
||
// cosmetic for the widgets.
|
||
return {
|
||
items: [] as Quote[],
|
||
page: 1,
|
||
perPage,
|
||
totalItems: 0,
|
||
totalPages: 1,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Quotes whose user-set `follow_up_due` is today or in the past and
|
||
* which are not yet in a terminal status. Used by the dashboard widget
|
||
* to flag "needs review" work.
|
||
*
|
||
* Returns an empty list (with a console warning) until `follow_up_due`
|
||
* and `is_template` exist on the live PB collection.
|
||
*/
|
||
export async function getFollowUpDueQuotes() {
|
||
const pb = getPocketBase();
|
||
const today = new Date().toISOString();
|
||
try {
|
||
return await pb.collection("quotes").getFullList<Quote>({
|
||
filter: `follow_up_due != "" && follow_up_due <= "${today}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`,
|
||
sort: "follow_up_due",
|
||
expand: "customer",
|
||
});
|
||
} catch (err) {
|
||
if (!is400(err)) throw err;
|
||
warnSchemaMissing("follow_up_due / is_template (follow-ups)");
|
||
return [] as Quote[];
|
||
}
|
||
}
|
||
|
||
export async function getQuote(id: string) {
|
||
const pb = getPocketBase();
|
||
// Expand customer AND customer's nested relations (company + rep)
|
||
// so the detail page can render real names instead of opaque ids.
|
||
// PB expand syntax: comma-separated paths, nested via dot.
|
||
return pb.collection("quotes").getOne<Quote>(id, {
|
||
expand: "customer,customer.company,customer.rep,company",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Per-tab quote counts for the list-page tabs. Returns a `Record<tabKey, number>`
|
||
* where each key matches a tab key on the quotes list page:
|
||
*
|
||
* new ← status = new || status = follow_up (follow-up rows live in New)
|
||
* claimed ← status = claimed
|
||
* pending_approval ← status = processed || status = approval_pending || status = sending
|
||
* completed ← status = approved || status = complete || status = emailed
|
||
* || status = cancelled || status = rejected
|
||
* email_failed ← status = email_failed || status = mail_failed || status = unmatched_bounce
|
||
* problem ← status = problem
|
||
* templates ← is_template = true
|
||
* all ← unfiltered (excludes templates, like every other tab)
|
||
*
|
||
* One PB call per underlying status (with `perPage: 1` so we only transfer a
|
||
* count, not records). We don't make a separate "all" call — we sum the
|
||
* per-tab counts and trust PB's atomicity within a single request batch
|
||
* (the alternative — an unfiltered call — can disagree with the per-tab
|
||
* sum if statuses flip mid-query).
|
||
*/
|
||
export async function getQuoteStatusCounts() {
|
||
const pb = getPocketBase();
|
||
// Underlying statuses → tab key. A tab key can map to multiple statuses.
|
||
const statusToTab: Record<string, string> = {
|
||
new: "new",
|
||
follow_up: "new",
|
||
claimed: "claimed",
|
||
processed: "pending_approval",
|
||
approval_pending: "pending_approval",
|
||
sending: "pending_approval",
|
||
approved: "completed",
|
||
complete: "completed",
|
||
emailed: "completed",
|
||
cancelled: "completed",
|
||
rejected: "completed",
|
||
email_failed: "email_failed",
|
||
mail_failed: "email_failed",
|
||
unmatched_bounce: "email_failed",
|
||
problem: "problem",
|
||
};
|
||
const tabKeys = [
|
||
"new",
|
||
"claimed",
|
||
"pending_approval",
|
||
"completed",
|
||
"email_failed",
|
||
"problem",
|
||
];
|
||
|
||
// Try with the v5 template-exclusion filter; fall back if PB doesn't
|
||
// know `is_template` yet (schema not imported).
|
||
const notTemplate = `is_template != true`;
|
||
const buildRequests = (withTemplateGate: boolean) => {
|
||
const gate = withTemplateGate ? ` && ${notTemplate}` : "";
|
||
const requests = Object.keys(statusToTab).map((status) =>
|
||
pb.collection("quotes").getList<Quote>(1, 1, {
|
||
filter: `status = "${status}"${gate}`,
|
||
})
|
||
);
|
||
// Templates tab uses `is_template = true` (the status filter would
|
||
// exclude templates from the workflow track).
|
||
const templateFilter = withTemplateGate
|
||
? `is_template = true`
|
||
: `is_template = true`;
|
||
requests.push(pb.collection("quotes").getList<Quote>(1, 1, { filter: templateFilter }));
|
||
return requests;
|
||
};
|
||
|
||
try {
|
||
const perStatus = await Promise.all(buildRequests(true));
|
||
const counts: Record<string, number> = {};
|
||
for (const key of tabKeys) counts[key] = 0;
|
||
counts.templates = 0;
|
||
let allCount = 0;
|
||
|
||
Object.keys(statusToTab).forEach((status, i) => {
|
||
const tab = statusToTab[status];
|
||
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
|
||
allCount += perStatus[i].totalItems;
|
||
});
|
||
counts.templates = perStatus[perStatus.length - 1].totalItems;
|
||
// "All" includes every tab EXCEPT templates (templates live in their own tab).
|
||
counts.all = allCount;
|
||
return counts;
|
||
} catch (err) {
|
||
if (!is400(err)) throw err;
|
||
warnSchemaMissing("is_template (status counts)");
|
||
const perStatus = await Promise.all(buildRequests(false));
|
||
const counts: Record<string, number> = {};
|
||
for (const key of tabKeys) counts[key] = 0;
|
||
counts.templates = 0;
|
||
let allCount = 0;
|
||
Object.keys(statusToTab).forEach((status, i) => {
|
||
const tab = statusToTab[status];
|
||
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
|
||
allCount += perStatus[i].totalItems;
|
||
});
|
||
counts.templates = perStatus[perStatus.length - 1].totalItems;
|
||
counts.all = allCount;
|
||
return counts;
|
||
}
|
||
}
|
||
|
||
export async function getQuoteItems(quoteId: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("quote_items").getFullList<QuoteItem>({
|
||
filter: `quote = "${quoteId}"`,
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
|
||
/* ============================================================================
|
||
* searchQuotesWithItems — multi-field client-side search, parallel to
|
||
* `searchOrdersWithItems`. Matches (case-insensitive substring):
|
||
*
|
||
* - quote_number, quote_reference, rfq_number, external_number
|
||
* - customer name (via expand)
|
||
* - company name (via expand)
|
||
* - quote_items.description (any line item)
|
||
* - quote_items.selling_price / cost_price (numeric exact match if the
|
||
* query parses as a plain decimal)
|
||
*
|
||
* Templates ARE included — the user can search them the same way. The
|
||
* list page's tab filter still hides templates from non-template tabs,
|
||
* but the search itself doesn't gate by status.
|
||
*
|
||
* Pagination is intentionally suppressed while searching (same trade-off
|
||
* as `searchOrdersWithItems`): the operator is hunting for a specific
|
||
* match, not paging through.
|
||
*
|
||
* Capped at the same `SEARCH_HARD_CAP` (500) as the orders search so a
|
||
* runaway query can't OOM the browser.
|
||
* ========================================================================== */
|
||
|
||
function quoteMatchesSearch(
|
||
quote: Quote,
|
||
items: QuoteItem[],
|
||
qLower: string,
|
||
numericMatch: number | null
|
||
): boolean {
|
||
// 1. Quote identifiers contain query
|
||
if ((quote.quote_number || "").toLowerCase().includes(qLower)) return true;
|
||
if ((quote.quote_reference || "").toLowerCase().includes(qLower)) return true;
|
||
if ((quote.rfq_number || "").toLowerCase().includes(qLower)) return true;
|
||
if ((quote.external_number || "").toLowerCase().includes(qLower)) return true;
|
||
// 2. Customer name contains query
|
||
const customer = quote.expand?.customer?.customer || "";
|
||
if (customer.toLowerCase().includes(qLower)) return true;
|
||
// 3. Company name contains query
|
||
const company = quote.expand?.company?.company || "";
|
||
if (company.toLowerCase().includes(qLower)) return true;
|
||
// 4. Any line item description contains query
|
||
for (const it of items) {
|
||
if ((it.description || "").toLowerCase().includes(qLower)) return true;
|
||
}
|
||
// 5. If the query is numeric, match against line prices. Tolerant
|
||
// epsilon so "12" matches 12.00 and "12.5" matches 12.50.
|
||
if (numericMatch !== null) {
|
||
const epsilon = 0.001;
|
||
for (const it of items) {
|
||
if (
|
||
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
|
||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export async function searchQuotesWithItems(
|
||
q: string,
|
||
sort = "-created"
|
||
): Promise<Array<{ quote: Quote; items: QuoteItem[] }>> {
|
||
const pb = getPocketBase();
|
||
const trimmed = q.trim();
|
||
if (!trimmed) return [];
|
||
|
||
const quotes = await pb.collection("quotes").getList<Quote>(1, SEARCH_HARD_CAP, {
|
||
sort,
|
||
expand: "customer,customer.company,customer.rep,company",
|
||
});
|
||
const quoteIds = quotes.items.map((q0) => q0.id);
|
||
|
||
// Pull items in one OR-chain query (PB 0.26 doesn't accept IN).
|
||
let items: QuoteItem[] = [];
|
||
if (quoteIds.length > 0) {
|
||
const orChain = quoteIds.map((id) => `quote = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
||
items = await pb.collection("quote_items").getFullList<QuoteItem>({
|
||
filter: orChain,
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
const itemsByQuote: Record<string, QuoteItem[]> = {};
|
||
for (const item of items) {
|
||
(itemsByQuote[item.quote] ||= []).push(item);
|
||
}
|
||
|
||
const qLower = trimmed.toLowerCase();
|
||
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
|
||
|
||
const result: Array<{ quote: Quote; items: QuoteItem[] }> = [];
|
||
for (const quote of quotes.items) {
|
||
const qItems = itemsByQuote[quote.id] || [];
|
||
if (quoteMatchesSearch(quote, qItems, qLower, numericMatch)) {
|
||
result.push({ quote, items: qItems });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Order ↔ Quote matching
|
||
*
|
||
* Strategy ladder — each step is tried in order; the first non-empty hit
|
||
* is returned and recorded as the audit trail. The UI surfaces the
|
||
* method so the operator understands WHY a suggestion exists.
|
||
*
|
||
* 1. exact_quote_number — `quotes.quote_number == ref`
|
||
* 2. exact_quote_reference — `quotes.quote_reference == ref`
|
||
* 3. exact_rfq_number — `quotes.rfq_number == ref`
|
||
* 4. exact_external_number — `quotes.external_number == ref`
|
||
* 5. company+customer+desc+price (the fuzzy fallback) — used when
|
||
* no reference text is set OR when none of the exact lookups
|
||
* returned a hit. Filters quotes by company (and customer when
|
||
* known), scores each candidate by description-token overlap
|
||
* (Jaccard) plus price tolerance, and takes the best.
|
||
*
|
||
* Only ~55% of quotes have a meaningful quote_number (per the operator),
|
||
* so the fuzzy fallback is the path that handles the rest of the
|
||
* population. We cap the fuzzy candidate set at FUZZY_CANDIDATE_CAP
|
||
* so a runaway scan can't OOM the browser.
|
||
*
|
||
* The pure scoring helpers (tokenize, jaccard, priceScore,
|
||
* scoreCandidate) live in `src/lib/server/order-quote-match.ts` so
|
||
* they're unit-testable without PocketBase.
|
||
* ========================================================================== */
|
||
|
||
export type QuoteMatchMethod =
|
||
| "exact_quote_number"
|
||
| "exact_quote_reference"
|
||
| "exact_rfq_number"
|
||
| "exact_external_number"
|
||
| "company_customer_desc_price"
|
||
| "no_match";
|
||
|
||
export interface QuoteMatchCandidate {
|
||
quote: Quote;
|
||
items: QuoteItem[];
|
||
confidence: number; // 0..1
|
||
/** Per-signal score breakdown so the UI can show operators WHY a
|
||
* quote was chosen. Optional for backward compatibility with
|
||
* exact-lookup paths (which don't run the scorer). */
|
||
breakdown?: import("./server/order-quote-match").ScoreBreakdown;
|
||
method: Exclude<QuoteMatchMethod, "no_match">;
|
||
}
|
||
|
||
export interface QuoteMatchResult {
|
||
/** Best (and only) suggestion, or null if no candidate cleared the
|
||
* confidence threshold. */
|
||
best: QuoteMatchCandidate | null;
|
||
/** Up to 5 runners-up, useful for the "see all candidates" UI later. */
|
||
alternatives: QuoteMatchCandidate[];
|
||
method: QuoteMatchMethod;
|
||
}
|
||
|
||
function normRef(s: string): string {
|
||
return (s || "").trim();
|
||
}
|
||
|
||
/** Try to find one quote that matches `ref` against the four identifier
|
||
* fields. Returns the first hit (in priority order) or null. */
|
||
async function findExactMatch(
|
||
pb: ReturnType<typeof getPocketBase>,
|
||
ref: string
|
||
): Promise<{ quote: Quote; method: Exclude<QuoteMatchMethod, "no_match" | "company_customer_desc_price"> } | null> {
|
||
if (!ref) return null;
|
||
// Quote_number first (most common), then quote_reference (the customer's
|
||
// own PO ref we recorded when we issued), then rfq_number, then
|
||
// external_number.
|
||
for (const [field, method] of [
|
||
["quote_number", "exact_quote_number"],
|
||
["quote_reference", "exact_quote_reference"],
|
||
["rfq_number", "exact_rfq_number"],
|
||
["external_number", "exact_external_number"],
|
||
] as const) {
|
||
try {
|
||
const escaped = ref.replace(/"/g, '\\"');
|
||
const list = await pb.collection("quotes").getList<Quote>(1, 5, {
|
||
filter: `${field} = "${escaped}"`,
|
||
});
|
||
if (list.items.length > 0) {
|
||
return { quote: list.items[0], method };
|
||
}
|
||
} catch {
|
||
// Field might not exist in this PB schema (older deploys) —
|
||
// skip and try the next one.
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Score & rank candidate quotes by company (+ customer) match. */
|
||
async function findFuzzyMatches(
|
||
pb: ReturnType<typeof getPocketBase>,
|
||
order: Order,
|
||
orderItems: OrderItem[]
|
||
): Promise<QuoteMatchCandidate[]> {
|
||
if (orderItems.length === 0) return []; // no signal — bail
|
||
|
||
// PB filter: company is required, customer narrows further when known.
|
||
const filters: string[] = [];
|
||
if (order.company) filters.push(`company = "${order.company}"`);
|
||
if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`);
|
||
// Exclude templates — they aren't real orders.
|
||
filters.push("is_template != true");
|
||
const filter = filters.join(" && ");
|
||
|
||
const candidates = await pb.collection("quotes").getList<Quote>(1, FUZZY_CANDIDATE_CAP, {
|
||
filter,
|
||
sort: "-created",
|
||
expand: "customer",
|
||
});
|
||
if (candidates.items.length === 0) return [];
|
||
|
||
// Pull all candidate items in one OR-chain query.
|
||
const qIds = candidates.items.map((q) => q.id);
|
||
const orChain = qIds.map((id) => `quote = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
||
const allItems = await pb.collection("quote_items").getFullList<QuoteItem>({
|
||
filter: orChain,
|
||
});
|
||
const itemsByQuote: Record<string, QuoteItem[]> = {};
|
||
for (const it of allItems) {
|
||
(itemsByQuote[it.quote] ||= []).push(it);
|
||
}
|
||
|
||
const projectedOrderItems = orderItems.map((it) => ({
|
||
description: it.description || "",
|
||
selling_price: Number(it.selling_price) || 0,
|
||
quantity: Number(it.quantity) || 1,
|
||
}));
|
||
|
||
const scored: QuoteMatchCandidate[] = [];
|
||
for (const q of candidates.items) {
|
||
const qItems = itemsByQuote[q.id] || [];
|
||
const projectedQuoteItems = qItems.map((it) => ({
|
||
description: it.description || "",
|
||
selling_price: Number(it.selling_price) || 0,
|
||
quantity: Number(it.quantity) || 1,
|
||
}));
|
||
// Rule-based scorer — uses company/customer relations and per-line
|
||
// signal matches. See `scoreOrderVsQuote` for the point budget.
|
||
const breakdown = scoreOrderVsQuote(
|
||
{
|
||
companyId: order.company,
|
||
customerId: order.customer,
|
||
items: projectedOrderItems,
|
||
},
|
||
{
|
||
company: q.company,
|
||
customer: q.customer,
|
||
items: projectedQuoteItems,
|
||
}
|
||
);
|
||
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
|
||
scored.push({
|
||
quote: q,
|
||
items: qItems,
|
||
confidence: breakdown.confidence,
|
||
breakdown,
|
||
method: "company_customer_desc_price",
|
||
});
|
||
}
|
||
}
|
||
// Sort: highest confidence first, then most recently created on tie.
|
||
// The PB query already returns candidates `-created`, but the local
|
||
// sort above only sees confidence — without a tie-breaker, an older
|
||
// quote can win over a fresher one when they score the same. Using
|
||
// `created` (not `updated`) so a no-op re-save of an old quote can't
|
||
// bump it past a genuinely newer competitor.
|
||
scored.sort((a, b) => {
|
||
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
||
return (b.quote.created || "").localeCompare(a.quote.created || "");
|
||
});
|
||
return scored;
|
||
}
|
||
|
||
/**
|
||
* Run the matcher against an order and return the best suggestion
|
||
* (or null) plus up to 5 alternatives. Doesn't persist anything —
|
||
* the caller decides whether to accept the suggestion.
|
||
*
|
||
* `pb` is optional: when omitted the matcher uses the client-side
|
||
* singleton from `@/lib/pocketbase` (the normal browser path).
|
||
* Pass an explicit `pb` to use a server-side or admin-authenticated
|
||
* instance — useful for server scripts, n8n workflows, and tests
|
||
* that don't have the browser's auth context.
|
||
*/
|
||
export async function findMatchingQuotesForOrder(
|
||
order: Order,
|
||
orderItems: OrderItem[],
|
||
pb?: ReturnType<typeof getPocketBase>
|
||
): Promise<QuoteMatchResult> {
|
||
const client = pb ?? getPocketBase();
|
||
const ref = normRef(order.quote_reference_text || "");
|
||
|
||
// 1-4: Exact-key lookups when the operator gave us a reference. This
|
||
// is a deterministic fast path — we run it before the fuzzy scan
|
||
// because the operator typed something specific and we should
|
||
// honor it first when it matches.
|
||
if (ref) {
|
||
const exact = await findExactMatch(client, ref);
|
||
if (exact) {
|
||
return {
|
||
best: {
|
||
quote: exact.quote,
|
||
items: [], // not pre-fetched — caller can lazy-load if they open it
|
||
confidence: 1.0,
|
||
method: exact.method,
|
||
},
|
||
alternatives: [],
|
||
method: exact.method,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 5: Fuzzy fallback. Runs whether or not the operator supplied a
|
||
// reference — the rule-based scorer can find matches from company +
|
||
// customer + items + price alone, without needing a typed
|
||
// identifier. This is what makes "Re-match" work on a freshly
|
||
// loaded order that has nothing in `quote_reference_text`.
|
||
const fuzzy = await findFuzzyMatches(client, order, orderItems);
|
||
if (fuzzy.length === 0) {
|
||
return { best: null, alternatives: [], method: "no_match" };
|
||
}
|
||
return {
|
||
best: fuzzy[0],
|
||
alternatives: fuzzy.slice(1, 6),
|
||
method: "company_customer_desc_price",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Persist the matcher's verdict on the order record. Sets
|
||
* `quote_matching_attempted = true` so the UI can show "we tried but
|
||
* found nothing" distinctly from "we haven't tried yet".
|
||
*
|
||
* If `accept` is true AND a candidate was found, the candidate's
|
||
* quote is promoted from `potential_quote` to the hard-linked
|
||
* `quote` field. If false, only the suggestion is recorded.
|
||
*
|
||
* `pb` is optional: same convention as `findMatchingQuotesForOrder`.
|
||
*/
|
||
export async function applyOrderQuoteMatch(
|
||
orderId: string,
|
||
result: QuoteMatchResult,
|
||
accept: boolean,
|
||
pb?: ReturnType<typeof getPocketBase>
|
||
): Promise<void> {
|
||
const client = pb ?? getPocketBase();
|
||
const patch: Record<string, unknown> = {
|
||
quote_matching_attempted: true,
|
||
quote_match_method: result.method,
|
||
};
|
||
if (result.best) {
|
||
patch.potential_quote = result.best.quote.id;
|
||
patch.potential_quote_confidence = result.best.confidence;
|
||
if (accept) {
|
||
patch.quote = result.best.quote.id;
|
||
}
|
||
} else {
|
||
// No suggestion — clear any stale soft-match.
|
||
patch.potential_quote = null;
|
||
patch.potential_quote_confidence = 0;
|
||
}
|
||
await client.collection("orders").update(orderId, patch);
|
||
}
|
||
|
||
/**
|
||
* Reset the matching verdict on an order so the operator can retry.
|
||
* Same `pb` convention as the other match helpers.
|
||
*/
|
||
export async function clearOrderQuoteMatch(
|
||
orderId: string,
|
||
pb?: ReturnType<typeof getPocketBase>
|
||
): Promise<void> {
|
||
const client = pb ?? getPocketBase();
|
||
await client.collection("orders").update(orderId, {
|
||
quote_matching_attempted: false,
|
||
quote_match_method: null,
|
||
potential_quote: null,
|
||
potential_quote_confidence: 0,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Manually hard-link an order to a quote. Used by the QuoteMatchCard
|
||
* "Browse quotes…" affordance when the operator picks a quote from
|
||
* the search dropdown — there's no reference text to match against,
|
||
* and we don't want to fake a QuoteMatchResult just to round-trip
|
||
* through `applyOrderQuoteMatch`.
|
||
*
|
||
* Sets the same fields as an accepted auto-match (`quote`,
|
||
* `quote_matching_attempted`, `quote_match_method = "manual"`) but
|
||
* does NOT populate `potential_quote` — manual links bypass the
|
||
* suggestion stage and become the hard link directly.
|
||
*
|
||
* `pb` is optional for symmetry with the other match helpers — pass
|
||
* it in server-side to keep one auth path.
|
||
*/
|
||
export async function setOrderQuoteManually(
|
||
orderId: string,
|
||
quoteId: string,
|
||
pb?: ReturnType<typeof getPocketBase>
|
||
): Promise<void> {
|
||
const client = pb ?? getPocketBase();
|
||
await client.collection("orders").update(orderId, {
|
||
quote: quoteId,
|
||
quote_matching_attempted: true,
|
||
quote_match_method: "manual",
|
||
potential_quote: null,
|
||
potential_quote_confidence: 0,
|
||
});
|
||
}
|
||
|
||
// Re-export the pure scoring helpers so unit tests can import them
|
||
// without poking into the PB layer.
|
||
export {
|
||
tokenize,
|
||
jaccard,
|
||
priceScore,
|
||
scoreCandidate,
|
||
scoreOrderVsQuote,
|
||
RULE_POINTS,
|
||
MATCH_CONFIDENCE_THRESHOLD,
|
||
} from "./server/order-quote-match";
|
||
export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match";
|
||
|
||
/**
|
||
* Fetch all attachments saved against a quote. Used by the
|
||
* "Original Email" card on the quote detail page so the quoter can
|
||
* download the files that came in with the original RFQ email.
|
||
*
|
||
* Separate query rather than a reverse-relation expand because PB's
|
||
* expand syntax doesn't easily cover reverse relations, and the
|
||
* `quote_attachments` collection is small per quote (usually 0–3 rows).
|
||
*
|
||
* Direction filter: pass "inbound" to fetch only email attachments,
|
||
* "outbound" for operator-uploaded files, "generated" for the
|
||
* rendered PDF, or omit to fetch all. Rows predating the 2026-06-23
|
||
* migration have `direction = null`; we treat those as "inbound"
|
||
* because every historical row came in from an email.
|
||
*/
|
||
export async function getQuoteAttachments(
|
||
quoteId: string,
|
||
direction?: "inbound" | "outbound" | "generated" | "all",
|
||
) {
|
||
const pb = getPocketBase();
|
||
const dir = direction && direction !== "all" ? direction : null;
|
||
const filterParts = [`quote = "${quoteId}"`];
|
||
if (dir) {
|
||
filterParts.push(`direction = "${dir}"`);
|
||
} else if (direction === "all") {
|
||
// No direction filter — return everything.
|
||
} else {
|
||
// Default to "inbound" (the historical meaning of attachments
|
||
// before the direction migration). We use a deliberately loose
|
||
// filter: `direction = "inbound" || direction = ""`. PB treats
|
||
// null and missing as equal for text compares, so this catches
|
||
// both pre-migration rows and explicitly-nulled rows.
|
||
filterParts.push(`(direction = "inbound" || direction = "")`);
|
||
}
|
||
const filter = filterParts.join(" && ");
|
||
try {
|
||
return await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
|
||
filter,
|
||
sort: "-uploaded_at",
|
||
});
|
||
} catch (err) {
|
||
// Same fallback pattern as getQuotes: if the `direction` field
|
||
// doesn't exist in this PB yet (pre-2026-06-23 migration), PB
|
||
// returns 400. Retry without the direction clause so the page
|
||
// still renders; the user gets the historical "all attachments"
|
||
// view until they run the migration.
|
||
if (!is400(err)) throw err;
|
||
warnSchemaMissing("direction (attachments)");
|
||
return pb.collection("quote_attachments").getFullList<QuoteAttachment>({
|
||
filter: `quote = "${quoteId}"`,
|
||
sort: "-uploaded_at",
|
||
});
|
||
}
|
||
}
|
||
|
||
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created") {
|
||
const pb = getPocketBase();
|
||
const filter = search ? `order_number ~ "${search}"` : "";
|
||
// List view expands company, customer, quote, and potential_quote
|
||
// so the cards can show the matched-quote pill inline (and the
|
||
// soft-suggestion indicator) without an extra round-trip per row.
|
||
// The price is a slightly larger PB response per row, but at the
|
||
// WFDASH fleet scale this is well under a hundred bytes per
|
||
// extra field.
|
||
return pb.collection("orders").getList<Order>(page, perPage, {
|
||
filter,
|
||
sort,
|
||
expand: "company,customer,quote,potential_quote",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Per-tab-group totals for the orders list tabs.
|
||
*
|
||
* Counts the number of UNIQUE orders that have at least one item in
|
||
* each tab's item-status group. The actual data fetch happens in
|
||
* `getOrdersWithItems`; this helper is here so the list page can show
|
||
* tab badges without re-running the orders query.
|
||
*
|
||
* Implementation: one PB call per tab (6 total) using `getFullList`
|
||
* with a narrow `fields: "order"` projection, then deduping in JS.
|
||
* For the WFDASH fleet scale (hundreds of orders, not millions)
|
||
* this is well under a thousand records scanned total — cheap
|
||
* enough that we don't bother caching.
|
||
*/
|
||
export async function getOrderItemStatusTabCounts(): Promise<Record<TabGroup, number>> {
|
||
const pb = getPocketBase();
|
||
const tabs: TabGroup[] = [
|
||
"new",
|
||
"processing",
|
||
"collection_pool",
|
||
"collected",
|
||
"delivered",
|
||
"cancelled",
|
||
];
|
||
const results = await Promise.all(
|
||
tabs.map((tab) => {
|
||
const filter = TAB_GROUP_ITEM_STATUSES[tab]
|
||
.map((s) => `item_status = "${s}"`)
|
||
.join(" || ");
|
||
return pb.collection("order_items").getFullList<{ order: string }>({
|
||
filter,
|
||
fields: "order",
|
||
});
|
||
})
|
||
);
|
||
const counts = {} as Record<TabGroup, number>;
|
||
tabs.forEach((tab, i) => {
|
||
const uniqueOrders = new Set(results[i].map((row) => row.order).filter(Boolean));
|
||
counts[tab] = uniqueOrders.size;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
/**
|
||
* Orders with their line items and the tab groups each order touches.
|
||
*
|
||
* The list page needs the full picture for every visible row because
|
||
* an order can appear on multiple tabs (a 5-item order with items in
|
||
* `pending` + `processing` + `delivered` shows up on three tabs).
|
||
*
|
||
* Strategy:
|
||
* 1. Fetch the page of orders (no expand beyond company/customer).
|
||
* 2. Fetch all line items whose `order` is in this page (one PB
|
||
* call with an `IN` filter — far cheaper than N+1).
|
||
* 3. Bucket items onto their parent order.
|
||
* 4. Compute the set of tab groups each order touches (deduped).
|
||
*
|
||
* `search` filters by order_number. The returned `OrderWithItems`
|
||
* shape is what the page renders directly.
|
||
*/
|
||
export interface OrderWithItems {
|
||
order: Order;
|
||
items: OrderItem[];
|
||
/** Tab groups this order has at least one item in. */
|
||
tabGroups: TabGroup[];
|
||
}
|
||
|
||
export async function getOrdersWithItems(
|
||
page = 1,
|
||
perPage = 20,
|
||
search = "",
|
||
sort = "-created"
|
||
): Promise<{ items: OrderWithItems[]; totalPages: number; totalItems: number }> {
|
||
const pb = getPocketBase();
|
||
const ordersPage = await getOrders(page, perPage, search, sort);
|
||
const orderIds = ordersPage.items.map((o) => o.id);
|
||
let itemsByOrder: Record<string, OrderItem[]> = {};
|
||
if (orderIds.length > 0) {
|
||
// PocketBase 0.26 doesn't accept the `IN` operator in raw filter
|
||
// strings (returns HTTP 400). The portable form is OR-joined
|
||
// equality clauses, which PB evaluates fine for short lists
|
||
// (the page is capped at 20 ids). For larger datasets we'd
|
||
// batch this — but at the WFDASH fleet scale (hundreds of
|
||
// orders) a single OR-chain stays well under PB's filter-length
|
||
// budget and avoids the round-trip cost of N+1 fetches.
|
||
//
|
||
// Each id is double-quoted; PB ids are 15-char lowercase
|
||
// alphanumeric so escaping is theoretical but cheap to keep safe.
|
||
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
||
const items = await pb.collection("order_items").getFullList<OrderItem>({
|
||
filter: orChain,
|
||
expand: "supplier",
|
||
sort: "created",
|
||
});
|
||
itemsByOrder = items.reduce<Record<string, OrderItem[]>>((acc, item) => {
|
||
(acc[item.order] ||= []).push(item);
|
||
return acc;
|
||
}, {});
|
||
}
|
||
const items: OrderWithItems[] = ordersPage.items.map((order) => {
|
||
const orderItems = itemsByOrder[order.id] || [];
|
||
const tabSet = new Set<TabGroup>();
|
||
for (const it of orderItems) {
|
||
tabSet.add(itemStatusToTabGroup(it.item_status));
|
||
}
|
||
return {
|
||
order,
|
||
items: orderItems,
|
||
tabGroups: Array.from(tabSet),
|
||
};
|
||
});
|
||
return {
|
||
items,
|
||
totalPages: ordersPage.totalPages,
|
||
totalItems: ordersPage.totalItems,
|
||
};
|
||
}
|
||
|
||
/* ============================================================================
|
||
* searchOrdersWithItems — multi-field, client-side filtered search.
|
||
*
|
||
* Searches across (case-insensitive substring):
|
||
* - orders.order_number
|
||
* - company name (via expand)
|
||
* - customer name (via expand)
|
||
* - order_items.description (any line item)
|
||
* - order_items.selling_price / cost_price (exact numeric match if the
|
||
* query parses as a number — so "12" finds a line priced at 12 or
|
||
* 120; "12.22" finds a line priced at exactly 12.22)
|
||
*
|
||
* Why client-side instead of a single PB filter: PocketBase 0.26's
|
||
* filter syntax can't join across collections, and we want one search
|
||
* box that matches five different fields on two collections. Fetching
|
||
* up to `SEARCH_HARD_CAP` orders + their items, then filtering in JS,
|
||
* is the cleanest path at the WFDASH fleet scale (hundreds of orders).
|
||
*
|
||
* Returns a flat list — pagination is intentionally suppressed while
|
||
* searching because the operator is looking for a specific match, not
|
||
* browsing. The page surfaces "X matches" instead of page controls.
|
||
*
|
||
* Capped at `SEARCH_HARD_CAP` so a runaway query can't OOM the browser
|
||
* — at WFDASH's scale this is well above what real searches ever hit.
|
||
* ========================================================================== */
|
||
|
||
const SEARCH_HARD_CAP = 500;
|
||
|
||
/** True if the query parses as a plain decimal number (no currency, no commas). */
|
||
function isNumericQuery(q: string): boolean {
|
||
if (!/^\d+(\.\d+)?$/.test(q.trim())) return false;
|
||
return !Number.isNaN(Number(q));
|
||
}
|
||
|
||
function orderMatchesSearch(
|
||
order: Order,
|
||
items: OrderItem[],
|
||
qLower: string,
|
||
numericMatch: number | null
|
||
): boolean {
|
||
// 1. Order number contains query
|
||
if ((order.order_number || "").toLowerCase().includes(qLower)) return true;
|
||
// 2. Company name contains query
|
||
const company = order.expand?.company?.company || "";
|
||
if (company.toLowerCase().includes(qLower)) return true;
|
||
// 3. Customer name contains query
|
||
const customer = order.expand?.customer?.customer || "";
|
||
if (customer.toLowerCase().includes(qLower)) return true;
|
||
// 4. Any line item description contains query
|
||
for (const it of items) {
|
||
if ((it.description || "").toLowerCase().includes(qLower)) return true;
|
||
if (it.po_description && it.po_description.toLowerCase().includes(qLower)) return true;
|
||
}
|
||
// 5. If the query is numeric, match against line prices.
|
||
// Use a tolerant epsilon for float comparison so "12" matches 12.00,
|
||
// "12.5" matches 12.50, etc.
|
||
if (numericMatch !== null) {
|
||
const epsilon = 0.001;
|
||
for (const it of items) {
|
||
if (
|
||
Math.abs(Number(it.selling_price || 0) - numericMatch) < epsilon ||
|
||
Math.abs(Number(it.cost_price || 0) - numericMatch) < epsilon
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export async function searchOrdersWithItems(
|
||
q: string,
|
||
sort = "-created"
|
||
): Promise<OrderWithItems[]> {
|
||
const pb = getPocketBase();
|
||
const trimmed = q.trim();
|
||
if (!trimmed) return [];
|
||
|
||
const orders = await pb.collection("orders").getList<Order>(1, SEARCH_HARD_CAP, {
|
||
sort,
|
||
expand: "company,customer",
|
||
});
|
||
const orderIds = orders.items.map((o) => o.id);
|
||
|
||
// Fetch all items for these orders in one OR-chain call. PB 0.26
|
||
// doesn't accept IN, so we OR the equality clauses (see
|
||
// getOrdersWithItems for the rationale).
|
||
let items: OrderItem[] = [];
|
||
if (orderIds.length > 0) {
|
||
const orChain = orderIds.map((id) => `order = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
||
items = await pb.collection("order_items").getFullList<OrderItem>({
|
||
filter: orChain,
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
|
||
// Bucket items onto their parent order for O(1) lookup during match.
|
||
const itemsByOrder: Record<string, OrderItem[]> = {};
|
||
for (const item of items) {
|
||
(itemsByOrder[item.order] ||= []).push(item);
|
||
}
|
||
|
||
const qLower = trimmed.toLowerCase();
|
||
const numericMatch = isNumericQuery(trimmed) ? Number(trimmed) : null;
|
||
|
||
const result: OrderWithItems[] = [];
|
||
for (const order of orders.items) {
|
||
const orderItems = itemsByOrder[order.id] || [];
|
||
if (!orderMatchesSearch(order, orderItems, qLower, numericMatch)) continue;
|
||
const tabSet = new Set<TabGroup>();
|
||
for (const it of orderItems) {
|
||
tabSet.add(itemStatusToTabGroup(it.item_status));
|
||
}
|
||
result.push({ order, items: orderItems, tabGroups: Array.from(tabSet) });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export async function getOrder(id: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
|
||
}
|
||
|
||
export async function getOrderByQuote(quoteId: string) {
|
||
const pb = getPocketBase();
|
||
try {
|
||
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
|
||
expand: "company,customer,quote",
|
||
});
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function getOrderItems(orderId: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("order_items").getFullList<OrderItem>({
|
||
filter: `order = "${orderId}"`,
|
||
expand: "supplier",
|
||
});
|
||
}
|
||
|
||
export async function createOrderFromQuote(quoteId: string) {
|
||
const pb = getPocketBase();
|
||
const existingOrder = await getOrderByQuote(quoteId);
|
||
if (existingOrder) {
|
||
return { order: existingOrder, alreadyExists: true };
|
||
}
|
||
|
||
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
|
||
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
|
||
filter: `quote = "${quoteId}"`,
|
||
sort: "created",
|
||
});
|
||
if (quoteItems.length === 0) {
|
||
throw new Error("Quote has no items to convert.");
|
||
}
|
||
|
||
// Pull rep from the linked customer record when present, so the order
|
||
// inherits it instead of starting empty. The operator can still override
|
||
// it on the order detail page if needed.
|
||
let rep = "";
|
||
if (quote.customer) {
|
||
try {
|
||
const cust = await pb.collection("customers").getOne<Customer>(quote.customer);
|
||
rep = cust.rep || "";
|
||
} catch {
|
||
// Customer record missing — leave rep empty, the operator can
|
||
// type it on the order detail page.
|
||
}
|
||
}
|
||
|
||
const orderNumber = await generateOrderNumber();
|
||
const order = await pb.collection("orders").create<Order>({
|
||
order_number: orderNumber,
|
||
company: quote.company,
|
||
customer: quote.customer || "",
|
||
quote: quote.id,
|
||
notes: quote.notes || "",
|
||
quote_matching_attempted: true,
|
||
quote_match_confidence: 1,
|
||
potential_quote: quote.id,
|
||
potential_quote_confidence: 1,
|
||
is_repair_order: false,
|
||
invoiced: false,
|
||
rep,
|
||
});
|
||
|
||
for (const item of quoteItems) {
|
||
const quantity = Number(item.quantity || 1);
|
||
await pb.collection("order_items").create({
|
||
order: order.id,
|
||
description: item.description || "",
|
||
po_description: item.description || "",
|
||
quantity,
|
||
order_qty: quantity,
|
||
selling_price: Number(item.selling_price || 0),
|
||
supplier: item.supplier || null,
|
||
cost_price: Number(item.cost_price || 0),
|
||
markup: Number(item.markup || 0),
|
||
markup_percent: Number(item.markup || 0),
|
||
item_status: "pending",
|
||
drv_status: "unassigned",
|
||
driver_status: "unassigned",
|
||
notes: item.notes || "",
|
||
quote_item: item.id,
|
||
});
|
||
}
|
||
|
||
return { order, alreadyExists: false };
|
||
}
|
||
|
||
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
|
||
const pb = getPocketBase();
|
||
const filter = status ? `pool_status = "${status}"` : "";
|
||
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
|
||
filter,
|
||
sort: "-created",
|
||
expand: "order_item,order,supplier,drv_id",
|
||
});
|
||
}
|
||
|
||
export async function updateOrderItemWorkflowStatus(
|
||
itemId: string,
|
||
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
|
||
) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("order_items").update<OrderItem>(itemId, patch);
|
||
}
|
||
|
||
export async function updateCollectionPoolStatus(
|
||
poolItem: CollectionPool,
|
||
poolStatus: CollectionPool["pool_status"]
|
||
) {
|
||
const pb = getPocketBase();
|
||
const payload: Record<string, unknown> = { pool_status: poolStatus };
|
||
if (poolStatus === "assigned") {
|
||
payload.assigned_at = new Date().toISOString();
|
||
}
|
||
if (poolStatus === "completed") {
|
||
payload.completed_at = new Date().toISOString();
|
||
}
|
||
|
||
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
|
||
|
||
if (poolStatus === "assigned") {
|
||
await pb.collection("order_items").update(poolItem.order_item, {
|
||
item_status: "assigned",
|
||
drv_status: "assigned",
|
||
});
|
||
}
|
||
|
||
if (poolStatus === "in_progress") {
|
||
await pb.collection("order_items").update(poolItem.order_item, {
|
||
drv_status: "en_route",
|
||
});
|
||
}
|
||
|
||
if (poolStatus === "completed") {
|
||
await pb.collection("order_items").update(poolItem.order_item, {
|
||
item_status: "collected",
|
||
drv_status: "collected",
|
||
});
|
||
}
|
||
|
||
if (poolStatus === "failed") {
|
||
await pb.collection("order_items").update(poolItem.order_item, {
|
||
drv_status: "failed",
|
||
problem_flag: true,
|
||
problem_type: "driver",
|
||
});
|
||
}
|
||
|
||
return updated;
|
||
}
|
||
|
||
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
|
||
const pb = getPocketBase();
|
||
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
|
||
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
|
||
filter,
|
||
sort: "-created",
|
||
expand: "order,company,customer",
|
||
});
|
||
}
|
||
|
||
export async function generateDeliveryNoteNumber(): Promise<string> {
|
||
const pb = getPocketBase();
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const prefix = `DN-${year}-${month}-`;
|
||
|
||
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
|
||
filter: `delivery_note_number ~ "${prefix}"`,
|
||
sort: "-delivery_note_number",
|
||
});
|
||
|
||
let seq = 1;
|
||
if (existing.items.length > 0) {
|
||
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
|
||
seq = lastNum + 1;
|
||
}
|
||
|
||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||
}
|
||
|
||
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
|
||
const pb = getPocketBase();
|
||
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
|
||
if (eligible.length === 0) {
|
||
throw new Error("No ready or delivered items available for a delivery note.");
|
||
}
|
||
|
||
const deliveryNoteNumber = await generateDeliveryNoteNumber();
|
||
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
|
||
delivery_note_number: deliveryNoteNumber,
|
||
order: order.id,
|
||
company: order.company || null,
|
||
customer: order.customer || null,
|
||
status: "draft",
|
||
notes: order.notes || "",
|
||
});
|
||
|
||
for (const item of eligible) {
|
||
await pb.collection("delivery_note_items").create({
|
||
delivery_note: deliveryNote.id,
|
||
order_item: item.id,
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
});
|
||
}
|
||
|
||
return deliveryNote;
|
||
}
|
||
|
||
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
|
||
const pb = getPocketBase();
|
||
const filters: string[] = [];
|
||
if (search) {
|
||
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
|
||
}
|
||
if (status) {
|
||
filters.push(`status = "${status}"`);
|
||
}
|
||
return pb.collection("repairs").getList<Repair>(page, perPage, {
|
||
filter: filters.join(" && "),
|
||
sort: "-created",
|
||
expand: "customer,company,order",
|
||
});
|
||
}
|
||
|
||
export async function generateRepairNumber(): Promise<string> {
|
||
const pb = getPocketBase();
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const prefix = `REP-${year}-${month}-`;
|
||
|
||
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
|
||
filter: `repair_number ~ "${prefix}"`,
|
||
sort: "-repair_number",
|
||
});
|
||
|
||
let seq = 1;
|
||
if (existing.items.length > 0) {
|
||
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
|
||
seq = lastNum + 1;
|
||
}
|
||
|
||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||
}
|
||
|
||
/**
|
||
* Pricelist — fetch a page of rows from the `raw_price_list` collection.
|
||
*
|
||
* Backs the `/pricelist` page. The live collection has these fields
|
||
* (discovered via the admin API):
|
||
* - supplier_file: the file
|
||
* - supplier_name: relation → suppliers
|
||
* - status: select
|
||
* - log_summary: free-text
|
||
*
|
||
* Search hits `log_summary` and the expanded supplier's `suppliername`.
|
||
* (PB can't substring-match file tokens, so the filename isn't in the
|
||
* search filter — but PB's URL token is the filename in practice, so
|
||
* the user can find rows by scrolling.)
|
||
*
|
||
* Newest first — the table is a feed of recent uploads, not an
|
||
* alphabetised directory.
|
||
*/
|
||
export async function getRawPriceLists(
|
||
page = 1,
|
||
perPage = 50,
|
||
search = "",
|
||
sort = "-created"
|
||
) {
|
||
const pb = getPocketBase();
|
||
// `~` is PB's case-insensitive contains operator. We match against
|
||
// log_summary directly; for supplier we OR against the expanded
|
||
// supplier's suppliername. PB 0.26 doesn't accept the `IN` operator
|
||
// (see MEMORY.md), so we do a simple OR-chain.
|
||
const filter = search
|
||
? `log_summary ~ "${search}" || supplier_name.suppliername ~ "${search}"`
|
||
: "";
|
||
return pb.collection("raw_price_list").getList<RawPriceList>(page, perPage, {
|
||
filter,
|
||
sort,
|
||
expand: "supplier_name",
|
||
});
|
||
}
|
||
|
||
export async function deleteRawPriceList(id: string) {
|
||
const pb = getPocketBase();
|
||
return pb.collection("raw_price_list").delete(id);
|
||
}
|
||
|
||
/**
|
||
* Catalogue — fetch a page of rows from the `price_list` collection.
|
||
*
|
||
* Backs the "Catalogue" tab on the `/pricelist` page. Search hits
|
||
* every searchable text field on the row (OR'd, case-insensitive):
|
||
*
|
||
* Direct columns (PB `~` operator, each indexed):
|
||
* - sku, description, brand, supplier
|
||
* - category, subcategory, unit, source_file
|
||
*
|
||
* Catch-all (denormalised blob, indexed):
|
||
* - search_text — every token from the row, including the full
|
||
* `specifications` JSON (keys + string values + number values
|
||
* + array values + nested-object keys), all four number
|
||
* columns tokenised, and the resolved supplier name. PB can't
|
||
* substring-match JSON, so without this the rich bits
|
||
* (synonyms, dimensions, material, etc.) would be unreachable.
|
||
*
|
||
* Why two layers: the explicit columns let PB's filter planner use
|
||
* their indexes; search_text is the catch-all for everything else
|
||
* (the JSON bits, numbers, supplier-name resolution). Adding a
|
||
* new text column to price_list only requires adding it to
|
||
* `PRICE_LIST_SEARCHABLE_TEXT_COLUMNS` in `price-list-search.ts`.
|
||
*
|
||
* Optional `category` and `brand` filters AND the search (narrow
|
||
* further).
|
||
*
|
||
* Newest first — the table is a feed of recent extractions, not an
|
||
* alphabetised directory.
|
||
*/
|
||
export async function getPriceLists(
|
||
page = 1,
|
||
perPage = 50,
|
||
search = "",
|
||
filters: { category?: string; brand?: string } = {},
|
||
sort = "-updated"
|
||
) {
|
||
const pb = getPocketBase();
|
||
const clauses: string[] = [];
|
||
const searchClause = buildPriceListSearchFilter(search);
|
||
if (searchClause) clauses.push(searchClause);
|
||
if (filters.category) {
|
||
clauses.push(`category = "${filters.category.replace(/"/g, '\\"')}"`);
|
||
}
|
||
if (filters.brand) {
|
||
clauses.push(`brand = "${filters.brand.replace(/"/g, '\\"')}"`);
|
||
}
|
||
const filter = clauses.join(" && ");
|
||
return pb.collection("price_list").getList<PriceList>(page, perPage, {
|
||
filter,
|
||
sort,
|
||
});
|
||
}
|
||
|
||
export async function createRepair(data: {
|
||
customer?: string;
|
||
company?: string;
|
||
order?: string;
|
||
item_description: string;
|
||
fault_description: string;
|
||
notes?: string;
|
||
}) {
|
||
const pb = getPocketBase();
|
||
const repairNumber = await generateRepairNumber();
|
||
return pb.collection("repairs").create<Repair>({
|
||
repair_number: repairNumber,
|
||
customer: data.customer || null,
|
||
company: data.company || null,
|
||
order: data.order || null,
|
||
status: "new",
|
||
item_description: data.item_description,
|
||
fault_description: data.fault_description,
|
||
notes: data.notes || "",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @deprecated For new code, call `/api/v1/quotes/next-number` instead. This
|
||
* client-side wrapper is kept for backwards compatibility with the quote
|
||
* form. The server endpoint uses an atomic counter (no race), while this
|
||
* client function reads-then-writes the last quote number (racy under
|
||
* concurrent creates).
|
||
*/
|
||
export async function generateQuoteNumber(): Promise<string> {
|
||
const pb = getPocketBase();
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const prefix = `Q${year}${month}`;
|
||
|
||
const existing = await pb.collection("quotes").getList<Quote>(1, 1, {
|
||
filter: `quote_number ~ "${prefix}"`,
|
||
sort: "-quote_number",
|
||
});
|
||
|
||
let seq = 1;
|
||
if (existing.items.length > 0) {
|
||
const lastRef = existing.items[0].quote_number;
|
||
// Extract trailing digits (handles both Q202607011 and legacy
|
||
// Q-2026-07-011 formats).
|
||
const m = lastRef.match(/(\d+)$/);
|
||
const lastNum = m ? parseInt(m[1], 10) : 0;
|
||
seq = lastNum + 1;
|
||
}
|
||
|
||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||
}
|
||
|
||
export async function generateOrderNumber(): Promise<string> {
|
||
const pb = getPocketBase();
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const prefix = `ORD-${year}-${month}-`;
|
||
|
||
const existing = await pb.collection("orders").getList<Order>(1, 1, {
|
||
filter: `order_number ~ "${prefix}"`,
|
||
sort: "-order_number",
|
||
});
|
||
|
||
let seq = 1;
|
||
if (existing.items.length > 0) {
|
||
const lastNum = parseInt(existing.items[0].order_number.split("-").pop() || "0", 10);
|
||
seq = lastNum + 1;
|
||
}
|
||
|
||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||
}
|
||
|
||
export async function generatePONumber(): Promise<string> {
|
||
const pb = getPocketBase();
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const prefix = `PO-${year}-${month}-`;
|
||
|
||
const existing = await pb.collection("purchase_orders").getList<PurchaseOrder>(1, 1, {
|
||
filter: `po_number ~ "${prefix}"`,
|
||
sort: "-po_number",
|
||
});
|
||
|
||
let seq = 1;
|
||
if (existing.items.length > 0) {
|
||
const lastNum = parseInt(existing.items[0].po_number.split("-").pop() || "0", 10);
|
||
seq = lastNum + 1;
|
||
}
|
||
|
||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||
}
|
||
|
||
export function calculateTotals(items: { selling_price: number; quantity: number }[]) {
|
||
const subtotal = Number(items.reduce((sum, item) => sum + item.selling_price * item.quantity, 0).toFixed(2));
|
||
const vat_amount = Number((subtotal * 0.15).toFixed(2));
|
||
const grand_total = Number((subtotal + vat_amount).toFixed(2));
|
||
return { subtotal, vat_amount, grand_total };
|
||
}
|
||
|
||
export function formatCurrency(amount: number): string {
|
||
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
|
||
}
|
||
|
||
export function formatDate(dateStr: string): string {
|
||
if (!dateStr) return "";
|
||
return new Date(dateStr).toLocaleDateString("en-ZA", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
export function exportToCSV(data: Record<string, unknown>[], filename: string) {
|
||
if (data.length === 0) return;
|
||
const headers = Object.keys(data[0]);
|
||
const csv = [
|
||
headers.join(","),
|
||
...data.map((row) =>
|
||
headers
|
||
.map((h) => {
|
||
const val = row[h];
|
||
const str = val === null || val === undefined ? "" : String(val);
|
||
return str.includes(",") || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
|
||
})
|
||
.join(",")
|
||
),
|
||
].join("\n");
|
||
|
||
const blob = new Blob([csv], { type: "text/csv" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${filename}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|