Add unit and integration tests for collected invoice queue handling, route hardening, lifecycle validation, and manual batch processing logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-08 15:53:22 +02:00
parent c5cd42be7f
commit 653680376a
26 changed files with 2974 additions and 428 deletions
@@ -3,11 +3,13 @@
namespace routes;
use classes\authentication;
use classes\economic_transfer_queue;
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
use classes\invoicing_period_utils;
use classes\slack;
use Exception;
use objects\collected_order_invoices_o;
use objects\customer_vehicles_o;
use objects\logs_o;
use objects\order_items_o;
@@ -1129,6 +1131,14 @@ class InvoicingPeriodRoute
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions);
}, 'possible_duplicates');
$queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) {
return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo);
}, 'active_collected_invoice_queue_overlay');
$types = self::applyCollectedInvoiceQueueOverlayToPeriodTypes(
$types,
$queueOverlay['by_collection_id'] ?? [],
$queueOverlay['by_customer_number'] ?? [],
);
return [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
@@ -1300,6 +1310,7 @@ class InvoicingPeriodRoute
}, $transactions),
'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action),
'meta' => $meta ?? [],
'queue' => self::getDefaultQueueSummary(),
];
}
@@ -1309,6 +1320,7 @@ class InvoicingPeriodRoute
private static function constructTransactionObject(orders_o $transaction): array
{
$departmentId = (int)$transaction->department_id->value();
$invoiceCollectionId = (int)$transaction->invoice_collection_id->value();
return [
'id' => $transaction->id,
'date' => $transaction->created_at->value(),
@@ -1316,6 +1328,9 @@ class InvoicingPeriodRoute
'booked' => $transaction->isBooked(true),
'department_id' => $departmentId,
'excluded' => self::isDepartmentExcludedFromInvoicingCached($departmentId),
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
'queue_status' => null,
'queue_job_id' => null,
];
}
@@ -1335,6 +1350,247 @@ class InvoicingPeriodRoute
return false;
}
private static function getDefaultQueueSummary(): array
{
return [
'has_active_job' => false,
'statuses' => [],
'invoice_collection_ids' => [],
'is_action_blocked' => false,
];
}
private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array
{
$overlay = [
'by_collection_id' => [],
'by_customer_number' => [],
];
try {
$queue = new economic_transfer_queue();
$offset = 0;
$limit = 250;
do {
$jobs = $queue->listJobs(
[
economic_transfer_queue::STATUS_QUEUED,
economic_transfer_queue::STATUS_PROCESSING,
],
$limit,
$offset,
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
);
foreach ($jobs as $job) {
$normalizedJob = self::normalizeCollectedInvoiceQueueJob($job, $dateFrom, $dateTo);
if ($normalizedJob === null || empty($normalizedJob['is_period_relevant'])) {
continue;
}
$invoiceCollectionId = (int)($normalizedJob['invoice_collection_id'] ?? 0);
if ($invoiceCollectionId > 0 && !isset($overlay['by_collection_id'][$invoiceCollectionId])) {
$overlay['by_collection_id'][$invoiceCollectionId] = $normalizedJob;
}
$customerNumber = (int)($normalizedJob['customer_number'] ?? 0);
if ($customerNumber > 0) {
$overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? [];
$overlay['by_customer_number'][$customerNumber][] = $normalizedJob;
}
}
$offset += count($jobs);
} while (count($jobs) === $limit);
} catch (\Throwable) {
return $overlay;
}
return $overlay;
}
private static function normalizeCollectedInvoiceQueueJob(array $job, string $dateFrom, string $dateTo): ?array
{
$invoiceCollectionId = (int)(
$job['payload']['collected_invoice_id']
?? $job['collected_invoice_id']
?? $job['invoice_collection_id']
?? 0
);
if ($invoiceCollectionId < 1) {
return null;
}
try {
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$invoiceCollection->exists()) {
return null;
}
$customerNumber = (int)$invoiceCollection->customer_number->value();
$closedAt = (string)$invoiceCollection->closed_at->value();
$createdAt = (string)$invoiceCollection->created_at->value();
return [
'queue_job_id' => (int)($job['id'] ?? $job['queue_job_id'] ?? 0),
'queue_status' => (string)($job['status'] ?? $job['queue_status'] ?? ''),
'invoice_collection_id' => $invoiceCollectionId,
'customer_number' => $customerNumber,
'created_at' => $createdAt,
'closed_at' => $closedAt,
'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod($createdAt, $closedAt, $dateFrom, $dateTo),
];
} catch (\Throwable) {
return null;
}
}
private static function isInvoiceCollectionRelevantToPeriod(
?string $createdAt,
?string $closedAt,
string $dateFrom,
string $dateTo
): bool {
return self::isTimestampWithinPeriod($closedAt, $dateFrom, $dateTo)
|| self::isTimestampWithinPeriod($createdAt, $dateFrom, $dateTo);
}
private static function isTimestampWithinPeriod(?string $timestamp, string $dateFrom, string $dateTo): bool
{
if (empty($timestamp)) {
return false;
}
$normalizedTimestamp = substr((string)$timestamp, 0, 10);
return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo;
}
private static function applyCollectedInvoiceQueueOverlayToPeriodTypes(
array $types,
array $queueJobsByCollectionId,
array $queueJobsByCustomerNumber
): array {
foreach ($types as $type => $customers) {
if (!is_array($customers)) {
continue;
}
$types[$type] = array_map(function ($customer) use ($queueJobsByCollectionId, $queueJobsByCustomerNumber) {
if (!is_array($customer)) {
return $customer;
}
return self::applyCollectedInvoiceQueueOverlayToCustomer(
$customer,
$queueJobsByCollectionId,
$queueJobsByCustomerNumber
);
}, $customers);
}
return $types;
}
private static function applyCollectedInvoiceQueueOverlayToCustomer(
array $customer,
array $queueJobsByCollectionId,
array $queueJobsByCustomerNumber
): array {
$customerNumber = (int)($customer['customer_number'] ?? 0);
$activeCustomerJobs = array_values(array_filter(
$queueJobsByCustomerNumber[$customerNumber] ?? [],
static function ($job): bool {
return !empty($job['is_period_relevant']);
}
));
$transactions = [];
$actionableTransactionCount = 0;
$queuedActionableTransactionCount = 0;
foreach (($customer['transactions'] ?? []) as $transaction) {
if (!is_array($transaction)) {
continue;
}
$transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0
? (int)$transaction['invoice_collection_id']
: null;
$transaction['queue_status'] = $transaction['queue_status'] ?? null;
$transaction['queue_job_id'] = $transaction['queue_job_id'] ?? null;
$isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false);
if ($isActionable) {
$actionableTransactionCount++;
}
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
if ($invoiceCollectionId > 0 && isset($queueJobsByCollectionId[$invoiceCollectionId])) {
$queueJob = $queueJobsByCollectionId[$invoiceCollectionId];
$transaction['queue_status'] = $queueJob['queue_status'] ?? null;
$transaction['queue_job_id'] = $queueJob['queue_job_id'] ?? null;
if ($isActionable) {
$queuedActionableTransactionCount++;
}
}
$transactions[] = $transaction;
}
$customerLevelQueueBlock = false;
if ($actionableTransactionCount === 0 && self::customerSupportsCustomerLevelQueueBlocking($customer) && !empty($activeCustomerJobs)) {
$customerLevelQueueBlock = true;
}
$isActionBlocked = false;
if ($actionableTransactionCount > 0) {
$isActionBlocked = $queuedActionableTransactionCount > 0
&& $queuedActionableTransactionCount === $actionableTransactionCount;
} elseif ($customerLevelQueueBlock) {
$isActionBlocked = true;
}
$statuses = [];
$invoiceCollectionIds = [];
foreach ($activeCustomerJobs as $job) {
$status = (string)($job['queue_status'] ?? '');
if ($status !== '' && !in_array($status, $statuses, true)) {
$statuses[] = $status;
}
$invoiceCollectionId = (int)($job['invoice_collection_id'] ?? 0);
if ($invoiceCollectionId > 0 && !in_array($invoiceCollectionId, $invoiceCollectionIds, true)) {
$invoiceCollectionIds[] = $invoiceCollectionId;
}
}
$customer['transactions'] = $transactions;
$customer['queue'] = [
'has_active_job' => !empty($activeCustomerJobs),
'statuses' => $statuses,
'invoice_collection_ids' => $invoiceCollectionIds,
'is_action_blocked' => $isActionBlocked,
];
if ($isActionBlocked) {
$customer['requires_action'] = false;
}
return $customer;
}
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
{
$meta = $customer['meta'] ?? [];
return isset($meta['fixed_pricing'])
|| isset($meta['wash_subscription'])
|| !empty($meta['has_vehicle_subscription']);
}
/**
* @throws Exception
*/
@@ -1350,11 +1606,24 @@ class InvoicingPeriodRoute
$subscriptions = [];
/** @var int $customer_number */
foreach ( $customer_numbers as $customer_number ) {
$subscriptions[] = (self::getCustomerFromList((int)$customer_number, $customersWithTransactions)) ?? self::constructCustomerObject(
$customer = self::getCustomerFromList((int)$customer_number, $customersWithTransactions);
if ($customer !== null) {
$customer['meta'] = array_merge($customer['meta'] ?? [], [
'has_vehicle_subscription' => true,
]);
$subscriptions[] = $customer;
continue;
}
$subscriptions[] = self::constructCustomerObject(
(int)$customer_number,
(new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer',
[],
true
true,
null,
[
'has_vehicle_subscription' => true,
]
);
}
return $subscriptions;