diff --git a/src/__tests__/offer-invoice-totals.test.ts b/src/__tests__/offer-invoice-totals.test.ts new file mode 100644 index 0000000..83e03d9 --- /dev/null +++ b/src/__tests__/offer-invoice-totals.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { createOffer, getOfferTotals } from "../services/offers.service"; +import { + createInvoice, + getInvoiceListTotals, +} from "../services/invoices.service"; +import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices"; + +// Per-currency total aggregation for the offers, issued-invoices and +// received-invoices lists. These hit the real `app_test` DB via the service +// layer (suite convention) and prove the totals fns sum each document's TOTAL +// per currency across the WHOLE filtered set, and that the filtering `where` +// (search / month-year / status) is respected — mirroring order-totals.test.ts. +// +// For invoices a far-future month/year is used so seeded/other-test rows can't +// fall in the window and skew the deterministic sums. Offers have NO date +// filter, so they are isolated by a unique `project_code` searched on instead. +// Received invoices store amount as GROSS (VAT-inclusive), summed directly. + +const STATS_YEAR = 2097; +const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01) + +// Unique marker so the offers test (which has no date filter) can scope its +// aggregation to only the rows it created via a `search` on project_code. +const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`; + +const createdOfferIds: number[] = []; +const createdInvoiceIds: number[] = []; +const createdReceivedIds: number[] = []; + +afterEach(async () => { + for (const id of createdOfferIds) + await prisma.quotations.deleteMany({ where: { id } }); + for (const id of createdInvoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + for (const id of createdReceivedIds) + await prisma.received_invoices.deleteMany({ where: { id } }); + createdOfferIds.length = 0; + createdInvoiceIds.length = 0; + createdReceivedIds.length = 0; + // Finalized offers/invoices consume their sequences — reset so we don't leak + // a consumed number into sibling test files. + await prisma.number_sequences.deleteMany({ + where: { type: { in: ["offer", "invoice"] } }, + }); +}); + +function amountFor( + totals: { currency: string; amount: number }[], + currency: string, +): number | undefined { + return totals.find((t) => t.currency === currency)?.amount; +} + +describe("getOfferTotals per-currency aggregation", () => { + it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => { + // Two CZK offers + one EUR. 21% VAT applied on the whole subtotal + // (enrichQuotation math). + // CZK #1: 2 x 1000 = 2000 net -> +21% = 2420 + // CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025 + // EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363 + const mk = async (currency: string, qty: number, price: number) => { + const res = await createOffer({ + status: "draft", // draft so no offer number is consumed + currency, + vat_rate: 21, + apply_vat: true, + project_code: OFFER_MARKER, + items: [{ description: "X", quantity: qty, unit_price: price }], + }); + if ("error" in res) throw new Error(JSON.stringify(res)); + createdOfferIds.push(res.id); + return res.id; + }; + + await mk("CZK", 2, 1000); + await mk("CZK", 1, 500); + await mk("EUR", 3, 100); + + const { totals } = await getOfferTotals({ search: OFFER_MARKER }); + expect(amountFor(totals, "CZK")).toBe(3025); + expect(amountFor(totals, "EUR")).toBe(363); + }); + + it("respects the where: a status filter narrows the result", async () => { + const mk = async (status: string) => { + const res = await createOffer({ + status, + currency: "CZK", + vat_rate: 21, + apply_vat: true, + project_code: OFFER_MARKER, + items: [{ description: "X", quantity: 1, unit_price: 1000 }], + }); + if ("error" in res) throw new Error(JSON.stringify(res)); + createdOfferIds.push(res.id); + return res.id; + }; + + // Two drafts + one invalidated, all sharing the marker. + await mk("draft"); + await mk("draft"); + await mk("invalidated"); + + // Marker only: all three count -> 3 x 1210 = 3630. + const all = await getOfferTotals({ search: OFFER_MARKER }); + expect(amountFor(all.totals, "CZK")).toBe(3630); + + // Status filter narrows to the two drafts -> 2 x 1210 = 2420. + const onlyDraft = await getOfferTotals({ + search: OFFER_MARKER, + status: "draft", + }); + expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420); + + // Invalidated alone -> 1210. + const onlyInvalid = await getOfferTotals({ + search: OFFER_MARKER, + status: "invalidated", + }); + expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210); + }); +}); + +describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => { + // issue_date is settable at create, so no post-create pin needed. VAT is + // applied per-line (computeInvoiceTotals) but with a single line per invoice + // here the result matches the whole-subtotal math. + const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`; + + const mk = async ( + currency: string, + qty: number, + price: number, + status = "draft", + ) => { + const inv = await createInvoice({ + status, + currency, + vat_rate: 21, + apply_vat: true, + issue_date: dateStr, + items: [{ description: "X", quantity: qty, unit_price: price }], + }); + createdInvoiceIds.push(inv.id); + return inv.id; + }; + + it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => { + // CZK #1: 2 x 1000 @21% = 2420 + // CZK #2: 1 x 500 @21% = 605 => CZK total 3025 + // EUR : 3 x 100 @21% = 363 => EUR total 363 + await mk("CZK", 2, 1000); + await mk("CZK", 1, 500); + await mk("EUR", 3, 100); + + const { totals } = await getInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + }); + expect(amountFor(totals, "CZK")).toBe(3025); + expect(amountFor(totals, "EUR")).toBe(363); + }); + + it("respects the where: a different month and a status filter change the result", async () => { + const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`; + + // Target month: one draft, one issued (both 1210). Next month: one draft. + await mk("CZK", 1, 1000, "draft"); + // An already-issued invoice numbers immediately; that's fine for the sum. + const issued = await createInvoice({ + status: "issued", + currency: "CZK", + vat_rate: 21, + apply_vat: true, + issue_date: dateStr, + items: [{ description: "X", quantity: 1, unit_price: 1000 }], + }); + createdInvoiceIds.push(issued.id); + + const next = await createInvoice({ + status: "draft", + currency: "CZK", + vat_rate: 21, + apply_vat: true, + issue_date: nextDateStr, + items: [{ description: "X", quantity: 1, unit_price: 1000 }], + }); + createdInvoiceIds.push(next.id); + + // Whole target month: both count -> 2 x 1210 = 2420. + const whole = await getInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + }); + expect(amountFor(whole.totals, "CZK")).toBe(2420); + + // Status filter narrows to the single 'issued' in the target month -> 1210. + const onlyIssued = await getInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + status: "issued", + }); + expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210); + + // Next month sees only the one invoice there -> 1210. + const nextMonth = await getInvoiceListTotals({ + month: STATS_MONTH + 1, + year: STATS_YEAR, + }); + expect(amountFor(nextMonth.totals, "CZK")).toBe(1210); + }); +}); + +describe("received-invoices list-totals per-currency aggregation (GROSS)", () => { + // No service create for received invoices — rows are written directly. The + // list/totals `where` filters by the month/year COLUMNS (not issue_date). + // amount is GROSS, summed directly per currency. + const mkReceived = async (currency: string, amount: number) => { + const row = await prisma.received_invoices.create({ + data: { + month: STATS_MONTH, + year: STATS_YEAR, + supplier_name: "Totals Test Supplier", + amount, + currency, + vat_rate: 21, + status: "unpaid", + }, + }); + createdReceivedIds.push(row.id); + return row.id; + }; + + it("sums GROSS amount per currency over the full filtered set", async () => { + // Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is. + await mkReceived("CZK", 2420); + await mkReceived("CZK", 605); + await mkReceived("EUR", 363); + + const { totals } = await getReceivedInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + }); + expect(amountFor(totals, "CZK")).toBe(3025); + expect(amountFor(totals, "EUR")).toBe(363); + }); + + it("respects the where: a different month and a search filter change the result", async () => { + await mkReceived("CZK", 1000); + // A row in the NEXT month must not count toward the target month's total. + const other = await prisma.received_invoices.create({ + data: { + month: STATS_MONTH + 1, + year: STATS_YEAR, + supplier_name: "Totals Test Supplier", + amount: 9999, + currency: "CZK", + vat_rate: 21, + status: "unpaid", + }, + }); + createdReceivedIds.push(other.id); + + const { totals } = await getReceivedInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + }); + expect(amountFor(totals, "CZK")).toBe(1000); + + // Search on the supplier name still scopes to the target-month row. + const { totals: searched } = await getReceivedInvoiceListTotals({ + month: STATS_MONTH, + year: STATS_YEAR, + search: "Totals Test Supplier", + }); + expect(amountFor(searched, "CZK")).toBe(1000); + }); +}); diff --git a/src/admin/lib/queries/invoices.ts b/src/admin/lib/queries/invoices.ts index 64a13be..ac8899d 100644 --- a/src/admin/lib/queries/invoices.ts +++ b/src/admin/lib/queries/invoices.ts @@ -192,6 +192,31 @@ export const invoiceStatsOptions = (month: number, year: number) => ), }); +// Per-currency total over the WHOLE filtered set of the ISSUED-invoice list. +// Distinct from `invoiceStatsOptions` (the KPI cards) — hits /list-totals and +// passes the same filters the issued list uses (search/status/month/year). +export const invoiceTotalsOptions = (filters: { + search?: string; + status?: string; + month?: number; + year?: number; +}) => + queryOptions({ + queryKey: ["invoices", "list-totals", filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.search) params.set("search", filters.search); + 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 data = await jsonQuery<{ totals?: CurrencyAmount[] }>( + `/api/admin/invoices/list-totals${qs ? `?${qs}` : ""}`, + ); + return data.totals ?? []; + }, + }); + export const receivedInvoiceStatsOptions = (month: number, year: number) => queryOptions({ queryKey: ["invoices", "received", "stats", month, year], @@ -201,6 +226,30 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) => ), }); +// Per-currency total over the WHOLE filtered set of the received-invoice list. +// Distinct from `receivedInvoiceStatsOptions` (the KPI cards) — hits +// /list-totals and passes the same filters the received list uses +// (month/year/search). `amount` is GROSS (VAT-inclusive). +export const receivedInvoiceTotalsOptions = (filters: { + month?: number; + year?: number; + search?: string; +}) => + queryOptions({ + queryKey: ["invoices", "received", "list-totals", filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.month) params.set("month", String(filters.month)); + if (filters.year) params.set("year", String(filters.year)); + if (filters.search) params.set("search", filters.search); + const qs = params.toString(); + const data = await jsonQuery<{ totals?: CurrencyAmount[] }>( + `/api/admin/received-invoices/list-totals${qs ? `?${qs}` : ""}`, + ); + return data.totals ?? []; + }, + }); + export const invoiceDetailOptions = (id: string | undefined) => queryOptions({ queryKey: ["invoices", id], diff --git a/src/admin/lib/queries/offers.ts b/src/admin/lib/queries/offers.ts index e6634f0..4ae0500 100644 --- a/src/admin/lib/queries/offers.ts +++ b/src/admin/lib/queries/offers.ts @@ -1,6 +1,11 @@ import { queryOptions } from "@tanstack/react-query"; import { jsonQuery, paginatedJsonQuery } from "../apiAdapter"; +export interface CurrencyAmount { + amount: number; + currency: string; +} + export interface ItemTemplate { id: number; name: string; @@ -91,6 +96,27 @@ export const offerListOptions = (filters: { }, }); +export const offerStatsOptions = (filters: { + search?: string; + status?: string; + customer_id?: number; +}) => + queryOptions({ + queryKey: ["offers", "stats", filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.search) params.set("search", filters.search); + if (filters.status) params.set("status", filters.status); + if (filters.customer_id) + params.set("customer_id", String(filters.customer_id)); + const qs = params.toString(); + const data = await jsonQuery<{ totals?: CurrencyAmount[] }>( + `/api/admin/offers/stats${qs ? `?${qs}` : ""}`, + ); + return data.totals ?? []; + }, + }); + export interface OfferItemData { id?: number; description: string; diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index c98fca8..7d9b56e 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -23,6 +23,7 @@ import useTableSort from "../hooks/useTableSort"; import { invoiceListOptions, invoiceStatsOptions, + invoiceTotalsOptions, type Invoice, type CurrencyAmount, } from "../lib/queries/invoices"; @@ -283,6 +284,18 @@ export default function Invoices() { }), ); + // Per-currency total over the WHOLE filtered set of the ISSUED list (not one + // page). Uses the SAME filters as the issued list query so the summary + // matches what's shown. Separate from the KPI `statsQuery` above. + const listTotalsQuery = useQuery( + invoiceTotalsOptions({ + search, + status: statusFilter || undefined, + month: statsMonth, + year: statsYear, + }), + ); + // Mark first load done in an effect (not during render) to avoid a // render-phase ref mutation. useEffect(() => { @@ -755,6 +768,27 @@ export default function Invoices() { ) } /> + {(listTotalsQuery.data?.length ?? 0) > 0 && ( + + Celkem: + + {formatMultiCurrency(listTotalsQuery.data ?? [])} + + + )} { @@ -875,6 +894,27 @@ export default function Offers() { ) } /> + {(statsQuery.data?.length ?? 0) > 0 && ( + + Celkem: + + {formatMultiCurrency(statsQuery.data ?? [])} + + + )} )} + {(listTotalsQuery.data?.length ?? 0) > 0 && ( + + Celkem: + + {formatMultiCurrency(listTotalsQuery.data ?? [])} + + + )} diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 6813841..03b0c6c 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -12,6 +12,7 @@ import { import { markOverdueInvoices, listInvoices, + getInvoiceListTotals, getNextInvoiceNumberFormatted, getNextInvoiceNumberPreview, getInvoiceStats, @@ -77,7 +78,7 @@ export default async function invoicesRoutes( }, ); - // GET /api/admin/invoices/stats + // GET /api/admin/invoices/stats — monthly KPI cards (paid/awaiting/overdue/VAT). fastify.get( "/stats", { preHandler: requirePermission("invoices.view") }, @@ -90,6 +91,27 @@ export default async function invoicesRoutes( }, ); + // GET /api/admin/invoices/list-totals — per-currency total (incl. VAT) summed + // over the WHOLE filtered set (not one page), matching the issued-invoice + // list's filters (search/status/month/year). Distinct path from "/stats" (the + // KPI cards) to avoid colliding with that endpoint. Registered BEFORE "/:id" + // so the literal path isn't captured as an invoice id. + fastify.get( + "/list-totals", + { preHandler: requirePermission("invoices.view") }, + async (request, reply) => { + const query = request.query as Record; + const result = await getInvoiceListTotals({ + search: query.search ? String(query.search) : undefined, + status: query.status ? String(query.status) : 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 success(reply, { totals: result.totals }); + }, + ); + // GET /api/admin/invoices/order-data/:id fastify.get<{ Params: { id: string } }>( "/order-data/:id", diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index 822afe7..bb86f23 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -11,6 +11,7 @@ import { import prisma from "../../config/database"; import { listOffers, + getOfferTotals, getOffer, createOffer, updateOffer, @@ -52,6 +53,24 @@ export default async function quotationsRoutes( }, ); + // GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the + // WHOLE filtered set (not one page). Offers have NO month/year filter; the + // list filters by search + status + customer_id, so the totals use the same. + // Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id. + fastify.get( + "/stats", + { preHandler: requirePermission("offers.view") }, + async (request, reply) => { + const query = request.query as Record; + const result = await getOfferTotals({ + search: query.search ? String(query.search) : undefined, + status: query.status ? String(query.status) : undefined, + customer_id: query.customer_id ? Number(query.customer_id) : undefined, + }); + return success(reply, { totals: result.totals }); + }, + ); + // GET /api/admin/offers/next-number fastify.get( "/next-number", diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index c344693..d3b0c44 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -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 { + const { year, month, status, supplier_name, search } = params; + const where: Record = {}; + 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 = {}; + 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 { @@ -53,22 +118,15 @@ export default async function receivedInvoicesRoutes( const query = request.query as Record; const { page, limit, skip, order } = parsePagination(query); - const where: Record = {}; - 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; + 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", diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index 92d9c13..b8022f3 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -104,17 +104,53 @@ function computeMoney( return { subtotal, vat }; } -interface ListInvoicesParams { +interface InvoiceFilterParams { + search?: string; + status?: string; + customer_id?: number; + month?: number; + year?: number; +} + +interface ListInvoicesParams extends InvoiceFilterParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; - status?: string; - customer_id?: number; - month?: number; - year?: number; +} + +export interface CurrencyAmount { + amount: number; + currency: string; +} + +/** + * Shared `where` builder for the issued-invoices list and the per-currency + * list-totals aggregation, so both stay in sync (any new filter applies to + * both). month/year filter on issue_date (matching the list). + */ +function buildInvoiceWhere( + params: InvoiceFilterParams, +): Record { + const { status, customer_id, month, year, search } = params; + const where: Record = {}; + if (status) where.status = status; + if (customer_id) where.customer_id = customer_id; + if (month && year) { + const from = new Date(year, month - 1, 1); + const to = new Date(year, month, 1); + where.issue_date = { gte: from, lt: to }; + } + if (search) { + where.OR = [ + { invoice_number: { contains: search } }, + { customers: { name: { contains: search } } }, + { customers: { company_id: { contains: search } } }, + ]; + } + return where; } function computeInvoiceTotals( @@ -159,35 +195,10 @@ export async function markOverdueInvoices() { } export async function listInvoices(params: ListInvoicesParams) { - const { - page, - limit, - skip, - sort, - order, - search, - status, - customer_id, - month, - year, - } = params; + const { page, limit, skip, sort, order } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; - const where: Record = {}; - if (status) where.status = status; - if (customer_id) where.customer_id = customer_id; - if (month && year) { - const from = new Date(year, month - 1, 1); - const to = new Date(year, month, 1); - where.issue_date = { gte: from, lt: to }; - } - if (search) { - where.OR = [ - { invoice_number: { contains: search } }, - { customers: { name: { contains: search } } }, - { customers: { company_id: { contains: search } } }, - ]; - } + const where = buildInvoiceWhere(params); const orderBy: Record = { [sortField]: order }; @@ -225,6 +236,47 @@ export async function listInvoices(params: ListInvoicesParams) { return { data: enriched, total, page, limit }; } +/** + * Sum issued-invoice TOTAL (incl. VAT) per currency across the WHOLE filtered + * set (not a single page). Reuses `buildInvoiceWhere` so the filters track the + * list, and `computeInvoiceTotals` so the per-invoice math matches the + * list/detail exactly. Returns one entry per currency, rounded to 2dp, + * zero/empty totals dropped. + * + * NOTE: This is SEPARATE from `getInvoiceStats` (the monthly KPI cards). That + * function is untouched — this only sums the list's visible rows per currency. + */ +export async function getInvoiceListTotals( + params: InvoiceFilterParams, +): Promise<{ totals: CurrencyAmount[] }> { + const where = buildInvoiceWhere(params); + + const invoices = await prisma.invoices.findMany({ + where, + include: { invoice_items: true }, + }); + + const byCurrency: Record = {}; + for (const inv of invoices) { + const { total } = computeInvoiceTotals( + inv.invoice_items, + inv.apply_vat, + inv.vat_rate, + ); + const cur = inv.currency || "CZK"; + byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0); + } + + const totals: CurrencyAmount[] = Object.entries(byCurrency) + .map(([currency, amount]) => ({ + currency, + amount: Math.round(amount * 100) / 100, + })) + .filter((t) => t.amount > 0); + + return { totals }; +} + export { generateInvoiceNumber as getNextInvoiceNumberFormatted, previewInvoiceNumber as getNextInvoiceNumberPreview, diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index f81fa98..3f43142 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -37,15 +37,44 @@ const ALLOWED_SORT_FIELDS = [ "status", ]; -interface ListOffersParams { +interface OfferFilterParams { + search?: string; + status?: string; + customer_id?: number; +} + +interface ListOffersParams extends OfferFilterParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; - status?: string; - customer_id?: number; +} + +export interface CurrencyAmount { + amount: number; + currency: string; +} + +/** + * Shared `where` builder for the offers list and the per-currency stats + * aggregation, so both stay in sync (any new filter applies to both). Offers + * have NO month/year filter — the list filters by search + status + customer_id. + */ +function buildOfferWhere(params: OfferFilterParams): Record { + const { search, status, customer_id } = params; + const where: Record = {}; + if (status) where.status = status; + if (customer_id) where.customer_id = customer_id; + if (search) { + where.OR = [ + { quotation_number: { contains: search } }, + { project_code: { contains: search } }, + { customers: { name: { contains: search } } }, + ]; + } + return where; } function enrichQuotation(q: any) { @@ -74,20 +103,10 @@ function enrichQuotation(q: any) { } export async function listOffers(params: ListOffersParams) { - const { page, limit, skip, sort, order, search, status, customer_id } = - params; + const { page, limit, skip, sort, order } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; - const where: Record = {}; - if (status) where.status = status; - if (customer_id) where.customer_id = customer_id; - if (search) { - where.OR = [ - { quotation_number: { contains: search } }, - { project_code: { contains: search } }, - { customers: { name: { contains: search } } }, - ]; - } + const where = buildOfferWhere(params); const [quotations, total] = await Promise.all([ prisma.quotations.findMany({ @@ -128,6 +147,43 @@ export async function listOffers(params: ListOffersParams) { return { data: enriched, total, page, limit }; } +/** + * Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a + * single page). Reuses `buildOfferWhere` so the filters track the list, and + * `enrichQuotation` so the per-offer math matches the list/detail exactly. + * Returns one entry per currency, rounded to 2dp, zero/empty totals dropped. + */ +export async function getOfferTotals( + params: OfferFilterParams, +): Promise<{ totals: CurrencyAmount[] }> { + const where = buildOfferWhere(params); + + const quotations = await prisma.quotations.findMany({ + where, + include: { + customers: { select: { id: true, name: true } }, + quotation_items: { orderBy: { position: "asc" } }, + scope_sections: { orderBy: { position: "asc" } }, + }, + }); + + const byCurrency: Record = {}; + for (const q of quotations) { + const { total, currency } = enrichQuotation(q); + const cur = currency || "CZK"; + byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0); + } + + const totals: CurrencyAmount[] = Object.entries(byCurrency) + .map(([currency, amount]) => ({ + currency, + amount: Math.round(amount * 100) / 100, + })) + .filter((t) => t.amount > 0); + + return { totals }; +} + export async function getOffer(id: number) { const quotation = await prisma.quotations.findUnique({ where: { id },