2026-07-30 19:38:58 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* ========================================================================== */
|
|
|
|
|
|
2026-07-30 20:02:39 +00:00
|
|
|
/**
|
|
|
|
|
* 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, '\\"');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 19:38:58 +00:00
|
|
|
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);
|
2026-07-30 20:02:39 +00:00
|
|
|
const escaped = escapePbFilterString(q);
|
2026-07-30 19:38:58 +00:00
|
|
|
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,
|
|
|
|
|
}));
|
2026-07-30 19:50:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* 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({
|
2026-07-30 20:02:39 +00:00
|
|
|
filter: `stock_item = "${escapePbFilterString(stockItemId)}" && quantity_received > 0`,
|
2026-07-30 19:50:04 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
2026-07-30 19:55:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* findOrCreateOpenDraft
|
|
|
|
|
* ========================================================================== */
|
|
|
|
|
|
2026-07-30 20:02:39 +00:00
|
|
|
type FindOrCreateOpenDraftResult =
|
|
|
|
|
| {
|
|
|
|
|
ok: true;
|
|
|
|
|
purchaseId: string;
|
|
|
|
|
purchaseNumber: string;
|
|
|
|
|
createdDraft: boolean;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
ok: false;
|
|
|
|
|
reason: string;
|
|
|
|
|
message: string;
|
|
|
|
|
};
|
2026-07-30 19:55:02 +00:00
|
|
|
|
|
|
|
|
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, {
|
2026-07-30 20:02:39 +00:00
|
|
|
filter: `supplier = "${escapePbFilterString(supplierId)}" && status = "draft"`,
|
2026-07-30 19:55:02 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 20:02:39 +00:00
|
|
|
// 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: "",
|
|
|
|
|
});
|
2026-07-30 19:55:02 +00:00
|
|
|
|
2026-07-30 20:02:39 +00:00
|
|
|
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",
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-30 19:55:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* 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",
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-30 20:04:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
* 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",
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-30 20:06:24 +00:00
|
|
|
}
|