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:
@@ -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({
|
||||
|
||||
@@ -26,6 +26,18 @@ function escapeHtml(str: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user