One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import prisma from "../config/database";
|
|
import { createProject } from "../services/projects.service";
|
|
import {
|
|
createOrder,
|
|
createOrderFromQuotation,
|
|
deleteOrder,
|
|
} from "../services/orders.service";
|
|
import { previewProjectNumber } from "../services/numbering.service";
|
|
|
|
const YEAR = new Date().getFullYear();
|
|
|
|
const createdProjectIds: number[] = [];
|
|
const createdOrderIds: number[] = [];
|
|
const createdQuotationIds: number[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const id of createdProjectIds)
|
|
await prisma.projects.deleteMany({ where: { id } });
|
|
for (const id of createdOrderIds)
|
|
await prisma.orders.deleteMany({ where: { id } });
|
|
for (const id of createdQuotationIds)
|
|
await prisma.quotations.deleteMany({ where: { id } });
|
|
createdProjectIds.length = 0;
|
|
createdOrderIds.length = 0;
|
|
createdQuotationIds.length = 0;
|
|
// Orders and projects now draw from SEPARATE sequences — reset both.
|
|
await prisma.number_sequences.deleteMany({
|
|
where: { type: { in: ["shared", "project"] } },
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Read the current `last_number` of a (type, current-year) sequence row, or 0
|
|
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
|
|
* prove each generator advances its OWN row by exactly 1.
|
|
*/
|
|
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
|
|
const row = await prisma.number_sequences.findFirst({
|
|
where: { type, year: YEAR },
|
|
select: { last_number: true },
|
|
});
|
|
return row?.last_number ?? 0;
|
|
}
|
|
|
|
const baseOrder = {
|
|
status: "prijata" as const,
|
|
currency: "CZK",
|
|
language: "cs",
|
|
exchange_rate: 1,
|
|
};
|
|
|
|
/**
|
|
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
|
|
* passed the test when a create failed) while still narrowing the union via the
|
|
* `in` operator so the success-branch fields stay typed.
|
|
*/
|
|
function failResult(res: unknown): never {
|
|
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
|
|
}
|
|
|
|
describe("createOrder (manual, optional linked project)", () => {
|
|
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
|
|
// Capture BOTH sequence counters before the create so we can prove each
|
|
// generator advanced its OWN row (not that the project consumed the shared
|
|
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
|
|
// pass even if the project drew from `shared` — the deltas catch that.
|
|
const sharedBefore = await seqLastNumber("shared");
|
|
const projectBefore = await seqLastNumber("project");
|
|
|
|
const res = await createOrder({
|
|
...baseOrder,
|
|
scope_title: "Ruční zakázka",
|
|
create_project: true,
|
|
});
|
|
if (!("id" in res)) failResult(res);
|
|
createdOrderIds.push(res.id!);
|
|
expect(res.project_id).toBeTruthy();
|
|
const project = await prisma.projects.findUnique({
|
|
where: { id: res.project_id! },
|
|
});
|
|
if (project) createdProjectIds.push(project.id);
|
|
// The auto-created project now draws from the dedicated project sequence,
|
|
// so its number is distinct from the order's shared number. They relate
|
|
// only via the order_id FK, not by sharing a number.
|
|
expect(project?.project_number).toBeTruthy();
|
|
expect(project?.project_number).not.toBe(res.order_number);
|
|
expect(project?.order_id).toBe(res.id);
|
|
|
|
// Sequence isolation: the order consumed exactly one `shared` number and
|
|
// the project consumed exactly one `project` number — each generator
|
|
// advanced its OWN row by 1. A regression where the project draws from the
|
|
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
|
|
const sharedAfter = await seqLastNumber("shared");
|
|
const projectAfter = await seqLastNumber("project");
|
|
expect(sharedAfter - sharedBefore).toBe(1);
|
|
expect(projectAfter - projectBefore).toBe(1);
|
|
});
|
|
|
|
it("creates only the order when create_project=false", async () => {
|
|
const res = await createOrder({ ...baseOrder, create_project: false });
|
|
if (!("id" in res)) failResult(res);
|
|
createdOrderIds.push(res.id!);
|
|
expect(res.project_id).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
|
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
|
|
// Seed a minimal quotation (no items/sections needed — createOrderFrom
|
|
// Quotation copies whatever is present and both arrays may be empty).
|
|
const quotation = await prisma.quotations.create({
|
|
data: {
|
|
quotation_number: `Q-FROMQ-${Date.now()}`,
|
|
status: "active",
|
|
currency: "CZK",
|
|
language: "cs",
|
|
},
|
|
});
|
|
createdQuotationIds.push(quotation.id);
|
|
|
|
const res = await createOrderFromQuotation({
|
|
quotationId: quotation.id,
|
|
customerOrderNumber: "PO-FROMQ",
|
|
});
|
|
if (!("data" in res) || !res.data) failResult(res);
|
|
const orderId = res.data.order_id;
|
|
createdOrderIds.push(orderId);
|
|
|
|
const order = await prisma.orders.findUnique({ where: { id: orderId } });
|
|
const project = await prisma.projects.findFirst({
|
|
where: { order_id: orderId },
|
|
});
|
|
if (project) createdProjectIds.push(project.id);
|
|
|
|
// Primary flow: the auto-created project must get a non-empty number from
|
|
// the dedicated `project` sequence (code 73), DISTINCT from the order's
|
|
// shared number (code 71). Pre-split they shared one number.
|
|
expect(project).toBeTruthy();
|
|
expect(project?.project_number).toBeTruthy();
|
|
expect(order?.order_number).toBeTruthy();
|
|
expect(project?.project_number).not.toBe(order?.order_number);
|
|
expect(project?.order_id).toBe(orderId);
|
|
});
|
|
|
|
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
|
const draft = await prisma.quotations.create({
|
|
data: { status: "draft", currency: "CZK", language: "cs" },
|
|
});
|
|
createdQuotationIds.push(draft.id);
|
|
|
|
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
|
expect("error" in res).toBe(true);
|
|
if ("error" in res) {
|
|
expect(res.status).toBe(400);
|
|
expect(res.error).toContain('ve stavu "draft"');
|
|
}
|
|
// The offer is untouched — still a numberless draft.
|
|
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
|
expect(row!.status).toBe("draft");
|
|
expect(row!.quotation_number).toBeNull();
|
|
expect(row!.order_id).toBeNull();
|
|
});
|
|
|
|
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
|
const invalidated = await prisma.quotations.create({
|
|
data: {
|
|
quotation_number: `Q-INVAL-${Date.now()}`,
|
|
status: "invalidated",
|
|
currency: "CZK",
|
|
language: "cs",
|
|
},
|
|
});
|
|
createdQuotationIds.push(invalidated.id);
|
|
|
|
const res = await createOrderFromQuotation({
|
|
quotationId: invalidated.id,
|
|
});
|
|
expect("error" in res).toBe(true);
|
|
if ("error" in res) {
|
|
expect(res.status).toBe(400);
|
|
expect(res.error).toContain('ve stavu "invalidated"');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("deleteOrder releases the project number (release regression)", () => {
|
|
it("frees the auto-created project's number back to the project sequence", async () => {
|
|
// The project sequence is reset in afterEach, so the preview before the
|
|
// create is the number createOrder will consume for the project.
|
|
const previewBefore = await previewProjectNumber();
|
|
|
|
const res = await createOrder({
|
|
...baseOrder,
|
|
scope_title: "Smazatelná zakázka",
|
|
create_project: true,
|
|
});
|
|
if (!("id" in res)) failResult(res);
|
|
const orderId = res.id!;
|
|
expect(res.project_id).toBeTruthy();
|
|
const project = await prisma.projects.findUnique({
|
|
where: { id: res.project_id! },
|
|
});
|
|
expect(project?.project_number).toBe(previewBefore);
|
|
|
|
// After the order delete, the project row is gone AND its number must have
|
|
// been released (decremented) so the preview returns the freed number — not
|
|
// a permanent gap. This is the fix #1 regression guard.
|
|
const del = await deleteOrder(orderId);
|
|
if ("error" in del) failResult(del);
|
|
|
|
const gone = await prisma.projects.findUnique({
|
|
where: { id: res.project_id! },
|
|
});
|
|
expect(gone).toBeNull();
|
|
|
|
const previewAfter = await previewProjectNumber();
|
|
expect(previewAfter).toBe(previewBefore);
|
|
});
|
|
});
|
|
|
|
describe("separate order vs project sequences (coherence)", () => {
|
|
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
|
if (!("id" in o1)) failResult(o1);
|
|
createdOrderIds.push(o1.id!);
|
|
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
|
|
|
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
|
if (!("project_number" in p)) failResult(p);
|
|
createdProjectIds.push(p.id);
|
|
|
|
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
|
if (!("id" in o2)) failResult(o2);
|
|
createdOrderIds.push(o2.id!);
|
|
|
|
// All three numbers must be present (non-null) AND distinct. Orders draw
|
|
// from the `shared` sequence (code 71); projects from the dedicated
|
|
// `project` sequence (code 73), so they never collide.
|
|
expect(o1.order_number).toBeTruthy();
|
|
expect(p.project_number).toBeTruthy();
|
|
expect(o2.order_number).toBeTruthy();
|
|
expect(
|
|
new Set([o1.order_number, p.project_number, o2.order_number]).size,
|
|
).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("createProject (manual, standalone)", () => {
|
|
it("assigns a project number and is not linked to an order", async () => {
|
|
const result = await createProject({
|
|
name: "Test projekt",
|
|
status: "aktivni",
|
|
});
|
|
if (!("project_number" in result)) failResult(result);
|
|
createdProjectIds.push(result.id);
|
|
expect(typeof result.project_number).toBe("string");
|
|
expect(result.project_number!.length).toBeGreaterThan(0);
|
|
expect(result.order_id).toBeNull();
|
|
});
|
|
|
|
it("gives consecutive standalone projects distinct numbers", async () => {
|
|
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
|
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
|
if ("project_number" in a) createdProjectIds.push(a.id);
|
|
if ("project_number" in b) createdProjectIds.push(b.id);
|
|
expect("project_number" in a && "project_number" in b).toBe(true);
|
|
if ("project_number" in a && "project_number" in b) {
|
|
expect(a.project_number).not.toBe(b.project_number);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("createOrder with price line item + attachment", () => {
|
|
it("stores a single line item with the entered price", async () => {
|
|
const res = await createOrder({
|
|
...baseOrder,
|
|
create_project: false,
|
|
items: [
|
|
{
|
|
description: "Práce",
|
|
quantity: 1,
|
|
unit_price: 12345,
|
|
is_included_in_total: true,
|
|
},
|
|
],
|
|
});
|
|
if (!("id" in res)) failResult(res);
|
|
createdOrderIds.push(res.id!);
|
|
const items = await prisma.order_items.findMany({
|
|
where: { order_id: res.id },
|
|
});
|
|
expect(items.length).toBe(1);
|
|
expect(Number(items[0].unit_price)).toBe(12345);
|
|
});
|
|
|
|
it("stores an uploaded PO attachment with the exact bytes", async () => {
|
|
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
|
|
const res = await createOrder(
|
|
{ ...baseOrder, create_project: false },
|
|
buf,
|
|
"po.pdf",
|
|
);
|
|
if (!("id" in res)) failResult(res);
|
|
createdOrderIds.push(res.id!);
|
|
const order = await prisma.orders.findUnique({
|
|
where: { id: res.id },
|
|
select: { attachment_name: true, attachment_data: true },
|
|
});
|
|
expect(order?.attachment_name).toBe("po.pdf");
|
|
// Verify the stored content/size, not just truthiness: round-trip the
|
|
// bytes and compare length + value against what we uploaded.
|
|
expect(order?.attachment_data).toBeTruthy();
|
|
const stored = Buffer.from(order!.attachment_data!);
|
|
expect(stored.length).toBe(buf.length);
|
|
expect(stored.equals(buf)).toBe(true);
|
|
});
|
|
});
|