feat(stock-quick-draft): add updateQuickDraftLine service

Adds a TDD'd updateQuickDraftLine function that PATCHes
quantity and/or cost_price on a stock_purchase_items row.
Returns the same discriminated-union shape as its sibling
add/remove functions. No schema changes.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-31 13:35:07 +02:00
parent f9a0d08dab
commit da0a689507
2 changed files with 181 additions and 1 deletions

View file

@ -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<typeof import("pocketbase")>();
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);

View file

@ -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<UpdateQuickDraftLineResult> {
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
* ========================================================================== */