feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)

Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued +
received invoices) endpoints sum each doc's total per currency across the active
filter (reusing extracted where-builders so they stay in sync with the lists),
rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency.
Existing invoice/received KPI stats untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 22:12:24 +02:00
parent 1e4dd1fbcc
commit 97101c4db7
11 changed files with 760 additions and 66 deletions

View File

@@ -41,6 +41,71 @@ const ALLOWED_SORT_FIELDS = [
"created_at",
];
interface ReceivedInvoiceFilterParams {
year?: number;
month?: number;
status?: string;
supplier_name?: string;
search?: string;
}
/**
* Shared `where` builder for the received-invoices list and the per-currency
* list-totals aggregation, so both stay in sync. The list filters by
* year/month/status/supplier_name/search.
*/
export function buildReceivedWhere(
params: ReceivedInvoiceFilterParams,
): Record<string, unknown> {
const { year, month, status, supplier_name, search } = params;
const where: Record<string, unknown> = {};
if (year) where.year = year;
if (month) where.month = month;
if (status) where.status = status;
if (supplier_name) where.supplier_name = { contains: supplier_name };
if (search) {
where.OR = [
{ supplier_name: { contains: search } },
{ invoice_number: { contains: search } },
{ description: { contains: search } },
];
}
return where;
}
/**
* Sum received-invoice GROSS `amount` per currency over the WHOLE filtered set
* (not one page), reusing `buildReceivedWhere` so the totals track the list.
* `amount` is VAT-inclusive (GROSS), so it is summed directly. Returns one
* entry per currency, rounded to 2dp, zero/empty totals dropped. Exported so
* the route and the totals test share one implementation.
*/
export async function getReceivedInvoiceListTotals(
params: ReceivedInvoiceFilterParams,
): Promise<{ totals: Array<{ amount: number; currency: string }> }> {
const where = buildReceivedWhere(params);
const invoices = await prisma.received_invoices.findMany({
where,
select: { amount: true, currency: true },
});
const byCurrency: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(inv.amount) || 0);
}
const totals = Object.entries(byCurrency)
.map(([currency, amount]) => ({
currency,
amount: Math.round(amount * 100) / 100,
}))
.filter((t) => t.amount > 0);
return { totals };
}
export default async function receivedInvoicesRoutes(
fastify: FastifyInstance,
): Promise<void> {
@@ -53,22 +118,15 @@ export default async function receivedInvoicesRoutes(
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.year) where.year = Number(query.year);
if (query.month) where.month = Number(query.month);
if (query.status) where.status = String(query.status);
if (query.supplier_name)
where.supplier_name = { contains: String(query.supplier_name) };
// Search across supplier_name, invoice_number, description
if (query.search) {
const search = String(query.search);
where.OR = [
{ supplier_name: { contains: search } },
{ invoice_number: { contains: search } },
{ description: { contains: search } },
];
}
const where = buildReceivedWhere({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
status: query.status ? String(query.status) : undefined,
supplier_name: query.supplier_name
? String(query.supplier_name)
: undefined,
search: query.search ? String(query.search) : undefined,
});
// Sort field whitelisting
const sortField =
@@ -168,6 +226,30 @@ export default async function receivedInvoicesRoutes(
},
);
// GET /api/admin/received-invoices/list-totals — per-currency total summed
// over the WHOLE filtered set (not one page), matching the received-invoice
// list's filters (year/month/status/supplier_name/search). `amount` is GROSS
// (VAT-inclusive), so it is summed directly per currency. Distinct path from
// "/stats" (the monthly KPI cards) to avoid colliding with that endpoint.
// Registered BEFORE "/:id" so the literal path isn't captured as an id.
fastify.get(
"/list-totals",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const result = await getReceivedInvoiceListTotals({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
status: query.status ? String(query.status) : undefined,
supplier_name: query.supplier_name
? String(query.supplier_name)
: undefined,
search: query.search ? String(query.search) : undefined,
});
return success(reply, { totals: result.totals });
},
);
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
fastify.get(
"/suppliers",