The dodavatele modal is now an exact mirror of the customers modal (minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme, ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md). Two migrations on sklad_suppliers: - structured street/city/postal_code/country replace the free-text address blob (best-effort split: street / PSC regex / city) - custom_fields LONGTEXT replaces contact_person/email/phone/notes; existing values are preserved as labeled custom fields The encode/decode of the custom_fields blob is shared with customers via the new utils/custom-fields.ts. The PO PDF supplier block renders the structured address + custom-field lines like the customer block; the supplier picker/detail selects and types follow. Legacy clients sending the dropped keys are silently stripped (tested). Suppliers list shows Ulice/Mesto instead of the dropped contact columns; search covers name/ico/city. BREAKING CHANGE: sklad_suppliers.address, contact_person, email, phone, notes columns dropped (data migrated); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import prisma from "../../config/database";
|
||
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||
import { logAudit } from "../../services/audit";
|
||
import { success, error, parseId, paginated } from "../../utils/response";
|
||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||
import { parseBody } from "../../schemas/common";
|
||
import {
|
||
CreateIssuedOrderSchema,
|
||
UpdateIssuedOrderSchema,
|
||
} from "../../schemas/issued-orders.schema";
|
||
import {
|
||
listIssuedOrders,
|
||
getIssuedOrderTotals,
|
||
getIssuedOrder,
|
||
createIssuedOrder,
|
||
updateIssuedOrder,
|
||
deleteIssuedOrder,
|
||
getNextIssuedOrderNumberPreview,
|
||
} from "../../services/issued-orders.service";
|
||
import {
|
||
renderIssuedOrderHtml,
|
||
buildIssuedOrderPdfFooter,
|
||
buildIssuedOrderPdfHeader,
|
||
} from "./issued-orders-pdf";
|
||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
|
||
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
|
||
fastify.get(
|
||
"/",
|
||
{ preHandler: requirePermission("orders.view") },
|
||
async (request, reply) => {
|
||
const query = request.query as Record<string, unknown>;
|
||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||
const result = await listIssuedOrders({
|
||
page,
|
||
limit,
|
||
skip,
|
||
sort,
|
||
order,
|
||
search,
|
||
status: query.status ? String(query.status) : undefined,
|
||
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||
month: query.month ? Number(query.month) : undefined,
|
||
year: query.year ? Number(query.year) : undefined,
|
||
});
|
||
return paginated(
|
||
reply,
|
||
result.data,
|
||
buildPaginationMeta(result.total, page, limit),
|
||
);
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/issued-orders/next-number
|
||
fastify.get(
|
||
"/next-number",
|
||
{ preHandler: requirePermission("orders.create") },
|
||
async (_request, reply) => {
|
||
return success(reply, await getNextIssuedOrderNumberPreview());
|
||
},
|
||
);
|
||
|
||
// 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") },
|
||
async (request, reply) => {
|
||
const query = request.query as Record<string, unknown>;
|
||
const result = await getIssuedOrderTotals({
|
||
search: query.search ? String(query.search) : undefined,
|
||
status: query.status ? String(query.status) : undefined,
|
||
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||
month: query.month ? Number(query.month) : undefined,
|
||
year: query.year ? Number(query.year) : undefined,
|
||
});
|
||
return success(reply, { totals: result.totals });
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
|
||
// the PO supplier picker. Guarded by the ORDERS permissions (any of
|
||
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
|
||
// gated by warehouse.manage, which orders users typically lack — without
|
||
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
|
||
// so the literal "suppliers" path isn't captured as an order id.
|
||
fastify.get(
|
||
"/suppliers",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"orders.view",
|
||
"orders.create",
|
||
"orders.edit",
|
||
),
|
||
},
|
||
async (_request, reply) => {
|
||
const suppliers = await prisma.sklad_suppliers.findMany({
|
||
where: { is_active: true },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
ico: true,
|
||
dic: true,
|
||
street: true,
|
||
city: true,
|
||
postal_code: true,
|
||
country: true,
|
||
},
|
||
// id tiebreak so same-name suppliers sort deterministically.
|
||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||
});
|
||
return success(reply, suppliers);
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/issued-orders/:id
|
||
fastify.get<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("orders.view") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const order = await getIssuedOrder(id);
|
||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||
|
||
// `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, {
|
||
headerTemplate: buildIssuedOrderPdfHeader(
|
||
lang,
|
||
logoDataUriFromSettings(settings),
|
||
order.po_number || "",
|
||
),
|
||
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||
});
|
||
|
||
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);
|
||
}
|
||
},
|
||
);
|
||
|
||
// POST /api/admin/issued-orders
|
||
fastify.post(
|
||
"/",
|
||
{ preHandler: requirePermission("orders.create") },
|
||
async (request, reply) => {
|
||
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||
const order = await createIssuedOrder(parsed.data);
|
||
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({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "issued_order",
|
||
entityId: order.id,
|
||
description: `Vytvořena vydaná objednávka ${order.po_number ?? "koncept"}`,
|
||
});
|
||
return success(
|
||
reply,
|
||
{ id: order.id, po_number: order.po_number },
|
||
201,
|
||
"Objednávka byla vytvořena",
|
||
);
|
||
},
|
||
);
|
||
|
||
// PUT /api/admin/issued-orders/:id
|
||
fastify.put<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("orders.edit") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const parsed = parseBody(UpdateIssuedOrderSchema, request.body);
|
||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||
const result = await updateIssuedOrder(id, parsed.data);
|
||
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")
|
||
return error(
|
||
reply,
|
||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||
400,
|
||
);
|
||
return error(reply, "Neznámá chyba", 500);
|
||
}
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "issued_order",
|
||
entityId: id,
|
||
description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`,
|
||
oldValues: result.oldValues,
|
||
newValues: result.newValues,
|
||
});
|
||
// 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",
|
||
);
|
||
},
|
||
);
|
||
|
||
// DELETE /api/admin/issued-orders/:id
|
||
fastify.delete<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("orders.delete") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const existing = await deleteIssuedOrder(id);
|
||
if (!existing) return error(reply, "Objednávka nenalezena", 404);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "delete",
|
||
entityType: "issued_order",
|
||
entityId: id,
|
||
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");
|
||
},
|
||
);
|
||
}
|