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>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import prisma from "../config/database";
|
|
import {
|
|
previewIssuedOrderNumber,
|
|
previewInvoiceNumber,
|
|
previewOfferNumber,
|
|
} from "../services/numbering.service";
|
|
import {
|
|
createIssuedOrder,
|
|
updateIssuedOrder,
|
|
deleteIssuedOrder,
|
|
} from "../services/issued-orders.service";
|
|
import {
|
|
createInvoice,
|
|
updateInvoice,
|
|
deleteInvoice,
|
|
} from "../services/invoices.service";
|
|
import {
|
|
createOffer,
|
|
updateOffer,
|
|
deleteOffer,
|
|
} from "../services/offers.service";
|
|
|
|
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
|
const issuedOrderIds: number[] = [];
|
|
const invoiceIds: number[] = [];
|
|
const offerIds: number[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const id of issuedOrderIds)
|
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
|
for (const id of invoiceIds)
|
|
await prisma.invoices.deleteMany({ where: { id } });
|
|
for (const id of offerIds)
|
|
await prisma.quotations.deleteMany({ where: { id } });
|
|
issuedOrderIds.length = 0;
|
|
invoiceIds.length = 0;
|
|
offerIds.length = 0;
|
|
// Reset every sequence this suite may have touched so preview values are
|
|
// stable across tests and we don't leak consumed numbers into other files.
|
|
await prisma.number_sequences.deleteMany({
|
|
where: { type: { in: ["issued_order", "invoice", "offer"] } },
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Issued orders */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
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();
|
|
// Preview is unchanged → the sequence was not consumed.
|
|
const after = (await previewIssuedOrderNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
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" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.issued_orders.findUnique({
|
|
where: { id: draft.id },
|
|
});
|
|
expect(row?.status).toBe("sent");
|
|
expect(row?.po_number).toBe(expected);
|
|
expect("po_number" in res && res.po_number).toBe(expected);
|
|
});
|
|
|
|
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" });
|
|
const first = (
|
|
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
|
)?.po_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "issued_order" },
|
|
});
|
|
|
|
// draft -> sent is no longer valid (already sent); the number must not change.
|
|
// Drive a second finalize-style update that keeps status sent.
|
|
await updateIssuedOrder(draft.id, { status: "sent" });
|
|
const second = (
|
|
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
|
)?.po_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "issued_order" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Invoices */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("invoice drafts — deferred numbering", () => {
|
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
|
const before = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.invoice_number).toBeNull();
|
|
const after = (await previewInvoiceNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
|
|
const expected = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
|
|
const res = await updateInvoice(draft.id, { status: "issued" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
|
expect(row?.status).toBe("issued");
|
|
expect(row?.invoice_number).toBe(expected);
|
|
expect("invoice_number" in res && res.invoice_number).toBe(expected);
|
|
});
|
|
|
|
it("finalizing twice does not re-number / double-consume", async () => {
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
|
|
await updateInvoice(draft.id, { status: "issued" });
|
|
const first = (
|
|
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
|
)?.invoice_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "invoice" },
|
|
});
|
|
|
|
// issued -> issued is a no-op status-wise; the number must stay put.
|
|
await updateInvoice(draft.id, { status: "issued" });
|
|
const second = (
|
|
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
|
)?.invoice_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "invoice" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
it("two invoice drafts coexist with null numbers", async () => {
|
|
const a = await createInvoice({});
|
|
const b = await createInvoice({});
|
|
invoiceIds.push(a.id, b.id);
|
|
expect(a.invoice_number).toBeNull();
|
|
expect(b.invoice_number).toBeNull();
|
|
});
|
|
|
|
it("deleting a draft releases nothing", async () => {
|
|
const before = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
await deleteInvoice(draft.id);
|
|
const after = (await previewInvoiceNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Offers */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("offer drafts — deferred numbering", () => {
|
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
|
const before = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.quotation_number).toBeNull();
|
|
const after = await previewOfferNumber();
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
|
|
const expected = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
|
|
const res = await updateOffer(draft.id, { status: "active" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
|
expect(row?.status).toBe("active");
|
|
expect(row?.quotation_number).toBe(expected);
|
|
expect("quotation_number" in res && res.quotation_number).toBe(expected);
|
|
});
|
|
|
|
it("finalizing twice does not re-number / double-consume", async () => {
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
|
|
await updateOffer(draft.id, { status: "active" });
|
|
const first = (
|
|
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
|
)?.quotation_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "offer" },
|
|
});
|
|
|
|
// active -> active is a no-op; the number must stay put.
|
|
await updateOffer(draft.id, { status: "active" });
|
|
const second = (
|
|
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
|
)?.quotation_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "offer" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
it("two offer drafts coexist with null numbers", async () => {
|
|
const a = await createOffer({});
|
|
const b = await createOffer({});
|
|
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
|
|
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
|
|
offerIds.push(a.id, b.id);
|
|
expect(a.quotation_number).toBeNull();
|
|
expect(b.quotation_number).toBeNull();
|
|
});
|
|
|
|
it("an offer created already-finalized (status active) numbers immediately", async () => {
|
|
const expected = await previewOfferNumber();
|
|
const offer = await createOffer({ status: "active" });
|
|
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
|
|
offerIds.push(offer.id);
|
|
expect(offer.quotation_number).toBe(expected);
|
|
});
|
|
|
|
it("deleting a draft releases nothing", async () => {
|
|
const before = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
await deleteOffer(draft.id);
|
|
const after = await previewOfferNumber();
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|