feat(repairs): supplier scope, signed copy, status counts, order link

This commit is contained in:
Dave 2026-11-06 06:17:56 +02:00
parent 4c0256baf4
commit d178309236
3 changed files with 330 additions and 49 deletions

View file

@ -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<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
getList?: ReturnType<typeof vi.fn>;
create?: ReturnType<typeof vi.fn>;
}
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<string, unknown>) {
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<string, unknown>,
order?: Record<string, unknown>,
listImpl?: (filter: string) => number,
) {
const records: Record<string, Record<string, unknown>> = {
[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<string, unknown>) => ({
@ -53,16 +88,35 @@ function makePb(repair: typeof baseRepair & Record<string, unknown>) {
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<string, unknown>;
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<string, number> = {
'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/,
);
});
});

View file

@ -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<RepairStatus, RepairStatus[]> = {
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<string, unknown> = {};
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<Repair>(repairId, form);
let updated = await pb.collection(REPAIRS).update<Repair>(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<Repair>(repairId, {
status: "quoted",
});
}
return updated;
@ -572,6 +580,70 @@ export async function removeRepairFile(
return pb.collection(REPAIRS).update<Repair>(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<RepairStatus, number>;
}
/**
* 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<RepairStatusCounts> {
const pb = await getAdminPocketBase();
const byStatus = {} as Record<RepairStatus, number>;
let total = 0;
for (const status of REPAIR_STATUS) {
const result = await pb.collection(REPAIRS).getList<Repair>(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<Order>(orderId);
const repair = await pb.collection(REPAIRS).getOne<Repair>(repairId);
const order = await pb.collection(ORDERS).update<Order>(orderId, {
repair: repairId,
is_repair_order: true,
});
const patchedRepair = await pb.collection(REPAIRS).update<Repair>(repairId, {
order: orderId,
});
return { order, repair: patchedRepair };
}
/* ============================================================================
* Read helpers for the UI (small)
* ========================================================================== */

View file

@ -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