setTable('departments'); } public function objectChanged(): void { // Clear the cache redis->clear_departments(); } /** * Get the lanes associated with the department * @retuns department_lanes_o[] The lanes associated with the department * @throws Exception If the department is not selected */ public function getLanes(): array { self::requireSelected(); return (new department_lanes_o())->getDepartmentLanes($this->id); } /** * Add a department * @param string $name * @param string $description * @throws Exception If the object was not created successfully */ public function create(string $name, string $description): void { global $db; // Avoid SQL injection $name = $db->escape_string($name); $description = $db->escape_string($description); // Create a new record in the database $sql = "INSERT INTO $this->table (name, description) VALUES ('$name', '$description')"; $db->query($sql); // Get the id of the new record $this->id = $db->insert_id(); // Set the values of the object properties $this->getObjectProperties(); // Clear the cache redis->clear_departments(); } /** * @throws Exception * @return bool true if self-serve is enabled, false if it is disabled */ public function getSelfServeEnabled(): bool { self::requireSelected(); // Get the department variable $department_variables = (new department_variables_o())->selectDepartment($this->id); $enabled = $department_variables->getVariable('selfserve_enabled'); return $enabled === true; } /** * Get the object properties of the department * @throws Exception If the object is not selected */ public function getObjectProperties(): void { self::requireSelected(); $this->name = new object_property($this->table, $this->id, 'name', 'string', true); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); $this->economic_department_id = new object_property($this->table, $this->id, 'economic_department_id', 'int', false); $this->slack_webhook = new object_property($this->table, $this->id, 'slack_webhook', 'string', false); $this->variables = (new department_variables_o())->selectDepartment($this->id); $this->dimension = new object_property($this->table, $this->id, 'dimension', 'int', false); $this->branding = new object_property($this->table, $this->id, 'branding', 'int', false); $this->visible = new object_property($this->table, $this->id, 'visible', 'int', false); $this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false); $this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false); $this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false); $this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false); } public function edit(int $id, string $name, string $description, int $economic_department_id): void { global $db; $this->id = $id; // Avoid SQL injection $name = $db->escape_string($name); $description = $db->escape_string($description); // If the economic_department_id is 0, set it to null if ($economic_department_id === 0) { $economic_department_id = null; } // Update the record in the database $sql = "UPDATE $this->table SET name = '$name', description = '$description', economic_department_id = " . ($economic_department_id ?? 'NULL') . " WHERE id = $this->id"; $db->query($sql); // Set the values of the object properties $this->getObjectProperties(); // Clear the cache redis->clear_department($id); } public function list($superUser = false): array { global $db; // Check if the departments are cached $departments = redis->get_departments(); // If the departments are not cached, get them from the database if ($departments === null) { $sql = "SELECT * FROM $this->table"; $result = $db->query($sql); $departments = $db->fetch_all($result); // Cache the departments (If it is not empty or null) if (!empty($departments)) { redis->cache_departments($departments); } } // Only show the name, description and id if the user isn't a super user if (!$superUser) { // Map the departments to only show the name, description and id $departments = array_map(function ($department) { return [ 'name' => $department['name'], 'description' => $department['description'], 'id' => $department['id'], 'visible' => $department['visible'], 'archived' => $department['archived'] ?? 0, ]; }, $departments); } return $departments; } public function getDepartmentById(int $id, bool $force_all = false): array { global $db; // Check if the department is cached $department = redis->get_department($id); // If the department is not cached, get it from the database if ($department === null || $force_all) { $sql = "SELECT * FROM $this->table WHERE id = $id"; $result = $db->query($sql); $department = $db->fetch_assoc($result); // Cache the department (If it is not empty or null) if (!empty($department)) { redis->cache_department($id, $department); } } return $department; } public function isCustomPricingOnly(int $department_id): bool { $department = $this->getDepartmentById($department_id); return (bool)(int)($department['custom_pricing_only'] ?? 0); } /** * Get the price of a product in a department * @param int $department_id * @param int $product_id * @param int $price * @return void */ public function setDepartmentProductPrice(int $department_id, int $product_id, int $price): void { global $db; // Check if the record already exists $this->removeDepartmentProductPriceIfExist($department_id, $product_id); // If the price is 0, return if ($price === 0) { return; } // Create a new record in the database $sql = "INSERT INTO product_department_prices (department_id, product_id, price) VALUES ($department_id, $product_id, $price)"; $db->query($sql); } /** * Remove the price of a product in a department * @param int $department_id * @param int $product_id * @return void */ private function removeDepartmentProductPriceIfExist(int $department_id, int $product_id): void { global $db; // Get the price from the database $sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id AND product_id = $product_id"; $result = $db->query($sql); if ($result->num_rows > 0) { // Remove the record from the database $sql = "DELETE FROM product_department_prices WHERE department_id = $department_id AND product_id = $product_id"; $db->query($sql); } } public function getDepartmentProductPrices(int $department_id): array { global $db; $sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id"; $result = $db->query($sql); return $db->fetch_all($result); } public function getDepartmentName(int $department): string { global $db; // Check if the department name is cached $name = redis->get_department_name($department); // If the department name is not cached, get it from the database if ($name === null) { try { $this->id = $department; $this->getObjectProperties(); $name = $this->name->value(); // Cache the department name (If it is not empty or null) if (!empty($name)) { redis->cache_department_name($department, $name); } } catch (Exception $e) { $name = 'Unable to get department name: ' . $department; } return $name; } return redis->get_department_name($department) ?? 'Unable to get department name: ' . $department; } /** * Get the Stripe terminal location department variable * @note This is a link to the department_variables_o::getVariable(stripe_terminal_location) method * @return string The Stripe terminal location id * @throws Exception If the department variable is not set * @throws Exception If the department is not selected */ public function getStripeTerminalLocation(): string { self::requireSelected(); if (!$this->variables->isSet('stripe_terminal_location')) { throw new Exception('The Stripe terminal location is not set for the department.'); } return $this->variables->getVariable('stripe_terminal_location'); } /** * Check if the Stripe terminal location variable is set * @note This is a link to the department_variables_o::isSet(stripe_terminal_location) method * @return bool true If the Stripe terminal location is set * @throws Exception If the department is not selected */ public function isStripeTerminalLocationSet(): bool { self::requireSelected(); return $this->variables->isSet('stripe_terminal_location'); } /** * Set the Stripe terminal location department variable * @note This is a link to the department_variables_o::set(stripe_terminal_location) method * @param string $location The Stripe terminal location id * @throws Exception If the department is not selected * @throws Exception If the department variable is not updated successfully */ public function setStripeTerminalLocation(string $location): void { self::requireSelected(); $this->variables->set('stripe_terminal_location', $location); } /** * Get the Stripe terminal readers * @note This is a link to the stripe_endpoint_readers::listLocation method * @throws Exception If the department is not selected * @throws Exception If the Stripe terminal location is not set for the department */ public function getStripeTerminalReaders(): \Stripe\Collection { self::requireSelected(); if (!$this->variables->isSet('stripe_terminal_location')) { throw new Exception('The Stripe terminal location is not set for the department.'); } return (new stripe())->readers->listLocation($this->variables->getVariable('stripe_terminal_location')); } /** * Convert the object to an array * @param array|null $options The options for the conversion (e.g. ['slack_webhook' => true]) * @return array The object as an array * @throws Exception If the object is not selected */ public function asArray(array $options = null): array { self::requireSelected(); $tmp = [ 'id' => (int)$this->id, 'name' => (string)$this->name->value(), 'description' => (string)$this->description->value(), 'economic_department_id' => (int)$this->economic_department_id->value(), 'dimension' => (int)$this->dimension->value(), 'branding' => (int)$this->branding->value(), 'longitude' => (float)$this->longitude->value(), 'latitude' => (float)$this->latitude->value(), 'order_priority' => (int)$this->order_priority->value(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; // Check if the webhook should be included if ($options && $options['slack_webhook']) { $tmp['slack_webhook'] = (string)$this->slack_webhook->value(); } return $tmp; } /** * Check if the Stripe is configured for the department * @note This is a link to the department_variables_o::isSet(stripe_terminal_location) method * @return bool true If the Stripe is configured * @throws Exception If the department is not selected */ public function isStripeConfigured(): bool { self::requireSelected(); if ($this->variables->isSet('stripe_terminal_location')) { return true; } return false; } /** * @throws Exception If the department is not selected * @throws Exception If the branding is not set * @throws Exception If the branding is not found */ public function getBranding(): branding_o { self::requireSelected(); return (new branding_o())->select($this->branding->value()); } /** * Get the phone numbers for the SMS notification * @note This is a link to the department_notification_sms_o::getDepartmentActivePhoneNumbers method * @throws Exception If the department is not selected */ public function notificationSmsPhoneNumbers(): array { self::requireSelected(); return (new department_notification_sms_o())->getDepartmentActivePhoneNumbers($this->id); } /** * @throws Exception */ public function isModuleTimeBookingsEnabled(): bool { self::requireSelected(); return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled'); } /** * Select a department by its name (case-sensitive, exact match) * @throws Exception If the department selection fails or the department is not found * @example "Hvidovre" */ public function selectByName(string $departmentName): self { $result = self::getFieldsWhere([ 'name' => $departmentName, ], [ 'id', ]); if (empty($result)) { throw new Exception('Department not found: ' . $departmentName); } $this->selectId($result[0]['id']); return $this; } public function selectId(int $department_id): self { $this->id = $department_id; $this->getObjectProperties(); return $this; } /** * @return products_o[] An array of all products in the department's categories * @throws Exception */ public function getAllProductInDepartmentCategories(): array { self::requireSelected(); // Get all the categories associated $department_categories = (new department_categories_o())->getDepartmentCategoryObjects($this->id); // Use array_reduce to combine both loops into a single operation $products = array_reduce($department_categories, function ($products, $department_category) { return array_merge($products, $department_category->getCategory()->getProducts()); }, []); // Remove duplicate products return array_unique($products, SORT_REGULAR); } /** * If the department is excluded from invoicing * @return bool * @throws Exception If the department is not selected */ public function isExcludedFromInvoicing(): bool { 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) * @param int[] $product_ids The product ids to include in the statistics (e.g. [25, [23, 24], 22, 27, 21, 26]) * @param bool $return_as_array Whether to return the results as an array or not * @return string[] * @throws Exception If the department is not selected */ public function sendPeriodStatisticsToSlack(string $start_date, string $end_date, array $product_ids = [ 25, [23, 24], // Used to merge two products into one percentage (Spot Free) 22, 27, 21, 26 ], bool $return_as_array = false): array { self::requireSelected(); // Ensure only one message is sent a week using redis cache $cacheKey = "department_{$this->id}_weekly_statistics_sent_v2"; // Get time until next monday at 00:00:00 $nextMonday = strtotime('next monday'); $cacheDuration = $nextMonday - time(); $lastSent = redis->get($cacheKey); // If null or more than a week has passed since the last message, send a new message $sendNow = match (true) { $lastSent === null => true, default => (time() - $lastSent) >= $cacheDuration }; if (!$sendNow) { return $return_as_array ? ["A weekly statistics message has already been sent for this department."] : ["A weekly statistics message has already been sent for this department."]; } $this->cache($cacheKey, $cacheDuration); $this->setCachedExpiration($cacheKey, $cacheDuration); // Configuration $department_id = $this->id; $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((int)$this->id); $weekNumber = (int)date('W', strtotime($date_end)); $tmp = "*Weekly results for {$department->name->value()} (Week {$weekNumber})*\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); // If it should be returned as an array, return it as an array instead of sending it to Slack if ($return_as_array) { return explode("\n", trim($tmp)); } // Check if there's a custom webhook for the department $slack = new slack(); if (empty($this->slack_webhook->value())) { // If no webhook is set for the department, use the default Slack notification $slack->send_message($tmp); } else { // Set the custom webhook for the department $slack->send_webhook_message($tmp, $this->slack_webhook->value()); } return explode("\n", trim($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 ) { $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 $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 { $product_options_o = new product_options_o(); // 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 += $product_options_o->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); $addon_max_count += $product_options_o->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id); } } else { $pid = $product_id; $addon_sold_count = $product_options_o->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id); $addon_max_count = $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 $percentages; } /** * Get the total amount of addons sold in a department in a date range * @param array $product_ids * @param string $date_start * @param string $date_end * @param int $department_id * @return float */ public function getTotalPercentageAddonsSoldInDepartment(array $product_ids, string $date_start, string $date_end, int $department_id): float { $max_addons = []; $sold_addons = []; $percentages = []; $this->getThePercentageOfAddonsSoldOutOfMax($product_ids, $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages); $total_sold = array_sum($sold_addons); $total_max = array_sum($max_addons); if ($total_max === 0) { return 0; } return ($total_sold / $total_max) * 100; } /** * Get the total maximum addons available in a department in a date range * @param array $product_ids * @param string $date_start * @param string $date_end * @param int $department_id * @return float */ public function getTotalPercentageMaxAddonsInDepartment(array $product_ids, string $date_start, string $date_end, int $department_id): float { return $this->getTotalPercentageAddonsSoldInDepartment($product_ids, $date_start, $date_end, $department_id); } /** * Get the total amount of addons sold in a department in a date range * @param array $product_ids * @param string $date_start * @param string $date_end * @param int $department_id * @return int * @throws Exception */ public function getTotalAddonsSoldInDepartment(array $product_ids, string $date_start, string $date_end, int $department_id): int { $total_addons_sold = 0; foreach ($product_ids as $product_id) { $addon_sold_count = (new product_options_o())->countSoldAddonsInDateRange($product_id, $date_start, $date_end, $department_id); $total_addons_sold += $addon_sold_count; } return $total_addons_sold; } /** * Get the total maximum addons available in a department in a date range * @param array $product_ids * @param string $date_start * @param string $date_end * @param int $department_id * @return int * @throws Exception */ public function getTotalMaxAddonsInDepartment(array $product_ids, string $date_start, string $date_end, int $department_id): int { return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($product_ids, $date_start, $date_end, $department_id) { $total_max_addons = 0; foreach ($product_ids as $product_id) { $addon_max_count = (new product_options_o())->getMaxAddonsInDateRange($product_id, $date_start, $date_end, $department_id); $total_max_addons += $addon_max_count; } return $total_max_addons; }); } /** * Send a (truck wash internal) department statistic Slack notification * @param string $date_start (YYYY-MM-DD) * @param string $date_end (YYYY-MM-DD) * @param int[] $department_ids * @param int[] $product_ids * @param bool $return_as_array Whether to return the results as an array or not * @return null|array * @throws Exception */ public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids = [], array $product_ids = [ 25, [23, 24], // Used to merge two products into one percentage (Spot Free) 22, 27, 21, 26 ], bool $return_as_array = false): null|array { $slack = new slack(); if (empty($department_ids)) { $department_ids = $slack->get_internal_department_ids(); } if (empty($department_ids)) { throw new Exception('No department ids provided for the Slack internal statistic notification.'); } if (empty($product_ids)) { throw new Exception('No product ids provided for the Slack internal statistic notification.'); } // Sort the departments by order_priority (lowest => first) $departments_data = $this->getFieldsWhereIn(['id' => $department_ids], ['id', 'order_priority']); usort($departments_data, function ($a, $b) { return (int)$a['order_priority'] <=> (int)$b['order_priority']; }); $department_ids = array_column($departments_data, 'id'); // Configuration $date_end = date('Y-m-d 23:59:59', strtotime($date_end)); // End date at 23:59:59 $date_start = date('Y-m-d 00:00:00', strtotime($date_start)); // Start date at 00:00:00 /** * Weekly results for Truck Wash * * Period: 29.01.2026 - 04.02.2026 * Washes: * * Taastrup: 300 * Hvidovre: 52 * Glostrup: 121 * Køge: 54 * Roskilde: 65 * Aarhus C: 32 * Taulov: 122 * - Total: 650 * Undervognsskyl * * Taastrup: 55% * Hvidovre: 22% * Glostrup: 22% * Køge: 24% * Roskilde: 54% * Aarhus C: 66% * Taulov: 55% * - Total: 55% * ... */ $weekNumber = (int)date('W', strtotime($date_end)); $tmp = "*Weekly results for Truck Wash (Week {$weekNumber})*\n"; $tmp .= "_Period: " . date('d.m.Y', strtotime($date_start)) . " - " . date('d.m.Y', strtotime($date_end)) . "_\n"; // Washes $total_wash_count = 0; $tmp .= "*Washes:*\n"; $orders_o = new orders_o(); foreach ( $department_ids as $department_id ) { $wash_count = $orders_o->countWashesInDateRange($date_start, $date_end, $department_id); $total_wash_count += $wash_count; $department = (new departments_o())->select($department_id); $tmp .= "> {$department->name->value()}: $wash_count\n"; } $tmp .= "> - Total: {$total_wash_count}\n"; // Pre-instantiate products_o to reuse $products_o = new products_o(); $max_addons = []; $sold_addons = []; $percentages = []; // Loop through each product and get the percentages for each department foreach ( $product_ids as $product_id ) { // Reset totals for each product $total_percentage = 0; $total_count = 0; $total_sold = 0; // Header for the product if (is_array($product_id)) { $product_names = []; foreach ( $product_id as $pid ) { $products_o->select($pid); $product_names[] = $products_o->name->value(); } $product_name = match (true) { in_array(23, $product_id) && in_array(24, $product_id) => 'Spot Free', default => implode(' + ', $product_names), }; } else { $product_name = match ($product_id) { 25 => 'Fælg Flex', 22 => 'Special Sæbe', 27 => '10 min ekstra', 21 => 'Undervognsskyl', 26 => 'Voks', default => $products_o->select($product_id)->name->value(), }; } $tmp .= "\n*{$product_name}:*\n"; $product_key = is_array($product_id) ? implode('_', $product_id) : $product_id; // Loop through each department to get the percentage foreach ( $department_ids as $department_id ) { $department = (new departments_o())->select($department_id); $this->getThePercentageOfAddonsSoldOutOfMax([$product_id], $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages); $percentage = $percentages[$product_key]; $total_count += $max_addons[$product_key]; $total_sold += $sold_addons[$product_key]; $tmp .= "> {$department->name->value()}: {$percentage}%\n"; } // Get the total percentage for the product if ($total_count !== 0) { // Prevent division by zero $total_percentage = ($total_sold / $total_count) * 100; } $tmp .= "> - Total: " . number_format($total_percentage, 2) . "%\n"; } $array_of_results = [ "daily_management" => [], "departments" => [] ]; // Add $tmp to the daily management message $array_of_results['daily_management'][] = $tmp; // Send the message to the internal Slack webhook if (!$return_as_array) { $webhook = $slack->get_internal_department_goal_progress_webhook_url(); if ($webhook !== '') { $slack->send_webhook_message($tmp, $webhook); } } // Send a department-specific message for each internal department with the percentage of addons sold foreach ( $department_ids as $department_id ) { $department = (new departments_o())->select($department_id); $tmp_dept = $department->sendPeriodStatisticsToSlack($date_start, $date_end, $product_ids, $return_as_array); $array_of_results['departments'][$department_id] = $tmp_dept; } return $array_of_results; } }