Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.15% covered (danger)
16.15%
47 / 291
58.06% covered (warning)
58.06%
18 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 2
OvertimeCalculator
7.06% covered (danger)
7.06%
18 / 255
25.00% covered (danger)
25.00%
2 / 8
1976.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadRulesForStore
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getDefaultRules
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 calculateForWeek
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
56
 calculateDailyOvertime
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
156
 buildDailySegments
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
420
 saveRulesForStore
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getRuleHistory
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
OvertimeResult
80.56% covered (warning)
80.56%
29 / 36
69.57% covered (warning)
69.57%
16 / 23
28.23
0.00% covered (danger)
0.00%
0 / 1
 setWeekStart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWeekStart
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setEmployeeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEmployeeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRegularHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRegularHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOvertimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOvertimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDoubletimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDoubletimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDailyOvertimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDailyOvertimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setWeeklyOvertimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWeeklyOvertimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOvertimeMultiplier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOvertimeMultiplier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDoubletimeMultiplier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDoubletimeMultiplier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDailyBreakdown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDailyBreakdown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTotalHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOvertime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 toArray
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace BuyerKiosk\Scheduling\Services;
4
5use BuyerKiosk\Scheduling\Repositories\TimePunchRepository;
6use DateTime;
7use DateTimeZone;
8use 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 */
24class 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 */
542class 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}