From f503a5379ed6cff314ed85c68bfa4a2672a8eb45 Mon Sep 17 00:00:00 2001 From: Jepp9350 <2jepp9350@gmail.com> Date: Tue, 1 Jul 2025 13:01:01 +0200 Subject: [PATCH] Enhance `orders_o`, `order_items_o`, and `InvoicingPeriodRoute`: add transaction and calculation methods, implement debug execution timing, refine invoicing period handling, optimize order retrieval, improve caching, and expand database query logic. --- .../objects/collected_order_invoices_o.php | 17 +- services/nginx/app/objects/order_items_o.php | 15 +- services/nginx/app/objects/orders_o.php | 102 ++++++++- .../nginx/app/routes/InvoicingPeriodRoute.php | 211 ++++++++++++------ services/nginx/app/traits/db_object_t.php | 1 + 5 files changed, 265 insertions(+), 81 deletions(-) diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 70f88757..157b000d 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -274,9 +274,16 @@ class collected_order_invoices_o extends db { // Require the invoice collection to be selected self::requireSelected(); + // Check if the booked state is cached + $cached = self::getCached('isBooked', $this->id); + if ($cached !== null) { + return (bool)$cached; + } // Get the invoice booked id from the external id try { self::getInvoiceBookedId(); + self::cache('isBooked', true, $this->id); + self::setCachedExpiration('isBooked', self::$isBookedCacheExpiration, $this->id); return true; } catch (Exception $e) { return false; @@ -294,17 +301,19 @@ class collected_order_invoices_o extends db // Require the invoice collection to be selected self::requireSelected(); // Check if the invoice_booked_id is already set (We don't want to make a request to E-conomic if we already have the id - Since this is slow.) - if (!empty($this->booked_invoice_id->value())) { - return (int)$this->booked_invoice_id->value(); + $booked_invoice_id = $this->booked_invoice_id->value(); + if (!empty($booked_invoice_id)) { + return (int)$booked_invoice_id; } + $external_id = $this->external_id->value(); // If the external id is empty, the invoice booked does not exist - if ($this->external_id->value() === null) { + if (empty($external_id)) { throw new Exception('Invoice booked does not exist'); } // Create an economic object $economic = new economic(); // Get the invoice booked id from the external id - $invoice_booked_id = $economic->invoices->booked->get_from_external_id($this->external_id->value()); + $invoice_booked_id = $economic->invoices->booked->get_from_external_id($external_id); $this->booked_invoice_id->set($invoice_booked_id); return (int)$invoice_booked_id; } diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index ea497389..222601a9 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -236,17 +236,22 @@ class order_items_o extends db ]; } - public function getAllItemsAsArray(int $orderId): array + public function getAllItemsAsArray(int $orderId, ?array $onlySpecificColumns = null): array { global $db; - $sql = "SELECT * FROM $this->table WHERE order_id = $orderId AND deleted_at IS NULL"; + // Get all items for the order + if ($onlySpecificColumns) { + $columns = implode(', ', $onlySpecificColumns); + } else { + $columns = '*'; + } + $sql = "SELECT $columns FROM $this->table WHERE order_id = $orderId AND deleted_at IS NULL"; $result = $db->query($sql); // Circumvent the repeated instantiation of the object, by just selecting the fields $items = []; if ($result->num_rows > 0) { - while ($row = $result->fetch_assoc()) { - $items[] = $row; - } + // Fetch all rows + return $result->fetch_all(MYSQLI_ASSOC); } return $items; } diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 2c4fe745..fa0f2ee5 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -9,6 +9,7 @@ use classes\response; use Exception; use helpers\xlvask_usage_log; use helpers\xlvask_wash_item; +use routes\InvoicingPeriodRoute; use traits\db_object_t; class orders_o extends db @@ -34,6 +35,14 @@ class orders_o extends db public object_property $wash_id; // The XL Vask Wash ID, if any public object_property $lane; // The lane used for the order, if any + /** + * Temporary keys + * These are used for calculations and should not be stored in the database. + * @var float $temporary_net_amount + * @see InvoicingPeriodRoute::getInvoicingPeriod() + */ + public float $temporary_net_amount = 0.0; // Temporary net amount for the order, used for calculations + public function structure(): void { @@ -441,7 +450,9 @@ class orders_o extends db { self::requireSelected(); $order_items = new order_items_o(); - $items = $order_items->getAllItemsAsArray($this->id); + $items = $order_items->getAllItemsAsArray($this->id, [ + 'include_in_invoice', + ]); $count = 0; foreach ( $items as $item ) { if ($item['include_in_invoice']) { @@ -529,7 +540,11 @@ class orders_o extends db // Get the order items object $order_items = new order_items_o(); // Get the price, quantity of the order items - $items = $order_items->getAllItemsAsArray($this->id); + $items = $order_items->getAllItemsAsArray($this->id, [ + 'include_in_invoice', + 'price', + 'quantity', + ]); // Loop through the items and get the net amount foreach ( $items as $item ) { // Check if the item is included in the invoice @@ -544,6 +559,34 @@ class orders_o extends db return $net; } + /** + * Get the net amount of the orders + * @param int[] $order_ids An array of order IDs to get the net amount for + * @return array An array with the net amount for each order [ order_id => net_amount ] + * @throws Exception If the order is not selected + */ + public function getNetAmountForOrders(array $order_ids): array + { + $order_items = new order_items_o(); + $tmp = $order_items->getFieldsWhere( + [ + 'order_id' => $order_ids, + 'include_in_invoice' => true, + ], + [ + 'price', + 'quantity', + 'include_in_invoice', + 'order_id' + ] + ); + $net_amounts = []; + foreach ( $tmp as $item ) { + $net_amounts[$item['order_id']] = (float)(($net_amounts[$item['order_id']] ?? 0) + ((int)$item['price'] * (int)$item['quantity'])); + } + return $net_amounts; + } + public function includeIncludes(): orders_o { global /** @var response $response */ @@ -825,15 +868,17 @@ class orders_o extends db $dateTo = $db->escape_string($dateTo); $sql = "SELECT DISTINCT customer_id FROM $this->table WHERE created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); - $customer_numbers = []; - while ($row = $result->fetch_assoc()) { - $customer_numbers[] = (int)trim($row['customer_id']); - } - if (empty($customer_numbers)) { + if ($result->num_rows === 0) { return []; // No customers found in the date range } + // Fetch all + $result = $db->fetch_all($result); // Get the customers by their customer numbers - return (new users_o())->getUsersByCustomerNumbers($customer_numbers); + return (new users_o())->getUsersByCustomerNumbers( + array_map(function ($row) { + return (int)$row['customer_id']; + }, $result) + ); } /** @@ -871,6 +916,41 @@ class orders_o extends db return $transactions; } + /** + * Get transactions for customers in a date range + * @param int[] $customers An array of customer numbers to filter by + * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format + * @param string $dateTo The end date of the date range (inclusive) "Y-m-d H:i:s" format + * @return array[customer_number => orders_o[]] The transactions for each customer within the specified date range + */ + public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array + { + global $db; + // Validate the date range + if (strtotime($dateFrom) === false || strtotime($dateTo) === false) { + throw new Exception('Invalid date range provided'); + } + if (strtotime($dateFrom) > strtotime($dateTo)) { + throw new Exception('The start date cannot be after the end date'); + } + // Prepare the SQL query to find transactions for the customers in the date range + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $customerNumbers = implode(',', array_map('intval', $customers)); + $sql = "SELECT id, customer_id FROM $this->table WHERE customer_id IN ($customerNumbers) AND created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; + $result = $db->query($sql); + if ($result->num_rows === 0) { + return []; // No transactions found for the customers in the date range + } + $transactions = []; + while ($row = $result->fetch_assoc()) { + $order = new orders_o(); + $order->select((int)$row['id']); + $transactions[(int)$row['customer_id']][] = $order; + } + return $transactions; + } + /** * @throws Exception */ @@ -940,4 +1020,10 @@ class orders_o extends db } return $resultOrders; } + + public function setTemporaryNetAmount(float $amount): void + { + // Set a temporary net amount for the order + $this->temporary_net_amount = $amount; + } } \ No newline at end of file diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index a9f96bc8..9cd3b5a6 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -55,48 +55,148 @@ class InvoicingPeriodRoute */ private static function getInvoicingPeriod(string $dateFrom, string $dateTo): array { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) + + $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) { + return self::getCustomersWithTransactions($dateFrom, $dateTo); + }, 'customers_with_transactions'); + $types = []; + // Add the customers with transactions to the types array + $types['all'] = $customersWithTransactions; + $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions); + }, 'vehicle_subscriptions'); + $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions); + }, 'fixed_pricing'); + $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions); + }, 'tank_cleaning'); + $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions); + }, 'special_arrangements'); + $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions); + }, 'invoice_per_order'); + $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { + return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions); + }, 'possible_duplicates'); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, - 'types' => [ - 'vehicle_subscriptions' => self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions), - 'fixed_pricing' => self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions), - 'tank_cleaning' => self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions), - 'special_arrangements' => self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions), - 'invoice_per_order' => self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions), - 'possible_duplicates' => self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions), - 'all' => $customersWithTransactions, - ], + 'types' => $types, ]; } + /** + * Debugging function to measure the time taken by a function. + * + * @param callable $function The function to debug. + * @return mixed The result of the function. + */ + private static function debugGetTime(callable $function, string $debug_label = ''): mixed + { + global $response; + $start_time = microtime(true) * 1000; // Start time in milliseconds + $result = $function(); + $end_time = microtime(true) * 1000; // End time in milliseconds + $execution_time = $end_time - $start_time; + $response->add_include( + 'debug_invoicing_period_' . $debug_label, + [ + 'execution_time' => $execution_time, + ] + ); + return $result; + } + /** * @throws Exception */ private static function getCustomersWithTransactions(string $dateFrom, string $dateTo): array { // Define the customers with orders in the specified date range - $customers = (new orders_o)->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); - $customer_numbers_processed = []; + $customers = self::debugGetTime(function () use ($dateFrom, $dateTo) { + return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); + }, 'customers_with_orders_in_date_range'); + //$customers = (new orders_o)->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); + + /** + * // user_id => customer_number, + * @example + * [ + * '123' => '12345678', + * '456' => '87654321' + * ] + */ + $customer_numbers = []; $tmp = []; foreach ( $customers as $customer ) { - if (in_array((int)$customer->customer_number->value(), $customer_numbers_processed)) { - // Skip if the customer has already been processed + $customer_number = (int)$customer->customer_number->value(); + if (empty($customer_number)) { + // Skip if the customer number is empty continue; } - $customer_numbers_processed[] = (int)$customer->customer_number->value(); + // Check if the customer number is already in the array + // This ensures that we only process each customer number once + // We use (int)$customer_number to ensure that the customer number is an integer + if (isset($customer_numbers[$customer_number])) { + continue; + } + // Add the customer number to the array + $customer_numbers[$customer_number] = $customer->id; + } + // Get the transactions for the customers in the specified date range + /** + * @example + * [ + * '12345678' => [ + * orders_o, + * orders_o, + * ] + * ] + * @var $customer_number_transactions + */ + $customer_number_transactions = (new orders_o())->getTransactionsForCustomersInDateRange(array_keys($customer_numbers), $dateFrom, $dateTo); + // Calculate the total amount for each transaction, to minimize the number of queries + $transaction_ids = []; + foreach ( $customer_number_transactions as $customer_number => $transactions ) { + foreach ( $transactions as $transaction ) { + if ($transaction instanceof orders_o) { + $transaction_ids[] = $transaction->id; + } + } + } + // Get the total amount for each transaction + $transaction_totals = (new orders_o())->getNetAmountForOrders($transaction_ids); + // Add the total amount to each transaction + foreach ( $customer_number_transactions as $customer_number => $transactions ) { + foreach ( $transactions as $transaction ) { + if ($transaction instanceof orders_o) { + // Set the total amount for the transaction + $transaction->setTemporaryNetAmount($transaction_totals[$transaction->id] ?? 0); + } + } + } + // Process the customer numbers to ensure they are unique + foreach ( $customer_numbers as $customer_number => $user_id ) { + if (empty($customer_number)) { + // Skip if the customer number is empty + continue; + } + // Check if the customer number is already in the array + if (isset($tmp[$customer_number])) { + // If the customer number is already in the array, skip it + continue; + } + // If the customer number is not in the array, add it // Construct the customer object with transactions $tmp[] = self::constructCustomerObject( - (int)$customer->customer_number->value(), - (new \objects\users_o())->getCustomerName((int)$customer->customer_number->value()), - (new \objects\orders_o())->getTransactionsForCustomer( - (int)$customer->customer_number->value(), - $dateFrom, - $dateTo - ), + (int)$customer_number, + (new \objects\users_o())->getCustomerName((int)$customer_number), + $customer_number_transactions[(int)$customer_number] ?? [], false, - (int)$customer->id + (int)$user_id, ); } return $tmp; @@ -137,7 +237,7 @@ class InvoicingPeriodRoute return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), - 'amount' => $transaction->getNetAmount(), + 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier 'booked' => $transaction->isBooked(), ]; } @@ -205,44 +305,33 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getFixedPricing(string $dateFrom, string $dateTo): array + private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array { - // Get all customers with fixed pricing + // If customersWithTransactions is not provided, get all customers with transactions in the specified date range + if ($customersWithTransactions === null) { + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + } + // Filter out customers that do not have any transactions in the specified date range $customer_numbers = (new \objects\users_o())->getCustomersWithFixedPricing(); + //self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); + + // Get all customers with fixed pricing $fixed_pricing = []; /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $tmp_fixed_pricing = [ - 'customer_number' => $customer_number, - 'customer_name' => (new \objects\users_o())->getCustomerName($customer_number) ?? 'Unknown Customer', - 'transactions' => [], - 'requires_action' => true, - ]; - // Check if the customer has a fixed pricing order within the date range - $orders = (new \objects\orders_o())->getFixedPricingTransactions($customer_number, false, $dateFrom, $dateTo); - /** @var orders_o $order */ - foreach ( $orders as $order ) { - // Add the order to the fixed pricing transactions - $tmp_fixed_pricing['transactions'][] = [ - 'id' => $order->id, - ]; + $fixed_pricing[] = self::getCustomerFromList( + (int)$customer_number, + $customersWithTransactions + ); + // Check if the last entry is null, if so, create a new customer object + if (end($fixed_pricing) === null) { + $fixed_pricing[count($fixed_pricing) - 1] = self::constructCustomerObject( + (int)$customer_number, + (new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer', + [], + true + ); } - // If there are transactions, set requires_action to false - if (count($tmp_fixed_pricing['transactions']) > 0) { - // Check if there's any transaction that has not been booked yet - $requires_action = false; - foreach ( $tmp_fixed_pricing['transactions'] as $transaction ) { - $order = (new orders_o())->select((int)$transaction['id']); - if (!$order->isBooked()) { - $requires_action = true; - break; - } - } - // Set requires_action based on the transactions - $tmp_fixed_pricing['requires_action'] = $requires_action; - } - // Add the fixed pricing to the list if it has transactions - $fixed_pricing[] = $tmp_fixed_pricing; } return $fixed_pricing; } @@ -308,15 +397,9 @@ class InvoicingPeriodRoute $special_arrangements = []; /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $special_arrangements[] = self::constructCustomerObject( + $special_arrangements[] = self::getCustomerFromList( (int)$customer_number, - (new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer', - (new orders_o())->getTransactionsForCustomer( - (int)$customer_number, - $dateFrom, - $dateTo, - ), - false, + $customersWithTransactions ); } return $special_arrangements; diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index f8185b4d..87f207cb 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -45,6 +45,7 @@ use objects\users_o; trait db_object_t { public static int $asArrayCacheExpiration = 600; // The id of the object in the database + public static int $isBookedCacheExpiration = 60; // The booked / not booked cache expiration time, in seconds. Default is 1 minute (60 seconds). public int $id; // The table of the objects in the database (e.g. users) private string $table; // The fields to search in the database (e.g. ['name', 'email']). If empty, all fields will be searched private array $searchableFields = []; // The where clauses to add to the pagination query