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