Merge pull request #69 from copenhagentruckwash/limble-module

limble-module
This commit is contained in:
Jeppe B
2025-07-01 13:56:03 +02:00
committed by GitHub
5 changed files with 323 additions and 132 deletions
@@ -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;
}
+10 -5
View File
@@ -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;
}
+130 -43
View File
@@ -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)
);
}
/**
@@ -872,6 +917,45 @@ class orders_o extends db
}
/**
* 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;
}
/**
* Get orders with possible duplicates in a date range
* @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 An array of possible duplicate orders, keyed by registration number.
* @throws Exception
*/
public function getOrdersWithPossibleDuplicates(string $dateFrom, string $dateTo): array
@@ -900,44 +984,47 @@ class orders_o extends db
'created_at' => (string)$row['created_at'],
];
// Add the order to the list
$orders[$tmp['reg_1']][] = $tmp;
$orders[$tmp['reg_1']][] = [
'id' => $tmp['id'],
'reg_1' => $tmp['reg_1'],
'created_at' => $tmp['created_at'],
'object' => (new orders_o())->select((int)$tmp['id'])
];
}
// Filter out orders with more than one entry (possible duplicates)
// More than one order with the same reg_1, consider it a possible duplicate
$possibleDuplicates = array_filter($orders, function ($orderList) {
return count($orderList) > 1; // Keep only those with more than one order
});
if (empty($possibleDuplicates)) {
return []; // No possible duplicates found
}
// Loop through the possible duplicates and check if they are within 24 hours of each other
foreach ( $possibleDuplicates as $reg_1 => $orderList ) {
// Sort the orders by created_at date
usort($orderList, function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
// Check if any two orders are within 24 hours of each other
for ( $i = 0; $i < count($orderList) - 1; $i++ ) {
$firstOrder = $orderList[$i];
$secondOrder = $orderList[$i + 1];
if (strtotime($secondOrder['created_at']) - strtotime($firstOrder['created_at']) <= 86400) { // 86400 seconds in a day
// Mark them as possible duplicates
$possibleDuplicates[$reg_1][$i]['is_duplicate'] = true;
$possibleDuplicates[$reg_1][$i + 1]['is_duplicate'] = true;
// Filter out orders with more than one entry for the same registration number (in a 24 hour period)
$possibleDuplicates = [];
// Loop through the registration numbers
foreach ( $orders as $reg_1 => $orderList ) {
// If there are more than one order for the same registration number, add it to the possible duplicates
if (count($orderList) > 1) {
// Loop through the orders and check if they are within 24 hours of each other
$filteredOrders = [];
foreach ( $orderList as $order ) {
// Check if the order is within 24 hours of the first order
if (empty($filteredOrders)) {
$filteredOrders[] = $order; // Add the first order
} else {
// Check if the order is within 24 hours of the first order
$firstOrderTime = strtotime($filteredOrders[0]['created_at']);
$currentOrderTime = strtotime($order['created_at']);
if ($currentOrderTime - $firstOrderTime <= 86400) { // 86400 seconds = 24 hours
$filteredOrders[] = $order; // Add the order to the filtered list
}
}
}
// If there are more than one order in the filtered list, add it to the possible duplicates
if (count($filteredOrders) > 1) {
$possibleDuplicates[$reg_1] = $filteredOrders;
}
}
}
// Return the possible duplicates orders_o objects
$resultOrders = [];
foreach ( $possibleDuplicates as $reg_1 => $orderList ) {
foreach ( $orderList as $order ) {
// Create a new orders_o object and select the order by id
$orderObject = new orders_o();
$orderObject->select((int)$order['id']);
// Add the order object to the result
$resultOrders[] = $orderObject;
}
}
return $resultOrders;
// Return the possible duplicates
return $possibleDuplicates;
}
public function setTemporaryNetAmount(float $amount): void
{
// Set a temporary net amount for the order
$this->temporary_net_amount = $amount;
}
}
@@ -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;
@@ -356,26 +439,32 @@ class InvoicingPeriodRoute
// Get orders with the same reg_1, that has been created within 24 hours of each other
$orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo);
// Get the customer numbers from the orders
$customer_numbers = array_map(function ($order) {
return (int)$order->customer_id->value();
}, $orders);
$tmp_customer_arr = [];
// Remove duplicates from the customer numbers
$customer_numbers = array_unique($customer_numbers);
$possible_duplicates = [];
/** @var int $customer_number */
foreach ( $customer_numbers as $customer_number ) {
$possible_duplicate = self::getCustomerFromList((int)$customer_number, $customersWithTransactions);
// Set the requires_action to true, as these are potential duplicates
$possible_duplicate['requires_action'] = true;
$possible_duplicate['transactions'] = [];
// Add the transactions to the possible duplicate
foreach ( $orders as $order ) {
if ((int)$order->customer_id->value() === $customer_number) {
$possible_duplicate['transactions'][] = self::constructTransactionObject($order);
}
foreach ( $orders as $order ) {
// Get the customer number from the order
$customer_number = (int)$order[0]['object']->customer_id->value();
// Check if the customer number is already in the array
if (isset($tmp_customer_arr[$customer_number])) {
continue;
}
// Add the possible duplicate to the list
$possible_duplicates[] = $possible_duplicate;
// Add the customer number to the array
$tmp_customer_arr[$customer_number] = true;
// Get the customer from the list of customers with transactions
$customer = self::getCustomerFromList($customer_number, $customersWithTransactions);
// Add the customer to the possible duplicates array
$possible_duplicates[] = self::constructCustomerObject(
$customer_number,
(new \objects\users_o())->getCustomerName($customer_number) ?? 'Unknown Customer',
array_map(function ($transaction) {
// Construct the transaction object from the order
return $transaction['object'];
}, $order),
true, // Requires action because there are possible duplicates
$customer['id'] ?? null // Use the id from the customer object if available
);
}
return $possible_duplicates;
}
@@ -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