Add unit tests for invoicing period pagination, normalization, and filtering logic

- Implemented `InvoicingPeriodPaginationTest` for testing period pagination modes, normalization of options, search functionality, and visibility filters.
- Added comprehensive tests to validate scenarios such as active period views, exact counts, and customer-card level search.
- Improved cURL timeout settings with `CURLOPT_CONNECTTIMEOUT` and `CURLOPT_TIMEOUT` adjustments.
- Introduced and documented helper classes/methods for local caching, pagination response structure, and customer name retrieval.
This commit is contained in:
Jeppe Bundgaard
2026-05-12 03:37:47 +02:00
parent 70080086da
commit 6c40810caf
21 changed files with 2261 additions and 158 deletions
@@ -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;
}
}
@@ -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;
@@ -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);
@@ -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;
@@ -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,
+135
View File
@@ -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<int, array<int, array<string,mixed>>>
* @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
+157 -5
View File
@@ -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<string, int> 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<int,string>
*/
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<int,string>
*/
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<int,string>
*/
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<int, string> Map of cashier id => display name
@@ -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'
+672 -111
View File
@@ -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<int, bool>
*/
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<int,array<string,mixed>> $customers
* @return array<int,array<string,mixed>>
*/
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'];
@@ -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);
@@ -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'
@@ -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');
}
});
@@ -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 = []');
});
@@ -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');
});
@@ -0,0 +1,388 @@
<?php
app_require('classes/response.php');
app_require('routes/InvoicingPeriodRoute.php');
use classes\response;
use routes\InvoicingPeriodRoute;
function invoicing_period_pagination_invoke(string $method, array $args = []): mixed
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->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);
});
@@ -1,5 +1,9 @@
<?php
app_require('routes/InvoicingPeriodRoute.php');
use routes\InvoicingPeriodRoute;
it('requires superuser permission for invoicing period distribution all endpoint', function (): void {
$routeFile = app_path('routes/InvoicingPeriodRoute.php');
$content = file_get_contents($routeFile);
@@ -18,3 +22,86 @@ it('uses shared date-range normalization across invoicing period endpoints', fun
expect(substr_count((string)$content, 'requireAndNormalizeDateRange()'))->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)');
});
@@ -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();
@@ -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']],
@@ -1,6 +1,7 @@
<?php
use helpers\xlvask_usage_log;
use objects\xlvask_usage_logs_o;
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
$log = new xlvask_usage_log();
@@ -36,3 +37,23 @@ it('accepts persisted ignore metadata from xlvask usage log rows', function ():
->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',
]);
});
@@ -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']");
});
@@ -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,