305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import prisma from "../config/database";
|
|
import {
|
|
generateIssuedOrderNumber,
|
|
previewIssuedOrderNumber,
|
|
releaseIssuedOrderNumber,
|
|
} from "../services/numbering.service";
|
|
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
|
|
import {
|
|
computeIssuedOrderTotals,
|
|
createIssuedOrder,
|
|
getIssuedOrder,
|
|
listIssuedOrders,
|
|
updateIssuedOrder,
|
|
deleteIssuedOrder,
|
|
} from "../services/issued-orders.service";
|
|
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
|
|
|
afterEach(async () => {
|
|
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
|
|
});
|
|
|
|
describe("issued-order numbering", () => {
|
|
it("generates sequential numbers", async () => {
|
|
const year = new Date().getFullYear();
|
|
const a = await generateIssuedOrderNumber(year);
|
|
const b = await generateIssuedOrderNumber(year);
|
|
expect(Number(b.number)).toBe(Number(a.number) + 1);
|
|
});
|
|
|
|
it("preview does not consume the sequence", async () => {
|
|
const year = new Date().getFullYear();
|
|
const p1 = await previewIssuedOrderNumber(year);
|
|
const p2 = await previewIssuedOrderNumber(year);
|
|
expect(p1.number).toBe(p2.number);
|
|
});
|
|
|
|
it("release frees only the highest number", async () => {
|
|
const year = new Date().getFullYear();
|
|
await generateIssuedOrderNumber(year); // seq 1
|
|
await generateIssuedOrderNumber(year); // seq 2
|
|
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
|
|
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
|
|
// Releasing a number that is NOT the current highest is a no-op.
|
|
await releaseIssuedOrderNumber(year, "00000000");
|
|
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
|
|
// Releasing the current highest reclaims it.
|
|
await releaseIssuedOrderNumber(year, c.number);
|
|
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
|
|
});
|
|
});
|
|
|
|
describe("CreateIssuedOrderSchema", () => {
|
|
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
|
const ok = CreateIssuedOrderSchema.safeParse({
|
|
customer_id: "5",
|
|
vat_rate: "21",
|
|
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
|
});
|
|
expect(ok.success).toBe(true);
|
|
if (ok.success) expect(ok.data.customer_id).toBe(5);
|
|
|
|
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
|
expect(bad.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
const createdIds: number[] = [];
|
|
const createdCustomerIds: number[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const id of createdIds)
|
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
|
for (const id of createdCustomerIds)
|
|
await prisma.customers.deleteMany({ where: { id } });
|
|
createdIds.length = 0;
|
|
createdCustomerIds.length = 0;
|
|
});
|
|
|
|
async function makeCustomer() {
|
|
const c = await prisma.customers.create({
|
|
data: { name: "Dodavatel s.r.o." },
|
|
});
|
|
createdCustomerIds.push(c.id);
|
|
return c;
|
|
}
|
|
|
|
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
|
it("adds VAT on top of net, rounded per line", () => {
|
|
const t = computeIssuedOrderTotals(
|
|
[
|
|
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
|
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
|
],
|
|
true,
|
|
21,
|
|
);
|
|
expect(t.subtotal).toBe(250);
|
|
expect(t.vat_amount).toBe(48);
|
|
expect(t.total).toBe(298);
|
|
});
|
|
|
|
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
|
|
const t = computeIssuedOrderTotals(
|
|
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
|
|
false,
|
|
21,
|
|
);
|
|
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
|
|
});
|
|
|
|
it("falls back to the document rate when a line rate is null", () => {
|
|
const t = computeIssuedOrderTotals(
|
|
[{ quantity: 1, unit_price: 100, vat_rate: null }],
|
|
true,
|
|
15,
|
|
);
|
|
expect(t.vat_amount).toBe(15);
|
|
});
|
|
});
|
|
|
|
describe("createIssuedOrder", () => {
|
|
it("defaults to draft with NO PO number, stores items", async () => {
|
|
const c = await makeCustomer();
|
|
const order = await createIssuedOrder({
|
|
customer_id: c.id,
|
|
items: [
|
|
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
|
],
|
|
});
|
|
createdIds.push(order.id);
|
|
// Deferred numbering: a draft carries no number.
|
|
expect(order.po_number).toBeNull();
|
|
expect(order.status).toBe("draft");
|
|
const items = await prisma.issued_order_items.findMany({
|
|
where: { issued_order_id: order.id },
|
|
});
|
|
expect(items.length).toBe(1);
|
|
expect(Number(items[0].unit_price)).toBe(100);
|
|
});
|
|
|
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
|
const before = (await previewIssuedOrderNumber()).number;
|
|
const order = await createIssuedOrder({ status: "sent" });
|
|
createdIds.push(order.id);
|
|
expect(order.po_number).toBe(before);
|
|
expect(order.status).toBe("sent");
|
|
});
|
|
});
|
|
|
|
describe("updateIssuedOrder status transitions", () => {
|
|
it("allows draft -> sent and rejects draft -> completed", async () => {
|
|
const order = await createIssuedOrder({});
|
|
createdIds.push(order.id);
|
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
|
expect("error" in ok).toBe(false);
|
|
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
|
expect("error" in bad && bad.error).toBe("invalid_transition");
|
|
});
|
|
|
|
it("locks items once confirmed", async () => {
|
|
const order = await createIssuedOrder({
|
|
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
|
});
|
|
createdIds.push(order.id);
|
|
await updateIssuedOrder(order.id, { status: "sent" });
|
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
|
await updateIssuedOrder(order.id, {
|
|
items: [{ description: "B", quantity: 9, unit_price: 99 }],
|
|
});
|
|
const items = await prisma.issued_order_items.findMany({
|
|
where: { issued_order_id: order.id },
|
|
});
|
|
expect(items.length).toBe(1);
|
|
expect(items[0].description).toBe("A");
|
|
});
|
|
});
|
|
|
|
describe("getIssuedOrder", () => {
|
|
it("returns customer_name and valid_transitions", async () => {
|
|
const c = await makeCustomer();
|
|
const order = await createIssuedOrder({ customer_id: c.id });
|
|
createdIds.push(order.id);
|
|
const detail = await getIssuedOrder(order.id);
|
|
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
|
expect(detail?.valid_transitions).toContain("sent");
|
|
});
|
|
});
|
|
|
|
describe("deleteIssuedOrder", () => {
|
|
it("deletes, cascades items, frees the latest number", async () => {
|
|
const order = await createIssuedOrder({
|
|
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
|
});
|
|
// Finalize so the order has a real (consumed) number to free on delete.
|
|
const finalized = await updateIssuedOrder(order.id, { status: "sent" });
|
|
const before = "po_number" in finalized ? finalized.po_number : null;
|
|
expect(before).toBeTruthy();
|
|
await deleteIssuedOrder(order.id);
|
|
expect(
|
|
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
|
|
).toBeNull();
|
|
expect(
|
|
(
|
|
await prisma.issued_order_items.findMany({
|
|
where: { issued_order_id: order.id },
|
|
})
|
|
).length,
|
|
).toBe(0);
|
|
expect((await previewIssuedOrderNumber()).number).toBe(before);
|
|
});
|
|
|
|
it("releases the number against the order's creation year, not the current year", async () => {
|
|
const priorYear = new Date().getFullYear() - 1;
|
|
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
|
// so the PO number matches the prior year's current highest.
|
|
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
|
const order = await createIssuedOrder({ po_number: poNumber });
|
|
createdIds.push(order.id);
|
|
// Backdate creation into the prior year so delete derives that year.
|
|
await prisma.issued_orders.update({
|
|
where: { id: order.id },
|
|
data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) },
|
|
});
|
|
await deleteIssuedOrder(order.id);
|
|
// The prior-year sequence must be decremented (1 -> 0). With the old
|
|
// current-year bug it would stay at 1 (no current-year row to match).
|
|
const seq = await prisma.number_sequences.findFirst({
|
|
where: { type: "issued_order", year: priorYear },
|
|
});
|
|
expect(seq?.last_number).toBe(0);
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
const order = {
|
|
po_number: "26720001",
|
|
order_date: new Date("2026-06-09T12:00:00"),
|
|
delivery_date: null,
|
|
currency: "CZK",
|
|
apply_vat: true,
|
|
vat_rate: 21,
|
|
notes: "<p>Pozn</p><script>alert(1)</script>",
|
|
delivery_terms: null,
|
|
payment_terms: null,
|
|
issued_by: null,
|
|
};
|
|
const items = [
|
|
{
|
|
description: "Materiál",
|
|
item_description: "Detailní popis položky",
|
|
quantity: 2,
|
|
unit: "ks",
|
|
unit_price: 100,
|
|
vat_rate: 21,
|
|
},
|
|
];
|
|
|
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
|
const html = renderIssuedOrderHtml(
|
|
order,
|
|
items,
|
|
{ name: "Dodavatel s.r.o." },
|
|
{ company_name: "Naše firma" },
|
|
"cs",
|
|
);
|
|
expect(html).toContain("26720001");
|
|
expect(html).toContain("Materiál");
|
|
// Optional item sub-line.
|
|
expect(html).toContain("Detailní popis položky");
|
|
// PO direction: the customer record is the Dodavatel (supplier), our
|
|
// company is the Odběratel (buyer).
|
|
expect(html).toContain("Dodavatel s.r.o.");
|
|
expect(html).toContain("Naše firma");
|
|
expect(html).toContain("Dodavatel");
|
|
expect(html).toContain("Odběratel");
|
|
});
|
|
|
|
it("strips script tags from notes", () => {
|
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs");
|
|
expect(html).not.toContain("<script>");
|
|
expect(html).not.toContain("alert(1)");
|
|
});
|
|
});
|