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

@@ -27,13 +27,7 @@ type AttendanceWithRelations = Prisma.attendanceGetPayload<{
};
}>;
const VALID_LEAVE_TYPES = [
"work",
"vacation",
"sick",
"holiday",
"unpaid",
] as const;
const VALID_LEAVE_TYPES = ["work", "vacation", "sick", "unpaid"] as const;
const MONTH_NAMES = [
"Leden",
@@ -77,6 +71,20 @@ const roundDown = (d: Date, minutes: number) => {
return new Date(Math.floor(d.getTime() / ms) * ms);
};
/**
* Lock a user row inside an existing transaction. Used to serialize punch
* actions and project switches for a single user: two parallel taps of the
* "Příchod" button would otherwise both see "no open shift" and both create
* one, leaving the user with two parallel open shifts. With the lock, the
* second call waits, then re-reads and bails.
*/
async function lockUserRow(
tx: Prisma.TransactionClient,
userId: number,
): Promise<void> {
await tx.$executeRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
}
// ── Interfaces ───────────────────────────────────────────────────────
export interface ListAttendanceParams {
@@ -225,7 +233,6 @@ export async function getStatus(userId: number) {
let workedHours = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const rec of monthRecords) {
@@ -234,8 +241,9 @@ export async function getStatus(userId: number) {
const hrs = Number(rec.leave_hours) || 8;
if (lt === "vacation") vacationHours += hrs;
else if (lt === "sick") sickHours += hrs;
else if (lt === "holiday") holidayHours += hrs;
else if (lt === "unpaid") unpaidHours += hrs;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-derived from Czech public holidays. Old rows are skipped.
continue;
}
if (rec.arrival_time && rec.departure_time) {
@@ -266,7 +274,6 @@ export async function getStatus(userId: number) {
leave_hours: leaveHours,
vacation_hours: vacationHours,
sick_hours: sickHours,
holiday_hours: holidayHours,
unpaid_hours: unpaidHours,
};
@@ -409,55 +416,62 @@ export async function updateAddress(
}
export async function switchProject(userId: number, projectId: number | null) {
const ongoing = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
if (!ongoing) return { error: "Nemáte aktivní směnu." };
return prisma.$transaction(async (tx) => {
// Lock the user row so two parallel "switch project" clicks (or one
// interleaved with a departure punch) cannot both create overlapping
// project logs for the same open shift.
await lockUserRow(tx, userId);
const now = new Date();
// End active project logs, ensuring ended_at is never before started_at
// (can happen when arrival_time was rounded up and now is still earlier)
const activeLogs = await prisma.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null },
});
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > now ? log.started_at : now;
await prisma.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
});
}
if (projectId) {
const existingLogs = await prisma.attendance_project_logs.count({
where: { attendance_id: ongoing.id },
});
const isFirstProject = existingLogs === 0;
let startedAt = isFirstProject ? ongoing.arrival_time! : now;
if (startedAt > now) startedAt = now;
await prisma.attendance_project_logs.create({
data: {
attendance_id: ongoing.id,
project_id: projectId,
started_at: startedAt,
ended_at: null,
const ongoing = await tx.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
}
if (!ongoing) return { error: "Nemáte aktivní směnu." };
await prisma.attendance.update({
where: { id: ongoing.id },
data: { project_id: projectId },
const now = new Date();
// End active project logs, ensuring ended_at is never before started_at
// (can happen when arrival_time was rounded up and now is still earlier)
const activeLogs = await tx.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null },
});
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > now ? log.started_at : now;
await tx.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
});
}
if (projectId) {
const existingLogs = await tx.attendance_project_logs.count({
where: { attendance_id: ongoing.id },
});
const isFirstProject = existingLogs === 0;
let startedAt = isFirstProject ? ongoing.arrival_time! : now;
if (startedAt > now) startedAt = now;
await tx.attendance_project_logs.create({
data: {
attendance_id: ongoing.id,
project_id: projectId,
started_at: startedAt,
ended_at: null,
},
});
}
await tx.attendance.update({
where: { id: ongoing.id },
data: { project_id: projectId },
});
return { success: true };
});
return { success: true };
}
export async function getBalances(year: number) {
@@ -578,8 +592,6 @@ export async function getWorkfund(year: number) {
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
if (lt === "work") {
@@ -595,14 +607,13 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") {
holidayHours += Number(rec.leave_hours) || 8;
}
// "holiday" is auto-derived from Czech public holidays.
}
const userFund = fundToDate;
const workedRound = Math.round(worked * 10) / 10;
const leaveHours = vacationHours + sickHours + holidayHours;
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 10) / 10;
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
@@ -868,7 +879,6 @@ export async function getPrintData(
records: [],
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
fund: fundHours,
worked_hours: 0,
@@ -899,9 +909,8 @@ export async function getPrintData(
const hrs = Number(rec.leave_hours) || 8;
if (lt === "vacation") (userTotals[uid].vacation_hours as number) += hrs;
else if (lt === "sick") (userTotals[uid].sick_hours as number) += hrs;
else if (lt === "holiday")
(userTotals[uid].holiday_hours as number) += hrs;
else if (lt === "unpaid") (userTotals[uid].unpaid_hours as number) += hrs;
// "holiday" is auto-derived from Czech public holidays.
} else if (rec.arrival_time && rec.departure_time) {
const mins =
calcWorkedHours(
@@ -919,10 +928,7 @@ export async function getPrintData(
const workedH = Math.round(((ut.minutes as number) / 60) * 10) / 10;
ut.worked_hours = workedH;
const covered =
workedH +
(ut.vacation_hours as number) +
(ut.sick_hours as number) +
(ut.holiday_hours as number);
workedH + (ut.vacation_hours as number) + (ut.sick_hours as number);
ut.covered = Math.round(covered * 10) / 10;
ut.missing = Math.max(
0,
@@ -1153,6 +1159,15 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
if (dow === 0 || dow === 6) continue;
// Holidays are auto-paid via the fond in mzda math — skip them in
// the bulk-create work-shift fill. The user shouldn't end up with
// an explicit "holiday" record; the Svátek summary line and
// attendance_hours are derived from Czech public holidays instead.
if (isHoliday(dateStr)) {
skipped++;
continue;
}
if (existingSet.has(`${userId}:${dateStr}`)) {
skipped++;
continue;
@@ -1160,19 +1175,6 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
if (isHoliday(dateStr)) {
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: "holiday",
leave_hours: 8,
},
});
inserted++;
continue;
}
await tx.attendance.create({
data: {
user_id: userId,
@@ -1321,146 +1323,167 @@ export async function punchAction(userId: number, data: PunchData) {
const gpsAddr = data.address ?? null;
if (action === "arrival") {
const ongoing = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
if (ongoing) {
return { error: "Máte již aktivní směnu. Nejdříve zaznamenejte odchod." };
}
return prisma.$transaction(async (tx) => {
// Lock the user row first so a double-tap of "Příchod" (parallel POSTs
// to /api/attendance/punch) cannot both see "no open shift" and both
// create one. The second call waits here, then re-reads and bails.
await lockUserRow(tx, userId);
const arrivalTime = roundUp(now, settings.clock_rounding_minutes);
const record = await prisma.attendance.create({
data: {
user_id: userId,
shift_date: today,
arrival_time: arrivalTime,
arrival_lat: gpsLat,
arrival_lng: gpsLng,
arrival_accuracy: gpsAcc,
arrival_address: gpsAddr,
leave_type: "work",
},
});
return {
id: record.id,
status: 201,
message: "Příchod zaznamenán",
auditAction: "create" as const,
auditDescription: "Zaznamenán příchod",
};
} else if (action === "departure") {
const ongoing = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
if (!ongoing) {
return { error: "Nemáte aktivní směnu." };
}
const departureTime = roundDown(now, settings.clock_rounding_minutes);
const updateData: Record<string, unknown> = {
departure_time: departureTime,
departure_lat: gpsLat,
departure_lng: gpsLng,
departure_accuracy: gpsAcc,
departure_address: gpsAddr,
};
if (!ongoing.break_start && ongoing.arrival_time) {
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
const shiftHours = shiftMs / (1000 * 60 * 60);
if (shiftHours > settings.break_threshold_hours) {
const midpoint = roundDown(new Date(ongoing.arrival_time.getTime() + shiftMs / 2), settings.clock_rounding_minutes);
const breakMins =
shiftHours > settings.break_threshold_hours * 2
? settings.break_duration_long
: settings.break_duration_short;
updateData.break_start = midpoint;
updateData.break_end = new Date(
midpoint.getTime() + breakMins * 60 * 1000,
);
}
}
await prisma.attendance.update({
where: { id: ongoing.id },
data: updateData,
});
// End active project logs, ensuring ended_at is never before started_at
const activeLogs = await prisma.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null },
});
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > departureTime
? log.started_at
: departureTime;
await prisma.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
const ongoing = await tx.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
}
if (ongoing) {
return {
error: "Máte již aktivní směnu. Nejdříve zaznamenejte odchod.",
};
}
return {
id: ongoing.id,
status: 200,
message: "Odchod zaznamenán",
auditAction: "update" as const,
auditDescription: "Zaznamenán odchod",
};
const arrivalTime = roundUp(now, settings.clock_rounding_minutes);
const record = await tx.attendance.create({
data: {
user_id: userId,
shift_date: today,
arrival_time: arrivalTime,
arrival_lat: gpsLat,
arrival_lng: gpsLng,
arrival_accuracy: gpsAcc,
arrival_address: gpsAddr,
leave_type: "work",
},
});
return {
id: record.id,
status: 201,
message: "Příchod zaznamenán",
auditAction: "create" as const,
auditDescription: "Zaznamenán příchod",
};
});
} else if (action === "departure") {
return prisma.$transaction(async (tx) => {
await lockUserRow(tx, userId);
const ongoing = await tx.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
if (!ongoing) {
return { error: "Nemáte aktivní směnu." };
}
const departureTime = roundDown(now, settings.clock_rounding_minutes);
const updateData: Record<string, unknown> = {
departure_time: departureTime,
departure_lat: gpsLat,
departure_lng: gpsLng,
departure_accuracy: gpsAcc,
departure_address: gpsAddr,
};
if (!ongoing.break_start && ongoing.arrival_time) {
const shiftMs =
departureTime.getTime() - ongoing.arrival_time.getTime();
const shiftHours = shiftMs / (1000 * 60 * 60);
if (shiftHours > settings.break_threshold_hours) {
const midpoint = roundDown(
new Date(ongoing.arrival_time.getTime() + shiftMs / 2),
settings.clock_rounding_minutes,
);
const breakMins =
shiftHours > settings.break_threshold_hours * 2
? settings.break_duration_long
: settings.break_duration_short;
updateData.break_start = midpoint;
updateData.break_end = new Date(
midpoint.getTime() + breakMins * 60 * 1000,
);
}
}
await tx.attendance.update({
where: { id: ongoing.id },
data: updateData,
});
// End active project logs, ensuring ended_at is never before started_at
const activeLogs = await tx.attendance_project_logs.findMany({
where: { attendance_id: ongoing.id, ended_at: null },
});
for (const log of activeLogs) {
const endedAt =
log.started_at && log.started_at > departureTime
? log.started_at
: departureTime;
await tx.attendance_project_logs.update({
where: { id: log.id },
data: { ended_at: endedAt },
});
}
return {
id: ongoing.id,
status: 200,
message: "Odchod zaznamenán",
auditAction: "update" as const,
auditDescription: "Zaznamenán odchod",
};
});
} else if (action === "break_start") {
const ongoing = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
break_start: null,
},
orderBy: { created_at: "desc" },
return prisma.$transaction(async (tx) => {
await lockUserRow(tx, userId);
const ongoing = await tx.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
break_start: null,
},
orderBy: { created_at: "desc" },
});
if (!ongoing) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
let msRound = settings.clock_rounding_minutes * 60 * 1000;
if (
!settings.clock_rounding_minutes ||
settings.clock_rounding_minutes <= 0
) {
msRound = 0;
}
const breakStart =
msRound > 0
? new Date(Math.round(now.getTime() / msRound) * msRound)
: now;
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);
await tx.attendance.update({
where: { id: ongoing.id },
data: { break_start: breakStart, break_end: breakEnd },
});
return {
id: ongoing.id,
status: 200,
message: "Přestávka zaznamenána",
auditAction: "update" as const,
auditDescription: "Zaznamenána přestávka",
};
});
if (!ongoing) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
let msRound = settings.clock_rounding_minutes * 60 * 1000;
if (
!settings.clock_rounding_minutes ||
settings.clock_rounding_minutes <= 0
) {
msRound = 0;
}
const breakStart =
msRound > 0
? new Date(Math.round(now.getTime() / msRound) * msRound)
: now;
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);
await prisma.attendance.update({
where: { id: ongoing.id },
data: { break_start: breakStart, break_end: breakEnd },
});
return {
id: ongoing.id,
status: 200,
message: "Přestávka zaznamenána",
auditAction: "update" as const,
auditDescription: "Zaznamenána přestávka",
};
}
return { error: "Neplatná akce" };
@@ -1483,17 +1506,60 @@ export async function createAttendance(
shiftDate.getDate() + 1,
);
const duplicate = await prisma.attendance.findFirst({
// Multiple work shifts per day are allowed — only block when the new
// shift's time range overlaps an existing one. Two leave-type records
// on the same day still make no sense and are blocked.
const dayRecords = await prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: startOfDay, lt: endOfDay },
},
select: {
id: true,
leave_type: true,
arrival_time: true,
departure_time: true,
},
});
if (duplicate) {
return {
error: "Pro zvolené datumy již existují záznamy docházky",
status: 400,
};
const isLeave = (data.leave_type as string) !== "work";
if (isLeave) {
const otherLeave = dayRecords.find(
(r) => (r.leave_type as string) !== "work",
);
if (otherLeave) {
return {
error:
"Pro zvolené datum již existuje záznam o nepřítomnosti (dovolená / nemoc / neplacené volno).",
status: 400,
};
}
} else if (data.arrival_time && data.departure_time) {
const newStart = new Date(data.arrival_time).getTime();
const newEnd = new Date(data.departure_time).getTime();
for (const r of dayRecords) {
if ((r.leave_type as string) !== "work") continue;
if (!r.arrival_time || !r.departure_time) continue;
const eStart = new Date(r.arrival_time).getTime();
const eEnd = new Date(r.departure_time).getTime();
// Half-open overlap: [newStart, newEnd) ∩ [eStart, eEnd) ≠ ∅
if (newStart < eEnd && eStart < newEnd) {
return {
error: "Pro zvolený čas se směna překrývá s jinou směnou tentýž den.",
status: 400,
};
}
}
} else {
// Work shift with missing arrival/departure — block any other record
// on the same day so the user doesn't end up with two ambiguous rows.
if (dayRecords.length > 0) {
return {
error:
"Pro zvolené datum již existuje záznam docházky. Doplňte příchod a odchod, nebo smažte/upravte stávající záznam.",
status: 400,
};
}
}
const record = await prisma.attendance.create({

View File

@@ -26,6 +26,18 @@ function escapeHtml(str: string): string {
.replace(/"/g, "&quot;");
}
// Strip characters that could break out of the email Subject: header
// (CRLF, NUL) or otherwise pollute the envelope. Without this, a user
// can set their first_name to "attacker\nBcc: victim@x.com" via the
// profile editor and pivot into sending mail as noreply@boha.cz.
function sanitizeHeaderValue(str: string): string {
// eslint-disable-next-line no-control-regex
return str
.replace(/[\r\n\t\0]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
interface LeaveRequestData {
leave_type: string;
date_from: string;
@@ -48,7 +60,7 @@ export async function notifyNewLeaveRequest(
const dateTo = formatDate(request.date_to);
const notes = request.notes || "";
const subject = `Nová žádost o nepřítomnost - ${employeeName} (${leaveType})`;
const subject = `Nová žádost o nepřítomnost - ${sanitizeHeaderValue(employeeName)} (${sanitizeHeaderValue(leaveType)})`;
const appUrl = config.appUrl || "";
const approvalLink = appUrl

View File

@@ -16,7 +16,7 @@ const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
* Apply a numbering pattern template.
* Placeholders: {YYYY}, {YY}, {PREFIX}, {CODE}, {N+} (padding = count of N's)
*/
function applyPattern(
export function applyPattern(
pattern: string,
vars: { year: number; prefix: string; code: string; seq: number },
): string {
@@ -52,7 +52,7 @@ async function getSettings() {
* If `tx` is provided, the increment happens inside the caller's transaction
* (no nested transaction is created).
*/
async function getNextSequence(
export async function getNextSequence(
type: string,
year: number,
tx?: TxClient,

View File

@@ -518,6 +518,46 @@ export async function deleteOrder(id: number) {
select: { id: true, created_at: true },
});
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
// sklad_reservations, attendance_project_logs all use project_id as a
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
// rather than letting Prisma throw P2003 -> 500.
//
// Only ACTIVE records count: a CANCELLED issue/restored batch or
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
// restored the batch qty; cancelReservation() zeroes remaining_qty).
// Attendance project logs are time-records — they always block, since
// deleting the project would orphan the time record. The user must
// re-assign the attendance log to a different project first.
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
await Promise.all([
prisma.sklad_issues.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.sklad_reservations.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.attendance_project_logs.count({
where: { project_id: { in: projectIds } },
}),
]);
if (activeIssuesCount + activeReservationsCount + attendanceLogCount > 0) {
return {
error:
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
status: 409,
} as const;
}
}
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({

View File

@@ -1,66 +1,103 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { applyPattern, getNextSequence } from "./numbering.service";
type TxClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;
// ---------------------------------------------------------------------------
// Concurrency helpers
// ---------------------------------------------------------------------------
/**
* Lock a single parent row (receipt / issue / inventory session / item) for the
* remainder of the current transaction. Two concurrent calls land in serial
* order: the second one waits for the first to commit and then re-reads the
* row, seeing the updated status.
*
* The row is locked with SELECT ... FOR UPDATE — releasing on commit/rollback.
* Always call this at the top of a `confirm*` / `cancel*` transaction,
* BEFORE the read-then-write status check, so the check-then-act window is
* closed.
*
* If the row does not exist, the caller (e.g. the `findUnique` that follows)
* will see `null` and return a 404 — the FOR UPDATE returns no rows in that
* case (no lock taken, but also no work to do).
*/
async function lockParentRow(
tx: TxClient,
table:
| "sklad_receipts"
| "sklad_issues"
| "sklad_inventory_sessions"
| "sklad_items",
id: number,
): Promise<void> {
// Table name is whitelisted above; no user input. `Prisma.raw` lets us splice
// the identifier into the query while `${id}` parameterizes the value.
await tx.$executeRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
}
// ---------------------------------------------------------------------------
// Number sequence helpers for warehouse documents
// ---------------------------------------------------------------------------
const WAREHOUSE_NUMBER_PREFIXES: Record<string, string> = {
warehouse_receipt: "PRI",
warehouse_issue: "VYD",
warehouse_inventory: "INV",
};
// Default prefixes and pattern (backward compatible with existing hardcoded format)
const DEFAULT_RECEIPT_PREFIX = "PRI";
const DEFAULT_ISSUE_PREFIX = "VYD";
const DEFAULT_INVENTORY_PREFIX = "INV";
const DEFAULT_WAREHOUSE_PATTERN = "{PREFIX}-{YYYY}-{NNN}";
/**
* Atomically get and increment the next sequence number for a warehouse doc type.
* Uses SELECT ... FOR UPDATE to prevent race conditions.
*/
async function getNextSequence(
type: string,
year: number,
tx: TxClient,
): Promise<number> {
const existing = await tx.$queryRaw<
Array<{ id: number; last_number: number }>
>`
SELECT id, last_number FROM number_sequences
WHERE \`type\` = ${type} AND \`year\` = ${year}
FOR UPDATE
`;
if (existing.length === 0) {
await tx.$executeRaw`
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
VALUES (${type}, ${year}, 1)
`;
return 1;
}
const next = existing[0].last_number + 1;
await tx.$executeRaw`
UPDATE number_sequences
SET \`last_number\` = ${next}
WHERE id = ${existing[0].id}
`;
return next;
}
/**
* Generate a formatted warehouse document number like "PRI-2026-001".
* Generate a formatted warehouse document number using configurable pattern.
* Reads prefix and pattern from company_settings, falls back to defaults.
*/
async function generateWarehouseNumber(
type: string,
tx: TxClient,
): Promise<string> {
const year = new Date().getFullYear();
const prefix = WAREHOUSE_NUMBER_PREFIXES[type] || "WH";
const settings = await prisma.company_settings.findFirst({
select: {
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,
},
});
let prefix: string;
let pattern: string;
switch (type) {
case "warehouse_receipt":
prefix = settings?.warehouse_receipt_prefix || DEFAULT_RECEIPT_PREFIX;
pattern =
settings?.warehouse_receipt_number_pattern || DEFAULT_WAREHOUSE_PATTERN;
break;
case "warehouse_issue":
prefix = settings?.warehouse_issue_prefix || DEFAULT_ISSUE_PREFIX;
pattern =
settings?.warehouse_issue_number_pattern || DEFAULT_WAREHOUSE_PATTERN;
break;
case "warehouse_inventory":
prefix = settings?.warehouse_inventory_prefix || DEFAULT_INVENTORY_PREFIX;
pattern =
settings?.warehouse_inventory_number_pattern ||
DEFAULT_WAREHOUSE_PATTERN;
break;
default:
prefix = "WH";
pattern = DEFAULT_WAREHOUSE_PATTERN;
}
const seq = await getNextSequence(type, year, tx);
return `${prefix}-${year}-${String(seq).padStart(3, "0")}`;
return applyPattern(pattern, { year, prefix, code: "", seq });
}
// ---------------------------------------------------------------------------
@@ -88,6 +125,31 @@ export async function getItemAvailableQty(itemId: number): Promise<number> {
return totalStock - reservedQty;
}
/**
* Transactional version of getItemAvailableQty. Use inside a $transaction
* when reading available stock after locking the item row — non-tx reads
* can otherwise miss another transaction's uncommitted writes.
*/
async function getItemAvailableQtyTx(
itemId: number,
tx: TxClient,
): Promise<number> {
const [batchResult, reservationResult] = await Promise.all([
tx.sklad_batches.aggregate({
_sum: { quantity: true },
where: { item_id: itemId, is_consumed: false },
}),
tx.sklad_reservations.aggregate({
_sum: { remaining_qty: true },
where: { item_id: itemId, status: "ACTIVE" },
}),
]);
const totalStock = Number(batchResult._sum.quantity ?? 0);
const reservedQty = Number(reservationResult._sum.remaining_qty ?? 0);
return totalStock - reservedQty;
}
// ---------------------------------------------------------------------------
// FIFO Batch Selection
// ---------------------------------------------------------------------------
@@ -217,9 +279,13 @@ export async function confirmReceipt(
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
// Lock the receipt row first so a concurrent confirm/cancel on the same
// receipt serializes behind us. The status check below is now safe.
await lockParentRow(tx, "sklad_receipts", receiptId);
const receipt = await tx.sklad_receipts.findUnique({
where: { id: receiptId },
include: { lines: true },
include: { items: true },
});
if (!receipt) {
@@ -235,40 +301,40 @@ export async function confirmReceipt(
tx,
);
for (const line of receipt.lines) {
const lineQty = Number(line.quantity);
const linePrice = Number(line.unit_price);
for (const item of receipt.items) {
const itemQty = Number(item.quantity);
const linePrice = Number(item.unit_price);
// Create batch for this receipt line
const batch = await tx.sklad_batches.create({
data: {
item_id: line.item_id,
receipt_line_id: line.id,
quantity: line.quantity,
original_qty: line.quantity,
unit_price: line.unit_price,
item_id: item.item_id,
receipt_line_id: item.id,
quantity: item.quantity,
original_qty: item.quantity,
unit_price: item.unit_price,
received_at: new Date(),
is_consumed: false,
},
});
// Upsert item location if location_id is specified
if (line.location_id != null) {
if (item.location_id != null) {
await tx.sklad_item_locations.upsert({
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,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
quantity: line.quantity,
item_id: item.item_id,
location_id: item.location_id,
quantity: item.quantity,
},
update: {
quantity: {
increment: line.quantity,
increment: item.quantity,
},
},
});
@@ -303,34 +369,38 @@ export async function confirmReceipt(
export async function cancelReceipt(
receiptId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const receipt = await prisma.sklad_receipts.findUnique({
where: { id: receiptId },
include: { lines: { include: { batch: true } } },
});
if (!receipt) {
return { error: "Příjem nebyl nalezen", status: 404 };
}
if (receipt.status === "CANCELLED") {
return { error: "Příjem je již zrušen", status: 400 };
}
if (receipt.status === "DRAFT") {
await prisma.sklad_receipts.update({
where: { id: receiptId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: receiptId } };
}
// CONFIRMED -> CANCELLED (storno)
return prisma.$transaction(async (tx) => {
// Lock the receipt row so a concurrent confirm/cancel on the same receipt
// cannot race with us. The status check is now authoritative.
await lockParentRow(tx, "sklad_receipts", receiptId);
const receipt = await tx.sklad_receipts.findUnique({
where: { id: receiptId },
include: { items: { include: { batch: true } } },
});
if (!receipt) {
return { error: "Příjem nebyl nalezen", status: 404 };
}
if (receipt.status === "CANCELLED") {
return { error: "Příjem je již zrušen", status: 400 };
}
if (receipt.status === "DRAFT") {
await tx.sklad_receipts.update({
where: { id: receiptId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: receiptId } };
}
// CONFIRMED -> CANCELLED (storno)
// Validate no batch has been consumed
for (const line of receipt.lines) {
if (line.batch) {
for (const item of receipt.items) {
if (item.batch) {
const batchData = await tx.sklad_batches.findUnique({
where: { id: line.batch.id },
where: { id: item.batch.id },
});
if (batchData && batchData.is_consumed) {
return {
@@ -352,21 +422,49 @@ export async function cancelReceipt(
}
}
// Guard against FK violation: sklad_batches cannot be deleted if any
// ACTIVE issue still references them. CANCELLED issues are safe to
// ignore — cancelIssue() already restored the batch quantity and reset
// is_consumed=false, so the issue_lines are audit-trail-only and no
// longer represent live consumption. The is_consumed / quantity check
// above doesn't see issue_lines at all, so we need this separate check.
// A DRAFT issue is still treated as blocking — it has chosen the batch
// and may be confirmed later, so deleting the batch underneath it would
// orphan the issue.
const batchIds = receipt.items
.map((item) => item.batch?.id)
.filter((id): id is number => typeof id === "number");
if (batchIds.length > 0) {
const referencingIssueLines = await tx.sklad_issue_lines.count({
where: {
batch_id: { in: batchIds },
issue: { status: { not: "CANCELLED" } },
},
});
if (referencingIssueLines > 0) {
return {
error:
"Příjem nelze zrušit - některé šarže jsou použity v aktivních výdejích. Nejdříve zrušte nebo smažte příslušné výdeje.",
status: 409,
};
}
}
// Reverse item_locations and delete batches
for (const line of receipt.lines) {
if (line.location_id != null) {
for (const item of receipt.items) {
if (item.location_id != null) {
// Decrement location quantity (or delete if it would go to zero/below)
const loc = await tx.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,
},
},
});
if (loc) {
const newQty = Number(loc.quantity) - Number(line.quantity);
const newQty = Number(loc.quantity) - Number(item.quantity);
if (newQty <= 0) {
await tx.sklad_item_locations.delete({
where: { id: loc.id },
@@ -381,9 +479,9 @@ export async function cancelReceipt(
}
// Delete the batch (it's a 1:1 with receipt_line via @unique)
if (line.batch) {
if (item.batch) {
await tx.sklad_batches.delete({
where: { id: line.batch.id },
where: { id: item.batch.id },
});
}
}
@@ -418,9 +516,15 @@ export async function confirmIssue(
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
// Lock the issue row first so a concurrent confirm/cancel on the same
// issue serializes behind us. The status check, batch-quantity validation,
// and decrement below are now safe from a double-confirm race that would
// otherwise drive batch quantities negative.
await lockParentRow(tx, "sklad_issues", issueId);
const issue = await tx.sklad_issues.findUnique({
where: { id: issueId },
include: { lines: true },
include: { items: true },
});
if (!issue) {
@@ -432,45 +536,62 @@ export async function confirmIssue(
}
// Validate each line's batch has enough quantity
for (const line of issue.lines) {
for (const item of issue.items) {
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
where: { id: item.batch_id },
});
if (!batch) {
return {
error: `Šarže ID ${line.batch_id} nebyla nalezena`,
error: `Šarže ID ${item.batch_id} nebyla nalezena`,
status: 404,
};
}
if (batch.is_consumed) {
return {
error: `Šarže ID ${line.batch_id} je již spotřebována`,
error: `Šarže ID ${item.batch_id} je již spotřebována`,
status: 400,
};
}
if (Number(batch.quantity) < Number(line.quantity)) {
if (Number(batch.quantity) < Number(item.quantity)) {
return {
error: `Šarže ID ${line.batch_id} nemá dostatečné množství`,
error: `Šarže ID ${item.batch_id} nemá dostatečné množství`,
status: 400,
};
}
}
// Re-validate reservation rules (reservation may have been cancelled between draft and confirm)
const reservationValidation = await validateIssueReservationRules(
issue.items.map((item) => ({
item_id: item.item_id,
quantity: Number(item.quantity),
reservation_id: item.reservation_id,
})),
issue.project_id,
tx,
);
if ("error" in reservationValidation) {
return {
error: reservationValidation.error,
status: reservationValidation.status,
};
}
const issueNumber = await generateWarehouseNumber("warehouse_issue", tx);
// Process each issue line
for (const line of issue.lines) {
const lineQty = Number(line.quantity);
for (const item of issue.items) {
const itemQty = Number(item.quantity);
// Decrement batch quantity
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
where: { id: item.batch_id },
});
if (!batch) continue; // already validated above
const newBatchQty = Number(batch.quantity) - lineQty;
const newBatchQty = Number(batch.quantity) - itemQty;
await tx.sklad_batches.update({
where: { id: line.batch_id },
where: { id: item.batch_id },
data: {
quantity: newBatchQty,
is_consumed: newBatchQty <= 0,
@@ -478,18 +599,18 @@ export async function confirmIssue(
});
// Decrement item_locations if location_id is specified
if (line.location_id != null) {
if (item.location_id != null) {
const loc = await tx.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,
},
},
});
if (loc) {
const newLocQty = Number(loc.quantity) - lineQty;
const newLocQty = Number(loc.quantity) - itemQty;
if (newLocQty <= 0) {
await tx.sklad_item_locations.delete({
where: { id: loc.id },
@@ -504,14 +625,14 @@ export async function confirmIssue(
}
// Fulfill reservation if linked
if (line.reservation_id != null) {
if (item.reservation_id != null) {
const reservation = await tx.sklad_reservations.findUnique({
where: { id: line.reservation_id },
where: { id: item.reservation_id },
});
if (reservation) {
const newRemaining = Number(reservation.remaining_qty) - lineQty;
const newRemaining = Number(reservation.remaining_qty) - itemQty;
await tx.sklad_reservations.update({
where: { id: line.reservation_id },
where: { id: item.reservation_id },
data: {
remaining_qty: Math.max(0, newRemaining),
status: newRemaining <= 0 ? "FULFILLED" : reservation.status,
@@ -548,40 +669,45 @@ export async function confirmIssue(
export async function cancelIssue(
issueId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const issue = await prisma.sklad_issues.findUnique({
where: { id: issueId },
include: { lines: true },
});
if (!issue) {
return { error: "Výdej nebyl nalezen", status: 404 };
}
if (issue.status === "CANCELLED") {
return { error: "Výdej je již zrušen", status: 400 };
}
if (issue.status === "DRAFT") {
await prisma.sklad_issues.update({
where: { id: issueId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: issueId } };
}
// CONFIRMED -> CANCELLED: restore everything
return prisma.$transaction(async (tx) => {
for (const line of issue.lines) {
const lineQty = Number(line.quantity);
// Lock the issue row so a concurrent confirm/cancel on the same issue
// cannot race with us. Without this, a parallel confirm could restore
// batches that the original confirm already decremented (or vice versa).
await lockParentRow(tx, "sklad_issues", issueId);
const issue = await tx.sklad_issues.findUnique({
where: { id: issueId },
include: { items: true },
});
if (!issue) {
return { error: "Výdej nebyl nalezen", status: 404 };
}
if (issue.status === "CANCELLED") {
return { error: "Výdej je již zrušen", status: 400 };
}
if (issue.status === "DRAFT") {
await tx.sklad_issues.update({
where: { id: issueId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: issueId } };
}
// CONFIRMED -> CANCELLED: restore everything
for (const item of issue.items) {
const itemQty = Number(item.quantity);
// Restore batch quantity
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
where: { id: item.batch_id },
});
if (batch) {
const restoredQty = Number(batch.quantity) + lineQty;
const restoredQty = Number(batch.quantity) + itemQty;
await tx.sklad_batches.update({
where: { id: line.batch_id },
where: { id: item.batch_id },
data: {
quantity: restoredQty,
is_consumed: false,
@@ -590,39 +716,39 @@ export async function cancelIssue(
}
// Restore item_locations
if (line.location_id != null) {
if (item.location_id != null) {
await tx.sklad_item_locations.upsert({
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,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
quantity: lineQty,
item_id: item.item_id,
location_id: item.location_id,
quantity: itemQty,
},
update: {
quantity: {
increment: lineQty,
increment: itemQty,
},
},
});
}
// Restore reservation
if (line.reservation_id != null) {
if (item.reservation_id != null) {
const reservation = await tx.sklad_reservations.findUnique({
where: { id: line.reservation_id },
where: { id: item.reservation_id },
});
if (reservation) {
const restoredRemaining = Number(reservation.remaining_qty) + lineQty;
const restoredRemaining = Number(reservation.remaining_qty) + itemQty;
// Restore to ACTIVE if it was FULFILLED
const newStatus =
reservation.status === "FULFILLED" ? "ACTIVE" : reservation.status;
await tx.sklad_reservations.update({
where: { id: line.reservation_id },
where: { id: item.reservation_id },
data: {
remaining_qty: restoredRemaining,
status: newStatus,
@@ -642,6 +768,121 @@ export async function cancelIssue(
});
}
// ---------------------------------------------------------------------------
// Issue Reservation Validation
// ---------------------------------------------------------------------------
/**
* Validates that issue lines respect reservation rules:
* - Lines WITH reservation_id: reservation must be ACTIVE, match the line's
* item_id and the issue's project_id, and have remaining_qty >= quantity.
* - Lines WITHOUT reservation_id: the total unreserved quantity for that item
* must cover all non-reserved lines. Available = totalStock - activeReservations.
* - Multiple lines can reference the same reservation; their combined quantity
* must not exceed the reservation's remaining_qty.
*/
export async function validateIssueReservationRules(
lines: Array<{
item_id: number;
quantity: number;
reservation_id: number | null;
}>,
issueProjectId: number,
tx?: TxClient,
): Promise<{ valid: true } | { error: string; status: number }> {
const client = tx ?? prisma;
// Group lines by item_id
const linesByItem = new Map<
number,
{
unreservedQty: number;
reservationLines: Array<{ reservation_id: number; quantity: number }>;
}
>();
for (const line of lines) {
const existing = linesByItem.get(line.item_id) ?? {
unreservedQty: 0,
reservationLines: [],
};
if (line.reservation_id) {
existing.reservationLines.push({
reservation_id: line.reservation_id,
quantity: line.quantity,
});
} else {
existing.unreservedQty += line.quantity;
}
linesByItem.set(line.item_id, existing);
}
for (const [itemId, group] of linesByItem) {
// Validate reservation lines: aggregate by reservation_id
const reservationQtyMap = new Map<number, number>();
for (const rl of group.reservationLines) {
const existing = reservationQtyMap.get(rl.reservation_id) ?? 0;
reservationQtyMap.set(rl.reservation_id, existing + rl.quantity);
}
for (const [reservationId, totalQty] of reservationQtyMap) {
const reservation = await client.sklad_reservations.findUnique({
where: { id: reservationId },
});
if (!reservation) {
return {
error: `Rezervace ID ${reservationId} nebyla nalezena`,
status: 404,
};
}
if (reservation.status !== "ACTIVE") {
return {
error: `Rezervace ID ${reservationId} není aktivní (stav: ${reservation.status})`,
status: 400,
};
}
if (reservation.item_id !== itemId) {
return {
error: `Rezervace ID ${reservationId} nepatří k této položce`,
status: 400,
};
}
if (reservation.project_id !== issueProjectId) {
return {
error: `Rezervace ID ${reservationId} patří k jinému projektu`,
status: 400,
};
}
if (Number(reservation.remaining_qty) < totalQty) {
return {
error: `Rezervace ID ${reservationId} nemá dostatečné zbývající množství (${reservation.remaining_qty} < ${totalQty})`,
status: 400,
};
}
}
// Validate unreserved lines
if (group.unreservedQty > 0) {
const totalStock = await getItemTotalStock(itemId);
const reservedResult = await client.sklad_reservations.aggregate({
_sum: { remaining_qty: true },
where: { item_id: itemId, status: "ACTIVE" },
});
const reservedQty = Number(reservedResult._sum.remaining_qty ?? 0);
const availableQty = totalStock - reservedQty;
if (availableQty < group.unreservedQty) {
return {
error: `Nedostatečné dostupné množství pro položku ID ${itemId} (dostupné: ${availableQty}, požadované: ${group.unreservedQty}). Položka má aktivní rezervace — připojte výdej k rezervaci.`,
status: 400,
};
}
}
}
return { valid: true };
}
// ---------------------------------------------------------------------------
// Reservation Create
// ---------------------------------------------------------------------------
@@ -662,40 +903,50 @@ export async function createReservation(data: {
}): Promise<
{ data: Record<string, unknown> } | { error: string; status: number }
> {
const item = await prisma.sklad_items.findUnique({
where: { id: data.item_id },
return prisma.$transaction(async (tx) => {
// Lock the item row first. Without this, two concurrent reservations
// could both read `available_qty = 100`, both decide `100 >= 50` and
// `100 >= 60`, and both create reservations — oversubscribing the stock
// because the second reservation's existence wasn't visible to the first.
await lockParentRow(tx, "sklad_items", data.item_id);
const item = await tx.sklad_items.findUnique({
where: { id: data.item_id },
});
if (!item) {
return { error: "Položka nebyla nalezena", status: 404 };
}
if (!item.is_active) {
return { error: "Položka není aktivní", status: 400 };
}
// Re-read available qty inside the lock so the second concurrent caller
// sees the first caller's reservation (created below) already counted.
const availableQty = await getItemAvailableQtyTx(data.item_id, tx);
if (availableQty < data.quantity) {
return {
error: `Nedostatečné dostupné množství (dostupné: ${availableQty}, požadované: ${data.quantity})`,
status: 400,
};
}
const reservation = await tx.sklad_reservations.create({
data: {
item_id: data.item_id,
project_id: data.project_id,
quantity: data.quantity,
remaining_qty: data.quantity,
reserved_by: data.reserved_by ?? null,
notes: data.notes ?? null,
status: "ACTIVE",
},
});
return { data: reservation };
});
if (!item) {
return { error: "Položka nebyla nalezena", status: 404 };
}
if (!item.is_active) {
return { error: "Položka není aktivní", status: 400 };
}
const availableQty = await getItemAvailableQty(data.item_id);
if (availableQty < data.quantity) {
return {
error: `Nedostatečné dostupné množství (dostupné: ${availableQty}, požadované: ${data.quantity})`,
status: 400,
};
}
const reservation = await prisma.sklad_reservations.create({
data: {
item_id: data.item_id,
project_id: data.project_id,
quantity: data.quantity,
remaining_qty: data.quantity,
reserved_by: data.reserved_by ?? null,
notes: data.notes ?? null,
status: "ACTIVE",
},
});
return { data: reservation };
}
// ---------------------------------------------------------------------------
@@ -757,9 +1008,13 @@ export async function confirmInventorySession(
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
// Lock the inventory session row so two concurrent confirms on the same
// session cannot both create corrective receipts and double-decrement.
await lockParentRow(tx, "sklad_inventory_sessions", sessionId);
const session = await tx.sklad_inventory_sessions.findUnique({
where: { id: sessionId },
include: { lines: true },
include: { items: true },
});
if (!session) {
@@ -770,8 +1025,8 @@ export async function confirmInventorySession(
return { error: "Inventarizace není ve stavu DRAFT", status: 400 };
}
for (const line of session.lines) {
const diff = Number(line.difference);
for (const item of session.items) {
const diff = Number(item.difference);
if (diff === 0) continue;
if (diff > 0) {
@@ -792,10 +1047,10 @@ export async function confirmInventorySession(
const receiptLine = await tx.sklad_receipt_lines.create({
data: {
receipt_id: correctiveReceipt.id,
item_id: line.item_id,
item_id: item.item_id,
quantity: diff,
unit_price: 0,
location_id: line.location_id,
location_id: item.location_id,
notes: `Korekce inventarizace (přebytek)`,
},
});
@@ -803,7 +1058,7 @@ export async function confirmInventorySession(
// Create batch for the corrective receipt
await tx.sklad_batches.create({
data: {
item_id: line.item_id,
item_id: item.item_id,
receipt_line_id: receiptLine.id,
quantity: diff,
original_qty: diff,
@@ -814,17 +1069,17 @@ export async function confirmInventorySession(
});
// Upsert item_locations
if (line.location_id != null) {
if (item.location_id != null) {
await tx.sklad_item_locations.upsert({
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,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
item_id: item.item_id,
location_id: item.location_id,
quantity: diff,
},
update: {
@@ -840,7 +1095,7 @@ export async function confirmInventorySession(
try {
const fifoBatches = await selectFifoBatches(
line.item_id,
item.item_id,
absDiff,
tx,
);
@@ -862,12 +1117,12 @@ export async function confirmInventorySession(
}
// Decrement item_locations
if (line.location_id != null) {
if (item.location_id != null) {
const loc = await tx.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,
},
},
});
@@ -892,7 +1147,7 @@ export async function confirmInventorySession(
? err.message
: "Nedostatečné množství na skladě";
return {
error: `Inventarizace položky ID ${line.item_id}: ${message}`,
error: `Inventarizace položky ID ${item.item_id}: ${message}`,
status: 400,
};
}