WFDASH-WEBAPP/src/lib/server/stock-pricing-service.ts

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 };
}