feat(stock): add stock-pricing-service scaffold with happy-path
This commit is contained in:
parent
e66437322f
commit
85f238d3e0
2 changed files with 200 additions and 0 deletions
49
src/lib/server/stock-pricing-service.test.ts
Normal file
49
src/lib/server/stock-pricing-service.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
updateStockItemsPricing,
|
||||||
|
type UpdateStockItemsPricingInput,
|
||||||
|
} from "./stock-pricing-service";
|
||||||
|
|
||||||
|
describe("updateStockItemsPricing — happy path", () => {
|
||||||
|
it("patches all three pricing fields and reports per-row previous", async () => {
|
||||||
|
const before = {
|
||||||
|
id: "abcdefghijklmno",
|
||||||
|
cost_price: 2.5,
|
||||||
|
selling_price: 3.25,
|
||||||
|
markup: 30,
|
||||||
|
};
|
||||||
|
const updated = { ...before, cost_price: 3.2, selling_price: 4.16, markup: 30 };
|
||||||
|
|
||||||
|
const pb = {
|
||||||
|
collection: (name: string) => {
|
||||||
|
if (name === "stock_items") {
|
||||||
|
return {
|
||||||
|
getOne: vi.fn().mockResolvedValue(before),
|
||||||
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === "stock_ledger") {
|
||||||
|
return { create: vi.fn() };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected collection ${name}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateStockItemsPricing(
|
||||||
|
{
|
||||||
|
pb: pb as never,
|
||||||
|
updates: [
|
||||||
|
{ id: "abcdefghijklmno", costPrice: 3.2, sellingPrice: 4.16, markup: 30 },
|
||||||
|
],
|
||||||
|
actor: { id: "useropid1234567", name: "Gary" },
|
||||||
|
} as UpdateStockItemsPricingInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.summary).toEqual({ total: 1, succeeded: 1, failed: 0 });
|
||||||
|
expect(result.results[0]).toMatchObject({
|
||||||
|
id: "abcdefghijklmno",
|
||||||
|
ok: true,
|
||||||
|
previous: { cost_price: 2.5, selling_price: 3.25, markup: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/lib/server/stock-pricing-service.ts
Normal file
151
src/lib/server/stock-pricing-service.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateStockItemsPricingResultRow =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
ok: true;
|
||||||
|
item: StockItem;
|
||||||
|
previous: StockItemPricingSnapshot;
|
||||||
|
}
|
||||||
|
| { id: string; ok: false; reason: string; 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 FORBIDDEN_KEYS = ["id", "quantity", "created", "updated", "is_active"] as const;
|
||||||
|
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).
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
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`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Continue only if validation above did not push a failure for this row.
|
||||||
|
if (results.length > 0 && results[results.length - 1]?.id === update.id) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused-import warning — kept available for the bulk endpoint.
|
||||||
|
export { FORBIDDEN_KEYS };
|
||||||
Loading…
Reference in a new issue