feat(repairs): supplier scope, signed copy, status counts, order link
This commit is contained in:
parent
4c0256baf4
commit
d178309236
3 changed files with 330 additions and 49 deletions
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
* ========================================================================== */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue