diff --git a/services/nginx/app/classes/department_wash_count_service.php b/services/nginx/app/classes/department_wash_count_service.php index 8bb55a55..77a74520 100644 --- a/services/nginx/app/classes/department_wash_count_service.php +++ b/services/nginx/app/classes/department_wash_count_service.php @@ -2,6 +2,8 @@ namespace classes; +require_once WD . '/classes/selfserve_schema_bootstrap.php'; + use Exception; class department_wash_count_service diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index 50ad00a4..23545c67 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -73,7 +73,9 @@ class selfserve_schema_bootstrap INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg), INDEX idx_selfserve_wash_sessions_status (status), INDEX idx_selfserve_wash_sessions_customer (customer_number), - INDEX idx_selfserve_wash_sessions_created_at (created_at) + INDEX idx_selfserve_wash_sessions_created_at (created_at), + INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at), + INDEX idx_selfserve_wash_sessions_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers ( @@ -219,6 +221,16 @@ class selfserve_schema_bootstrap 'wash_started_at', 'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at' ); + self::ensureIndex( + 'selfserve_wash_sessions', + 'idx_selfserve_wash_sessions_department_completed', + 'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)' + ); + self::ensureIndex( + 'selfserve_wash_sessions', + 'idx_selfserve_wash_sessions_order', + 'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)' + ); self::$initialized = true; } @@ -252,6 +264,35 @@ class selfserve_schema_bootstrap $db->query($alterSql); } + public static function tableHasIndex(string $table, string $index): bool + { + global $db; + $table = $db->escape_string($table); + $index = $db->escape_string($index); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND INDEX_NAME = '$index'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } + + public static function ensureIndex(string $table, string $index, string $alterSql): void + { + global $db; + if (self::tableHasIndex($table, $index)) { + return; + } + $db->query($alterSql); + } + /** * @param array $acceptedDataTypes */ diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php index 17b8de0a..9cce84ad 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -440,6 +440,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return null; } + $this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId); + if (!$session->markCompletedIfOpen($orderId)) { return $this->getSessionSummary((int)$session->id); } @@ -456,6 +458,25 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $this->getSessionSummary((int)$session->id); } + protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void + { + try { + if ($session->wash_started_at->value() !== null) { + return; + } + + $lane = (new selfserve())->lane($laneId); + $washStartedAt = (int)$lane->getWashStartTime(); + if ($washStartedAt <= 0) { + return; + } + + $session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt)); + } catch (\Throwable) { + // Session timestamp enrichment must not block STOP completion. + } + } + public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array { $lane = (new selfserve())->lane($laneId); diff --git a/services/nginx/app/objects/department_daily_reports_o.php b/services/nginx/app/objects/department_daily_reports_o.php index 0bc54af1..e98e9d16 100644 --- a/services/nginx/app/objects/department_daily_reports_o.php +++ b/services/nginx/app/objects/department_daily_reports_o.php @@ -2,7 +2,10 @@ namespace objects; +require_once WD . '/classes/department_wash_count_service.php'; + use classes\db; +use classes\department_wash_count_service; use classes\object_property; use Exception; use traits\db_object_t; @@ -510,8 +513,6 @@ class department_daily_reports_o extends db public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int { return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) { - global /** @var db $db */ - $db; // If the date_to is null, set it to the date if ($date_to === null) { $date_to = $date; // Making the report for an entire day @@ -519,33 +520,7 @@ class department_daily_reports_o extends db // Set the date time to cover the entire day $date = date('Y-m-d 00:00:00', strtotime($date)); $date_to = date('Y-m-d 23:59:59', strtotime($date_to)); - $conn = $db->conn(); - // Get all the product prices, in all the orders (Not counting removed orders), for the given date and department, and sum them - $stmt = $conn->prepare( - 'SELECT COUNT(DISTINCT o.id) as amount FROM orders o - JOIN order_items oi ON o.id = oi.order_id - JOIN products p ON oi.product_id = p.id - WHERE o.department_id = ? AND DATE(o.created_at) BETWEEN ? AND ? AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1' - ); - if ($stmt) { - $stmt->bind_param('iss', $department_id, $date, $date_to); // Bind parameters (i = integer, s = string) - $stmt->execute(); - $result = $stmt->get_result(); // Get the result set from the statement - $data = $result->fetch_assoc(); // Fetch the result as an associative array - - // Access the "amount" field - if (!$data) { - // If there are no orders, set the amount to 0 - $amount = 0; - } else { - $amount = $data['amount']; - } - $stmt->close(); // Close the statement - } else { - // Handle query preparation error - die('Query preparation failed: ' . $conn->error); - } - return (int)$amount; + return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id); }); } @@ -616,9 +591,6 @@ class department_daily_reports_o extends db */ public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array { - global /** @var db $db */ - $db; - $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); if ($normalized_department_ids === []) { return [ @@ -631,30 +603,17 @@ class department_daily_reports_o extends db } [$date_start, $date_end] = $this->resolveDateRange($date, $date_to); - $department_ids_sql = implode(',', $normalized_department_ids); - $escaped_start = $db->escape_string($date_start); - $escaped_end = $db->escape_string($date_end); - - $sql = "SELECT COUNT(DISTINCT o.id) AS quantity, - COALESCE(SUM(oi.quantity), 0) AS products, - COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings, - COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes - FROM orders o - JOIN order_items oi ON oi.order_id = o.id - LEFT JOIN products p ON p.id = oi.product_id - WHERE o.department_id IN ($department_ids_sql) - AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' - AND o.deleted_at IS NULL - AND oi.deleted_at IS NULL"; - - $result = $db->query($sql); - $row = is_object($result) ? $result->fetch_assoc() : null; + $transaction_summary = (new department_wash_count_service())->transactionSummary( + $date_start, + $date_end, + $normalized_department_ids + ); return [ - 'quantity' => (int)($row['quantity'] ?? 0), - 'products' => (int)($row['products'] ?? 0), - 'earnings' => (int)round((float)($row['earnings'] ?? 0)), - 'washes' => (int)($row['washes'] ?? 0), + 'quantity' => (int)($transaction_summary['quantity'] ?? 0), + 'products' => (int)($transaction_summary['products'] ?? 0), + 'earnings' => (int)($transaction_summary['earnings'] ?? 0), + 'washes' => (int)($transaction_summary['washes'] ?? 0), 'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to), ]; } @@ -758,45 +717,13 @@ class department_daily_reports_o extends db */ public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array { - global /** @var db $db */ - $db; - $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); if ($normalized_department_ids === []) { return []; } [$date_start, $date_end] = $this->resolveDateRange($date, $date_to); - $department_ids_sql = implode(',', $normalized_department_ids); - $escaped_start = $db->escape_string($date_start); - $escaped_end = $db->escape_string($date_end); - - $sql = "SELECT DISTINCT o.id, o.department_id, o.created_at - FROM orders o - JOIN order_items oi ON oi.order_id = o.id - JOIN products p ON p.id = oi.product_id - WHERE o.department_id IN ($department_ids_sql) - AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' - AND o.deleted_at IS NULL - AND oi.deleted_at IS NULL - AND p.is_wash = 1 - ORDER BY o.created_at ASC"; - - $result = $db->query($sql); - if (!is_object($result) || $result->num_rows === 0) { - return []; - } - - $rows = []; - while ($row = $result->fetch_assoc()) { - $rows[] = [ - 'id' => (int)($row['id'] ?? 0), - 'department_id' => (int)($row['department_id'] ?? 0), - 'created_at' => (string)($row['created_at'] ?? ''), - ]; - } - - return $rows; + return (new department_wash_count_service())->listTransactions($date_start, $date_end, $normalized_department_ids); } /** diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 5a28ab8c..9efb4809 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -2,8 +2,11 @@ namespace objects; +require_once WD . '/classes/department_wash_count_service.php'; + use attachments\helpers\attachment_content; use classes\db; +use classes\department_wash_count_service; use classes\email; use classes\invoicing_period_utils; use classes\orders_schema_bootstrap; @@ -2005,33 +2008,7 @@ class orders_o extends db public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int { - global /** @var db $db */ - $db; - // Validate the date range - if (strtotime($date_start) === false || strtotime($date_end) === false) { - throw new Exception('Invalid date range provided'); - } - if (strtotime($date_start) > strtotime($date_end)) { - throw new Exception('The start date cannot be after the end date'); - } - // Prepare the SQL query to count washes in the date range for the department - $date_start = $db->escape_string($date_start); - $date_end = $db->escape_string($date_end); - // Get the amount of orders with at least one order item that has a product with the is_wash column set to true - $sql = "SELECT COUNT(DISTINCT o.id) AS wash_count - FROM $this->table o - JOIN order_items oi ON oi.order_id = o.id - JOIN products p ON p.id = oi.product_id - WHERE o.department_id = $department_id - AND o.created_at BETWEEN '$date_start' AND '$date_end' - AND o.deleted_at IS NULL - AND p.is_wash = 1"; - $result = $db->query($sql); - if ($result->num_rows === 0) { - return 0; // No washes found in the date range - } - $row = $result->fetch_assoc(); - return (int)$row['wash_count']; + return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id); } /** @@ -2041,58 +2018,7 @@ class orders_o extends db */ public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array { - global /** @var db $db */ - $db; - - if (strtotime($date_start) === false || strtotime($date_end) === false) { - throw new Exception('Invalid date range provided'); - } - if (strtotime($date_start) > strtotime($date_end)) { - throw new Exception('The start date cannot be after the end date'); - } - - $normalized_department_ids = []; - foreach ($department_ids as $department_id) { - $normalized_id = (int)$department_id; - if ($normalized_id > 0) { - $normalized_department_ids[$normalized_id] = true; - } - } - if ($normalized_department_ids === []) { - return []; - } - - $department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids))); - $escaped_start = $db->escape_string($date_start); - $escaped_end = $db->escape_string($date_end); - - $sql = "SELECT o.department_id, - DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket, - COUNT(DISTINCT o.id) AS wash_count - FROM $this->table o - JOIN order_items oi ON oi.order_id = o.id - JOIN products p ON p.id = oi.product_id - WHERE o.department_id IN ($department_ids_sql) - AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' - AND o.deleted_at IS NULL - AND p.is_wash = 1 - GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')"; - - $result = $db->query($sql); - if (!is_object($result) || $result->num_rows === 0) { - return []; - } - - $rows = []; - while ($row = $result->fetch_assoc()) { - $rows[] = [ - 'department_id' => (int)($row['department_id'] ?? 0), - 'hour_bucket' => (string)($row['hour_bucket'] ?? ''), - 'wash_count' => (int)($row['wash_count'] ?? 0), - ]; - } - - return $rows; + return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids); } /** diff --git a/services/nginx/app/tests/Integration/DailyReports/DepartmentWashCountServiceIntegrationTest.php b/services/nginx/app/tests/Integration/DailyReports/DepartmentWashCountServiceIntegrationTest.php new file mode 100644 index 00000000..33b18c9c --- /dev/null +++ b/services/nginx/app/tests/Integration/DailyReports/DepartmentWashCountServiceIntegrationTest.php @@ -0,0 +1,267 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/department_wash_count_service.php'); + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + 'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306), + ]); + $db->connect(); + $GLOBALS['db'] = $db; + + department_wash_count_prepare_tables($db); + + return $db; +} + +function department_wash_count_prepare_tables(db $db): void +{ + $db->query( + 'CREATE TABLE IF NOT EXISTS department_lanes ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NULL, + name VARCHAR(255) NULL, + dynamic_image_id INT NULL, + selfserve_enabled TINYINT(1) NOT NULL DEFAULT 0 + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS department_selfserve_conditions ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NULL, + name VARCHAR(255) NULL, + product INT NULL, + action VARCHAR(64) NULL, + order_index INT NOT NULL DEFAULT 0, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS department_selfserve_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + condition_id INT NULL, + product INT NULL, + description TEXT NULL, + task VARCHAR(64) NULL, + buttons TEXT NULL, + order_index INT NOT NULL DEFAULT 0, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS products ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + price INT NOT NULL DEFAULT 0, + is_wash TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + cashier_id INT NOT NULL, + reference VARCHAR(255) NULL, + notes TEXT NULL, + department_id INT NOT NULL, + reg_1 VARCHAR(64) NULL, + reg_2 VARCHAR(64) NULL, + reg_3 VARCHAR(64) NULL, + created_at DATETIME NOT NULL, + include_in_invoice TINYINT(1) NULL, + wash_id VARCHAR(255) NULL, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS order_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_id INT NOT NULL, + product_id INT NOT NULL, + reference VARCHAR(255) NULL, + notes TEXT NULL, + cashier_id INT NOT NULL, + price DECIMAL(10,2) NOT NULL DEFAULT 0, + quantity INT NOT NULL DEFAULT 1, + deleted_at DATETIME NULL + )' + ); + + $db->query( + 'CREATE TABLE IF NOT EXISTS selfserve_wash_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + lane_id INT NOT NULL, + department_id INT NOT NULL, + reg VARCHAR(64) NOT NULL, + status VARCHAR(64) NOT NULL, + allowed TINYINT(1) NOT NULL DEFAULT 0, + wash_started_at DATETIME NULL, + machine_start_triggered_at DATETIME NULL, + order_id INT NULL, + completed_at DATETIME NULL, + created_at DATETIME NULL, + deleted_at DATETIME NULL + )' + ); +} + +function department_wash_count_insert_product(db $db, string $suffix, bool $isWash): int +{ + $productNameColumn = null; + if ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'name'")) > 0) { + $productNameColumn = 'name'; + } elseif ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'title'")) > 0) { + $productNameColumn = 'title'; + } else { + test()->markTestSkipped('Products table is missing both name and title columns required by this integration test.'); + } + + $escapedName = $db->escape_string('Wash count product ' . $suffix . ' ' . ($isWash ? 'wash' : 'minute')); + $descriptionSql = $db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'description'")) > 0 + ? ', description' + : ''; + $descriptionValueSql = $descriptionSql !== '' ? ", 'Integration wash count product'" : ''; + + $db->query( + "INSERT INTO products ($productNameColumn$descriptionSql, is_wash) + VALUES ('$escapedName'$descriptionValueSql, " . ($isWash ? '1' : '0') . ')' + ); + + return (int)$db->insert_id(); +} + +function department_wash_count_insert_order(db $db, int $departmentId, int $productId, string $reference, string $createdAt, int $price = 100): int +{ + $escapedReference = $db->escape_string($reference); + $escapedCreatedAt = $db->escape_string($createdAt); + + $db->query( + "INSERT INTO orders (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, created_at) + VALUES (1, 1, '$escapedReference', 'Integration test', $departmentId, 'COUNT$departmentId', '', '', '$escapedCreatedAt')" + ); + $orderId = (int)$db->insert_id(); + + $db->query( + "INSERT INTO order_items (order_id, product_id, reference, notes, cashier_id, price, quantity) + VALUES ($orderId, $productId, '$escapedReference', 'Integration test', 1, $price, 1)" + ); + + return $orderId; +} + +function department_wash_count_insert_session( + db $db, + int $departmentId, + string $reg, + string $status, + ?string $completedAt, + ?int $orderId = null +): int { + $escapedReg = $db->escape_string($reg); + $escapedStatus = $db->escape_string($status); + $completedAtSql = $completedAt === null ? 'NULL' : "'" . $db->escape_string($completedAt) . "'"; + $orderIdSql = $orderId === null ? 'NULL' : (string)$orderId; + + $db->query( + "INSERT INTO selfserve_wash_sessions (lane_id, department_id, reg, status, allowed, wash_started_at, machine_start_triggered_at, order_id, completed_at, created_at) + VALUES (1, $departmentId, '$escapedReg', '$escapedStatus', 1, NULL, NULL, $orderIdSql, $completedAtSql, $completedAtSql)" + ); + + return (int)$db->insert_id(); +} + +it('counts completed self-serve minute billing sessions site-wide and dedupes linked wash orders', function (): void { + $db = department_wash_count_integration_db(); + $service = new department_wash_count_service(); + $suffix = (string)random_int(10000, 99999); + $departmentId = 700000 + (int)$suffix; + $orderIds = []; + $productIds = []; + $sessionIds = []; + + try { + $washProductId = department_wash_count_insert_product($db, $suffix, true); + $minuteProductId = department_wash_count_insert_product($db, $suffix, false); + $productIds = [$washProductId, $minuteProductId]; + + $orderIds[] = department_wash_count_insert_order($db, $departmentId, $washProductId, 'plain-wash-' . $suffix, '2026-04-01 10:15:00', 100); + $linkedMinuteOrderId = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'linked-minute-' . $suffix, '2026-04-01 11:10:00', 25); + $orderIds[] = $linkedMinuteOrderId; + $linkedWashOrderId = department_wash_count_insert_order($db, $departmentId, $washProductId, 'linked-wash-' . $suffix, '2026-04-01 13:05:00', 100); + $orderIds[] = $linkedWashOrderId; + $orderIds[] = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'plain-minute-' . $suffix, '2026-04-01 14:00:00', 25); + + $sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKMIN' . $suffix, 'COMPLETED', '2026-04-01 12:00:00', $linkedMinuteOrderId); + $sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'STAND' . $suffix, 'COMPLETED', '2026-04-01 12:20:00'); + $sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKWASH' . $suffix, 'COMPLETED', '2026-04-01 13:30:00', $linkedWashOrderId); + $sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'FORCE' . $suffix, 'FORCE_STOPPED', '2026-04-01 15:00:00'); + $sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'OPEN' . $suffix, 'COMPLETED', null); + + expect($service->countInDateRange('2026-04-01 00:00:00', '2026-04-01 23:59:59', $departmentId))->toBe(4); + + $hourRows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]); + $countsByHour = []; + foreach ($hourRows as $row) { + $countsByHour[$row['hour_bucket']] = $row['wash_count']; + } + + expect($countsByHour)->toMatchArray([ + '2026-04-01 10:00:00' => 1, + '2026-04-01 11:00:00' => 1, + '2026-04-01 12:00:00' => 1, + '2026-04-01 13:00:00' => 1, + ]); + expect($countsByHour)->not->toHaveKey('2026-04-01 14:00:00') + ->and($countsByHour)->not->toHaveKey('2026-04-01 15:00:00'); + + $summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]); + expect($summary)->toMatchArray([ + 'quantity' => 4, + 'products' => 4, + 'earnings' => 250, + 'washes' => 4, + ]); + } finally { + if ($sessionIds !== []) { + $db->query('DELETE FROM selfserve_wash_sessions WHERE id IN (' . implode(',', array_map('intval', $sessionIds)) . ')'); + } + if ($orderIds !== []) { + $orderIdsSql = implode(',', array_map('intval', $orderIds)); + $db->query("DELETE FROM order_items WHERE order_id IN ($orderIdsSql)"); + $db->query("DELETE FROM orders WHERE id IN ($orderIdsSql)"); + } + if ($productIds !== []) { + $db->query('DELETE FROM products WHERE id IN (' . implode(',', array_map('intval', $productIds)) . ')'); + } + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index fd22006b..e7ad3d3a 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -361,7 +361,9 @@ CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` ( PRIMARY KEY (`id`), KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`), KEY `idx_selfserve_wash_sessions_status` (`status`), - KEY `idx_selfserve_wash_sessions_customer` (`customer_number`) + KEY `idx_selfserve_wash_sessions_customer` (`customer_number`), + KEY `idx_selfserve_wash_sessions_department_completed` (`department_id`, `completed_at`), + KEY `idx_selfserve_wash_sessions_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'selfserve_wash_session_answers' => <<<'SQL' diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php index 56df03b4..a8ecf4d2 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php @@ -392,5 +392,8 @@ it('wires the overview route to batched repository methods and overview path', f expect($routeContent)->toContain('/departments/daily-reports/outside-hours-trend'); expect($routeContent)->toContain('getTransactionSummaryForDepartments'); expect($routeContent)->toContain('normalizeDepartmentIdsParameter'); + expect($objectContent)->toContain('department_wash_count_service'); + expect($objectContent)->toContain('countInDateRange'); + expect($objectContent)->toContain('listTransactions'); expect($objectContent)->toContain('public function getBookingSummaryForDepartments'); }); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentWashCountServiceTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentWashCountServiceTest.php new file mode 100644 index 00000000..aecd09f5 --- /dev/null +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentWashCountServiceTest.php @@ -0,0 +1,138 @@ +num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + if (!array_key_exists($this->cursor, $this->rows)) { + return null; + } + + return $this->rows[$this->cursor++]; + } +} + +final class DepartmentWashCountFakeDb +{ + public array $queries = []; + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function getDatabase(): string + { + return 'test_db'; + } + + public function query(string $sql): DepartmentWashCountFakeResult + { + $this->queries[] = $sql; + + if (str_contains($sql, 'information_schema.COLUMNS') || str_contains($sql, 'information_schema.STATISTICS')) { + return new DepartmentWashCountFakeResult([['c' => 1, 'DATA_TYPE' => 'text']]); + } + + if (str_contains($sql, 'COUNT(DISTINCT o.id) AS quantity')) { + return new DepartmentWashCountFakeResult([[ + 'quantity' => 3, + 'products' => 5, + 'earnings' => 250, + ]]); + } + + return new DepartmentWashCountFakeResult([[ + 'department_id' => 7, + 'hour_bucket' => '2026-04-01 12:00:00', + 'wash_count' => 2, + ]]); + } +} + +function department_wash_count_find_query(array $queries, string $needle): string +{ + foreach ($queries as $query) { + if (str_contains($query, $needle)) { + return $query; + } + } + + return ''; +} + +it('builds wash counts from order washes and completed self-serve sessions with linked-order dedupe', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $fakeDb = new DepartmentWashCountFakeDb(); + $GLOBALS['db'] = $fakeDb; + + try { + $service = new department_wash_count_service(); + + $rows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]); + + expect($rows)->toBe([[ + 'department_id' => 7, + 'hour_bucket' => '2026-04-01 12:00:00', + 'wash_count' => 2, + ]]); + + $query = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s'); + expect($query)->toContain('FROM selfserve_wash_sessions s') + ->and($query)->toContain("UPPER(TRIM(s.status)) = 'COMPLETED'") + ->and($query)->toContain('s.completed_at IS NOT NULL') + ->and($query)->toContain('COALESCE(linked_o.created_at, s.completed_at)') + ->and($query)->toContain("CONCAT('order:', linked_o.id)") + ->and($query)->toContain('GROUP BY dedupe_key, department_id'); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('keeps transaction totals order-based while using expanded wash counts', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $fakeDb = new DepartmentWashCountFakeDb(); + $GLOBALS['db'] = $fakeDb; + + try { + $service = new department_wash_count_service(); + + $summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]); + + expect($summary)->toBe([ + 'quantity' => 3, + 'products' => 5, + 'earnings' => 250, + 'washes' => 2, + ]); + $summaryQuery = department_wash_count_find_query($fakeDb->queries, 'COUNT(DISTINCT o.id) AS quantity'); + $countQuery = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s'); + expect($summaryQuery)->toContain('COUNT(DISTINCT o.id) AS quantity') + ->and($summaryQuery)->not->toContain('selfserve_wash_sessions') + ->and($countQuery)->toContain('selfserve_wash_sessions'); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index b03cfce7..cd965f60 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -130,6 +130,21 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); }); +it('adds reporting indexes for completed self-serve wash session counts', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + $apiSchemaContent = file_get_contents(app_path('tests/Support/Api/ApiSchemaBootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse() + ->and($apiSchemaContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('idx_selfserve_wash_sessions_department_completed') + ->and($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)') + ->and($bootstrapContent)->toContain('idx_selfserve_wash_sessions_order') + ->and($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)') + ->and($bootstrapContent)->toContain('public static function ensureIndex'); + expect($apiSchemaContent)->toContain('idx_selfserve_wash_sessions_department_completed') + ->and($apiSchemaContent)->toContain('idx_selfserve_wash_sessions_order'); +}); + it('adds lane-level self-serve enablement for existing department lanes', function (): void { $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index 2f1ec50e..27f915e1 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -563,5 +563,5 @@ it('wires department weather route to use batched wash aggregation', function () expect($routeContent)->toContain('loadDepartmentWashCountsBySlot'); expect($routeContent)->toContain('countWashesByHourForDepartments'); expect($ordersContent)->toContain('public function countWashesByHourForDepartments'); - expect($ordersContent)->toContain('GROUP BY o.department_id'); + expect($ordersContent)->toContain('department_wash_count_service'); });