diff --git a/services/nginx/app/classes/customer_name_cache_payload_builder.php b/services/nginx/app/classes/customer_name_cache_payload_builder.php index 5bd0dc38..510ec2cd 100644 --- a/services/nginx/app/classes/customer_name_cache_payload_builder.php +++ b/services/nginx/app/classes/customer_name_cache_payload_builder.php @@ -9,19 +9,86 @@ class customer_name_cache_payload_builder */ public static function build(mixed $cached_name, ?string $fallback_name): ?array { - if ( - is_object($cached_name) - && isset($cached_name->name) - && is_string($cached_name->name) - && trim($cached_name->name) !== '' - ) { - return ['name' => $cached_name->name]; + $cached_name = self::normalizePayload($cached_name); + $name = self::extractName($cached_name); + if ($name !== null) { + return ['name' => $name]; } - if ($fallback_name !== null && trim($fallback_name) !== '') { + $fallback_name = self::normalizeName($fallback_name); + if ($fallback_name !== null) { return ['name' => $fallback_name]; } return null; } + + private static function normalizePayload(mixed $payload): mixed + { + if (!is_string($payload)) { + return $payload; + } + + $trimmed = trim($payload); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return $trimmed; + } + + private static function extractName(mixed $payload): ?string + { + if (is_string($payload)) { + return self::normalizeName($payload); + } + + if (!is_object($payload) && !is_array($payload)) { + return null; + } + + foreach (['name', 'customerName', 'customer_name', 'displayName', 'display_name'] as $key) { + $name = self::normalizeName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + foreach (['customer', 'data', 'economic_customer'] as $key) { + $name = self::extractName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + return null; + } + + private static function payloadValue(mixed $payload, string $key): mixed + { + if (is_object($payload) && property_exists($payload, $key)) { + return $payload->{$key}; + } + + if (is_array($payload) && array_key_exists($key, $payload)) { + return $payload[$key]; + } + + return null; + } + + private static function normalizeName(mixed $name): ?string + { + if (!is_string($name)) { + return null; + } + + $name = trim($name); + return $name === '' ? null : $name; + } } diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 57afb73f..6ea19d85 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -24,6 +24,7 @@ class invoice_period_flag_service private const WASH_CERTIFICATE_PRODUCT_ID = 41; private array $economicCustomerDiscountCache = []; private array $userDisplayNameCache = []; + private array $orderItemsPreviewCache = []; public function __construct() { @@ -204,7 +205,11 @@ class invoice_period_flag_service foreach ($types as $typeName => $customers) { foreach ($customers as $index => $customer) { $customerNumber = (int)($customer['customer_number'] ?? 0); - $flags = $flagsByCustomerNumber[$customerNumber] ?? []; + $flags = $this->flagsForCustomerCard( + $customer, + $flagsByCustomerNumber[$customerNumber] ?? [], + (string)$typeName + ); usort($flags, [$this, 'sortFlags']); $types[$typeName][$index]['flags'] = array_values($flags); $types[$typeName][$index]['flag_counts'] = $this->countFlags($flags); @@ -218,6 +223,69 @@ class invoice_period_flag_service return $types; } + private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array + { + $transactionIds = []; + $invoiceCollectionIds = []; + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $transactionIds[$orderId] = true; + } + + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + } + } + + return array_values(array_filter($flags, function (array $flag) use ($customer, $transactionIds, $invoiceCollectionIds, $typeName): bool { + return $this->flagBelongsToCustomerCard($customer, $flag, $transactionIds, $invoiceCollectionIds, $typeName); + })); + } + + private function flagBelongsToCustomerCard( + array $customer, + array $flag, + array $transactionIds, + array $invoiceCollectionIds, + string $typeName = '' + ): bool { + if ((string)($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + return false; + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + $targetType = (string)($flag['target_type'] ?? ''); + if ($targetType === 'customer') { + return (int)($flag['customer_number'] ?? $flag['target_id'] ?? 0) === $customerNumber; + } + + if ($targetType === 'xlvask_usage_log') { + return $typeName === 'all' && (int)($flag['customer_number'] ?? 0) === $customerNumber; + } + + if (in_array($targetType, ['order', 'order_field', 'order_item', 'order_item_field'], true)) { + $orderId = (int)($flag['order_id'] ?? $flag['context']['order_id'] ?? 0); + if ($orderId < 1 && in_array($targetType, ['order', 'order_field'], true)) { + $orderId = (int)($flag['target_id'] ?? 0); + } + return $orderId > 0 && isset($transactionIds[$orderId]); + } + + if ($targetType === 'collected_order_invoice') { + $invoiceCollectionId = (int)( + $flag['invoice_collection_id'] + ?? $flag['context']['invoice_collection_id'] + ?? $flag['target_id'] + ?? 0 + ); + return $invoiceCollectionId > 0 && isset($invoiceCollectionIds[$invoiceCollectionId]); + } + + return false; + } + private function getStoredFlag(int $id): array { global $db; @@ -539,6 +607,7 @@ class invoice_period_flag_service $row['has_wash_certificate_attachment'] = isset($certificateAttachmentOrderIds[$orderId]) ? 1 : 0; } unset($row); + $this->seedOrderItemsPreviewCacheFromRows($rows); return $rows; } @@ -759,6 +828,8 @@ class invoice_period_flag_service private function detectPriceMismatches(array $rows): array { + $this->preloadEconomicCustomerDiscounts($rows); + $flags = []; foreach ($rows as $row) { $orderItemId = (int)($row['order_item_id'] ?? 0); @@ -986,7 +1057,7 @@ class invoice_period_flag_service } } - $history = $this->getPrimaryProductHistory($dateFrom); + $history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1')); foreach ($primaryRows as $row) { $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); if ($reg === '' || !isset($history[$reg])) { @@ -1372,10 +1443,11 @@ class invoice_period_flag_service private function getCustomerName(int $customerNumber): string { global $db; - $result = $db->query("SELECT name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); + $result = $db->query("SELECT display_name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); if ($result && $result->num_rows > 0) { $row = $result->fetch_assoc(); - return (string)($row['name'] ?? ('#' . $customerNumber)); + $displayName = trim((string)($row['display_name'] ?? '')); + return $displayName !== '' ? $displayName : '#' . $customerNumber; } return '#' . $customerNumber; } @@ -1462,11 +1534,27 @@ class invoice_period_flag_service return $map; } - private function getPrimaryProductHistory(string $dateFrom): array + private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array { global $db; + + $registrations = []; + foreach ($registrationNumbers as $registrationNumber) { + $registrationNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim((string)$registrationNumber))); + $registrationNumber = is_string($registrationNumber) ? $registrationNumber : ''; + if ($registrationNumber !== '') { + $registrations[$registrationNumber] = true; + } + } + if (empty($registrations)) { + return []; + } + $dateFrom = $db->escape_string($dateFrom); $historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months'))); + $registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string { + return "'" . $db->escape_string($registrationNumber) . "'"; + }, array_keys($registrations))); $result = $db->query( "SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count FROM orders o @@ -1479,32 +1567,37 @@ class invoice_period_flag_service AND p.is_wash = 1 AND COALESCE(oi.related_item_id, 0) = 0 AND COALESCE(o.reg_1, '') <> '' + AND o.reg_1 IN ({$registrationFilter}) GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name - ORDER BY reg, usage_count DESC" + ORDER BY reg, usage_count DESC, oi.product_id ASC" ); - $byReg = []; + $history = []; + $seenCounts = []; if ($result) { while ($row = $result->fetch_assoc()) { - $byReg[(string)$row['reg']][] = [ + $reg = (string)$row['reg']; + $item = [ 'product_id' => (int)$row['product_id'], 'product_name' => (string)($row['product_name'] ?? ''), 'count' => (int)$row['usage_count'], ]; - } - } - $history = []; - foreach ($byReg as $reg => $items) { - $top = $items[0] ?? null; - $second = $items[1] ?? null; - if (!$top || (int)$top['count'] < 3) { - continue; + if (!isset($seenCounts[$reg])) { + $seenCounts[$reg] = 1; + if ($item['count'] >= 3) { + $history[$reg] = $item; + } + continue; + } + + if ($seenCounts[$reg] === 1) { + $seenCounts[$reg] = 2; + if (isset($history[$reg]) && $item['count'] >= (int)$history[$reg]['count']) { + unset($history[$reg]); + } + } } - if ($second && (int)$second['count'] >= (int)$top['count']) { - continue; - } - $history[$reg] = $top; } return $history; } @@ -1559,16 +1652,123 @@ class invoice_period_flag_service if ($orderId < 1) { return []; } + if (array_key_exists($orderId, $this->orderItemsPreviewCache)) { + return $this->orderItemsPreviewCache[$orderId]; + } + $result = $db->query( - "SELECT oi.id, oi.order_id, oi.product_id, oi.reference, oi.notes, oi.price, oi.quantity, - oi.related_item_id, p.name AS product_name, p.price AS product_base_price + "SELECT oi.id, oi.product_id, oi.price, oi.quantity, p.name AS product_name FROM order_items oi LEFT JOIN products p ON p.id = oi.product_id WHERE oi.order_id = {$orderId} AND (oi.deleted_at IS NULL OR oi.deleted_at = '') ORDER BY oi.related_item_id IS NOT NULL, oi.id" ); - return $result ? $db->fetch_all($result) : []; + $rows = $result ? $db->fetch_all($result) : []; + $this->orderItemsPreviewCache[$orderId] = array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['quantity'] ?? 0), + 'price' => (int)($row['price'] ?? 0), + ]; + }, $rows); + + return $this->orderItemsPreviewCache[$orderId]; + } + + private function seedOrderItemsPreviewCacheFromRows(array $rows): void + { + $grouped = []; + foreach ($rows as $row) { + $orderId = (int)($row['order_id'] ?? 0); + if ($orderId < 1) { + continue; + } + $grouped[$orderId] ??= []; + + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1) { + continue; + } + + $grouped[$orderId][] = [ + 'id' => $orderItemId, + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['item_quantity'] ?? 0), + 'price' => (int)($row['item_price'] ?? 0), + '_related_sort' => (int)($row['related_item_id'] ?? 0) > 0 ? 1 : 0, + ]; + } + + foreach ($grouped as $orderId => $items) { + usort($items, static function (array $a, array $b): int { + return ((int)$a['_related_sort'] <=> (int)$b['_related_sort']) + ?: ((int)$a['id'] <=> (int)$b['id']); + }); + $this->orderItemsPreviewCache[(int)$orderId] = array_map(static function (array $item): array { + unset($item['_related_sort']); + return $item; + }, $items); + } + } + + private function preloadEconomicCustomerDiscounts(array $rows): void + { + $customerUserIds = []; + foreach ($rows as $row) { + if ((int)($row['apply_category_discount'] ?? 0) !== 1) { + continue; + } + + $customerNumber = (int)($row['customer_number'] ?? 0); + $userId = (int)($row['user_id'] ?? 0); + if ($customerNumber < 1 || $userId < 1 || array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + continue; + } + + $customerUserIds[$customerNumber] = $userId; + } + + foreach ($customerUserIds as $customerNumber => $userId) { + $discount = $this->getCachedEconomicCustomerDiscount($userId); + if ($discount === null) { + $discount = $this->loadEconomicCustomerDiscount((int)$customerNumber, $userId); + } + + $this->economicCustomerDiscountCache[(int)$customerNumber] = $discount; + } + } + + private function getCachedEconomicCustomerDiscount(int $userId): ?int + { + if ($userId < 1 || !defined('redis')) { + return null; + } + + try { + $cachedDiscount = constant('redis')->get_economic_customer_discount_percentage($userId); + return $cachedDiscount === null ? null : (int)$cachedDiscount; + } catch (Throwable $e) { + return null; + } + } + + private function loadEconomicCustomerDiscount(int $customerNumber, int $userId): int + { + if ($customerNumber < 1 || $userId < 1 || !defined('redis')) { + return 0; + } + + try { + $discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customerNumber); + constant('redis')->cache_economic_customer_discount_percentage($userId, $discount); + return $discount; + } catch (Throwable $e) { + return 0; + } } private function calculateExpectedPrice(array $row): int @@ -1621,11 +1821,10 @@ class invoice_period_flag_service return $this->economicCustomerDiscountCache[$customerNumber]; } - try { - $user = (new \objects\users_o())->getUserByCustomerNumber($customerNumber); - $discount = (int)$user->getEconomicCustomerDiscountPercentage(); - } catch (Throwable $e) { - $discount = 0; + $discount = 0; + $userId = (int)($row['user_id'] ?? 0); + if ($userId > 0) { + $discount = $this->getCachedEconomicCustomerDiscount($userId) ?? 0; } $this->economicCustomerDiscountCache[$customerNumber] = $discount; diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php index b572c73d..f70545aa 100644 --- a/services/nginx/app/classes/orders_schema_bootstrap.php +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -41,6 +41,11 @@ class orders_schema_bootstrap ); } + self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at'); + self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id'); + self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at'); + self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id'); + self::$initialized = true; } @@ -69,6 +74,46 @@ class orders_schema_bootstrap return (int)$result->num_rows > 0; } + private static function ensureIndex(object $db, string $table, string $index, string $columns): void + { + if ( + !self::tableExists($db, $table) + || self::indexExists($db, $table, $index) + || !self::columnsExist($db, $table, $columns) + ) { + return; + } + + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})"); + } + + private static function columnsExist(object $db, string $table, string $columns): bool + { + foreach (explode(',', $columns) as $column) { + $column = trim($column, " \t\n\r\0\x0B`"); + if ($column === '' || !self::columnExists($db, $table, $column)) { + return false; + } + } + + return true; + } + + private static function indexExists(object $db, string $table, string $index): bool + { + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + private static function escapeIdentifier(string $value): string { return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php index 8aadcb98..b300973c 100644 --- a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -28,6 +28,9 @@ class xlvask_usage_logs_schema_bootstrap self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name'); self::ensureAutomationTables($db); self::$initialized = true; diff --git a/services/nginx/app/modules/economic/economic_m.php b/services/nginx/app/modules/economic/economic_m.php index 81fe86ab..88c27c3c 100644 --- a/services/nginx/app/modules/economic/economic_m.php +++ b/services/nginx/app/modules/economic/economic_m.php @@ -92,7 +92,8 @@ class economic_m CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => $method, diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index a48383b7..03d98546 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1194,6 +1194,141 @@ class orders_o extends db return $transactions; } + /** + * Get period transactions as plain rows grouped by customer number. + * + * This avoids hydrating one orders_o object per order for the invoicing period response. + * + * @param int[]|null $customers Null means all local customers with orders in the period. + * @return array>> + * @throws Exception + */ + public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array + { + global $db; + 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'); + } + + $customerFilter = ''; + if ($customers !== null) { + $customers = array_values(array_unique(array_filter( + array_map('intval', $customers), + static fn(int $customerNumber): bool => $customerNumber > 0 + ))); + if (empty($customers)) { + return []; + } + $customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')'; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $sql = " + SELECT + o.id, + o.customer_id AS customer_number, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount, + CASE + WHEN COALESCE(o.invoice_collection_id, 0) > 0 + THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END + ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END + END AS booked, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + CASE + WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice + WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0 + ELSE 1 + END AS include_in_invoice_effective + FROM {$this->table} o + INNER JOIN ( + SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) customer_user ON customer_user.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id + LEFT JOIN economic_module_orders emo ON emo.id = o.id + LEFT JOIN ( + SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing + FROM department_variables + WHERE variable = 'exclude_from_invoicing' + GROUP BY department_id + ) department_flags ON department_flags.department_id = o.department_id + WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND o.deleted_at IS NULL + {$customerFilter} + GROUP BY + o.id, + o.customer_id, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + coi.booked_invoice_id, + emo.invoice_id, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + o.include_in_invoice, + department_flags.exclude_from_invoicing + ORDER BY o.customer_id, o.created_at, o.id"; + $result = $db->query($sql); + if (!$result || $result->num_rows === 0) { + return []; + } + + $transactions = []; + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + if ($customerNumber < 1) { + continue; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + $transactions[$customerNumber][] = [ + 'id' => (int)$row['id'], + 'date' => (string)($row['created_at'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + 'amount' => (float)($row['net_amount'] ?? 0), + 'booked' => (int)($row['booked'] ?? 0) === 1, + 'department_id' => (int)($row['department_id'] ?? 0), + 'customer_number' => $customerNumber, + 'reference' => (string)($row['reference'] ?? ''), + 'po' => (string)($row['po'] ?? ''), + 'notes' => (string)($row['notes'] ?? ''), + 'reg_1' => (string)($row['reg_1'] ?? ''), + 'reg_2' => (string)($row['reg_2'] ?? ''), + 'reg_3' => (string)($row['reg_3'] ?? ''), + 'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1, + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => null, + 'queue_job_id' => null, + 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null, + 'customer_name' => (string)($row['customer_name'] ?? ''), + ]; + } + + 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 diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index c8e275d0..242982dd 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -7,6 +7,7 @@ use classes\customer_name_cache_payload_builder; use classes\object_property; use classes\redis; use classes\response; +use classes\system_search_economic_customer_index; use classes\xlvask; use customers\economic_customer_mo; use customers\economicCustomers; @@ -499,8 +500,9 @@ class users_o extends db } $cached = $tmp_user->getCached('economic_customer'); } - if ($cached && isset($cached->name) && is_string($cached->name) && trim($cached->name) !== '') { - return $cached->name; + $cachePayload = self::buildCustomerNameCachePayload($cached, $fallbackName); + if ($cachePayload !== null) { + return $cachePayload['name']; } return $fallbackName; } @@ -1534,7 +1536,7 @@ class users_o extends db * @param int[] $customer_numbers * @return array Map of customer number to customer name */ - public function getCustomerNames(array $customer_numbers): array + public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array { global $db; $customer_numbers = array_map('intval', $customer_numbers); @@ -1547,7 +1549,8 @@ class users_o extends db $customer_names_cached = self::getCachedForMultipleObjects('economic_customer_name', $customer_numbers); // Loop through the customer numbers and check if they are cached $customer_names = array_map(function ($cached_name) { - return $cached_name ? json_decode($cached_name)->name : null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + return $cache_payload['name'] ?? null; }, array_values($customer_names_cached)); // Set the names for the cached customer numbers [ "customer_number" => "customer_name" ] $customer_names = array_combine( @@ -1560,11 +1563,24 @@ class users_o extends db $customer_numbers_to_fetch[] = (int)$customer_number; } } + $fallback_names = $this->getLocalDisplayNamesByCustomerNumber($customer_numbers_to_fetch); + $local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch); + if (!$allowExternalFetch) { + foreach ($customer_numbers_to_fetch as $customer_number) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number] ?? $fallback_names[$customer_number] ?? 'Unknown Customer'; + } + return $customer_names; + } + // Fetch the remaining customer names from E-conomic if (count($customer_numbers_to_fetch) > 0) { foreach ( $customer_numbers_to_fetch as $customer_number ) { + if (isset($local_cached_names[$customer_number])) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number]; + continue; + } // Get the customer name from the external source - $fallback_name = null; + $fallback_name = $fallback_names[$customer_number] ?? null; try { // Try to get the economic customer data cached in the user $tmp_user = new users_o(); @@ -1597,6 +1613,142 @@ class users_o extends db return $customer_names; } + /** + * @param int[] $customer_numbers + * @return array + */ + private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT customer_number, display_name FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $display_name = trim((string)($row['display_name'] ?? '')); + if ($customer_number > 0 && $display_name !== '') { + $names[$customer_number] = $display_name; + } + } + + return $names; + } + + /** + * Resolve names from local e-conomic snapshots only. This keeps period/listing + * requests fast while still avoiding "Unnamed" fallbacks when a richer cached + * e-conomic customer payload already exists. + * + * @param int[] $customer_numbers + * @return array + */ + private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT id, customer_number FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return $this->getIndexedEconomicCustomerNamesByCustomerNumber($customer_numbers); + } + + $user_ids_by_customer_number = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $user_id = (int)($row['id'] ?? 0); + if ($customer_number <= 0 || $user_id <= 0) { + continue; + } + + $user_ids_by_customer_number[$customer_number] = $user_id; + } + + $names = []; + $customer_numbers_by_index = array_keys($user_ids_by_customer_number); + $cached_names = $this->getCachedForMultipleObjects('economic_customer', array_values($user_ids_by_customer_number)); + foreach ($customer_numbers_by_index as $index => $customer_number) { + $cached_name = $cached_names[$index] ?? null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + + $missing_customer_numbers = array_values(array_diff($customer_numbers, array_keys($names))); + if (!empty($missing_customer_numbers)) { + foreach ($this->getIndexedEconomicCustomerNamesByCustomerNumber($missing_customer_numbers) as $customer_number => $name) { + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $name], null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + } + + return $names; + } + + /** + * @param int[] $customer_numbers + * @return array + */ + private function getIndexedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + try { + system_search_economic_customer_index::ensureTable(); + } catch (\Throwable) { + return []; + } + + $result = $db->query( + "SELECT customer_number, economic_name FROM `" . system_search_economic_customer_index::TABLE . "`" + . " WHERE customer_number IN (" . implode(',', $customer_numbers) . ")" + ); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $row['economic_name'] ?? null], null); + if ($customer_number > 0 && $cache_payload !== null) { + $names[$customer_number] = $cache_payload['name']; + } + } + + return $names; + } + /** * @param int[] $cashier_ids * @return array Map of cashier id => display name diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 792b4623..ac30d7d9 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -92,6 +92,107 @@ class xlvask_usage_logs_o extends db //TODO: Add cache invalidation } + public function getCachedAmountSummaryFromRow(array $row): array + { + $cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); + $cached_at = trim((string)($row['cached_amount_at'] ?? '')); + + if ($cached_amount !== null && $cached_at !== '') { + return [ + 'total_net_amount' => $cached_amount, + 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), + 'cached' => true, + ]; + } + + $summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []); + $id = (int)($row['id'] ?? 0); + if ($id > 0) { + self::cacheAmountSummary($id, $summary); + } + + return [ + ...$summary, + 'cached' => false, + ]; + } + + public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array + { + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $washItems = is_array($decoded) ? $decoded : []; + } + + $total = 0.0; + $primaryProductName = ''; + foreach (is_array($washItems) ? $washItems : [] as $item) { + if (!is_array($item)) { + continue; + } + + if ($primaryProductName === '' && isset($item['OriginalProductName'])) { + $primaryProductName = trim((string)$item['OriginalProductName']); + } + + $priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null); + $vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0; + if ($priceIncVat === null) { + continue; + } + + $total += $priceIncVat - $vat; + } + + return [ + 'total_net_amount' => round($total, 2), + 'primary_product_name' => $primaryProductName, + ]; + } + + private static function cacheAmountSummary(int $id, array $summary): void + { + global $db; + + if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { + return; + } + + $amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', ''); + $primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? '')); + $db->query( + "UPDATE xlvask_usage_logs + SET cached_total_net_amount = {$amount}, + cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ", + cached_amount_at = NOW() + WHERE id = {$id}" + ); + } + + private static function normalizeMoneyValue(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (is_int($value) || is_float($value)) { + return (float)$value; + } + + $normalized = preg_replace('/[^\d,.\-]/', '', (string)$value); + if ($normalized === null || $normalized === '') { + return null; + } + + if (str_contains($normalized, ',') && !str_contains($normalized, '.')) { + $normalized = str_replace(',', '.', $normalized); + } else { + $normalized = str_replace(',', '', $normalized); + } + + return is_numeric($normalized) ? (float)$normalized : null; + } + /** * Import the usage logs from XL Vask * @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days' diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 4dddd124..0995f2ea 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -37,6 +37,14 @@ class InvoicingPeriodRoute private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; + /** + * Local-only booked status caches used by the period response. + * The period endpoint must not call e-conomic for each order. + * @var array + */ + private static array $periodOrderBookedCache = []; + private static array $periodInvoiceCollectionBookedCache = []; + /** * @throws Exception */ @@ -62,6 +70,12 @@ class InvoicingPeriodRoute return self::$departmentExcludedFromInvoicingCache[$departmentId]; } + private static function getLocalCustomerName(int $customerNumber): string + { + $names = (new users_o())->getCustomerNames([$customerNumber], false); + return (string)($names[$customerNumber] ?? 'Unknown Customer'); + } + /** * Slack summaries are expensive on request latency, so they are opt-in. * Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`. @@ -166,6 +180,22 @@ class InvoicingPeriodRoute })); } + /** + * @param array> $customers + * @return array> + */ + private static function indexCustomersByNumber(array $customers): array + { + $customersByNumber = []; + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customersByNumber[$customerNumber] = $customer; + } + } + return $customersByNumber; + } + /** * Response cache TTL (seconds) for v2 distribution endpoints. * Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override. @@ -244,6 +274,491 @@ class InvoicingPeriodRoute return $collective_results; } + private static function jsonFragment(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($json) ? $json : 'null'; + } + + private static function streamInvoicingPeriodResponse(array $period): void + { + global $response; + + header('Content-Type: application/json; charset=utf-8'); + http_response_code(200); + + echo '{"success":true,"data":{'; + echo '"dateFrom":' . self::jsonFragment($period['dateFrom'] ?? null); + echo ',"dateTo":' . self::jsonFragment($period['dateTo'] ?? null); + echo ',"types":{'; + + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $firstType = true; + foreach ($types as $typeName => $customers) { + if (!$firstType) { + echo ','; + } + $firstType = false; + echo self::jsonFragment((string)$typeName) . ':['; + + $firstCustomer = true; + foreach ((array)$customers as $customer) { + if (!$firstCustomer) { + echo ','; + } + $firstCustomer = false; + echo self::jsonFragment($customer); + } + echo ']'; + } + + echo '}'; + foreach ($period as $key => $value) { + if (in_array((string)$key, ['dateFrom', 'dateTo', 'types'], true)) { + continue; + } + echo ',' . self::jsonFragment((string)$key) . ':' . self::jsonFragment($value); + } + echo '}'; + echo ',"meta":' . self::jsonFragment($response->get_meta()); + echo ',"includes":' . self::jsonFragment($response->get_includes()); + echo '}'; + exit; + } + + private static function periodTypeNames(): array + { + return [ + 'all', + 'vehicle_subscriptions', + 'fixed_pricing', + 'tank_cleaning', + 'special_arrangements', + 'invoice_per_order', + 'possible_duplicates', + ]; + } + + private static function getPeriodPaginationOptionsFromRequest(): ?array + { + global $response; + + $paginationKeys = [ + 'periodView', + 'page', + 'limit', + 'search', + 'includeRequiresAction', + 'includeBooked', + ]; + + $isPaginatedRequest = false; + foreach ($paginationKeys as $key) { + if ($response->isRequestParameterSet($key)) { + $isPaginatedRequest = true; + break; + } + } + + if (!$isPaginatedRequest) { + return null; + } + + return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); + } + + private static function normalizePeriodPaginationOptions(array $parameters): array + { + $allowedViews = array_fill_keys(self::periodTypeNames(), true); + $periodView = trim((string)($parameters['periodView'] ?? 'all')); + if ($periodView === '' || !isset($allowedViews[$periodView])) { + $periodView = 'all'; + } + + $page = (int)($parameters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + + $limitParameter = strtolower(trim((string)($parameters['limit'] ?? '100'))); + if ($limitParameter === 'all') { + $limit = 'all'; + } else { + $limit = (int)$limitParameter; + if ($limit < 1) { + $limit = 100; + } + $limit = min(500, $limit); + } + + return [ + 'periodView' => $periodView, + 'page' => $page, + 'limit' => $limit, + 'search' => trim((string)($parameters['search'] ?? '')), + 'includeRequiresAction' => self::parsePeriodBooleanOption( + $parameters['includeRequiresAction'] ?? null, + true + ), + 'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true), + ]; + } + + private static function parsePeriodBooleanOption(mixed $value, bool $default): bool + { + if ($value === null || $value === '') { + return $default; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + + return $default; + } + + private static function applyPeriodPagination(array $period, array $options): array + { + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $types = self::ensurePeriodTypeKeys($types); + $types = self::enrichPeriodCustomerMetaFromTypes($types); + $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); + $types = self::filterPeriodTypesByVisibility( + $types, + (bool)($options['includeRequiresAction'] ?? true), + (bool)($options['includeBooked'] ?? true) + ); + + $periodView = (string)($options['periodView'] ?? 'all'); + if (!array_key_exists($periodView, $types)) { + $periodView = 'all'; + } + + $total = count($types[$periodView] ?? []); + $limit = $options['limit'] ?? 100; + $isAllLimit = $limit === 'all'; + $perPage = $isAllLimit ? 'all' : max(1, min(500, (int)$limit)); + $totalPages = $isAllLimit || $total === 0 ? 1 : (int)ceil($total / $perPage); + $page = $isAllLimit ? 1 : max(1, (int)($options['page'] ?? 1)); + $page = min($page, $totalPages); + + $pagedTypes = array_fill_keys(array_keys($types), []); + if ($isAllLimit) { + $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); + } else { + $offset = ($page - 1) * $perPage; + $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); + } + + $period['types'] = $pagedTypes; + $period['type_counts'] = self::summarizePeriodTypes($types); + $period['type_totals'] = self::summarizePeriodTypeTotals($types); + + return [ + 'period' => $period, + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'search' => (string)($options['search'] ?? ''), + 'filters' => [ + 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), + 'includeBooked' => (bool)($options['includeBooked'] ?? true), + ], + 'order' => [ + 'field' => 'customer_name', + 'direction' => 'asc', + ], + ], + ]; + } + + private static function ensurePeriodTypeKeys(array $types): array + { + foreach (self::periodTypeNames() as $typeName) { + if (!array_key_exists($typeName, $types) || !is_array($types[$typeName])) { + $types[$typeName] = []; + } + } + + return $types; + } + + private static function enrichPeriodCustomerMetaFromTypes(array $types): array + { + $metaByCustomerNumber = []; + foreach (['fixed_pricing', 'vehicle_subscriptions'] as $typeName) { + foreach (($types[$typeName] ?? []) as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : []; + if ($meta === []) { + continue; + } + $metaByCustomerNumber[$customerNumber] = array_merge( + $metaByCustomerNumber[$customerNumber] ?? [], + $meta + ); + } + } + + if ($metaByCustomerNumber === []) { + return $types; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1 || !isset($metaByCustomerNumber[$customerNumber])) { + continue; + } + + $types[$typeName][$index]['meta'] = array_merge( + is_array($customer['meta'] ?? null) ? $customer['meta'] : [], + $metaByCustomerNumber[$customerNumber] + ); + } + } + + return $types; + } + + private static function filterPeriodTypesBySearch(array $types, string $search): array + { + $search = self::normalizePeriodSearchTerm($search); + if ($search === '') { + return $types; + } + + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static fn(array $customer): bool => self::periodCustomerMatchesSearch($customer, $search) + )); + } + + return $types; + } + + private static function filterPeriodTypesByVisibility( + array $types, + bool $includeRequiresAction, + bool $includeBooked + ): array { + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static function (array $customer) use ($includeRequiresAction, $includeBooked): bool { + if (!$includeRequiresAction && (bool)($customer['requires_action'] ?? false)) { + return false; + } + + if ( + !$includeBooked + && !((bool)($customer['requires_action'] ?? false)) + && self::areAllPeriodCustomerTransactionsBooked($customer) + ) { + return false; + } + + return true; + } + )); + } + + return $types; + } + + private static function periodCustomerMatchesSearch(array $customer, string $search): bool + { + $values = [ + $customer['customer_number'] ?? '', + $customer['customer_name'] ?? '', + ]; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + foreach (['id', 'reference', 'po', 'notes', 'reg_1', 'reg_2', 'reg_3'] as $field) { + $values[] = $transaction[$field] ?? ''; + } + } + + foreach ($values as $value) { + if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { + return true; + } + } + + return false; + } + + private static function normalizePeriodSearchTerm(string $value): string + { + return mb_strtolower(trim($value), 'UTF-8'); + } + + private static function areAllPeriodCustomerTransactionsBooked(array $customer): bool + { + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction) || (bool)($transaction['booked'] ?? false) !== true) { + return false; + } + } + + return true; + } + + private static function summarizePeriodTypes(array $types): array + { + $counts = []; + foreach ($types as $typeName => $customers) { + $counts[$typeName] = self::summarizePeriodType(is_array($customers) ? $customers : []); + } + + return $counts; + } + + private static function summarizePeriodTypeTotals(array $types): array + { + $totals = []; + foreach ($types as $typeName => $customers) { + $totals[$typeName] = self::summarizePeriodTypeTotalsForCustomers( + is_array($customers) ? $customers : [] + ); + } + + return $totals; + } + + private static function summarizePeriodTypeTotalsForCustomers(array $customers): array + { + $total = 0.0; + $booked = 0.0; + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + $total += self::getPeriodCustomerTotalAmount($customer); + $booked += self::sumPeriodCustomerTransactions($customer, true); + } + + return [ + 'total' => $total, + 'booked' => $booked, + 'not_booked' => $total - $booked, + ]; + } + + private static function getPeriodCustomerTotalAmount(array $customer): float + { + $fixedPrice = $customer['meta']['fixed_pricing']['price'] ?? null; + if ($fixedPrice !== null && $fixedPrice !== '') { + return (float)$fixedPrice; + } + + return self::sumPeriodCustomerTransactions($customer, false); + } + + private static function sumPeriodCustomerTransactions(array $customer, bool $bookedOnly): float + { + $total = 0.0; + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction)) { + continue; + } + if ((bool)($transaction['excluded'] ?? false)) { + continue; + } + if ($bookedOnly && (bool)($transaction['booked'] ?? false) !== true) { + continue; + } + + $total += (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0); + } + + return $total; + } + + private static function summarizePeriodType(array $customers): array + { + $requiresAction = 0; + $draft = 0; + $manualFlags = 0; + $automaticFlags = 0; + + foreach ($customers as $customer) { + if ((bool)($customer['requires_action'] ?? false)) { + $requiresAction++; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + $draft++; + } + + $flagCounts = self::getActivePeriodFlagCounts($customer); + if ($flagCounts['manual'] > 0) { + $manualFlags++; + } + if ($flagCounts['automatic'] > 0) { + $automaticFlags++; + } + } + + return [ + 'requires_action' => $requiresAction, + 'draft' => $draft, + 'manual_flags' => $manualFlags, + 'automatic_flags' => $automaticFlags, + 'completed' => max(0, count($customers) - $requiresAction - $draft), + 'total' => count($customers), + ]; + } + + private static function getActivePeriodFlagCounts(array $customer): array + { + $manual = 0; + $automatic = 0; + if (is_array($customer['flags'] ?? null)) { + foreach ($customer['flags'] as $flag) { + if (!is_array($flag) || (string)($flag['status'] ?? 'active') !== 'active') { + continue; + } + if (($flag['source'] ?? null) === 'manual') { + $manual++; + } elseif (($flag['source'] ?? null) === 'automatic') { + $automatic++; + } + } + + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + return [ + 'manual' => (int)($customer['flag_counts']['manual'] ?? 0), + 'automatic' => (int)($customer['flag_counts']['automatic'] ?? 0), + 'total' => (int)($customer['flag_counts']['total'] ?? 0), + ]; + } + public function run(): void { $this->get('/superuser/invoicing/period', function () { @@ -264,8 +779,15 @@ class InvoicingPeriodRoute if ($customerNumbers !== null) { $response->add_meta('customer_numbers', $customerNumbers); } + $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); // Get the invoicing period for the user - $response->success([...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)]); + $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers); + if ($paginationOptions !== null) { + $paginated = self::applyPeriodPagination($period, $paginationOptions); + $period = $paginated['period']; + $response->add_meta('pagination', $paginated['pagination']); + } + self::streamInvoicingPeriodResponse($period); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session'); @@ -883,7 +1405,7 @@ class InvoicingPeriodRoute 'difference' => $difference, ]; // Send slack alert - $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . (new users_o())->getCustomerName((int)$customer['customer_number']) . ")\n"; + $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . self::getLocalCustomerName((int)$customer['customer_number']) . ")\n"; $message .= "Subscription total: " . $customer['meta']['subscription']['subscription_total'] . "\n"; $message .= "Distribution total: " . $sum_of_distribution . "\n"; $message .= "Difference: " . $difference . "\n"; @@ -934,7 +1456,7 @@ class InvoicingPeriodRoute */ private static function attemptSubscriptionFallbacks(customer_vehicles_o $vehicle, array &$customer): bool { - if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . (new users_o())->getCustomerName((int)$customer['customer_number']) . "\n"; + if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . self::getLocalCustomerName((int)$customer['customer_number']) . "\n"; // Run the fallback options in order $subscription_price = (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(); if (self::divideSubscriptionAcrossCustomerDepartments($customer, $subscription_price)) { @@ -1346,89 +1868,36 @@ class InvoicingPeriodRoute return []; } - // Define the customers with orders in the specified date range - $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); + $customer_number_transactions = []; + self::debugGetTime(function () use ($onlyCustomerNumbers, $dateFrom, $dateTo, &$customer_number_transactions) { + $customer_number_transactions = (new orders_o())->getPeriodTransactionsForCustomersInDateRange( + $onlyCustomerNumbers, + $dateFrom, + $dateTo + ); + }, 'get_transactions_for_customers_in_date_range'); - /** - * // user_id => customer_number, - * @example - * [ - * '123' => '12345678', - * '456' => '87654321' - * ] - */ $customer_numbers = []; $tmp = []; - $allowed_customer_numbers = $onlyCustomerNumbers !== null - ? array_fill_keys(array_map('intval', $onlyCustomerNumbers), true) - : null; - self::debugGetTime(function () use ($customers, &$customer_numbers, $allowed_customer_numbers) { - // Process the customer numbers to ensure they are unique - foreach ( $customers as $customer ) { - $customer_number = (int)$customer->customer_number->value(); + self::debugGetTime(function () use ($customer_number_transactions, &$customer_numbers) { + foreach ( $customer_number_transactions as $customer_number => $transactions ) { + $customer_number = (int)$customer_number; if (empty($customer_number)) { - // Skip if the customer number is empty continue; } - if ($allowed_customer_numbers !== null && !isset($allowed_customer_numbers[$customer_number])) { - continue; - } - // 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; + $firstTransaction = is_array($transactions) ? ($transactions[0] ?? []) : []; + $userId = (int)($firstTransaction['user_id'] ?? 0); + $customer_numbers[$customer_number] = $userId > 0 ? $userId : null; } }, 'process_customer_numbers'); - // Get the transactions for the customers in the specified date range - /** - * @example - * [ - * '12345678' => [ - * orders_o, - * orders_o, - * ] - * ] - * @var $customer_number_transactions - */ - self::debugGetTime(function () use ($customer_numbers, $dateFrom, $dateTo, &$customer_number_transactions) { - if (empty($customer_numbers)) { - $customer_number_transactions = []; - return; - } - $customer_number_transactions = (new orders_o())->getTransactionsForCustomersInDateRange(array_keys($customer_numbers), $dateFrom, $dateTo); - }, 'get_transactions_for_customers_in_date_range'); - // Calculate the total amount for each transaction, to minimize the number of queries - self::debugGetTime(function () use ($customer_number_transactions) { - // Get all transaction ids - $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); - } - } - } + self::debugGetTime(static function (): void { + // Net totals are resolved by getPeriodTransactionsForCustomersInDateRange(). }, 'calculate_transaction_totals'); // Get the customer names from the cache - $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers)); + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers), false); // Process the customer numbers to ensure they are unique self::debugGetTime(function () use ($customer_numbers, $customer_number_transactions, &$tmp, $customer_names) { foreach ( $customer_numbers as $customer_number => $user_id ) { @@ -1446,10 +1915,9 @@ class InvoicingPeriodRoute $tmp[] = self::constructCustomerObject( (int)$customer_number, $customer_names[(int)$customer_number] ?? 'Unknown Customer', - //(new \objects\users_o())->getCustomerName((int)$customer_number), $customer_number_transactions[(int)$customer_number] ?? [], false, - (int)$user_id, + (int)$user_id > 0 ? (int)$user_id : null, ); } }, 'construct_customer_objects'); @@ -1473,9 +1941,12 @@ class InvoicingPeriodRoute ?array $meta = null ): array { - $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + $user = null; + if ($user_id === null) { + $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + } return [ - 'id' => $user_id ?? ($user->exists() ? $user->id : null), + 'id' => $user_id ?? ($user !== null && $user->exists() ? $user->id : null), 'customer_number' => $customer_number, 'customer_name' => $customer_name, 'transactions' => $parsed_transactions = array_map(function ($transaction) { @@ -1491,15 +1962,22 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function constructTransactionObject(orders_o $transaction): array + private static function constructTransactionObject(mixed $transaction): array { + if (is_array($transaction)) { + return self::constructTransactionObjectFromPeriodRow($transaction); + } + if (!$transaction instanceof orders_o) { + throw new \InvalidArgumentException('Invalid period transaction row.'); + } + $departmentId = (int)$transaction->department_id->value(); $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier - 'booked' => $transaction->isBooked(true), + 'booked' => self::isTransactionBookedFromLocalState($transaction), 'department_id' => $departmentId, 'customer_number' => (int)$transaction->customer_id->value(), 'reference' => (string)$transaction->reference->value(), @@ -1515,6 +1993,61 @@ class InvoicingPeriodRoute ]; } + private static function constructTransactionObjectFromPeriodRow(array $transaction): array + { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + return [ + 'id' => (int)($transaction['id'] ?? $transaction['order_id'] ?? 0), + 'date' => (string)($transaction['date'] ?? $transaction['created_at'] ?? ''), + 'amount' => (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0), + 'booked' => (bool)($transaction['booked'] ?? false), + 'department_id' => (int)($transaction['department_id'] ?? 0), + 'customer_number' => (int)($transaction['customer_number'] ?? $transaction['customer_id'] ?? 0), + 'reference' => (string)($transaction['reference'] ?? $transaction['order_reference'] ?? ''), + 'po' => (string)($transaction['po'] ?? $transaction['order_po'] ?? ''), + 'notes' => (string)($transaction['notes'] ?? $transaction['order_notes'] ?? ''), + 'reg_1' => (string)($transaction['reg_1'] ?? ''), + 'reg_2' => (string)($transaction['reg_2'] ?? ''), + 'reg_3' => (string)($transaction['reg_3'] ?? ''), + 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => $transaction['queue_status'] ?? null, + 'queue_job_id' => $transaction['queue_job_id'] ?? null, + ]; + } + + /** + * Resolve booked state from local stored invoice metadata only. + * Remote e-conomic invoice lookups are intentionally avoided here because this method runs for every + * transaction in the period response. + */ + private static function isTransactionBookedFromLocalState(orders_o $transaction): bool + { + global $db; + + $orderId = (int)$transaction->id; + if ($orderId < 1) { + return false; + } + if (array_key_exists($orderId, self::$periodOrderBookedCache)) { + return self::$periodOrderBookedCache[$orderId]; + } + + $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); + if ($invoiceCollectionId > 0) { + if (!array_key_exists($invoiceCollectionId, self::$periodInvoiceCollectionBookedCache)) { + $result = $db->query("SELECT booked_invoice_id FROM collected_order_invoices WHERE id = {$invoiceCollectionId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId] = !empty($row['booked_invoice_id'] ?? null); + } + return self::$periodOrderBookedCache[$orderId] = self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId]; + } + + $result = $db->query("SELECT invoice_id FROM economic_module_orders WHERE id = {$orderId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null); + } + private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool { // If requires_action is already set to true, return true @@ -2044,9 +2577,11 @@ class InvoicingPeriodRoute ); // Get all customers with vehicle subscriptions $subscriptions = []; + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $customer = self::getCustomerFromList((int)$customer_number, $customersWithTransactions); + $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $customer['meta'] = array_merge($customer['meta'] ?? [], [ 'has_vehicle_subscription' => true, @@ -2057,7 +2592,7 @@ class InvoicingPeriodRoute $subscriptions[] = self::constructCustomerObject( (int)$customer_number, - (new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer', + $customer_names[(int)$customer_number] ?? 'Unknown Customer', [], true, null, @@ -2078,6 +2613,11 @@ class InvoicingPeriodRoute */ private static function getCustomerFromList(int $customer_number, array $customersWithTransactions): ?array { + $direct = $customersWithTransactions[$customer_number] ?? null; + if (is_array($direct) && (int)($direct['customer_number'] ?? 0) === $customer_number) { + return $direct; + } + // Search for the customer in the list of customers with transactions foreach ( $customersWithTransactions as $customer ) { if ($customer['customer_number'] === $customer_number) { @@ -2104,10 +2644,7 @@ class InvoicingPeriodRoute $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $customer_numbers); } - $customers_by_number = []; - foreach ( $customersWithTransactions as $customer ) { - $customers_by_number[(int)$customer['customer_number']] = $customer; - } + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); // Get the customers fixed pricing $tmp_fixed_pricing = array_map(function ($arr) { @@ -2124,7 +2661,7 @@ class InvoicingPeriodRoute $fixed_pricing_by_customer_number[(int)$item['customer_number']] = $item; } - $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers)); + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); // Get all customers with fixed pricing $fixed_pricing = []; @@ -2184,12 +2721,13 @@ class InvoicingPeriodRoute self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); // Get all customers with tank cleaning $tank_cleaning = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $tank_cleaning[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $tank_cleaning[] = $customer; + } // Add the tank cleaning to the list if it has transactions } return $tank_cleaning; @@ -2203,16 +2741,10 @@ class InvoicingPeriodRoute */ private static function filterCustomersWithTransactions(array &$customer_numbers, array $customersWithTransactions): void { - // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = array_filter($customer_numbers, function ($customer_number) use ($customersWithTransactions) { - // Check if the customer has any transactions in the specified date range - foreach ( $customersWithTransactions as $customer ) { - if ($customer['customer_number'] === $customer_number) { - return true; - } - } - return false; - }); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); + $customer_numbers = array_values(array_filter($customer_numbers, static function ($customer_number) use ($customers_by_number): bool { + return isset($customers_by_number[(int)$customer_number]); + })); } /** @@ -2232,12 +2764,13 @@ class InvoicingPeriodRoute // Filter out customers that do not have any transactions in the specified date range self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $special_arrangements = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $special_arrangements[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $special_arrangements[] = $customer; + } } return $special_arrangements; } @@ -2259,10 +2792,14 @@ class InvoicingPeriodRoute self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $invoicing_per_order = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { // Add the invoicing per order to the list - $invoicing_per_order[] = self::getCustomerFromList((int)$customer_number, $customersWithTransactions); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $invoicing_per_order[] = $customer; + } } return $invoicing_per_order; } @@ -2279,16 +2816,40 @@ class InvoicingPeriodRoute $allowedCustomerNumbers = $onlyCustomerNumbers !== null ? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true) : null; - // 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 + $ordersByRegistration = []; + foreach ($customersWithTransactions as $customer) { + foreach (($customer['transactions'] ?? []) as $transaction) { + $transaction = self::constructTransactionObject($transaction); + $registration = trim((string)($transaction['reg_1'] ?? '')); + if ($registration === '') { + continue; + } + $customerNumber = (int)($transaction['customer_number'] ?? 0); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + $ordersByRegistration[$registration][] = [ + 'id' => (int)$transaction['id'], + 'created_at' => (string)$transaction['date'], + 'customer_number' => $customerNumber, + 'object' => $transaction, + ]; + } + } + $orders = invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400); $tmp_customer_arr = []; // Remove duplicates from the customer numbers $possible_duplicates = []; + $customer_names = []; + foreach ($orders as $order) { + $customer_names[(int)($order[0]['customer_number'] ?? 0)] = true; + } + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_names), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $orders as $order ) { // Get the customer number from the order - $customer_number = (int)$order[0]['object']->customer_id->value(); + $customer_number = (int)($order[0]['customer_number'] ?? 0); if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) { continue; } @@ -2299,11 +2860,11 @@ class InvoicingPeriodRoute // 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); + $customer = $customers_by_number[$customer_number] ?? null; // Add the customer to the possible duplicates array $possible_duplicates[] = self::constructCustomerObject( $customer_number, - (new \objects\users_o())->getCustomerName($customer_number) ?? 'Unknown Customer', + $customer_names[$customer_number] ?? 'Unknown Customer', array_map(function ($transaction) { // Construct the transaction object from the order return $transaction['object']; diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index fef17769..70bd02db 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -70,13 +70,39 @@ class xlvaskUsageLogsRoute $automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true); // Remove the 'id' field from the log $id = (int)$log['id']; + $amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log); unset($log['id']); // Convert the 'WashItems' field from JSON to an array $log['WashItems'] = json_decode($log['WashItems'], true); + $usage_log_payload = array_intersect_key($log, array_flip([ + 'WashId', + 'CustomerId', + 'Customer', + 'VatNumber', + 'Location', + 'Hall', + 'HallId', + 'StartTime', + 'FinishTime', + 'RegistrationNumber', + 'VehicleType', + 'IdentificationType', + 'IdentificationId', + 'Info', + 'Updated', + 'Prepaid', + 'FinishStatus', + 'CustomerGuid', + 'VehicleId', + 'WashItems', + 'ignored_at', + 'ignored_by', + 'ignored_reason', + ])); // Create a new xlvask usage log object $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); // Set the properties of the temporary object - $tmp->setProperties($log); + $tmp->setProperties($usage_log_payload); $wash_id = (string)$tmp->WashId; if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { $linked_order = (new orders_o())->selectByWashId($wash_id); @@ -98,6 +124,9 @@ class xlvaskUsageLogsRoute // Return the result $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order + $tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount']; + $tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name']; + $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; // Clear memory unset($tmp); unset($log); diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index f0c79e9d..0be985b8 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -232,7 +232,9 @@ CREATE TABLE IF NOT EXISTS `orders` ( KEY `idx_orders_customer_id` (`customer_id`), KEY `idx_orders_department_id` (`department_id`), KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`), - KEY `idx_orders_reg_1` (`reg_1`) + KEY `idx_orders_reg_1` (`reg_1`), + KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`), + KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'order_bookings' => <<<'SQL' @@ -278,7 +280,8 @@ CREATE TABLE IF NOT EXISTS `order_items` ( `deleted_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_order_items_order_id` (`order_id`), - KEY `idx_order_items_deleted_at` (`deleted_at`) + KEY `idx_order_items_deleted_at` (`deleted_at`), + KEY `idx_order_items_order_deleted` (`order_id`, `deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'customer_vehicles' => <<<'SQL' @@ -336,6 +339,9 @@ CREATE TABLE IF NOT EXISTS `xlvask_usage_logs` ( `ignored_at` DATETIME NULL, `ignored_by` INT NULL, `ignored_reason` TEXT NULL, + `cached_total_net_amount` DECIMAL(12,2) NULL, + `cached_primary_product_name` VARCHAR(255) NULL, + `cached_amount_at` DATETIME NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_xlvask_usage_logs_wash_id` (`WashId`), KEY `idx_xlvask_usage_logs_customer` (`CustomerId`), @@ -411,7 +417,8 @@ CREATE TABLE IF NOT EXISTS `customer_attributes` ( `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_customer_attributes_user_id` (`user_id`), - KEY `idx_customer_attributes_attribute` (`attribute`) + KEY `idx_customer_attributes_attribute` (`attribute`), + KEY `idx_customer_attributes_attribute_user` (`attribute`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'module_config' => <<<'SQL' diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php index 47ef90f3..6bf7f9e7 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php @@ -176,3 +176,17 @@ it('returns raw payload unchanged on successful HTTP statuses', function (): voi expect($endpointProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); expect($legacyProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); }); + +it('bounds e-conomic curl calls below the PHP request timeout', function (): void { + $legacyContent = file_get_contents(app_path('modules/economic/economic_m.php')); + $endpointContent = file_get_contents(app_path('traits/economic_endpoint_t.php')); + + expect($legacyContent)->not->toBeFalse() + ->and($endpointContent)->not->toBeFalse(); + + foreach ([(string)$legacyContent, (string)$endpointContent] as $content) { + expect($content)->toContain('CURLOPT_CONNECTTIMEOUT => 3') + ->and($content)->toContain('CURLOPT_TIMEOUT => 10') + ->and($content)->not->toContain('CURLOPT_TIMEOUT => 0'); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 4d64e042..dfae664f 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -554,6 +554,45 @@ it('uses the highest customer-specific discount in expected price breakdowns', f ]); }); +it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $cache = $reflection->getProperty('economicCustomerDiscountCache'); + $cache->setAccessible(true); + $cache->setValue($service, [ + 35131752 => 18, + ]); + + $calculate = $reflection->getMethod('calculateExpectedPrice'); + $calculate->setAccessible(true); + $breakdownMethod = $reflection->getMethod('priceBreakdown'); + $breakdownMethod->setAccessible(true); + + $row = [ + 'customer_number' => 35131752, + 'user_id' => 411, + 'product_base_price' => 100, + 'department_price' => null, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'apply_category_discount' => 1, + ]; + + $expected = $calculate->invoke($service, $row); + $breakdown = $breakdownMethod->invoke($service, $row, $expected); + + expect($expected)->toBe(82); + expect($breakdown)->toMatchArray([ + 'effective_base_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'economic_customer_discount_percentage' => 18, + 'applied_discount_percentage' => 18, + 'expected_price' => 82, + ]); +}); + it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void { $row = [ 'customer_number' => 35131752, @@ -589,6 +628,69 @@ it('does not report a price mismatch when a product-specific discount makes the expect($flags)->toBe([]); }); +it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$this->preloadEconomicCustomerDiscounts($rows);'); + expect($content)->toContain('private function preloadEconomicCustomerDiscounts(array $rows): void'); + expect($content)->toContain("constant('redis')->get_economic_customer_discount_percentage(\$userId)"); + expect($content)->toContain('getCustomerDiscountPercentage($customerNumber)'); + expect($content)->toContain("constant('redis')->cache_economic_customer_discount_percentage(\$userId, \$discount)"); +}); + +it('seeds order item preview cache from period rows', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $seed = $reflection->getMethod('seedOrderItemsPreviewCacheFromRows'); + $seed->setAccessible(true); + $preview = $reflection->getMethod('getOrderItemsForPreview'); + $preview->setAccessible(true); + + $seed->invoke($service, [ + [ + 'order_id' => 9001, + 'order_item_id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'item_quantity' => 2, + 'item_price' => 25, + 'related_item_id' => 12, + ], + [ + 'order_id' => 9001, + 'order_item_id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'item_quantity' => 1, + 'item_price' => 100, + 'related_item_id' => null, + ], + [ + 'order_id' => 9002, + 'order_item_id' => null, + ], + ]); + + expect($preview->invoke($service, 9001))->toBe([ + [ + 'id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'quantity' => 1, + 'price' => 100, + ], + [ + 'id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'quantity' => 2, + 'price' => 25, + ], + ])->and($preview->invoke($service, 9002))->toBe([]); +}); + it('sorts manual flags before automatic warnings and preserves legacy circle indicators without flags', function (): void { $manual = [ 'id' => 12, @@ -647,6 +749,104 @@ it('sorts manual flags before automatic warnings and preserves legacy circle ind ]))->toBe('circle_yellow'); }); +it('scopes invoice period flags to the customer card that can render them', function (): void { + $customer = [ + 'customer_number' => 424242, + 'transactions' => [ + ['id' => 61311, 'invoice_collection_id' => 16912], + ], + ]; + + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order_item_field', 'order_id' => 61311], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order', 'order_id' => 99999], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'collected_order_invoice', 'target_id' => 16912], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'vehicle_subscriptions', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); +}); + +it('keeps order item preview context compact for the period response', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public string $lastQuery = ''; + + public function query(string $sql): object + { + $this->lastQuery = $sql; + return (object)['ok' => true]; + } + + public function fetch_all(object $result): array + { + return [[ + 'id' => '7001', + 'order_id' => '61311', + 'product_id' => '3', + 'reference' => 'REF', + 'notes' => str_repeat('x', 1024), + 'price' => '649', + 'quantity' => '1', + 'related_item_id' => '0', + 'product_name' => 'Forvogn', + 'product_base_price' => '649', + ]]; + } + }; + + try { + $rows = invoice_period_flag_service_invoke('getOrderItemsForPreview', [61311]); + + expect($rows)->toBe([[ + 'id' => 7001, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'quantity' => 1, + 'price' => 649, + ]]); + expect($db->lastQuery)->not->toContain('oi.reference'); + expect($db->lastQuery)->not->toContain('oi.notes'); + expect($db->lastQuery)->not->toContain('p.price AS product_base_price'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + it('formats stored manual flags with the creating superuser display name', function (): void { global $db; @@ -764,3 +964,15 @@ it('guards optional customer vehicle deleted_at filtering behind a column check' expect($content)->toContain('$deletedFilter'); expect($content)->toContain('{$deletedFilter}'); }); + +it('limits historical primary product lookup to current period registrations', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))'); + expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array'); + expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})'); + expect($content)->not->toContain('$byReg = []'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php index 82c74a93..a1b6ea36 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php @@ -27,3 +27,32 @@ it('uses centralized duplicate filtering for possible duplicate detection', func expect($content)->not->toBeFalse(); expect($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($orders, 86400)'); }); + +it('provides a batched plain-row transaction query for invoicing period responses', function (): void { + $ordersFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array') + ->and($content)->toContain('COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount') + ->and($content)->toContain('COALESCE(coi.booked_invoice_id, 0)') + ->and($content)->toContain('COALESCE(emo.invoice_id, 0)') + ->and($content)->toContain('department_flags.exclude_from_invoicing') + ->and($content)->not->toContain('$order->select((int)$row[\'id\']);' . PHP_EOL . ' $transactions[$customerNumber][]'); +}); + +it('adds guarded composite indexes for invoicing period lookups', function (): void { + $schemaFile = app_path('classes/orders_schema_bootstrap.php'); + $content = file_get_contents($schemaFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id')") + ->and($content)->toContain("self::ensureIndex(\$db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id')") + ->and($content)->toContain('private static function indexExists(object $db, string $table, string $index): bool'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php new file mode 100644 index 00000000..ded2b3ee --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php @@ -0,0 +1,388 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_customer_card( + int $customerNumber, + string $customerName, + array $transactions, + bool $requiresAction = false, + array $extra = [] +): array { + return array_merge([ + 'id' => $customerNumber, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName, + 'requires_action' => $requiresAction, + 'transactions' => $transactions, + 'meta' => [], + 'queue' => [ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + 'draft' => [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + ], $extra); +} + +function invoicing_period_transaction(array $overrides = []): array +{ + return array_merge([ + 'id' => 9001, + 'date' => '2026-04-10 12:00:00', + 'amount' => 125.5, + 'booked' => false, + 'department_id' => 1, + 'customer_number' => 1001, + 'reference' => 'REF-9001', + 'po' => 'PO-9001', + 'notes' => 'Gate note', + 'reg_1' => 'AB12345', + 'reg_2' => '', + 'reg_3' => '', + 'excluded' => false, + 'invoice_collection_id' => 3001, + 'queue_status' => null, + 'queue_job_id' => null, + ], $overrides); +} + +it('detects paginated period mode only when pagination parameters are present', function (): void { + global $response; + + $previousResponse = $GLOBALS['response'] ?? null; + $previousGet = $_GET; + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $response = new response(); + + try { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = [ + 'dateFrom' => '2026-04-01', + 'dateTo' => '2026-04-30', + ]; + + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull(); + + $_GET['page'] = '2'; + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 100, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + if ($previousResponse === null) { + unset($GLOBALS['response']); + } else { + $response = $previousResponse; + } + } +}); + +it('normalizes period pagination options and clamps invalid page and limit values', function (): void { + $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'not-a-view', + 'page' => '-4', + 'limit' => '500', + 'search' => ' Nordic ', + 'includeRequiresAction' => '0', + 'includeBooked' => 'false', + ]]); + + expect($options)->toBe([ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 500, + 'search' => 'Nordic', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => '900', + ]]))->toMatchArray([ + 'limit' => 500, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => 'all', + ]]))->toMatchArray([ + 'limit' => 'all', + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'invoice_per_order', + 'page' => '3', + 'limit' => '0', + ]]))->toMatchArray([ + 'periodView' => 'invoice_per_order', + 'page' => 3, + 'limit' => 100, + ]); +}); + +it('slices only the active period view and keeps exact full-result type counts', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [ + invoicing_period_transaction(['id' => 11, 'customer_number' => 1001, 'amount' => 50]), + ], false), + invoicing_period_customer_card(1002, 'Beta Transport', [ + invoicing_period_transaction([ + 'id' => 12, + 'customer_number' => 1002, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]), + ], true), + invoicing_period_customer_card(1003, 'Gamma Transport', [ + invoicing_period_transaction([ + 'id' => 13, + 'customer_number' => 1003, + 'amount' => 125, + 'booked' => true, + ]), + ], false, [ + 'draft' => [ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3013], + 'is_action_blocked' => true, + ], + ]), + ], + 'fixed_pricing' => [ + invoicing_period_customer_card(1002, 'Beta Transport', [], true, [ + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]), + ], + 'invoice_per_order' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [], true, [ + 'flags' => [ + ['source' => 'manual', 'status' => 'active'], + ], + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 1, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 2, + 'per_page' => 1, + 'total' => 3, + 'search' => '', + ]); + + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0])->toMatchArray([ + 'customer_number' => 1002, + 'customer_name' => 'Beta Transport', + 'requires_action' => true, + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]); + expect($result['period']['types']['all'][0]['transactions'][0])->toMatchArray([ + 'id' => 12, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]); + expect($result['period']['types']['fixed_pricing'])->toBe([]); + expect($result['period']['type_counts']['all'])->toBe([ + 'requires_action' => 1, + 'draft' => 1, + 'manual_flags' => 0, + 'automatic_flags' => 0, + 'completed' => 1, + 'total' => 3, + ]); + expect($result['period']['type_totals']['all'])->toBe([ + 'total' => 1174.0, + 'booked' => 125.0, + 'not_booked' => 1049.0, + ]); + expect($result['period']['type_totals']['fixed_pricing'])->toBe([ + 'total' => 999.0, + 'booked' => 0.0, + 'not_booked' => 999.0, + ]); + expect($result['period']['type_counts']['invoice_per_order']['manual_flags'])->toBe(1); +}); + +it('returns the entire active period view when the limit is all', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1101, 'Alpha', [ + invoicing_period_transaction(['id' => 31, 'customer_number' => 1101]), + ]), + invoicing_period_customer_card(1102, 'Beta', [ + invoicing_period_transaction(['id' => 32, 'customer_number' => 1102]), + ]), + invoicing_period_customer_card(1103, 'Gamma', [ + invoicing_period_transaction(['id' => 33, 'customer_number' => 1103]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 3, + 'limit' => 'all', + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 1, + 'per_page' => 'all', + 'total' => 3, + ]); + expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([1101, 1102, 1103]); +}); + +it('searches customer fields and order fields at the customer-card level', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(2001, 'Solaris Fleet', [ + invoicing_period_transaction([ + 'id' => 21, + 'customer_number' => 2001, + 'reference' => 'REF-KEEP', + 'po' => 'PO-KEEP', + ]), + ]), + invoicing_period_customer_card(2002, 'Nordic Logistics', [ + invoicing_period_transaction([ + 'id' => 22, + 'customer_number' => 2002, + 'reference' => 'MISS', + 'po' => 'PO-777', + 'notes' => 'Driver waits at gate', + 'reg_1' => 'CD33333', + ]), + invoicing_period_transaction([ + 'id' => 23, + 'customer_number' => 2002, + 'reference' => 'SECOND-LINE', + ]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'po-777', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(2002); + expect($result['period']['types']['all'][0]['transactions'])->toHaveCount(2); + + $registrationMatch = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'cd33333', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + expect($registrationMatch['period']['types']['all'][0]['customer_name'])->toBe('Nordic Logistics'); +}); + +it('applies requires-action and booked visibility filters before counting and slicing', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(3001, 'Needs Action', [ + invoicing_period_transaction(['customer_number' => 3001, 'booked' => false]), + ], true), + invoicing_period_customer_card(3002, 'Already Booked', [ + invoicing_period_transaction(['customer_number' => 3002, 'booked' => true]), + ], false), + invoicing_period_customer_card(3003, 'Still Open', [ + invoicing_period_transaction(['customer_number' => 3003, 'booked' => false]), + ], false), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => '', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['type_counts']['all']['total'])->toBe(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index afe913e5..f707b548 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -1,5 +1,9 @@ toBeGreaterThanOrEqual(5); }); +it('keeps the main period response local-only for booked state and customer names', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->not->toContain('isBooked(true)') + ->and($content)->toContain('isTransactionBookedFromLocalState($transaction)') + ->and($content)->toContain('SELECT booked_invoice_id FROM collected_order_invoices') + ->and($content)->toContain('SELECT invoice_id FROM economic_module_orders') + ->and($content)->toContain('getCustomerNames(array_keys($customer_numbers), false)') + ->and($content)->toContain('getCustomerNames(array_map(\'intval\', $customer_numbers), false)'); +}); + +it('streams the main period response instead of encoding the full payload at once', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void') + ->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);') + ->and($content)->toContain('self::streamInvoicingPeriodResponse($period);') + ->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)') + ->and($content)->toContain('echo self::jsonFragment($customer);'); +}); + +it('maps batched period transaction rows to the legacy transaction response shape', function (): void { + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $method = $reflection->getMethod('constructTransactionObjectFromPeriodRow'); + $method->setAccessible(true); + + $transaction = $method->invokeArgs(null, [[ + 'id' => '42', + 'created_at' => '2026-04-10 12:34:56', + 'net_amount' => '123.50', + 'booked' => '1', + 'department_id' => '7', + 'customer_id' => '27983', + 'order_reference' => 'REF-42', + 'order_po' => 'PO-42', + 'order_notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'invoice_collection_id' => '314', + 'include_in_invoice_effective' => '0', + ]]); + + expect($transaction)->toMatchArray([ + 'id' => 42, + 'date' => '2026-04-10 12:34:56', + 'amount' => 123.5, + 'booked' => true, + 'department_id' => 7, + 'customer_number' => 27983, + 'reference' => 'REF-42', + 'po' => 'PO-42', + 'notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'excluded' => true, + 'invoice_collection_id' => 314, + 'queue_status' => null, + 'queue_job_id' => null, + ]); +}); + +it('uses batched period transactions and keyed customer maps in the main period route', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPeriodTransactionsForCustomersInDateRange(') + ->and($content)->toContain('private static function indexCustomersByNumber(array $customers): array') + ->and($content)->toContain('$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);') + ->and($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400)') + ->and($content)->not->toContain('getOrdersWithPossibleDuplicates($dateFrom, $dateTo)'); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php index 01c9440b..1adb56bc 100644 --- a/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php +++ b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php @@ -12,6 +12,16 @@ it('builds customer name cache payloads from economic data or display-name fallb 'Fallback Name' ))->toBe(['name' => 'Truckwash ApS']); + expect(customer_name_cache_payload_builder::build( + '{"customer":{"name":"Nested Truckwash ApS"}}', + 'Fallback Name' + ))->toBe(['name' => 'Nested Truckwash ApS']); + + expect(customer_name_cache_payload_builder::build( + ['customer_name' => 'Array Truckwash ApS'], + 'Fallback Name' + ))->toBe(['name' => 'Array Truckwash ApS']); + expect(customer_name_cache_payload_builder::build( null, 'Fallback Name' @@ -29,12 +39,28 @@ it('guards bulk customer-name cache writes behind a resolved payload check', fun expect($content)->not->toBeFalse(); expect($content)->toContain('use classes\customer_name_cache_payload_builder;'); + expect($content)->toContain('use classes\system_search_economic_customer_index;'); expect($content)->toContain('return customer_name_cache_payload_builder::build($cached_name, $fallback_name);'); expect($content)->toContain('$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);'); expect($content)->toContain('if ($cache_payload !== null) {'); expect($content)->toContain("\$this->cache('economic_customer_name', \$cache_payload, \$customer_number);"); }); +it('allows callers to resolve customer names without e-conomic fallback', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array'); + expect($content)->toContain('if (!$allowExternalFetch) {'); + expect($content)->toContain('$local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch);'); + expect($content)->toContain("\$customer_names[(string)\$customer_number] = \$local_cached_names[\$customer_number] ?? \$fallback_names[\$customer_number] ?? 'Unknown Customer';"); + expect($content)->toContain('private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain('private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain("\$cached_names = \$this->getCachedForMultipleObjects('economic_customer', array_values(\$user_ids_by_customer_number));"); + expect($content)->toContain('system_search_economic_customer_index::TABLE'); +}); + it('returns an empty customer name map without touching cache for empty input', function (): void { $users = new users_o(); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php index 5651ebd3..6a8ba391 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -98,6 +98,15 @@ it('declares a persistent OpenAI cache table for XL Vask automation', function ( ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); }); +it('declares cached amount summary columns for XL Vask usage logs', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('cached_total_net_amount') + ->toContain('cached_primary_product_name') + ->toContain('cached_amount_at'); +}); + it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { $usageItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php index 7e8d7074..69e642b8 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php @@ -1,6 +1,7 @@ and($log->ignored_reason)->toBe('Already handled in period review'); }); +it('calculates XL Vask amount summaries without hydrating order item previews', function (): void { + $summary = xlvask_usage_logs_o::calculateAmountSummaryFromWashItems(json_encode([ + [ + 'OriginalProductName' => 'Stor bil', + 'PriceIncVat' => '625.00', + 'Vat' => '125.00', + ], + [ + 'OriginalProductName' => 'Skylning', + 'PriceIncVat' => '125,00', + 'Vat' => '25,00', + ], + ], JSON_THROW_ON_ERROR)); + + expect($summary) + ->toMatchArray([ + 'total_net_amount' => 600.0, + 'primary_product_name' => 'Stor bil', + ]); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php index 8d0e031b..514ce4d3 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -14,3 +14,19 @@ it('exposes direct linked order metadata on XL Vask usage order rows', function ->and($route)->toContain("'linked_order_id' => \$linked_order_id") ->and($route)->toContain("'usage_log_id' => \$id"); }); + +it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log)') + ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') + ->and($route)->toContain('$tmp->setProperties($usage_log_payload)') + ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); +}); diff --git a/services/nginx/app/traits/economic_endpoint_t.php b/services/nginx/app/traits/economic_endpoint_t.php index 2d4cc8d6..a8ca6394 100644 --- a/services/nginx/app/traits/economic_endpoint_t.php +++ b/services/nginx/app/traits/economic_endpoint_t.php @@ -147,7 +147,8 @@ trait economic_endpoint_t CURLOPT_RETURNTRANSFER => true, //CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_CUSTOMREQUEST => $method,