feat(documents)!: unify offers and issued orders end to end

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>
This commit is contained in:
BOHA
2026-06-10 16:22:39 +02:00
parent 1c1b9dca17
commit d1533ffc4d
35 changed files with 4762 additions and 2835 deletions

View File

@@ -18,6 +18,15 @@ import {
deleteIssuedOrder,
getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service";
import { renderIssuedOrderHtml } from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import { contentDisposition } from "../../utils/content-disposition";
// 3× the client's 10s heartbeat: a single missed beat (background-tab
// throttling, transient network) must not silently hand the lock to a second
// editor. Keep in sync with quotations.ts and useDocumentLock.
const LOCK_TIMEOUT_MS = 30 * 1000;
export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
// GET /api/admin/issued-orders
@@ -26,12 +35,12 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const { page, limit, skip, sort, order, search } = parsePagination(query);
const result = await listIssuedOrders({
page,
limit,
skip,
sort: String(query.sort || ""),
sort,
order,
search,
status: query.status ? String(query.status) : undefined,
@@ -56,9 +65,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
},
);
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the
// literal "stats" path isn't captured as an order id.
// GET /api/admin/issued-orders/stats — per-currency NET total summed over
// the WHOLE filtered set (not one page). Issued orders are not tax documents
// — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
// isn't captured as an order id.
fastify.get(
"/stats",
{ preHandler: requirePermission("orders.view") },
@@ -118,7 +128,215 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if (id === null) return;
const order = await getIssuedOrder(id);
if (!order) return error(reply, "Objednávka nenalezena", 404);
return success(reply, order);
// `getIssuedOrder` already loaded locked_by/locked_at — enrich locked_by
// to {user_id, username, full_name} ONLY when the lock is fresh and held
// by ANOTHER user (same shape as offers so the frontend lock hook can be
// shared); otherwise null.
let lockedBy: {
user_id: number;
username: string;
full_name: string;
} | null = null;
if (order.locked_by && order.locked_at) {
const lockAge = Date.now() - new Date(order.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
order.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: order.locked_by },
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
});
if (lockUser) {
lockedBy = {
user_id: lockUser.id,
username: lockUser.username,
full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(),
};
}
}
}
return success(reply, { ...order, locked_by: lockedBy });
},
);
// POST /api/admin/issued-orders/:id/lock — acquire edit lock (mirrors offers)
fastify.post<{ Params: { id: string } }>(
"/:id/lock",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.issued_orders.findUnique({
where: { id },
select: { locked_by: true, locked_at: true },
});
if (!order) return error(reply, "Objednávka nenalezena", 404);
// Check if locked by someone else and lock is fresh
if (order.locked_by && order.locked_at) {
const lockAge = Date.now() - new Date(order.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
order.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: order.locked_by },
select: { first_name: true, last_name: true },
});
return error(
reply,
`Objednávku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
423,
);
}
}
await prisma.issued_orders.update({
where: { id },
data: { locked_by: request.authData!.userId, locked_at: new Date() },
});
return success(reply, null, 200, "Zámek nastaven");
},
);
// POST /api/admin/issued-orders/:id/heartbeat — keep lock alive
fastify.post<{ Params: { id: string } }>(
"/:id/heartbeat",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.issued_orders.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_at: new Date() },
});
return success(reply, null);
},
);
// POST /api/admin/issued-orders/:id/unlock — release lock
fastify.post<{ Params: { id: string } }>(
"/:id/unlock",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.issued_orders.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_by: null, locked_at: null },
});
return success(reply, null);
},
);
// GET /api/admin/issued-orders/:id/file — serve the archived PDF from the
// NAS; on an archive miss, fall back to a LIVE render from current data,
// re-archive the fresh PDF, and serve it (archived-file model with live
// fallback — same model as offers' /:id/file).
fastify.get<{ Params: { id: string } }>(
"/:id/file",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.issued_orders.findUnique({ where: { id } });
// A draft has no po_number → nothing was ever archived; 404 like offers.
if (!order?.po_number) return error(reply, "Objednávka nenalezena", 404);
const fileName = `Objednavka-${order.po_number}.pdf`;
// Archive hit: the by-number sweep finds the PDF in whichever
// Vydané/YYYY/MM folder it was saved to (order_date can move after
// archiving, so a fixed expected path would miss).
const archived = nasOrdersManager.readIssuedByNumber(order.po_number);
if (archived) {
return reply
.type("application/pdf")
.header("Content-Disposition", contentDisposition("inline", fileName))
.send(archived.data);
}
// Archive miss → live render (same data path as issued-orders-pdf),
// then archive the fresh PDF so the next request is a plain hit.
try {
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const sections = await prisma.issued_order_sections.findMany({
where: { issued_order_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
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<
string,
unknown
> | null;
const lang =
order.language === "en" ? ("en" as const) : ("cs" as const);
const issuer = {
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
};
const html = renderIssuedOrderHtml(
order,
items,
supplier,
settings,
lang,
issuer,
sections,
);
const pdf = await htmlToPdf(html);
if (nasOrdersManager.isConfigured()) {
const baseDate = order.order_date
? new Date(order.order_date)
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const saved = nasOrdersManager.saveIssuedPdf(
order.po_number,
baseDate.getFullYear(),
baseDate.getMonth() + 1,
pdf,
);
if (!saved) {
request.log.error(
`Archivace PDF objednávky ${order.po_number} na NAS při /file fallbacku selhala`,
);
}
}
return reply
.type("application/pdf")
.header("Content-Disposition", contentDisposition("inline", fileName))
.send(pdf);
} catch (err) {
request.log.error(
err,
"Issued order /file live-render fallback failed",
);
return error(reply, "Chyba při generování PDF", 500);
}
},
);
@@ -133,6 +351,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in order) {
if (order.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (order.error === "po_number_taken")
return error(reply, "Číslo objednávky je již použito", 409);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
@@ -165,6 +385,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 === "not_editable")
return error(reply, "Objednávku v tomto stavu nelze upravovat", 400);
if (result.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (result.error === "invalid_transition")
@@ -181,9 +403,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "update",
entityType: "issued_order",
entityId: id,
description: `Upravena vydaná objednávka ${result.po_number}`,
description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`,
oldValues: result.oldValues,
newValues: result.newValues,
});
return success(reply, { id }, 200, "Objednávka byla aktualizována");
// po_number in the response so a draft->sent finalize immediately shows
// the assigned number (POST already returns it).
return success(
reply,
{ id, po_number: result.po_number },
200,
"Objednávka byla aktualizována",
);
},
);
@@ -202,7 +433,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "delete",
entityType: "issued_order",
entityId: id,
description: `Smazána vydaná objednávka ${existing.po_number}`,
description: `Smazána vydaná objednávka ${existing.po_number ?? "koncept"}`,
oldValues: existing as unknown as Record<string, unknown>,
});
return success(reply, null, 200, "Objednávka smazána");
},