Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CustomersListController
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 2
2162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomersList
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 1
2070
1<?php
2
3namespace BuyerKiosk\Customers\Controllers;
4
5// Removed PSR-7 use statements
6
7class CustomersListController extends \BuyerKiosk\Core\Controllers\BaseController {
8
9    private $log;
10    private $store;
11    private $storeDB;
12
13    // Constructor remains the same
14    public function __construct($app, \Store $store)
15    {
16        parent::__construct($app); // Passes $app to BaseController, which stores it in $_app
17        $this->log = new \KLogger(logDirectory."customer_list_controller.log", \KLogger::DEBUG);
18        $this->store = $store;
19        $this->storeDB = dbConnectByName($store->getDbName());
20    }
21
22    /**
23     * Returns a list of Customers for the DataTables AJAX request (Slim v2).
24     *
25     * Triggered by POST /{typeNum}/customers
26     * Uses $this->_app (from BaseController) to access request/response.
27     *
28     * @param string $typeNum (Passed from route closure)
29     */
30    public function getCustomersList($typeNum) // Route passes $typeNum
31    {
32        // Access Slim v2 request object via $_app property
33        $request = $this->_app->request();
34
35        // Get parameters using Slim v2 methods (checks POST first)
36        $params = $request->params(); // Use params() to get POST/GET vars
37
38        // --- Parameter extraction ---
39        $draw = isset($params['draw']) ? intval($params['draw']) : 0;
40        $start = isset($params['start']) ? intval($params['start']) : 0;
41        $length = isset($params['length']) ? intval($params['length']) : 25;
42        // Access nested search value correctly
43        $search = isset($params['search']) && isset($params['search']['value']) ? $params['search']['value'] : '';
44        $order = isset($params['order']) ? $params['order'] : [];
45        $columns = isset($params['columns']) ? $params['columns'] : [];
46        // Get rating filter specifically from POST if params() doesn't work as expected
47        $ratingFilter = $request->post('rating_filter', ''); // Default to empty string if not found
48
49        // --- Column mapping ---
50        $dbColumns = [
51            0 => 'c.customerID', 1 => 'c.phone', 2 => 'c.driversLicense',
52            3 => 'c.lastName', 4 => 'buy_count', 5 => 'avg_rating', 6 => 'latest_buy_time'
53        ];
54
55        // --- Base Query Construction ---
56        $selectQuery = "
57            SELECT
58                c.customerID, c.phone, c.driversLicense, c.firstName, c.lastName,
59                COUNT(DISTINCT bq.buyID) as buy_count,
60                COALESCE(ROUND(AVG(cr.rating)), 'NR') as avg_rating, -- Removed / 2, round to whole number
61                MAX(bq.timeEntered) as latest_buy_time
62            FROM customers c
63            LEFT JOIN buyQueue bq ON c.customerID = bq.customerID AND bq.doCount = 1
64            LEFT JOIN customerRatings cr ON c.customerID = cr.customerID
65        ";
66        $countQueryBase = "SELECT COUNT(DISTINCT c.customerID) FROM customers c";
67        $whereClauses = [];
68        $bindings = [];
69
70        // --- Filtering (Global Search) ---
71        if (!empty($search)) {
72            $searchWhere = [];
73            $searchTerm = "%{$search}%";
74            $searchableColumns = ['c.customerID', 'c.phone', 'c.driversLicense', 'c.firstName', 'c.lastName'];
75            foreach ($searchableColumns as $col) {
76                 $searchWhere[] = "{$col} LIKE :search_term";
77            }
78            $searchWhere[] = "CONCAT(c.firstName, ' ', c.lastName) LIKE :search_term";
79            $whereClauses[] = "(" . implode(" OR ", $searchWhere) . ")";
80            $bindings[':search_term'] = $searchTerm;
81        }
82
83        $whereSql = !empty($whereClauses) ? " WHERE " . implode(" AND ", $whereClauses) : "";
84        $groupBySql = " GROUP BY c.customerID, c.firstName, c.lastName, c.phone, c.driversLicense";
85
86        // --- Filtering (Rating - HAVING clause) ---
87        $havingClauses = [];
88        if (!empty($ratingFilter)) {
89            if ($ratingFilter === 'NR') {
90                $havingClauses[] = "avg_rating = 'NR'";
91            } elseif (is_numeric($ratingFilter)) {
92                $ratingValue = intval($ratingFilter); // Compare as integer
93                // Compare the rounded average rating (as integer)
94                $havingClauses[] = "(avg_rating != 'NR' AND CAST(avg_rating AS UNSIGNED) >= :rating_filter)";
95                $bindings[':rating_filter'] = $ratingValue;
96            } else {
97                 $this->log->LogWarning("[GET_CUSTOMERS_LIST] Invalid non-numeric rating filter value received: " . $ratingFilter);
98            }
99        }
100        $havingSql = !empty($havingClauses) ? " HAVING " . implode(" AND ", $havingClauses) : "";
101
102        // --- Counts ---
103        $recordsTotal = 0;
104        $recordsFiltered = 0;
105        try {
106            // Total count
107            $stmtTotal = $this->storeDB->query($countQueryBase);
108            $recordsTotal = $stmtTotal ? intval($stmtTotal->fetchColumn()) : 0;
109
110            // Filtered count
111            // Need to use the correct calculation in the subquery for HAVING
112            $countFilteredQuery = "SELECT COUNT(*) FROM (
113                                       SELECT c.customerID, COALESCE(ROUND(AVG(cr.rating)), 'NR') as avg_rating -- Removed / 2
114                                       FROM customers c
115                                       LEFT JOIN buyQueue bq ON c.customerID = bq.customerID AND bq.doCount = 1
116                                       LEFT JOIN customerRatings cr ON c.customerID = cr.customerID
117                                       {$whereSql}
118                                       GROUP BY c.customerID
119                                       {$havingSql}
120                                   ) AS filtered_customers";
121            $stmtFiltered = $this->storeDB->prepare($countFilteredQuery);
122            $countBindings = [];
123            if (isset($bindings[':search_term'])) $countBindings[':search_term'] = $bindings[':search_term'];
124            // Bind rating filter as integer
125            if (isset($bindings[':rating_filter'])) $countBindings[':rating_filter'] = $bindings[':rating_filter'];
126            $stmtFiltered->execute($countBindings);
127            $recordsFiltered = $stmtFiltered ? intval($stmtFiltered->fetchColumn()) : 0;
128
129        } catch (\PDOException $e) {
130            $this->log->LogError("[GET_CUSTOMERS_LIST] Error getting counts: " . $e->getMessage());
131        }
132
133        // --- Sorting ---
134        $orderBySql = "";
135        if (!empty($order)) {
136            $orderByParts = [];
137            foreach ($order as $orderItem) {
138                // Ensure keys exist before accessing
139                $colIndex = isset($orderItem['column']) ? intval($orderItem['column']) : null;
140                $colDir = isset($orderItem['dir']) && strtolower($orderItem['dir']) === 'desc' ? 'DESC' : 'ASC';
141
142                if ($colIndex !== null && isset($dbColumns[$colIndex])) {
143                    $colName = $dbColumns[$colIndex];
144                    if ($colIndex == 3) { $orderByParts[] = "c.lastName {$colDir}, c.firstName {$colDir}"; }
145                    // Use the correct calculation in ORDER BY for rating, handling 'NR'
146                    elseif ($colIndex == 5) { $orderByParts[] = "CASE WHEN COALESCE(ROUND(AVG(cr.rating)), 'NR') = 'NR' THEN -1 ELSE ROUND(AVG(cr.rating)) END {$colDir}"; } // Removed / 2
147                    elseif ($colIndex == 6) { $orderByParts[] = "latest_buy_time {$colDir}"; }
148                    else { $orderByParts[] = "{$colName} {$colDir}"; }
149                }
150            }
151            if (!empty($orderByParts)) { $orderBySql = " ORDER BY " . implode(", ", $orderByParts); }
152        }
153        if (empty($orderBySql)) { $orderBySql = " ORDER BY c.lastName ASC, c.firstName ASC"; } // Default sort
154
155        // --- Pagination ---
156        $limitSql = "";
157        if ($length == -1) {
158             $limitSql = ""; // Show all
159        } elseif ($length > 0) {
160            $limitSql = " LIMIT :limit OFFSET :offset";
161        } else { // Invalid length, default to 25
162             $length = 25;
163             $limitSql = " LIMIT :limit OFFSET :offset";
164        }
165
166        // --- Data Fetching ---
167        $data = [];
168        try {
169            $dataQuery = $selectQuery . $whereSql . $groupBySql . $havingSql . $orderBySql . $limitSql;
170            $stmtData = $this->storeDB->prepare($dataQuery);
171
172            // Bind all parameters
173            foreach ($bindings as $key => $value) {
174                 // Bind rating filter as integer if it exists
175                 $paramType = ($key === ':rating_filter') ? \PDO::PARAM_INT : \PDO::PARAM_STR;
176                 $stmtData->bindValue($key, $value, $paramType);
177            }
178            if (!empty($limitSql)) {
179                 $stmtData->bindValue(':limit', $length, \PDO::PARAM_INT);
180                 $stmtData->bindValue(':offset', $start, \PDO::PARAM_INT);
181            }
182
183            $stmtData->execute();
184            $results = $stmtData->fetchAll(\PDO::FETCH_ASSOC);
185
186            // --- Data Formatting ---
187            $timezone = new \DateTimeZone($this->store->getTimeZone());
188            foreach ($results as $row) {
189                $latestVisitTimestamp = null;
190                $latestVisitDisplay = 'N/A';
191                if ($row['latest_buy_time'] && $row['latest_buy_time'] !== '0000-00-00 00:00:00') {
192                    try {
193                        $dt = new \DateTime($row['latest_buy_time']);
194                        $dt->setTimezone($timezone);
195                        $latestVisitTimestamp = $dt->getTimestamp();
196                        $latestVisitDisplay = $dt->format('m/d/Y g:i A');
197                    } catch (\Exception $e) {
198                         $this->log->LogWarning("[GET_CUSTOMERS_LIST] Error parsing date: " . $row['latest_buy_time'] . " Error: " . $e->getMessage());
199                    }
200                }
201
202                // Mask the driversLicense: show first 2 and last 4 chars
203                $maskedLicense = '-'; // Default to '-'
204                $dl = $row['driversLicense'];
205                if (!empty($dl) && strlen($dl) >= 6) { // Ensure it's long enough to mask
206                    $maskedLicense = substr($dl, 0, 2) . '****' . substr($dl, -4);
207                } elseif (!empty($dl)) {
208                    // If shorter than 6 chars, just show '-'
209                     $maskedLicense = '-';
210                }
211
212                $data[] = [
213                    "customerID" => $row['customerID'],
214                    "phone" => $row['phone'],
215                    "driversLicense" => $maskedLicense, // Use the masked value
216                    "fullName" => $row['firstName'] . ' ' . $row['lastName'],
217                    "count" => intval($row['buy_count']),
218                    "rating" => $row['avg_rating'], // This should now be a whole number 1-5 or 'NR'
219                    "latestVisit" => ["display" => $latestVisitDisplay, "timeStamp" => $latestVisitTimestamp]
220                ];
221            }
222
223        } catch (\PDOException $e) {
224            $this->log->LogError("[GET_CUSTOMERS_LIST] Error fetching data: " . $e->getMessage() . " Query: " . $dataQuery);
225            $data = []; // Ensure data is empty on error
226        }
227
228        // --- Response Preparation (Slim v2) ---
229        $responseJson = [
230            'draw' => $draw,
231            'recordsTotal' => $recordsTotal,
232            'recordsFiltered' => $recordsFiltered,
233            'data' => $data
234        ];
235
236        // Use Slim v2 response methods via $_app property
237        $response = $this->_app->response();
238        $response->headers->set('Content-Type', 'application/json');
239        $response->setBody(json_encode($responseJson));
240        // Slim v2 handles sending the response implicitly.
241    }
242}