v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights: - Warehouse module: receipts, issues, reservations, inventory, reports, dashboard, master data (categories, suppliers, locations), FIFO service, integration tests - Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek / So/Ne / Noc) with Czech weekday names and decimal hours - AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep - Remove leave_type=holiday entirely (auto-computed from Czech public holidays) - Allow multiple work shifts per day (overlap detection only) - Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads - Prisma: company_settings gets 6 nullable columns for warehouse numbering (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
@@ -1,9 +1,23 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, paginated, error } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
|
||||
// DELETE_ALL_CONFIRM must be sent literally in the body to authorize a
|
||||
// full audit-log wipe. Protects against accidental / automated wipes of
|
||||
// forensic history.
|
||||
const DELETE_ALL_CONFIRM = "DELETE_ALL_AUDIT" as const;
|
||||
|
||||
const AuditCleanupSchema = z
|
||||
.object({
|
||||
days: z.number().int().nonnegative().optional(),
|
||||
confirm: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export default async function auditLogRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -44,15 +58,27 @@ export default async function auditLogRoutes(
|
||||
);
|
||||
|
||||
// POST /api/admin/audit-log/cleanup — delete old audit logs
|
||||
// days=0 with confirm="DELETE_ALL_AUDIT" wipes everything (intentionally
|
||||
// a two-step path so a stray API call cannot destroy forensic history).
|
||||
fastify.post(
|
||||
"/cleanup",
|
||||
{ preHandler: requirePermission("settings.audit") },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const days = body.days !== undefined ? Number(body.days) : null;
|
||||
const parsed = parseBody(AuditCleanupSchema, request.body);
|
||||
if ("error" in parsed) {
|
||||
return error(reply, parsed.error, 400);
|
||||
}
|
||||
const { days, confirm } = parsed.data;
|
||||
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === "all") {
|
||||
// Wipe-all path: requires explicit confirmation literal.
|
||||
if (days === 0) {
|
||||
if (confirm !== DELETE_ALL_CONFIRM) {
|
||||
return error(
|
||||
reply,
|
||||
`Pro smazání všech audit logů je nutné odeslat confirm: "${DELETE_ALL_CONFIRM}"`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -64,7 +90,7 @@ export default async function auditLogRoutes(
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
|
||||
if (days && days > 0) {
|
||||
if (days !== undefined && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const result = await prisma.audit_logs.deleteMany({
|
||||
|
||||
@@ -178,6 +178,12 @@ export default async function companySettingsRoutes(
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
warehouse_receipt_prefix: true,
|
||||
warehouse_receipt_number_pattern: true,
|
||||
warehouse_issue_prefix: true,
|
||||
warehouse_issue_number_pattern: true,
|
||||
warehouse_inventory_prefix: true,
|
||||
warehouse_inventory_number_pattern: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -228,6 +234,12 @@ export default async function companySettingsRoutes(
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
warehouse_receipt_prefix: true,
|
||||
warehouse_receipt_number_pattern: true,
|
||||
warehouse_issue_prefix: true,
|
||||
warehouse_issue_number_pattern: true,
|
||||
warehouse_inventory_prefix: true,
|
||||
warehouse_inventory_number_pattern: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -274,6 +286,12 @@ export default async function companySettingsRoutes(
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
warehouse_receipt_prefix: true,
|
||||
warehouse_receipt_number_pattern: true,
|
||||
warehouse_issue_prefix: true,
|
||||
warehouse_issue_number_pattern: true,
|
||||
warehouse_inventory_prefix: true,
|
||||
warehouse_inventory_number_pattern: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -434,6 +452,12 @@ export default async function companySettingsRoutes(
|
||||
"offer_number_pattern",
|
||||
"order_number_pattern",
|
||||
"invoice_number_pattern",
|
||||
"warehouse_receipt_prefix",
|
||||
"warehouse_receipt_number_pattern",
|
||||
"warehouse_issue_prefix",
|
||||
"warehouse_issue_number_pattern",
|
||||
"warehouse_inventory_prefix",
|
||||
"warehouse_inventory_number_pattern",
|
||||
];
|
||||
const bodyRec = body as Record<string, unknown>;
|
||||
for (const f of strFields) {
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function dashboardRoutes(
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
shift_date: { gte: todayStart, lt: todayEnd },
|
||||
leave_type: { in: ["vacation", "sick", "holiday", "unpaid"] },
|
||||
leave_type: { in: ["vacation", "sick", "unpaid"] },
|
||||
},
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { received_invoices_status } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
@@ -246,9 +247,26 @@ export default async function receivedInvoicesRoutes(
|
||||
});
|
||||
} else if (part.fieldname === "invoices") {
|
||||
try {
|
||||
invoicesMeta = JSON.parse(part.value as string);
|
||||
const parsed: unknown = JSON.parse(part.value as string);
|
||||
// The frontend sends a partial payload (one element per uploaded
|
||||
// file, only the editable fields). Validate each entry against a
|
||||
// permissive variant of the create schema so untyped keys (NaN,
|
||||
// huge numbers, weird date strings) can't reach the
|
||||
// calculations below.
|
||||
const arrSchema = z.array(CreateReceivedInvoiceSchema.partial());
|
||||
const validated = arrSchema.safeParse(parsed);
|
||||
if (validated.success) {
|
||||
invoicesMeta = validated.data as Array<Record<string, unknown>>;
|
||||
} else {
|
||||
return error(
|
||||
reply,
|
||||
"Neplatná metadata faktur: " +
|
||||
validated.error.issues.map((i) => i.message).join(", "),
|
||||
400,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Malformed invoices metadata — ignore, use defaults
|
||||
return error(reply, "Neplatný JSON v poli 'invoices'", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +94,31 @@ export default async function scopeTemplatesRoutes(
|
||||
if ("error" in scopeParsed) return error(reply, scopeParsed.error, 400);
|
||||
const body = scopeParsed.data;
|
||||
|
||||
const template = await prisma.scope_templates.create({
|
||||
data: {
|
||||
name: body.name ? String(body.name) : null,
|
||||
title: body.title ? String(body.title) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: template.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
// Wrap create + sections in a single transaction so a failed
|
||||
// createMany can't leave a template row behind with no sections.
|
||||
const template = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.scope_templates.create({
|
||||
data: {
|
||||
name: body.name ? String(body.name) : null,
|
||||
title: body.title ? String(body.title) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: created.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return success(reply, { id: template.id }, 201, "Šablona byla vytvořena");
|
||||
},
|
||||
|
||||
@@ -103,16 +103,22 @@ export default async function usersRoutes(
|
||||
const parsed = parseBody(UpdateUserSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const userData = {
|
||||
...parsed.data,
|
||||
role_id:
|
||||
parsed.data.role_id != null
|
||||
? Number(parsed.data.role_id)
|
||||
: (parsed.data.role_id as number | null | undefined),
|
||||
};
|
||||
// Privilege-escalation guard: role changes and activation toggles are
|
||||
// admin-only. Any other `users.edit` holder (e.g. "manager") could
|
||||
// otherwise reassign roles or disable other accounts.
|
||||
const callerIsAdmin = request.authData?.roleName === "admin";
|
||||
const userData: Record<string, unknown> = { ...parsed.data };
|
||||
if (!callerIsAdmin) {
|
||||
delete userData.role_id;
|
||||
delete userData.is_active;
|
||||
}
|
||||
if (userData.role_id != null) {
|
||||
userData.role_id = Number(userData.role_id);
|
||||
}
|
||||
|
||||
const result = await updateUser(
|
||||
id,
|
||||
userData,
|
||||
userData as Parameters<typeof updateUser>[1],
|
||||
request.authData?.roleName ?? undefined,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
cancelReservation,
|
||||
confirmInventorySession,
|
||||
getBelowMinimumItems,
|
||||
validateIssueReservationRules,
|
||||
} from "../../services/warehouse.service";
|
||||
import {
|
||||
CreateCategorySchema,
|
||||
@@ -385,13 +386,22 @@ export default async function warehouseRoutes(
|
||||
// LOCATIONS
|
||||
// =============================================================
|
||||
|
||||
// GET /locations — list all active, ordered by code asc
|
||||
fastify.get(
|
||||
// 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) => {
|
||||
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: { is_active: true },
|
||||
where,
|
||||
orderBy: { code: "asc" },
|
||||
});
|
||||
return success(reply, locations);
|
||||
@@ -829,7 +839,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /items/:id — soft delete (set is_active=false)
|
||||
// DELETE /items/:id — hard delete, blocked if item has stock activity
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/items/:id",
|
||||
{ preHandler: requirePermission("warehouse.manage") },
|
||||
@@ -842,23 +852,43 @@ export default async function warehouseRoutes(
|
||||
});
|
||||
if (!existing) return error(reply, "Položka nenalezena", 404);
|
||||
|
||||
await prisma.sklad_items.update({
|
||||
where: { id },
|
||||
data: { is_active: false },
|
||||
// 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: "deactivate",
|
||||
action: "delete",
|
||||
entityType: "warehouse_item",
|
||||
entityId: id,
|
||||
description: `Deaktivována položka ${existing.name}`,
|
||||
oldValues: { is_active: existing.is_active },
|
||||
newValues: { is_active: false },
|
||||
description: `Smazána položka ${existing.name}`,
|
||||
oldValues: { name: existing.name, item_number: existing.item_number },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, "Položka byla deaktivována");
|
||||
return success(reply, null, 200, "Položka byla smazána");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -908,7 +938,21 @@ export default async function warehouseRoutes(
|
||||
orderBy: { received_at: "asc" },
|
||||
});
|
||||
|
||||
return success(reply, batches);
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -967,7 +1011,7 @@ export default async function warehouseRoutes(
|
||||
received_by_user: {
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
},
|
||||
_count: { select: { lines: true } },
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
}),
|
||||
prisma.sklad_receipts.count({ where: filter }),
|
||||
@@ -981,7 +1025,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /receipts/:id — detail with supplier, received_by_user, lines, attachments
|
||||
// GET /receipts/:id — detail with supplier, received_by_user, items, attachments
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/receipts/:id",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
@@ -996,7 +1040,7 @@ export default async function warehouseRoutes(
|
||||
received_by_user: {
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
},
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
location: { select: { id: true, code: true, name: true } },
|
||||
@@ -1011,7 +1055,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// POST /receipts — create DRAFT with lines
|
||||
// POST /receipts — create DRAFT with items
|
||||
fastify.post(
|
||||
"/receipts",
|
||||
{ preHandler: requirePermission("warehouse.operate") },
|
||||
@@ -1031,19 +1075,19 @@ export default async function warehouseRoutes(
|
||||
notes: body.notes ?? null,
|
||||
status: "DRAFT",
|
||||
received_by: authUserId,
|
||||
lines: {
|
||||
create: body.lines.map((line) => ({
|
||||
item_id: line.item_id,
|
||||
quantity: line.quantity,
|
||||
unit_price: line.unit_price,
|
||||
location_id: line.location_id ?? null,
|
||||
notes: line.notes ?? null,
|
||||
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 } },
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
},
|
||||
@@ -1061,7 +1105,7 @@ export default async function warehouseRoutes(
|
||||
newValues: {
|
||||
supplier_id: receipt.supplier_id,
|
||||
status: receipt.status,
|
||||
line_count: body.lines.length,
|
||||
item_count: body.items.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1069,7 +1113,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /receipts/:id — update header + replace lines (DRAFT only)
|
||||
// PUT /receipts/:id — update header + replace items (DRAFT only)
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/receipts/:id",
|
||||
{ preHandler: requirePermission("warehouse.operate") },
|
||||
@@ -1083,7 +1127,7 @@ export default async function warehouseRoutes(
|
||||
|
||||
const existing = await prisma.sklad_receipts.findUnique({
|
||||
where: { id },
|
||||
include: { lines: true },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!existing) return error(reply, "Příjem nenalezen", 404);
|
||||
if (existing.status !== "DRAFT") {
|
||||
@@ -1102,19 +1146,19 @@ export default async function warehouseRoutes(
|
||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||
updateData.modified_at = new Date();
|
||||
|
||||
// Replace lines if provided
|
||||
if (body.lines) {
|
||||
// Replace items if provided
|
||||
if (body.items) {
|
||||
await prisma.sklad_receipt_lines.deleteMany({
|
||||
where: { receipt_id: id },
|
||||
});
|
||||
await prisma.sklad_receipt_lines.createMany({
|
||||
data: body.lines.map((line) => ({
|
||||
data: body.items.map((item) => ({
|
||||
receipt_id: id,
|
||||
item_id: line.item_id,
|
||||
quantity: line.quantity,
|
||||
unit_price: line.unit_price,
|
||||
location_id: line.location_id ?? null,
|
||||
notes: line.notes ?? null,
|
||||
item_id: item.item_id,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
location_id: item.location_id ?? null,
|
||||
notes: item.notes ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1124,7 +1168,7 @@ export default async function warehouseRoutes(
|
||||
data: updateData,
|
||||
include: {
|
||||
supplier: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
location: { select: { id: true, code: true, name: true } },
|
||||
@@ -1144,13 +1188,13 @@ export default async function warehouseRoutes(
|
||||
supplier_id: existing.supplier_id,
|
||||
delivery_note_number: existing.delivery_note_number,
|
||||
notes: existing.notes,
|
||||
line_count: existing.lines.length,
|
||||
item_count: existing.items.length,
|
||||
},
|
||||
newValues: {
|
||||
supplier_id: updated.supplier_id,
|
||||
delivery_note_number: updated.delivery_note_number,
|
||||
notes: updated.notes,
|
||||
line_count: updated.lines.length,
|
||||
item_count: updated.items.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1362,11 +1406,11 @@ export default async function warehouseRoutes(
|
||||
take: limit,
|
||||
orderBy: { id: "desc" },
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
issued_by_user: {
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
},
|
||||
_count: { select: { lines: true } },
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
}),
|
||||
prisma.sklad_issues.count({ where: filter }),
|
||||
@@ -1376,7 +1420,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /issues/:id — detail with project, issued_by_user, lines
|
||||
// GET /issues/:id — detail with project, issued_by_user, items
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/issues/:id",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
@@ -1387,11 +1431,11 @@ export default async function warehouseRoutes(
|
||||
const issue = await prisma.sklad_issues.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
issued_by_user: {
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
},
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
batch: {
|
||||
@@ -1416,7 +1460,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// POST /issues — create DRAFT with lines, auto-FIFO for lines without batch_id
|
||||
// POST /issues — create DRAFT with items, auto-FIFO for items without batch_id
|
||||
fastify.post(
|
||||
"/issues",
|
||||
{ preHandler: requirePermission("warehouse.operate") },
|
||||
@@ -1426,8 +1470,8 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
// Resolve FIFO batches for lines without batch_id
|
||||
const resolvedLines: Array<{
|
||||
// Resolve FIFO batches for items without batch_id
|
||||
const resolvedItems: Array<{
|
||||
item_id: number;
|
||||
batch_id: number;
|
||||
quantity: number;
|
||||
@@ -1436,31 +1480,31 @@ export default async function warehouseRoutes(
|
||||
notes: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const line of body.lines) {
|
||||
if (line.batch_id != null) {
|
||||
resolvedLines.push({
|
||||
item_id: line.item_id,
|
||||
batch_id: line.batch_id,
|
||||
quantity: line.quantity,
|
||||
location_id: line.location_id ?? null,
|
||||
reservation_id: line.reservation_id ?? null,
|
||||
notes: line.notes ?? 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(
|
||||
line.item_id,
|
||||
line.quantity,
|
||||
item.item_id,
|
||||
item.quantity,
|
||||
);
|
||||
for (const fb of fifoBatches) {
|
||||
resolvedLines.push({
|
||||
item_id: line.item_id,
|
||||
resolvedItems.push({
|
||||
item_id: item.item_id,
|
||||
batch_id: fb.batchId,
|
||||
quantity: fb.qty,
|
||||
location_id: line.location_id ?? null,
|
||||
reservation_id: line.reservation_id ?? null,
|
||||
notes: line.notes ?? null,
|
||||
location_id: item.location_id ?? null,
|
||||
reservation_id: item.reservation_id ?? null,
|
||||
notes: item.notes ?? null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1473,19 +1517,32 @@ export default async function warehouseRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
lines: {
|
||||
create: resolvedLines,
|
||||
items: {
|
||||
create: resolvedItems,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
},
|
||||
@@ -1503,7 +1560,7 @@ export default async function warehouseRoutes(
|
||||
newValues: {
|
||||
project_id: issue.project_id,
|
||||
status: issue.status,
|
||||
line_count: resolvedLines.length,
|
||||
item_count: resolvedItems.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1511,7 +1568,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /issues/:id — update (DRAFT only), auto-FIFO for lines without batch_id
|
||||
// PUT /issues/:id — update (DRAFT only), auto-FIFO for items without batch_id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/issues/:id",
|
||||
{ preHandler: requirePermission("warehouse.operate") },
|
||||
@@ -1525,7 +1582,7 @@ export default async function warehouseRoutes(
|
||||
|
||||
const existing = await prisma.sklad_issues.findUnique({
|
||||
where: { id },
|
||||
include: { lines: true },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!existing) return error(reply, "Výdej nenalezen", 404);
|
||||
if (existing.status !== "DRAFT") {
|
||||
@@ -1538,10 +1595,10 @@ export default async function warehouseRoutes(
|
||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||
updateData.modified_at = new Date();
|
||||
|
||||
// Replace lines if provided
|
||||
if (body.lines) {
|
||||
// Resolve FIFO batches for lines without batch_id
|
||||
const resolvedLines: Array<{
|
||||
// Replace items if provided
|
||||
if (body.items) {
|
||||
// Resolve FIFO batches for items without batch_id
|
||||
const resolvedItems: Array<{
|
||||
item_id: number;
|
||||
batch_id: number;
|
||||
quantity: number;
|
||||
@@ -1550,30 +1607,30 @@ export default async function warehouseRoutes(
|
||||
notes: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const line of body.lines) {
|
||||
if (line.batch_id != null) {
|
||||
resolvedLines.push({
|
||||
item_id: line.item_id,
|
||||
batch_id: line.batch_id,
|
||||
quantity: line.quantity,
|
||||
location_id: line.location_id ?? null,
|
||||
reservation_id: line.reservation_id ?? null,
|
||||
notes: line.notes ?? 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 {
|
||||
try {
|
||||
const fifoBatches = await selectFifoBatches(
|
||||
line.item_id,
|
||||
line.quantity,
|
||||
item.item_id,
|
||||
item.quantity,
|
||||
);
|
||||
for (const fb of fifoBatches) {
|
||||
resolvedLines.push({
|
||||
item_id: line.item_id,
|
||||
resolvedItems.push({
|
||||
item_id: item.item_id,
|
||||
batch_id: fb.batchId,
|
||||
quantity: fb.qty,
|
||||
location_id: line.location_id ?? null,
|
||||
reservation_id: line.reservation_id ?? null,
|
||||
notes: line.notes ?? null,
|
||||
location_id: item.location_id ?? null,
|
||||
reservation_id: item.reservation_id ?? null,
|
||||
notes: item.notes ?? null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1586,18 +1643,32 @@ export default async function warehouseRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.sklad_issue_lines.deleteMany({
|
||||
where: { issue_id: id },
|
||||
});
|
||||
await prisma.sklad_issue_lines.createMany({
|
||||
data: resolvedLines.map((line) => ({
|
||||
data: resolvedItems.map((item) => ({
|
||||
issue_id: id,
|
||||
item_id: line.item_id,
|
||||
batch_id: line.batch_id,
|
||||
quantity: line.quantity,
|
||||
location_id: line.location_id,
|
||||
reservation_id: line.reservation_id,
|
||||
notes: line.notes,
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1606,8 +1677,8 @@ export default async function warehouseRoutes(
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
},
|
||||
@@ -1625,12 +1696,12 @@ export default async function warehouseRoutes(
|
||||
oldValues: {
|
||||
project_id: existing.project_id,
|
||||
notes: existing.notes,
|
||||
line_count: existing.lines.length,
|
||||
item_count: existing.items.length,
|
||||
},
|
||||
newValues: {
|
||||
project_id: updated.project_id,
|
||||
notes: updated.notes,
|
||||
line_count: updated.lines.length,
|
||||
item_count: updated.items.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1730,7 +1801,7 @@ export default async function warehouseRoutes(
|
||||
orderBy: { id: "desc" },
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
project: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
reserved_by_user: {
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
},
|
||||
@@ -1837,7 +1908,7 @@ export default async function warehouseRoutes(
|
||||
take: limit,
|
||||
orderBy: { id: "desc" },
|
||||
include: {
|
||||
_count: { select: { lines: true } },
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
}),
|
||||
prisma.sklad_inventory_sessions.count({ where }),
|
||||
@@ -1851,7 +1922,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /inventory-sessions/:id — detail with lines
|
||||
// GET /inventory-sessions/:id — detail with items
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/inventory-sessions/:id",
|
||||
{ preHandler: requirePermission("warehouse.inventory") },
|
||||
@@ -1862,7 +1933,7 @@ export default async function warehouseRoutes(
|
||||
const session = await prisma.sklad_inventory_sessions.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
location: { select: { id: true, code: true, name: true } },
|
||||
@@ -1885,36 +1956,36 @@ export default async function warehouseRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
// Build lines with auto-filled system_qty and calculated difference
|
||||
const linesData = await Promise.all(
|
||||
body.lines.map(async (line) => {
|
||||
// Build items with auto-filled system_qty and calculated difference
|
||||
const itemsData = await Promise.all(
|
||||
body.items.map(async (item) => {
|
||||
let systemQty = 0;
|
||||
|
||||
if (line.location_id != null) {
|
||||
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: line.item_id,
|
||||
location_id: line.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(line.item_id);
|
||||
systemQty = await getItemTotalStock(item.item_id);
|
||||
}
|
||||
|
||||
const difference = Number(line.actual_qty) - systemQty;
|
||||
const difference = Number(item.actual_qty) - systemQty;
|
||||
|
||||
return {
|
||||
item_id: line.item_id,
|
||||
location_id: line.location_id ?? null,
|
||||
item_id: item.item_id,
|
||||
location_id: item.location_id ?? null,
|
||||
system_qty: systemQty,
|
||||
actual_qty: Number(line.actual_qty),
|
||||
actual_qty: Number(item.actual_qty),
|
||||
difference,
|
||||
notes: line.notes ?? null,
|
||||
notes: item.notes ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1923,12 +1994,12 @@ export default async function warehouseRoutes(
|
||||
data: {
|
||||
notes: body.notes ?? null,
|
||||
status: "DRAFT",
|
||||
lines: {
|
||||
create: linesData,
|
||||
items: {
|
||||
create: itemsData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
location: { select: { id: true, code: true, name: true } },
|
||||
@@ -1946,7 +2017,7 @@ export default async function warehouseRoutes(
|
||||
description: `Vytvořena inventarizace #${session.id}`,
|
||||
newValues: {
|
||||
status: session.status,
|
||||
line_count: linesData.length,
|
||||
item_count: itemsData.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1954,7 +2025,7 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /inventory-sessions/:id — update lines (DRAFT only), recalculate system_qty and difference
|
||||
// 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") },
|
||||
@@ -1983,35 +2054,35 @@ export default async function warehouseRoutes(
|
||||
};
|
||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||
|
||||
// Replace lines if provided, recalculating system_qty and difference
|
||||
if (body.lines) {
|
||||
const linesData = await Promise.all(
|
||||
body.lines.map(async (line) => {
|
||||
// Replace items if provided, recalculating system_qty and difference
|
||||
if (body.items) {
|
||||
const itemsData = await Promise.all(
|
||||
body.items.map(async (item) => {
|
||||
let systemQty = 0;
|
||||
|
||||
if (line.location_id != null) {
|
||||
if (item.location_id != null) {
|
||||
const loc = await prisma.sklad_item_locations.findUnique({
|
||||
where: {
|
||||
item_id_location_id: {
|
||||
item_id: line.item_id,
|
||||
location_id: line.location_id,
|
||||
item_id: item.item_id,
|
||||
location_id: item.location_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
systemQty = loc ? Number(loc.quantity) : 0;
|
||||
} else {
|
||||
systemQty = await getItemTotalStock(line.item_id);
|
||||
systemQty = await getItemTotalStock(item.item_id);
|
||||
}
|
||||
|
||||
const difference = Number(line.actual_qty) - systemQty;
|
||||
const difference = Number(item.actual_qty) - systemQty;
|
||||
|
||||
return {
|
||||
item_id: line.item_id,
|
||||
location_id: line.location_id ?? null,
|
||||
item_id: item.item_id,
|
||||
location_id: item.location_id ?? null,
|
||||
system_qty: systemQty,
|
||||
actual_qty: Number(line.actual_qty),
|
||||
actual_qty: Number(item.actual_qty),
|
||||
difference,
|
||||
notes: line.notes ?? null,
|
||||
notes: item.notes ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2020,9 +2091,9 @@ export default async function warehouseRoutes(
|
||||
where: { session_id: id },
|
||||
});
|
||||
await prisma.sklad_inventory_lines.createMany({
|
||||
data: linesData.map((line) => ({
|
||||
data: itemsData.map((item) => ({
|
||||
session_id: id,
|
||||
...line,
|
||||
...item,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -2031,7 +2102,7 @@ export default async function warehouseRoutes(
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true, unit: true } },
|
||||
location: { select: { id: true, code: true, name: true } },
|
||||
@@ -2160,8 +2231,8 @@ export default async function warehouseRoutes(
|
||||
const issues = await prisma.sklad_issues.findMany({
|
||||
where: { AND: issueWhere },
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true } },
|
||||
batch: { select: { unit_price: true } },
|
||||
@@ -2184,10 +2255,10 @@ export default async function warehouseRoutes(
|
||||
>();
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const line of issue.lines) {
|
||||
const key = `${line.item_id}-${issue.project_id}`;
|
||||
const qty = Number(line.quantity);
|
||||
const price = Number(line.batch?.unit_price ?? 0);
|
||||
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);
|
||||
@@ -2196,8 +2267,8 @@ export default async function warehouseRoutes(
|
||||
existing.total_value += value;
|
||||
} else {
|
||||
consumptionMap.set(key, {
|
||||
item_id: line.item_id,
|
||||
item_name: line.item.name,
|
||||
item_id: item.item_id,
|
||||
item_name: item.item.name,
|
||||
project_id: issue.project_id,
|
||||
project_name: issue.project.name,
|
||||
total_qty: qty,
|
||||
@@ -2237,7 +2308,7 @@ export default async function warehouseRoutes(
|
||||
where: { AND: receiptWhere },
|
||||
include: {
|
||||
supplier: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true } },
|
||||
},
|
||||
@@ -2248,8 +2319,8 @@ export default async function warehouseRoutes(
|
||||
prisma.sklad_issues.findMany({
|
||||
where: { AND: issueWhere },
|
||||
include: {
|
||||
project: { select: { id: true, name: true } },
|
||||
lines: {
|
||||
project: { select: { id: true, name: true, project_number: true } },
|
||||
items: {
|
||||
include: {
|
||||
item: { select: { id: true, name: true } },
|
||||
batch: { select: { unit_price: true } },
|
||||
@@ -2274,32 +2345,32 @@ export default async function warehouseRoutes(
|
||||
}> = [];
|
||||
|
||||
for (const receipt of receipts) {
|
||||
for (const line of receipt.lines) {
|
||||
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: line.item_id,
|
||||
item_name: line.item.name,
|
||||
quantity: Number(line.quantity),
|
||||
unit_price: Number(line.unit_price),
|
||||
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 line of issue.lines) {
|
||||
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: line.item_id,
|
||||
item_name: line.item.name,
|
||||
quantity: Number(line.quantity),
|
||||
unit_price: Number(line.batch?.unit_price ?? 0),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user