From 9b41f80ea5e0519bf2c94a9e76e159a42a4e0247 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 21 Jan 2026 13:42:19 +0100 Subject: [PATCH] Add methods to retrieve and analyze product sales data, send department statistics to Slack, and implement scheduled messaging logic --- services/nginx/app/classes/db.php | 11 ++ services/nginx/app/objects/departments_o.php | 153 ++++++++++++++++++ services/nginx/app/objects/orders_o.php | 31 ++++ .../nginx/app/objects/product_options_o.php | 76 +++++++++ services/nginx/app/routes/exampleRoute.php | 32 +++- services/nginx/app/routes/workerRoute.php | 98 ++++++++++- 6 files changed, 399 insertions(+), 2 deletions(-) diff --git a/services/nginx/app/classes/db.php b/services/nginx/app/classes/db.php index bbe45c1f..f2187e27 100644 --- a/services/nginx/app/classes/db.php +++ b/services/nginx/app/classes/db.php @@ -22,6 +22,17 @@ class db $this->database = $config['database']; } + public static function getPDO(): \PDO + { + global $config; + $dsn = "mysql:host={$config['db']['host']};dbname={$config['db']['database']};charset=utf8mb4"; + return new \PDO($dsn, $config['db']['user'], $config['db']['password'], [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + public function connect(): void { global $response; diff --git a/services/nginx/app/objects/departments_o.php b/services/nginx/app/objects/departments_o.php index fe6cda66..1df2e3f9 100644 --- a/services/nginx/app/objects/departments_o.php +++ b/services/nginx/app/objects/departments_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use classes\slack; use classes\stripe; use Exception; use traits\db_object_t; @@ -422,4 +423,156 @@ class departments_o extends db self::requireSelected(); return $this->variables->getVariable('exclude_from_invoicing') === true; } + + + + /** + * Send department period statistics to Slack + * @param string $start_date (YYYY-MM-DD) + * @param string $end_date (YYYY-MM-DD) + * @return void + * @throws Exception If the department is not selected + */ + public function sendPeriodStatisticsToSlack(string $start_date, string $end_date): void + { + self::requireSelected(); + // Configuration + $department_id = $this->id; + $product_ids = [ + 25, + [23, 24], // Used to merge two products into one percentage (Spot Free) + 22, + 27, + 21, + 26 + ]; + $date_end = date('Y-m-d 23:59:59', strtotime($end_date)); // End date at 23:59:59 + $date_start = date('Y-m-d 00:00:00', strtotime($start_date)); // Start date at 00:00:00 + /** + * Weekly results for Roskilde + * + * Period: 29.01.2026 - 04.02.2026 + * Washes: 67 + * Fælg Flex: 55% + * Spot Free: 22% + * Special Sæbe: 11% + * 10 min ekstra: 15% + * Undervognsskyl: 44% + * Voks: 66% + */ + $max_addons = []; + $sold_addons = []; + $percentages = []; + $department = (new departments_o())->select($this->id); + $tmp = "*Weekly results for {$department->name->value()}*\n"; + $tmp .= "Period: " . date('d.m.Y', strtotime($date_start)) . " - " . date('d.m.Y', strtotime($date_end)) . "\n"; + // Washes + $wash_count = (new orders_o())->countWashesInDateRange($date_start, $date_end, $department_id); + $analytics = $this->analyzeAddonSalesData($product_ids, $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages); + $tmp .= "Washes: $wash_count\n" . implode('', $analytics); + // Send test message to Slack + $slack = new slack(); + $slack->send_department_booking_notification($this->id, $tmp); + } + + /** + * @param array $product_ids + * @param string $date_start + * @param string $date_end + * @param int|array $department_id + * @param array $max_addons + * @param array $sold_addons + * @param array $percentages + * @return array + * @throws Exception + */ + public function analyzeAddonSalesData(array $product_ids, string $date_start, string $date_end, int|array $department_id, array $max_addons, array $sold_addons, array $percentages): array + { + if (is_array($department_id)) { + // Combine percentages from multiple departments + $combined_percentages = []; + foreach ( $department_id as $dept_id ) { + list($product_id, $pid, $dept_percentages) = $this->getThePercentageOfAddonsSoldOutOfMax($product_ids, $date_start, $date_end, $dept_id, $max_addons, $sold_addons, $percentages); + foreach ( $dept_percentages as $key => $value ) { + if (!isset($combined_percentages[$key])) { + $combined_percentages[$key] = 0; + } + $combined_percentages[$key] += $value; + } + } + $percentages = $combined_percentages; + } else { + // Single department + list($product_id, $pid, $percentages) = $this->getThePercentageOfAddonsSoldOutOfMax($product_ids, $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages); + } + $tmp = []; + foreach ( $product_ids as $product_id ) { + if (is_array($product_id)) { + // Merged product names + $product_names = []; + foreach ( $product_id as $pid ) { + $product_names[] = (new products_o())->select($pid)->name->value(); + } + // Switch to joined names + $product_name = match (true) { + in_array(23, $product_id) && in_array(24, $product_id) => 'Spot Free', + default => implode(' + ', $product_names), + }; + } else { + // Check single product name + $product_name = match ($product_id) { + 25 => 'Fælg Flex', + 22 => 'Special Sæbe', + 27 => '10 min ekstra', + 21 => 'Undervognsskyl', + 26 => 'Voks', + default => (new products_o())->select($product_id)->name->value(), + }; + } + $tmp[] = "{$product_name}: {$percentages[is_array($product_id) ? implode('_', $product_id) : $product_id]}%\n"; + } + // Return the data + return $tmp; + } + + /** + * @param array $product_ids + * @param string $date_start + * @param string $date_end + * @param int $department_id + * @param array $max_addons + * @param array $sold_addons + * @param array $percentages + * @return array + */ + public function getThePercentageOfAddonsSoldOutOfMax(array $product_ids, string $date_start, string $date_end, int $department_id, array $max_addons, array $sold_addons, array $percentages): array + { + // Get the percentage of addons sold out of max + foreach ( $product_ids as $product_id ) { + // Handle merged products + if (is_array($product_id)) { + $addon_sold_count = 0; + $addon_max_count = 0; + foreach ( $product_id as $pid ) { + $pid = (int)$pid; + $addon_sold_count += (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); + $addon_max_count += (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id); + } + } else { + $pid = $product_id; + $addon_sold_count = (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); + $addon_max_count = (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id); + } + // Prevent division by zero + if ($addon_max_count === 0) { + $addon_percentage_sold = 0; + } else { + $addon_percentage_sold = ($addon_sold_count / $addon_max_count) * 100; + } + $max_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_max_count; + $sold_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_sold_count; + $percentages[is_array($product_id) ? implode('_', $product_id) : $product_id] = number_format($addon_percentage_sold, 2); + } + return array($product_id, $pid, $percentages); + } } \ No newline at end of file diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 8a455bf4..37e89865 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1518,4 +1518,35 @@ class orders_o extends db $booking->select((int)$this->booking_id->value()); return $booking->exists() ? $booking : null; } + + 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']; + } } \ No newline at end of file diff --git a/services/nginx/app/objects/product_options_o.php b/services/nginx/app/objects/product_options_o.php index 9c6bdbaa..18eb0053 100644 --- a/services/nginx/app/objects/product_options_o.php +++ b/services/nginx/app/objects/product_options_o.php @@ -17,6 +17,19 @@ class product_options_o extends db public object_property $min; public object_property $max; + public static function getPDO(): \PDO + { + return db::getPDO(); + } + + private static function fetchRow(string $query, array $params): array + { + $stmt = self::getPDO()->prepare($query); + $stmt->execute($params); + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + return $result ?: []; + } + public function structure(): void { $this->setTable('products_options'); @@ -198,4 +211,67 @@ class product_options_o extends db ); return !empty($allowed_options); } + + public function countSoldAddonsInDateRange(int $product_id, string $date_start, string $date_end, int $department_id): int + { + global /** @var db $db */ + $db; + /** + * Count the number of sold addons (order items that's related to any another product) + * The order they were sold in must: + * - Be within the date range + * - Belong to the specified department + * - Have deleted_at IS NULL + * The item must: + * - Be an addon (related_item_id IS NOT NULL) + * - Not be deleted (deleted_at IS NULL) + */ + // Sanitize inputs + $product_id = (int)$product_id; + $date_start = date('Y-m-d H:i:s', strtotime($date_start)); + $date_end = date('Y-m-d H:i:s', strtotime($date_end)); + $department_id = (int)$department_id; + $query = " + SELECT COUNT(oi.id) AS sold_addons_count + FROM order_items oi + INNER JOIN orders o ON oi.order_id = o.id + WHERE oi.related_item_id IS NOT NULL + AND oi.product_id = {$product_id} + AND oi.deleted_at IS NULL + AND o.department_id = {$department_id} + AND o.created_at BETWEEN '{$date_start}' AND '{$date_end}' + AND o.deleted_at IS NULL + "; + return (int)$db->query($query)->fetch_object()->sold_addons_count; + } + + public function getMaxAddonsInDateRange(int $product_id, string $date_start, string $date_end, int $department_id): int + { + /** + * Get the number of addons that could have been sold (maximum) + * Every order item that has the product_id as an option should be counted (number of orders with that order item) + * The order they were sold in must: + * - Be within the date range + * - Belong to the specified department + * - Have deleted_at IS NULL + */ + global /** @var db $db */ + $db; + // Sanitize inputs + $product_id = (int)$product_id; + $date_start = date('Y-m-d H:i:s', strtotime($date_start)); + $date_end = date('Y-m-d H:i:s', strtotime($date_end)); + $department_id = (int)$department_id; + $query = " + SELECT COUNT(oi.id) AS max_addons_count + FROM order_items oi + INNER JOIN orders o ON oi.order_id = o.id + WHERE oi.product_id = {$product_id} + AND oi.deleted_at IS NULL + AND o.department_id = {$department_id} + AND o.created_at BETWEEN '{$date_start}' AND '{$date_end}' + AND o.deleted_at IS NULL + "; + return (int)$db->query($query)->fetch_object()->max_addons_count; + } } \ No newline at end of file diff --git a/services/nginx/app/routes/exampleRoute.php b/services/nginx/app/routes/exampleRoute.php index a78788b1..07abf817 100644 --- a/services/nginx/app/routes/exampleRoute.php +++ b/services/nginx/app/routes/exampleRoute.php @@ -3,6 +3,8 @@ namespace routes; use classes\entra; +use classes\redis; +use objects\departments_o; use traits\route_t; class exampleRoute @@ -13,7 +15,35 @@ class exampleRoute { $this->get('/example', function () { global $response; - $response->success(['message' => 'Hello World!']); + // This is very quite hack-y but it works for now + // Check if it's monday + if ((int)date('N') === 1) { + // Check if the time is between 06:00 and 17:00 + if (!redis->exists('system:last_sent_monday_message') && (date('H') >= 6 && date('H') < 17)) { + // Set the key to expire in 24 hours + redis->set('system:last_sent_monday_message', date('Y-m-d H:i:s')); + redis->expire('system:last_sent_monday_message', 86400); + // Send the messages + $departments = [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ]; + foreach ($departments as $department_id) { + $department = (new departments_o())->select((int)$department_id); + $days = 7; + // The time should be from 00:00:00 of the start date to 23:59:59 of the end date + $date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (sunday) at 23:59:59 + $date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00 + $department->sendPeriodStatisticsToSlack($date_start, $date_end); + } + } + } + $response->success(['message' => 'Hello World!', 'time' => date('Y-m-d H:i:s'), 'date' => (int)date('N')]); }); $this->get('/debug', function () { diff --git a/services/nginx/app/routes/workerRoute.php b/services/nginx/app/routes/workerRoute.php index ab6b6fea..f45af64b 100644 --- a/services/nginx/app/routes/workerRoute.php +++ b/services/nginx/app/routes/workerRoute.php @@ -10,6 +10,10 @@ use classes\virkdata; use modules\shelly\helpers\shelly_device_switch; use modules\shelly\helpers\shelly_request_body_get_states; use modules\virkdata\helpers\virkdata_response; +use objects\departments_o; +use objects\orders_o; +use objects\product_options_o; +use objects\products_o; use objects\users_o; use traits\route_t; @@ -33,10 +37,102 @@ class workerRoute redis->set('worker_target_version', $version); $response->success(['message' => 'Version update functionality is not yet implemented.']); }); + $this->get('/worker/test', function () { + global /** @var router $router */ + $response, $router; + $department = (new departments_o())->select((int)6); + $days = 7; + // The time should be from 00:00:00 of the start date to 23:59:59 of the end date + $date_end = date('Y-m-d 23:59:59'); // Today at 23:59:59 + $date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00 + $department->sendPeriodStatisticsToSlack($date_start, $date_end); + $response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]); + exit; + // Configuration + $department_id = 6; + /** + * Weekly results for Roskilde + * + * Period: 29.01.2026 - 04.02.2026 + * Washes: 67 + * Fælg Flex: 55% + * Spot Free: 22% + * Special Sæbe: 11% + * 10 min ekstra: 15% + * Undervognsskyl: 44% + * Voks: 66% + */ + $max_addons = []; + $sold_addons = []; + $percentages = []; + $department = (new departments_o())->select($department_id); + echo "Testing addon sales calculation for department ID: $department_id from $date_start to $date_end\n"; + $tmp = "*Weekly results for {$department->name->value()}*\n"; + $tmp .= "Period: " . date('d.m.Y', strtotime($date_start)) . " - " . date('d.m.Y', strtotime($date_end)) . "\n"; + // Washes + $wash_count = (new orders_o())->countWashesInDateRange($date_start, $date_end, $department_id); + $tmp .= "Washes: $wash_count\n"; + // Get the percentage of addons sold out of max + foreach ($product_ids as $product_id) { + // Handle merged products + if (is_array($product_id)) { + $addon_sold_count = 0; + $addon_max_count = 0; + foreach ($product_id as $pid) { + $pid = (int)$pid; + $addon_sold_count += (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); + $addon_max_count += (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id); + } + } else { + $pid = $product_id; + $addon_sold_count = (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); + $addon_max_count = (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id); + } + // Prevent division by zero + if ($addon_max_count === 0) { + $addon_percentage_sold = 0; + } else { + $addon_percentage_sold = ($addon_sold_count / $addon_max_count) * 100; + } + $max_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_max_count; + $sold_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_sold_count; + $percentages[is_array($product_id) ? implode('_', $product_id) : $product_id] = number_format($addon_percentage_sold, 2); + } + foreach ($product_ids as $product_id) { + if (is_array($product_id)) { + // Merged product names + $product_names = []; + foreach ($product_id as $pid) { + $product_names[] = (new products_o())->select($pid)->name->value(); + } + // Switch to joined names + $product_name = match (true) { + in_array(23, $product_id) && in_array(24, $product_id) => 'Spot Free', + default => implode(' + ', $product_names), + }; + } else { + $product_name = (new products_o())->select($product_id)->name->value(); + } + $tmp .= "{$product_name}: {$percentages[is_array($product_id) ? implode('_', $product_id) : $product_id]}%\n"; + } + // Send test message to Slack + $slack = new slack(); + $department = (new departments_o())->select($department_id); + $slack->send_message($tmp); + $response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]); + }); $this->get('/worker/status', function () { global /** @var router $router */ $response, $router; - $response->success(['message' => 'Worker is running', 'status' => 'OK', 'time' => date('Y-m-d H:i:s'), 'timezone' => date_default_timezone_get(), 'host' => gethostname(), 'version' => '1.0.1', 'routes' => $router->countRoutes()]); + $response->success([ + 'message' => 'Worker is running', + 'status' => 'OK', + 'time' => date('Y-m-d H:i:s'), + 'timezone' => date_default_timezone_get(), + 'host' => gethostname(), + 'version' => '1.0.1', + 'routes' => $router->countRoutes() + ]); }); $this->get('/worker/debug', function () { global /** @var router $router */