Files
app/src/services/attendance.service.ts
BOHA 975a555af5 fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:28 +02:00

1773 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { attendance_leave_type, Prisma } from "@prisma/client";
import prisma from "../config/database";
import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
/** Get active users whose role has attendance.record permission */
async function getAttendanceUsers() {
return prisma.users.findMany({
where: {
is_active: true,
roles: {
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: "asc" },
});
}
type AttendanceWithRelations = Prisma.attendanceGetPayload<{
include: {
users: { select: { id: true; first_name: true; last_name: true } };
attendance_project_logs: true;
};
}>;
const VALID_LEAVE_TYPES = ["work", "vacation", "sick", "unpaid"] as const;
/** Default annual vacation allowance (hours) when a user has no leave_balances row. */
const DEFAULT_VACATION_TOTAL = 160;
const MONTH_NAMES = [
"Leden",
"Únor",
"Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
];
// ── Helpers ──────────────────────────────────────────────────────────
function calcWorkedHours(
arrival: Date,
departure: Date,
breakStart: Date | null,
breakEnd: Date | null,
): number {
let mins = (departure.getTime() - arrival.getTime()) / 60000;
if (breakStart && breakEnd) {
mins -= (breakEnd.getTime() - breakStart.getTime()) / 60000;
}
return Math.max(0, mins) / 60;
}
const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
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> {
// Must be $queryRaw (not $executeRaw): $executeRaw is for write statements
// and may not reliably acquire/hold the row lock for a bare SELECT ... FOR
// UPDATE. $queryRaw runs it as a query so the lock is actually taken.
await tx.$queryRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
}
// ── Interfaces ───────────────────────────────────────────────────────
export interface ListAttendanceParams {
page: number;
limit: number;
skip: number;
order: "asc" | "desc";
userId?: number;
isAdmin: boolean;
authUserId: number;
month?: number;
year?: number;
}
export interface PunchData {
punch_action: string;
latitude?: number | string | null;
longitude?: number | string | null;
accuracy?: number | string | null;
address?: string | null;
}
export interface CreateAttendanceData {
user_id?: number;
shift_date: string;
arrival_time?: string | null;
arrival_lat?: number | null;
arrival_lng?: number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_time?: string | null;
departure_lat?: number | null;
departure_lng?: number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
leave_type: string;
leave_hours?: number | null;
project_logs?: Array<{
project_id: number;
hours?: number;
minutes?: number;
}>;
}
export interface UpdateAttendanceData {
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
leave_type?: string;
leave_hours?: number | null;
project_logs?: Array<{
project_id: number;
hours?: number;
minutes?: number;
}>;
}
export interface LeaveData {
user_id?: number;
date_from: string;
date_to?: string;
leave_type: string;
leave_hours?: number;
notes?: string;
}
export interface BulkAttendanceData {
month: string;
user_ids: number[];
arrival_time: string;
departure_time: string;
break_start_time: string;
break_end_time: string;
}
export interface BalancesData {
user_id: number;
year?: number;
action_type: string;
vacation_total?: number | null;
vacation_used?: number | null;
sick_used?: number | null;
}
// ── Service Functions ────────────────────────────────────────────────
export async function getStatus(userId: number) {
const now = new Date();
const y = now.getFullYear(),
m = now.getMonth(),
d = now.getDate();
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
// one day back. Half-open [gte, lt) ranges.
const todayStart = new Date(Date.UTC(y, m, d));
const todayEnd = new Date(Date.UTC(y, m, d + 1));
// Monthly fund range (used by query #4)
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
// Queries 1-4 are independent of one another → run them in parallel.
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
await Promise.all([
// 1) Ongoing shift (no departure)
prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
}),
// 2) Today's completed shifts
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lt: todayEnd },
departure_time: { not: null },
},
include: {
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
orderBy: { arrival_time: "asc" },
}),
// 3) Leave balance
prisma.leave_balances.findFirst({
where: { user_id: userId, year: y },
}),
// 4) Monthly fund records
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: monthStart, lt: monthEnd },
},
}),
]);
const leaveBalance = {
vacation_total: balance
? Number(balance.vacation_total)
: DEFAULT_VACATION_TOTAL,
vacation_used: balance ? Number(balance.vacation_used) : 0,
vacation_remaining: balance
? Number(balance.vacation_total) - Number(balance.vacation_used)
: DEFAULT_VACATION_TOTAL,
sick_used: balance ? Number(balance.sick_used) : 0,
};
const workingDays = getBusinessDaysInMonth(y, m);
const fund = workingDays * 8;
let workedHours = 0;
let vacationHours = 0;
let sickHours = 0;
let unpaidHours = 0;
for (const rec of monthRecords) {
const lt = (rec.leave_type as string) || "work";
if (lt !== "work") {
const hrs = Number(rec.leave_hours) || 8;
if (lt === "vacation") vacationHours += hrs;
else if (lt === "sick") sickHours += 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) {
workedHours += calcWorkedHours(
rec.arrival_time,
rec.departure_time,
rec.break_start,
rec.break_end,
);
}
}
const worked = Math.round(workedHours * 100) / 100;
const adjustedFund = Math.max(0, fund);
const leaveHours = vacationHours + sickHours;
const covered = worked + leaveHours;
const remaining = Math.max(0, adjustedFund - covered);
const overtime = Math.max(0, covered - adjustedFund);
const monthlyFund = {
month_name: `${MONTH_NAMES[m]} ${y}`,
fund: adjustedFund,
business_days: workingDays,
worked,
covered,
remaining,
overtime,
leave_hours: leaveHours,
vacation_hours: vacationHours,
sick_hours: sickHours,
unpaid_hours: unpaidHours,
};
// 5) Project logs for ongoing shift
const completedProjectIds = new Set<number>();
for (const shift of todayShiftsRaw) {
for (const log of shift.attendance_project_logs) {
completedProjectIds.add(log.project_id);
}
}
const completedProjectNames = new Map<number, string>();
if (completedProjectIds.size > 0) {
const projects = await prisma.projects.findMany({
where: { id: { in: [...completedProjectIds] } },
select: { id: true, name: true, project_number: true },
});
for (const p of projects) {
completedProjectNames.set(
p.id,
p.project_number ? `${p.project_number} ${p.name}` : p.name || "",
);
}
}
const todayShifts = todayShiftsRaw.map((shift) => ({
...shift,
project_logs: shift.attendance_project_logs.map((l) => ({
...l,
project_name:
completedProjectNames.get(l.project_id) || `Projekt #${l.project_id}`,
})),
}));
let projectLogs: Array<{
id: number;
attendance_id: number;
project_id: number;
started_at: Date | null;
ended_at: Date | null;
project_name?: string;
}> = [];
let activeProjectId: number | null = null;
if (ongoingShift) {
const logs = await prisma.attendance_project_logs.findMany({
where: { attendance_id: ongoingShift.id },
orderBy: { started_at: "asc" },
});
const projectIds = [...new Set(logs.map((l) => l.project_id))];
const projectNames = new Map<number, string>();
if (projectIds.length > 0) {
const projects = await prisma.projects.findMany({
where: { id: { in: projectIds } },
select: { id: true, name: true, project_number: true },
});
for (const p of projects) {
projectNames.set(
p.id,
p.project_number ? `${p.project_number} ${p.name}` : p.name || "",
);
}
}
projectLogs = logs.map((l) => ({
...l,
project_name:
projectNames.get(l.project_id) || `Projekt #${l.project_id}`,
}));
const activeLog = logs.find((l) => l.ended_at === null);
if (activeLog) {
activeProjectId = activeLog.project_id;
} else {
activeProjectId = ongoingShift.project_id ?? null;
}
}
return {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
monthly_fund: monthlyFund,
date: localDateStr(now),
project_logs: projectLogs,
active_project_id: activeProjectId,
};
}
export async function saveNotes(userId: number, notes: string | 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." };
await prisma.attendance.update({
where: { id: ongoing.id },
data: { notes: notes ? String(notes) : null },
});
return { success: true };
}
export async function updateAddress(
userId: number,
address: string | null,
punchAction: string,
) {
// When updating departure address, the punch already set departure_time,
// so we can't filter on departure_time: null. Find the latest record instead.
const where: Record<string, unknown> = {
user_id: userId,
arrival_time: { not: null },
};
if (punchAction === "arrival") {
where.departure_time = null;
}
const latest = await prisma.attendance.findFirst({
where,
orderBy: { created_at: "desc" },
});
if (!latest) return { error: "Nenalezen záznam" };
const data: Record<string, unknown> = {};
if (punchAction === "departure") {
data.departure_address = address;
} else {
data.arrival_address = address;
}
await prisma.attendance.update({ where: { id: latest.id }, data });
return { success: true };
}
export async function switchProject(userId: number, projectId: number | null) {
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 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 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 };
});
}
export async function getBalances(year: number) {
const users = await getAttendanceUsers();
const balances: Record<
string,
{
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
> = {};
// Single query for every user's balance for the year (was an N+1 loop).
const balanceRows = await prisma.leave_balances.findMany({
where: { year, user_id: { in: users.map((u) => u.id) } },
});
// Keep the first row seen per user (matches the old per-user findFirst).
const balanceByUser = new Map<number, (typeof balanceRows)[number]>();
for (const row of balanceRows) {
if (!balanceByUser.has(row.user_id)) balanceByUser.set(row.user_id, row);
}
for (const u of users) {
const lb = balanceByUser.get(u.id);
const vTotal = lb ? Number(lb.vacation_total) : DEFAULT_VACATION_TOTAL;
const vUsed = lb ? Number(lb.vacation_used) : 0;
const sUsed = lb ? Number(lb.sick_used) : 0;
balances[String(u.id)] = {
name: `${u.first_name} ${u.last_name}`.trim(),
vacation_total: vTotal,
vacation_used: vUsed,
vacation_remaining: vTotal - vUsed,
sick_used: sUsed,
};
}
return {
users: users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim(),
})),
balances,
};
}
export async function getWorkfund(year: number) {
const users = await getAttendanceUsers();
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); // 0-based
const maxMonth =
year < currentYear ? 11 : year === currentYear ? currentMonth : -1;
if (maxMonth < 0) {
return {
months: {},
users: users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim(),
})),
balances: {},
};
}
const months: Record<
string,
{
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users: Record<
string,
{
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
>;
}
> = {};
for (let m = 0; m <= maxMonth; m++) {
const isCurrentMonth = year === currentYear && m === currentMonth;
const bizDays = getBusinessDaysInMonth(year, m);
const bizDaysToDate = isCurrentMonth
? getBusinessDaysInMonth(year, m, now.getDate())
: bizDays;
const fund = bizDays * 8;
const fundToDate = bizDaysToDate * 8;
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
// month boundaries, half-open range. Local midnights would double-count
// each previous month's last day.
const monthStart = new Date(Date.UTC(year, m, 1));
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
const monthRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: monthStart, lt: monthEnd } },
select: {
user_id: true,
shift_date: true,
leave_type: true,
arrival_time: true,
departure_time: true,
break_start: true,
break_end: true,
leave_hours: true,
},
});
const monthUsers: Record<
string,
{
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
> = {};
for (const u of users) {
const recs = monthRecords.filter((r) => r.user_id === u.id);
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
if (lt === "work") {
if (rec.arrival_time && rec.departure_time) {
worked += calcWorkedHours(
rec.arrival_time,
rec.departure_time,
rec.break_start,
rec.break_end,
);
}
} else if (lt === "vacation") {
vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") {
sickHours += 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;
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);
monthUsers[String(u.id)] = {
name: `${u.first_name} ${u.last_name}`.trim(),
worked: workedRound,
covered,
overtime,
missing,
};
}
months[String(m + 1)] = {
month_name: MONTH_NAMES[m],
fund,
fund_to_date: fundToDate,
business_days: bizDays,
users: monthUsers,
};
}
return {
months,
users: users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim(),
})),
balances: {},
holidays: [],
};
}
export async function getProjectReport(year: number) {
const yearStart = new Date(year, 0, 1);
const yearEnd = new Date(year, 11, 31, 23, 59, 59);
const records = await prisma.attendance.findMany({
where: {
shift_date: { gte: yearStart, lte: yearEnd },
leave_type: "work",
arrival_time: { not: null },
departure_time: { not: null },
},
include: {
users: { select: { id: true, first_name: true, last_name: true } },
attendance_project_logs: {
orderBy: { started_at: "asc" },
},
},
});
// Collect all project ids from both attendance.project_id and project logs
const projectIds = new Set<number>();
for (const rec of records) {
if (rec.project_id) projectIds.add(rec.project_id);
for (const log of rec.attendance_project_logs) {
projectIds.add(log.project_id);
}
}
const projectsMap = new Map<
number,
{ name: string; project_number: string }
>();
if (projectIds.size > 0) {
const projects = await prisma.projects.findMany({
where: { id: { in: [...projectIds] } },
select: { id: true, name: true, project_number: true },
});
for (const p of projects) {
projectsMap.set(p.id, {
name: p.name || "",
project_number: p.project_number || "",
});
}
}
const months: Record<
string,
{
month_name: string;
projects: Array<{
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: Array<{ user_id: number; user_name: string; hours: number }>;
}>;
}
> = {};
for (let m = 0; m < 12; m++) {
const monthStart = new Date(year, m, 1);
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
const monthRecs = records.filter(
(r) => r.shift_date >= monthStart && r.shift_date <= monthEnd,
);
if (monthRecs.length === 0) continue;
const projectMap = new Map<
number | null,
{
project_number?: string;
project_name?: string;
userMap: Map<number, { name: string; hours: number }>;
}
>();
for (const rec of monthRecs) {
const uid = rec.user_id;
const uName = rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
: `User #${uid}`;
if (rec.attendance_project_logs.length === 0) {
// No detailed project logs — fall back to attendance.project_id
const pid = rec.project_id;
const hours = calcWorkedHours(
rec.arrival_time!,
rec.departure_time!,
rec.break_start,
rec.break_end,
);
if (!projectMap.has(pid)) {
const projInfo = pid ? projectsMap.get(pid) : undefined;
projectMap.set(pid, {
project_number: projInfo?.project_number || undefined,
project_name: projInfo?.name || undefined,
userMap: new Map(),
});
}
const pg = projectMap.get(pid)!;
if (!pg.userMap.has(uid)) {
pg.userMap.set(uid, { name: uName, hours: 0 });
}
pg.userMap.get(uid)!.hours += hours;
continue;
}
// Use detailed project logs (started_at/ended_at or hours/minutes)
for (const log of rec.attendance_project_logs) {
let hours = 0;
if (log.hours != null || log.minutes != null) {
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
} else if (log.started_at && log.ended_at) {
hours =
(new Date(log.ended_at).getTime() -
new Date(log.started_at).getTime()) /
(1000 * 60 * 60);
} else {
continue;
}
const pid = log.project_id;
if (!projectMap.has(pid)) {
const projInfo = projectsMap.get(pid);
projectMap.set(pid, {
project_number: projInfo?.project_number || undefined,
project_name: projInfo?.name || undefined,
userMap: new Map(),
});
}
const pg = projectMap.get(pid)!;
if (!pg.userMap.has(uid)) {
pg.userMap.set(uid, { name: uName, hours: 0 });
}
pg.userMap.get(uid)!.hours += hours;
}
}
const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({
project_id: pid,
project_number: pg.project_number,
project_name: pg.project_name,
hours:
Math.round(
Array.from(pg.userMap.values()).reduce((s, u) => s + u.hours, 0) * 10,
) / 10,
users: Array.from(pg.userMap.entries()).map(([uid, ud]) => ({
user_id: uid,
user_name: ud.name,
hours: Math.round(ud.hours * 10) / 10,
})),
}));
months[String(m + 1)] = {
month_name: MONTH_NAMES[m],
projects,
};
}
return { months };
}
export async function getPrintData(
monthStr: string,
filterUserId: number | null,
) {
const [yearStr, monthNumStr] = monthStr.split("-");
const yr = Number(yearStr);
const mo = Number(monthNumStr);
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
// boundaries, half-open range (local midnights pulled in the previous
// month's last day).
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
const monthEnd = new Date(Date.UTC(yr, mo, 1));
const users = await getAttendanceUsers();
const where: Record<string, unknown> = {
shift_date: { gte: monthStart, lt: monthEnd },
};
if (filterUserId) where.user_id = filterUserId;
const records = await prisma.attendance.findMany({
where,
include: {
users: { select: { id: true, first_name: true, last_name: true } },
attendance_project_logs: {
orderBy: { started_at: "asc" },
},
},
orderBy: [{ users: { last_name: "asc" } }, { shift_date: "asc" }],
});
const fundHours = getBusinessDaysInMonth(yr, mo - 1) * 8;
const typedRecords = records as AttendanceWithRelations[];
const projectIds = [
...new Set(
typedRecords
.flatMap(
(r) => r.attendance_project_logs?.map((l) => l.project_id) || [],
)
.filter(Boolean),
),
];
const projectMap: Record<number, string> = {};
if (projectIds.length > 0) {
const projects = await prisma.projects.findMany({
where: { id: { in: projectIds } },
select: { id: true, name: true, project_number: true },
});
for (const p of projects) {
projectMap[p.id] = p.project_number
? `${p.project_number} ${p.name}`
: p.name || `#${p.id}`;
}
}
const userTotals: Record<string, Record<string, unknown>> = {};
for (const rec of typedRecords) {
const uid = String(rec.user_id);
if (!userTotals[uid]) {
const u = rec.users;
userTotals[uid] = {
name: u ? `${u.first_name} ${u.last_name}`.trim() : `User #${uid}`,
minutes: 0,
records: [],
vacation_hours: 0,
sick_hours: 0,
unpaid_hours: 0,
fund: fundHours,
worked_hours: 0,
covered: 0,
missing: 0,
overtime: 0,
};
}
const projectLogs =
rec.attendance_project_logs?.map((log) => ({
project_id: log.project_id,
project_name: projectMap[log.project_id] || null,
hours: log.hours,
minutes: log.minutes,
started_at: log.started_at,
ended_at: log.ended_at,
})) || [];
(userTotals[uid].records as unknown[]).push({
...rec,
project_logs: projectLogs,
project_name: projectLogs.length > 0 ? projectLogs[0].project_name : null,
});
const lt = (rec.leave_type as string) || "work";
if (lt !== "work") {
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 === "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(
rec.arrival_time,
rec.departure_time,
rec.break_start,
rec.break_end,
) * 60;
(userTotals[uid].minutes as number) += Math.round(mins);
}
}
for (const uid of Object.keys(userTotals)) {
const ut = userTotals[uid];
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.covered = Math.round(covered * 10) / 10;
ut.missing = Math.max(
0,
Math.round(((ut.fund as number) - covered) * 10) / 10,
);
ut.overtime = Math.max(
0,
Math.round((covered - (ut.fund as number)) * 10) / 10,
);
}
const leaveBalances: Record<string, Record<string, number>> = {};
const balanceRecords = await prisma.leave_balances.findMany({
where: { year: yr },
});
for (const bal of balanceRecords) {
const uid = String(bal.user_id);
leaveBalances[uid] = {
vacation_total: Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_remaining:
(Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL) -
(Number(bal.vacation_used) || 0),
};
}
let selectedUserName = "";
if (filterUserId) {
const u = users.find((u) => u.id === filterUserId);
if (u) selectedUserName = `${u.first_name} ${u.last_name}`.trim();
}
return {
user_totals: userTotals,
leave_balances: leaveBalances,
users: users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim(),
})),
month: monthStr,
month_name: `${MONTH_NAMES[mo - 1]} ${yr}`,
selected_user: filterUserId,
selected_user_name: selectedUserName,
year: yr,
fund: {
business_days: getBusinessDaysInMonth(yr, mo - 1),
hours: fundHours,
},
};
}
export async function getActiveProjects() {
const activeProjects = await prisma.projects.findMany({
where: { status: "aktivni" },
select: { id: true, name: true, project_number: true },
orderBy: { name: "asc" },
});
return activeProjects.map((p) => ({
id: p.id,
name: p.name,
project_number: p.project_number ?? "",
}));
}
export async function getProjectLogs(attendanceId: number) {
return prisma.attendance_project_logs.findMany({
where: { attendance_id: attendanceId },
orderBy: { started_at: "asc" },
});
}
export async function getLocationRecord(id: number) {
return prisma.attendance.findUnique({
where: { id },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
},
});
}
export async function listAttendance(params: ListAttendanceParams) {
const { page, limit, skip, order, isAdmin, authUserId } = params;
const where: Record<string, unknown> = {};
if (!isAdmin) {
where.user_id = authUserId;
} else if (params.userId) {
where.user_id = params.userId;
}
if (params.month && params.year) {
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
where.shift_date = {
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
lt: new Date(Date.UTC(params.year, params.month, 1)),
};
}
const [records, total] = await Promise.all([
prisma.attendance.findMany({
where,
skip,
take: limit,
orderBy: { shift_date: order },
include: {
users: {
select: {
id: true,
first_name: true,
last_name: true,
username: true,
},
},
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
}),
prisma.attendance.count({ where }),
]);
const allProjectIds = new Set<number>();
for (const rec of records) {
if (rec.project_id) allProjectIds.add(rec.project_id);
for (const log of rec.attendance_project_logs) {
allProjectIds.add(log.project_id);
}
}
const projectNameMap = new Map<number, string>();
if (allProjectIds.size > 0) {
const projects = await prisma.projects.findMany({
where: { id: { in: [...allProjectIds] } },
select: { id: true, name: true, project_number: true },
});
for (const p of projects) {
projectNameMap.set(
p.id,
p.project_number ? `${p.project_number} ${p.name}` : p.name || "",
);
}
}
const enriched = records.map((rec) => {
const logs = rec.attendance_project_logs.map((l) => ({
...l,
project_name:
projectNameMap.get(l.project_id) || `Projekt #${l.project_id}`,
}));
const u = rec.users;
return {
...rec,
user_name: u ? `${u.first_name} ${u.last_name}`.trim() : "",
project_name: rec.project_id
? projectNameMap.get(rec.project_id) || null
: null,
project_logs: logs,
};
});
return { records: enriched, total, page, limit };
}
export async function handleBalances(data: BalancesData) {
const yr = data.year || new Date().getFullYear();
if (data.action_type === "edit") {
await prisma.leave_balances.upsert({
where: { user_id_year: { user_id: data.user_id, year: yr } },
update: {
vacation_total:
data.vacation_total != null ? Number(data.vacation_total) : undefined,
vacation_used:
data.vacation_used != null ? Number(data.vacation_used) : undefined,
sick_used: data.sick_used != null ? Number(data.sick_used) : undefined,
updated_at: new Date(),
},
create: {
user_id: data.user_id,
year: yr,
vacation_total: Number(data.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_used: Number(data.vacation_used) || 0,
sick_used: Number(data.sick_used) || 0,
},
});
return { success: true, message: "Bilance byla uložena", year: yr };
}
if (data.action_type === "reset") {
await prisma.leave_balances.upsert({
where: { user_id_year: { user_id: data.user_id, year: yr } },
update: { vacation_used: 0, sick_used: 0, updated_at: new Date() },
create: {
user_id: data.user_id,
year: yr,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: 0,
sick_used: 0,
},
});
return { success: true, message: "Bilance byla resetována", year: yr };
}
return { error: "Neplatný typ akce" };
}
export async function bulkCreateAttendance(data: BulkAttendanceData) {
const [yrStr, moStr] = data.month.split("-");
const yr = Number(yrStr);
const mo = Number(moStr);
const daysInMonth = new Date(yr, mo, 0).getDate();
const dateFrom = new Date(yr, mo - 1, 1);
const dateTo = new Date(yr, mo, 0, 23, 59, 59);
const existing = await prisma.attendance.findMany({
where: {
user_id: { in: data.user_ids.map(Number) },
shift_date: { gte: dateFrom, lte: dateTo },
},
select: { user_id: true, shift_date: true },
});
const existingSet = new Set(
existing.map((r) => `${r.user_id}:${localDateStr(r.shift_date)}`),
);
let inserted = 0;
let skipped = 0;
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
const dateStr = localDateStr(date);
const dow = date.getDay();
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;
}
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
leave_type: "work",
},
});
inserted++;
}
}
});
let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
return { inserted, skipped, message: msg };
}
export async function createLeave(data: LeaveData, authUserId: number) {
const userId = data.user_id ?? authUserId;
const dateFrom = data.date_from;
const dateTo = data.date_to || dateFrom;
const leaveTypeStr = data.leave_type;
if (
!VALID_LEAVE_TYPES.includes(
leaveTypeStr as (typeof VALID_LEAVE_TYPES)[number],
)
) {
return { error: "Neplatný typ nepřítomnosti" };
}
const leaveType = leaveTypeStr as attendance_leave_type;
if (!dateFrom) return { error: "Datum je povinné" };
const start = new Date(dateFrom);
const end = new Date(dateTo);
const perDayHours = data.leave_hours ? Number(data.leave_hours) : 8;
let created = 0;
try {
await prisma.$transaction(async (tx) => {
// A leave range can span a year boundary (e.g. Dec → Jan). Each day's
// hours must be booked against ITS OWN year's balance, so accumulate
// per calendar year rather than attributing the whole range to the
// start year.
const hoursByYear = new Map<number, number>();
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await tx.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
throw new Error("Pro zvolené datumy již existují záznamy docházky");
}
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: perDayHours,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
const dayYear = current.getFullYear();
hoursByYear.set(
dayYear,
(hoursByYear.get(dayYear) ?? 0) + perDayHours,
);
}
current.setDate(current.getDate() + 1);
}
if (leaveType === "vacation" || leaveType === "sick") {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
for (const [year, yearHours] of hoursByYear) {
if (yearHours <= 0) continue;
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + yearHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: leaveType === "vacation" ? yearHours : 0,
sick_used: leaveType === "sick" ? yearHours : 0,
},
});
}
}
}
});
} catch (err) {
if (
err instanceof Error &&
err.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return { error: err.message };
}
throw err;
}
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
}
export async function punchAction(userId: number, data: PunchData) {
const settings = await getSystemSettings();
const action = data.punch_action;
const now = new Date();
const y = now.getFullYear(),
m = now.getMonth(),
d = now.getDate();
const today = new Date(y, m, d, 12, 0, 0);
const gpsLat =
data.latitude != null && data.latitude !== ""
? Number(data.latitude)
: null;
const gpsLng =
data.longitude != null && data.longitude !== ""
? Number(data.longitude)
: null;
const gpsAcc =
data.accuracy != null && data.accuracy !== ""
? Number(data.accuracy)
: null;
const gpsAddr = data.address ?? null;
if (action === "arrival") {
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 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.",
};
}
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") {
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",
};
});
}
return { error: "Neplatná akce" };
}
export async function createAttendance(
data: CreateAttendanceData,
authUserId: number,
) {
const userId = data.user_id ?? authUserId;
const shiftDate = new Date(data.shift_date);
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
// @db.Date column is compared by UTC date part. The duplicate/overlap
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
// day, so the validation queried ONLY yesterday's records (same-day
// overlaps undetected, false duplicates when yesterday had records).
const startOfDay = new Date(
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate(),
),
);
const endOfDay = new Date(
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate() + 1,
),
);
// 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,
},
});
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({
data: {
user_id: userId,
shift_date: shiftDate,
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
arrival_lat: data.arrival_lat ?? null,
arrival_lng: data.arrival_lng ?? null,
arrival_accuracy: data.arrival_accuracy ?? null,
arrival_address: data.arrival_address ?? null,
departure_time: data.departure_time
? new Date(data.departure_time)
: null,
departure_lat: data.departure_lat ?? null,
departure_lng: data.departure_lng ?? null,
departure_accuracy: data.departure_accuracy ?? null,
departure_address: data.departure_address ?? null,
break_start: data.break_start ? new Date(data.break_start) : null,
break_end: data.break_end ? new Date(data.break_end) : null,
notes: data.notes ?? null,
project_id: data.project_id ?? null,
leave_type: data.leave_type as attendance_leave_type,
leave_hours: data.leave_hours ?? null,
},
});
if (Array.isArray(data.project_logs)) {
const logs = data.project_logs.filter(
(l) => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0),
);
if (logs.length > 0) {
await prisma.attendance_project_logs.createMany({
data: logs.map((l) => ({
attendance_id: record.id,
project_id: Number(l.project_id),
hours: Number(l.hours) || 0,
minutes: Number(l.minutes) || 0,
})),
});
}
}
return { id: record.id };
}
export async function updateAttendance(
id: number,
data: UpdateAttendanceData,
authUserId: number,
isAdmin: boolean,
) {
const existing = await prisma.attendance.findUnique({ where: { id } });
if (!existing) return { error: "Záznam nenalezen", status: 404 };
if (existing.user_id !== authUserId && !isAdmin) {
return { error: "Nemáte oprávnění upravit tento záznam", status: 403 };
}
await prisma.attendance.update({
where: { id },
data: {
arrival_time:
data.arrival_time !== undefined
? data.arrival_time
? new Date(String(data.arrival_time))
: null
: undefined,
departure_time:
data.departure_time !== undefined
? data.departure_time
? new Date(String(data.departure_time))
: null
: undefined,
break_start:
data.break_start !== undefined
? data.break_start
? new Date(String(data.break_start))
: null
: undefined,
break_end:
data.break_end !== undefined
? data.break_end
? new Date(String(data.break_end))
: null
: undefined,
notes:
data.notes !== undefined
? data.notes
? String(data.notes)
: null
: undefined,
project_id:
data.project_id !== undefined
? data.project_id
? Number(data.project_id)
: null
: undefined,
leave_type:
data.leave_type !== undefined
? (String(data.leave_type) as attendance_leave_type)
: undefined,
leave_hours:
data.leave_hours !== undefined
? data.leave_hours
? Number(data.leave_hours)
: null
: undefined,
},
});
if (Array.isArray(data.project_logs)) {
await prisma.attendance_project_logs.deleteMany({
where: { attendance_id: id },
});
const logs = data.project_logs.filter(
(l) => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0),
);
if (logs.length > 0) {
await prisma.attendance_project_logs.createMany({
data: logs.map((l) => ({
attendance_id: id,
project_id: Number(l.project_id),
hours: Number(l.hours) || 0,
minutes: Number(l.minutes) || 0,
})),
});
}
}
return { id };
}
export async function deleteAttendance(
id: number,
requesterId?: number,
isAdmin?: boolean,
) {
const existing = await prisma.attendance.findUnique({ where: { id } });
if (!existing) return { error: "Záznam nenalezen" };
// Ownership is enforced ONLY when a requester is supplied, keeping the
// signature backward compatible (existing callers pass nothing and skip
// the check, behaving exactly as before).
if (
requesterId !== undefined &&
!isAdmin &&
existing.user_id !== requesterId
) {
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
}
await prisma.attendance.delete({ where: { id } });
return { success: true };
}