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>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import {
|
||||
generateIssuedOrderNumber,
|
||||
previewIssuedOrderNumber,
|
||||
@@ -13,7 +16,9 @@ import {
|
||||
listIssuedOrders,
|
||||
updateIssuedOrder,
|
||||
deleteIssuedOrder,
|
||||
type IssuedOrderInput,
|
||||
} from "../services/issued-orders.service";
|
||||
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -53,12 +58,12 @@ describe("issued-order numbering", () => {
|
||||
describe("CreateIssuedOrderSchema", () => {
|
||||
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
||||
const ok = CreateIssuedOrderSchema.safeParse({
|
||||
customer_id: "5",
|
||||
supplier_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);
|
||||
if (ok.success) expect(ok.data.supplier_id).toBe(5);
|
||||
|
||||
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
||||
expect(bad.success).toBe(false);
|
||||
@@ -66,23 +71,38 @@ describe("CreateIssuedOrderSchema", () => {
|
||||
});
|
||||
|
||||
const createdIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
const createdSupplierIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
// Orders first — the supplier FK is onDelete Restrict.
|
||||
for (const id of createdIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
for (const id of createdSupplierIds)
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { id } });
|
||||
createdIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
createdSupplierIds.length = 0;
|
||||
});
|
||||
|
||||
async function makeCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "Dodavatel s.r.o." },
|
||||
async function makeSupplier(
|
||||
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
|
||||
) {
|
||||
const s = await prisma.sklad_suppliers.create({
|
||||
data: {
|
||||
name: data.name ?? "io_test_Dodavatel s.r.o.",
|
||||
ico: data.ico ?? null,
|
||||
is_active: data.is_active ?? true,
|
||||
},
|
||||
});
|
||||
createdCustomerIds.push(c.id);
|
||||
return c;
|
||||
createdSupplierIds.push(s.id);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
|
||||
async function mkIssued(input: IssuedOrderInput = {}) {
|
||||
const res = await createIssuedOrder(input);
|
||||
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
|
||||
createdIds.push(res.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||
@@ -120,18 +140,18 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
|
||||
const s = await makeSupplier();
|
||||
const order = await mkIssued({
|
||||
supplier_id: s.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");
|
||||
expect(order.supplier_id).toBe(s.id);
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
});
|
||||
@@ -141,17 +161,20 @@ describe("createIssuedOrder", () => {
|
||||
|
||||
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);
|
||||
const order = await mkIssued({ status: "sent" });
|
||||
expect(order.po_number).toBe(before);
|
||||
expect(order.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
|
||||
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateIssuedOrder status transitions", () => {
|
||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||
const order = await createIssuedOrder({});
|
||||
createdIds.push(order.id);
|
||||
const order = await mkIssued({});
|
||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||
expect("error" in ok).toBe(false);
|
||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||
@@ -159,10 +182,9 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
});
|
||||
|
||||
it("locks items once confirmed", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
const order = await mkIssued({
|
||||
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, {
|
||||
@@ -174,22 +196,28 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].description).toBe("A");
|
||||
});
|
||||
|
||||
it("rejects a nonexistent supplier_id on update", async () => {
|
||||
const order = await mkIssued({});
|
||||
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
it("returns supplier, supplier_name and valid_transitions", async () => {
|
||||
const s = await makeSupplier();
|
||||
const order = await mkIssued({ supplier_id: s.id });
|
||||
const detail = await getIssuedOrder(order.id);
|
||||
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
||||
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
|
||||
expect(detail?.supplier?.id).toBe(s.id);
|
||||
expect(detail?.valid_transitions).toContain("sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIssuedOrder", () => {
|
||||
it("deletes, cascades items, frees the latest number", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
const order = await mkIssued({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
// Finalize so the order has a real (consumed) number to free on delete.
|
||||
@@ -215,8 +243,7 @@ describe("deleteIssuedOrder", () => {
|
||||
// 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);
|
||||
const order = await mkIssued({ po_number: poNumber });
|
||||
// Backdate creation into the prior year so delete derives that year.
|
||||
await prisma.issued_orders.update({
|
||||
where: { id: order.id },
|
||||
@@ -234,9 +261,8 @@ 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 inMonth = await mkIssued({ order_date: "2026-03-15" });
|
||||
const outMonth = await mkIssued({ order_date: "2026-04-15" });
|
||||
const res = await listIssuedOrders({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
@@ -252,6 +278,142 @@ describe("listIssuedOrders month filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Route-level: suppliers lookup endpoint + supplier validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
let noPermToken = "";
|
||||
let noPermUserId = 0;
|
||||
let noPermRoleId = 0;
|
||||
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(issuedOrdersRoutes, {
|
||||
prefix: "/api/admin/issued-orders",
|
||||
});
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: "admin",
|
||||
});
|
||||
|
||||
// A role with NO orders permissions, to prove the lookup endpoint is gated.
|
||||
const noPermRole = await prisma.roles.create({
|
||||
data: {
|
||||
name: "io_test_no_orders",
|
||||
display_name: "Test No Orders",
|
||||
description: "Test role without orders permissions",
|
||||
},
|
||||
});
|
||||
noPermRoleId = noPermRole.id;
|
||||
const noPermUser = await prisma.users.create({
|
||||
data: {
|
||||
username: "io_test_noperm",
|
||||
email: "io_test_noperm@test.local",
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "No",
|
||||
last_name: "Orders",
|
||||
is_active: true,
|
||||
role_id: noPermRole.id,
|
||||
},
|
||||
});
|
||||
noPermUserId = noPermUser.id;
|
||||
noPermToken = generateToken({
|
||||
id: noPermUser.id,
|
||||
username: noPermUser.username,
|
||||
roleName: noPermRole.name,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (noPermUserId)
|
||||
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
|
||||
if (noPermRoleId)
|
||||
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/issued-orders/suppliers", () => {
|
||||
it("returns active suppliers only (with the picker fields)", async () => {
|
||||
const active = await makeSupplier({
|
||||
name: "io_test_Aktivní dodavatel",
|
||||
ico: "12345678",
|
||||
});
|
||||
const inactive = await makeSupplier({
|
||||
name: "io_test_Neaktivní dodavatel",
|
||||
is_active: false,
|
||||
});
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/issued-orders/suppliers",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
const ids = body.data.map((s: { id: number }) => s.id);
|
||||
expect(ids).toContain(active.id);
|
||||
expect(ids).not.toContain(inactive.id);
|
||||
const row = body.data.find((s: { id: number }) => s.id === active.id);
|
||||
expect(row.name).toBe("io_test_Aktivní dodavatel");
|
||||
expect(row.ico).toBe("12345678");
|
||||
// Lightweight lookup shape — all picker fields present.
|
||||
expect(Object.keys(row).sort()).toEqual(
|
||||
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is denied (403) to a user with no orders permissions", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/issued-orders/suppliers",
|
||||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/issued-orders supplier validation", () => {
|
||||
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/issued-orders",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
payload: { supplier_id: 99999999 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBe("Dodavatel nenalezen");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PDF render */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("renderIssuedOrderHtml", () => {
|
||||
const order = {
|
||||
po_number: "26720001",
|
||||
@@ -278,11 +440,19 @@ describe("renderIssuedOrderHtml", () => {
|
||||
|
||||
const issuer = { name: "Jan Novák" };
|
||||
|
||||
// sklad_suppliers shape: single Text address blob + ico/dic columns.
|
||||
const supplier = {
|
||||
name: "Dodavatel s.r.o.",
|
||||
ico: "12345678",
|
||||
dic: "CZ12345678",
|
||||
address: "Průmyslová 5\n190 00 Praha",
|
||||
};
|
||||
|
||||
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
{ name: "Dodavatel s.r.o." },
|
||||
supplier,
|
||||
{ company_name: "Naše firma" },
|
||||
"cs",
|
||||
issuer,
|
||||
@@ -291,14 +461,46 @@ describe("renderIssuedOrderHtml", () => {
|
||||
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).
|
||||
// PO direction: the sklad_suppliers 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("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
supplier,
|
||||
null,
|
||||
"cs",
|
||||
issuer,
|
||||
);
|
||||
// The single Text address blob becomes one address line per newline.
|
||||
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
|
||||
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
|
||||
expect(html).toContain("IČ: 12345678");
|
||||
expect(html).toContain("DIČ: CZ12345678");
|
||||
});
|
||||
|
||||
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
|
||||
null,
|
||||
"cs",
|
||||
issuer,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
|
||||
);
|
||||
expect(html).not.toContain("IČ: ");
|
||||
expect(html).not.toContain("DIČ: ");
|
||||
});
|
||||
|
||||
it("strips script tags from notes", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
expect(html).not.toContain("<script>");
|
||||
|
||||
Reference in New Issue
Block a user