Files
app/src/__tests__/order-totals.test.ts
BOHA 6a22195c7d feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer
Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:06 +02:00

253 lines
8.4 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOrder, getOrderTotals } from "../services/orders.service";
import {
createIssuedOrder,
getIssuedOrderTotals,
} from "../services/issued-orders.service";
// Per-currency total aggregation for both order lists. These hit the real
// `app_test` DB via the service layer (suite convention) and prove the /stats
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
// set, and that the where (month/year + status) is respected.
//
// A far-future month/year is used so seeded/other-test rows can't fall in the
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
const STATS_YEAR = 2097;
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
const createdOrderIds: number[] = [];
const createdIssuedIds: number[] = [];
const createdCustomerIds: number[] = [];
afterEach(async () => {
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdIssuedIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
createdOrderIds.length = 0;
createdIssuedIds.length = 0;
createdCustomerIds.length = 0;
// Finalized orders consume the shared/issued sequences — reset so we don't
// leak a consumed number into sibling test files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "issued_order", "project"] } },
});
});
function amountFor(
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined {
return totals.find((t) => t.currency === currency)?.amount;
}
describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
// applied on the whole subtotal (enrichOrder 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 createOrder({
status: "prijata",
currency,
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if (!("id" in res)) throw new Error(JSON.stringify(res));
createdOrderIds.push(res.id!);
// created_at is auto-managed; pin it into the stats window so the
// month/year filter is deterministic.
await prisma.orders.update({
where: { id: res.id },
data: {
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
},
});
return res.id!;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getOrderTotals({
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 mk = async (status: string, monthOffset: number) => {
const res = await createOrder({
status,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if (!("id" in res)) throw new Error(JSON.stringify(res));
createdOrderIds.push(res.id!);
await prisma.orders.update({
where: { id: res.id },
data: {
created_at: new Date(
STATS_YEAR,
STATS_MONTH - 1 + monthOffset,
15,
12,
0,
0,
),
},
});
return res.id!;
};
// One 'prijata' in the target month, one 'stornovana' in the target month,
// one 'prijata' in the NEXT month.
await mk("prijata", 0);
await mk("stornovana", 0);
await mk("prijata", 1);
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
const whole = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
// Status filter narrows to the single 'prijata' in the target month -> 1210.
const onlyPrijata = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "prijata",
});
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
// Next month sees only the one 'prijata' there -> 1210.
const nextMonth = await getOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
});
});
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeIssuedOrderTotals) but with a single line per
// order here the result matches the on-the-whole subtotal math.
// 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
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({
currency,
vat_rate: 21,
apply_vat: true,
order_date: dateStr,
items: [
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
],
});
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id);
return o.id;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getIssuedOrderTotals({
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 inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one already-sent (both 1210). Next month: one.
const draft = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
createdIssuedIds.push(draft.id);
const sent = await createIssuedOrder({
status: "sent",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
});
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
createdIssuedIds.push(sent.id);
const next = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: nextMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
});
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
createdIssuedIds.push(next.id);
// Whole target month: both count -> 2 x 1210 = 2420.
const whole = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
// Status filter narrows to the single 'sent' in the target month -> 1210.
const onlySent = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "sent",
});
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
// Next month sees only the one order there -> 1210.
const nextStats = await getIssuedOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
});
});