Two related enhancements to the orders review modal: * Tier 1 — header totals: the "Order items" header now shows the item count plus the cost and selling totals (qty*price summed across the live draft state). Uses formatCurrency from pb-helpers.ts for the ZAR formatting. * Tier 2 — add/remove items: the edit-mode items list now has an "Add item" button (Plus icon) and a per-row Trash icon. New rows get a sentinel id like "__new_1" and a left-edge accent stripe; rows flagged for removal flip to a strikethrough + reduced opacity with disabled inputs (toggle button flips to an Undo2 icon). On save, the route posts to /api/v1/orders/[id]/review-save with new itemsToAdd and itemsToRemove arrays. The service layer adds createOrderItems + softFlagOrderItems handlers (set item_status="removed" — operator confirmed the value is in the PB admin schema). The modal re-keys the new drafts with the returned PB ids so a follow-up save treats them as existing items. Partial-failure UI now has three sections (updates / adds / removes) sharing a labelled FailureSection helper. Response envelope now includes itemsAdded and itemsRemoved alongside the existing items block, plus a flat partialFailures array for the toast summary. Type cleanup: "removed" added to ItemStatus and the ITEM_STATUS_VISUAL map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
637 lines
22 KiB
TypeScript
637 lines
22 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
|
|
const ORDER_COLLECTION = "orders";
|
|
const QUOTE_COLLECTION = "quotes";
|
|
const QUOTE_ITEMS_COLLECTION = "quote_items";
|
|
const ORDER_COUNTERS_COLLECTION = "order_counters";
|
|
|
|
export function getOrderItemsCollectionName() {
|
|
return process.env.ORDER_ITEMS_COLLECTION || "order_items";
|
|
}
|
|
|
|
export async function createOrderFromQuote(quoteId: string) {
|
|
const pb = await getAdminPocketBase();
|
|
const orderItemsCollection = getOrderItemsCollectionName();
|
|
|
|
const existingOrderForQuote = await pb.collection(ORDER_COLLECTION).getList(1, 1, {
|
|
filter: `quote = "${quoteId}"`,
|
|
sort: "-created",
|
|
});
|
|
if (existingOrderForQuote.items.length > 0) {
|
|
return {
|
|
order: existingOrderForQuote.items[0],
|
|
created_items: 0,
|
|
order_items_collection: orderItemsCollection,
|
|
already_exists: true,
|
|
};
|
|
}
|
|
|
|
const quote = await pb.collection(QUOTE_COLLECTION).getOne(quoteId, {
|
|
expand: "customer,company",
|
|
});
|
|
|
|
const quoteItems = await pb.collection(QUOTE_ITEMS_COLLECTION).getFullList({
|
|
filter: `quote = "${quoteId}"`,
|
|
sort: "created",
|
|
});
|
|
|
|
if (quoteItems.length === 0) {
|
|
throw new Error("Quote has no items to convert into an order.");
|
|
}
|
|
|
|
// Atomic order number generation.
|
|
//
|
|
// Two bugs the old (read-then-write) code had:
|
|
// 1. **Month rollover**: it used `new Date()` for the year/month prefix
|
|
// even when the most recent order was from a prior month, so the
|
|
// sequence reset at every month boundary. The contract is: the
|
|
// order number follows the most recent order's month until the
|
|
// sequence is manually rolled (or naturally dies).
|
|
// 2. **Concurrency race**: read-then-write meant two simultaneous
|
|
// `createOrderFromQuote` calls could both observe 007 and both
|
|
// write 008, producing two orders with the same number.
|
|
//
|
|
// The fix mirrors the quote side: a dedicated `order_counters` collection
|
|
// keyed on `(year, month)` with a unique index, and an atomic
|
|
// `last_seq+` increment. The first request for a (year, month) pair
|
|
// creates the row; concurrent creates swallow the unique-constraint
|
|
// error and re-fetch. Sequence is reserved server-side, so the gap on
|
|
// rollback is the standard "monotonic counter" trade-off — much
|
|
// simpler than reservation + release.
|
|
const orderNumber = await generateOrderNumberServer(pb);
|
|
|
|
const order = await pb.collection(ORDER_COLLECTION).create({
|
|
order_number: orderNumber,
|
|
company: quote.company,
|
|
customer: quote.customer || null,
|
|
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,
|
|
});
|
|
|
|
for (const item of quoteItems) {
|
|
const quantity = Number(item.quantity || 1);
|
|
const payload = {
|
|
order: order.id,
|
|
description: item.description || "",
|
|
po_description: item.description || "",
|
|
quantity,
|
|
order_qty: quantity,
|
|
qty_ordered: String(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",
|
|
status: "active",
|
|
drv_status: "unassigned",
|
|
notes: item.notes || "",
|
|
quote_item: item.id,
|
|
};
|
|
|
|
await pb.collection(orderItemsCollection).create(payload);
|
|
}
|
|
|
|
return { order, created_items: quoteItems.length, order_items_collection: orderItemsCollection, already_exists: false };
|
|
}
|
|
|
|
/**
|
|
* Generates the next order number (`ORD-YYYY-MM-NNN`) atomically.
|
|
*
|
|
* Sequence is keyed on the **(year, month) of the most recent order**,
|
|
* not on wall-clock time. This means the sequence does NOT reset at
|
|
* month boundary — if the latest order is `ORD-2026-06-007` and we're
|
|
* now in July, the next order is `ORD-2026-06-008`. The sequence
|
|
* restarts at 001 only when the `order_counters` row for the relevant
|
|
* month is absent (i.e. no order has ever been created for that month,
|
|
* or the row was wiped by a destructive operation).
|
|
*
|
|
* The (year, month) is discovered from the latest order in a single
|
|
* `getList` call. The counter row for that pair is then incremented
|
|
* atomically with `last_seq+`. Two concurrent callers cannot collide
|
|
* — the first commits the increment, the second reads the new value
|
|
* and commits +1 from there.
|
|
*
|
|
* If the `orders` collection is empty, the sequence starts at
|
|
* `ORD-{now.year}-{now.month}-001` (i.e. a fresh month starts at 001).
|
|
*
|
|
* Mirrors `generateQuoteNumberServer` in `quotes-service.ts`; the two
|
|
* counters are independent but use the same pattern.
|
|
*/
|
|
export async function generateOrderNumberServer(
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>
|
|
): Promise<string> {
|
|
const pb = pbArg || (await getAdminPocketBase());
|
|
|
|
const { year, month } = await resolveCurrentSequenceMonth(pb);
|
|
const counter = await getOrCreateOrderCounter(pb, year, month);
|
|
const updated = await pb.collection(ORDER_COUNTERS_COLLECTION).update(counter.id, {
|
|
"last_seq+": 1,
|
|
});
|
|
|
|
return formatOrderNumber(year, month, updated.last_seq);
|
|
}
|
|
|
|
export async function listCollectionPool(params: { status?: string; page?: number; perPage?: number }) {
|
|
const pb = await getAdminPocketBase();
|
|
const page = params.page || 1;
|
|
const perPage = params.perPage || 50;
|
|
const status = params.status || "";
|
|
|
|
const filter = status ? `pool_status = "${status}"` : "";
|
|
|
|
return pb.collection("collection_pool").getList(page, perPage, {
|
|
filter,
|
|
sort: "-created",
|
|
expand: "order_item,order,supplier,drv_id",
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the (year, month) that the next order number should be keyed
|
|
* on. If there's a most recent order, that's the (year, month) — the
|
|
* sequence continues across month boundaries. If the table is empty
|
|
* (or the latest order's prefix doesn't parse), fall back to
|
|
* wall-clock year/month so a brand-new month starts at 001.
|
|
*/
|
|
async function resolveCurrentSequenceMonth(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>
|
|
): Promise<{ year: number; month: number }> {
|
|
const now = new Date();
|
|
const fallback = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
|
|
try {
|
|
const latest = await pb.collection(ORDER_COLLECTION).getList<{ order_number?: string }>(1, 1, {
|
|
sort: "-order_number",
|
|
});
|
|
if (latest.items.length === 0) return fallback;
|
|
const parsed = parseOrderNumberPrefix(latest.items[0].order_number || "");
|
|
return parsed ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses an `ORD-YYYY-MM-NNN` (or legacy `ORD-YYYY-MM-NN`) string and
|
|
* returns the (year, month) prefix, or null if the input doesn't look
|
|
* like a recognized order number. Used to discover which (year, month)
|
|
* the counter should be keyed on.
|
|
*/
|
|
function parseOrderNumberPrefix(s: string): { year: number; month: number } | null {
|
|
const m = (s || "").match(/^ORD-(\d{4})-(\d{2})-\d+$/);
|
|
if (!m) return null;
|
|
const year = Number(m[1]);
|
|
const month = Number(m[2]);
|
|
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
|
|
return null;
|
|
}
|
|
return { year, month };
|
|
}
|
|
|
|
async function getOrCreateOrderCounter(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
year: number,
|
|
month: number
|
|
) {
|
|
try {
|
|
return await pb
|
|
.collection(ORDER_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
} catch (err) {
|
|
// First request for this (year, month) — backfill the counter from
|
|
// any pre-existing orders in that month, then create it. Without
|
|
// the backfill, the new code would start at ORD-{year}-{month}-001
|
|
// and collide with the existing sequence. The `last_seq` we set is
|
|
// the max sequence in (year, month) — the next `last_seq+1`
|
|
// increment will land on the correct next number.
|
|
const backfill = await getMaxOrderSequenceInMonth(pb, year, month);
|
|
try {
|
|
return await pb.collection(ORDER_COUNTERS_COLLECTION).create({
|
|
year,
|
|
month,
|
|
last_seq: backfill,
|
|
});
|
|
} catch {
|
|
// Concurrent caller beat us to it — re-fetch the row they made.
|
|
return await pb
|
|
.collection(ORDER_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the highest sequence number found in `orders.order_number` for
|
|
* the given (year, month) — or 0 if no orders exist for that month. Used
|
|
* to seed the counter when it's first created for a (year, month) pair,
|
|
* so the generator continues from the existing sequence rather than
|
|
* resetting to 001.
|
|
*/
|
|
async function getMaxOrderSequenceInMonth(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
year: number,
|
|
month: number
|
|
): Promise<number> {
|
|
const mm = String(month).padStart(2, "0");
|
|
const prefix = `ORD-${year}-${mm}-`;
|
|
try {
|
|
const all = await pb
|
|
.collection(ORDER_COLLECTION)
|
|
.getFullList<{ order_number?: string }>({
|
|
filter: `order_number ~ "${prefix}"`,
|
|
});
|
|
let max = 0;
|
|
for (const o of all) {
|
|
const m = (o.order_number || "").match(/^ORD-\d{4}-\d{2}-(\d+)$/);
|
|
if (m) {
|
|
const n = parseInt(m[1], 10);
|
|
if (Number.isFinite(n) && n > max) max = n;
|
|
}
|
|
}
|
|
return max;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function formatOrderNumber(year: number, month: number, seq: number): string {
|
|
return `ORD-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Thrown by `updateOrderReviewFields` when the requested `order_number`
|
|
* is already in use by a different order. Carries the colliding
|
|
* order's id so the route handler can include it in the 409 response
|
|
* (the modal renders it as a link to the conflicting order).
|
|
*/
|
|
export class OrderNumberCollisionError extends Error {
|
|
readonly collidingOrderId: string;
|
|
readonly attemptedNumber: string;
|
|
constructor(collidingOrderId: string, attemptedNumber: string) {
|
|
super(
|
|
`Order number "${attemptedNumber}" is already in use by another order.`,
|
|
);
|
|
this.name = "OrderNumberCollisionError";
|
|
this.collidingOrderId = collidingOrderId;
|
|
this.attemptedNumber = attemptedNumber;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patches the order header fields editable from the review modal.
|
|
* Currently supports `order_number` and `customer`. Validates
|
|
* `order_number` uniqueness against the `orders` collection
|
|
* (excluding self) before write; throws
|
|
* `OrderNumberCollisionError` on collision.
|
|
*/
|
|
export async function updateOrderReviewFields(
|
|
orderId: string,
|
|
patch: { order_number?: string; customer?: string },
|
|
) {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// Validate order_number uniqueness before write — we do this as a
|
|
// separate query rather than letting PB throw on its unique index
|
|
// so we can return the colliding row's id to the UI (PB's own
|
|
// 400 doesn't carry it).
|
|
if (patch.order_number !== undefined) {
|
|
const existing = await pb
|
|
.collection(ORDER_COLLECTION)
|
|
.getOne(orderId);
|
|
if (existing.order_number !== patch.order_number) {
|
|
// Escape embedded quotes before interpolating into the PB
|
|
// filter — an order_number containing `"` would otherwise break
|
|
// the filter expression (or worse, alter it).
|
|
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
|
|
const escapedId = orderId.replace(/"/g, '\\"');
|
|
const conflict = await pb
|
|
.collection(ORDER_COLLECTION)
|
|
.getList<{ id: string; order_number: string }>(1, 1, {
|
|
filter: `order_number = "${escapedNumber}" && id != "${escapedId}"`,
|
|
});
|
|
if (conflict.items.length > 0) {
|
|
throw new OrderNumberCollisionError(
|
|
conflict.items[0].id,
|
|
patch.order_number,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build a clean patch object with only the defined keys — passing
|
|
// `undefined` to PB is harmless but we keep the wire payload tight.
|
|
const cleanPatch: Record<string, unknown> = {};
|
|
if (patch.order_number !== undefined) cleanPatch.order_number = patch.order_number;
|
|
if (patch.customer !== undefined) cleanPatch.customer = patch.customer;
|
|
|
|
if (Object.keys(cleanPatch).length === 0) {
|
|
// No-op — just re-fetch and return.
|
|
return pb.collection(ORDER_COLLECTION).getOne(orderId);
|
|
}
|
|
|
|
try {
|
|
return await pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch);
|
|
} catch (err) {
|
|
// Catch the unique-index race: two callers can both pass the
|
|
// pre-write collision check above, then race to commit. The loser's
|
|
// update then fails on the unique index. Map that to the same
|
|
// structured error class so the route can still 409 — and re-
|
|
// query to surface the colliding row's id (PB's native error
|
|
// string doesn't carry it).
|
|
if (err && /UNIQUE constraint failed/i.test(String(err))) {
|
|
if (patch.order_number !== undefined) {
|
|
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
|
|
const conflict = await pb
|
|
.collection(ORDER_COLLECTION)
|
|
.getList<{ id: string }>(1, 1, {
|
|
filter: `order_number = "${escapedNumber}"`,
|
|
});
|
|
if (conflict.items[0]) {
|
|
throw new OrderNumberCollisionError(
|
|
conflict.items[0].id,
|
|
patch.order_number,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patches price/qty fields on the order's line items in a non-
|
|
* transactional batch. Each item is PATCHed individually; per-row
|
|
* failures are accumulated into `failed` rather than aborting the
|
|
* batch, so the operator sees which lines couldn't save and can
|
|
* retry just those.
|
|
*
|
|
* Markup computation: if the caller supplies `selling_price` OR
|
|
* `cost_price` (but not both) and we know the other from the
|
|
* current row, we recompute `markup` from the supplied + known
|
|
* values. If the caller supplies `markup` explicitly, we pass it
|
|
* through verbatim.
|
|
*/
|
|
export async function updateOrderItemsPrices(
|
|
orderId: string,
|
|
items: Array<{
|
|
id: string;
|
|
quantity?: number | null;
|
|
selling_price?: number | null;
|
|
cost_price?: number | null;
|
|
markup?: number | null;
|
|
}>,
|
|
): Promise<{
|
|
updated: number;
|
|
failed: Array<{ id: string; error: string }>;
|
|
}> {
|
|
const pb = await getAdminPocketBase();
|
|
const orderItemsCollection = getOrderItemsCollectionName();
|
|
|
|
if (items.length === 0) return { updated: 0, failed: [] };
|
|
|
|
const updated: string[] = [];
|
|
const failed: Array<{ id: string; error: string }> = [];
|
|
|
|
for (const item of items) {
|
|
try {
|
|
// Read the current row so we can compute markup when only
|
|
// one of selling_price/cost_price is supplied.
|
|
const current = await pb.collection(orderItemsCollection).getOne(item.id);
|
|
|
|
// Reject items that don't belong to the requested order. Without
|
|
// this, a request to order A can include an item ID from order B
|
|
// and modify it — a serious cross-order write vulnerability.
|
|
if (current.order !== orderId) {
|
|
failed.push({
|
|
id: item.id,
|
|
error: "Item does not belong to this order",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {};
|
|
if (item.quantity !== undefined) patch.quantity = item.quantity;
|
|
if (item.selling_price !== undefined) patch.selling_price = item.selling_price;
|
|
if (item.cost_price !== undefined) patch.cost_price = item.cost_price;
|
|
|
|
// Markup computation:
|
|
// - explicit markup in patch → pass through
|
|
// - selling_price supplied, cost_price not → recompute from selling/current-cost
|
|
// - cost_price supplied, selling_price not → recompute from current-selling/cost
|
|
// - both supplied → recompute from the new pair
|
|
// - neither supplied, no explicit markup → leave current markup alone
|
|
//
|
|
// `markup_percent` mirrors `markup` (the two fields are kept in
|
|
// lock-step by `createOrderFromQuote`); write both together so
|
|
// the row never goes stale after a price edit.
|
|
if (item.markup !== undefined) {
|
|
patch.markup = item.markup;
|
|
patch.markup_percent = item.markup;
|
|
} else if (
|
|
item.selling_price !== undefined ||
|
|
item.cost_price !== undefined
|
|
) {
|
|
const newSelling = item.selling_price ?? current.selling_price;
|
|
const newCost = item.cost_price ?? current.cost_price;
|
|
const newMarkup =
|
|
Number(newCost) > 0
|
|
? Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100
|
|
: 0;
|
|
patch.markup = newMarkup;
|
|
patch.markup_percent = newMarkup;
|
|
}
|
|
|
|
// Empty patch: nothing to write. Skip the PB round-trip so we
|
|
// don't stamp `updated` and don't bump the row's `updated` ts.
|
|
if (Object.keys(patch).length === 0) {
|
|
continue;
|
|
}
|
|
|
|
await pb.collection(orderItemsCollection).update(item.id, patch);
|
|
updated.push(item.id);
|
|
} catch (err) {
|
|
failed.push({
|
|
id: item.id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { updated: updated.length, failed };
|
|
}
|
|
|
|
/**
|
|
* Creates new line items on the order in a non-transactional batch.
|
|
*
|
|
* Each draft is created independently; per-row failures are accumulated
|
|
* into `failed` rather than aborting the batch (matching the
|
|
* partial-failure semantics of `updateOrderItemsPrices`). The route
|
|
* handler can surface the failed rows so the operator can retry just
|
|
* those.
|
|
*
|
|
* Markup is written in lock-step with `markup_percent` (mirrors the
|
|
* existing `createOrderFromQuote` pattern). New rows default to
|
|
* `item_status = "pending"` so the standard order workflow picks them
|
|
* up; the operator can change the status later via the process page.
|
|
*/
|
|
export async function createOrderItems(
|
|
orderId: string,
|
|
drafts: Array<{
|
|
description: string;
|
|
quantity: number | null;
|
|
cost_price: number;
|
|
markup: number;
|
|
selling_price: number;
|
|
}>,
|
|
): Promise<{
|
|
created: Array<{
|
|
id: string;
|
|
description: string;
|
|
quantity: number | null;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
markup: number;
|
|
}>;
|
|
failed: Array<{ draft: typeof drafts[number]; error: string }>;
|
|
}> {
|
|
const pb = await getAdminPocketBase();
|
|
const orderItemsCollection = getOrderItemsCollectionName();
|
|
|
|
if (drafts.length === 0) return { created: [], failed: [] };
|
|
|
|
// Verify the parent order exists before we touch any items. PB
|
|
// would otherwise happily create orphan rows on a non-existent
|
|
// order id and the operator would only learn about it from the
|
|
// partial-failure UI. A 404 on the parent is a clean signal.
|
|
try {
|
|
await pb.collection(ORDER_COLLECTION).getOne(orderId);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
created: [],
|
|
failed: drafts.map((d) => ({ draft: d, error: `Order not found: ${message}` })),
|
|
};
|
|
}
|
|
|
|
const created: Array<{
|
|
id: string;
|
|
description: string;
|
|
quantity: number | null;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
markup: number;
|
|
}> = [];
|
|
const failed: Array<{ draft: typeof drafts[number]; error: string }> = [];
|
|
|
|
for (const draft of drafts) {
|
|
try {
|
|
// Description is required (PB would 400 on a missing value
|
|
// anyway, but we surface a clear message in the per-row failure
|
|
// list rather than a generic PB error). Empty description is
|
|
// treated as a validation failure so the operator gets a
|
|
// targeted hint instead of a 500.
|
|
if (!draft.description || !draft.description.trim()) {
|
|
failed.push({
|
|
draft,
|
|
error: "Description is required",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const payload = {
|
|
order: orderId,
|
|
description: draft.description,
|
|
po_description: draft.description,
|
|
quantity: draft.quantity ?? 1,
|
|
order_qty: draft.quantity ?? 1,
|
|
selling_price: draft.selling_price,
|
|
cost_price: draft.cost_price,
|
|
markup: draft.markup,
|
|
markup_percent: draft.markup,
|
|
item_status: "pending",
|
|
status: "active",
|
|
drv_status: "unassigned",
|
|
in_stock: false,
|
|
notes: "",
|
|
};
|
|
|
|
const row = await pb.collection(orderItemsCollection).create(payload);
|
|
created.push({
|
|
id: row.id,
|
|
description: draft.description,
|
|
quantity: draft.quantity ?? null,
|
|
cost_price: draft.cost_price,
|
|
selling_price: draft.selling_price,
|
|
markup: draft.markup,
|
|
});
|
|
} catch (err) {
|
|
failed.push({
|
|
draft,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { created, failed };
|
|
}
|
|
|
|
/**
|
|
* Soft-flags line items by setting `item_status = "removed"`. We
|
|
* don't delete the rows — the order page, PO PDFs, and historical
|
|
* reports all reference them. The "removed" status is a value the PB
|
|
* admin has added to the `item_status` select field (added by the
|
|
* operator before this code landed; see AGENTS.md — the API will 500
|
|
* with "Failed to update record." if the value isn't in the schema).
|
|
*
|
|
* Cross-order check: each target is read and verified to belong to
|
|
* `orderId` before the flag is written. Same pattern as
|
|
* `updateOrderItemsPrices` — without this, a request against order A
|
|
* could soft-flag items owned by order B.
|
|
*/
|
|
export async function softFlagOrderItems(
|
|
orderId: string,
|
|
ids: string[],
|
|
): Promise<{
|
|
softFlagged: string[];
|
|
failed: Array<{ id: string; error: string }>;
|
|
}> {
|
|
const pb = await getAdminPocketBase();
|
|
const orderItemsCollection = getOrderItemsCollectionName();
|
|
|
|
if (ids.length === 0) return { softFlagged: [], failed: [] };
|
|
|
|
const softFlagged: string[] = [];
|
|
const failed: Array<{ id: string; error: string }> = [];
|
|
|
|
for (const id of ids) {
|
|
try {
|
|
const current = await pb.collection(orderItemsCollection).getOne(id);
|
|
if (current.order !== orderId) {
|
|
failed.push({
|
|
id,
|
|
error: "Item does not belong to this order",
|
|
});
|
|
continue;
|
|
}
|
|
await pb.collection(orderItemsCollection).update(id, {
|
|
item_status: "removed",
|
|
});
|
|
softFlagged.push(id);
|
|
} catch (err) {
|
|
failed.push({
|
|
id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { softFlagged, failed };
|
|
}
|