Files
app/src/routes/admin/warehouse.ts
BOHA db7a5c3d15 feat(suppliers)!: full customers model - structured address + custom fields
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>
2026-06-10 20:11:22 +02:00

2618 lines
84 KiB
TypeScript

import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import prisma from "../../config/database";
import { config } from "../../config/env";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import {
encodeCustomFields,
decodeCustomFields,
} from "../../utils/custom-fields";
import { parseBody } from "../../schemas/common";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
import {
getItemAvailableQty,
getItemTotalStock,
getItemStockValue,
confirmReceipt,
cancelReceipt,
confirmIssue,
cancelIssue,
selectFifoBatches,
createReservation,
cancelReservation,
confirmInventorySession,
getBelowMinimumItems,
validateIssueReservationRules,
} from "../../services/warehouse.service";
import {
CreateCategorySchema,
UpdateCategorySchema,
CreateSupplierSchema,
UpdateSupplierSchema,
CreateLocationSchema,
UpdateLocationSchema,
CreateItemSchema,
UpdateItemSchema,
CreateReceiptSchema,
UpdateReceiptSchema,
CreateIssueSchema,
UpdateIssueSchema,
CreateReservationSchema,
CreateInventorySessionSchema,
UpdateInventorySessionSchema,
} from "../../schemas/warehouse.schema";
// Hard cap on the number of source documents (issues/receipts) a report will
// scan before aggregating/expanding. Bounds worst-case work + response size on
// a wide date range; the per-report pagination then pages the derived rows.
const REPORT_SOURCE_CAP = 5000;
/**
* Coerce a querystring `status` filter to a plain string, or undefined if it
* isn't a usable scalar. Querystrings can be arrays/objects (`?status[]=x`);
* passing one straight into a Prisma string `where` is a type mismatch that
* 500s. Returning undefined for non-strings makes a bad value a no-op filter
* instead of a crash.
*/
function statusFilter(raw: unknown): string | undefined {
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
}
/**
* Coerce a querystring numeric filter (FK id etc.) to a finite number, or
* undefined if it isn't parseable. A junk value becomes a no-op filter rather
* than a NaN reaching Prisma (which 500s).
*/
function numFilter(raw: unknown): number | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
// NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it
// per sub-entity (items/receipts/issues/reservations/inventory/reports) is a
// tracked refactor, deferred — see REVIEW_FINDINGS.md.
export default async function warehouseRoutes(
fastify: FastifyInstance,
): Promise<void> {
// =============================================================
// CATEGORIES
// =============================================================
// GET /categories — list all, ordered by sort_order asc
fastify.get(
"/categories",
{ preHandler: requirePermission("warehouse.manage") },
async (_request, reply) => {
const categories = await prisma.sklad_categories.findMany({
orderBy: { sort_order: "asc" },
});
return success(reply, categories);
},
);
// POST /categories — create
fastify.post(
"/categories",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const parsed = parseBody(CreateCategorySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const category = await prisma.sklad_categories.create({
data: {
name: body.name,
description: body.description ?? null,
sort_order: body.sort_order ?? 0,
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_category",
entityId: category.id,
description: `Vytvořena kategorie ${category.name}`,
newValues: {
name: category.name,
description: category.description,
sort_order: category.sort_order,
},
});
return success(reply, category, 201, "Kategorie byla vytvořena");
},
);
// PUT /categories/:id — update
fastify.put<{ Params: { id: string } }>(
"/categories/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateCategorySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_categories.findUnique({
where: { id },
});
if (!existing) return error(reply, "Kategorie nenalezena", 404);
const updateData: Record<string, unknown> = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.description !== undefined)
updateData.description = body.description;
if (body.sort_order !== undefined)
updateData.sort_order = body.sort_order;
const updated = await prisma.sklad_categories.update({
where: { id },
data: updateData,
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_category",
entityId: id,
description: `Upravena kategorie ${updated.name}`,
oldValues: {
name: existing.name,
description: existing.description,
sort_order: existing.sort_order,
},
newValues: {
name: updated.name,
description: updated.description,
sort_order: updated.sort_order,
},
});
return success(reply, updated, 200, "Kategorie byla uložena");
},
);
// DELETE /categories/:id — delete only if no items reference it
fastify.delete<{ Params: { id: string } }>(
"/categories/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.sklad_categories.findUnique({
where: { id },
});
if (!existing) return error(reply, "Kategorie nenalezena", 404);
const itemCount = await prisma.sklad_items.count({
where: { category_id: id },
});
if (itemCount > 0) {
return error(
reply,
"Kategorii nelze smazat — obsahuje přiřazené položky",
409,
);
}
await prisma.sklad_categories.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "warehouse_category",
entityId: id,
description: `Smazána kategorie ${existing.name}`,
oldValues: {
name: existing.name,
description: existing.description,
sort_order: existing.sort_order,
},
});
return success(reply, null, 200, "Kategorie smazána");
},
);
// =============================================================
// SUPPLIERS
// =============================================================
// GET /suppliers — paginated list, search by name/ico/city
fastify.get(
"/suppliers",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const { page, limit, skip, search } = parsePagination(
request.query as Record<string, unknown>,
);
const where: Record<string, unknown> = {};
if (search) {
where.OR = [
{ name: { contains: search } },
{ ico: { contains: search } },
{ city: { contains: search } },
];
}
const [suppliers, total] = await Promise.all([
prisma.sklad_suppliers.findMany({
where,
skip,
take: limit,
orderBy: { name: "asc" },
}),
prisma.sklad_suppliers.count({ where }),
]);
// Decode the custom_fields blob into the array shape the modal edits
// (same model as customers).
const enriched = suppliers.map((s) => ({
...s,
custom_fields: decodeCustomFields(s.custom_fields).custom_fields,
}));
return paginated(
reply,
enriched,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /suppliers/:id — detail
fastify.get<{ Params: { id: string } }>(
"/suppliers/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const supplier = await prisma.sklad_suppliers.findUnique({
where: { id },
});
if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
return success(reply, {
...supplier,
custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields,
});
},
);
// POST /suppliers — create
fastify.post(
"/suppliers",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const parsed = parseBody(CreateSupplierSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const supplier = await prisma.sklad_suppliers.create({
data: {
name: body.name,
ico: body.ico ?? null,
dic: body.dic ?? null,
street: body.street ?? null,
city: body.city ?? null,
postal_code: body.postal_code ?? null,
country: body.country ?? null,
// Suppliers have no PDF field-order picker — order is always [].
custom_fields: encodeCustomFields(body.custom_fields, []),
is_active: body.is_active ?? true,
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_supplier",
entityId: supplier.id,
description: `Vytvořen dodavatel ${supplier.name}`,
newValues: {
name: supplier.name,
ico: supplier.ico,
city: supplier.city,
is_active: supplier.is_active,
},
});
return success(reply, supplier, 201, "Dodavatel byl vytvořen");
},
);
// PUT /suppliers/:id — update
fastify.put<{ Params: { id: string } }>(
"/suppliers/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateSupplierSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_suppliers.findUnique({
where: { id },
});
if (!existing) return error(reply, "Dodavatel nenalezen", 404);
const updateData: Record<string, unknown> = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.ico !== undefined) updateData.ico = body.ico;
if (body.dic !== undefined) updateData.dic = body.dic;
if (body.street !== undefined) updateData.street = body.street;
if (body.city !== undefined) updateData.city = body.city;
if (body.postal_code !== undefined)
updateData.postal_code = body.postal_code;
if (body.country !== undefined) updateData.country = body.country;
if (body.custom_fields !== undefined)
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_suppliers.update({
where: { id },
data: updateData,
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_supplier",
entityId: id,
description: `Upraven dodavatel ${updated.name}`,
oldValues: {
name: existing.name,
ico: existing.ico,
city: existing.city,
is_active: existing.is_active,
},
newValues: {
name: updated.name,
ico: updated.ico,
city: updated.city,
is_active: updated.is_active,
},
});
return success(reply, updated, 200, "Dodavatel byl uložen");
},
);
// DELETE /suppliers/:id — soft delete (set is_active=false)
fastify.delete<{ Params: { id: string } }>(
"/suppliers/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.sklad_suppliers.findUnique({
where: { id },
});
if (!existing) return error(reply, "Dodavatel nenalezen", 404);
await prisma.sklad_suppliers.update({
where: { id },
data: { is_active: false },
});
await logAudit({
request,
authData: request.authData,
action: "deactivate",
entityType: "warehouse_supplier",
entityId: id,
description: `Deaktivován dodavatel ${existing.name}`,
oldValues: { is_active: existing.is_active },
newValues: { is_active: false },
});
return success(reply, null, 200, "Dodavatel byl deaktivován");
},
);
// =============================================================
// LOCATIONS
// =============================================================
// GET /locations — list locations, ordered by code asc
// Query param `active`: "true" (default — only active, used by dropdowns),
// "false" (only inactive), or "all" (everything, used by the management page
// so deactivated rows remain visible — soft-delete, not a hard hide).
fastify.get<{ Querystring: { active?: string } }>(
"/locations",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const where: Record<string, unknown> = {};
const active = request.query.active ?? "true";
if (active === "true") where.is_active = true;
else if (active === "false") where.is_active = false;
// "all" or anything else: no filter
const locations = await prisma.sklad_locations.findMany({
where,
orderBy: { code: "asc" },
});
return success(reply, locations);
},
);
// POST /locations — create
fastify.post(
"/locations",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const parsed = parseBody(CreateLocationSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
// Check for duplicate code
const duplicate = await prisma.sklad_locations.findUnique({
where: { code: body.code },
});
if (duplicate) {
return error(reply, "Kód umístění již existuje", 409);
}
const location = await prisma.sklad_locations.create({
data: {
code: body.code,
name: body.name,
description: body.description ?? null,
is_active: body.is_active ?? true,
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_location",
entityId: location.id,
description: `Vytvořeno umístění ${location.code} - ${location.name}`,
newValues: {
code: location.code,
name: location.name,
is_active: location.is_active,
},
});
return success(reply, location, 201, "Umístění bylo vytvořeno");
},
);
// PUT /locations/:id — update
fastify.put<{ Params: { id: string } }>(
"/locations/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateLocationSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_locations.findUnique({
where: { id },
});
if (!existing) return error(reply, "Umístění nenalezeno", 404);
// Check for duplicate code if code is being changed
if (body.code !== undefined && body.code !== existing.code) {
const duplicate = await prisma.sklad_locations.findUnique({
where: { code: body.code },
});
if (duplicate) {
return error(reply, "Kód umístění již existuje", 409);
}
}
const updateData: Record<string, unknown> = {};
if (body.code !== undefined) updateData.code = body.code;
if (body.name !== undefined) updateData.name = body.name;
if (body.description !== undefined)
updateData.description = body.description;
if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_locations.update({
where: { id },
data: updateData,
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_location",
entityId: id,
description: `Upraveno umístění ${updated.code} - ${updated.name}`,
oldValues: {
code: existing.code,
name: existing.name,
description: existing.description,
is_active: existing.is_active,
},
newValues: {
code: updated.code,
name: updated.name,
description: updated.description,
is_active: updated.is_active,
},
});
return success(reply, updated, 200, "Umístění bylo uloženo");
},
);
// DELETE /locations/:id — delete only if no items at location with non-zero quantity
fastify.delete<{ Params: { id: string } }>(
"/locations/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.sklad_locations.findUnique({
where: { id },
});
if (!existing) return error(reply, "Umístění nenalezeno", 404);
// Check for items at this location with non-zero quantity
const itemsAtLocation = await prisma.sklad_item_locations.findFirst({
where: {
location_id: id,
quantity: { not: 0 },
},
});
if (itemsAtLocation) {
return error(
reply,
"Umístění nelze smazat — obsahuje položky s nenulovým množstvím",
409,
);
}
// Clean up zero-quantity item_location rows before deleting the location
await prisma.sklad_item_locations.deleteMany({
where: { location_id: id },
});
await prisma.sklad_locations.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "warehouse_location",
entityId: id,
description: `Smazáno umístění ${existing.code} - ${existing.name}`,
oldValues: {
code: existing.code,
name: existing.name,
description: existing.description,
},
});
return success(reply, null, 200, "Umístění smazáno");
},
);
// =============================================================
// ITEMS
// =============================================================
// GET /items — paginated list, search by name/item_number, filter by category_id
fastify.get(
"/items",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const { page, limit, skip, search } = parsePagination(
request.query as Record<string, unknown>,
);
const query = request.query as Record<string, unknown>;
const categoryFilter = query.category_id
? Number(query.category_id)
: undefined;
const where: Record<string, unknown> = {};
if (search || categoryFilter !== undefined) {
const conditions: Record<string, unknown>[] = [];
if (search) {
conditions.push({
OR: [
{ name: { contains: search } },
{ item_number: { contains: search } },
],
});
}
if (categoryFilter !== undefined && !isNaN(categoryFilter)) {
conditions.push({ category_id: categoryFilter });
}
if (conditions.length === 1) {
Object.assign(where, conditions[0]);
} else if (conditions.length > 1) {
where.AND = conditions;
}
}
const [items, total] = await Promise.all([
prisma.sklad_items.findMany({
where,
skip,
take: limit,
orderBy: { name: "asc" },
include: {
category: { select: { id: true, name: true } },
item_locations: {
include: {
location: { select: { id: true, code: true, name: true } },
},
},
},
}),
prisma.sklad_items.count({ where }),
]);
// Add computed fields for each item
const enriched = await Promise.all(
items.map(async (item) => {
const total_quantity = await getItemTotalStock(item.id);
const available_quantity = await getItemAvailableQty(item.id);
const stock_value = await getItemStockValue(item.id);
const minQty = item.min_quantity ? Number(item.min_quantity) : null;
const below_minimum =
minQty !== null ? total_quantity < minQty : false;
return {
...item,
total_quantity,
available_quantity,
stock_value,
below_minimum,
};
}),
);
return paginated(
reply,
enriched,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /items/:id — detail with category, batches, item_locations, plus computed fields
fastify.get<{ Params: { id: string } }>(
"/items/:id",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const item = await prisma.sklad_items.findUnique({
where: { id },
include: {
category: { select: { id: true, name: true } },
batches: {
where: { is_consumed: false },
orderBy: { received_at: "asc" },
},
item_locations: {
include: {
location: { select: { id: true, code: true, name: true } },
},
},
},
});
if (!item) return error(reply, "Položka nenalezena", 404);
const total_quantity = await getItemTotalStock(id);
const available_quantity = await getItemAvailableQty(id);
const stock_value = await getItemStockValue(id);
return success(reply, {
...item,
total_quantity,
available_quantity,
stock_value,
});
},
);
// POST /items — create
fastify.post(
"/items",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const parsed = parseBody(CreateItemSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
// Check for duplicate item_number if provided
if (body.item_number) {
const duplicate = await prisma.sklad_items.findUnique({
where: { item_number: body.item_number },
});
if (duplicate) {
return error(reply, "Číslo položky již existuje", 409);
}
}
const item = await prisma.sklad_items.create({
data: {
item_number: body.item_number ?? null,
name: body.name,
description: body.description ?? null,
category_id: body.category_id ?? null,
unit: body.unit,
min_quantity: body.min_quantity ?? null,
notes: body.notes ?? null,
is_active: body.is_active ?? true,
},
include: {
category: { select: { id: true, name: true } },
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_item",
entityId: item.id,
description: `Vytvořena položka ${item.name}`,
newValues: {
item_number: item.item_number,
name: item.name,
unit: item.unit,
min_quantity: item.min_quantity,
is_active: item.is_active,
},
});
return success(reply, item, 201, "Položka byla vytvořena");
},
);
// PUT /items/:id — update
fastify.put<{ Params: { id: string } }>(
"/items/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateItemSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_items.findUnique({
where: { id },
});
if (!existing) return error(reply, "Položka nenalezena", 404);
// Extra validation: if unit is being changed and batches exist, return 409
if (body.unit !== undefined && body.unit !== existing.unit) {
const batchCount = await prisma.sklad_batches.count({
where: { item_id: id },
});
if (batchCount > 0) {
return error(reply, "Jednotku nelze změnit - položka má pohyby", 409);
}
}
// Check for duplicate item_number if changing
if (
body.item_number !== undefined &&
body.item_number !== existing.item_number
) {
if (body.item_number) {
const duplicate = await prisma.sklad_items.findUnique({
where: { item_number: body.item_number },
});
if (duplicate) {
return error(reply, "Číslo položky již existuje", 409);
}
}
}
const updateData: Record<string, unknown> = {};
if (body.item_number !== undefined)
updateData.item_number = body.item_number;
if (body.name !== undefined) updateData.name = body.name;
if (body.description !== undefined)
updateData.description = body.description;
if (body.category_id !== undefined)
updateData.category_id = body.category_id;
if (body.unit !== undefined) updateData.unit = body.unit;
if (body.min_quantity !== undefined)
updateData.min_quantity = body.min_quantity;
if (body.notes !== undefined) updateData.notes = body.notes;
if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_items.update({
where: { id },
data: updateData,
include: {
category: { select: { id: true, name: true } },
},
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_item",
entityId: id,
description: `Upravena položka ${updated.name}`,
oldValues: {
item_number: existing.item_number,
name: existing.name,
unit: existing.unit,
min_quantity: existing.min_quantity,
is_active: existing.is_active,
category_id: existing.category_id,
},
newValues: {
item_number: updated.item_number,
name: updated.name,
unit: updated.unit,
min_quantity: updated.min_quantity,
is_active: updated.is_active,
category_id: updated.category_id,
},
});
return success(reply, updated, 200, "Položka byla uložena");
},
);
// DELETE /items/:id — hard delete, blocked if item has stock activity
fastify.delete<{ Params: { id: string } }>(
"/items/:id",
{ preHandler: requirePermission("warehouse.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.sklad_items.findUnique({
where: { id },
});
if (!existing) return error(reply, "Položka nenalezena", 404);
// Check if item is referenced by any stock activity
const [receiptLines, issueLines, activeReservations] = await Promise.all([
prisma.sklad_receipt_lines.count({ where: { item_id: id } }),
prisma.sklad_issue_lines.count({ where: { item_id: id } }),
prisma.sklad_reservations.count({
where: { item_id: id, status: "ACTIVE" },
}),
]);
if (receiptLines > 0 || issueLines > 0 || activeReservations > 0) {
return error(
reply,
"Položku nelze smazat — má vazby na příjmy, výdaje nebo aktivní rezervace",
409,
);
}
// Safe to delete: clean up locations and consumed batches first
await prisma.sklad_item_locations.deleteMany({ where: { item_id: id } });
await prisma.sklad_batches.deleteMany({ where: { item_id: id } });
await prisma.sklad_reservations.deleteMany({
where: { item_id: id, status: "CANCELLED" },
});
await prisma.sklad_inventory_lines.deleteMany({ where: { item_id: id } });
await prisma.sklad_items.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "warehouse_item",
entityId: id,
description: `Smazána položka ${existing.name}`,
oldValues: { name: existing.name, item_number: existing.item_number },
});
return success(reply, null, 200, "Položka byla smazána");
},
);
// GET /items/:id/available-qty — return available quantity
fastify.get<{ Params: { id: string } }>(
"/items/:id/available-qty",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const item = await prisma.sklad_items.findUnique({
where: { id },
select: { id: true },
});
if (!item) return error(reply, "Položka nenalezena", 404);
const available_quantity = await getItemAvailableQty(id);
return success(reply, {
item_id: id,
available_quantity,
});
},
);
// GET /items/:id/batches — return unconsumed batches with quantity > 0
fastify.get<{ Params: { id: string } }>(
"/items/:id/batches",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const item = await prisma.sklad_items.findUnique({
where: { id },
select: { id: true },
});
if (!item) return error(reply, "Položka nenalezena", 404);
const batches = await prisma.sklad_batches.findMany({
where: {
item_id: id,
is_consumed: false,
quantity: { gt: 0 },
},
orderBy: { received_at: "asc" },
});
// Include reservation-aware available quantity
const totalStock = await getItemTotalStock(id);
const reservedResult = await prisma.sklad_reservations.aggregate({
_sum: { remaining_qty: true },
where: { item_id: id, status: "ACTIVE" },
});
const reservedQty = Number(reservedResult._sum.remaining_qty ?? 0);
const availableQuantity = totalStock - reservedQty;
return success(reply, {
batches,
total_stock: totalStock,
reserved_quantity: reservedQty,
available_quantity: availableQuantity,
});
},
);
// =============================================================
// RECEIPTS
// =============================================================
await fastify.register(multipart, {
limits: { fileSize: config.nas.maxUploadSize },
});
// GET /receipts — paginated list, filter by status/supplier_id/date_from/date_to
fastify.get(
"/receipts",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const { page, limit, skip, search } = parsePagination(
request.query as Record<string, unknown>,
);
const query = request.query as Record<string, unknown>;
const where: Record<string, unknown>[] = [];
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
const supplierId = numFilter(query.supplier_id);
if (supplierId !== undefined) {
where.push({ supplier_id: supplierId });
}
if (query.date_from) {
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
}
if (query.date_to) {
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
}
if (search) {
where.push({
OR: [
{ receipt_number: { contains: search } },
{ delivery_note_number: { contains: search } },
{ notes: { contains: search } },
],
});
}
const filter = where.length > 0 ? { AND: where } : {};
const [receipts, total] = await Promise.all([
prisma.sklad_receipts.findMany({
where: filter,
skip,
take: limit,
orderBy: { id: "desc" },
include: {
supplier: { select: { id: true, name: true } },
received_by_user: {
select: { id: true, first_name: true, last_name: true },
},
_count: { select: { items: true } },
},
}),
prisma.sklad_receipts.count({ where: filter }),
]);
return paginated(
reply,
receipts,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /receipts/:id — detail with supplier, received_by_user, items, attachments
fastify.get<{ Params: { id: string } }>(
"/receipts/:id",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const receipt = await prisma.sklad_receipts.findUnique({
where: { id },
include: {
supplier: { select: { id: true, name: true } },
received_by_user: {
select: { id: true, first_name: true, last_name: true },
},
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
attachments: true,
},
});
if (!receipt) return error(reply, "Příjem nenalezen", 404);
return success(reply, receipt);
},
);
// POST /receipts — create DRAFT with items
fastify.post(
"/receipts",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const parsed = parseBody(CreateReceiptSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authUserId = request.authData!.userId;
const receipt = await prisma.sklad_receipts.create({
data: {
supplier_id: body.supplier_id ?? null,
delivery_note_number: body.delivery_note_number ?? null,
delivery_note_date: body.delivery_note_date
? new Date(body.delivery_note_date)
: null,
notes: body.notes ?? null,
status: "DRAFT",
received_by: authUserId,
items: {
create: body.items.map((item) => ({
item_id: item.item_id,
quantity: item.quantity,
unit_price: item.unit_price,
location_id: item.location_id ?? null,
notes: item.notes ?? null,
})),
},
},
include: {
supplier: { select: { id: true, name: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
},
},
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_receipt",
entityId: receipt.id,
description: `Vytvořen příjem #${receipt.id}`,
newValues: {
supplier_id: receipt.supplier_id,
status: receipt.status,
item_count: body.items.length,
},
});
return success(reply, receipt, 201, "Příjem byl vytvořen");
},
);
// PUT /receipts/:id — update header + replace items (DRAFT only)
fastify.put<{ Params: { id: string } }>(
"/receipts/:id",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateReceiptSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_receipts.findUnique({
where: { id },
include: { items: true },
});
if (!existing) return error(reply, "Příjem nenalezen", 404);
if (existing.status !== "DRAFT") {
return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400);
}
const updateData: Record<string, unknown> = {};
if (body.supplier_id !== undefined)
updateData.supplier_id = body.supplier_id;
if (body.delivery_note_number !== undefined)
updateData.delivery_note_number = body.delivery_note_number;
if (body.delivery_note_date !== undefined)
updateData.delivery_note_date = body.delivery_note_date
? new Date(body.delivery_note_date)
: null;
if (body.notes !== undefined) updateData.notes = body.notes;
updateData.modified_at = new Date();
// Replace items + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the draft with zero (or partial) lines.
const updated = await prisma.$transaction(async (tx) => {
if (body.items) {
await tx.sklad_receipt_lines.deleteMany({
where: { receipt_id: id },
});
await tx.sklad_receipt_lines.createMany({
data: body.items.map((item) => ({
receipt_id: id,
item_id: item.item_id,
quantity: item.quantity,
unit_price: item.unit_price,
location_id: item.location_id ?? null,
notes: item.notes ?? null,
})),
});
}
return tx.sklad_receipts.update({
where: { id },
data: updateData,
include: {
supplier: { select: { id: true, name: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
});
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_receipt",
entityId: id,
description: `Upraven příjem #${id}`,
oldValues: {
supplier_id: existing.supplier_id,
delivery_note_number: existing.delivery_note_number,
notes: existing.notes,
item_count: existing.items.length,
},
newValues: {
supplier_id: updated.supplier_id,
delivery_note_number: updated.delivery_note_number,
notes: updated.notes,
item_count: updated.items.length,
},
});
return success(reply, updated, 200, "Příjem byl uložen");
},
);
// POST /receipts/:id/confirm — confirm receipt
fastify.post<{ Params: { id: string } }>(
"/receipts/:id/confirm",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const authUserId = request.authData!.userId;
const result = await confirmReceipt(id, authUserId);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "confirm",
entityType: "warehouse_receipt",
entityId: id,
description: `Potvrzen příjem ${result.data.receipt_number}`,
newValues: {
status: "CONFIRMED",
receipt_number: result.data.receipt_number,
},
});
return success(reply, result.data, 200, "Příjem byl potvrzen");
},
);
// POST /receipts/:id/cancel — cancel receipt
fastify.post<{ Params: { id: string } }>(
"/receipts/:id/cancel",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await cancelReceipt(id);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "cancel",
entityType: "warehouse_receipt",
entityId: id,
description: `Zrušen příjem #${id}`,
newValues: { status: "CANCELLED" },
});
return success(reply, result.data, 200, "Příjem byl zrušen");
},
);
// GET /receipts/:id/attachments/:attachmentId — download attachment from NAS
fastify.get<{ Params: { id: string; attachmentId: string } }>(
"/receipts/:id/attachments/:attachmentId",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const attachmentId = parseId(request.params.attachmentId, reply);
if (attachmentId === null) return;
const attachment = await prisma.sklad_receipt_attachments.findUnique({
where: { id: attachmentId },
});
// 404 (not 400) when the attachment belongs to a different receipt:
// a read endpoint should not confirm that the id exists elsewhere.
if (!attachment || attachment.receipt_id !== id) {
return error(reply, "Příloha nenalezena", 404);
}
const nasFile = nasInvoicesManager.readReceived(attachment.file_path);
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = attachment.file_mime || "application/octet-stream";
// "attachment": the UI always downloads via blob + a.download, never
// renders inline — and attachment is safer with client-controlled mime.
return reply
.type(mime)
.header(
"Content-Disposition",
contentDisposition("attachment", attachment.file_name),
)
.send(nasFile.data);
},
);
// POST /receipts/:id/attachments — upload attachment
fastify.post<{ Params: { id: string } }>(
"/receipts/:id/attachments",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const receipt = await prisma.sklad_receipts.findUnique({
where: { id },
});
if (!receipt) return error(reply, "Příjem nenalezen", 404);
const part = await request.file();
if (!part) return error(reply, "Soubor nebyl nahrán", 400);
const buffer = await part.toBuffer();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
if (!nasInvoicesManager.isConfigured()) {
return error(reply, "Úložiště souborů není dostupné", 503);
}
const result = nasInvoicesManager.saveReceived(
part.filename,
year,
month,
buffer,
);
if ("error" in result) return error(reply, result.error, 500);
// Compensating delete: the NAS write already succeeded above. If the DB
// insert now fails we must remove the just-written file, otherwise it is
// orphaned on the NAS with no DB row pointing at it.
let attachment: Awaited<
ReturnType<typeof prisma.sklad_receipt_attachments.create>
>;
try {
attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: id,
file_name: part.filename,
file_mime: part.mimetype,
file_size: buffer.length,
file_path: result.filePath,
},
});
} catch (e) {
const removed = nasInvoicesManager.deleteReceived(result.filePath);
request.log.error(
{ err: e, filePath: result.filePath, orphanRemoved: removed },
"warehouse attachment DB insert failed after NAS write; rolled back NAS file",
);
return error(reply, "Přílohu se nepodařilo uložit", 500);
}
await logAudit({
request,
authData: request.authData,
action: "upload",
entityType: "warehouse_receipt_attachment",
entityId: attachment.id,
description: `Nahrána příloha k příjmu #${id}: ${part.filename}`,
newValues: {
file_name: attachment.file_name,
file_size: attachment.file_size,
},
});
return success(reply, attachment, 201, "Příloha byla nahrána");
},
);
// DELETE /receipts/:id/attachments/:attachmentId — delete attachment
fastify.delete<{ Params: { id: string; attachmentId: string } }>(
"/receipts/:id/attachments/:attachmentId",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const attachmentId = parseId(request.params.attachmentId, reply);
if (attachmentId === null) return;
const attachment = await prisma.sklad_receipt_attachments.findUnique({
where: { id: attachmentId },
});
if (!attachment) return error(reply, "Příloha nenalezena", 404);
if (attachment.receipt_id !== id) {
return error(reply, "Příloha nepatří k tomuto příjmu", 400);
}
// Delete file from NAS. A failure here is non-fatal (we still remove the
// DB row) but must not be silent — log it so an undeleted NAS file is
// visible rather than leaking quietly.
const nasDeleted = nasInvoicesManager.deleteReceived(
attachment.file_path,
);
if (!nasDeleted) {
request.log.error(
{ filePath: attachment.file_path, attachmentId },
"warehouse attachment NAS delete failed; DB row will still be removed",
);
}
await prisma.sklad_receipt_attachments.delete({
where: { id: attachmentId },
});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "warehouse_receipt_attachment",
entityId: attachmentId,
description: `Smazána příloha příjmu #${id}: ${attachment.file_name}`,
oldValues: {
file_name: attachment.file_name,
file_path: attachment.file_path,
},
});
return success(reply, null, 200, "Příloha smazána");
},
);
// =============================================================
// ISSUES
// =============================================================
// GET /issues — paginated list, filter by status/project_id/date_from/date_to
fastify.get(
"/issues",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const { page, limit, skip, search } = parsePagination(
request.query as Record<string, unknown>,
);
const query = request.query as Record<string, unknown>;
const where: Record<string, unknown>[] = [];
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
const projectId = numFilter(query.project_id);
if (projectId !== undefined) {
where.push({ project_id: projectId });
}
if (query.date_from) {
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
}
if (query.date_to) {
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
}
if (search) {
where.push({
OR: [
{ issue_number: { contains: search } },
{ notes: { contains: search } },
],
});
}
const filter = where.length > 0 ? { AND: where } : {};
const [issues, total] = await Promise.all([
prisma.sklad_issues.findMany({
where: filter,
skip,
take: limit,
orderBy: { id: "desc" },
include: {
project: { select: { id: true, name: true, project_number: true } },
issued_by_user: {
select: { id: true, first_name: true, last_name: true },
},
_count: { select: { items: true } },
},
}),
prisma.sklad_issues.count({ where: filter }),
]);
return paginated(reply, issues, buildPaginationMeta(total, page, limit));
},
);
// GET /issues/:id — detail with project, issued_by_user, items
fastify.get<{ Params: { id: string } }>(
"/issues/:id",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const issue = await prisma.sklad_issues.findUnique({
where: { id },
include: {
project: { select: { id: true, name: true, project_number: true } },
issued_by_user: {
select: { id: true, first_name: true, last_name: true },
},
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
batch: {
select: { id: true, received_at: true, unit_price: true },
},
location: { select: { id: true, code: true, name: true } },
reservation: {
select: {
id: true,
quantity: true,
remaining_qty: true,
status: true,
},
},
},
},
},
});
if (!issue) return error(reply, "Výdej nenalezen", 404);
return success(reply, issue);
},
);
// POST /issues — create DRAFT with items, auto-FIFO for items without batch_id
fastify.post(
"/issues",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const parsed = parseBody(CreateIssueSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authUserId = request.authData!.userId;
// Resolve FIFO batches for items without batch_id
const resolvedItems: Array<{
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
}> = [];
for (const item of body.items) {
if (item.batch_id != null) {
resolvedItems.push({
item_id: item.item_id,
batch_id: item.batch_id,
quantity: item.quantity,
location_id: item.location_id ?? null,
reservation_id: item.reservation_id ?? null,
notes: item.notes ?? null,
});
} else {
// Auto-select FIFO batches
try {
const fifoBatches = await selectFifoBatches(
item.item_id,
item.quantity,
);
for (const fb of fifoBatches) {
resolvedItems.push({
item_id: item.item_id,
batch_id: fb.batchId,
quantity: fb.qty,
location_id: item.location_id ?? null,
reservation_id: item.reservation_id ?? null,
notes: item.notes ?? null,
});
}
} catch (err) {
const message =
err instanceof Error
? err.message
: "Nedostatečné množství na skladě";
return error(reply, message, 400);
}
}
}
// Validate reservation rules
const reservationValidation = await validateIssueReservationRules(
resolvedItems,
body.project_id,
);
if ("error" in reservationValidation) {
return error(
reply,
reservationValidation.error,
reservationValidation.status,
);
}
const issue = await prisma.sklad_issues.create({
data: {
project_id: body.project_id,
notes: body.notes ?? null,
status: "DRAFT",
issued_by: authUserId,
items: {
create: resolvedItems,
},
},
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
},
},
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_issue",
entityId: issue.id,
description: `Vytvořen výdej #${issue.id}`,
newValues: {
project_id: issue.project_id,
status: issue.status,
item_count: resolvedItems.length,
},
});
return success(reply, issue, 201, "Výdej byl vytvořen");
},
);
// PUT /issues/:id — update (DRAFT only), auto-FIFO for items without batch_id
fastify.put<{ Params: { id: string } }>(
"/issues/:id",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateIssueSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_issues.findUnique({
where: { id },
include: { items: true },
});
if (!existing) return error(reply, "Výdej nenalezen", 404);
if (existing.status !== "DRAFT") {
return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400);
}
const updateData: Record<string, unknown> = {};
if (body.project_id !== undefined)
updateData.project_id = body.project_id;
if (body.notes !== undefined) updateData.notes = body.notes;
updateData.modified_at = new Date();
// Resolved replacement lines (null = caller did not send `items`, so the
// lines are left untouched). Populated/validated below, then persisted
// atomically with the header update.
let resolvedItems: Array<{
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
}> | null = null;
// Replace items if provided
if (body.items) {
resolvedItems = [];
for (const item of body.items) {
if (item.batch_id != null) {
resolvedItems.push({
item_id: item.item_id,
batch_id: item.batch_id,
quantity: item.quantity,
location_id: item.location_id ?? null,
reservation_id: item.reservation_id ?? null,
notes: item.notes ?? null,
});
} else {
try {
const fifoBatches = await selectFifoBatches(
item.item_id,
item.quantity,
);
for (const fb of fifoBatches) {
resolvedItems.push({
item_id: item.item_id,
batch_id: fb.batchId,
quantity: fb.qty,
location_id: item.location_id ?? null,
reservation_id: item.reservation_id ?? null,
notes: item.notes ?? null,
});
}
} catch (err) {
const message =
err instanceof Error
? err.message
: "Nedostatečné množství na skladě";
return error(reply, message, 400);
}
}
}
// Validate reservation rules
const issueProjectId = body.project_id ?? existing.project_id;
const reservationValidation = await validateIssueReservationRules(
resolvedItems,
issueProjectId,
);
if ("error" in reservationValidation) {
return error(
reply,
reservationValidation.error,
reservationValidation.status,
);
}
}
// Replace lines + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the draft with zero (or partial) lines.
const linesToWrite = resolvedItems;
const updated = await prisma.$transaction(async (tx) => {
if (linesToWrite) {
await tx.sklad_issue_lines.deleteMany({
where: { issue_id: id },
});
await tx.sklad_issue_lines.createMany({
data: linesToWrite.map((item) => ({
issue_id: id,
item_id: item.item_id,
batch_id: item.batch_id,
quantity: item.quantity,
location_id: item.location_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
});
}
return tx.sklad_issues.update({
where: { id },
data: updateData,
include: {
project: {
select: { id: true, name: true, project_number: true },
},
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
},
},
},
});
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_issue",
entityId: id,
description: `Upraven výdej #${id}`,
oldValues: {
project_id: existing.project_id,
notes: existing.notes,
item_count: existing.items.length,
},
newValues: {
project_id: updated.project_id,
notes: updated.notes,
item_count: updated.items.length,
},
});
return success(reply, updated, 200, "Výdej byl uložen");
},
);
// POST /issues/:id/confirm — confirm issue
fastify.post<{ Params: { id: string } }>(
"/issues/:id/confirm",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const authUserId = request.authData!.userId;
const result = await confirmIssue(id, authUserId);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "confirm",
entityType: "warehouse_issue",
entityId: id,
description: `Potvrzen výdej ${result.data.issue_number}`,
newValues: {
status: "CONFIRMED",
issue_number: result.data.issue_number,
},
});
return success(reply, result.data, 200, "Výdej byl potvrzen");
},
);
// POST /issues/:id/cancel — cancel issue
fastify.post<{ Params: { id: string } }>(
"/issues/:id/cancel",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await cancelIssue(id);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "cancel",
entityType: "warehouse_issue",
entityId: id,
description: `Zrušen výdej #${id}`,
newValues: { status: "CANCELLED" },
});
return success(reply, result.data, 200, "Výdej byl zrušen");
},
);
// =============================================================
// RESERVATIONS
// =============================================================
// GET /reservations — paginated list, filter by item_id/project_id/status
fastify.get(
"/reservations",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const { page, limit, skip } = parsePagination(
request.query as Record<string, unknown>,
);
const query = request.query as Record<string, unknown>;
const where: Record<string, unknown>[] = [];
const itemId = numFilter(query.item_id);
if (itemId !== undefined) {
where.push({ item_id: itemId });
}
const projectId = numFilter(query.project_id);
if (projectId !== undefined) {
where.push({ project_id: projectId });
}
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
const filter = where.length > 0 ? { AND: where } : {};
const [reservations, total] = await Promise.all([
prisma.sklad_reservations.findMany({
where: filter,
skip,
take: limit,
orderBy: { id: "desc" },
include: {
item: { select: { id: true, name: true, unit: true } },
project: { select: { id: true, name: true, project_number: true } },
reserved_by_user: {
select: { id: true, first_name: true, last_name: true },
},
},
}),
prisma.sklad_reservations.count({ where: filter }),
]);
return paginated(
reply,
reservations,
buildPaginationMeta(total, page, limit),
);
},
);
// POST /reservations — create reservation
fastify.post(
"/reservations",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const parsed = parseBody(CreateReservationSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authUserId = request.authData!.userId;
const result = await createReservation({
item_id: body.item_id,
project_id: body.project_id,
quantity: body.quantity,
reserved_by: authUserId,
notes: body.notes ?? null,
});
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_reservation",
entityId: (result.data as Record<string, unknown>).id as number,
description: `Vytvořena rezervace položky ID ${body.item_id} pro projekt ID ${body.project_id}`,
newValues: {
item_id: body.item_id,
project_id: body.project_id,
quantity: body.quantity,
},
});
return success(reply, result.data, 201, "Rezervace byla vytvořena");
},
);
// POST /reservations/:id/cancel — cancel reservation
fastify.post<{ Params: { id: string } }>(
"/reservations/:id/cancel",
{ preHandler: requirePermission("warehouse.operate") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await cancelReservation(id);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "cancel",
entityType: "warehouse_reservation",
entityId: id,
description: `Zrušena rezervace #${id}`,
newValues: { status: "CANCELLED" },
});
return success(reply, result.data, 200, "Rezervace byla zrušena");
},
);
// =============================================================
// INVENTORY
// =============================================================
// GET /inventory-sessions — paginated list, filter by status
fastify.get(
"/inventory-sessions",
{ preHandler: requirePermission("warehouse.inventory") },
async (request, reply) => {
const { page, limit, skip } = parsePagination(
request.query as Record<string, unknown>,
);
const query = request.query as Record<string, unknown>;
const where: Record<string, unknown> = {};
const status = statusFilter(query.status);
if (status) {
where.status = status;
}
const [sessions, total] = await Promise.all([
prisma.sklad_inventory_sessions.findMany({
where,
skip,
take: limit,
orderBy: { id: "desc" },
include: {
_count: { select: { items: true } },
},
}),
prisma.sklad_inventory_sessions.count({ where }),
]);
return paginated(
reply,
sessions,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /inventory-sessions/:id — detail with items
fastify.get<{ Params: { id: string } }>(
"/inventory-sessions/:id",
{ preHandler: requirePermission("warehouse.inventory") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const session = await prisma.sklad_inventory_sessions.findUnique({
where: { id },
include: {
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
});
if (!session) return error(reply, "Inventarizace nenalezena", 404);
return success(reply, session);
},
);
// POST /inventory-sessions — create DRAFT, auto-fill system_qty
fastify.post(
"/inventory-sessions",
{ preHandler: requirePermission("warehouse.inventory") },
async (request, reply) => {
const parsed = parseBody(CreateInventorySessionSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
// Build items with auto-filled system_qty and calculated difference
const itemsData = await Promise.all(
body.items.map(async (item) => {
let systemQty = 0;
if (item.location_id != null) {
// Get stock at specific location
const loc = await prisma.sklad_item_locations.findUnique({
where: {
item_id_location_id: {
item_id: item.item_id,
location_id: item.location_id,
},
},
});
systemQty = loc ? Number(loc.quantity) : 0;
} else {
// Get total stock across all locations
systemQty = await getItemTotalStock(item.item_id);
}
const difference = Number(item.actual_qty) - systemQty;
return {
item_id: item.item_id,
location_id: item.location_id ?? null,
system_qty: systemQty,
actual_qty: Number(item.actual_qty),
difference,
notes: item.notes ?? null,
};
}),
);
const session = await prisma.sklad_inventory_sessions.create({
data: {
notes: body.notes ?? null,
status: "DRAFT",
items: {
create: itemsData,
},
},
include: {
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "warehouse_inventory",
entityId: session.id,
description: `Vytvořena inventarizace #${session.id}`,
newValues: {
status: session.status,
item_count: itemsData.length,
},
});
return success(reply, session, 201, "Inventarizace byla vytvořena");
},
);
// PUT /inventory-sessions/:id — update items (DRAFT only), recalculate system_qty and difference
fastify.put<{ Params: { id: string } }>(
"/inventory-sessions/:id",
{ preHandler: requirePermission("warehouse.inventory") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateInventorySessionSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.sklad_inventory_sessions.findUnique({
where: { id },
});
if (!existing) return error(reply, "Inventarizace nenalezena", 404);
if (existing.status !== "DRAFT") {
return error(
reply,
"Inventarizaci lze upravit pouze ve stavu DRAFT",
400,
);
}
const updateData: Record<string, unknown> = {
modified_at: new Date(),
};
if (body.notes !== undefined) updateData.notes = body.notes;
// Replace items if provided, recalculating system_qty and difference.
// The system_qty/difference computation is read-only and runs first;
// the resulting lines are then persisted atomically with the header.
let itemsData: Array<{
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
}> | null = null;
if (body.items) {
itemsData = await Promise.all(
body.items.map(async (item) => {
let systemQty = 0;
if (item.location_id != null) {
const loc = await prisma.sklad_item_locations.findUnique({
where: {
item_id_location_id: {
item_id: item.item_id,
location_id: item.location_id,
},
},
});
systemQty = loc ? Number(loc.quantity) : 0;
} else {
systemQty = await getItemTotalStock(item.item_id);
}
const difference = Number(item.actual_qty) - systemQty;
return {
item_id: item.item_id,
location_id: item.location_id ?? null,
system_qty: systemQty,
actual_qty: Number(item.actual_qty),
difference,
notes: item.notes ?? null,
};
}),
);
}
// Replace lines + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the session with zero (or partial) lines.
const linesToWrite = itemsData;
const updated = await prisma.$transaction(async (tx) => {
if (linesToWrite) {
await tx.sklad_inventory_lines.deleteMany({
where: { session_id: id },
});
await tx.sklad_inventory_lines.createMany({
data: linesToWrite.map((item) => ({
session_id: id,
...item,
})),
});
}
return tx.sklad_inventory_sessions.update({
where: { id },
data: updateData,
include: {
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
});
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "warehouse_inventory",
entityId: id,
description: `Upravena inventarizace #${id}`,
oldValues: { notes: existing.notes },
newValues: { notes: updated.notes },
});
return success(reply, updated, 200, "Inventarizace byla uložena");
},
);
// POST /inventory-sessions/:id/confirm — confirm inventory session
fastify.post<{ Params: { id: string } }>(
"/inventory-sessions/:id/confirm",
{ preHandler: requirePermission("warehouse.inventory") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await confirmInventorySession(id);
if ("error" in result)
return error(reply, result.error, result.status ?? 400);
await logAudit({
request,
authData: request.authData,
action: "confirm",
entityType: "warehouse_inventory",
entityId: id,
description: `Potvrzena inventarizace ${result.data.session_number}`,
newValues: {
status: "CONFIRMED",
session_number: result.data.session_number,
},
});
return success(reply, result.data, 200, "Inventarizace byla potvrzena");
},
);
// =============================================================
// REPORTS
// =============================================================
// GET /reports/stock-status — items with stock info (paginated)
// Pagination + a hard `take` cap (parsePagination clamps limit to ≤100) keep
// this from returning an unbounded result set. Response stays `data`-as-array
// (paginated() adds a top-level `pagination` meta the existing clients ignore).
fastify.get(
"/reports/stock-status",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const where: Record<string, unknown> = { is_active: true };
// NaN-guard the category filter: a junk value yields no filter rather
// than a NaN reaching Prisma (which 500s) or silently matching nothing.
if (query.category_id !== undefined) {
const categoryId = Number(query.category_id);
if (!Number.isNaN(categoryId)) where.category_id = categoryId;
}
const [items, total] = await Promise.all([
prisma.sklad_items.findMany({
where,
orderBy: { name: "asc" },
skip,
take: limit,
include: {
category: { select: { id: true, name: true } },
},
}),
prisma.sklad_items.count({ where }),
]);
const enriched = await Promise.all(
items.map(async (item) => {
const total_quantity = await getItemTotalStock(item.id);
const available_quantity = await getItemAvailableQty(item.id);
const stock_value = await getItemStockValue(item.id);
const minQty = item.min_quantity ? Number(item.min_quantity) : null;
const below_minimum =
minQty !== null ? total_quantity < minQty : false;
return {
id: item.id,
item_number: item.item_number,
name: item.name,
unit: item.unit,
category: item.category,
total_quantity,
available_quantity,
stock_value,
below_minimum,
};
}),
);
return paginated(
reply,
enriched,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /reports/project-consumption — aggregated material consumed per project
// Bounded two ways: (1) at most REPORT_SOURCE_CAP source issues are scanned
// for the aggregate, and (2) the aggregated rows are paginated. This prevents
// an unbounded scan/response on a wide date range. Response stays
// `data`-as-array via paginated().
fastify.get(
"/reports/project-consumption",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
// NaN-guard the project filter (junk value -> no filter, not a 500).
if (query.project_id !== undefined) {
const projectId = Number(query.project_id);
if (!Number.isNaN(projectId))
issueWhere.push({ project_id: projectId });
}
if (query.date_from) {
issueWhere.push({
created_at: { gte: new Date(String(query.date_from)) },
});
}
if (query.date_to) {
issueWhere.push({
created_at: { lte: new Date(String(query.date_to)) },
});
}
const issues = await prisma.sklad_issues.findMany({
where: { AND: issueWhere },
take: REPORT_SOURCE_CAP,
orderBy: { id: "desc" },
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
include: {
item: { select: { id: true, name: true } },
batch: { select: { unit_price: true } },
},
},
},
});
// Aggregate by item + project
const consumptionMap = new Map<
string,
{
item_id: number;
item_name: string;
project_id: number;
project_name: string | null;
total_qty: number;
total_value: number;
}
>();
for (const issue of issues) {
for (const item of issue.items) {
const key = `${item.item_id}-${issue.project_id}`;
const qty = Number(item.quantity);
const price = Number(item.batch?.unit_price ?? 0);
const value = qty * price;
const existing = consumptionMap.get(key);
if (existing) {
existing.total_qty += qty;
existing.total_value += value;
} else {
consumptionMap.set(key, {
item_id: item.item_id,
item_name: item.item.name,
project_id: issue.project_id,
project_name: issue.project.name,
total_qty: qty,
total_value: value,
});
}
}
}
const allRows = Array.from(consumptionMap.values());
const pageRows = allRows.slice(skip, skip + limit);
return paginated(
reply,
pageRows,
buildPaginationMeta(allRows.length, page, limit),
);
},
);
// GET /reports/movement-log — combined list of confirmed receipts + issues
// Bounded: each source query is capped at REPORT_SOURCE_CAP rows and the
// merged, date-sorted movement list is paginated. Without this a wide date
// range expands to an unbounded number of movement lines. Response stays
// `data`-as-array via paginated().
fastify.get(
"/reports/movement-log",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const receiptWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
if (query.date_from) {
const dateFrom = new Date(String(query.date_from));
receiptWhere.push({ created_at: { gte: dateFrom } });
issueWhere.push({ created_at: { gte: dateFrom } });
}
if (query.date_to) {
const dateTo = new Date(String(query.date_to));
receiptWhere.push({ created_at: { lte: dateTo } });
issueWhere.push({ created_at: { lte: dateTo } });
}
const [receipts, issues] = await Promise.all([
prisma.sklad_receipts.findMany({
where: { AND: receiptWhere },
take: REPORT_SOURCE_CAP,
include: {
supplier: { select: { id: true, name: true } },
items: {
include: {
item: { select: { id: true, name: true } },
},
},
},
orderBy: { id: "desc" },
}),
prisma.sklad_issues.findMany({
where: { AND: issueWhere },
take: REPORT_SOURCE_CAP,
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
include: {
item: { select: { id: true, name: true } },
batch: { select: { unit_price: true } },
},
},
},
orderBy: { id: "desc" },
}),
]);
const movements: Array<{
type: "receipt" | "issue";
document_id: number;
document_number: string | null;
date: Date | null;
item_id: number;
item_name: string;
quantity: number;
unit_price: number;
supplier?: { id: number; name: string | null } | null;
project?: { id: number; name: string | null } | null;
}> = [];
for (const receipt of receipts) {
for (const item of receipt.items) {
movements.push({
type: "receipt",
document_id: receipt.id,
document_number: receipt.receipt_number,
date: receipt.created_at,
item_id: item.item_id,
item_name: item.item.name,
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
supplier: receipt.supplier,
});
}
}
for (const issue of issues) {
for (const item of issue.items) {
movements.push({
type: "issue",
document_id: issue.id,
document_number: issue.issue_number,
date: issue.created_at,
item_id: item.item_id,
item_name: item.item.name,
quantity: Number(item.quantity),
unit_price: Number(item.batch?.unit_price ?? 0),
project: issue.project,
});
}
}
// Sort by date descending
movements.sort((a, b) => {
const dateA = a.date ? a.date.getTime() : 0;
const dateB = b.date ? b.date.getTime() : 0;
return dateB - dateA;
});
const pageMovements = movements.slice(skip, skip + limit);
return paginated(
reply,
pageMovements,
buildPaginationMeta(movements.length, page, limit),
);
},
);
// GET /reports/below-minimum — items below minimum stock level (paginated)
// getBelowMinimumItems() now does ONE grouped aggregate (no per-item N+1),
// and the result is paginated here so the response can't grow unbounded.
fastify.get(
"/reports/below-minimum",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const { page, limit, skip } = parsePagination(
request.query as Record<string, unknown>,
{ defaultLimit: 2000, maxLimit: 5000 },
);
const all = await getBelowMinimumItems();
const pageItems = all.slice(skip, skip + limit);
return paginated(
reply,
pageItems,
buildPaginationMeta(all.length, page, limit),
);
},
);
}