Files
app/src/routes/admin/quotations.ts
BOHA d1533ffc4d 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>
2026-06-10 16:22:39 +02:00

480 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { contentDisposition } from "../../utils/content-disposition";
import { parseBody } from "../../schemas/common";
import {
CreateQuotationSchema,
UpdateQuotationSchema,
} from "../../schemas/offers.schema";
import prisma from "../../config/database";
import {
listOffers,
getOfferTotals,
getOffer,
createOffer,
updateOffer,
deleteOffer,
duplicateOffer,
invalidateOffer,
getNextOfferNumber,
} from "../../services/offers.service";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { renderOfferHtml } from "./offers-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
// 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 issued-orders.ts and useDocumentLock.
const LOCK_TIMEOUT_MS = 30 * 1000;
export default async function quotationsRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get(
"/",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const result = await listOffers({
page,
limit,
skip,
sort,
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);
// GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
// filtered set (not one page). Offers are not tax documents — no VAT
// anywhere. Offers have NO month/year filter; the list filters by
// search + status + customer_id, so the totals use the same.
// Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
fastify.get(
"/stats",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const result = await getOfferTotals({
search: query.search ? String(query.search) : undefined,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return success(reply, { totals: result.totals });
},
);
// GET /api/admin/offers/next-number
fastify.get(
"/next-number",
{ preHandler: requirePermission("offers.create") },
async (_request, reply) => {
const number = await getNextOfferNumber();
return success(reply, { number, next_number: number });
},
);
// POST /api/admin/offers/:id/duplicate
fastify.post<{ Params: { id: string } }>(
"/:id/duplicate",
{ preHandler: requirePermission("offers.create") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await duplicateOffer(id);
if (!result) return error(reply, "Nabídka nenalezena", 404);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "quotation",
entityId: result.copy.id,
description: `Duplikována nabídka ${result.original.quotation_number}${result.copy.quotation_number}`,
});
return success(
reply,
{ id: result.copy.id, quotation_number: result.copy.quotation_number },
201,
"Nabídka byla duplikována",
);
},
);
// POST /api/admin/offers/:id/invalidate
fastify.post<{ Params: { id: string } }>(
"/:id/invalidate",
{ preHandler: requirePermission("offers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await invalidateOffer(id);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
return error(reply, "Neznámá chyba", 500);
}
const existing = result.data;
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "quotation",
entityId: id,
description: `Zneplatněna nabídka ${existing.quotation_number ?? "koncept"}`,
oldValues: { status: existing.status },
newValues: { status: "invalidated" },
});
return success(reply, null, 200, "Nabídka zneplatněna");
},
);
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const data = await getOffer(id);
if (!data) return error(reply, "Nabídka nenalezena", 404);
// `getOffer` already loaded the quotation row, including locked_by /
// locked_at — reuse them instead of a second findUnique.
let lockedBy: {
user_id: number;
username: string;
full_name: string;
} | null = null;
if (data.locked_by && data.locked_at) {
const lockAge = Date.now() - new Date(data.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
data.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: data.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, { ...data, locked_by: lockedBy });
},
);
// POST /api/admin/offers/:id/lock — acquire lock
fastify.post<{ Params: { id: string } }>(
"/:id/lock",
{ preHandler: requirePermission("offers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const quotation = await prisma.quotations.findUnique({
where: { id },
select: { locked_by: true, locked_at: true },
});
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
// Check if locked by someone else and lock is fresh
if (quotation.locked_by && quotation.locked_at) {
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
quotation.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: quotation.locked_by },
select: { first_name: true, last_name: true },
});
return error(
reply,
`Nabídku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
423,
);
}
}
await prisma.quotations.update({
where: { id },
data: { locked_by: request.authData!.userId, locked_at: new Date() },
});
return success(reply, null, 200, "Zámek nastaven");
},
);
// POST /api/admin/offers/:id/heartbeat — keep lock alive
fastify.post<{ Params: { id: string } }>(
"/:id/heartbeat",
{ preHandler: requirePermission("offers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.quotations.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_at: new Date() },
});
return success(reply, null);
},
);
// POST /api/admin/offers/:id/unlock — release lock
fastify.post<{ Params: { id: string } }>(
"/:id/unlock",
{ preHandler: requirePermission("offers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.quotations.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_by: null, locked_at: null },
});
return success(reply, null);
},
);
fastify.post(
"/",
{ preHandler: requirePermission("offers.create") },
async (request, reply) => {
const parsed = parseBody(CreateQuotationSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data);
if ("error" in quotation) {
if (quotation.error === "customer_not_found")
return error(reply, "Zákazník nenalezen", 400);
if (quotation.error === "number_taken")
return error(reply, "Číslo nabídky je již použito", 409);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "quotation",
entityId: quotation.id,
description: `Vytvořena nabídka ${quotation.quotation_number ?? "koncept"}`,
});
return success(
reply,
{ id: quotation.id },
201,
"Nabídka byla vytvořena",
);
},
);
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateQuotationSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const result = await updateOffer(id, parsed.data);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "not_editable")
return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
if (result.error === "customer_not_found")
return error(reply, "Zákazník nenalezen", 400);
return error(reply, "Neznámá chyba", 500);
}
// Keep lock — user stays on the page after save
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "quotation",
entityId: id,
description: `Upravena nabídka ${result.quotation_number ?? "koncept"}`,
oldValues: result.oldValues,
newValues: result.newValues,
});
return success(reply, { id }, 200, "Nabídka byla uložena");
},
);
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await deleteOffer(id);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "linked")
return error(
reply,
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
409,
);
return error(reply, "Neznámá chyba", 500);
}
const existing = result.data;
// NAS PDF cleanup is owned by deleteOffer (single owner) — the route
// used to repeat it here, which always failed the second time and logged
// a spurious error.
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "quotation",
entityId: id,
description: `Smazána nabídka ${existing.quotation_number ?? "koncept"}`,
oldValues: existing as unknown as Record<string, unknown>,
});
return success(reply, null, 200, "Nabídka smazána");
},
);
// GET /api/admin/offers/:id/file — serve PDF from NAS
fastify.get<{ Params: { id: string } }>(
"/:id/file",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const offer = await prisma.quotations.findUnique({
where: { id },
select: { quotation_number: true, created_at: true },
});
if (!offer?.quotation_number)
return error(reply, "Nabídka nenalezena", 404);
const year = offer.created_at
? new Date(offer.created_at).getFullYear()
: new Date().getFullYear();
const relPath = nasOffersManager.buildRelativePath(
offer.quotation_number,
year,
);
const file = nasOffersManager.readOfferPdf(relPath);
if (file) {
return reply
.type("application/pdf")
.header(
"Content-Disposition",
// Quotation numbers contain "/" segments — not header- or
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
contentDisposition(
"inline",
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
),
)
.send(file.data);
}
// Archive miss → live-render fallback (same model as issued orders'
// /:id/file): rebuild the PDF from current data with the exact PDF-route
// template, re-archive it so the next request is a plain hit, and serve
// the fresh bytes with identical response semantics.
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
const settings = await prisma.company_settings.findFirst();
const html = renderOfferHtml(quotation, settings);
const pdfBuffer = await htmlToPdf(html);
if (nasOffersManager.isConfigured()) {
// saveOfferPdf logs internally and returns null on failure —
// archiving is best-effort here, serving the PDF must not fail.
const saved = nasOffersManager.saveOfferPdf(
offer.quotation_number,
year,
pdfBuffer,
);
if (!saved) {
request.log.error(
`Archivace PDF nabídky ${offer.quotation_number} na NAS při /file fallbacku selhala`,
);
}
}
return reply
.type("application/pdf")
.header(
"Content-Disposition",
// Quotation numbers contain "/" segments — not header- or
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
contentDisposition(
"inline",
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
),
)
.send(pdfBuffer);
} catch (err) {
request.log.error(err, "Offer /file live-render fallback failed");
return error(reply, "Chyba při generování PDF", 500);
}
},
);
}