Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimesheetExporter
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 12
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 exportToCsv
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 exportWeek
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultColumns
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getSimplifiedColumns
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 buildRow
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getColumnValue
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
420
 formatValue
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 applyRounding
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 generateSummary
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 exportWithSummary
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 validateForExport
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Scheduling\Services;
4
5use BuyerKiosk\Scheduling\Models\Timesheet;
6use BuyerKiosk\Scheduling\Repositories\TimesheetRepository;
7use DateTime;
8
9/**
10 * TimesheetExporter
11 *
12 * Exports timesheet data to CSV format for payroll processing.
13 * Supports payroll rounding and customizable column configuration.
14 *
15 * @package BuyerKiosk\Scheduling\Services
16 */
17class TimesheetExporter
18{
19    private TimesheetRepository $timesheetRepository;
20
21    /**
22     * Rounding modes
23     */
24    public const ROUNDING_NONE = 'none';
25    public const ROUNDING_NEAREST = 'nearest';
26    public const ROUNDING_UP = 'up';
27    public const ROUNDING_DOWN = 'down';
28
29    public function __construct(TimesheetRepository $timesheetRepository)
30    {
31        $this->timesheetRepository = $timesheetRepository;
32    }
33
34    /**
35     * Export timesheets to CSV
36     *
37     * @param Timesheet[] $timesheets Timesheets to export
38     * @param array $options Export options
39     * @return string CSV content
40     */
41    public function exportToCsv(array $timesheets, array $options = []): string
42    {
43        $roundingMode = $options['roundingMode'] ?? self::ROUNDING_NONE;
44        $roundingIncrement = $options['roundingIncrementMinutes'] ?? 15;
45        $includeHeaders = $options['includeHeaders'] ?? true;
46        $storeName = $options['storeName'] ?? '';
47        $columns = $options['columns'] ?? $this->getDefaultColumns();
48
49        $output = fopen('php://temp', 'r+');
50
51        // Write headers
52        if ($includeHeaders) {
53            $headers = array_map(fn($col) => $col['label'], $columns);
54            fputcsv($output, $headers);
55        }
56
57        // Write data rows
58        foreach ($timesheets as $timesheet) {
59            $row = $this->buildRow($timesheet, $columns, $roundingMode, $roundingIncrement, $storeName);
60            fputcsv($output, $row);
61        }
62
63        rewind($output);
64        $csv = stream_get_contents($output);
65        fclose($output);
66
67        return $csv;
68    }
69
70    /**
71     * Export timesheets for a week
72     *
73     * @param DateTime $weekStart Week start date
74     * @param array $options Export options
75     * @return string CSV content
76     */
77    public function exportWeek(DateTime $weekStart, array $options = []): string
78    {
79        $timesheets = $this->timesheetRepository->findReadyForExport($weekStart);
80        return $this->exportToCsv($timesheets, $options);
81    }
82
83    /**
84     * Get default column configuration
85     *
86     * @return array
87     */
88    public function getDefaultColumns(): array
89    {
90        return [
91            ['key' => 'employeeId', 'label' => 'Employee ID', 'type' => 'integer'],
92            ['key' => 'employeeName', 'label' => 'Employee Name', 'type' => 'string'],
93            ['key' => 'weekStartDate', 'label' => 'Week Start', 'type' => 'date'],
94            ['key' => 'weekEndDate', 'label' => 'Week End', 'type' => 'date'],
95            ['key' => 'regularHours', 'label' => 'Regular Hours', 'type' => 'decimal'],
96            ['key' => 'overtimeHours', 'label' => 'Overtime Hours', 'type' => 'decimal'],
97            ['key' => 'doubletimeHours', 'label' => 'Doubletime Hours', 'type' => 'decimal'],
98            ['key' => 'totalHours', 'label' => 'Total Hours', 'type' => 'decimal'],
99            ['key' => 'totalPay', 'label' => 'Total Pay', 'type' => 'currency'],
100            ['key' => 'status', 'label' => 'Status', 'type' => 'string'],
101            ['key' => 'approvedBy', 'label' => 'Approved By', 'type' => 'string'],
102            ['key' => 'approvedAt', 'label' => 'Approved Date', 'type' => 'datetime'],
103        ];
104    }
105
106    /**
107     * Get simplified columns for basic payroll export
108     *
109     * @return array
110     */
111    public function getSimplifiedColumns(): array
112    {
113        return [
114            ['key' => 'employeeId', 'label' => 'Employee ID', 'type' => 'integer'],
115            ['key' => 'employeeName', 'label' => 'Name', 'type' => 'string'],
116            ['key' => 'regularHours', 'label' => 'Regular', 'type' => 'decimal'],
117            ['key' => 'overtimeHours', 'label' => 'OT', 'type' => 'decimal'],
118            ['key' => 'totalHours', 'label' => 'Total', 'type' => 'decimal'],
119        ];
120    }
121
122    /**
123     * Build a row from a timesheet
124     *
125     * @param Timesheet $timesheet Timesheet to export
126     * @param array $columns Column configuration
127     * @param string $roundingMode Rounding mode
128     * @param int $roundingIncrement Rounding increment in minutes
129     * @param string $storeName Store name for context
130     * @return array Row values
131     */
132    private function buildRow(
133        Timesheet $timesheet,
134        array $columns,
135        string $roundingMode,
136        int $roundingIncrement,
137        string $storeName
138    ): array {
139        $row = [];
140
141        foreach ($columns as $column) {
142            $key = $column['key'];
143            $type = $column['type'] ?? 'string';
144
145            $value = $this->getColumnValue($timesheet, $key, $storeName);
146
147            // Apply rounding to hour columns
148            if ($type === 'decimal' && str_contains(strtolower($key), 'hours')) {
149                $value = $this->applyRounding($value, $roundingMode, $roundingIncrement);
150            }
151
152            // Format based on type
153            $row[] = $this->formatValue($value, $type);
154        }
155
156        return $row;
157    }
158
159    /**
160     * Get a column value from a timesheet
161     *
162     * @param Timesheet $timesheet Timesheet
163     * @param string $key Column key
164     * @param string $storeName Store name
165     * @return mixed
166     */
167    private function getColumnValue(Timesheet $timesheet, string $key, string $storeName): mixed
168    {
169        return match ($key) {
170            'employeeId' => $timesheet->getEmployeeId(),
171            'employeeName' => $timesheet->getEmployeeName(),
172            'weekStartDate' => $timesheet->getWeekStartDate()->format('Y-m-d'),
173            'weekEndDate' => $timesheet->getWeekEndDate()->format('Y-m-d'),
174            'regularHours' => $timesheet->getRegularHours(),
175            'overtimeHours' => $timesheet->getOvertimeHours(),
176            'doubletimeHours' => $timesheet->getDoubletimeHours(),
177            'totalHours' => $timesheet->getTotalHours(),
178            'scheduledRegularHours' => $timesheet->getScheduledRegularHours(),
179            'scheduledOvertimeHours' => $timesheet->getScheduledOvertimeHours(),
180            'scheduledTotalHours' => $timesheet->getScheduledTotalHours(),
181            'totalPay' => $timesheet->getTotalPay(),
182            'status' => $timesheet->getStatus(),
183            'approvedBy' => $timesheet->getApproverName(),
184            'approvedAt' => $timesheet->getApprovedAt()?->format('Y-m-d H:i:s'),
185            'exportedAt' => $timesheet->getExportedAt()?->format('Y-m-d H:i:s'),
186            'storeName' => $storeName,
187            'variance' => $timesheet->getHoursVariance(),
188            default => '',
189        };
190    }
191
192    /**
193     * Format a value based on its type
194     *
195     * @param mixed $value Value to format
196     * @param string $type Data type
197     * @return string
198     */
199    private function formatValue(mixed $value, string $type): string
200    {
201        if ($value === null) {
202            return '';
203        }
204
205        return match ($type) {
206            'integer' => (string)(int)$value,
207            'decimal' => number_format((float)$value, 2, '.', ''),
208            'currency' => number_format((float)$value, 2, '.', ''),
209            'date' => $value,
210            'datetime' => $value,
211            default => (string)$value,
212        };
213    }
214
215    /**
216     * Apply payroll rounding to hours
217     *
218     * @param float $hours Hours to round
219     * @param string $mode Rounding mode
220     * @param int $incrementMinutes Increment in minutes
221     * @return float Rounded hours
222     */
223    public function applyRounding(float $hours, string $mode, int $incrementMinutes): float
224    {
225        if ($mode === self::ROUNDING_NONE || $incrementMinutes <= 0) {
226            return $hours;
227        }
228
229        // Convert to minutes for rounding
230        $totalMinutes = $hours * 60;
231        $increment = $incrementMinutes;
232
233        $roundedMinutes = match ($mode) {
234            self::ROUNDING_NEAREST => round($totalMinutes / $increment) * $increment,
235            self::ROUNDING_UP => ceil($totalMinutes / $increment) * $increment,
236            self::ROUNDING_DOWN => floor($totalMinutes / $increment) * $increment,
237            default => $totalMinutes,
238        };
239
240        return $roundedMinutes / 60;
241    }
242
243    /**
244     * Generate a summary row for totals
245     *
246     * @param Timesheet[] $timesheets Timesheets to summarize
247     * @param string $roundingMode Rounding mode
248     * @param int $roundingIncrement Rounding increment
249     * @return array Summary data
250     */
251    public function generateSummary(array $timesheets, string $roundingMode = self::ROUNDING_NONE, int $roundingIncrement = 15): array
252    {
253        $totalRegular = 0;
254        $totalOvertime = 0;
255        $totalDoubletime = 0;
256        $totalPay = 0;
257        $employeeCount = count($timesheets);
258
259        foreach ($timesheets as $timesheet) {
260            $regular = $this->applyRounding($timesheet->getRegularHours(), $roundingMode, $roundingIncrement);
261            $overtime = $this->applyRounding($timesheet->getOvertimeHours(), $roundingMode, $roundingIncrement);
262            $doubletime = $this->applyRounding($timesheet->getDoubletimeHours(), $roundingMode, $roundingIncrement);
263
264            $totalRegular += $regular;
265            $totalOvertime += $overtime;
266            $totalDoubletime += $doubletime;
267            $totalPay += $timesheet->getTotalPay();
268        }
269
270        return [
271            'employeeCount' => $employeeCount,
272            'regularHours' => round($totalRegular, 2),
273            'overtimeHours' => round($totalOvertime, 2),
274            'doubletimeHours' => round($totalDoubletime, 2),
275            'totalHours' => round($totalRegular + $totalOvertime + $totalDoubletime, 2),
276            'totalPay' => round($totalPay, 2),
277        ];
278    }
279
280    /**
281     * Export with a summary row appended
282     *
283     * @param Timesheet[] $timesheets Timesheets to export
284     * @param array $options Export options
285     * @return string CSV content with summary
286     */
287    public function exportWithSummary(array $timesheets, array $options = []): string
288    {
289        $roundingMode = $options['roundingMode'] ?? self::ROUNDING_NONE;
290        $roundingIncrement = $options['roundingIncrementMinutes'] ?? 15;
291
292        // Get base export
293        $csv = $this->exportToCsv($timesheets, $options);
294
295        // Generate summary
296        $summary = $this->generateSummary($timesheets, $roundingMode, $roundingIncrement);
297
298        // Append summary row
299        $summaryRow = sprintf(
300            "\nTOTALS,%d employees,,%s,%s,%s,%s,$%s,,,",
301            $summary['employeeCount'],
302            number_format($summary['regularHours'], 2),
303            number_format($summary['overtimeHours'], 2),
304            number_format($summary['doubletimeHours'], 2),
305            number_format($summary['totalHours'], 2),
306            number_format($summary['totalPay'], 2)
307        );
308
309        return $csv . $summaryRow;
310    }
311
312    /**
313     * Validate timesheets before export
314     *
315     * @param Timesheet[] $timesheets Timesheets to validate
316     * @return array{valid: bool, errors: array<string>}
317     */
318    public function validateForExport(array $timesheets): array
319    {
320        $errors = [];
321
322        foreach ($timesheets as $timesheet) {
323            if (!$timesheet->isApproved()) {
324                $errors[] = sprintf(
325                    'Timesheet for %s (ID: %d) is not approved',
326                    $timesheet->getEmployeeName() ?? 'Unknown',
327                    $timesheet->getEmployeeId()
328                );
329            }
330
331            if ($timesheet->getTotalHours() <= 0) {
332                $errors[] = sprintf(
333                    'Timesheet for %s has no hours recorded',
334                    $timesheet->getEmployeeName() ?? 'Employee ' . $timesheet->getEmployeeId()
335                );
336            }
337        }
338
339        return [
340            'valid' => empty($errors),
341            'errors' => $errors,
342        ];
343    }
344}