Implement department wash count service and refactor related reporting functions
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
|
||||
use Exception;
|
||||
|
||||
class department_wash_count_service
|
||||
|
||||
@@ -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<int,string> $acceptedDataTypes
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use classes\db;
|
||||
use classes\department_wash_count_service;
|
||||
|
||||
function department_wash_count_integration_db(): db
|
||||
{
|
||||
if (!integration_enabled()) {
|
||||
test()->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();
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/department_wash_count_service.php');
|
||||
|
||||
use classes\department_wash_count_service;
|
||||
|
||||
final class DepartmentWashCountFakeResult
|
||||
{
|
||||
public int $num_rows;
|
||||
private int $cursor = 0;
|
||||
|
||||
public function __construct(private readonly array $rows)
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user