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

658 lines
23 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 { 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; sort?: string; fields?: 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 };
/**
* Per-line summary surfaced in the cards region of the Quick Draft
* modal. Narrower than `StockPurchaseItem` — only the fields the
* buyer needs to decide whether to keep or remove a line.
*/
export interface DraftLineSummary {
id: string;
description: string;
quantity: number;
cost_price: number;
}
/**
* Per-purchase summary surfaced in the cards region. Distinct from
* `StockPurchase`: `supplier_name` is pre-expanded so the modal
* doesn't have to do a second lookup, and `lines` is the denormalised
* child list so a single render read populates the whole card.
*/
export interface DraftPurchaseSummary {
id: string;
purchase_number: string;
/** Raw supplier PB id (kept for symmetry with the underlying record). */
supplier: string;
/** Denormalised `suppliers.name`, looked up via the expand. */
supplier_name: string;
lines: DraftLineSummary[];
created: string;
}
/* ============================================================================
* Helpers
* ========================================================================== */
/**
* Escape a string so it can be safely embedded inside a PocketBase
* filter expression (which uses `"..."` quoting). PB itself allows
* backslash-escaped characters inside quoted strings; we only ever
* interpolate this from admin-PB routes where ids are PB-generated
* 15-char alphanumeric, but backfilling the pattern keeps the file
* consistent and rules out accidental injection from a future
* non-admin caller.
*/
function escapePbFilterString(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
export interface SupplierChoice {
id: string;
name: string;
}
export async function listSuppliersForQuickDraft(
pb: StockQuickDraftPb,
): Promise<SupplierChoice[]> {
const result = await pb.collection("suppliers").getFullList({
sort: "suppliername",
fields: "id,suppliername",
});
return result.map((r) => ({
id: String(r.id),
name: String(r.suppliername ?? ""),
}));
}
/**
* 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 = escapePbFilterString(q);
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 = "${escapePbFilterString(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,
);
}
/* ============================================================================
* findOrCreateOpenDraft
* ========================================================================== */
type FindOrCreateOpenDraftResult =
| {
ok: true;
purchaseId: string;
purchaseNumber: string;
createdDraft: boolean;
}
| {
ok: false;
reason: string;
message: string;
};
async function findOrCreateOpenDraft(
pb: StockQuickDraftPb,
supplierId: string,
): Promise<FindOrCreateOpenDraftResult> {
// Look for an open draft for this supplier. The list is sorted
// by `created` desc so the most recent draft wins if there are
// multiple (the buyer may have accumulated them across modal
// sessions, but the design guarantees only one per supplier
// exists at a time in the happy path).
const existing = await pb.collection(STOCK_PURCHASES).getList(1, 1, {
filter: `supplier = "${escapePbFilterString(supplierId)}" && status = "draft"`,
sort: "-created",
});
if (existing.items.length > 0) {
const row = existing.items[0];
return {
ok: true,
purchaseId: String(row.id),
purchaseNumber: String(row.purchase_number ?? ""),
createdDraft: false,
};
}
// No open draft — generate a number and create one. Both of these
// calls can throw on PB-side errors (admin token expired, schema
// mismatch, race on purchase_number collision, etc.). Map them
// to the documented `create_failed` reason so the modal can show
// a recoverable error rather than crash with an unhandled
// rejection.
try {
const purchaseNumber = await generateStockPurchaseNumber(
pb as unknown as Parameters<typeof generateStockPurchaseNumber>[0],
);
const created = await pb.collection(STOCK_PURCHASES).create({
purchase_number: purchaseNumber,
supplier: supplierId,
status: "draft",
notes: "",
});
return {
ok: true,
purchaseId: String(created.id),
purchaseNumber,
createdDraft: true,
};
} catch (err) {
return {
ok: false,
reason: "create_failed",
message: err instanceof Error ? err.message : "draft creation failed",
};
}
}
/* ============================================================================
* addQuickDraftLine
* ========================================================================== */
/**
* Adds a single line to a draft stock_purchase for the given supplier.
*
* Behaviour:
* - If the caller supplied `newStockItem`, create that `stock_items`
* row first (current_qty: 0, is_active: true, supplier: input.supplierId).
* - Find or create an open draft for the supplier (status=draft).
* - Append a `stock_purchase_items` row to that draft.
*
* Returns `{ ok: true, ... }` with the purchase + line ids on success,
* or `{ ok: false, reason, message }` on validation / lookup / write
* failure. The modal maps `reason` to a UI error category.
*/
export async function addQuickDraftLine(
pb: StockQuickDraftPb,
input: AddQuickDraftLineInput,
): Promise<AddQuickDraftLineResult> {
// ── Validate input ─────────────────────────────────────────────
if (typeof input.supplierId !== "string" || input.supplierId.length === 0) {
return { ok: false, reason: "validation", message: "supplierId is required" };
}
if (typeof input.quantity !== "number" || input.quantity <= 0) {
return { ok: false, reason: "validation", message: "quantity must be > 0" };
}
if (typeof input.costPrice !== "number" || input.costPrice < 0) {
return { ok: false, reason: "validation", message: "costPrice must be >= 0" };
}
if (!input.stockItemId && !input.newStockItem) {
return {
ok: false,
reason: "validation",
message: "either stockItemId or newStockItem is required",
};
}
// ── Confirm supplier exists ────────────────────────────────────
try {
await pb.collection("suppliers").getOne(input.supplierId);
} catch (err) {
return {
ok: false,
reason: "supplier_not_found",
message: err instanceof Error ? err.message : "supplier lookup failed",
};
}
// ── Resolve the stock_item id (create if inline) ───────────────
let stockItemId = input.stockItemId;
if (!stockItemId && input.newStockItem) {
if (!input.newStockItem.name || !input.newStockItem.sku) {
return {
ok: false,
reason: "validation",
message: "newStockItem.name and newStockItem.sku are required",
};
}
try {
const created = await pb.collection(STOCK_ITEMS).create({
name: input.newStockItem.name,
sku: input.newStockItem.sku,
description: input.newStockItem.description ?? "",
unit: input.newStockItem.unit ?? "",
current_qty: 0,
is_active: true,
supplier: input.supplierId,
});
stockItemId = String(created.id);
} catch (err) {
return {
ok: false,
reason: "create_failed",
message: err instanceof Error ? err.message : "stock_item create failed",
};
}
} else if (stockItemId) {
// ── Confirm existing stock_item exists ────────────────────────
try {
await pb.collection(STOCK_ITEMS).getOne(stockItemId);
} catch (err) {
return {
ok: false,
reason: "stock_item_not_found",
message: err instanceof Error ? err.message : "stock_item lookup failed",
};
}
}
// stockItemId is now guaranteed non-null.
const finalStockItemId = stockItemId as string;
// ── Find or create the open draft ──────────────────────────────
const draft = await findOrCreateOpenDraft(pb, input.supplierId);
if (!draft.ok) {
return { ok: false, reason: "create_failed", message: "could not resolve draft" };
}
// ── Look up the item's name so we can denormalise onto the line ─
const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId);
const description = String(stockItem.name ?? "");
// ── Append the line ────────────────────────────────────────────
try {
const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({
purchase: draft.purchaseId,
stock_item: finalStockItemId,
description,
quantity: input.quantity,
cost_price: input.costPrice,
});
return {
ok: true,
purchaseId: draft.purchaseId,
purchaseNumber: draft.purchaseNumber,
lineId: String(line.id),
createdDraft: draft.createdDraft,
};
} catch (err) {
return {
ok: false,
reason: "append_failed",
message: err instanceof Error ? err.message : "line create failed",
};
}
}
/* ============================================================================
* removeQuickDraftLine
* ========================================================================== */
/**
* Deletes a `stock_purchase_items` row by id. Used by the modal's
* cards-region "Remove" button so the buyer can clean up mistakes
* without leaving the modal.
*
* Returns `{ ok: true }` on success, or `{ ok: false, reason }` on
* failure. The modal surfaces the message as a toast.
*/
export async function removeQuickDraftLine(
pb: StockQuickDraftPb,
lineId: string,
): Promise<RemoveQuickDraftLineResult> {
try {
await pb.collection(STOCK_PURCHASE_ITEMS).delete(lineId);
return { ok: true };
} catch (err) {
return {
ok: false,
reason: "line_not_found",
message: err instanceof Error ? err.message : "line delete failed",
};
}
}
/* ============================================================================
* updateQuickDraftLine
* ========================================================================== */
export type UpdateQuickDraftLineResult =
| { ok: true }
| { ok: false; reason: "validation" | "line_not_found" | "update_failed"; message: string };
/**
* Updates the quantity and/or cost price on a `stock_purchase_items` row.
* Used by the modal's inline line editor for correcting draft values.
*
* Returns `{ ok: true }` on success, or `{ ok: false, reason, message }`
* on validation or update failure. At least one field is required;
* quantity must be positive and cost price must be non-negative.
*/
export async function updateQuickDraftLine(
pb: StockQuickDraftPb,
input: { lineId: string; quantity?: number; costPrice?: number },
): Promise<UpdateQuickDraftLineResult> {
if (typeof input.lineId !== "string" || input.lineId.length === 0) {
return { ok: false, reason: "validation", message: "lineId is required" };
}
if (input.quantity === undefined && input.costPrice === undefined) {
return { ok: false, reason: "validation", message: "at least one of quantity or costPrice is required" };
}
if (input.quantity !== undefined && (typeof input.quantity !== "number" || input.quantity <= 0)) {
return { ok: false, reason: "validation", message: "quantity must be > 0" };
}
if (input.costPrice !== undefined && (typeof input.costPrice !== "number" || input.costPrice < 0)) {
return { ok: false, reason: "validation", message: "costPrice must be >= 0" };
}
const patch: { quantity?: number; cost_price?: number } = {};
if (input.quantity !== undefined) patch.quantity = input.quantity;
if (input.costPrice !== undefined) patch.cost_price = input.costPrice;
try {
await pb.collection(STOCK_PURCHASE_ITEMS).update(input.lineId, patch);
return { ok: true };
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 404) {
return { ok: false, reason: "line_not_found", message: err instanceof Error ? err.message : "line not found" };
}
return { ok: false, reason: "update_failed", message: err instanceof Error ? err.message : "line update failed" };
}
}
/* ============================================================================
* listOpenDrafts
* ========================================================================== */
/**
* List every `stock_purchase` in `draft` status, with supplier name
* pre-expanded and the child line items denormalised onto the result.
*
* Backs the cards region of the Quick Draft modal. The endpoint is
* intentionally separate from `GET /api/v1/stock-purchases` so the
* browser can call it without the agent-API bearer (and so the
* shape stays tailored to what the cards UI needs — no need to
* expose the full `StockPurchase` row, the per-row `item_count`,
* or the supplier PB id as a separate lookup).
*
* Two round-trips to PB:
* 1. `stock_purchases` with `status="draft"`, `expand=supplier`,
* `sort=-created`. Returns newest drafts first.
* 2. `stock_purchase_items` filtered by `purchase IN (id1, id2, ...)`,
* grouped by `purchase` id and attached to each summary under
* `lines`. Batched (single getFullList) so the cost is O(1)
* network round-trips regardless of how many drafts the buyer
* has accumulated.
*
* Returns the summary array sorted by `created` desc. Empty when
* no drafts exist (the modal renders an empty-state on length 0).
*
* NOTE: capped at `perPage=200` because the Quick Draft modal does not
* paginate. If a buyer ever accumulates more than 200 open drafts the
* oldest ones are silently truncated — at that point consider adding
* pagination to the modal or filtering by date.
*/
export async function listOpenDrafts(
pb: StockQuickDraftPb,
): Promise<DraftPurchaseSummary[]> {
const perPage = 200; // Upper bound for the modal's "open drafts" view; modal does not paginate.
const result = await pb.collection(STOCK_PURCHASES).getList(1, perPage, {
filter: `status = "draft"`,
sort: "-created",
expand: "supplier",
});
const purchases = result.items;
if (purchases.length === 0) return [];
// Batch-fetch lines for every draft in one call. PB doesn't auto-
// expand the `purchase` reverse relation on `stock_purchase_items`,
// so we filter on `purchase = id` (OR'd across all draft ids) and
// then group in JS. Quotes are escaped because ids could in theory
// contain a quote (PB-generated ids are 15-char alphanumeric, but
// the helper keeps the pattern consistent with the rest of the file).
const purchaseIds = purchases.map((p) => String(p.id));
const linesResult = await pb
.collection(STOCK_PURCHASE_ITEMS)
.getFullList({
filter: purchaseIds
.map((id) => `purchase = "${escapePbFilterString(id)}"`)
.join(" || "),
});
type LineRow = {
id: string;
purchase?: string | null;
description?: unknown;
quantity?: unknown;
cost_price?: unknown;
};
const linesByPurchase = new Map<string, DraftLineSummary[]>();
for (const raw of linesResult as unknown as LineRow[]) {
const purchaseId = raw.purchase ? String(raw.purchase) : null;
if (!purchaseId) continue;
const summary: DraftLineSummary = {
id: String(raw.id),
description: String(raw.description ?? ""),
quantity: typeof raw.quantity === "number" ? raw.quantity : Number(raw.quantity ?? 0),
cost_price:
typeof raw.cost_price === "number"
? raw.cost_price
: Number(raw.cost_price ?? 0),
};
const bucket = linesByPurchase.get(purchaseId);
if (bucket) bucket.push(summary);
else linesByPurchase.set(purchaseId, [summary]);
}
return purchases.map((row) => {
type ExpandedSupplier = { id: string; suppliername?: string };
const expand = row.expand as { supplier?: ExpandedSupplier } | undefined;
const supplierName = String(expand?.supplier?.suppliername ?? "");
return {
id: String(row.id),
purchase_number: String(row.purchase_number ?? ""),
supplier: String(row.supplier ?? ""),
supplier_name: String(supplierName),
lines: linesByPurchase.get(String(row.id)) ?? [],
created: String(row.created ?? ""),
};
});
}