feat(invoicing): include sibling order items in invoicing rows
- extract reusable constants and helper functions including escapeFilterValue, dedupeRawRows, listAllInvoicingRows, and loadInvoicingRows - add safe filter value escaping to prevent query injection risks - rewrite data fetching to pull all related order items for orders with ready-to-invoice rows, then deduplicate results - replace raw getFullList calls with paginated PocketBase API usage - update test suite to mock getList instead of getFullList and add coverage for new behavior - clean up redundant eslint disable comments in modal components
This commit is contained in:
parent
9f601002af
commit
d8dea63d16
2 changed files with 245 additions and 17 deletions
|
|
@ -14,15 +14,47 @@ const YESTERDAY_ISO = (() => {
|
|||
})();
|
||||
const YESTERDAY_ISO_AT_8AM = `${YESTERDAY_ISO}T08:00:00.000Z`;
|
||||
|
||||
const { fetchCustomersMock, getFullListMock, mockedRows, makeRawRow } = vi.hoisted(() => ({
|
||||
fetchCustomersMock: vi.fn(async () => [
|
||||
type ListOpts = {
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
expand?: string;
|
||||
fields?: string;
|
||||
};
|
||||
type PageResult = {
|
||||
items: Array<Record<string, unknown>>;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
function buildPageResult(items: Array<Record<string, unknown>>, page = 1, perPage = 500): PageResult {
|
||||
return {
|
||||
items,
|
||||
totalItems: items.length,
|
||||
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
|
||||
page,
|
||||
perPage,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
fetchCustomersMock,
|
||||
getFullListMock,
|
||||
getListMock,
|
||||
mockedRows,
|
||||
makeRawRow,
|
||||
} = vi.hoisted(() => {
|
||||
const mockedRows: Array<Record<string, unknown>> = [];
|
||||
const fetchCustomersMock = vi.fn(async () => [
|
||||
{ id: "qb-1", name: "Adal Engineering" },
|
||||
{ id: "qb-2", name: "Anglo Mining" },
|
||||
{ id: "qb-3", name: "GIELIE Trading" },
|
||||
]),
|
||||
getFullListMock: vi.fn(async () => mockedRows),
|
||||
mockedRows: [] as Array<Record<string, unknown>>,
|
||||
makeRawRow: (overrides: Record<string, unknown> = {}) => ({
|
||||
]);
|
||||
const getFullListMock = vi.fn(async () => mockedRows);
|
||||
const getListMock = vi.fn<
|
||||
(page?: number, perPage?: number, opts?: ListOpts) => Promise<PageResult>
|
||||
>(async (page = 1, perPage = 500) => buildPageResult(mockedRows, page, perPage));
|
||||
const makeRawRow = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
id: "row-1",
|
||||
description: "Wheel hub kit",
|
||||
item_status: "ready_invoice",
|
||||
|
|
@ -51,8 +83,16 @@ const { fetchCustomersMock, getFullListMock, mockedRows, makeRawRow } = vi.hoist
|
|||
},
|
||||
},
|
||||
...overrides,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
fetchCustomersMock,
|
||||
getFullListMock,
|
||||
getListMock,
|
||||
mockedRows,
|
||||
makeRawRow,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
getPocketBase: () => ({
|
||||
|
|
@ -65,6 +105,7 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||
},
|
||||
collection: () => ({
|
||||
getFullList: getFullListMock,
|
||||
getList: getListMock,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
|
@ -85,6 +126,10 @@ describe("InvoicingPage", () => {
|
|||
beforeEach(() => {
|
||||
getFullListMock.mockReset();
|
||||
getFullListMock.mockImplementation(async () => mockedRows);
|
||||
getListMock.mockReset();
|
||||
getListMock.mockImplementation(async (page = 1, perPage = 500) =>
|
||||
buildPageResult(mockedRows, page, perPage),
|
||||
);
|
||||
mockedRows.splice(
|
||||
0,
|
||||
mockedRows.length,
|
||||
|
|
@ -186,6 +231,115 @@ describe("InvoicingPage", () => {
|
|||
expect(screen.getByRole("tab", { name: "Invoiced" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads targeted invoicing rows and keeps sibling outstanding rows for ready orders", async () => {
|
||||
const readyRow = makeRawRow({
|
||||
id: "row-ready",
|
||||
description: "Pump shaft",
|
||||
item_status: "ready_invoice",
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "po-ready",
|
||||
po_number: "PO-READY",
|
||||
expand: { supplier: { id: "sup-ready", suppliername: "Alpha Bearings" } },
|
||||
},
|
||||
order: {
|
||||
id: "ord-ready",
|
||||
order_number: "SO-READY",
|
||||
expand: {
|
||||
company: { company: "Adal Engineering" },
|
||||
customer: { customer: "GIELIE" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const siblingOutstanding = makeRawRow({
|
||||
id: "row-outstanding",
|
||||
description: "Seal kit",
|
||||
item_status: "pending",
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "po-outstanding",
|
||||
po_number: "PO-OUTSTANDING",
|
||||
expand: { supplier: { id: "sup-outstanding", suppliername: "Beta Co" } },
|
||||
},
|
||||
order: {
|
||||
id: "ord-ready",
|
||||
order_number: "SO-READY",
|
||||
expand: {
|
||||
company: { company: "Adal Engineering" },
|
||||
customer: { customer: "GIELIE" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const recentInvoice = makeRawRow({
|
||||
id: "row-invoiced-recent",
|
||||
description: "Brake disc",
|
||||
item_status: "invoiced",
|
||||
order_item_details: {
|
||||
invoice: {
|
||||
date: YESTERDAY_ISO,
|
||||
number: "INV-RECENT",
|
||||
setBy: "u-1",
|
||||
setByName: "Op",
|
||||
setAt: YESTERDAY_ISO_AT_8AM,
|
||||
},
|
||||
},
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "po-invoiced",
|
||||
po_number: "PO-INVOICED",
|
||||
expand: { supplier: { id: "sup-invoiced", suppliername: "Gamma Co" } },
|
||||
},
|
||||
order: {
|
||||
id: "ord-invoiced",
|
||||
order_number: "SO-INVOICED",
|
||||
expand: {
|
||||
company: { company: "CNL" },
|
||||
customer: { customer: "South Pit" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedRows.splice(0, mockedRows.length, readyRow, siblingOutstanding, recentInvoice);
|
||||
getListMock.mockImplementation(async (page = 1, perPage = 500, opts?: ListOpts) => {
|
||||
const filter = opts?.filter ?? "";
|
||||
const items = filter.includes('order = "ord-ready"')
|
||||
? [readyRow, siblingOutstanding]
|
||||
: [readyRow, recentInvoice];
|
||||
return buildPageResult(items, page, perPage);
|
||||
});
|
||||
|
||||
render(<Page />);
|
||||
|
||||
expect(await screen.findByText("1 ready / 2 total")).toBeInTheDocument();
|
||||
expect(screen.getByText("Has outstanding")).toBeInTheDocument();
|
||||
expect(getFullListMock).not.toHaveBeenCalled();
|
||||
expect(getListMock).toHaveBeenCalledTimes(2);
|
||||
expect(getListMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
1,
|
||||
500,
|
||||
expect.objectContaining({
|
||||
expand: "purchase_order.supplier,order.company,order.customer",
|
||||
}),
|
||||
);
|
||||
expect(String((getListMock.mock.calls[0]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
|
||||
'item_status = "ready_invoice"',
|
||||
);
|
||||
expect(String((getListMock.mock.calls[0]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
|
||||
'item_status = "invoiced"',
|
||||
);
|
||||
expect(String((getListMock.mock.calls[1]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
|
||||
'order = "ord-ready"',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show outstanding" }));
|
||||
expect(await screen.findByText("Seal kit")).toBeInTheDocument();
|
||||
expect(screen.getByText(/pending/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only invoiced rows from the default last-3-days range", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
|
@ -803,7 +957,7 @@ describe("InvoicingPage", () => {
|
|||
printedBy: "user-42",
|
||||
printedByName: "Casey Booker",
|
||||
});
|
||||
await waitFor(() => expect(getFullListMock).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(2));
|
||||
expect(openSpy).toHaveBeenCalledWith("blob:test", "_blank", "noopener,noreferrer");
|
||||
expect(createObjectUrlSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,87 @@ type RawInvoicingRow = {
|
|||
};
|
||||
};
|
||||
|
||||
const ORDER_ITEMS_COLLECTION = "order_items";
|
||||
const INVOICING_PAGE_SIZE = 500;
|
||||
const INVOICING_EXPAND = "purchase_order.supplier,order.company,order.customer";
|
||||
const INVOICING_FIELDS = [
|
||||
"id",
|
||||
"collectionId",
|
||||
"description",
|
||||
"item_status",
|
||||
"qb_invoice_pdf",
|
||||
"order_qty",
|
||||
"qty_collected",
|
||||
"cost_price",
|
||||
"selling_price",
|
||||
"delivery_note_number",
|
||||
"order_item_details",
|
||||
"order",
|
||||
"purchase_order",
|
||||
].join(",");
|
||||
const INVOICING_STATUS_FILTER =
|
||||
'(item_status = "ready_invoice" || item_status = "invoiced" || item_status = "delivery_note")';
|
||||
|
||||
function escapeFilterValue(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function dedupeRawRows(rows: RawInvoicingRow[]): RawInvoicingRow[] {
|
||||
const seen = new Set<string>();
|
||||
const out: RawInvoicingRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.id)) continue;
|
||||
seen.add(row.id);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function listAllInvoicingRows(
|
||||
pb: ReturnType<typeof getPocketBase>,
|
||||
filter: string,
|
||||
): Promise<RawInvoicingRow[]> {
|
||||
const collection = pb.collection(ORDER_ITEMS_COLLECTION);
|
||||
const query = {
|
||||
filter,
|
||||
sort: "-updated",
|
||||
expand: INVOICING_EXPAND,
|
||||
fields: INVOICING_FIELDS,
|
||||
};
|
||||
const firstPage = await collection.getList<RawInvoicingRow>(1, INVOICING_PAGE_SIZE, query);
|
||||
if (firstPage.totalPages <= 1) return firstPage.items;
|
||||
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: firstPage.totalPages - 1 }, (_, index) =>
|
||||
collection.getList<RawInvoicingRow>(index + 2, INVOICING_PAGE_SIZE, query),
|
||||
),
|
||||
);
|
||||
|
||||
return [firstPage, ...rest].flatMap((page) => page.items);
|
||||
}
|
||||
|
||||
async function loadInvoicingRows(pb: ReturnType<typeof getPocketBase>): Promise<RawInvoicingRow[]> {
|
||||
const seedRows = await listAllInvoicingRows(pb, INVOICING_STATUS_FILTER);
|
||||
const readyOrderIds = Array.from(
|
||||
new Set(
|
||||
seedRows
|
||||
.filter((row) => row.item_status === "ready_invoice")
|
||||
.map((row) => row.expand?.order?.id?.trim() ?? "")
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
if (readyOrderIds.length === 0) {
|
||||
return seedRows;
|
||||
}
|
||||
|
||||
const siblingFilter = readyOrderIds
|
||||
.map((orderId) => `order = "${escapeFilterValue(orderId)}"`)
|
||||
.join(" || ");
|
||||
const siblingRows = await listAllInvoicingRows(pb, siblingFilter);
|
||||
return dedupeRawRows([...seedRows, ...siblingRows]);
|
||||
}
|
||||
|
||||
function rowPoNumber(row: RawInvoicingRow): string {
|
||||
return row.expand?.purchase_order?.po_number ?? "";
|
||||
}
|
||||
|
|
@ -538,7 +619,6 @@ function SingleOrderQbModal({
|
|||
// open/close transition — not on every parent re-render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed
|
||||
setOrderNumberOverride(group.orderNumber);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setLineOverrides(
|
||||
Object.fromEntries(
|
||||
group.rows.map((row) => {
|
||||
|
|
@ -549,7 +629,6 @@ function SingleOrderQbModal({
|
|||
);
|
||||
// Default to "all rows on this invoice" — the common case. Operator
|
||||
// can uncheck the ones they don't want.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setIncludedRowIds(new Set(group.rows.map((row) => row.id)));
|
||||
}, [open, group]);
|
||||
|
||||
|
|
@ -858,7 +937,6 @@ function SingleDeliveryNoteModal({
|
|||
if (!open || !group) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed
|
||||
setOrderNumberOverride(group.orderNumber);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setLineOverrides(
|
||||
Object.fromEntries(
|
||||
group.rows.map((row) => {
|
||||
|
|
@ -867,7 +945,6 @@ function SingleDeliveryNoteModal({
|
|||
}),
|
||||
),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setIncludedRowIds(new Set(group.rows.map((row) => row.id)));
|
||||
}, [open, group]);
|
||||
|
||||
|
|
@ -1106,10 +1183,7 @@ export default function InvoicingPage() {
|
|||
setRows(null);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const items = await pb.collection("order_items").getFullList<RawInvoicingRow>({
|
||||
sort: "-updated",
|
||||
expand: "purchase_order.supplier,order.company,order.customer",
|
||||
});
|
||||
const items = await loadInvoicingRows(pb);
|
||||
setRows(items);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
Loading…
Reference in a new issue