Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 210
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReportController
0.00% covered (danger)
0.00%
0 / 210
0.00% covered (danger)
0.00%
0 / 23
4970
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSummary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getAgeDistribution
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getAgingByCategory
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 getAgingSummary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAgingByLocation
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getCategoryHealth
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getLocationUtilization
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getStaleInventory
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getBinsByCategory
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getActivitySummary
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getActivityByCategory
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getEventReadiness
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 exportReport
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
210
 convertAgingToCsv
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 convertStaleToCsv
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 convertActivityToCsv
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 convertCategoryHealthToCsv
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 convertLocationUtilizationToCsv
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 escapeCsvField
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 outputJson
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 outputError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 outputCsv
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Backstock\Controllers;
4
5
6use BuyerKiosk\Backstock\ReportService;
7/**
8 * ReportController
9 *
10 * Handles analytics and reporting API endpoints for the Backstock system.
11 * Provides various reports including aging, activity, category health, and exports.
12 */
13class ReportController extends \BuyerKiosk\Core\Controllers\BaseController
14{
15    private $store;
16    private $reportService;
17
18    public function __construct($app, \Store $store)
19    {
20        parent::__construct($app);
21        $this->store = $store;
22        $this->reportService = new ReportService($store);
23    }
24
25    /**
26     * Get dashboard summary metrics
27     * GET /reports/summary
28     */
29    public function getSummary()
30    {
31        try {
32            $summary = $this->reportService->getSummary();
33            $this->outputJson($summary);
34        } catch (\Exception $e) {
35            error_log("ReportController::getSummary error: " . $e->getMessage());
36            $this->outputError("Failed to retrieve summary: " . $e->getMessage(), 500);
37        }
38    }
39
40    /**
41     * Get age distribution for charts
42     * GET /reports/age-distribution
43     */
44    public function getAgeDistribution()
45    {
46        try {
47            $distribution = $this->reportService->getAgeDistribution();
48            $this->outputJson($distribution);
49        } catch (\Exception $e) {
50            error_log("ReportController::getAgeDistribution error: " . $e->getMessage());
51            $this->outputError("Failed to retrieve age distribution: " . $e->getMessage(), 500);
52        }
53    }
54
55    /**
56     * Get aging by category for stacked bar chart
57     * GET /reports/aging/category
58     * Query params: location (optional), sort (name/oldest/stale/total)
59     */
60    public function getAgingByCategory()
61    {
62        try {
63            $location = isset($_GET['location']) && $_GET['location'] !== '' ? (int)$_GET['location'] : null;
64            $sort = isset($_GET['sort']) ? $_GET['sort'] : 'name';
65
66            // Validate sort parameter
67            $validSorts = ['name', 'oldest', 'stale', 'total'];
68            if (!in_array($sort, $validSorts)) {
69                $this->outputError("Invalid sort parameter. Must be one of: " . implode(', ', $validSorts), 400);
70                return;
71            }
72
73            $aging = $this->reportService->getAgingByCategory($location, $sort);
74            $this->outputJson($aging);
75        } catch (\Exception $e) {
76            error_log("ReportController::getAgingByCategory error: " . $e->getMessage());
77            $this->outputError("Failed to retrieve aging by category: " . $e->getMessage(), 500);
78        }
79    }
80
81    /**
82     * Get overall aging statistics
83     * GET /reports/aging/summary
84     * Query params: location (optional)
85     */
86    public function getAgingSummary()
87    {
88        try {
89            $location = isset($_GET['location']) ? $_GET['location'] : null;
90            $summary = $this->reportService->getAgingSummary($location);
91            $this->outputJson($summary);
92        } catch (\Exception $e) {
93            error_log("ReportController::getAgingSummary error: " . $e->getMessage());
94            $this->outputError("Failed to retrieve aging summary: " . $e->getMessage(), 500);
95        }
96    }
97
98    /**
99     * Get aging breakdown by location
100     * GET /reports/aging/location
101     */
102    public function getAgingByLocation()
103    {
104        try {
105            $aging = $this->reportService->getAgingByLocation();
106            $this->outputJson($aging);
107        } catch (\Exception $e) {
108            error_log("ReportController::getAgingByLocation error: " . $e->getMessage());
109            $this->outputError("Failed to retrieve aging by location: " . $e->getMessage(), 500);
110        }
111    }
112
113    /**
114     * Get health metrics for all categories
115     * GET /reports/categories/health
116     */
117    public function getCategoryHealth()
118    {
119        try {
120            $health = $this->reportService->getCategoryHealth();
121            $this->outputJson($health);
122        } catch (\Exception $e) {
123            error_log("ReportController::getCategoryHealth error: " . $e->getMessage());
124            $this->outputError("Failed to retrieve category health: " . $e->getMessage(), 500);
125        }
126    }
127
128    /**
129     * Get utilization metrics by location
130     * GET /reports/locations/utilization
131     */
132    public function getLocationUtilization()
133    {
134        try {
135            $utilization = $this->reportService->getLocationUtilization();
136            $this->outputJson($utilization);
137        } catch (\Exception $e) {
138            error_log("ReportController::getLocationUtilization error: " . $e->getMessage());
139            $this->outputError("Failed to retrieve location utilization: " . $e->getMessage(), 500);
140        }
141    }
142
143    /**
144     * Get stale inventory list
145     * GET /reports/stale
146     * Query params: threshold (default 180)
147     */
148    public function getStaleInventory()
149    {
150        try {
151            $threshold = isset($_GET['threshold']) ? (int)$_GET['threshold'] : 180;
152
153            if ($threshold < 1) {
154                $this->outputError("Threshold must be a positive integer", 400);
155                return;
156            }
157
158            $stale = $this->reportService->getStaleInventory($threshold);
159            $this->outputJson($stale);
160        } catch (\Exception $e) {
161            error_log("ReportController::getStaleInventory error: " . $e->getMessage());
162            $this->outputError("Failed to retrieve stale inventory: " . $e->getMessage(), 500);
163        }
164    }
165
166    /**
167     * Get bins for specific category
168     * GET /reports/category/:categoryId/bins
169     */
170    public function getBinsByCategory($categoryId)
171    {
172        try {
173            $categoryId = (int)$categoryId;
174            if ($categoryId < 1) {
175                $this->outputError("Invalid category ID", 400);
176                return;
177            }
178
179            $bins = $this->reportService->getBinsByCategory($categoryId);
180            $this->outputJson($bins);
181        } catch (\Exception $e) {
182            error_log("ReportController::getBinsByCategory error: " . $e->getMessage());
183            $this->outputError("Failed to retrieve bins by category: " . $e->getMessage(), 500);
184        }
185    }
186
187    /**
188     * Get activity metrics summary
189     * GET /reports/activity/summary
190     * Query params: days (default 30)
191     */
192    public function getActivitySummary()
193    {
194        try {
195            $days = isset($_GET['days']) ? (int)$_GET['days'] : 30;
196
197            if ($days < 1 || $days > 365) {
198                $this->outputError("Days must be between 1 and 365", 400);
199                return;
200            }
201
202            $activity = $this->reportService->getActivitySummary($days);
203            $this->outputJson($activity);
204        } catch (\Exception $e) {
205            error_log("ReportController::getActivitySummary error: " . $e->getMessage());
206            $this->outputError("Failed to retrieve activity summary: " . $e->getMessage(), 500);
207        }
208    }
209
210    /**
211     * Get activity by category
212     * GET /reports/activity/category
213     * Query params: days (default 30)
214     */
215    public function getActivityByCategory()
216    {
217        try {
218            $days = isset($_GET['days']) ? (int)$_GET['days'] : 30;
219
220            if ($days < 1 || $days > 365) {
221                $this->outputError("Days must be between 1 and 365", 400);
222                return;
223            }
224
225            $activity = $this->reportService->getActivityByCategory($days);
226            $this->outputJson($activity);
227        } catch (\Exception $e) {
228            error_log("ReportController::getActivityByCategory error: " . $e->getMessage());
229            $this->outputError("Failed to retrieve activity by category: " . $e->getMessage(), 500);
230        }
231    }
232
233    /**
234     * Get readiness metrics for upcoming events
235     * GET /reports/events/readiness
236     */
237    public function getEventReadiness()
238    {
239        try {
240            $readiness = $this->reportService->getEventReadiness();
241            $this->outputJson($readiness);
242        } catch (\Exception $e) {
243            error_log("ReportController::getEventReadiness error: " . $e->getMessage());
244            $this->outputError("Failed to retrieve event readiness: " . $e->getMessage(), 500);
245        }
246    }
247
248    /**
249     * Export report in specified format
250     * GET /reports/export/:format
251     * Query params: report (aging/stale/activity/categories/locations), plus report-specific options
252     */
253    public function exportReport($format)
254    {
255        try {
256            // Validate format
257            $format = strtolower($format);
258            if ($format !== 'csv') {
259                $this->outputError("Only CSV format is currently supported", 400);
260                return;
261            }
262
263            // Get report type
264            $reportType = isset($_GET['report']) ? $_GET['report'] : null;
265            if (!$reportType) {
266                $this->outputError("Report type is required", 400);
267                return;
268            }
269
270            // Validate report type
271            $validReports = ['aging', 'stale', 'activity', 'categories', 'locations'];
272            if (!in_array($reportType, $validReports)) {
273                $this->outputError("Invalid report type. Must be one of: " . implode(', ', $validReports), 400);
274                return;
275            }
276
277            // Get report data and convert to CSV
278            $csvContent = '';
279            $filename = '';
280
281            switch ($reportType) {
282                case 'aging':
283                    $location = isset($_GET['location']) ? $_GET['location'] : null;
284                    $data = $this->reportService->getAgingByCategory($location);
285                    $filename = 'aging_report_' . date('Y-m-d') . '.csv';
286                    $csvContent = $this->convertAgingToCsv($data);
287                    break;
288
289                case 'stale':
290                    $threshold = isset($_GET['threshold']) ? (int)$_GET['threshold'] : 180;
291                    $data = $this->reportService->getStaleInventory($threshold);
292                    $filename = 'stale_inventory_' . date('Y-m-d') . '.csv';
293                    $csvContent = $this->convertStaleToCsv($data);
294                    break;
295
296                case 'activity':
297                    $days = isset($_GET['days']) ? (int)$_GET['days'] : 30;
298                    $data = $this->reportService->getActivityByCategory($days);
299                    $filename = 'activity_report_' . date('Y-m-d') . '.csv';
300                    $csvContent = $this->convertActivityToCsv($data);
301                    break;
302
303                case 'categories':
304                    $data = $this->reportService->getCategoryHealth();
305                    $filename = 'category_health_' . date('Y-m-d') . '.csv';
306                    $csvContent = $this->convertCategoryHealthToCsv($data);
307                    break;
308
309                case 'locations':
310                    $data = $this->reportService->getLocationUtilization();
311                    $filename = 'location_utilization_' . date('Y-m-d') . '.csv';
312                    $csvContent = $this->convertLocationUtilizationToCsv($data);
313                    break;
314            }
315
316            $this->outputCsv($filename, $csvContent);
317        } catch (\Exception $e) {
318            error_log("ReportController::exportReport error: " . $e->getMessage());
319            $this->outputError("Failed to export report: " . $e->getMessage(), 500);
320        }
321    }
322
323    /**
324     * Convert aging data to CSV format
325     */
326    private function convertAgingToCsv($data)
327    {
328        $csv = "Category,Total Bins,0-30 Days,31-90 Days,91-180 Days,180+ Days,Oldest Bin (Days),Stale Bins\n";
329
330        foreach ($data as $row) {
331            $csv .= sprintf(
332                "%s,%d,%d,%d,%d,%d,%d,%d\n",
333                $this->escapeCsvField($row['category_name']),
334                $row['total_bins'],
335                $row['age_0_30'],
336                $row['age_31_90'],
337                $row['age_91_180'],
338                $row['age_180_plus'],
339                $row['oldest_bin_age'],
340                $row['stale_count']
341            );
342        }
343
344        return $csv;
345    }
346
347    /**
348     * Convert stale inventory data to CSV format
349     */
350    private function convertStaleToCsv($data)
351    {
352        $csv = "Bin Number,Category,Location,Age (Days),Added Date,Last Movement\n";
353
354        foreach ($data as $row) {
355            $csv .= sprintf(
356                "%s,%s,%s,%d,%s,%s\n",
357                $this->escapeCsvField($row['bin_number']),
358                $this->escapeCsvField($row['category_name']),
359                $this->escapeCsvField($row['location_name']),
360                $row['age_days'],
361                $row['created_at'],
362                $row['last_movement'] ?? 'Never'
363            );
364        }
365
366        return $csv;
367    }
368
369    /**
370     * Convert activity data to CSV format
371     */
372    private function convertActivityToCsv($data)
373    {
374        $csv = "Category,Additions,Removals,Moves,Total Activity,Turnover Rate\n";
375
376        foreach ($data as $row) {
377            $csv .= sprintf(
378                "%s,%d,%d,%d,%d,%.2f\n",
379                $this->escapeCsvField($row['category_name']),
380                $row['additions'],
381                $row['removals'],
382                $row['moves'],
383                $row['total_activity'],
384                $row['turnover_rate']
385            );
386        }
387
388        return $csv;
389    }
390
391    /**
392     * Convert category health data to CSV format
393     */
394    private function convertCategoryHealthToCsv($data)
395    {
396        $csv = "Category,Total Bins,Active Bins,Stale Bins,Avg Age (Days),Health Score\n";
397
398        foreach ($data as $row) {
399            $csv .= sprintf(
400                "%s,%d,%d,%d,%.1f,%s\n",
401                $this->escapeCsvField($row['category_name']),
402                $row['total_bins'],
403                $row['active_bins'],
404                $row['stale_bins'],
405                $row['avg_age'],
406                $this->escapeCsvField($row['health_score'])
407            );
408        }
409
410        return $csv;
411    }
412
413    /**
414     * Convert location utilization data to CSV format
415     */
416    private function convertLocationUtilizationToCsv($data)
417    {
418        $csv = "Location,Total Bins,Capacity,Utilization %,Avg Age (Days)\n";
419
420        foreach ($data as $row) {
421            $csv .= sprintf(
422                "%s,%d,%d,%.1f,%.1f\n",
423                $this->escapeCsvField($row['location_name']),
424                $row['bin_count'],
425                $row['capacity'],
426                $row['utilization_percent'],
427                $row['avg_age']
428            );
429        }
430
431        return $csv;
432    }
433
434    /**
435     * Escape CSV field (handle commas, quotes, newlines)
436     */
437    private function escapeCsvField($field)
438    {
439        if (strpos($field, ',') !== false || strpos($field, '"') !== false || strpos($field, "\n") !== false) {
440            return '"' . str_replace('"', '""', $field) . '"';
441        }
442        return $field;
443    }
444
445    /**
446     * Output JSON response
447     */
448    private function outputJson($data, $statusCode = 200)
449    {
450        http_response_code($statusCode);
451        header('Content-Type: application/json');
452        echo json_encode($data);
453    }
454
455    /**
456     * Output error response
457     */
458    private function outputError($message, $code = 400)
459    {
460        http_response_code($code);
461        header('Content-Type: application/json');
462        echo json_encode([
463            'error' => true,
464            'message' => $message
465        ]);
466    }
467
468    /**
469     * Output CSV file
470     */
471    private function outputCsv($filename, $content)
472    {
473        header('Content-Type: text/csv');
474        header('Content-Disposition: attachment; filename="' . $filename . '"');
475        header('Cache-Control: no-cache, must-revalidate');
476        header('Expires: 0');
477        echo $content;
478    }
479}