WFDASH-WEBAPP/src/lib/server/stock-quick-draft-service.ts

224 lines
7.3 KiB
TypeScript
Raw Normal View History

/**
* Stock Quick-Draft service — backs the /stock-orders modal that lets
* a buyer add items to draft stock_purchases in an item-first rhythm.
*
* Reuses `generateStockPurchaseNumber` from `stock-purchases-service.ts`
* for the find-or-create open-draft step; no schema changes, no new
* statuses. Admin PB only (consistent with the other service modules).
*/
import type PocketBase from "pocketbase";
import { getAdminPocketBase } from "./pocketbase-admin";
import { generateStockPurchaseNumber } from "./stock-purchases-service";
const STOCK_ITEMS = "stock_items";
const STOCK_PURCHASES = "stock_purchases";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
const SEARCH_LIMIT_MAX = 25;
const SEARCH_LIMIT_DEFAULT = 10;
/* ============================================================================
* Minimal PB client shape this module needs.
*
* The route passes in the real `getAdminPocketBase()`; the unit test
* passes in a tiny mock. Only the methods we actually call are typed.
* ========================================================================== */
export interface StockQuickDraftPb {
collection: (name: string) => {
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; sort?: string; expand?: string }
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
getFullList: (opts?: { filter?: string }) => Promise<Array<Record<string, unknown>>>;
getOne: (id: string, opts?: { expand?: string }) => Promise<Record<string, unknown>>;
create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
delete: (id: string) => Promise<Record<string, unknown>>;
};
}
/* ============================================================================
* Public types
* ========================================================================== */
export interface StockItemSearchResult {
id: string;
sku: string;
name: string;
description: string;
unit?: string;
}
export interface SupplierHistoryEntry {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
}
export interface AddQuickDraftLineNewStockItem {
name: string;
sku: string;
description?: string;
unit?: string;
}
export interface AddQuickDraftLineInput {
supplierId: string;
stockItemId?: string;
quantity: number;
costPrice: number;
newStockItem?: AddQuickDraftLineNewStockItem;
}
export type AddQuickDraftLineResult =
| { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean }
| { ok: false; reason: string; message: string };
export type RemoveQuickDraftLineResult =
| { ok: true }
| { ok: false; reason: string; message: string };
/* ============================================================================
* Helpers
* ========================================================================== */
async function resolvePb(pb?: StockQuickDraftPb): Promise<StockQuickDraftPb> {
if (pb) return pb;
return (await getAdminPocketBase()) as unknown as StockQuickDraftPb;
}
/* ============================================================================
* searchStockItemsForQuickDraft
* ========================================================================== */
/**
* Typeahead search over `stock_items`. Matches `sku` OR `name` with
* PocketBase's `~` (contains, case-insensitive). Excludes inactive
* items. Limit defaults to 10, clamped to a max of 25.
*
* Returns a narrow projection — only the fields the modal needs.
*/
export async function searchStockItemsForQuickDraft(
pb: StockQuickDraftPb,
q: string,
limit?: number,
): Promise<StockItemSearchResult[]> {
const safeLimit = Math.min(Math.max(limit ?? SEARCH_LIMIT_DEFAULT, 1), SEARCH_LIMIT_MAX);
const escaped = q.replace(/"/g, '\\"');
const filter = `is_active=true && (sku ~ "${escaped}" || name ~ "${escaped}")`;
const result = await pb.collection(STOCK_ITEMS).getList(1, safeLimit, {
filter,
sort: "name",
});
return result.items.map((row) => ({
id: String(row.id),
sku: String(row.sku ?? ""),
name: String(row.name ?? ""),
description: String(row.description ?? ""),
unit: row.unit ? String(row.unit) : undefined,
}));
}
/* ============================================================================
* getStockItemSupplierHistory
* ========================================================================== */
/**
* Returns the per-supplier purchase history for a `stock_item`.
* Reads `stock_purchase_items` joined through `stock_purchases` so
* we can aggregate by supplier. Filters to lines that have actually
* been received (`quantity_received > 0`) so drafts don't pollute
* the "we bought this from X before" list.
*
* The supplier is expanded via `purchase.supplier`; if the
* `stock_purchases.supplier` row no longer exists in `suppliers`,
* the line is silently skipped (we can't show a name for it).
*/
export async function getStockItemSupplierHistory(
pb: StockQuickDraftPb,
stockItemId: string,
): Promise<SupplierHistoryEntry[]> {
const rows = await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
filter: `stock_item = "${stockItemId}" && quantity_received > 0`,
});
const purchaseIds = Array.from(
new Set(
rows
.map((r) => (r.purchase ? String(r.purchase) : null))
.filter((id): id is string => id !== null),
),
);
if (purchaseIds.length === 0) return [];
// Fetch purchases in one call (with supplier expanded). We use
// a single getList rather than N getOnes — cheaper on PB.
const purchasesResult = await pb
.collection(STOCK_PURCHASES)
.getList(1, purchaseIds.length, {
filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
expand: "supplier",
});
const purchaseById = new Map<string, Record<string, unknown>>();
for (const p of purchasesResult.items) purchaseById.set(String(p.id), p);
type Agg = {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
};
const bySupplier = new Map<string, Agg>();
for (const row of rows) {
const purchaseId = row.purchase ? String(row.purchase) : null;
if (!purchaseId) continue;
const purchase = purchaseById.get(purchaseId);
if (!purchase) continue;
const expand = purchase.expand as
| { supplier?: { id: string; suppliername: string } }
| undefined;
const supplier = expand?.supplier;
if (!supplier) continue;
const supplierId = String(supplier.id);
const cost = typeof row.cost_price === "number" ? row.cost_price : 0;
const created = String(purchase.created ?? "");
const existing = bySupplier.get(supplierId);
if (!existing) {
bySupplier.set(supplierId, {
supplierId,
supplierName: String(supplier.suppliername ?? ""),
lastCost: cost,
lastDate: created,
timesBought: 1,
});
} else {
existing.timesBought += 1;
if (created > existing.lastDate) {
existing.lastDate = created;
existing.lastCost = cost;
}
}
}
return Array.from(bySupplier.values()).sort((a, b) =>
a.lastDate < b.lastDate ? 1 : a.lastDate > b.lastDate ? -1 : 0,
);
}