feat(warehouse): add master data API routes

Categories, suppliers, locations, and items CRUD endpoints with
pagination, search, computed stock fields, audit logging, and
conflict validation (409 for references, unit change with batches).
This commit is contained in:
BOHA
2026-05-29 14:12:53 +02:00
parent 637499a8ec
commit 5f08b0e086

View File

@@ -0,0 +1,895 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
getItemAvailableQty,
getItemTotalStock,
getItemStockValue,
} from "../../services/warehouse.service";
import {
CreateCategorySchema,
UpdateCategorySchema,
CreateSupplierSchema,
UpdateSupplierSchema,
CreateLocationSchema,
UpdateLocationSchema,
CreateItemSchema,
UpdateItemSchema,
} from "../../schemas/warehouse.schema";
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/contact_person
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 } },
{ contact_person: { contains: search } },
];
}
const [suppliers, total] = await Promise.all([
prisma.sklad_suppliers.findMany({
where,
skip,
take: limit,
orderBy: { name: "asc" },
}),
prisma.sklad_suppliers.count({ where }),
]);
return reply.send({
success: true,
data: suppliers,
pagination: 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);
},
);
// 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,
contact_person: body.contact_person ?? null,
email: body.email ?? null,
phone: body.phone ?? null,
address: body.address ?? null,
notes: body.notes ?? null,
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,
contact_person: supplier.contact_person,
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.contact_person !== undefined)
updateData.contact_person = body.contact_person;
if (body.email !== undefined) updateData.email = body.email;
if (body.phone !== undefined) updateData.phone = body.phone;
if (body.address !== undefined) updateData.address = body.address;
if (body.notes !== undefined) updateData.notes = body.notes;
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,
contact_person: existing.contact_person,
is_active: existing.is_active,
},
newValues: {
name: updated.name,
ico: updated.ico,
contact_person: updated.contact_person,
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 all active, ordered by code asc
fastify.get(
"/locations",
{ preHandler: requirePermission("warehouse.manage") },
async (_request, reply) => {
const locations = await prisma.sklad_locations.findMany({
where: { is_active: true },
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 reply.send({
success: true,
data: enriched,
pagination: 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 — soft delete (set is_active=false)
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);
await prisma.sklad_items.update({
where: { id },
data: { is_active: false },
});
await logAudit({
request,
authData: request.authData,
action: "deactivate",
entityType: "warehouse_item",
entityId: id,
description: `Deaktivována položka ${existing.name}`,
oldValues: { is_active: existing.is_active },
newValues: { is_active: false },
});
return success(reply, null, 200, "Položka byla deaktivová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" },
});
return success(reply, batches);
},
);
}