Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2568 lines
82 KiB
TypeScript
2568 lines
82 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 { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
|
import { parseBody } from "../../schemas/common";
|
|
import { nasFinancialsManager } 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/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 paginated(
|
|
reply,
|
|
suppliers,
|
|
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 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");
|
|
},
|
|
);
|
|
|
|
// 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 (!nasFinancialsManager.isConfigured()) {
|
|
return error(reply, "Úložiště souborů není dostupné", 503);
|
|
}
|
|
|
|
const result = nasFinancialsManager.saveReceivedInvoice(
|
|
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 = nasFinancialsManager.deleteReceivedInvoice(
|
|
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 = nasFinancialsManager.deleteReceivedInvoice(
|
|
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),
|
|
);
|
|
},
|
|
);
|
|
}
|