Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 129 |
|
0.00% |
0 / 2 |
CRAP | |
0.00% |
0 / 1 |
| CustomersListController | |
0.00% |
0 / 129 |
|
0.00% |
0 / 2 |
2162 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| getCustomersList | |
0.00% |
0 / 125 |
|
0.00% |
0 / 1 |
2070 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Customers\Controllers; |
| 4 | |
| 5 | // Removed PSR-7 use statements |
| 6 | |
| 7 | class 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 | } |