feat(orders): month/year filter on both order lists + fix received-orders search
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
computeIssuedOrderTotals,
|
computeIssuedOrderTotals,
|
||||||
createIssuedOrder,
|
createIssuedOrder,
|
||||||
getIssuedOrder,
|
getIssuedOrder,
|
||||||
|
listIssuedOrders,
|
||||||
updateIssuedOrder,
|
updateIssuedOrder,
|
||||||
deleteIssuedOrder,
|
deleteIssuedOrder,
|
||||||
} from "../services/issued-orders.service";
|
} from "../services/issued-orders.service";
|
||||||
@@ -219,6 +220,26 @@ describe("deleteIssuedOrder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listIssuedOrders month filter", () => {
|
||||||
|
it("returns only orders whose order_date is in the given month", async () => {
|
||||||
|
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
|
||||||
|
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
|
||||||
|
createdIds.push(inMonth.id, outMonth.id);
|
||||||
|
const res = await listIssuedOrders({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
skip: 0,
|
||||||
|
sort: "id",
|
||||||
|
order: "desc",
|
||||||
|
month: 3,
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
const ids = res.data.map((o) => o.id);
|
||||||
|
expect(ids).toContain(inMonth.id);
|
||||||
|
expect(ids).not.toContain(outMonth.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("renderIssuedOrderHtml", () => {
|
describe("renderIssuedOrderHtml", () => {
|
||||||
const order = {
|
const order = {
|
||||||
po_number: "26720001",
|
po_number: "26720001",
|
||||||
|
|||||||
114
src/__tests__/orders-list.test.ts
Normal file
114
src/__tests__/orders-list.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createOrder, listOrders } from "../services/orders.service";
|
||||||
|
|
||||||
|
const createdOrderIds: number[] = [];
|
||||||
|
const createdCustomerIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of createdOrderIds)
|
||||||
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
|
for (const id of createdCustomerIds)
|
||||||
|
await prisma.customers.deleteMany({ where: { id } });
|
||||||
|
createdOrderIds.length = 0;
|
||||||
|
createdCustomerIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOrder = {
|
||||||
|
status: "prijata" as const,
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
exchange_rate: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function failResult(res: unknown): never {
|
||||||
|
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeCustomer() {
|
||||||
|
const c = await prisma.customers.create({
|
||||||
|
data: { name: "Odběratel a.s." },
|
||||||
|
});
|
||||||
|
createdCustomerIds.push(c.id);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseListParams = {
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
skip: 0,
|
||||||
|
sort: "id",
|
||||||
|
order: "desc" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("listOrders search filter", () => {
|
||||||
|
it("returns the order when search matches its order_number", async () => {
|
||||||
|
const customer = await makeCustomer();
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
customer_id: customer.id,
|
||||||
|
create_project: false,
|
||||||
|
});
|
||||||
|
if (!("id" in res)) failResult(res);
|
||||||
|
createdOrderIds.push(res.id!);
|
||||||
|
|
||||||
|
const list = await listOrders({
|
||||||
|
...baseListParams,
|
||||||
|
search: res.order_number!,
|
||||||
|
});
|
||||||
|
expect(list.data.map((o) => o.id)).toContain(res.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the order when search matches nothing", async () => {
|
||||||
|
const customer = await makeCustomer();
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
customer_id: customer.id,
|
||||||
|
create_project: false,
|
||||||
|
});
|
||||||
|
if (!("id" in res)) failResult(res);
|
||||||
|
createdOrderIds.push(res.id!);
|
||||||
|
|
||||||
|
const list = await listOrders({
|
||||||
|
...baseListParams,
|
||||||
|
search: "ZZZ-NO-SUCH-ORDER-ZZZ",
|
||||||
|
});
|
||||||
|
expect(list.data.map((o) => o.id)).not.toContain(res.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listOrders month filter", () => {
|
||||||
|
it("returns only orders whose created_at is in the given month", async () => {
|
||||||
|
const customer = await makeCustomer();
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
customer_id: customer.id,
|
||||||
|
create_project: false,
|
||||||
|
});
|
||||||
|
if (!("id" in res)) failResult(res);
|
||||||
|
createdOrderIds.push(res.id!);
|
||||||
|
|
||||||
|
// Pin created_at to a known month so the range filter is deterministic.
|
||||||
|
await prisma.orders.update({
|
||||||
|
where: { id: res.id },
|
||||||
|
data: { created_at: new Date(2026, 2, 15, 12, 0, 0) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const inMonth = await listOrders({
|
||||||
|
...baseListParams,
|
||||||
|
month: 3,
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(inMonth.data.map((o) => o.id)).toContain(res.id);
|
||||||
|
|
||||||
|
const otherMonth = await listOrders({
|
||||||
|
...baseListParams,
|
||||||
|
month: 4,
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,8 @@ export const issuedOrderListOptions = (filters: {
|
|||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
month?: number;
|
||||||
|
year?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["issued-orders", "list", filters],
|
queryKey: ["issued-orders", "list", filters],
|
||||||
@@ -59,6 +61,8 @@ export const issuedOrderListOptions = (filters: {
|
|||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
if (filters.status) params.set("status", filters.status);
|
if (filters.status) params.set("status", filters.status);
|
||||||
|
if (filters.month) params.set("month", String(filters.month));
|
||||||
|
if (filters.year) params.set("year", String(filters.year));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return paginatedJsonQuery<IssuedOrder>(
|
return paginatedJsonQuery<IssuedOrder>(
|
||||||
`/api/admin/issued-orders${qs ? `?${qs}` : ""}`,
|
`/api/admin/issued-orders${qs ? `?${qs}` : ""}`,
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const orderListOptions = (filters: {
|
|||||||
order?: string;
|
order?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
|
month?: number;
|
||||||
|
year?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["orders", "list", filters],
|
queryKey: ["orders", "list", filters],
|
||||||
@@ -71,6 +73,8 @@ export const orderListOptions = (filters: {
|
|||||||
if (filters.order) params.set("order", filters.order);
|
if (filters.order) params.set("order", filters.order);
|
||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
|
if (filters.month) params.set("month", String(filters.month));
|
||||||
|
if (filters.year) params.set("year", String(filters.year));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
|
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
search,
|
search,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
|
month: query.month ? Number(query.month) : undefined,
|
||||||
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
return paginated(
|
return paginated(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default async function ordersRoutes(
|
|||||||
{ preHandler: requirePermission("orders.view") },
|
{ preHandler: requirePermission("orders.view") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||||
|
|
||||||
const result = await listOrders({
|
const result = await listOrders({
|
||||||
page,
|
page,
|
||||||
@@ -53,8 +53,11 @@ export default async function ordersRoutes(
|
|||||||
skip,
|
skip,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
|
search,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
|
month: query.month ? Number(query.month) : undefined,
|
||||||
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return paginated(
|
return paginated(
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface ListIssuedOrdersParams {
|
|||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
customer_id?: number;
|
||||||
|
month?: number;
|
||||||
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
@@ -92,8 +94,18 @@ export function computeIssuedOrderTotals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||||
const { page, limit, skip, sort, order, search, status, customer_id } =
|
const {
|
||||||
params;
|
page,
|
||||||
|
limit,
|
||||||
|
skip,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
customer_id,
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
} = params;
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
@@ -106,6 +118,11 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
{ customers: { company_id: { contains: search } } },
|
{ customers: { company_id: { contains: search } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (month && year) {
|
||||||
|
const from = new Date(year, month - 1, 1);
|
||||||
|
const to = new Date(year, month, 1);
|
||||||
|
where.order_date = { gte: from, lt: to };
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
|
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
|
||||||
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
|
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
|
||||||
|
|||||||
@@ -108,10 +108,13 @@ interface ListOrdersParams {
|
|||||||
order: "asc" | "desc";
|
order: "asc" | "desc";
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
customer_id?: number;
|
||||||
|
search?: string;
|
||||||
|
month?: number;
|
||||||
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOrders(params: ListOrdersParams) {
|
export async function listOrders(params: ListOrdersParams) {
|
||||||
const { page, limit, skip, order } = params;
|
const { page, limit, skip, order, search, month, year } = params;
|
||||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
||||||
? params.sort
|
? params.sort
|
||||||
: "id";
|
: "id";
|
||||||
@@ -119,13 +122,26 @@ export async function listOrders(params: ListOrdersParams) {
|
|||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (params.status) where.status = params.status;
|
if (params.status) where.status = params.status;
|
||||||
if (params.customer_id) where.customer_id = params.customer_id;
|
if (params.customer_id) where.customer_id = params.customer_id;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ order_number: { contains: search } },
|
||||||
|
{ customer_order_number: { contains: search } },
|
||||||
|
{ customers: { name: { contains: search } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (month && year) {
|
||||||
|
const from = new Date(year, month - 1, 1);
|
||||||
|
const to = new Date(year, month, 1);
|
||||||
|
where.created_at = { gte: from, lt: to };
|
||||||
|
}
|
||||||
|
|
||||||
const [orders, total] = await Promise.all([
|
const [orders, total] = await Promise.all([
|
||||||
prisma.orders.findMany({
|
prisma.orders.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { [sortField]: order },
|
// Tiebreak on id so same-second created_at rows sort deterministically.
|
||||||
|
orderBy: [{ [sortField]: order }, { id: order }],
|
||||||
include: {
|
include: {
|
||||||
customers: { select: { id: true, name: true } },
|
customers: { select: { id: true, name: true } },
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order_items: { orderBy: { position: "asc" } },
|
||||||
|
|||||||
Reference in New Issue
Block a user