WFDASH-WEBAPP/src/lib/server/stock-quick-draft-service.ts
Dave da90706ce0 feat(stock): implement auto-markup for selling price and markup on stock item creation and updates
This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include:

- Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates.
- Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator.
- Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios.

This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
2026-09-16 09:49:52 +02:00

761 lines
28 KiB
TypeScript

/**
* 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 type { StockCostHistoryEntry } from "@/lib/types";
import { applyDefaultMarkupToPatch } from "./stock-default-markup-service";
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;
/**
* Optional cost to seed onto the brand-new `stock_items` row at
* inline-create time. When supplied, `stock_items.cost_price` is
* stamped immediately and a one-entry `cost_history` is
* bootstrapped (matches `StockCostHistoryEntry` shape from
* `lib/types.ts`) so the till and the POS see a price on day
* one. The accountant's `bookStockFromInvoice` flow will append
* a second entry when the supplier invoice is booked.
*
* Existing callers (no `costPrice`) keep the previous behaviour:
* the row is created with no `cost_price` and an empty
* `cost_history`.
*/
costPrice?: number;
}
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) ───────────────
// `inlineCreated` distinguishes "we just made this row" from
// "the caller supplied an existing stockItemId". The cost-backfill
// step below should only run on the existing-item path: when the
// row is brand-new, the inline-create branch above has already
// handled the seed (or intentionally skipped it for legacy
// callers — running the backfill on top would re-seed a row the
// caller explicitly wanted left alone).
let stockItemId = input.stockItemId;
let inlineCreated = false;
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 inlineCost = input.newStockItem.costPrice;
const hasInlineCost =
typeof inlineCost === "number" && Number.isFinite(inlineCost) && inlineCost >= 0;
// Build the operator-supplied portion of the patch, then run
// `applyDefaultMarkupToPatch` over it. When `inlineCost` is
// positive AND the operator didn't separately override the
// shelf price or markup, the helper auto-populates
// `selling_price = round(cost * 1.80, 2)` and `markup = 80`.
// Same idempotency rules as the operator CLI backfill:
// never overwrites an operator-set price. Pass `undefined`
// for the existing row (no row yet — this is the create
// path).
const inlinePatch: Record<string, unknown> = {
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,
cost_history: [],
};
if (hasInlineCost) inlinePatch.cost_price = inlineCost;
const created = await pb.collection(STOCK_ITEMS).create(
applyDefaultMarkupToPatch(inlinePatch, undefined),
);
stockItemId = String(created.id);
inlineCreated = true;
} 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 ?? "");
// ── Cost backfill + history append (existing-item path only) ────
// When the buyer adds a line for an item that was created in the
// pre-Fix-#1 gap (no `cost_price` stamped, empty `cost_history`),
// the buyer's just-typed line cost IS authoritative — backfill it
// onto the row so the till and the POS see a price today.
//
// Idempotency rules:
// - If `cost_price` is already non-zero, DON'T clobber it. The
// buyer or the booking flow owns that number; this line is
// just an audit-trail entry.
// - ALWAYS append a `cost_history` entry (even when not
// backfilling `cost_price`) so the line addition shows up in
// the audit log — `bookStockFromInvoice` would have written
// it after the invoice was booked, but for an inline draft
// add the receipt hasn't happened yet, so we capture the
// intent here.
// - On the inline-create path the seed step above already
// handled `cost_price` + `cost_history` (or intentionally
// skipped both). Skip the backfill so we don't double-write.
// - The patch is then run through `applyDefaultMarkupToPatch`
// with the existing row's selling_price / markup / cost.
// When the row has a positive cost AND no operator-set
// selling/markup, the helper fills them in at 80%. The
// helper does NOT touch `cost_history` when the patch has
// already appended an entry — the appended entry wins.
if (!inlineCreated) {
try {
const existingCostPrice = Number(stockItem.cost_price ?? 0);
const existingSellingPrice = Number(stockItem.selling_price ?? 0);
const existingMarkup = Number(stockItem.markup ?? 0);
const existingHistory = Array.isArray(stockItem.cost_history)
? (stockItem.cost_history as unknown as StockCostHistoryEntry[])
: [];
const shouldBackfill =
!(existingCostPrice > 0) && input.costPrice > 0;
const newEntry: StockCostHistoryEntry = {
cost_price: input.costPrice,
quantity: 0,
purchased_at: new Date().toISOString(),
purchase_id: "",
};
const basePatch: Record<string, unknown> = {
cost_history: [...existingHistory, newEntry],
};
if (shouldBackfill) {
basePatch.cost_price = input.costPrice;
}
const finalPatch = applyDefaultMarkupToPatch(basePatch, {
cost_price: existingCostPrice,
selling_price: existingSellingPrice,
markup: existingMarkup,
cost_history: stockItem.cost_history,
});
await pb.collection(STOCK_ITEMS).update(finalStockItemId, finalPatch);
} catch {
// Cost-patch failures must NOT block the line append. The line
// is the buyer's primary intent; the cost backfill is a
// best-effort audit/cache step. Surface nothing — the till
// will read whatever `cost_price` it had before.
}
}
// ── 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 ?? ""),
};
});
}