Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
16.15% |
47 / 291 |
|
58.06% |
18 / 31 |
CRAP | |
0.00% |
0 / 2 |
| OvertimeCalculator | |
7.06% |
18 / 255 |
|
25.00% |
2 / 8 |
1976.60 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| loadRulesForStore | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
| getDefaultRules | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| calculateForWeek | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
56 | |||
| calculateDailyOvertime | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
156 | |||
| buildDailySegments | |
0.00% |
0 / 75 |
|
0.00% |
0 / 1 |
420 | |||
| saveRulesForStore | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
| getRuleHistory | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
| OvertimeResult | |
80.56% |
29 / 36 |
|
69.57% |
16 / 23 |
28.23 | |
0.00% |
0 / 1 |
| setWeekStart | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getWeekStart | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setEmployeeId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getEmployeeId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setRegularHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRegularHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setOvertimeHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getOvertimeHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setDoubletimeHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDoubletimeHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setDailyOvertimeHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDailyOvertimeHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setWeeklyOvertimeHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getWeeklyOvertimeHours | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setOvertimeMultiplier | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getOvertimeMultiplier | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setDoubletimeMultiplier | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDoubletimeMultiplier | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addDailyBreakdown | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDailyBreakdown | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTotalHours | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasOvertime | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| toArray | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Scheduling\Services; |
| 4 | |
| 5 | use BuyerKiosk\Scheduling\Repositories\TimePunchRepository; |
| 6 | use DateTime; |
| 7 | use DateTimeZone; |
| 8 | use PDO; |
| 9 | |
| 10 | /** |
| 11 | * OvertimeCalculator |
| 12 | * |
| 13 | * Calculates overtime hours based on jurisdiction-specific rules. |
| 14 | * Supports US FLSA, California daily/weekly/7th day rules, and Canadian provincial rules. |
| 15 | * |
| 16 | * Key calculation strategy (per SDD Section 1273-1287): |
| 17 | * - Compute daily overtime first using dailyRules |
| 18 | * - Apply weeklyCountableHoursPerDayCap for BC-style rules |
| 19 | * - Compute weekly overtime from remaining regular hours |
| 20 | * - Apply consecutiveDayRules as additional daily overlay |
| 21 | * |
| 22 | * @package BuyerKiosk\Scheduling\Services |
| 23 | */ |
| 24 | class OvertimeCalculator |
| 25 | { |
| 26 | /** |
| 27 | * Default rule schema version |
| 28 | */ |
| 29 | private const RULE_SCHEMA_VERSION = 1; |
| 30 | |
| 31 | private PDO $centralDb; |
| 32 | private TimePunchRepository $punchRepository; |
| 33 | |
| 34 | /** @var array<string, array> Cached rules by typeNum */ |
| 35 | private array $rulesCache = []; |
| 36 | |
| 37 | public function __construct(PDO $centralDb, TimePunchRepository $punchRepository) |
| 38 | { |
| 39 | $this->centralDb = $centralDb; |
| 40 | $this->punchRepository = $punchRepository; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Load overtime rules for a store |
| 45 | * |
| 46 | * @param string $typeNum Store type number |
| 47 | * @param DateTime|null $effectiveDate Date for which rules should be effective (null = now) |
| 48 | * @return array Rule configuration |
| 49 | */ |
| 50 | public function loadRulesForStore(string $typeNum, ?DateTime $effectiveDate = null): array |
| 51 | { |
| 52 | $effectiveDate = $effectiveDate ?? new DateTime(); |
| 53 | $cacheKey = $typeNum . '_' . $effectiveDate->format('Y-m-d'); |
| 54 | |
| 55 | if (isset($this->rulesCache[$cacheKey])) { |
| 56 | return $this->rulesCache[$cacheKey]; |
| 57 | } |
| 58 | |
| 59 | $stmt = $this->centralDb->prepare(" |
| 60 | SELECT * |
| 61 | FROM scheduleOvertimeRules |
| 62 | WHERE typeNum = :typeNum |
| 63 | AND effectiveStartDate <= :effectiveDate |
| 64 | ORDER BY effectiveStartDate DESC |
| 65 | LIMIT 1 |
| 66 | "); |
| 67 | $stmt->bindValue(':typeNum', $typeNum); |
| 68 | $stmt->bindValue(':effectiveDate', $effectiveDate->format('Y-m-d')); |
| 69 | $stmt->execute(); |
| 70 | |
| 71 | $row = $stmt->fetch(PDO::FETCH_ASSOC); |
| 72 | |
| 73 | if (!$row) { |
| 74 | // Return US-FLSA defaults if no rule configured |
| 75 | $rules = $this->getDefaultRules(); |
| 76 | } else { |
| 77 | $rules = [ |
| 78 | 'ruleId' => (int)$row['ruleId'], |
| 79 | 'typeNum' => $row['typeNum'], |
| 80 | 'countryCode' => $row['countryCode'], |
| 81 | 'regionCode' => $row['regionCode'], |
| 82 | 'ruleType' => $row['ruleType'], |
| 83 | 'presetKey' => $row['presetKey'], |
| 84 | 'effectiveStartDate' => $row['effectiveStartDate'], |
| 85 | 'ruleSchemaVersion' => (int)$row['ruleSchemaVersion'], |
| 86 | 'ruleJson' => json_decode($row['ruleJson'], true) ?? [], |
| 87 | ]; |
| 88 | } |
| 89 | |
| 90 | $this->rulesCache[$cacheKey] = $rules; |
| 91 | return $rules; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Get default US-FLSA rules |
| 96 | * |
| 97 | * @return array |
| 98 | */ |
| 99 | public function getDefaultRules(): array |
| 100 | { |
| 101 | return [ |
| 102 | 'ruleId' => null, |
| 103 | 'typeNum' => null, |
| 104 | 'countryCode' => 'US', |
| 105 | 'regionCode' => null, |
| 106 | 'ruleType' => 'preset', |
| 107 | 'presetKey' => 'US-FLSA', |
| 108 | 'effectiveStartDate' => null, |
| 109 | 'ruleSchemaVersion' => self::RULE_SCHEMA_VERSION, |
| 110 | 'ruleJson' => [ |
| 111 | 'overtimeMultiplier' => 1.5, |
| 112 | 'weeklyOvertimeThresholdHours' => 40, |
| 113 | 'dailyRules' => [], |
| 114 | 'dailyRulesMode' => 'absolute', |
| 115 | ], |
| 116 | ]; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Calculate overtime for an employee for a week |
| 121 | * |
| 122 | * @param int $employeeId Employee ID |
| 123 | * @param DateTime $weekStart Start of the work week |
| 124 | * @param array $rules Overtime rules (from loadRulesForStore) |
| 125 | * @param string $storeTimezone Store timezone for local date calculations |
| 126 | * @return OvertimeResult |
| 127 | */ |
| 128 | public function calculateForWeek( |
| 129 | int $employeeId, |
| 130 | DateTime $weekStart, |
| 131 | array $rules, |
| 132 | string $storeTimezone = 'America/Los_Angeles' |
| 133 | ): OvertimeResult { |
| 134 | $weekEnd = (clone $weekStart)->modify('+7 days'); |
| 135 | $ruleJson = $rules['ruleJson'] ?? []; |
| 136 | |
| 137 | // Get punches for the week |
| 138 | $punches = $this->punchRepository->findByEmployeeAndDateRange($employeeId, $weekStart, $weekEnd); |
| 139 | |
| 140 | // Build daily work segments from punches |
| 141 | $dailySegments = $this->buildDailySegments($punches, $storeTimezone); |
| 142 | |
| 143 | // Initialize result |
| 144 | $result = new OvertimeResult(); |
| 145 | $result->setWeekStart($weekStart); |
| 146 | $result->setEmployeeId($employeeId); |
| 147 | |
| 148 | // Step 1: Calculate daily overtime using dailyRules |
| 149 | $dailyOvertimeHours = 0; |
| 150 | $dailyDoubletimeHours = 0; |
| 151 | $dailyRegularHours = 0; |
| 152 | $countableHoursForWeekly = 0; |
| 153 | $consecutiveDaysWorked = 0; |
| 154 | $lastWorkDate = null; |
| 155 | |
| 156 | $weeklyCountableHoursPerDayCap = $ruleJson['weeklyCountableHoursPerDayCap'] ?? null; |
| 157 | $dailyRules = $ruleJson['dailyRules'] ?? []; |
| 158 | $dailyRulesMode = $ruleJson['dailyRulesMode'] ?? 'absolute'; |
| 159 | |
| 160 | ksort($dailySegments); |
| 161 | foreach ($dailySegments as $dateStr => $segments) { |
| 162 | $dayHours = array_sum(array_column($segments, 'hours')); |
| 163 | |
| 164 | // Track consecutive days for 7th day rules |
| 165 | $workDate = new DateTime($dateStr); |
| 166 | if ($lastWorkDate !== null) { |
| 167 | $daysDiff = $workDate->diff($lastWorkDate)->days; |
| 168 | if ($daysDiff === 1) { |
| 169 | $consecutiveDaysWorked++; |
| 170 | } else { |
| 171 | $consecutiveDaysWorked = 1; |
| 172 | } |
| 173 | } else { |
| 174 | $consecutiveDaysWorked = 1; |
| 175 | } |
| 176 | $lastWorkDate = $workDate; |
| 177 | |
| 178 | // Calculate daily overtime |
| 179 | $dailyResult = $this->calculateDailyOvertime( |
| 180 | $dayHours, |
| 181 | $dailyRules, |
| 182 | $dailyRulesMode, |
| 183 | $consecutiveDaysWorked, |
| 184 | $ruleJson['consecutiveDayRules'] ?? [] |
| 185 | ); |
| 186 | |
| 187 | $dailyRegularHours += $dailyResult['regular']; |
| 188 | $dailyOvertimeHours += $dailyResult['overtime']; |
| 189 | $dailyDoubletimeHours += $dailyResult['doubletime']; |
| 190 | |
| 191 | // Calculate countable hours for weekly (apply daily cap if set) |
| 192 | $countableForDay = $dailyResult['regular']; |
| 193 | if ($weeklyCountableHoursPerDayCap !== null) { |
| 194 | $countableForDay = min($countableForDay, $weeklyCountableHoursPerDayCap); |
| 195 | } |
| 196 | $countableHoursForWeekly += $countableForDay; |
| 197 | |
| 198 | $result->addDailyBreakdown($dateStr, [ |
| 199 | 'totalHours' => $dayHours, |
| 200 | 'regularHours' => $dailyResult['regular'], |
| 201 | 'overtimeHours' => $dailyResult['overtime'], |
| 202 | 'doubletimeHours' => $dailyResult['doubletime'], |
| 203 | 'consecutiveDay' => $consecutiveDaysWorked, |
| 204 | ]); |
| 205 | } |
| 206 | |
| 207 | // Step 2: Calculate weekly overtime from remaining eligible hours |
| 208 | $weeklyOvertimeThreshold = $ruleJson['weeklyOvertimeThresholdHours'] ?? null; |
| 209 | $weeklyOvertimeHours = 0; |
| 210 | |
| 211 | if ($weeklyOvertimeThreshold !== null && $countableHoursForWeekly > $weeklyOvertimeThreshold) { |
| 212 | // Weekly overtime comes from the "regular" hours pool |
| 213 | $weeklyOvertimeHours = $countableHoursForWeekly - $weeklyOvertimeThreshold; |
| 214 | |
| 215 | // Reduce daily regular hours by weekly overtime amount |
| 216 | $dailyRegularHours -= $weeklyOvertimeHours; |
| 217 | } |
| 218 | |
| 219 | // Total overtime is daily + weekly (doubletime is separate) |
| 220 | $totalOvertimeHours = $dailyOvertimeHours + $weeklyOvertimeHours; |
| 221 | |
| 222 | $result->setRegularHours(max(0, $dailyRegularHours)); |
| 223 | $result->setOvertimeHours($totalOvertimeHours); |
| 224 | $result->setDoubletimeHours($dailyDoubletimeHours); |
| 225 | $result->setDailyOvertimeHours($dailyOvertimeHours); |
| 226 | $result->setWeeklyOvertimeHours($weeklyOvertimeHours); |
| 227 | $result->setOvertimeMultiplier($ruleJson['overtimeMultiplier'] ?? 1.5); |
| 228 | $result->setDoubletimeMultiplier($ruleJson['doubletimeMultiplier'] ?? 2.0); |
| 229 | |
| 230 | return $result; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Calculate daily overtime for a single day |
| 235 | * |
| 236 | * @param float $totalHours Total hours worked in the day |
| 237 | * @param array $dailyRules Daily overtime rules (sorted by threshold) |
| 238 | * @param string $mode 'absolute' or 'scheduledDailyHours' |
| 239 | * @param int $consecutiveDaysWorked Number of consecutive days worked |
| 240 | * @param array $consecutiveDayRules Rules for consecutive day overtime (e.g., CA 7th day) |
| 241 | * @return array{regular: float, overtime: float, doubletime: float} |
| 242 | */ |
| 243 | private function calculateDailyOvertime( |
| 244 | float $totalHours, |
| 245 | array $dailyRules, |
| 246 | string $mode, |
| 247 | int $consecutiveDaysWorked, |
| 248 | array $consecutiveDayRules |
| 249 | ): array { |
| 250 | $result = [ |
| 251 | 'regular' => $totalHours, |
| 252 | 'overtime' => 0, |
| 253 | 'doubletime' => 0, |
| 254 | ]; |
| 255 | |
| 256 | // Only 'absolute' is supported right now (thresholds are direct hours worked). |
| 257 | // Other modes can be added when we have additional inputs (e.g., scheduled daily hours). |
| 258 | if ($mode !== 'absolute') { |
| 259 | $mode = 'absolute'; |
| 260 | } |
| 261 | |
| 262 | if (empty($dailyRules) && empty($consecutiveDayRules)) { |
| 263 | return $result; |
| 264 | } |
| 265 | |
| 266 | // Apply consecutive day rules first (e.g., CA 7th day) |
| 267 | $applicableConsecutiveRules = array_filter( |
| 268 | $consecutiveDayRules, |
| 269 | fn($rule) => ($rule['dayNumber'] ?? 0) === $consecutiveDaysWorked |
| 270 | ); |
| 271 | |
| 272 | if (!empty($applicableConsecutiveRules)) { |
| 273 | // Sort by threshold descending to apply highest thresholds first |
| 274 | usort($applicableConsecutiveRules, fn($a, $b) => ($b['thresholdHours'] ?? 0) <=> ($a['thresholdHours'] ?? 0)); |
| 275 | |
| 276 | $remainingHours = $totalHours; |
| 277 | foreach ($applicableConsecutiveRules as $rule) { |
| 278 | $threshold = $rule['thresholdHours'] ?? 0; |
| 279 | $multiplier = $rule['multiplier'] ?? 1.5; |
| 280 | |
| 281 | if ($remainingHours > $threshold) { |
| 282 | $hoursAtRate = $remainingHours - $threshold; |
| 283 | $remainingHours = $threshold; |
| 284 | |
| 285 | if ($multiplier >= 2.0) { |
| 286 | $result['doubletime'] += $hoursAtRate; |
| 287 | } else { |
| 288 | $result['overtime'] += $hoursAtRate; |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | $result['regular'] = $remainingHours; |
| 293 | return $result; |
| 294 | } |
| 295 | |
| 296 | // Apply standard daily rules |
| 297 | if (empty($dailyRules)) { |
| 298 | return $result; |
| 299 | } |
| 300 | |
| 301 | // Sort by threshold descending |
| 302 | usort($dailyRules, fn($a, $b) => ($b['thresholdHours'] ?? 0) <=> ($a['thresholdHours'] ?? 0)); |
| 303 | |
| 304 | $remainingHours = $totalHours; |
| 305 | foreach ($dailyRules as $rule) { |
| 306 | $threshold = $rule['thresholdHours'] ?? 8; |
| 307 | $multiplier = $rule['multiplier'] ?? 1.5; |
| 308 | |
| 309 | if ($remainingHours > $threshold) { |
| 310 | $hoursAtRate = $remainingHours - $threshold; |
| 311 | $remainingHours = $threshold; |
| 312 | |
| 313 | if ($multiplier >= 2.0) { |
| 314 | $result['doubletime'] += $hoursAtRate; |
| 315 | } else { |
| 316 | $result['overtime'] += $hoursAtRate; |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | $result['regular'] = $remainingHours; |
| 322 | return $result; |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Build daily work segments from time punches |
| 327 | * |
| 328 | * @param array $punches Array of TimePunch objects |
| 329 | * @param string $timezone Store timezone |
| 330 | * @return array<string, array<array{start: DateTime, end: DateTime, hours: float}>> |
| 331 | */ |
| 332 | private function buildDailySegments(array $punches, string $timezone): array |
| 333 | { |
| 334 | $tz = new DateTimeZone($timezone); |
| 335 | $utc = new DateTimeZone('UTC'); |
| 336 | $segments = []; |
| 337 | $clockInTime = null; |
| 338 | $unpaidBreakStartTime = null; |
| 339 | /** @var array<int, array{startTs: int, endTs: int}> $unpaidBreaks */ |
| 340 | $unpaidBreaks = []; |
| 341 | |
| 342 | $flushSession = function (DateTime $clockOutTime) use (&$segments, &$clockInTime, &$unpaidBreakStartTime, &$unpaidBreaks, $tz, $utc): void { |
| 343 | if ($clockInTime === null) { |
| 344 | return; |
| 345 | } |
| 346 | |
| 347 | $startTs = $clockInTime->getTimestamp(); |
| 348 | $endTs = $clockOutTime->getTimestamp(); |
| 349 | |
| 350 | if ($endTs <= $startTs) { |
| 351 | $clockInTime = null; |
| 352 | $unpaidBreakStartTime = null; |
| 353 | $unpaidBreaks = []; |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | if ($unpaidBreakStartTime !== null) { |
| 358 | $breakStartTs = $unpaidBreakStartTime->getTimestamp(); |
| 359 | if ($endTs > $breakStartTs) { |
| 360 | $unpaidBreaks[] = ['startTs' => $breakStartTs, 'endTs' => $endTs]; |
| 361 | } |
| 362 | $unpaidBreakStartTime = null; |
| 363 | } |
| 364 | |
| 365 | $segmentStartTs = $startTs; |
| 366 | while ($segmentStartTs < $endTs) { |
| 367 | $local = (new DateTime("@{$segmentStartTs}"))->setTimezone($tz); |
| 368 | $dateStr = $local->format('Y-m-d'); |
| 369 | |
| 370 | $nextLocalMidnight = (clone $local)->setTime(0, 0, 0)->modify('+1 day'); |
| 371 | $nextMidnightUtcTs = (clone $nextLocalMidnight)->setTimezone($utc)->getTimestamp(); |
| 372 | |
| 373 | $segmentEndTs = min($endTs, $nextMidnightUtcTs); |
| 374 | $grossSeconds = max(0, $segmentEndTs - $segmentStartTs); |
| 375 | |
| 376 | $breakSeconds = 0; |
| 377 | foreach ($unpaidBreaks as $break) { |
| 378 | $overlapStart = max($segmentStartTs, $break['startTs']); |
| 379 | $overlapEnd = min($segmentEndTs, $break['endTs']); |
| 380 | if ($overlapEnd > $overlapStart) { |
| 381 | $breakSeconds += ($overlapEnd - $overlapStart); |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | $workedSeconds = max(0, $grossSeconds - $breakSeconds); |
| 386 | if ($workedSeconds === 0) { |
| 387 | $segmentStartTs = $segmentEndTs; |
| 388 | continue; |
| 389 | } |
| 390 | |
| 391 | $workedHours = $workedSeconds / 3600; |
| 392 | |
| 393 | $segments[$dateStr] ??= []; |
| 394 | $segments[$dateStr][] = [ |
| 395 | 'start' => (new DateTime("@{$segmentStartTs}"))->setTimezone($utc), |
| 396 | 'end' => (new DateTime("@{$segmentEndTs}"))->setTimezone($utc), |
| 397 | 'hours' => $workedHours, |
| 398 | ]; |
| 399 | |
| 400 | $segmentStartTs = $segmentEndTs; |
| 401 | } |
| 402 | |
| 403 | $clockInTime = null; |
| 404 | $unpaidBreakStartTime = null; |
| 405 | $unpaidBreaks = []; |
| 406 | }; |
| 407 | |
| 408 | foreach ($punches as $punch) { |
| 409 | $punchType = $punch->getPunchType(); |
| 410 | $punchTime = $punch->getPunchTime(); |
| 411 | |
| 412 | switch ($punchType) { |
| 413 | case 'clockIn': |
| 414 | $clockInTime = $punchTime; |
| 415 | $unpaidBreakStartTime = null; |
| 416 | $unpaidBreaks = []; |
| 417 | break; |
| 418 | |
| 419 | case 'clockOut': |
| 420 | $flushSession($punchTime); |
| 421 | break; |
| 422 | |
| 423 | case 'breakStart': |
| 424 | if ($clockInTime !== null && $punch->getBreakType() === 'unpaid' && $unpaidBreakStartTime === null) { |
| 425 | $unpaidBreakStartTime = $punchTime; |
| 426 | } |
| 427 | break; |
| 428 | |
| 429 | case 'breakEnd': |
| 430 | if ($clockInTime !== null && $unpaidBreakStartTime !== null) { |
| 431 | $breakStartTs = $unpaidBreakStartTime->getTimestamp(); |
| 432 | $breakEndTs = $punchTime->getTimestamp(); |
| 433 | |
| 434 | if ($breakEndTs > $breakStartTs) { |
| 435 | $unpaidBreaks[] = ['startTs' => $breakStartTs, 'endTs' => $breakEndTs]; |
| 436 | } |
| 437 | |
| 438 | $unpaidBreakStartTime = null; |
| 439 | } |
| 440 | break; |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | return $segments; |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * Save overtime rules for a store |
| 449 | * |
| 450 | * @param string $typeNum Store type number |
| 451 | * @param string $countryCode Country code (US or CA) |
| 452 | * @param string $regionCode State/province code |
| 453 | * @param string $ruleType 'preset' or 'custom' |
| 454 | * @param string $presetKey Preset key (e.g., 'US-CA', 'CA-ON') |
| 455 | * @param DateTime $effectiveStartDate When rules take effect |
| 456 | * @param array $ruleJson Rule configuration JSON |
| 457 | * @return int Rule ID |
| 458 | */ |
| 459 | public function saveRulesForStore( |
| 460 | string $typeNum, |
| 461 | string $countryCode, |
| 462 | string $regionCode, |
| 463 | string $ruleType, |
| 464 | string $presetKey, |
| 465 | DateTime $effectiveStartDate, |
| 466 | array $ruleJson |
| 467 | ): int { |
| 468 | $stmt = $this->centralDb->prepare(" |
| 469 | INSERT INTO scheduleOvertimeRules |
| 470 | (typeNum, countryCode, regionCode, ruleType, presetKey, effectiveStartDate, ruleSchemaVersion, ruleJson) |
| 471 | VALUES |
| 472 | (:typeNum, :countryCode, :regionCode, :ruleType, :presetKey, :effectiveStartDate, :ruleSchemaVersion, :ruleJson) |
| 473 | ON DUPLICATE KEY UPDATE |
| 474 | countryCode = VALUES(countryCode), |
| 475 | regionCode = VALUES(regionCode), |
| 476 | ruleType = VALUES(ruleType), |
| 477 | presetKey = VALUES(presetKey), |
| 478 | ruleSchemaVersion = VALUES(ruleSchemaVersion), |
| 479 | ruleJson = VALUES(ruleJson) |
| 480 | "); |
| 481 | |
| 482 | $stmt->bindValue(':typeNum', $typeNum); |
| 483 | $stmt->bindValue(':countryCode', $countryCode); |
| 484 | $stmt->bindValue(':regionCode', $regionCode); |
| 485 | $stmt->bindValue(':ruleType', $ruleType); |
| 486 | $stmt->bindValue(':presetKey', $presetKey); |
| 487 | $stmt->bindValue(':effectiveStartDate', $effectiveStartDate->format('Y-m-d')); |
| 488 | $stmt->bindValue(':ruleSchemaVersion', self::RULE_SCHEMA_VERSION, PDO::PARAM_INT); |
| 489 | $stmt->bindValue(':ruleJson', json_encode($ruleJson)); |
| 490 | |
| 491 | $stmt->execute(); |
| 492 | |
| 493 | // Clear cache for this store |
| 494 | foreach ($this->rulesCache as $key => $value) { |
| 495 | if (str_starts_with($key, $typeNum . '_')) { |
| 496 | unset($this->rulesCache[$key]); |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | return (int)$this->centralDb->lastInsertId(); |
| 501 | } |
| 502 | |
| 503 | /** |
| 504 | * Get overtime rule history for a store |
| 505 | * |
| 506 | * @param string $typeNum Store type number |
| 507 | * @return array List of rule configurations |
| 508 | */ |
| 509 | public function getRuleHistory(string $typeNum): array |
| 510 | { |
| 511 | $stmt = $this->centralDb->prepare(" |
| 512 | SELECT * |
| 513 | FROM scheduleOvertimeRules |
| 514 | WHERE typeNum = :typeNum |
| 515 | ORDER BY effectiveStartDate DESC |
| 516 | "); |
| 517 | $stmt->bindValue(':typeNum', $typeNum); |
| 518 | $stmt->execute(); |
| 519 | |
| 520 | $rules = []; |
| 521 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 522 | $rules[] = [ |
| 523 | 'ruleId' => (int)$row['ruleId'], |
| 524 | 'typeNum' => $row['typeNum'], |
| 525 | 'countryCode' => $row['countryCode'], |
| 526 | 'regionCode' => $row['regionCode'], |
| 527 | 'ruleType' => $row['ruleType'], |
| 528 | 'presetKey' => $row['presetKey'], |
| 529 | 'effectiveStartDate' => $row['effectiveStartDate'], |
| 530 | 'ruleSchemaVersion' => (int)$row['ruleSchemaVersion'], |
| 531 | 'ruleJson' => json_decode($row['ruleJson'], true) ?? [], |
| 532 | ]; |
| 533 | } |
| 534 | |
| 535 | return $rules; |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | /** |
| 540 | * Result object for overtime calculations |
| 541 | */ |
| 542 | class OvertimeResult |
| 543 | { |
| 544 | private DateTime $weekStart; |
| 545 | private int $employeeId; |
| 546 | private float $regularHours = 0; |
| 547 | private float $overtimeHours = 0; |
| 548 | private float $doubletimeHours = 0; |
| 549 | private float $dailyOvertimeHours = 0; |
| 550 | private float $weeklyOvertimeHours = 0; |
| 551 | private float $overtimeMultiplier = 1.5; |
| 552 | private float $doubletimeMultiplier = 2.0; |
| 553 | /** @var array<string, array> */ |
| 554 | private array $dailyBreakdown = []; |
| 555 | |
| 556 | public function setWeekStart(DateTime $weekStart): void |
| 557 | { |
| 558 | $this->weekStart = $weekStart; |
| 559 | } |
| 560 | |
| 561 | public function getWeekStart(): DateTime |
| 562 | { |
| 563 | return $this->weekStart; |
| 564 | } |
| 565 | |
| 566 | public function setEmployeeId(int $employeeId): void |
| 567 | { |
| 568 | $this->employeeId = $employeeId; |
| 569 | } |
| 570 | |
| 571 | public function getEmployeeId(): int |
| 572 | { |
| 573 | return $this->employeeId; |
| 574 | } |
| 575 | |
| 576 | public function setRegularHours(float $hours): void |
| 577 | { |
| 578 | $this->regularHours = $hours; |
| 579 | } |
| 580 | |
| 581 | public function getRegularHours(): float |
| 582 | { |
| 583 | return $this->regularHours; |
| 584 | } |
| 585 | |
| 586 | public function setOvertimeHours(float $hours): void |
| 587 | { |
| 588 | $this->overtimeHours = $hours; |
| 589 | } |
| 590 | |
| 591 | public function getOvertimeHours(): float |
| 592 | { |
| 593 | return $this->overtimeHours; |
| 594 | } |
| 595 | |
| 596 | public function setDoubletimeHours(float $hours): void |
| 597 | { |
| 598 | $this->doubletimeHours = $hours; |
| 599 | } |
| 600 | |
| 601 | public function getDoubletimeHours(): float |
| 602 | { |
| 603 | return $this->doubletimeHours; |
| 604 | } |
| 605 | |
| 606 | public function setDailyOvertimeHours(float $hours): void |
| 607 | { |
| 608 | $this->dailyOvertimeHours = $hours; |
| 609 | } |
| 610 | |
| 611 | public function getDailyOvertimeHours(): float |
| 612 | { |
| 613 | return $this->dailyOvertimeHours; |
| 614 | } |
| 615 | |
| 616 | public function setWeeklyOvertimeHours(float $hours): void |
| 617 | { |
| 618 | $this->weeklyOvertimeHours = $hours; |
| 619 | } |
| 620 | |
| 621 | public function getWeeklyOvertimeHours(): float |
| 622 | { |
| 623 | return $this->weeklyOvertimeHours; |
| 624 | } |
| 625 | |
| 626 | public function setOvertimeMultiplier(float $multiplier): void |
| 627 | { |
| 628 | $this->overtimeMultiplier = $multiplier; |
| 629 | } |
| 630 | |
| 631 | public function getOvertimeMultiplier(): float |
| 632 | { |
| 633 | return $this->overtimeMultiplier; |
| 634 | } |
| 635 | |
| 636 | public function setDoubletimeMultiplier(float $multiplier): void |
| 637 | { |
| 638 | $this->doubletimeMultiplier = $multiplier; |
| 639 | } |
| 640 | |
| 641 | public function getDoubletimeMultiplier(): float |
| 642 | { |
| 643 | return $this->doubletimeMultiplier; |
| 644 | } |
| 645 | |
| 646 | public function addDailyBreakdown(string $date, array $data): void |
| 647 | { |
| 648 | $this->dailyBreakdown[$date] = $data; |
| 649 | } |
| 650 | |
| 651 | /** |
| 652 | * @return array<string, array> |
| 653 | */ |
| 654 | public function getDailyBreakdown(): array |
| 655 | { |
| 656 | return $this->dailyBreakdown; |
| 657 | } |
| 658 | |
| 659 | public function getTotalHours(): float |
| 660 | { |
| 661 | return $this->regularHours + $this->overtimeHours + $this->doubletimeHours; |
| 662 | } |
| 663 | |
| 664 | public function hasOvertime(): bool |
| 665 | { |
| 666 | return $this->overtimeHours > 0 || $this->doubletimeHours > 0; |
| 667 | } |
| 668 | |
| 669 | /** |
| 670 | * @return array |
| 671 | */ |
| 672 | public function toArray(): array |
| 673 | { |
| 674 | return [ |
| 675 | 'weekStart' => $this->weekStart->format('Y-m-d'), |
| 676 | 'employeeId' => $this->employeeId, |
| 677 | 'totalHours' => $this->getTotalHours(), |
| 678 | 'regularHours' => $this->regularHours, |
| 679 | 'overtimeHours' => $this->overtimeHours, |
| 680 | 'doubletimeHours' => $this->doubletimeHours, |
| 681 | 'dailyOvertimeHours' => $this->dailyOvertimeHours, |
| 682 | 'weeklyOvertimeHours' => $this->weeklyOvertimeHours, |
| 683 | 'overtimeMultiplier' => $this->overtimeMultiplier, |
| 684 | 'doubletimeMultiplier' => $this->doubletimeMultiplier, |
| 685 | 'hasOvertime' => $this->hasOvertime(), |
| 686 | 'dailyBreakdown' => $this->dailyBreakdown, |
| 687 | ]; |
| 688 | } |
| 689 | } |