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:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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 } },

View File

@@ -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);
}
}
}

View File

@@ -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");
},

View File

@@ -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!);

View File

@@ -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,
});
}