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:
BOHA
2026-06-10 11:29:06 +02:00
parent 74ce24e3fa
commit 6a22195c7d
15 changed files with 598 additions and 112 deletions

View File

@@ -51,6 +51,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull();
@@ -62,6 +64,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" });
@@ -77,6 +81,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" });
@@ -104,6 +110,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({});
const b = await createIssuedOrder({});
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull();
expect(b.po_number).toBeNull();
@@ -112,6 +120,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);

View File

@@ -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>");

View File

@@ -165,6 +165,7 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
{ 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;
};
@@ -195,6 +196,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
{ 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({
@@ -207,6 +210,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
{ 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({
@@ -218,6 +223,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
{ 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.

View File

@@ -4,8 +4,8 @@ import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
supplier_id: number | null;
supplier_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
@@ -14,6 +14,17 @@ export interface IssuedOrder {
total: number;
}
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
export interface Supplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
address: string | null;
email: string | null;
phone: string | null;
}
export interface IssuedOrderItem {
id?: number;
description: string | null;
@@ -37,10 +48,19 @@ export interface IssuedOrderDetail extends IssuedOrder {
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
supplier: Record<string, unknown> | null;
valid_transitions: string[];
}
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
export const issuedOrderSuppliersOptions = () =>
queryOptions({
queryKey: ["issued-orders", "suppliers"],
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
staleTime: 2 * 60_000,
});
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;

View File

@@ -41,8 +41,11 @@ import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
import {
issuedOrderDetailOptions,
issuedOrderSuppliersOptions,
type Supplier,
} from "../lib/queries/issued-orders";
import { companySettingsOptions } from "../lib/queries/settings";
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -58,7 +61,7 @@ import {
ConfirmDialog,
LoadingState,
PageEnter,
CustomerPicker,
SupplierPicker,
headerActionsSx,
} from "../ui";
import {
@@ -157,8 +160,8 @@ interface OrderItem {
}
interface OrderForm {
customer_id: number | null;
customer_name: string;
supplier_id: number | null;
supplier_name: string;
currency: string;
apply_vat: boolean;
vat_rate: number;
@@ -506,8 +509,8 @@ export default function IssuedOrderDetail() {
);
const [form, setForm] = useState<OrderForm>({
customer_id: null,
customer_name: "",
supplier_id: null,
supplier_name: "",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
@@ -551,8 +554,34 @@ export default function IssuedOrderDetail() {
const [deleting, setDeleting] = useState(false);
// ─── Queries ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const suppliersQuery = useQuery(issuedOrderSuppliersOptions());
// The lookup returns ACTIVE suppliers only, but a saved order may reference
// a since-deactivated one — without a fallback option the picker would
// resolve to nothing and render the counterparty blank. The fallback is
// built from the hydrated form state (supplier_name comes from the detail
// response), so the name stays visible and a re-save keeps the same id.
const suppliers = useMemo<Supplier[]>(() => {
const activeSuppliers = suppliersQuery.data ?? [];
if (
form.supplier_id != null &&
!activeSuppliers.some((s: Supplier) => s.id === form.supplier_id)
) {
return [
...activeSuppliers,
{
id: form.supplier_id,
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null,
dic: null,
address: null,
email: null,
phone: null,
},
];
}
return activeSuppliers;
}, [suppliersQuery.data, form.supplier_id, form.supplier_name]);
const companySettings = useQuery(companySettingsOptions()).data;
// Configurable currency list from company settings (falls back to the
@@ -576,13 +605,13 @@ export default function IssuedOrderDetail() {
// ─── Edit mode: hydrate form from detail (once) ───
useEffect(() => {
if (!isEdit || dataReady) return;
if (detailQuery.isLoading || customersQuery.isLoading) return;
if (detailQuery.isLoading || suppliersQuery.isLoading) return;
if (!detailQuery.data) return;
const d = detailQuery.data;
setForm({
customer_id: d.customer_id ?? null,
customer_name: d.customer_name ?? "",
supplier_id: d.supplier_id ?? null,
supplier_name: d.supplier_name ?? "",
currency: d.currency || "CZK",
apply_vat: d.apply_vat !== false,
vat_rate: numberOr(d.vat_rate, 21),
@@ -619,13 +648,13 @@ export default function IssuedOrderDetail() {
dataReady,
detailQuery.isLoading,
detailQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// ─── Create mode: set the previewed PO number + default issued_by ───
useEffect(() => {
if (isEdit || dataReady) return;
if (nextNumberQuery.isLoading || customersQuery.isLoading) return;
if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return;
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
setDataReady(true);
}, [
@@ -633,7 +662,7 @@ export default function IssuedOrderDetail() {
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// Keep the displayed PO number in sync once it becomes available — a draft
@@ -715,20 +744,20 @@ export default function IssuedOrderDetail() {
});
};
const selectCustomer = (id: number | null) => {
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
const selectSupplier = (id: number | null) => {
const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: c?.name || "",
supplier_id: id,
supplier_name: s?.name || "",
}));
setErrors((prev) => ({ ...prev, customer_id: "" }));
setErrors((prev) => ({ ...prev, supplier_id: "" }));
};
// ─── Submit (create + edit) ───
const handleSubmit = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
@@ -740,7 +769,7 @@ export default function IssuedOrderDetail() {
setSavingAction(targetStatus ?? "save");
try {
const payload: Record<string, unknown> = {
customer_id: form.customer_id,
supplier_id: form.supplier_id,
currency: form.currency,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
@@ -1108,13 +1137,13 @@ export default function IssuedOrderDetail() {
gap: 2,
}}
>
<Field label="Dodavatel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
<Field label="Dodavatel" error={errors.supplier_id} required>
<SupplierPicker
suppliers={suppliers}
value={form.supplier_id}
onChange={selectSupplier}
disabled={!editable}
error={errors.customer_id}
error={errors.supplier_id}
placeholder="Vyberte dodavatele…"
/>
</Field>

View File

@@ -229,10 +229,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
),
},
{
key: "customer_name",
key: "supplier_name",
header: "Dodavatel",
width: "24%",
render: (o) => o.customer_name || "—",
render: (o) => o.supplier_name || "—",
},
{
key: "status",

View File

@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;

View File

@@ -0,0 +1,88 @@
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
import MuiTextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { Supplier } from "../lib/queries/issued-orders";
interface SupplierPickerProps {
suppliers: Supplier[];
/** Selected supplier id (FK), or null when none chosen. */
value: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
/**
* When set, draws the input in its error state (red border). The error TEXT
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
* render helperText here to avoid duplicating the message.
*/
error?: string;
placeholder?: string;
/** Optional id forwarded by <Field> for label association. */
id?: string;
}
// Search matches the supplier name AND the IČO, so typing either finds the
// record. Match on a stringified "name + ico" so MUI's default
// case-insensitive substring filtering covers both.
const filterSuppliers = createFilterOptions<Supplier>({
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
});
/**
* Searchable supplier picker for issued orders (purchase orders) — a
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
* renders bare (no label/error text); wrap it in the page's
* <Field label error> for the label + error line.
*/
export default function SupplierPicker({
suppliers,
value,
onChange,
disabled,
error,
placeholder,
id,
}: SupplierPickerProps) {
const selected = suppliers.find((s) => s.id === value) ?? null;
return (
<Autocomplete<Supplier>
options={suppliers}
value={selected}
onChange={(_, opt) => onChange(opt ? opt.id : null)}
getOptionLabel={(s) => s?.name ?? ""}
isOptionEqualToValue={(o, v) => o.id === v.id}
filterOptions={filterSuppliers}
disabled={disabled}
size="small"
fullWidth
autoHighlight
renderOption={(props, s) => {
const { key, ...rest } = props as typeof props & { key: string };
return (
<Box component="li" key={key} {...rest}>
<Box>
{s.name}
{s.ico && (
<Typography
variant="caption"
sx={{ display: "block", color: "text.secondary" }}
>
: {s.ico}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<MuiTextField
{...params}
id={id}
placeholder={placeholder ?? "Vyberte dodavatele…"}
error={!!error}
/>
)}
/>
);
}

View File

@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
export { default as Select } from "./Select";
export type { SelectOption } from "./Select";
export { default as CustomerPicker } from "./CustomerPicker";
export { default as SupplierPicker } from "./SupplierPicker";
export { default as StatusChip } from "./StatusChip";
export { CheckboxField } from "./Checkbox";
export { default as Alert } from "./Alert";

View File

@@ -156,6 +156,31 @@ function buildAddressLines(
return { name, lines };
}
/**
* Address block for the sklad_suppliers counterparty. Unlike customers (which
* have structured street/city/postal columns), suppliers.address is a single
* Text blob — split it on newlines into one rendered line each (a blob without
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
* columns, prefixed with the same translated labels the customer block used.
*/
function buildSupplierLines(
supplier: Record<string, unknown> | null,
tObj: Record<string, string>,
): AddressResult {
if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || "");
const lines: string[] = [];
if (supplier.address) {
for (const part of String(supplier.address).split(/\r?\n/)) {
const line = part.trim();
if (line) lines.push(line);
}
}
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en";
@@ -165,7 +190,7 @@ const translations: Record<Lang, Record<string, string>> = {
title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the
// selected customer record is the supplier (Dodavatel).
// selected sklad_suppliers record is the supplier (Dodavatel).
supplier: "Dodavatel",
buyer: "Odběratel",
issue_date: "Datum vystavení:",
@@ -244,7 +269,7 @@ interface IssuedOrderPdfItem {
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null,
supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
issuer: { name: string },
@@ -268,14 +293,14 @@ export function renderIssuedOrderHtml(
}
// PO direction: our company (settings) = Odběratel (buyer);
// the customer record = Dodavatel (supplier).
// the sklad_suppliers record = Dodavatel (supplier).
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const supplierLinesHtml = supplier.lines
const supplierLinesHtml = supplierAddr.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
@@ -450,7 +475,7 @@ export function renderIssuedOrderHtml(
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
.header-grid td.addr-supplier {
background: #f5f5f5;
}
.header-grid td.details-bank {
@@ -715,7 +740,7 @@ ${indentCSS}
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
@@ -723,9 +748,9 @@ ${indentCSS}
<div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml}
</td>
<td class="addr-customer">
<td class="addr-supplier">
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supplier.name)}</div>
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
${supplierLinesHtml}
</td>
</tr>
@@ -818,9 +843,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const customer = order.customer_id
? ((await prisma.customers.findUnique({
where: { id: order.customer_id },
const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
@@ -838,7 +863,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const html = renderIssuedOrderHtml(
order,
items,
customer,
supplier,
settings,
lang,
issuer,

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import prisma from "../../config/database";
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -34,7 +35,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -66,7 +67,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const result = await getIssuedOrderTotals({
search: query.search ? String(query.search) : undefined,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -74,6 +75,40 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
},
);
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
// the PO supplier picker. Guarded by the ORDERS permissions (any of
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
// gated by warehouse.manage, which orders users typically lack — without
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
// so the literal "suppliers" path isn't captured as an order id.
fastify.get(
"/suppliers",
{
preHandler: requireAnyPermission(
"orders.view",
"orders.create",
"orders.edit",
),
},
async (_request, reply) => {
const suppliers = await prisma.sklad_suppliers.findMany({
where: { is_active: true },
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
// id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return success(reply, suppliers);
},
);
// GET /api/admin/issued-orders/:id
fastify.get<{ Params: { id: string } }>(
"/:id",
@@ -95,6 +130,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const order = await createIssuedOrder(parsed.data);
if ("error" in order) {
if (order.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
authData: request.authData,
@@ -125,6 +165,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404);
if (result.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (result.error === "invalid_transition")
return error(
reply,

View File

@@ -28,7 +28,7 @@ const ISSUED_ORDER_STATUSES = [
export const CreateIssuedOrderSchema = z.object({
po_number: z.string().max(50).nullish(),
customer_id: nullableIntIdFromForm.nullish(),
supplier_id: nullableIntIdFromForm.nullish(),
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
currency: z.string().max(10).optional(),
vat_rate: numberInRange(0, 100).optional(),

View File

@@ -1,5 +1,6 @@
import type { $Enums } from "@prisma/client";
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
@@ -19,7 +20,7 @@ export interface IssuedOrderItemInput {
export interface IssuedOrderInput {
po_number?: string | number | null;
customer_id?: number | string | null;
supplier_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
@@ -40,7 +41,7 @@ export interface IssuedOrderInput {
interface IssuedOrderFilterParams {
search?: string;
status?: string;
customer_id?: number;
supplier_id?: number;
month?: number;
year?: number;
}
@@ -66,20 +67,23 @@ export interface CurrencyAmount {
function buildIssuedOrderWhere(
params: IssuedOrderFilterParams,
): Record<string, unknown> {
const { search, status, customer_id, month, year } = params;
const { search, status, supplier_id, month, year } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (supplier_id) where.supplier_id = supplier_id;
if (search) {
where.OR = [
{ po_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
{ suppliers: { name: { contains: search } } },
{ suppliers: { ico: { contains: search } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// order_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.order_date = { gte: from, lt: to };
}
return where;
@@ -150,7 +154,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
suppliers: { select: { id: true, name: true } },
issued_order_items: true,
},
}),
@@ -167,7 +171,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
return {
...rest,
items: issued_order_items,
customer_name: o.customers?.name || null,
supplier_name: o.suppliers?.name || null,
...totals,
};
});
@@ -217,7 +221,19 @@ export async function getIssuedOrder(id: number) {
const order = await prisma.issued_orders.findUnique({
where: { id },
include: {
customers: true,
// Same minimal field set as the picker lookup endpoint — the full row
// would expose internal notes/contact to any orders.view holder.
suppliers: {
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
},
issued_order_items: { orderBy: { position: "asc" } },
},
});
@@ -226,8 +242,8 @@ export async function getIssuedOrder(id: number) {
return {
...rest,
items: issued_order_items,
customer: order.customers,
customer_name: order.customers?.name || null,
supplier: order.suppliers,
supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
};
}
@@ -236,6 +252,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
// Validate the referenced supplier exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400.
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
const supplier = await tx.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
// Deferred numbering: a draft carries NO po_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
@@ -255,16 +282,19 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
customer_id: body.customer_id ? Number(body.customer_id) : null,
supplier_id: supplierId,
status: status as $Enums.issued_orders_status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
// order_date is @db.Date (truncated to the UTC date part) — the
// "today" default must be utcMidnightOfLocalDay, or it stores
// yesterday during the 00:0002:00 Prague window.
order_date: body.order_date
? new Date(String(body.order_date))
: new Date(),
: utcMidnightOfLocalDay(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
@@ -328,8 +358,18 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.supplier_id !== undefined) {
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
// Same dangling-FK guard as create: 400, not a P2003 500.
const supplier = await prisma.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
data.supplier_id = supplierId;
}
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =