feat(stock-purchases): add additional_attachments service helpers
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9e118a3b5f
commit
70c158b374
3 changed files with 212 additions and 0 deletions
101
src/lib/server/stock-purchases-service.attachments.test.ts
Normal file
101
src/lib/server/stock-purchases-service.attachments.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
const getOneMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: () => ({
|
||||
getOne: getOneMock,
|
||||
update: updateMock,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
addStockPurchaseAttachment,
|
||||
removeStockPurchaseAttachment,
|
||||
} from "./stock-purchases-service";
|
||||
|
||||
describe("addStockPurchaseAttachment", () => {
|
||||
it("appends the new token to additional_attachments", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "sp-1",
|
||||
additional_attachments: ["existing.pdf"],
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({
|
||||
additional_attachments: ["existing.pdf", "newtoken.pdf"],
|
||||
});
|
||||
|
||||
const result = await addStockPurchaseAttachment({
|
||||
purchaseId: "sp-1",
|
||||
file: new File(["data"], "invoice.pdf", { type: "application/pdf" }),
|
||||
});
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.attachments).toEqual(["existing.pdf", "newtoken.pdf"]);
|
||||
expect(result.added).toEqual(["newtoken.pdf"]);
|
||||
});
|
||||
|
||||
it("normalises a legacy string field to an array", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "sp-2",
|
||||
additional_attachments: "legacy-token.pdf", // pre-multi PB row
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({
|
||||
additional_attachments: ["legacy-token.pdf", "fresh.pdf"],
|
||||
});
|
||||
|
||||
const result = await addStockPurchaseAttachment({
|
||||
purchaseId: "sp-2",
|
||||
file: new File(["x"], "fresh.pdf", { type: "application/pdf" }),
|
||||
});
|
||||
|
||||
expect(result.attachments).toContain("fresh.pdf");
|
||||
});
|
||||
|
||||
it("throws when the purchase has no additional_attachments field yet (PB schema missing)", async () => {
|
||||
getOneMock.mockResolvedValueOnce({ id: "sp-3" });
|
||||
updateMock.mockRejectedValueOnce(new Error("Failed to update record."));
|
||||
|
||||
await expect(
|
||||
addStockPurchaseAttachment({
|
||||
purchaseId: "sp-3",
|
||||
file: new File(["x"], "x.pdf", { type: "application/pdf" }),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStockPurchaseAttachment", () => {
|
||||
it("removes the matching token and returns the rest", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "sp-1",
|
||||
additional_attachments: ["keep.pdf", "drop.pdf"],
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({ additional_attachments: ["keep.pdf"] });
|
||||
|
||||
const result = await removeStockPurchaseAttachment({
|
||||
purchaseId: "sp-1",
|
||||
token: "drop.pdf",
|
||||
});
|
||||
|
||||
expect(result.attachments).toEqual(["keep.pdf"]);
|
||||
expect(updateMock).toHaveBeenCalledWith("sp-1", {
|
||||
additional_attachments: ["keep.pdf"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 'not found' when the token is not in the array", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "sp-1",
|
||||
additional_attachments: ["keep.pdf"],
|
||||
});
|
||||
updateMock.mockClear();
|
||||
|
||||
await expect(
|
||||
removeStockPurchaseAttachment({ purchaseId: "sp-1", token: "nope.pdf" }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1388,3 +1388,101 @@ export async function generateStockPurchasePdf(
|
|||
generatedAt: generatedAtIso,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Free-form attachments (operator-curated, distinct from the versioned PDF).
|
||||
//
|
||||
// `stock_purchases.documents` / `documents_history` are a strict versioned
|
||||
// PDF ledger (see the send-email + list-pdf routes) — we do NOT touch them
|
||||
// from this code path. Operator-dropped attachments live on the sibling
|
||||
// `stock_purchases.additional_attachments` field, mirroring
|
||||
// `purchase_orders.additional_attachments`.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Append a file to `stock_purchases.additional_attachments`. PB's
|
||||
* FormData update REPLACES the field, so we read the current array,
|
||||
* write the new file, and re-read to discover PB's slug-on-disk
|
||||
* token (the operator's original filename is preserved separately
|
||||
* via the `documents_history`-style audit on PO equivalents; for
|
||||
* stock purchases we don't keep a history because the field is
|
||||
* free-form and operator-curated).
|
||||
*
|
||||
* Mirrors `purchase_orders.additional_attachments` semantics.
|
||||
*/
|
||||
export async function addStockPurchaseAttachment(input: {
|
||||
purchaseId: string;
|
||||
file: File;
|
||||
}): Promise<{ attachments: string[]; added: string[] }> {
|
||||
const pb = await getAdminPocketBase();
|
||||
// Read the current array so we can diff what's new after the
|
||||
// FormData write (PB's update REPLACES the file field, so we
|
||||
// can't see the "before" state from the response alone).
|
||||
const before = await pb
|
||||
.collection(STOCK_PURCHASES_COLL)
|
||||
.getOne<{ additional_attachments?: string[] | string }>(
|
||||
input.purchaseId,
|
||||
{ fields: "id,additional_attachments" },
|
||||
);
|
||||
const beforeArr = normaliseAttachmentArray(
|
||||
before?.additional_attachments,
|
||||
);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("additional_attachments", input.file);
|
||||
// PB returns the updated record including the file field — use it
|
||||
// directly instead of a second getOne (mirrors the PDF-render
|
||||
// pattern at the top of this file).
|
||||
const updated = await pb
|
||||
.collection(STOCK_PURCHASES_COLL)
|
||||
.update<{ additional_attachments?: string[] | string }>(
|
||||
input.purchaseId,
|
||||
fd,
|
||||
);
|
||||
const afterArr = normaliseAttachmentArray(updated?.additional_attachments);
|
||||
|
||||
return {
|
||||
attachments: afterArr,
|
||||
added: afterArr.filter((t) => !beforeArr.includes(t)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single attachment token from
|
||||
* `stock_purchases.additional_attachments`. PB does not expose a
|
||||
* per-file delete from a multi-file field via the SDK; we filter
|
||||
* the array client-side and write the rest back. The original PB
|
||||
* file on disk is NOT deleted (same limitation as the PO route).
|
||||
*/
|
||||
export async function removeStockPurchaseAttachment(input: {
|
||||
purchaseId: string;
|
||||
token: string;
|
||||
}): Promise<{ attachments: string[] }> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const row = await pb
|
||||
.collection(STOCK_PURCHASES_COLL)
|
||||
.getOne<{ additional_attachments?: string[] | string }>(
|
||||
input.purchaseId,
|
||||
{ fields: "id,additional_attachments" },
|
||||
);
|
||||
const arr = normaliseAttachmentArray(row?.additional_attachments);
|
||||
|
||||
if (!arr.includes(input.token)) {
|
||||
throw new Error(`Attachment not found: ${input.token}`);
|
||||
}
|
||||
|
||||
const remaining = arr.filter((t) => t !== input.token);
|
||||
await pb.collection(STOCK_PURCHASES_COLL).update(input.purchaseId, {
|
||||
additional_attachments: remaining,
|
||||
});
|
||||
|
||||
return { attachments: remaining };
|
||||
}
|
||||
|
||||
/** PB may return a single string for a 1-row field; coerce to array. */
|
||||
function normaliseAttachmentArray(
|
||||
raw: string | string[] | null | undefined,
|
||||
): string[] {
|
||||
if (raw == null) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,19 @@ export interface StockPurchase extends BaseRecord {
|
|||
* 2026-07-16 (was previously absent on stock purchases).
|
||||
*/
|
||||
documents_history?: PurchaseOrderDocumentEntry[];
|
||||
/**
|
||||
* Free-form attachments the operator has dropped on the PO
|
||||
* (supplier invoice, packing slip, photo of damaged stock).
|
||||
* Distinct from `documents`, which is the versioned-PDF ledger
|
||||
* (see `documents_history`). Multi-file, no PB-side cap; the
|
||||
* upload route enforces 25 MB per file.
|
||||
*
|
||||
* Mirrors `purchase_orders.additional_attachments` (see
|
||||
* `src/app/api/v1/purchase-orders/[id]/attachments/upload/`).
|
||||
*
|
||||
* PB schema: added 2026-08-06.
|
||||
*/
|
||||
additional_attachments?: string[];
|
||||
expand?: {
|
||||
supplier?: Supplier;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue