200 lines
5.9 KiB
TypeScript
200 lines
5.9 KiB
TypeScript
import type { StockItem } from "@/lib/types";
|
|
|
|
export type StockPricingUpdate = {
|
|
id: string;
|
|
costPrice?: number;
|
|
sellingPrice?: number;
|
|
markup?: number;
|
|
};
|
|
|
|
export type UpdateStockItemsPricingInput = {
|
|
/** PB client; passed in so tests can swap a stub. */
|
|
pb: StockItemsPb;
|
|
updates: StockPricingUpdate[];
|
|
actor: { id: string; name: string };
|
|
};
|
|
|
|
export type StockItemsPb = {
|
|
collection: (id: string) => {
|
|
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
|
|
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
|
|
create: (data: Record<string, unknown>) => Promise<unknown>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Each failure has a stable reason code so the route can map to HTTP status
|
|
* (not_found → 404, forbidden_field / validation_error → 400, pb_error → 422)
|
|
* without parsing the message string.
|
|
*/
|
|
export type UpdateStockItemsPricingFailureReason =
|
|
| "forbidden_field"
|
|
| "validation_error"
|
|
| "not_found"
|
|
| "pb_error";
|
|
|
|
export type UpdateStockItemsPricingResultRow =
|
|
| {
|
|
id: string;
|
|
ok: true;
|
|
item: StockItem;
|
|
previous: StockItemPricingSnapshot;
|
|
}
|
|
| {
|
|
id: string;
|
|
ok: false;
|
|
reason: UpdateStockItemsPricingFailureReason;
|
|
message: string;
|
|
};
|
|
|
|
export type StockItemPricingSnapshot = {
|
|
cost_price?: number;
|
|
selling_price?: number;
|
|
markup?: number;
|
|
};
|
|
|
|
export type UpdateStockItemsPricingResult = {
|
|
results: UpdateStockItemsPricingResultRow[];
|
|
summary: { total: number; succeeded: number; failed: number };
|
|
};
|
|
|
|
const PATCHABLE_KEYS = ["costPrice", "sellingPrice", "markup"] as const;
|
|
|
|
export async function updateStockItemsPricing(
|
|
input: UpdateStockItemsPricingInput,
|
|
): Promise<UpdateStockItemsPricingResult> {
|
|
const results: UpdateStockItemsPricingResultRow[] = [];
|
|
for (const update of input.updates) {
|
|
// Whitelist check (route also enforces — service is defensive).
|
|
// Single explicit failure flag per row avoids coupling to push order.
|
|
let validationFailed = false;
|
|
for (const key of Object.keys(update) as (keyof StockPricingUpdate)[]) {
|
|
if (
|
|
key !== "id" &&
|
|
!(PATCHABLE_KEYS as readonly string[]).includes(key)
|
|
) {
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "forbidden_field",
|
|
message: `forbidden_field: ${key}`,
|
|
});
|
|
validationFailed = true;
|
|
continue;
|
|
}
|
|
const value = (update as Record<string, unknown>)[key];
|
|
if (
|
|
key !== "id" &&
|
|
value !== undefined &&
|
|
(typeof value !== "number" ||
|
|
!Number.isFinite(value) ||
|
|
(value as number) < 0)
|
|
) {
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "validation_error",
|
|
message: `${key} must be a finite number >= 0`,
|
|
});
|
|
validationFailed = true;
|
|
continue;
|
|
}
|
|
}
|
|
if (validationFailed) continue;
|
|
|
|
// 1. Load existing row for previous-snapshot + not_found detection.
|
|
let before: StockItem;
|
|
try {
|
|
before = (await input.pb
|
|
.collection("stock_items")
|
|
.getOne(update.id)) as StockItem;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (/not found|404/i.test(message)) {
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "not_found",
|
|
message: `stock_items row ${update.id} not found`,
|
|
});
|
|
continue;
|
|
}
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "pb_error",
|
|
message: `Failed to load: ${message}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 2. Build the PB patch — only fields the caller passed.
|
|
const patch: Record<string, number> = {};
|
|
if (update.costPrice !== undefined) patch.cost_price = update.costPrice;
|
|
if (update.sellingPrice !== undefined) patch.selling_price = update.sellingPrice;
|
|
if (update.markup !== undefined) patch.markup = update.markup;
|
|
|
|
// 3. Persist the row update.
|
|
let updated: StockItem;
|
|
try {
|
|
updated = (await input.pb
|
|
.collection("stock_items")
|
|
.update(update.id, patch)) as StockItem;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "pb_error",
|
|
message: `Failed to update: ${message}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 3.5. Cost audit — write a stock_ledger row ONLY when costPrice was
|
|
// supplied and differs from the existing cost_price. Side-effect:
|
|
// a failed ledger write aborts THIS row (we won't half-write the
|
|
// patch without recording the audit trail).
|
|
if (
|
|
update.costPrice !== undefined &&
|
|
before.cost_price !== update.costPrice
|
|
) {
|
|
try {
|
|
await input.pb.collection("stock_ledger").create({
|
|
stock_item: update.id,
|
|
change_qty: 0,
|
|
unit_cost: update.costPrice,
|
|
reason: "adjustment",
|
|
reference_type: "manual",
|
|
notes: `${input.actor.name} edited cost from /stock`,
|
|
created_by: input.actor.id,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
results.push({
|
|
id: update.id,
|
|
ok: false,
|
|
reason: "pb_error",
|
|
message: `Patched row but failed to write stock_ledger: ${message}`,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// 4. Capture previous snapshot for the response.
|
|
const previous: StockItemPricingSnapshot = {
|
|
cost_price: before.cost_price,
|
|
selling_price: before.selling_price,
|
|
markup: before.markup,
|
|
};
|
|
|
|
results.push({ id: update.id, ok: true, item: updated, previous });
|
|
}
|
|
|
|
const summary = {
|
|
total: input.updates.length,
|
|
succeeded: results.filter((r) => r.ok).length,
|
|
failed: results.filter((r) => !r.ok).length,
|
|
};
|
|
return { results, summary };
|
|
}
|