diff --git a/src/lib/server/stock-quick-draft-service.test.ts b/src/lib/server/stock-quick-draft-service.test.ts index eaaa8b4..2feed77 100644 --- a/src/lib/server/stock-quick-draft-service.test.ts +++ b/src/lib/server/stock-quick-draft-service.test.ts @@ -160,8 +160,12 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { return { store, pbMock, clearStore }; }); -vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) })); +vi.mock("pocketbase", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: vi.fn().mockImplementation(() => pbMock) }; +}); +import { ClientResponseError } from "pocketbase"; import { addQuickDraftLine, getStockItemSupplierHistory, @@ -169,6 +173,7 @@ import { removeQuickDraftLine, searchStockItemsForQuickDraft, listSuppliersForQuickDraft, + updateQuickDraftLine, } from "./stock-quick-draft-service"; beforeEach(() => clearStore()); @@ -640,6 +645,132 @@ describe("removeQuickDraftLine", () => { }); }); +describe("updateQuickDraftLine", () => { + async function createDraftLine() { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "S" }) + ).id; + const purchase = await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "draft", + purchase_number: "PO26070101STK", + }); + return pbMock.collection("stock_purchase_items").create({ + purchase: purchase.id, + stock_item: "item-1", + description: "115mm Cutting Disc", + quantity: 10, + cost_price: 18.5, + }); + } + + it("updates quantity on the line and returns ok", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: line.id, + quantity: 25, + }); + + expect(result).toEqual({ ok: true }); + const updated = await pbMock.collection("stock_purchase_items").getOne(line.id); + expect(updated.quantity).toBe(25); + expect(updated.cost_price).toBe(18.5); + }); + + it("updates cost_price on the line and returns ok", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: line.id, + costPrice: 20, + }); + + expect(result).toEqual({ ok: true }); + const updated = await pbMock.collection("stock_purchase_items").getOne(line.id); + expect(updated.quantity).toBe(10); + expect(updated.cost_price).toBe(20); + }); + + it("updates both fields in one call", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: line.id, + quantity: 30, + costPrice: 21.5, + }); + + expect(result).toEqual({ ok: true }); + const updated = await pbMock.collection("stock_purchase_items").getOne(line.id); + expect(updated.quantity).toBe(30); + expect(updated.cost_price).toBe(21.5); + }); + + it("rejects when neither quantity nor costPrice is supplied", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { lineId: line.id }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("validation"); + }); + + it("rejects non-positive quantity", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: line.id, + quantity: 0, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("validation"); + }); + + it("rejects negative cost_price", async () => { + const line = await createDraftLine(); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: line.id, + costPrice: -1, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("validation"); + }); + + it("returns line_not_found when the line does not exist", async () => { + const originalCollection = pbMock.collection; + const collectionSpy = vi.spyOn(pbMock, "collection").mockImplementation((name) => { + const collection = originalCollection(name); + if (name !== "stock_purchase_items") return collection; + return { + ...collection, + update: vi.fn().mockRejectedValue( + new ClientResponseError({ + status: 404, + response: { message: "line not found" }, + }), + ), + }; + }); + + const result = await updateQuickDraftLine(pbMock as never, { + lineId: "does-not-exist", + quantity: 1, + }); + collectionSpy.mockRestore(); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("line_not_found"); + }); +}); + describe("listOpenDrafts", () => { it("returns an empty array when there are no draft stock_purchases", async () => { const drafts = await listOpenDrafts(pbMock as never); diff --git a/src/lib/server/stock-quick-draft-service.ts b/src/lib/server/stock-quick-draft-service.ts index dd02015..bcac10a 100644 --- a/src/lib/server/stock-quick-draft-service.ts +++ b/src/lib/server/stock-quick-draft-service.ts @@ -505,6 +505,55 @@ export async function removeQuickDraftLine( } } +/* ============================================================================ + * updateQuickDraftLine + * ========================================================================== */ + +export type UpdateQuickDraftLineResult = + | { ok: true } + | { ok: false; reason: "validation" | "line_not_found" | "update_failed"; message: string }; + +/** + * Updates the quantity and/or cost price on a `stock_purchase_items` row. + * Used by the modal's inline line editor for correcting draft values. + * + * Returns `{ ok: true }` on success, or `{ ok: false, reason, message }` + * on validation or update failure. At least one field is required; + * quantity must be positive and cost price must be non-negative. + */ +export async function updateQuickDraftLine( + pb: StockQuickDraftPb, + input: { lineId: string; quantity?: number; costPrice?: number }, +): Promise { + if (typeof input.lineId !== "string" || input.lineId.length === 0) { + return { ok: false, reason: "validation", message: "lineId is required" }; + } + if (input.quantity === undefined && input.costPrice === undefined) { + return { ok: false, reason: "validation", message: "at least one of quantity or costPrice is required" }; + } + if (input.quantity !== undefined && (typeof input.quantity !== "number" || input.quantity <= 0)) { + return { ok: false, reason: "validation", message: "quantity must be > 0" }; + } + if (input.costPrice !== undefined && (typeof input.costPrice !== "number" || input.costPrice < 0)) { + return { ok: false, reason: "validation", message: "costPrice must be >= 0" }; + } + + const patch: { quantity?: number; cost_price?: number } = {}; + if (input.quantity !== undefined) patch.quantity = input.quantity; + if (input.costPrice !== undefined) patch.cost_price = input.costPrice; + + try { + await pb.collection(STOCK_PURCHASE_ITEMS).update(input.lineId, patch); + return { ok: true }; + } catch (err) { + const status = (err as { status?: number }).status; + if (status === 404) { + return { ok: false, reason: "line_not_found", message: err instanceof Error ? err.message : "line not found" }; + } + return { ok: false, reason: "update_failed", message: err instanceof Error ? err.message : "line update failed" }; + } +} + /* ============================================================================ * listOpenDrafts * ========================================================================== */