Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 687
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatsApiController
0.00% covered (danger)
0.00%
0 / 687
0.00% covered (danger)
0.00%
0 / 22
17292
0.00% covered (danger)
0.00%
0 / 1
 runDailyStats
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 runAllStats
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 runLatestStats
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 runAllEmployeeStats
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getStoreStats
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
90
 getEmployeeStats
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
12
 getEmployeeDayStats
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
56
 getStoreDayStats
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
42
 handleLegacyRequest
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
132
 getTodaysStoreStats
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getAllStoreStats
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
90
 latestStoreStats
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
306
 getStoresEmployeeStats
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 1
600
 getBuyersStatsArray
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getSortersStatsArray
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 addBuyerEfficiencyScores
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
56
 addSorterEfficiencyScores
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
56
 erf
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 cdf
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 standardDeviation
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 arithmeticMean
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 harmonicMean
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Stats\Controllers;
4
5use DateTime;
6use DateInterval;
7use DateTimeZone;
8use PDO;
9use PDOException;
10use Store;
11use KLogger;
12use KLoggerFactory;
13use BuyerKiosk\Core\EmployeeDaily;
14use BuyerKiosk\Core\StoreStat;
15
16/**
17 * StatsApiController
18 *
19 * Handles all statistics API endpoints for the BuyerKiosk system.
20 * Refactored from legacy stats.php to use modern PHP 8.x patterns.
21 *
22 * @package BuyerKiosk\Stats
23 */
24class StatsApiController
25{
26    /**
27     * Run daily stats generation for all stores
28     *
29     * Generates statistics for the current day across all active stores.
30     * Typically called by cron job at end of day.
31     *
32     * @return bool Success status
33     */
34    public function runDailyStats(): bool
35    {
36        $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "dev_log.txt", KLogger::DEBUG);
37        $stores = getAllStoresData();
38
39        foreach ($stores as $store) {
40            $this->getTodaysStoreStats($store, $log);
41        }
42
43        return true;
44    }
45
46    /**
47     * Run all historical stats for a specific store
48     *
49     * Generates complete historical statistics from the first buy to today.
50     * Warning: This is a resource-intensive operation.
51     *
52     * @param string $typeNum Store identifier (e.g., 'ou00', 'pa00')
53     * @return bool Success status
54     */
55    public function runAllStats(string $typeNum): bool
56    {
57        $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "dev_log.txt", KLogger::DEBUG);
58        $store = getStoreFromID($typeNum);
59
60        return $this->getAllStoreStats($store, $log);
61    }
62
63    /**
64     * Update stats from last recorded date to today for all stores
65     *
66     * Fills in any gaps in statistics from the last recorded date to present.
67     * Typically called by cron job to catch up on missing days.
68     *
69     * @return bool Success status
70     */
71    public function runLatestStats(): bool
72    {
73        $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "dev_log.txt", KLogger::DEBUG);
74        $log->LogDebug("Starting Latest Stats");
75
76        $stores = getAllStoresData();
77
78        foreach ($stores as $store) {
79            $this->latestStoreStats($store, $log);
80        }
81
82        $log->LogDebug("Finished Latest Stats");
83        unset($log);
84
85        return true;
86    }
87
88    /**
89     * Generate employee stats for all stores
90     *
91     * Calculates daily employee performance metrics across all active stores.
92     * Includes both buyer and sorter statistics.
93     *
94     * @return bool Success status
95     */
96    public function runAllEmployeeStats(): bool
97    {
98        $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "logs/dev_log.txt", KLogger::DEBUG);
99        $log->LogDebug("Starting get employeeStats");
100
101        $stores = getAllStoresData();
102        $count = 0;
103
104        foreach ($stores as $store) {
105            $this->getStoresEmployeeStats($store, $log);
106
107            // Force garbage collection to close connections
108            gc_collect_cycles();
109
110            // Every 10 stores, clean up loggers to prevent file handle exhaustion
111            if (++$count % 10 === 0) {
112                KLoggerFactory::closeAll();
113                $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "logs/dev_log.txt", KLogger::DEBUG);
114            }
115        }
116
117        $log->LogDebug("Finished get employeeStats");
118        unset($log);
119
120        return true;
121    }
122
123    /**
124     * Get store statistics for a date range
125     *
126     * @param string $typeNum Store identifier
127     * @param string $statType Type of stats: 'store', 'buyer', or 'sorter'
128     * @param string $dateStart Start date (Y-m-d H:i:s)
129     * @param string $dateEnd End date (Y-m-d H:i:s)
130     * @param string $timeType Time grouping: 'daily' or 'monthly'
131     * @param string $exclusions Day exclusions (underscore-separated day abbreviations)
132     * @return array Statistics array
133     */
134    public function getStoreStats(
135        string $typeNum,
136        string $statType,
137        string $dateStart,
138        string $dateEnd,
139        string $timeType,
140        string $exclusions = ''
141    ): array {
142        $store = getStoreFromID($typeNum);
143        $log = KLoggerFactory::getLogger($_ENV['LOG_DIR'] . "dev_log.txt", KLogger::DEBUG);
144
145        $dateStart = new DateTime($dateStart, new DateTimeZone('utc'));
146        $dateEnd = new DateTime($dateEnd, new DateTimeZone('utc'));
147        $includedDays = explode("_", $exclusions);
148
149        $db = dbConnectByName($store->getDbName());
150
151        $log->LogDebug("DateStart: " . $dateStart->format("Y-m-d H:i:s"));
152        $log->LogDebug("DateEnd: " . $dateEnd->format("Y-m-d H:i:s"));
153
154        $dateRange = getDateRange($dateStart->format("Y-m-d H:i:s"));
155        $dateStart = $dateRange['dateStart'];
156        $dateRange = getDateRange($dateEnd->format("Y-m-d H:i:s"));
157        $dateEnd = $dateRange['dateStart'];
158
159        $statsArray = [];
160
161        if ($timeType == "daily") {
162            if ($statType == "store") {
163                $query = $db->prepare(
164                    "SELECT * FROM statsStoreDaily
165                     WHERE date BETWEEN :dateStart AND :dateEnd
166                     ORDER BY date DESC"
167                );
168                $query->execute([
169                    ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
170                    ':dateEnd' => $dateEnd->format("Y-m-d H:i:s")
171                ]);
172
173                while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
174                    $thisDate = new DateTime($row['date'], new DateTimeZone('UTC'));
175                    if (in_array($thisDate->format("D"), $includedDays)) {
176                        $totalSeconds = (int)$row['avgDelay'] + (int)$row['sortDelay'] +
177                                      (int)$row['sortTime'] + (int)$row['avgTotal'];
178                        $row['totalSeconds'] = $totalSeconds;
179                        $statsArray[] = $row;
180                    }
181                }
182            } elseif ($statType == "buyer") {
183                $storeStats = getStoreProcessAverages(
184                    $store,
185                    $dateStart->format("Y-m-d H:i:s"),
186                    $dateEnd->format("Y-m-d H:i:s")
187                );
188                $employeeArray = $this->getBuyersStatsArray($dateStart, $dateEnd, $store);
189
190                $compositeScores = array_column($employeeArray, 'compositeScore');
191                $largestCompositeScore = !empty($compositeScores) ? max($compositeScores) : 0;
192                $storeStats['largestComposite'] = $largestCompositeScore;
193
194                $statsArray[] = [
195                    'storeStats' => $storeStats,
196                    'employeeStats' => $employeeArray
197                ];
198            } elseif ($statType == "sorter") {
199                $storeStats = getStoreSortAverages(
200                    $store,
201                    $dateStart->format("Y-m-d H:i:s"),
202                    $dateEnd->format("Y-m-d H:i:s")
203                );
204                $employeeArray = $this->getSortersStatsArray($dateStart, $dateEnd, $store);
205
206                $compositeScores = array_column($employeeArray, 'compositeScore');
207                $largestCompositeScore = !empty($compositeScores) ? max($compositeScores) : 0;
208                $storeStats['largestComposite'] = $largestCompositeScore;
209
210                $statsArray[] = [
211                    'storeStats' => $storeStats,
212                    'employeeStats' => $employeeArray
213                ];
214            }
215        }
216
217        return $statsArray;
218    }
219
220    /**
221     * Get employee statistics by ID
222     *
223     * @param string $typeNum Store identifier
224     * @param int $employeeId Employee ID
225     * @param string $dateStart Start date
226     * @param string $dateEnd End date
227     * @param string $statType Stat type filter
228     * @return array Employee statistics with store comparison
229     */
230    public function getEmployeeStats(
231        string $typeNum,
232        int $employeeId,
233        string $dateStart,
234        string $dateEnd,
235        string $statType
236    ): array {
237        $store = getStoreFromID($typeNum);
238        $dateStart = new DateTime($dateStart, new DateTimeZone('utc'));
239        $dateEnd = new DateTime($dateEnd, new DateTimeZone('utc'));
240
241        $db = dbConnectByName($store->getDbName());
242        $storeBuyerStats = getStoreProcessAverages(
243            $store,
244            $dateStart->format("Y-m-d H:i:s"),
245            $dateEnd->format("Y-m-d H:i:s")
246        );
247        $storeSorterStats = getStoreSortAverages(
248            $store,
249            $dateStart->format("Y-m-d H:i:s"),
250            $dateEnd->format("Y-m-d H:i:s")
251        );
252
253        $dateRange = getDateRange($dateStart->format("Y-m-d H:i:s"));
254        $dateStart = $dateRange['dateStart'];
255        $dateRange = getDateRange($dateEnd->format("Y-m-d H:i:s"));
256        $dateEnd = $dateRange['dateEnd'];
257
258        $query = $db->prepare(
259            "SELECT * FROM statsEmployeeDaily
260             WHERE `date` BETWEEN :dateStart AND :dateEnd
261             AND employeeID = :employeeID
262             ORDER BY id DESC"
263        );
264        $query->execute([
265            ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
266            ':dateEnd' => $dateEnd->format("Y-m-d H:i:s"),
267            ':employeeID' => $employeeId
268        ]);
269
270        $statsArray = [];
271        while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
272            $statsArray[] = $row;
273        }
274
275        $employeeArray = $this->getBuyersStatsArray($dateStart, $dateEnd, $store);
276        $compositeScores = array_column($employeeArray, 'compositeScore');
277        $largestCompositeScore = !empty($compositeScores) ? max($compositeScores) : 0;
278
279        return [
280            'storeStats' => [
281                'buyer' => $storeBuyerStats,
282                'sorter' => $storeSorterStats,
283                'largestComposite' => $largestCompositeScore
284            ],
285            'employeeStats' => $statsArray,
286            'allEmployeeStats' => $employeeArray
287        ];
288    }
289
290    /**
291     * Get employee daily detail stats
292     *
293     * @param string $typeNum Store identifier
294     * @param int $employeeId Employee ID
295     * @param string $dateStart Start date
296     * @param string $dateEnd End date
297     * @return array Daily buy details and summary stats
298     */
299    public function getEmployeeDayStats(
300        string $typeNum,
301        int $employeeId,
302        string $dateStart,
303        string $dateEnd
304    ): array {
305        $store = getStoreFromID($typeNum);
306        $dateStart = new DateTime($dateStart, new DateTimeZone('utc'));
307        $dateEnd = new DateTime($dateEnd, new DateTimeZone('utc'));
308
309        $db = dbConnectByName($store->getDbName());
310        $dateRange = getDateRange($dateStart->format("Y-m-d H:i:s"));
311        $dateStart = $dateRange['dateStart'];
312        $dateRange = getDateRange($dateEnd->format("Y-m-d H:i:s"));
313        $dateEnd = $dateRange['dateEnd'];
314
315        $query = $db->prepare(
316            "SELECT buyQueue.*, customers.firstName, customers.lastName
317             FROM buyQueue
318             JOIN customers ON buyQueue.customerID = customers.customerID
319             WHERE buyerID = :employeeId
320             AND timeEntered BETWEEN :dateStart AND :dateEnd"
321        );
322        $query->execute([
323            ':employeeId' => $employeeId,
324            ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
325            ':dateEnd' => $dateEnd->format("Y-m-d H:i:s")
326        ]);
327
328        $buysArray = [];
329        $employees = null;
330        $gotEmpNameArray = false;
331        $totalDelaySeconds = 0;
332        $totalSeconds = 0;
333        $totalBuys = 0;
334        $totalContainers = 0;
335
336        while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
337            if ($row['sorterID'] > 0 && !$gotEmpNameArray) {
338                $employees = getEmployeeNameArray($store);
339                $gotEmpNameArray = true;
340            }
341
342            $sorterName = ($row['sorterID'] > 0 && isset($employees[$row['sorterID']]))
343                ? $employees[$row['sorterID']]
344                : "None";
345
346            $timeEntered = new DateTime($row['timeEntered'], new DateTimeZone('UTC'));
347            $timeEntered->setTimezone(new DateTimeZone($store->getTimeZone()));
348            $timeStarted = new DateTime($row['timeStarted'], new DateTimeZone('UTC'));
349            $timeStarted->setTimezone(new DateTimeZone($store->getTimeZone()));
350            $timeCompleted = new DateTime($row['timeCompleted'], new DateTimeZone('UTC'));
351            $timeCompleted->setTimezone(new DateTimeZone($store->getTimeZone()));
352
353            $diffStartedCompleted = $timeCompleted->diff($timeStarted);
354            $diffEnteredStarted = $timeStarted->diff($timeEntered);
355            $totalSeconds += getSecondsFromADifference($diffStartedCompleted);
356            $totalDelaySeconds += getSecondsFromADifference($diffEnteredStarted);
357            $totalContainers += $row['numContainers'];
358            $totalBuys++;
359
360            $buysArray[] = [
361                'buyID' => $row['buyID'],
362                'dailyNum' => $row['dailyNum'],
363                'timeEntered' => $timeEntered->format("g:i A"),
364                'timeStarted' => $timeStarted->format("g:i A"),
365                'timeCompleted' => $timeCompleted->format("g:i A"),
366                'sorterName' => $sorterName,
367                'sorterID' => $row['sorterID'],
368                'customerName' => $row['firstName'] . " " . $row['lastName'],
369                'customerID' => $row['customerID'],
370                'totalSeconds' => getSecondsFromADifference($diffStartedCompleted),
371                'totalDelay' => getSecondsFromADifference($diffEnteredStarted),
372                'numContainers' => $row['numContainers'],
373                'textMe' => $row['textMe']
374            ];
375        }
376
377        if ($totalContainers > 0) {
378            $avgDelay = floor($totalDelaySeconds / $totalBuys);
379            $avgTotal = floor($totalSeconds / $totalBuys);
380            $avgPerContainer = floor($totalSeconds / $totalContainers);
381        } else {
382            $avgDelay = 0;
383            $avgTotal = 0;
384            $avgPerContainer = 0;
385        }
386
387        $statsArray = [
388            'avgDelay' => $avgDelay,
389            'avgTotal' => $avgTotal,
390            'avgPTC' => $avgPerContainer
391        ];
392
393        return [$statsArray, $buysArray];
394    }
395
396    /**
397     * Get store daily stats with buy details
398     *
399     * @param string $typeNum Store identifier
400     * @param string $dateStart Start date
401     * @param string $dateEnd End date
402     * @return array Daily stats and buy details
403     */
404    public function getStoreDayStats(
405        string $typeNum,
406        string $dateStart,
407        string $dateEnd
408    ): array {
409        $store = getStoreFromID($typeNum);
410        $dateStart = new DateTime($dateStart, new DateTimeZone('utc'));
411        $dateEnd = new DateTime($dateEnd, new DateTimeZone('utc'));
412
413        $db = dbConnectByName($store->getDbName());
414        $dateRange = getDateRange($dateStart->format("Y-m-d H:i:s"));
415        $dateStart = $dateRange['dateStart'];
416        $dateRange = getDateRange($dateEnd->format("Y-m-d H:i:s"));
417        $dateEnd = $dateRange['dateEnd'];
418
419        $query = $db->prepare(
420            "SELECT buyQueue.*, customers.firstName, customers.lastName
421             FROM buyQueue
422             JOIN customers ON buyQueue.customerID = customers.customerID
423             WHERE timeEntered BETWEEN :dateStart AND :dateEnd"
424        );
425        $query->execute([
426            ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
427            ':dateEnd' => $dateEnd->format("Y-m-d H:i:s")
428        ]);
429
430        $buysArray = [];
431        $employees = null;
432        $gotEmpNameArray = false;
433        $totalDelaySeconds = 0;
434        $totalSeconds = 0;
435        $totalBuys = 0;
436        $totalContainers = 0;
437
438        while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
439            if (!$gotEmpNameArray) {
440                $employees = getEmployeeNameArray($store);
441                $gotEmpNameArray = true;
442            }
443
444            $sorterName = ($row['sorterID'] > 0 && isset($employees[$row['sorterID']]))
445                ? $employees[$row['sorterID']]
446                : "None";
447
448            $timeEntered = new DateTime($row['timeEntered'], new DateTimeZone('UTC'));
449            $timeEntered->setTimezone(new DateTimeZone($store->getTimeZone()));
450            $timeStarted = new DateTime($row['timeStarted'], new DateTimeZone('UTC'));
451            $timeStarted->setTimezone(new DateTimeZone($store->getTimeZone()));
452            $timeCompleted = new DateTime($row['timeCompleted'], new DateTimeZone('UTC'));
453            $timeCompleted->setTimezone(new DateTimeZone($store->getTimeZone()));
454
455            $diffStartedCompleted = $timeCompleted->diff($timeStarted);
456            $diffEnteredStarted = $timeStarted->diff($timeEntered);
457            $totalSeconds += getSecondsFromADifference($diffStartedCompleted);
458            $totalDelaySeconds += getSecondsFromADifference($diffEnteredStarted);
459            $totalContainers += $row['numContainers'];
460            $totalBuys++;
461
462            $buysArray[] = [
463                'buyID' => $row['buyID'],
464                'dailyNum' => $row['dailyNum'],
465                'timeEntered' => $timeEntered->format("g:i a"),
466                'timeStarted' => $timeStarted->format("g:i a"),
467                'timeCompleted' => $timeCompleted->format("g:i a"),
468                'linkDate' => $timeEntered->format("m_d_y"),
469                'sorterName' => $sorterName,
470                'sorterID' => $row['sorterID'],
471                'buyerName' => $employees[$row['buyerID']] ?? '',
472                'buyerID' => $row['buyerID'],
473                'customerName' => $row['firstName'] . " " . $row['lastName'],
474                'customerID' => $row['customerID'],
475                'totalSeconds' => getSecondsFromADifference($diffStartedCompleted),
476                'totalDelay' => getSecondsFromADifference($diffEnteredStarted),
477                'numContainers' => $row['numContainers'],
478                'textMe' => $row['textMe']
479            ];
480        }
481
482        if ($totalContainers > 0) {
483            $avgDelay = floor($totalDelaySeconds / $totalBuys);
484            $avgTotal = floor($totalSeconds / $totalBuys);
485            $avgPerContainer = floor($totalSeconds / $totalContainers);
486        } else {
487            $avgDelay = 0;
488            $avgTotal = 0;
489            $avgPerContainer = 0;
490        }
491
492        $statsArray = [
493            'avgDelay' => $avgDelay,
494            'avgTotal' => $avgTotal,
495            'avgPTC' => $avgPerContainer,
496            'totalBuys' => $totalBuys
497        ];
498
499        return [$statsArray, $buysArray];
500    }
501
502    /**
503     * Handle legacy API requests
504     *
505     * Routes old-style GET/POST requests to appropriate controller methods.
506     * This provides backward compatibility for existing API consumers.
507     *
508     * @param array $getData GET parameters
509     * @param array $postData POST parameters
510     * @return string JSON response
511     */
512    public function handleLegacyRequest(array $getData, array $postData): string
513    {
514        // Handle GET action-based requests (cron jobs)
515        if (isset($getData['action'])) {
516            switch ($getData['action']) {
517                case "dailyStats":
518                    $this->runDailyStats();
519                    return json_encode(['status' => 'success']);
520
521                case "allStats":
522                    // Note: Original code had hardcoded typeNum, might need parameter
523                    if (isset($getData['typeNum'])) {
524                        $this->runAllStats($getData['typeNum']);
525                        return json_encode(['status' => 'success']);
526                    }
527                    return json_encode(['error' => 'Missing typeNum parameter']);
528
529                case "latestStats":
530                    $this->runLatestStats();
531                    return json_encode(['status' => 'success']);
532
533                case "allEmployeeStats":
534                    $this->runAllEmployeeStats();
535                    return json_encode(['status' => 'success']);
536            }
537        }
538
539        // Handle POST JSON-based requests
540        if (isset($postData['theJSON'])) {
541            $theJSON = json_decode($postData['theJSON']);
542            $statsArray = $this->getStoreStats(
543                $theJSON->store,
544                $theJSON->statType,
545                $theJSON->dateStart,
546                $theJSON->dateEnd,
547                $theJSON->timeType,
548                $theJSON->exclusions ?? ''
549            );
550            return json_encode($statsArray);
551        }
552
553        if (isset($postData['employeeJSON'])) {
554            $theJSON = json_decode($postData['employeeJSON']);
555            $stats = $this->getEmployeeStats(
556                $theJSON->store,
557                $theJSON->employeeID,
558                $theJSON->dateStart,
559                $theJSON->dateEnd,
560                $theJSON->statType
561            );
562            return json_encode($stats);
563        }
564
565        if (isset($postData['employeeDayJSON'])) {
566            $theJSON = json_decode($postData['employeeDayJSON']);
567            $stats = $this->getEmployeeDayStats(
568                $theJSON->store,
569                $theJSON->employeeID,
570                $theJSON->dateStart,
571                $theJSON->dateEnd
572            );
573            return json_encode($stats);
574        }
575
576        if (isset($postData['statsDayJSON'])) {
577            $theJSON = json_decode($postData['statsDayJSON']);
578            $stats = $this->getStoreDayStats(
579                $theJSON->store,
580                $theJSON->dateStart,
581                $theJSON->dateEnd
582            );
583            return json_encode($stats);
584        }
585
586        return json_encode(['error' => 'Invalid request']);
587    }
588
589    // ==================== PRIVATE HELPER METHODS ====================
590
591    /**
592     * Get today's store statistics
593     *
594     * @param Store $store Store object
595     * @param KLogger $log Logger instance
596     * @return bool Success status
597     */
598    private function getTodaysStoreStats(Store $store, KLogger $log): bool
599    {
600        $log->LogDebug("Starting Daily Stats");
601        $storeDB = dbConnectByName($store->getDbName());
602
603        $dateRange = getCurrentDateRange();
604        $dailyBuys = [];
605
606        $query = $storeDB->prepare(
607            "SELECT * FROM buyQueue
608             WHERE timeEntered BETWEEN :dateStart AND :dateEnd"
609        );
610        $query->execute([
611            ':dateStart' => $dateRange['dateStart']->format("Y-m-d H:i:s"),
612            ':dateEnd' => $dateRange['dateEnd']->format("Y-m-d H:i:s")
613        ]);
614
615        if ($query->rowCount() > 0) {
616            while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
617                $dailyBuys[] = $row;
618            }
619
620            $storeStat = new StoreStat();
621            $storeStat->createStoreStat($dailyBuys);
622            $storeStat->typeNum = $store->getTypeNum();
623            $log->LogDebug("Creating StoreStat for date: " . $storeStat->getDate()->format("Y-m-d H:i:s"));
624
625            if (insertStoreStat($storeStat)) {
626                $log->LogDebug("Successful Insert of StoreStat");
627                return true;
628            }
629        }
630
631        return false;
632    }
633
634    /**
635     * Get all historical store statistics
636     *
637     * @param Store $store Store object
638     * @param KLogger $log Logger instance
639     * @return bool Success status
640     */
641    private function getAllStoreStats(Store $store, KLogger $log): bool
642    {
643        $log->LogDebug("Starting Get All Store Stats for " . $store->getTypeNum());
644        $storeDB = dbConnectByName($store->getDbName());
645
646        $dateQuery = $storeDB->query(
647            "SELECT timeEntered FROM buyQueue ORDER BY timeEntered ASC LIMIT 1"
648        );
649        $theDate = $dateQuery->fetch(PDO::FETCH_ASSOC);
650
651        if ($theDate === false || empty($theDate['timeEntered'])) {
652            $log->LogDebug("No buy queue data found for store " . $store->getTypeNum());
653            return false;
654        }
655
656        $workingDate = new DateTime($theDate['timeEntered']);
657        $dateRange = getDateRange($workingDate->format("Y-m-d H:i:s"));
658        $workingDate = $dateRange['dateStart'];
659        $log->LogDebug("Initial Working Date: " . $workingDate->format("Y-m-d H:i:s"));
660
661        $currentDate = getCurrentDateRange();
662        $count = 0;
663
664        while ($workingDate <= $currentDate['dateEnd']) {
665            $log->LogDebug("Working Date: " . $workingDate->format("Y-m-d H:i:s"));
666            $dailyBuys = [];
667            $dateRange = getDateRange($workingDate->format("Y-m-d H:i:s"));
668
669            $buysQuery = $storeDB->prepare(
670                "SELECT * FROM buyQueue
671                 WHERE timeEntered BETWEEN :dateStart AND :dateEnd"
672            );
673            $buysQuery->execute([
674                ':dateStart' => $dateRange['dateStart']->format("Y-m-d H:i:s"),
675                ':dateEnd' => $dateRange['dateEnd']->format("Y-m-d H:i:s")
676            ]);
677
678            if ($buysQuery->rowCount() > 0) {
679                while ($row = $buysQuery->fetch(PDO::FETCH_ASSOC)) {
680                    $dailyBuys[] = $row;
681                }
682
683                $storeStat = new StoreStat();
684                $storeStat->createStoreStat($dailyBuys);
685                $storeStat->typeNum = $store->getTypeNum();
686
687                if ($storeStat->buysStranded == '') {
688                    $storeStat->setBuysStranded(0);
689                }
690
691                $log->LogDebug("StrandedBuys: " . $storeStat->buysStranded);
692
693                if ($storeStat->numBuys > 0) {
694                    if (insertStoreStat($storeStat)) {
695                        $log->LogDebug("Added " . $workingDate->format("Y-m-d H:i:s"));
696                    } else {
697                        $log->LogDebug("Nothing on " . $workingDate->format("Y-m-d H:i:s"));
698                    }
699                } else {
700                    $log->LogDebug("No Buys counted that day");
701                }
702            }
703
704            $workingDate->add(new DateInterval("P1D"));
705            $count++;
706        }
707
708        return true;
709    }
710
711    /**
712     * Get latest store statistics (catch-up from last recorded date)
713     *
714     * @param Store $store Store object
715     * @param KLogger $log Logger instance
716     * @return bool Success status
717     */
718    private function latestStoreStats(Store $store, KLogger $log): bool
719    {
720        $log->LogDebug("Starting Get Latest Store Stats for " . $store->getTypeNum());
721        $storeDB = dbConnectByName($store->getDbName());
722
723        if (!$storeDB) {
724            $log->LogError("Failed to connect to database for store: " . $store->getTypeNum());
725            return false;
726        }
727
728        // Find last recorded stat date
729        $dateQuery = $storeDB->query(
730            "SELECT date FROM statsStoreDaily ORDER BY date DESC LIMIT 1"
731        );
732        $theDate = $dateQuery->fetch(PDO::FETCH_ASSOC);
733
734        if ($theDate !== false && !empty($theDate['date'])) {
735            $workingDate = new DateTime($theDate['date']);
736        } else {
737            // No stats exist, find first buy
738            $buyQuery = $storeDB->query(
739                "SELECT timeEntered FROM buyQueue ORDER BY timeEntered ASC LIMIT 1"
740            );
741            $theDate = $buyQuery->fetch(PDO::FETCH_ASSOC);
742
743            if ($theDate === false || empty($theDate['timeEntered'])) {
744                $log->LogDebug("No buy queue data found for store " . $store->getTypeNum());
745                return false;
746            }
747
748            $workingDate = new DateTime($theDate['timeEntered']);
749            $workingDate->sub(new DateInterval("P1D"));
750        }
751
752        // Check last queue date
753        $queueDateQuery = $storeDB->query(
754            "SELECT timeEntered FROM buyQueue ORDER BY timeEntered ASC LIMIT 1"
755        );
756        $queueDate = $queueDateQuery->fetch(PDO::FETCH_ASSOC);
757
758        if ($queueDate === false || empty($queueDate['timeEntered'])) {
759            $log->LogDebug("No queue date found for store " . $store->getTypeNum());
760            return false;
761        }
762
763        if ($queueDateQuery->rowCount() == 0) {
764            $log->LogDebug("No buys in queue");
765            return false;
766        }
767
768        $lastQueueDate = new DateTime($queueDate['timeEntered']);
769        $log->LogDebug("Last Queue Date: " . $lastQueueDate->format("Y-m-d H:i:s"));
770
771        $dateRange = getDateRange($workingDate->format("Y-m-d H:i:s"));
772        $workingDate = $dateRange['dateStart'];
773        $log->LogDebug("Initial Working Date: " . $workingDate->format("Y-m-d H:i:s"));
774
775        $currentDate = getCurrentDateRange();
776        $log->LogDebug("Current Date: " . $currentDate['dateStart']->format("Y-m-d H:i:s"));
777
778        // Check if there's anything to process
779        if ($workingDate->getTimestamp() == $currentDate['dateStart']->getTimestamp()) {
780            $log->LogDebug("No new stats to add because current date is today");
781            return false;
782        }
783
784        if ($currentDate['dateStart']->format("Y-m-d") == $lastQueueDate->format("Y-m-d")) {
785            $log->LogDebug("No new stats to add because last queue date is today");
786            return false;
787        }
788
789        while ($workingDate->getTimestamp() <= $currentDate['dateStart']->getTimestamp()) {
790            $dailyBuys = [];
791            $dateRange = getDateRange($workingDate->format("Y-m-d H:i:s"));
792            $log->LogDebug("Loop Date: " . $workingDate->format("Y-m-d H:i:s"));
793
794            $buysQuery = $storeDB->prepare(
795                "SELECT * FROM buyQueue
796                 WHERE timeEntered BETWEEN :dateStart AND :dateEnd"
797            );
798            $buysQuery->execute([
799                ':dateStart' => $dateRange['dateStart']->format("Y-m-d H:i:s"),
800                ':dateEnd' => $dateRange['dateEnd']->format("Y-m-d H:i:s")
801            ]);
802
803            if ($buysQuery->rowCount() > 0) {
804                while ($row = $buysQuery->fetch(PDO::FETCH_ASSOC)) {
805                    $dailyBuys[] = $row;
806                }
807
808                $storeStat = new StoreStat();
809                $storeStat->createStoreStat($dailyBuys);
810                $storeStat->typeNum = $store->getTypeNum();
811                $storeStat->date = $dateRange['dateStart'];
812
813                if ($storeStat->buysStranded == '') {
814                    $storeStat->setBuysStranded(0);
815                }
816
817                $log->LogDebug("StrandedBuys: " . $storeStat->buysStranded);
818
819                if ($storeStat->numBuys > 0) {
820                    if (insertStoreStat($storeStat)) {
821                        $log->LogDebug("Added " . $workingDate->format("Y-m-d H:i:s"));
822                    } else {
823                        $log->LogDebug("Nothing on " . $workingDate->format("Y-m-d H:i:s"));
824                    }
825                } else {
826                    $log->LogDebug("No Buys counted that day");
827                }
828            }
829
830            $workingDate->add(new DateInterval("P1D"));
831        }
832
833        $storeDB = null;
834        return true;
835    }
836
837    /**
838     * Get all employee statistics for a store
839     *
840     * @param Store $store Store object
841     * @param KLogger $log Logger instance
842     * @return bool Success status
843     */
844    private function getStoresEmployeeStats(Store $store, KLogger $log): bool
845    {
846        $log->LogDebug("Starting Get All Employee Stats for " . $store->getTypeNum());
847        $timeStart = microtime(true);
848        $storeDB = dbConnectByName($store->getDbName());
849
850        if (!$storeDB) {
851            $log->LogError("Failed to connect to database for store: " . $store->getTypeNum());
852            return false;
853        }
854
855        // Find last stat date
856        $lastStatDateQuery = $storeDB->query(
857            "SELECT * FROM statsEmployeeDaily ORDER BY date DESC LIMIT 1"
858        );
859        $row = $lastStatDateQuery->fetch(PDO::FETCH_ASSOC);
860
861        if ($row === false || $row['date'] == NULL) {
862            // No stats found, check for first buy
863            $lastStatDateQuery = $storeDB->query(
864                "SELECT * FROM buyQueue ORDER BY timeEntered ASC LIMIT 1"
865            );
866            $row = $lastStatDateQuery->fetch(PDO::FETCH_ASSOC);
867
868            if ($row === false || empty($row['timeEntered'])) {
869                $log->LogDebug("No data found for store " . $store->getTypeNum() . ", skipping employee stats");
870                return false;
871            }
872
873            $startDate = new DateTime($row['timeEntered']);
874        } else {
875            $startDate = new DateTime($row['date']);
876        }
877
878        $dateRange = getDateRange($startDate->format("Y-m-d H:i:s"));
879        $startDate = $dateRange['dateStart'];
880        $startDate->add(new DateInterval("P1D"));
881
882        $currentDate = getCurrentDateRange();
883
884        if ($startDate->format("Y-m-d") == $currentDate['dateStart']->format("Y-m-d")) {
885            $log->LogDebug("No new stats to add");
886            return false;
887        }
888
889        // Don't process if more than 6 months old
890        $sixMonthsAgo = new DateTime("now");
891        $sixMonthsAgo->sub(new DateInterval("P6M"));
892
893        if ($startDate->getTimestamp() < $sixMonthsAgo->getTimestamp()) {
894            $log->LogDebug("Working Date is more than 6 months old");
895            return false;
896        }
897
898        $count = 0;
899        $log->LogDebug("Start Date: " . $startDate->format("Y-m-d H:i:s"));
900
901        while ($startDate->getTimestamp() < $currentDate['dateStart']->getTimestamp() && $count < 1000) {
902            $dateRange = getDateRange($startDate->format("Y-m-d H:i:s"));
903
904            // Get distinct employee IDs for this day
905            $empQuery = $storeDB->prepare(
906                "SELECT DISTINCT buyerID, sorterID FROM buyQueue
907                 WHERE timeEntered BETWEEN :dateStart AND :dateEnd"
908            );
909            $empQuery->execute([
910                ':dateStart' => $dateRange['dateStart']->format("Y-m-d H:i:s"),
911                ':dateEnd' => $dateRange['dateEnd']->format("Y-m-d H:i:s")
912            ]);
913
914            $employeeIDArray = [];
915
916            if ($empQuery) {
917                while ($row = $empQuery->fetch(PDO::FETCH_ASSOC)) {
918                    if (!in_array($row['buyerID'], $employeeIDArray) &&
919                        $row['buyerID'] !== 0 &&
920                        $row['buyerID'] !== NULL) {
921                        $employeeIDArray[] = $row['buyerID'];
922                    }
923                    if (!in_array($row['sorterID'], $employeeIDArray) &&
924                        $row['sorterID'] !== 0 &&
925                        $row['sorterID'] !== NULL) {
926                        $employeeIDArray[] = $row['sorterID'];
927                    }
928                }
929
930                if (count($employeeIDArray) > 0) {
931                    foreach ($employeeIDArray as $id) {
932                        $dailyStatsQuery = $storeDB->prepare(
933                            "SELECT * FROM buyQueue
934                             WHERE (buyerID = :employeeId OR sorterID = :employeeId)
935                             AND timeEntered BETWEEN :dateStart AND :dateEnd"
936                        );
937                        $dailyStatsQuery->execute([
938                            ':employeeId' => $id,
939                            ':dateStart' => $dateRange['dateStart']->format("Y-m-d H:i:s"),
940                            ':dateEnd' => $dateRange['dateEnd']->format("Y-m-d H:i:s")
941                        ]);
942
943                        $dailyStats = [];
944                        $atLeastOneStat = false;
945
946                        if ($dailyStatsQuery) {
947                            while ($row = $dailyStatsQuery->fetch(PDO::FETCH_ASSOC)) {
948                                $dailyStats[] = $row;
949                                $atLeastOneStat = true;
950                            }
951
952                            if ($atLeastOneStat) {
953                                $employeeDaily = new EmployeeDaily();
954                                $employeeDaily->createEmployeeDaily($dailyStats, $id);
955                                $employeeDaily->setTypeNum($store->getTypeNum());
956
957                                if ($employeeDaily->getAvgTotal() > 0) {
958                                    insertEmployeeDaily($employeeDaily);
959                                }
960                            }
961                        }
962                    }
963                }
964            }
965
966            $startDate->add(new DateInterval("P1D"));
967            $count++;
968        }
969
970        $storeDB = null;
971        return true;
972    }
973
974    /**
975     * Get buyer performance statistics
976     *
977     * @param DateTime $dateStart Start date
978     * @param DateTime $dateEnd End date
979     * @param Store $store Store object
980     * @return array Buyer statistics with efficiency scores
981     */
982    private function getBuyersStatsArray(DateTime $dateStart, DateTime $dateEnd, Store $store): array
983    {
984        $db = dbConnectByName($store->getDbName());
985
986        $query = $db->prepare("
987            SELECT * FROM (
988                SELECT buyerID,
989                    ROUND(AVG(UNIX_TIMESTAMP(timeCompleted) - UNIX_TIMESTAMP(timeStarted)), 1) as avgProcessTime,
990                    ROUND(AVG((UNIX_TIMESTAMP(timeCompleted) - UNIX_TIMESTAMP(timeStarted)) /
991                        (IF(remainingContainers > 0, remainingContainers, numContainers))), 1) as avgPTC
992                FROM buyQueue
993                WHERE timeEntered BETWEEN :dateStart AND :dateEnd
994                AND isProcessed = 1
995                AND (UNIX_TIMESTAMP(timeCompleted) - UNIX_TIMESTAMP(timeStarted) BETWEEN :minSeconds AND :maxSeconds)
996                GROUP BY buyerID
997            ) as A
998            JOIN (
999                SELECT buyerID, COUNT(d) as totalDays,
1000                    SUM(totalContainers) as totalContainers,
1001                    SUM(totalBuys) as totalBuys,
1002                    ROUND(SUM(totalBuys)/COUNT(d), 1) as avgBuysPerDay,
1003                    ROUND(SUM(totalContainers)/COUNT(d), 1) as avgContainersPerDay
1004                FROM (
1005                    SELECT buyerID, DATE(timeEntered) as d,
1006                        SUM(IF(remainingContainers > 0, remainingContainers, numContainers)) as totalContainers,
1007                        COUNT(buyID) as totalBuys
1008                    FROM buyQueue
1009                    WHERE timeEntered BETWEEN :dateStart AND :dateEnd
1010                    AND isProcessed = 1
1011                    AND (UNIX_TIMESTAMP(timeCompleted) - UNIX_TIMESTAMP(timeStarted) BETWEEN :minSeconds AND :maxSeconds)
1012                    GROUP BY buyerID, d
1013                ) as D
1014                GROUP BY buyerID
1015            ) AS E ON A.buyerID = E.buyerID
1016            LEFT JOIN (
1017                SELECT employeeFirstName as firstName,
1018                    employeeLastName as lastName,
1019                    employeeID as id
1020                FROM employees WHERE 1
1021            ) as F ON F.id = A.buyerID
1022        ");
1023
1024        $query->execute([
1025            ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
1026            ':dateEnd' => $dateEnd->format("Y-m-d H:i:s"),
1027            ':minSeconds' => $store->getStatMinSeconds(),
1028            ':maxSeconds' => $store->getStatMaxSeconds()
1029        ]);
1030
1031        $employeeArray = [];
1032        while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
1033            $employeeArray[] = $row;
1034        }
1035
1036        return $this->addBuyerEfficiencyScores($employeeArray);
1037    }
1038
1039    /**
1040     * Get sorter performance statistics
1041     *
1042     * @param DateTime $dateStart Start date
1043     * @param DateTime $dateEnd End date
1044     * @param Store $store Store object
1045     * @return array Sorter statistics with efficiency scores
1046     */
1047    private function getSortersStatsArray(DateTime $dateStart, DateTime $dateEnd, Store $store): array
1048    {
1049        $db = dbConnectByName($store->getDbName());
1050
1051        $query = $db->prepare("
1052            SELECT * FROM (
1053                SELECT sorterID,
1054                    ROUND(AVG(UNIX_TIMESTAMP(sortCompleted) - UNIX_TIMESTAMP(sortStarted)), 1) as avgsortTime,
1055                    ROUND(AVG((UNIX_TIMESTAMP(sortCompleted) - UNIX_TIMESTAMP(sortStarted)) / numContainers), 1) as avgSTC
1056                FROM buyQueue
1057                WHERE timeEntered BETWEEN :dateStart AND :dateEnd
1058                AND isProcessed = 1
1059                AND (UNIX_TIMESTAMP(sortCompleted) - UNIX_TIMESTAMP(sortStarted) BETWEEN :minSeconds AND :maxSeconds)
1060                GROUP BY sorterID
1061            ) as sortTimes
1062            JOIN (
1063                SELECT COUNT(date) as totalDays, sorterID,
1064                    SUM(totalContainers) as totalContainers,
1065                    SUM(totalSorts) as totalSorts,
1066                    ROUND(SUM(totalSorts)/COUNT(date), 1) as avgBuysPerDay,
1067                    ROUND(SUM(totalContainers)/COUNT(date), 1) as avgContainersPerDay
1068                FROM (
1069                    SELECT sorterID, DATE(timeEntered) as date,
1070                        SUM(numContainers) as totalContainers,
1071                        COUNT(buyID) as totalSorts
1072                    FROM buyQueue
1073                    WHERE timeEntered BETWEEN :dateStart AND :dateEnd
1074                    AND isProcessed = 1
1075                    AND (UNIX_TIMESTAMP(sortCompleted) - UNIX_TIMESTAMP(sortStarted) BETWEEN :minSeconds AND :maxSeconds)
1076                    GROUP BY sorterID, date
1077                ) as dateStuff
1078                GROUP BY sorterID
1079            ) as F ON sortTimes.sorterID = F.sorterID
1080            LEFT JOIN (
1081                SELECT employeeFirstName as firstName,
1082                    employeeLastName as lastName,
1083                    employeeID as id
1084                FROM employees WHERE 1
1085            ) as J ON J.id = sortTimes.sorterID
1086        ");
1087
1088        $query->execute([
1089            ':dateStart' => $dateStart->format("Y-m-d H:i:s"),
1090            ':dateEnd' => $dateEnd->format("Y-m-d H:i:s"),
1091            ':minSeconds' => $store->getStatMinSeconds(),
1092            ':maxSeconds' => $store->getStatMaxSeconds()
1093        ]);
1094
1095        $employeeArray = [];
1096        while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
1097            $employeeArray[] = $row;
1098        }
1099
1100        return $this->addSorterEfficiencyScores($employeeArray);
1101    }
1102
1103    /**
1104     * Add efficiency scores to buyer statistics
1105     *
1106     * @param array $employeeArray Raw employee statistics
1107     * @return array Employee statistics with composite scores
1108     */
1109    private function addBuyerEfficiencyScores(array $employeeArray): array
1110    {
1111        if (empty($employeeArray)) {
1112            return $employeeArray;
1113        }
1114
1115        $processTimesArray = array_column($employeeArray, 'avgProcessTime');
1116        $buyTotalsArray = array_column($employeeArray, 'totalBuys');
1117        $ptcArray = array_column($employeeArray, 'avgPTC');
1118        $binsTotalsArray = array_column($employeeArray, 'totalContainers');
1119
1120        // Compute the mean of each category
1121        $allAvgBuysPerDay = $this->harmonicMean($buyTotalsArray);
1122        $allBinsTotals = $this->harmonicMean($binsTotalsArray);
1123        $allAvgPTC = $this->arithmeticMean($ptcArray);
1124        $allAvgProcessTimes = $this->arithmeticMean($processTimesArray);
1125
1126        // Compute the standard deviation of each category
1127        $avgBuysSD = $this->standardDeviation($buyTotalsArray);
1128        $avgBinsSD = $this->standardDeviation($binsTotalsArray);
1129        $avgPTCSD = $this->standardDeviation($ptcArray);
1130        $avgProcessTimeSD = $this->standardDeviation($processTimesArray);
1131
1132        // Iterate through each employee
1133        foreach ($employeeArray as $key => $employee) {
1134            // Get z-scores
1135            $buysPerDayZScore = ((int)$employee['totalBuys'] - $allAvgBuysPerDay) /
1136                ($avgBuysSD > 0 ? $avgBuysSD : 1);
1137            $binsTotalsZScore = ((int)$employee['totalContainers'] - $allBinsTotals) /
1138                ($avgBinsSD > 0 ? $avgBinsSD : 1);
1139            $processTimeZScore = ((int)$employee['avgProcessTime'] - $allAvgProcessTimes) /
1140                ($avgProcessTimeSD > 0 ? $avgProcessTimeSD : 1);
1141            $ptcZScore = ((int)$employee['avgPTC'] - $allAvgPTC) /
1142                ($avgPTCSD > 0 ? $avgPTCSD : 1);
1143
1144            // Calculate percentiles
1145            $buysPercentile = $this->cdf($buysPerDayZScore);
1146            $binsPercentile = $this->cdf($binsTotalsZScore);
1147            $processTimesPercentile = $this->cdf($processTimeZScore);
1148            $ptcPercentile = $this->cdf($ptcZScore);
1149
1150            // Calculate composite score
1151            $compositeScore = (0.20 * $buysPercentile) +
1152                             (0.30 * $binsPercentile) +
1153                             (0.20 * (1 - $processTimesPercentile)) +
1154                             (0.30 * (1 - $ptcPercentile));
1155
1156            $employeeArray[$key]['compositeScore'] = $compositeScore;
1157            $employeeArray[$key]['buysScore'] = $buysPercentile;
1158            $employeeArray[$key]['binsScore'] = $binsPercentile;
1159            $employeeArray[$key]['processScore'] = 1 - $processTimesPercentile;
1160            $employeeArray[$key]['ptcScore'] = 1 - $ptcPercentile;
1161        }
1162
1163        return $employeeArray;
1164    }
1165
1166    /**
1167     * Add efficiency scores to sorter statistics
1168     *
1169     * @param array $employeeArray Raw employee statistics
1170     * @return array Employee statistics with composite scores
1171     */
1172    private function addSorterEfficiencyScores(array $employeeArray): array
1173    {
1174        if (empty($employeeArray)) {
1175            return $employeeArray;
1176        }
1177
1178        $processTimesArray = array_column($employeeArray, 'avgsortTime');
1179        $buyTotalsArray = array_column($employeeArray, 'totalSorts');
1180        $ptcArray = array_column($employeeArray, 'avgSTC');
1181        $binsTotalsArray = array_column($employeeArray, 'totalContainers');
1182
1183        // Compute the mean of each category
1184        $allAvgBuysPerDay = $this->harmonicMean($buyTotalsArray);
1185        $allBinsTotals = $this->harmonicMean($binsTotalsArray);
1186        $allAvgPTC = $this->arithmeticMean($ptcArray);
1187        $allAvgProcessTimes = $this->arithmeticMean($processTimesArray);
1188
1189        // Compute the standard deviation of each category
1190        $avgBuysSD = $this->standardDeviation($buyTotalsArray);
1191        $avgBinsSD = $this->standardDeviation($binsTotalsArray);
1192        $avgPTCSD = $this->standardDeviation($ptcArray);
1193        $avgProcessTimeSD = $this->standardDeviation($processTimesArray);
1194
1195        // Iterate through each employee
1196        foreach ($employeeArray as $key => $employee) {
1197            // Get z-scores
1198            $buysPerDayZScore = ((int)$employee['totalSorts'] - $allAvgBuysPerDay) /
1199                ($avgBuysSD > 0 ? $avgBuysSD : 1);
1200            $binsTotalsZScore = ((int)$employee['totalContainers'] - $allBinsTotals) /
1201                ($avgBinsSD > 0 ? $avgBinsSD : 1);
1202            $processTimeZScore = ((int)$employee['avgsortTime'] - $allAvgProcessTimes) /
1203                ($avgProcessTimeSD > 0 ? $avgProcessTimeSD : 1);
1204            $ptcZScore = ((int)$employee['avgSTC'] - $allAvgPTC) /
1205                ($avgPTCSD > 0 ? $avgPTCSD : 1);
1206
1207            // Calculate percentiles
1208            $buysPercentile = $this->cdf($buysPerDayZScore);
1209            $binsPercentile = $this->cdf($binsTotalsZScore);
1210            $processTimesPercentile = $this->cdf($processTimeZScore);
1211            $ptcPercentile = $this->cdf($ptcZScore);
1212
1213            // Calculate composite score
1214            $compositeScore = (0.20 * $buysPercentile) +
1215                             (0.30 * $binsPercentile) +
1216                             (0.20 * (1 - $processTimesPercentile)) +
1217                             (0.30 * (1 - $ptcPercentile));
1218
1219            $employeeArray[$key]['compositeScore'] = $compositeScore;
1220            $employeeArray[$key]['buysScore'] = $buysPercentile;
1221            $employeeArray[$key]['binsScore'] = $binsPercentile;
1222            $employeeArray[$key]['processScore'] = 1 - $processTimesPercentile;
1223            $employeeArray[$key]['ptcScore'] = 1 - $ptcPercentile;
1224        }
1225
1226        return $employeeArray;
1227    }
1228
1229    // ==================== STATISTICAL HELPER METHODS ====================
1230
1231    /**
1232     * Calculate error function (erf) approximation
1233     *
1234     * @param float $x Input value
1235     * @return float Error function result
1236     */
1237    private function erf(float $x): float
1238    {
1239        $pi = pi();
1240        $a = (8 * ($pi - 3)) / (3 * $pi * (4 - $pi));
1241        $x2 = $x * $x;
1242
1243        $ax2 = $a * $x2;
1244        $num = (4 / $pi) + $ax2;
1245        $denom = 1 + $ax2;
1246
1247        $inner = (-$x2) * $num / $denom;
1248        $erf2 = 1 - exp($inner);
1249
1250        return sqrt($erf2);
1251    }
1252
1253    /**
1254     * Calculate cumulative distribution function (CDF)
1255     *
1256     * @param float $n Z-score
1257     * @return float Percentile value
1258     */
1259    private function cdf(float $n): float
1260    {
1261        if ($n < 0) {
1262            return (1 - $this->erf($n / sqrt(2))) / 2;
1263        } else {
1264            return (1 + $this->erf($n / sqrt(2))) / 2;
1265        }
1266    }
1267
1268    /**
1269     * Calculate standard deviation
1270     *
1271     * @param array $a Array of values
1272     * @param bool $sample Use sample standard deviation
1273     * @return float Standard deviation
1274     */
1275    private function standardDeviation(array $a, bool $sample = false): float
1276    {
1277        $n = count($a);
1278
1279        if ($n === 0) {
1280            return 0;
1281        }
1282
1283        if ($sample && $n === 1) {
1284            return 0;
1285        }
1286
1287        $mean = array_sum($a) / $n;
1288        $carry = 0.0;
1289
1290        foreach ($a as $val) {
1291            $d = ((float)$val) - $mean;
1292            $carry += $d * $d;
1293        }
1294
1295        if ($sample) {
1296            --$n;
1297        }
1298
1299        return sqrt($carry / $n);
1300    }
1301
1302    /**
1303     * Calculate arithmetic mean
1304     *
1305     * @param array $a Array of values
1306     * @return float Mean value
1307     */
1308    private function arithmeticMean(array $a): float
1309    {
1310        if (count($a) == 0) {
1311            return 0;
1312        }
1313
1314        return array_sum($a) / count($a);
1315    }
1316
1317    /**
1318     * Calculate harmonic mean
1319     *
1320     * @param array $a Array of values
1321     * @return float Harmonic mean
1322     */
1323    private function harmonicMean(array $a): float
1324    {
1325        $sum = 0;
1326
1327        foreach ($a as $n) {
1328            if ($n != 0) {
1329                $sum += 1 / $n;
1330            }
1331        }
1332
1333        if ($sum == 0) {
1334            return 0;
1335        }
1336
1337        return (1 / $sum) * count($a);
1338    }
1339}