Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.07% covered (success)
90.07%
127 / 141
76.19% covered (warning)
76.19%
32 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Timesheet
90.07% covered (success)
90.07%
127 / 141
76.19% covered (warning)
76.19%
32 / 42
56.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromRow
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
12.03
 getTimesheetId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEmployeeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWeekStartDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWeekEndDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScheduledTotalHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScheduledRegularHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScheduledOvertimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScheduledDoubletimeHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTotalHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRegularHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOvertimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDoubletimeHours
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTotalPay
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApprovedByUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApprovedAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExportedAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreatedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUpdatedAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEmployeeName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApproverName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWarnings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTimesheetId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setScheduledHours
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setActualHours
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setTotalPay
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStatus
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setEmployeeName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setApproverName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setWarnings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 approve
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 markExported
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 unlock
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isPending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isApproved
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isExported
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
 getHoursVariance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toDbArray
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace BuyerKiosk\Scheduling\Models;
4
5use DateTime;
6use DateTimeInterface;
7use DateTimeZone;
8use JsonSerializable;
9
10/**
11 * Timesheet Entity Model
12 *
13 * Represents a weekly timesheet from the scheduleTimesheets table.
14 *
15 * @package BuyerKiosk\Scheduling\Models
16 */
17class Timesheet implements JsonSerializable
18{
19    public const STATUS_PENDING = 'pending';
20    public const STATUS_APPROVED = 'approved';
21    public const STATUS_EXPORTED = 'exported';
22
23    private ?int $timesheetId = null;
24    private int $employeeId;
25    private DateTime $weekStartDate;
26    private DateTime $weekEndDate;
27    private float $scheduledTotalHours = 0;
28    private float $scheduledRegularHours = 0;
29    private float $scheduledOvertimeHours = 0;
30    private float $scheduledDoubletimeHours = 0;
31    private float $totalHours = 0;
32    private float $regularHours = 0;
33    private float $overtimeHours = 0;
34    private float $doubletimeHours = 0;
35    private float $totalPay = 0;
36    private string $status = self::STATUS_PENDING;
37    private ?int $approvedByUserId = null;
38    private ?DateTime $approvedAt = null;
39    private ?DateTime $exportedAt = null;
40    private ?DateTime $createdAt = null;
41    private ?DateTime $updatedAt = null;
42
43    // Hydrated from JOINs (not persisted)
44    private ?string $employeeName = null;
45    private ?string $approverName = null;
46    /** @var array<string, mixed>|null Warnings for this timesheet */
47    private ?array $warnings = null;
48
49    /**
50     * Create a new Timesheet instance
51     *
52     * @param int $employeeId Employee ID
53     * @param DateTime $weekStartDate Start of the week
54     * @param DateTime $weekEndDate End of the week
55     */
56    public function __construct(
57        int $employeeId,
58        DateTime $weekStartDate,
59        DateTime $weekEndDate
60    ) {
61        $this->employeeId = $employeeId;
62        $this->weekStartDate = $weekStartDate;
63        $this->weekEndDate = $weekEndDate;
64    }
65
66    /**
67     * Create Timesheet from database row
68     *
69     * @param array $row Database row
70     * @return self
71     */
72    public static function fromRow(array $row): self
73    {
74        // weekStartDate/weekEndDate are stored as store-local dates (no timezone component),
75        // but we keep an explicit timezone to avoid environment-dependent parsing.
76        $utc = new DateTimeZone('UTC');
77
78        $timesheet = new self(
79            (int)$row['employeeId'],
80            new DateTime($row['weekStartDate'], $utc),
81            new DateTime($row['weekEndDate'], $utc)
82        );
83
84        $timesheet->timesheetId = isset($row['timesheetId']) ? (int)$row['timesheetId'] : null;
85        $timesheet->scheduledTotalHours = (float)($row['scheduledTotalHours'] ?? 0);
86        $timesheet->scheduledRegularHours = (float)($row['scheduledRegularHours'] ?? 0);
87        $timesheet->scheduledOvertimeHours = (float)($row['scheduledOvertimeHours'] ?? 0);
88        $timesheet->scheduledDoubletimeHours = (float)($row['scheduledDoubletimeHours'] ?? 0);
89        $timesheet->totalHours = (float)($row['totalHours'] ?? 0);
90        $timesheet->regularHours = (float)($row['regularHours'] ?? 0);
91        $timesheet->overtimeHours = (float)($row['overtimeHours'] ?? 0);
92        $timesheet->doubletimeHours = (float)($row['doubletimeHours'] ?? 0);
93        $timesheet->totalPay = (float)($row['totalPay'] ?? 0);
94        $timesheet->status = $row['status'] ?? self::STATUS_PENDING;
95        $timesheet->approvedByUserId = isset($row['approvedByUserId']) ? (int)$row['approvedByUserId'] : null;
96
97        if (isset($row['approved_at']) && $row['approved_at'] !== null) {
98            $timesheet->approvedAt = new DateTime($row['approved_at'], $utc);
99        }
100        if (isset($row['exported_at']) && $row['exported_at'] !== null) {
101            $timesheet->exportedAt = new DateTime($row['exported_at'], $utc);
102        }
103        if (isset($row['created_at'])) {
104            $timesheet->createdAt = new DateTime($row['created_at'], $utc);
105        }
106        if (isset($row['updated_at'])) {
107            $timesheet->updatedAt = new DateTime($row['updated_at'], $utc);
108        }
109
110        // Hydrate joined fields if present
111        if (isset($row['employeeName'])) {
112            $timesheet->employeeName = $row['employeeName'];
113        } elseif (isset($row['employeeFirstName'], $row['employeeLastName'])) {
114            $timesheet->employeeName = trim($row['employeeFirstName'] . ' ' . $row['employeeLastName']);
115        }
116        if (isset($row['approverName'])) {
117            $timesheet->approverName = $row['approverName'];
118        }
119
120        return $timesheet;
121    }
122
123    // Getters
124
125    public function getTimesheetId(): ?int
126    {
127        return $this->timesheetId;
128    }
129
130    public function getEmployeeId(): int
131    {
132        return $this->employeeId;
133    }
134
135    public function getWeekStartDate(): DateTime
136    {
137        return $this->weekStartDate;
138    }
139
140    public function getWeekEndDate(): DateTime
141    {
142        return $this->weekEndDate;
143    }
144
145    public function getScheduledTotalHours(): float
146    {
147        return $this->scheduledTotalHours;
148    }
149
150    public function getScheduledRegularHours(): float
151    {
152        return $this->scheduledRegularHours;
153    }
154
155    public function getScheduledOvertimeHours(): float
156    {
157        return $this->scheduledOvertimeHours;
158    }
159
160    public function getScheduledDoubletimeHours(): float
161    {
162        return $this->scheduledDoubletimeHours;
163    }
164
165    public function getTotalHours(): float
166    {
167        return $this->totalHours;
168    }
169
170    public function getRegularHours(): float
171    {
172        return $this->regularHours;
173    }
174
175    public function getOvertimeHours(): float
176    {
177        return $this->overtimeHours;
178    }
179
180    public function getDoubletimeHours(): float
181    {
182        return $this->doubletimeHours;
183    }
184
185    public function getTotalPay(): float
186    {
187        return $this->totalPay;
188    }
189
190    public function getStatus(): string
191    {
192        return $this->status;
193    }
194
195    public function getApprovedByUserId(): ?int
196    {
197        return $this->approvedByUserId;
198    }
199
200    public function getApprovedAt(): ?DateTime
201    {
202        return $this->approvedAt;
203    }
204
205    public function getExportedAt(): ?DateTime
206    {
207        return $this->exportedAt;
208    }
209
210    public function getCreatedAt(): ?DateTime
211    {
212        return $this->createdAt;
213    }
214
215    public function getUpdatedAt(): ?DateTime
216    {
217        return $this->updatedAt;
218    }
219
220    public function getEmployeeName(): ?string
221    {
222        return $this->employeeName;
223    }
224
225    public function getApproverName(): ?string
226    {
227        return $this->approverName;
228    }
229
230    /**
231     * @return array<string, mixed>|null
232     */
233    public function getWarnings(): ?array
234    {
235        return $this->warnings;
236    }
237
238    // Setters
239
240    public function setTimesheetId(int $timesheetId): self
241    {
242        $this->timesheetId = $timesheetId;
243        return $this;
244    }
245
246    public function setScheduledHours(float $total, float $regular, float $overtime, float $doubletime = 0): self
247    {
248        $this->scheduledTotalHours = $total;
249        $this->scheduledRegularHours = $regular;
250        $this->scheduledOvertimeHours = $overtime;
251        $this->scheduledDoubletimeHours = $doubletime;
252        return $this;
253    }
254
255    public function setActualHours(float $total, float $regular, float $overtime, float $doubletime = 0): self
256    {
257        $this->totalHours = $total;
258        $this->regularHours = $regular;
259        $this->overtimeHours = $overtime;
260        $this->doubletimeHours = $doubletime;
261        return $this;
262    }
263
264    public function setTotalPay(float $totalPay): self
265    {
266        $this->totalPay = $totalPay;
267        return $this;
268    }
269
270    public function setStatus(string $status): self
271    {
272        $this->status = $status;
273        return $this;
274    }
275
276    public function setEmployeeName(?string $name): self
277    {
278        $this->employeeName = $name;
279        return $this;
280    }
281
282    public function setApproverName(?string $name): self
283    {
284        $this->approverName = $name;
285        return $this;
286    }
287
288    /**
289     * @param array<string, mixed>|null $warnings
290     */
291    public function setWarnings(?array $warnings): self
292    {
293        $this->warnings = $warnings;
294        return $this;
295    }
296
297    // Business Logic
298
299    /**
300     * Approve the timesheet
301     *
302     * @param int $approverUserId User ID who approved
303     * @return self
304     */
305    public function approve(int $approverUserId): self
306    {
307        $this->status = self::STATUS_APPROVED;
308        $this->approvedByUserId = $approverUserId;
309        $this->approvedAt = new DateTime();
310        return $this;
311    }
312
313    /**
314     * Mark the timesheet as exported
315     *
316     * @return self
317     */
318    public function markExported(): self
319    {
320        $this->status = self::STATUS_EXPORTED;
321        $this->exportedAt = new DateTime();
322        return $this;
323    }
324
325    /**
326     * Unlock the timesheet (set back to pending)
327     *
328     * @return self
329     */
330    public function unlock(): self
331    {
332        $this->status = self::STATUS_PENDING;
333        $this->approvedByUserId = null;
334        $this->approvedAt = null;
335        return $this;
336    }
337
338    /**
339     * Check if timesheet is pending
340     *
341     * @return bool
342     */
343    public function isPending(): bool
344    {
345        return $this->status === self::STATUS_PENDING;
346    }
347
348    /**
349     * Check if timesheet is approved
350     *
351     * @return bool
352     */
353    public function isApproved(): bool
354    {
355        return $this->status === self::STATUS_APPROVED;
356    }
357
358    /**
359     * Check if timesheet is exported
360     *
361     * @return bool
362     */
363    public function isExported(): bool
364    {
365        return $this->status === self::STATUS_EXPORTED;
366    }
367
368    /**
369     * Check if timesheet has overtime
370     *
371     * @return bool
372     */
373    public function hasOvertime(): bool
374    {
375        return $this->overtimeHours > 0 || $this->doubletimeHours > 0;
376    }
377
378    /**
379     * Get variance between scheduled and actual hours
380     *
381     * @return float Positive = worked more than scheduled, negative = worked less
382     */
383    public function getHoursVariance(): float
384    {
385        return $this->totalHours - $this->scheduledTotalHours;
386    }
387
388    /**
389     * Convert to array for database insert/update
390     *
391     * @return array
392     */
393    public function toDbArray(): array
394    {
395        return [
396            'employeeId' => $this->employeeId,
397            'weekStartDate' => $this->weekStartDate->format('Y-m-d'),
398            'weekEndDate' => $this->weekEndDate->format('Y-m-d'),
399            'scheduledTotalHours' => $this->scheduledTotalHours,
400            'scheduledRegularHours' => $this->scheduledRegularHours,
401            'scheduledOvertimeHours' => $this->scheduledOvertimeHours,
402            'scheduledDoubletimeHours' => $this->scheduledDoubletimeHours,
403            'totalHours' => $this->totalHours,
404            'regularHours' => $this->regularHours,
405            'overtimeHours' => $this->overtimeHours,
406            'doubletimeHours' => $this->doubletimeHours,
407            'totalPay' => $this->totalPay,
408            'status' => $this->status,
409            'approvedByUserId' => $this->approvedByUserId,
410            'approved_at' => $this->approvedAt?->format('Y-m-d H:i:s'),
411            'exported_at' => $this->exportedAt?->format('Y-m-d H:i:s'),
412        ];
413    }
414
415    /**
416     * JSON serialization for API responses
417     *
418     * @return array
419     */
420    public function jsonSerialize(): array
421    {
422        return [
423            'timesheetId' => $this->timesheetId,
424            'employeeId' => $this->employeeId,
425            'employeeName' => $this->employeeName,
426            'weekStartDate' => $this->weekStartDate->format('Y-m-d'),
427            'weekEndDate' => $this->weekEndDate->format('Y-m-d'),
428            'scheduled' => [
429                'total' => $this->scheduledTotalHours,
430                'regular' => $this->scheduledRegularHours,
431                'overtime' => $this->scheduledOvertimeHours,
432                'doubletime' => $this->scheduledDoubletimeHours,
433            ],
434            'actual' => [
435                'total' => $this->totalHours,
436                'regular' => $this->regularHours,
437                'overtime' => $this->overtimeHours,
438                'doubletime' => $this->doubletimeHours,
439            ],
440            'variance' => $this->getHoursVariance(),
441            'totalPay' => $this->totalPay,
442            'status' => $this->status,
443            'approvedBy' => $this->approverName,
444            'approvedAt' => $this->approvedAt?->format(DateTimeInterface::ATOM),
445            'exportedAt' => $this->exportedAt?->format(DateTimeInterface::ATOM),
446            'hasOvertime' => $this->hasOvertime(),
447            'warnings' => $this->warnings,
448        ];
449    }
450}