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:
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user