diff --git a/src/lib/server/repairs-service.test.ts b/src/lib/server/repairs-service.test.ts index 91c2a8c..d817986 100644 --- a/src/lib/server/repairs-service.test.ts +++ b/src/lib/server/repairs-service.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; -import { createCustomerQuoteFromRepair } from "@/lib/server/repairs-service"; +import { + createCustomerQuoteFromRepair, + createRepair, + updateRepair, + uploadRepairFile, + getRepairStatusCounts, + linkRepairToOrder, +} from "@/lib/server/repairs-service"; import { createQuote } from "@/lib/server/quotes-service"; vi.mock("@/lib/server/pocketbase-admin", () => ({ @@ -17,8 +24,12 @@ const REPAIRS = "repairs"; interface CollectionMock { getOne?: ReturnType; update?: ReturnType; + getList?: ReturnType; + create?: ReturnType; } +const ORDERS = "orders"; + const baseRepair = { id: "rep1", repair_number: "REP-2026-09-001", @@ -37,15 +48,39 @@ const baseRepair = { repair_request: [], }; -function makePb(repair: typeof baseRepair & Record) { - const getOne = vi.fn().mockImplementation(async (id: string) => { - if (id !== repair.id) { - const err = new Error("not found") as Error & { status?: number }; - err.status = 404; - throw err; +function makePb( + repair: typeof baseRepair & Record, + order?: Record, + listImpl?: (filter: string) => number, +) { + const records: Record> = { + [REPAIRS]: repair, + // The orders collection always exists so the order-link helpers can + // be exercised. With no order row passed in, `getOne` on it answers + // "not found" for every id — which is exactly the "no such order" + // case the link helper has to reject. + [ORDERS]: order ?? {}, + }; + + const notFound = () => { + const err = new Error("not found") as Error & { status?: number }; + err.status = 404; + return err; + }; + + const find = (id: string) => { + for (const rec of Object.values(records)) { + if (rec.id === id) return rec; } - return repair; + return null; + }; + + const getOne = vi.fn().mockImplementation(async (id: string) => { + const rec = find(id); + if (!rec) throw notFound(); + return rec; }); + const update = vi .fn() .mockImplementation(async (id: string, patch: Record) => ({ @@ -53,16 +88,35 @@ function makePb(repair: typeof baseRepair & Record) { id, ...patch, })); + + const getList = vi.fn().mockImplementation( + async ( + _page: number, + _perPage: number, + opts?: { filter?: string }, + ) => { + const totalItems = listImpl ? listImpl(String(opts?.filter ?? "")) : 0; + return { + items: [], + totalItems, + totalPages: totalItems > 0 ? 1 : 0, + page: 1, + perPage: 1, + }; + }, + ); + return { autoCancellation: vi.fn(), collection: (name: string) => { - if (name !== REPAIRS) { + if (!records[name]) { throw new Error(`unexpected collection: ${name}`); } - return { getOne, update } satisfies CollectionMock; + return { getOne, update, getList } satisfies CollectionMock; }, _getOne: getOne, _update: update, + _getList: getList, }; } @@ -260,3 +314,140 @@ describe("createCustomerQuoteFromRepair", () => { ).rejects.toThrow(/items must be a non-empty array/); }); }); + +describe("supplier_scope and signed_request", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes supplier_scope on create", async () => { + const create = vi.fn().mockResolvedValue({ ...baseRepair, id: "rep-new" }); + const pb = makePb(baseRepair); + const originalCollection = pb.collection; + (pb as unknown as { collection: unknown }).collection = (name: string) => { + const c = originalCollection(name) as Record; + return name === REPAIRS ? { ...c, create } : c; + }; + mockAdminPb(pb); + + await createRepair({ + item_description: "Welding machine", + fault_description: "Will not strike", + supplier_scope: "Strip and quote the strike mechanism", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + supplier_scope: "Strip and quote the strike mechanism", + }), + ); + }); + + it("writes supplier_scope on update", async () => { + const pb = makePb(baseRepair); + mockAdminPb(pb); + + await updateRepair("rep1", { supplier_scope: "Check the transformer" }); + + expect(pb._update).toHaveBeenCalledWith( + "rep1", + expect.objectContaining({ supplier_scope: "Check the transformer" }), + ); + }); + + it("appends a signed_request file without dropping the existing ones", async () => { + const pb = makePb({ + ...baseRepair, + signed_request: ["signed-copy-1.pdf"], + }); + mockAdminPb(pb); + + const blob = new Blob([new Uint8Array([1, 2, 3])], { + type: "application/pdf", + }); + await uploadRepairFile("rep1", "signed_request", blob, "signed-copy-2.pdf"); + + const form = pb._update.mock.calls[0][1] as FormData; + expect(form.getAll("signed_request")).toHaveLength(2); + expect(form.getAll("signed_request")[0]).toBe("signed-copy-1.pdf"); + }); + + it("returns the bumped status after a supplier quote is uploaded", async () => { + const pb = makePb({ ...baseRepair, status: "assessing" }); + mockAdminPb(pb); + + const blob = new Blob([new Uint8Array([1, 2, 3])], { + type: "application/pdf", + }); + const updated = await uploadRepairFile( + "rep1", + "quote", + blob, + "supplier-quote.pdf", + ); + + expect(updated.status).toBe("quoted"); + }); +}); + +describe("getRepairStatusCounts", () => { + beforeEach(() => vi.clearAllMocks()); + + it("counts every status and totals them", async () => { + const counts: Record = { + 'status = "new"': 3, + 'status = "assessing"': 1, + 'status = "quoted"': 2, + 'status = "approved"': 0, + 'status = "in_progress"': 1, + 'status = "completed"': 4, + 'status = "cancelled"': 1, + }; + const pb = makePb(baseRepair, undefined, (filter) => counts[filter] ?? 0); + mockAdminPb(pb); + + const result = await getRepairStatusCounts(); + + expect(result.total).toBe(12); + expect(result.byStatus).toEqual({ + new: 3, + assessing: 1, + quoted: 2, + approved: 0, + in_progress: 1, + completed: 4, + cancelled: 1, + }); + }); +}); + +describe("linkRepairToOrder", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes the link in both directions", async () => { + const pb = makePb(baseRepair, { + id: "ord1", + order_number: "O-2026-11-001", + is_repair_order: false, + }); + mockAdminPb(pb); + + const result = await linkRepairToOrder("ord1", "rep1"); + + expect(pb._update).toHaveBeenCalledWith("ord1", { + repair: "rep1", + is_repair_order: true, + }); + expect(pb._update).toHaveBeenCalledWith("rep1", { order: "ord1" }); + expect(result.order.repair).toBe("rep1"); + expect(result.order.is_repair_order).toBe(true); + expect(result.repair.order).toBe("ord1"); + }); + + it("throws when the order does not exist", async () => { + const pb = makePb(baseRepair); + mockAdminPb(pb); + + await expect(linkRepairToOrder("nope", "rep1")).rejects.toThrow( + /not found/, + ); + }); +}); diff --git a/src/lib/server/repairs-service.ts b/src/lib/server/repairs-service.ts index 460c726..5bafb89 100644 --- a/src/lib/server/repairs-service.ts +++ b/src/lib/server/repairs-service.ts @@ -56,51 +56,35 @@ import { type CreateQuoteInputItem, type CreateQuoteResult, } from "./quotes-service"; +import { + REPAIR_STATUS, + REPAIR_TRANSITIONS, + assertRepairTransition, + type RepairStatus, +} from "@/lib/repair-status"; import type { Customer, + Order, Quote, Repair, Supplier, User, } from "@/lib/types"; +/** + * The status vocabulary lives in `@/lib/repair-status` — one source for + * every repairs surface. It is re-exported here because the existing + * importers (the repairs pages, the REST route) pull `RepairStatus` from + * this module. + */ +export type { RepairStatus } from "@/lib/repair-status"; + const REPAIRS = "repairs"; +const ORDERS = "orders"; const CUSTOMERS = "customers"; const SUPPLIERS = "suppliers"; const USERS = "users"; -export type RepairStatus = - | "new" - | "assessing" - | "quoted" - | "approved" - | "in_progress" - | "completed" - | "cancelled"; - -/** Allowed manual transitions. Centralised so the route + UI agree. */ -const ALLOWED_TRANSITIONS: Record = { - new: ["assessing", "quoted", "cancelled"], - assessing: ["quoted", "cancelled"], - quoted: ["approved", "in_progress", "cancelled"], - approved: ["in_progress", "cancelled"], - in_progress: ["completed", "cancelled"], - completed: [], - cancelled: [], -}; - -function rejectTransition(from: RepairStatus, to: RepairStatus): never { - const allowed = ALLOWED_TRANSITIONS[from] ?? []; - const list = allowed.length > 0 ? allowed.join(", ") : "none"; - throw new Error( - `Cannot transition repair from "${from}" to "${to}" (allowed: ${list}).`, - ); -} - -function assertTransition(from: RepairStatus, to: RepairStatus): void { - if (!ALLOWED_TRANSITIONS[from].includes(to)) rejectTransition(from, to); -} - /* ============================================================================ * List + read * ========================================================================== */ @@ -193,6 +177,7 @@ export interface CreateRepairInput { fault_description: string; notes?: string | null; serial_no?: string | null; + supplier_scope?: string | null; photos?: string[]; } @@ -221,6 +206,7 @@ export async function createRepair( order: input.order || null, supplier: input.supplier || null, serial_no: input.serial_no ?? "", + supplier_scope: input.supplier_scope ?? "", }; if (input.photos && input.photos.length > 0) { create.photos = input.photos; @@ -234,6 +220,7 @@ export interface UpdateRepairInput { fault_description?: string; item_description?: string; serial_no?: string | null; + supplier_scope?: string | null; customer?: string | null; company?: string | null; order?: string | null; @@ -249,7 +236,7 @@ export async function updateRepair( const patch: Record = {}; if (input.status !== undefined) { - assertTransition(existing.status, input.status); + assertRepairTransition(existing.status, input.status); patch.status = input.status; } if (input.notes !== undefined) patch.notes = input.notes ?? ""; @@ -258,6 +245,8 @@ export async function updateRepair( if (input.fault_description !== undefined) patch.fault_description = input.fault_description; if (input.serial_no !== undefined) patch.serial_no = input.serial_no ?? ""; + if (input.supplier_scope !== undefined) + patch.supplier_scope = input.supplier_scope ?? ""; if (input.customer !== undefined) patch.customer = input.customer || null; if (input.company !== undefined) patch.company = input.company || null; if (input.order !== undefined) patch.order = input.order || null; @@ -388,7 +377,7 @@ export async function generateRepairRequest( // illegal transitions (e.g. if the operator manually flipped // the status to something else first). if (repair.status === "new") { - assertTransition("new", "assessing"); + assertRepairTransition("new", "assessing"); await pb.collection(REPAIRS).update(repairId, { status: "assessing" }); } @@ -520,7 +509,21 @@ export async function createCustomerQuoteFromRepair( * File upload helpers (photos / quote / purchase_order / repair_request) * ========================================================================== */ -export type RepairFileField = "photos" | "quote" | "purchase_order" | "repair_request"; +export type RepairFileField = + | "photos" + | "quote" + | "purchase_order" + | "repair_request" + | "signed_request"; + +/** The fields a file may be uploaded to. Used to validate the MCP tool's `field` argument. */ +export const REPAIR_FILE_FIELDS: readonly RepairFileField[] = [ + "photos", + "quote", + "purchase_order", + "repair_request", + "signed_request", +]; export async function uploadRepairFile( repairId: string, @@ -538,12 +541,17 @@ export async function uploadRepairFile( for (const f of previousFiles) form.append(field, f); form.append(field, file, filename); - const updated = await pb.collection(REPAIRS).update(repairId, form); + let updated = await pb.collection(REPAIRS).update(repairId, form); // Auto-bump status: uploading a quote moves new/assessing → quoted. - if (field === "quote" && (existing.status === "new" || existing.status === "assessing")) { - assertTransition(existing.status, "quoted"); - await pb.collection(REPAIRS).update(repairId, { status: "quoted" }); + if ( + field === "quote" && + (existing.status === "new" || existing.status === "assessing") + ) { + assertRepairTransition(existing.status, "quoted"); + updated = await pb.collection(REPAIRS).update(repairId, { + status: "quoted", + }); } return updated; @@ -572,6 +580,70 @@ export async function removeRepairFile( return pb.collection(REPAIRS).update(repairId, form); } +/* ============================================================================ + * Status counts — one cheap count query per status + * ========================================================================== */ + +export interface RepairStatusCounts { + /** Sum of every status count. */ + total: number; + /** Count per status. Every status is present, even at zero. */ + byStatus: Record; +} + +/** + * Count repairs per status. PocketBase returns `totalItems` for a + * filtered list, so each status is one `getList(perPage: 1)` — cheap, + * and the seven counts add up to the total the board shows. + */ +export async function getRepairStatusCounts(): Promise { + const pb = await getAdminPocketBase(); + const byStatus = {} as Record; + let total = 0; + + for (const status of REPAIR_STATUS) { + const result = await pb.collection(REPAIRS).getList(1, 1, { + filter: `status = "${status}"`, + }); + byStatus[status] = result.totalItems; + total += result.totalItems; + } + + return { total, byStatus }; +} + +/* ============================================================================ + * Order ↔ repair link + * ========================================================================== */ + +/** + * Link an order to the repair it came from, in BOTH directions. + * + * `orders.repair` points at the repair (so the buyer can see the machine's + * status from the order) and `repairs.order` points back (so the repair can + * show the order). `orders.is_repair_order` is set in the same write — it + * already exists on the collection and is already a documented filter on + * `orders.list` in the MCP, but nothing ever set it. + */ +export async function linkRepairToOrder( + orderId: string, + repairId: string, +): Promise<{ order: Order; repair: Repair }> { + const pb = await getAdminPocketBase(); + await pb.collection(ORDERS).getOne(orderId); + const repair = await pb.collection(REPAIRS).getOne(repairId); + + const order = await pb.collection(ORDERS).update(orderId, { + repair: repairId, + is_repair_order: true, + }); + const patchedRepair = await pb.collection(REPAIRS).update(repairId, { + order: orderId, + }); + + return { order, repair: patchedRepair }; +} + /* ============================================================================ * Read helpers for the UI (small) * ========================================================================== */ diff --git a/src/lib/types.ts b/src/lib/types.ts index b038ac1..048b113 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1026,6 +1026,12 @@ export interface Order extends BaseRecord { /** True once suggestOrderDuplicate has run for this order. */ duplicate_order_matching_attempted?: boolean; is_repair_order: boolean; + /** + * The repair this order came from. Set when the order was created from + * a customer quote that a repair produced, so the buyer can see the + * machine's repair status without asking anyone. + */ + repair?: string; /** * Sales rep handling this order. Free-text — auto-populated from the * customer's `rep` field when a customer is selected, but always @@ -1142,6 +1148,7 @@ export interface Order extends BaseRecord { customer?: Customer; quote?: Quote; potential_quote?: Quote; + repair?: Repair; }; } @@ -1737,6 +1744,17 @@ export interface Repair extends BaseRecord { fault_description: string; notes?: string; serial_no?: string; + /** + * What the supplier must look at. Separate from `fault_description` + * (what the customer says is wrong) because two different people say + * different things on the same repair request form. + */ + supplier_scope?: string; + /** + * The supplier's signed copy of the repair request, handed back after + * they receive the machine. Multi-file field. + */ + signed_request?: string[]; /** * Photos of the machine as received from the customer. Multi-file * field (`maxSelect: 10`) so the operator can attach multiple angles