- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu) - users-handlers.php: password_hash explicitne vyloucen z dotazu - Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech) - Validator.php: validacni helper s pravidly required, string, int, email, in, numeric - PaginationHelper: PHPStan typy opraveny Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
11 KiB
PHP
373 lines
11 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Attendance helper functions - shared between user and admin handlers
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
function roundUpTo15Minutes(string $datetime): string
|
||
{
|
||
$timestamp = strtotime($datetime);
|
||
$minutes = (int)date('i', $timestamp);
|
||
|
||
$remainder = $minutes % 15;
|
||
|
||
if ($remainder === 0) {
|
||
$roundedMinutes = $minutes;
|
||
} else {
|
||
$roundedMinutes = $minutes + (15 - $remainder);
|
||
}
|
||
|
||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||
}
|
||
|
||
function roundDownTo15Minutes(string $datetime): string
|
||
{
|
||
$timestamp = strtotime($datetime);
|
||
$minutes = (int)date('i', $timestamp);
|
||
|
||
$remainder = $minutes % 15;
|
||
$roundedMinutes = $minutes - $remainder;
|
||
|
||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||
}
|
||
|
||
function roundToNearest10Minutes(string $datetime): string
|
||
{
|
||
$timestamp = strtotime($datetime);
|
||
$minutes = (int)date('i', $timestamp);
|
||
|
||
$remainder = $minutes % 10;
|
||
|
||
if ($remainder < 5) {
|
||
$roundedMinutes = $minutes - $remainder;
|
||
} else {
|
||
$roundedMinutes = $minutes + (10 - $remainder);
|
||
}
|
||
|
||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $record
|
||
*/
|
||
function calculateWorkMinutes(array $record): int
|
||
{
|
||
if (!$record['arrival_time'] || !$record['departure_time']) {
|
||
return 0;
|
||
}
|
||
|
||
$arrival = strtotime($record['arrival_time']);
|
||
$departure = strtotime($record['departure_time']);
|
||
$totalMinutes = ($departure - $arrival) / 60;
|
||
|
||
if ($record['break_start'] && $record['break_end']) {
|
||
$breakStart = strtotime($record['break_start']);
|
||
$breakEnd = strtotime($record['break_end']);
|
||
$breakMinutes = ($breakEnd - $breakStart) / 60;
|
||
$totalMinutes -= $breakMinutes;
|
||
}
|
||
|
||
return max(0, (int)$totalMinutes);
|
||
}
|
||
|
||
/**
|
||
* @return array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}
|
||
*/
|
||
function getLeaveBalance(PDO $pdo, int $userId, ?int $year = null): array
|
||
{
|
||
$year = $year ?: (int)date('Y');
|
||
|
||
$stmt = $pdo->prepare(
|
||
'SELECT vacation_total, vacation_used, sick_used FROM leave_balances WHERE user_id = ? AND year = ?'
|
||
);
|
||
$stmt->execute([$userId, $year]);
|
||
$balance = $stmt->fetch();
|
||
|
||
if (!$balance) {
|
||
return [
|
||
'vacation_total' => 160,
|
||
'vacation_used' => 0,
|
||
'vacation_remaining' => 160,
|
||
'sick_used' => 0,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'vacation_total' => (float)$balance['vacation_total'],
|
||
'vacation_used' => (float)$balance['vacation_used'],
|
||
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
||
'sick_used' => (float)$balance['sick_used'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Batch get leave balances for multiple users (eliminates N+1 queries)
|
||
*
|
||
* @param array<int, int> $userIds
|
||
* @return array<int, array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}>
|
||
*/
|
||
function getLeaveBalancesBatch(PDO $pdo, array $userIds, ?int $year = null): array
|
||
{
|
||
$year = $year ?: (int)date('Y');
|
||
$result = [];
|
||
|
||
$default = [
|
||
'vacation_total' => 160,
|
||
'vacation_used' => 0,
|
||
'vacation_remaining' => 160,
|
||
'sick_used' => 0,
|
||
];
|
||
|
||
if (empty($userIds)) {
|
||
return $result;
|
||
}
|
||
|
||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||
$stmt = $pdo->prepare("
|
||
SELECT user_id, vacation_total, vacation_used, sick_used
|
||
FROM leave_balances
|
||
WHERE user_id IN ($placeholders) AND year = ?
|
||
");
|
||
$params = array_values($userIds);
|
||
$params[] = $year;
|
||
$stmt->execute($params);
|
||
$rows = $stmt->fetchAll();
|
||
|
||
$balanceMap = [];
|
||
foreach ($rows as $row) {
|
||
$balanceMap[$row['user_id']] = [
|
||
'vacation_total' => (float)$row['vacation_total'],
|
||
'vacation_used' => (float)$row['vacation_used'],
|
||
'vacation_remaining' => (float)$row['vacation_total'] - (float)$row['vacation_used'],
|
||
'sick_used' => (float)$row['sick_used'],
|
||
];
|
||
}
|
||
|
||
foreach ($userIds as $uid) {
|
||
$result[$uid] = $balanceMap[$uid] ?? $default;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
function updateLeaveBalance(PDO $pdo, int $userId, string $date, string $leaveType, float $hours): void
|
||
{
|
||
if ($leaveType === 'work' || $leaveType === 'holiday' || $leaveType === 'unpaid') {
|
||
return;
|
||
}
|
||
|
||
$year = (int)date('Y', strtotime($date));
|
||
|
||
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||
$stmt->execute([$userId, $year]);
|
||
$balance = $stmt->fetch();
|
||
|
||
if (!$balance) {
|
||
$stmt = $pdo->prepare(
|
||
'INSERT INTO leave_balances (user_id, year, vacation_total, vacation_used, sick_used)
|
||
VALUES (?, ?, 160, 0, 0)'
|
||
);
|
||
$stmt->execute([$userId, $year]);
|
||
}
|
||
|
||
if ($leaveType === 'vacation') {
|
||
$stmt = $pdo->prepare(
|
||
'UPDATE leave_balances SET vacation_used = vacation_used + ? WHERE user_id = ? AND year = ?'
|
||
);
|
||
$stmt->execute([$hours, $userId, $year]);
|
||
} elseif ($leaveType === 'sick') {
|
||
$stmt = $pdo->prepare('UPDATE leave_balances SET sick_used = sick_used + ? WHERE user_id = ? AND year = ?');
|
||
$stmt->execute([$hours, $userId, $year]);
|
||
}
|
||
}
|
||
|
||
function getCzechMonthName(int $month): string
|
||
{
|
||
$months = [
|
||
1 => 'Leden', 2 => 'Únor', 3 => 'Březen', 4 => 'Duben',
|
||
5 => 'Květen', 6 => 'Červen', 7 => 'Červenec', 8 => 'Srpen',
|
||
9 => 'Září', 10 => 'Říjen', 11 => 'Listopad', 12 => 'Prosinec',
|
||
];
|
||
return $months[$month] ?? '';
|
||
}
|
||
|
||
function getCzechDayName(int $dayOfWeek): string
|
||
{
|
||
$days = [
|
||
0 => 'neděle', 1 => 'pondělí', 2 => 'úterý', 3 => 'středa',
|
||
4 => 'čtvrtek', 5 => 'pátek', 6 => 'sobota',
|
||
];
|
||
return $days[$dayOfWeek] ?? '';
|
||
}
|
||
|
||
/**
|
||
* Enrich attendance records with project logs and project names (in-place)
|
||
*
|
||
* @param array<int, array<string, mixed>> $records
|
||
*/
|
||
function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
|
||
{
|
||
$recordIds = array_column($records, 'id');
|
||
$recordProjectLogs = [];
|
||
if (!empty($recordIds)) {
|
||
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||
$stmt = $pdo->prepare(
|
||
"SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
|
||
FROM attendance_project_logs
|
||
WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC"
|
||
);
|
||
$stmt->execute($recordIds);
|
||
foreach ($stmt->fetchAll() as $log) {
|
||
$recordProjectLogs[$log['attendance_id']][] = $log;
|
||
}
|
||
}
|
||
|
||
$projectIds = [];
|
||
foreach ($records as $rec) {
|
||
if ($rec['project_id']) {
|
||
$projectIds[$rec['project_id']] = $rec['project_id'];
|
||
}
|
||
}
|
||
foreach ($recordProjectLogs as $logs) {
|
||
foreach ($logs as $l) {
|
||
$projectIds[$l['project_id']] = $l['project_id'];
|
||
}
|
||
}
|
||
$projectNameMap = fetchProjectNames($projectIds);
|
||
|
||
foreach ($records as &$rec) {
|
||
$rec['project_name'] = $rec['project_id'] ? ($projectNameMap[$rec['project_id']] ?? null) : null;
|
||
$logs = $recordProjectLogs[$rec['id']] ?? [];
|
||
foreach ($logs as &$l) {
|
||
$l['project_name'] = $projectNameMap[$l['project_id']] ?? null;
|
||
}
|
||
unset($l);
|
||
$rec['project_logs'] = $logs;
|
||
}
|
||
unset($rec);
|
||
}
|
||
|
||
/**
|
||
* Calculate per-user totals from records array
|
||
*
|
||
* @param list<array<string, mixed>> $records
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
function calculateUserTotals(array $records, bool $includeRecords = false): array
|
||
{
|
||
$userTotals = [];
|
||
foreach ($records as $record) {
|
||
$uid = $record['user_id'];
|
||
if (!isset($userTotals[$uid])) {
|
||
$userTotals[$uid] = [
|
||
'name' => $record['user_name'],
|
||
'minutes' => 0,
|
||
'working' => false,
|
||
'vacation_hours' => 0,
|
||
'sick_hours' => 0,
|
||
'holiday_hours' => 0,
|
||
'unpaid_hours' => 0,
|
||
];
|
||
if ($includeRecords) {
|
||
$userTotals[$uid]['records'] = [];
|
||
}
|
||
}
|
||
|
||
$leaveType = $record['leave_type'] ?? 'work';
|
||
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||
|
||
if ($leaveType === 'vacation') {
|
||
$userTotals[$uid]['vacation_hours'] += $leaveHours;
|
||
} elseif ($leaveType === 'sick') {
|
||
$userTotals[$uid]['sick_hours'] += $leaveHours;
|
||
} elseif ($leaveType === 'holiday') {
|
||
$userTotals[$uid]['holiday_hours'] += $leaveHours;
|
||
} elseif ($leaveType === 'unpaid') {
|
||
$userTotals[$uid]['unpaid_hours'] += $leaveHours;
|
||
} else {
|
||
$userTotals[$uid]['minutes'] += calculateWorkMinutes($record);
|
||
}
|
||
|
||
if ($includeRecords) {
|
||
$userTotals[$uid]['records'][] = $record;
|
||
}
|
||
|
||
if ($record['arrival_time'] && !$record['departure_time']) {
|
||
$userTotals[$uid]['working'] = true;
|
||
}
|
||
}
|
||
return $userTotals;
|
||
}
|
||
|
||
/**
|
||
* Add monthly fund data and "working now" status to user totals
|
||
*
|
||
* @param array<array<string, mixed>> $userTotals
|
||
*/
|
||
function addFundDataToUserTotals(PDO $pdo, array &$userTotals, int $year, int $monthNum): void
|
||
{
|
||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||
|
||
foreach ($userTotals as $uid => &$ut) {
|
||
$workedHours = round($ut['minutes'] / 60, 1);
|
||
$leaveHours = $ut['vacation_hours'] + $ut['sick_hours'];
|
||
$covered = $workedHours + $leaveHours;
|
||
$ut['fund'] = $fund;
|
||
$ut['business_days'] = $businessDays;
|
||
$ut['worked_hours'] = $workedHours;
|
||
$ut['covered'] = $covered;
|
||
$ut['missing'] = max(0, round($fund - $covered, 1));
|
||
$ut['overtime'] = max(0, round($covered - $fund, 1));
|
||
}
|
||
unset($ut);
|
||
|
||
$today = date('Y-m-d');
|
||
$stmt = $pdo->prepare("
|
||
SELECT DISTINCT user_id FROM attendance
|
||
WHERE shift_date = ?
|
||
AND arrival_time IS NOT NULL
|
||
AND departure_time IS NULL
|
||
AND (leave_type IS NULL OR leave_type = 'work')
|
||
");
|
||
$stmt->execute([$today]);
|
||
$workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||
foreach ($workingNow as $uid) {
|
||
if (isset($userTotals[$uid])) {
|
||
$userTotals[$uid]['working'] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch project names from offers DB
|
||
*
|
||
* @param array<int, int> $projectIds
|
||
* @return array<int, string>
|
||
*/
|
||
function fetchProjectNames(array $projectIds): array
|
||
{
|
||
if (empty($projectIds)) {
|
||
return [];
|
||
}
|
||
try {
|
||
$pdo = db();
|
||
$placeholders = implode(',', array_fill(0, count($projectIds), '?'));
|
||
$stmt = $pdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||
$stmt->execute(array_values($projectIds));
|
||
$map = [];
|
||
foreach ($stmt->fetchAll() as $p) {
|
||
$map[$p['id']] = $p['project_number'] . ' – ' . $p['name'];
|
||
}
|
||
return $map;
|
||
} catch (\Exception $e) {
|
||
error_log('Failed to fetch project names: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|