Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 143 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
| TimesheetExporter | |
0.00% |
0 / 143 |
|
0.00% |
0 / 12 |
2862 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| exportToCsv | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
| exportWeek | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getDefaultColumns | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
| getSimplifiedColumns | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| buildRow | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| getColumnValue | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
420 | |||
| formatValue | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
| applyRounding | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
56 | |||
| generateSummary | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
6 | |||
| exportWithSummary | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
| validateForExport | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Scheduling\Services; |
| 4 | |
| 5 | use BuyerKiosk\Scheduling\Models\Timesheet; |
| 6 | use BuyerKiosk\Scheduling\Repositories\TimesheetRepository; |
| 7 | use 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 | */ |
| 17 | class 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 | } |