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:
895
src/routes/admin/warehouse.ts
Normal file
895
src/routes/admin/warehouse.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user