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
+346 -35
View File
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\economic_transfer_queue_details_summary;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
@@ -561,7 +562,28 @@ class orderInvoicesRoute
$response->error('Invoice has already been booked', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
(int)$user->id,
'ADD_COLLECTED_INVOICE_ECONOMIC_FALLBACK',
'Processed collected invoice transfer synchronously because queue dependencies are unavailable'
);
$response->success([
'message' => 'Collected invoice export processed synchronously',
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
@@ -603,23 +625,26 @@ class orderInvoicesRoute
}
$this->ensureEconomicTransferQueueIsAvailable();
$statuses = [];
if (self::isParametersSet(['status'])) {
$status_raw = (string)self::getParameter('status');
$statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw))));
}
$limit = self::isParametersSet(['limit']) ? (int)self::getParameter('limit') : 50;
$offset = self::isParametersSet(['offset']) ? (int)self::getParameter('offset') : 0;
$statuses = $this->parseCollectedInvoiceQueueStatuses();
['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination();
$queue = new economic_transfer_queue();
$jobs = $queue->listJobs($statuses, $limit, $offset);
$filtered = array_values(array_filter($jobs, static function (array $job): bool {
return ($job['transfer_type'] ?? null) === economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT;
}));
$jobs = $queue->listJobs(
$statuses,
$limit,
$offset,
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
);
$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses);
$has_more = ($offset + count($jobs)) < $total_jobs;
$response->success([
'items' => $filtered,
'count' => count($filtered),
'items' => $this->withCollectedInvoiceQueueDetailsSummaryList($jobs),
'count' => count($jobs),
'total' => $total_jobs,
'limit' => $limit,
'offset' => $offset,
'has_more' => $has_more,
]);
},
[
@@ -636,16 +661,10 @@ class orderInvoicesRoute
}
$this->ensureEconomicTransferQueueIsAvailable();
self::requireParameters(['job_id']);
self::requireType((int)self::getParameter('job_id'), self::type_int());
self::requireMinValue((int)self::getParameter('job_id'), 1);
$job_id = $this->requireCollectedInvoiceQueueJobId();
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)self::getParameter('job_id'));
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
}
$response->success($job);
$response->success($this->withCollectedInvoiceQueueDetailsSummary($job));
},
[
'add_collected_invoice_economic' => 'Get queued collected invoice transfer job status.'
@@ -661,25 +680,24 @@ class orderInvoicesRoute
}
$this->ensureEconomicTransferQueueIsAvailable();
self::requireParameters(['job_id']);
self::requireType((int)self::getParameter('job_id'), self::type_int());
self::requireMinValue((int)self::getParameter('job_id'), 1);
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)self::getParameter('job_id'));
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
$job_id = $this->requireCollectedInvoiceQueueJobId();
$job = $this->requireCollectedInvoiceQueueJobById($job_id, true);
if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) {
$response->error('Collected invoice queue job reached max retry attempts', 409);
}
try {
$retried = $queue->retryJob((int)self::getParameter('job_id'));
$queue = new economic_transfer_queue();
$retried = $queue->retryJob($job_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
$message = trim((string)$e->getMessage());
$status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message);
$response->error('Failed to retry collected invoice queue job: ' . $message, $status_code);
}
$response->success([
'message' => 'Collected invoice queue job retried',
'job' => $retried,
'job' => $this->withCollectedInvoiceQueueDetailsSummary($retried),
]);
},
[
@@ -687,6 +705,47 @@ class orderInvoicesRoute
]
);
$this->post('/collected-invoices/economic/queue/run', function () {
global $response;
self::requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$limit = 10;
if (self::isParametersSet(['limit'])) {
$limit_raw = self::getParameter('limit');
if (!is_numeric($limit_raw)) {
$response->error('limit must be a positive integer', 400);
}
$limit = (int)$limit_raw;
}
$limit = max(1, min(10, $limit));
$queue = new economic_transfer_queue();
$batch = $this->runCollectedInvoiceQueueBatch($queue, $limit);
$result = (array)($batch['result'] ?? []);
$response->success([
'message' => (bool)($batch['fallback'] ?? false)
? 'Collected invoice queue batch processed using compatibility fallback'
: 'Collected invoice queue batch processed',
'processed' => (int)($result['processed'] ?? 0),
'completed' => (int)($result['completed'] ?? 0),
'failed' => (int)($result['failed'] ?? 0),
'jobs' => array_values(array_map('intval', (array)($result['jobs'] ?? []))),
'limit' => $limit,
'transfer_type' => economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
'fallback' => (bool)($batch['fallback'] ?? false),
]);
},
[
'add_collected_invoice_economic' => 'Run one queued collected invoice transfer batch immediately.'
]
);
/** Collected order invoices > Move multiple > Registration numbers > POST */
$this->post('/collected-invoices/move-multiple/registration-numbers', function () {
global $response;
@@ -941,7 +1000,28 @@ class orderInvoicesRoute
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, false);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
(int)$user->id,
'ADD_COLLECTED_INVOICE_STRIPE_FALLBACK',
'Processed Stripe collected invoice export synchronously because queue dependencies are unavailable'
);
$response->success([
'message' => 'Stripe collected invoice export processed synchronously',
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
@@ -1696,6 +1776,51 @@ class orderInvoicesRoute
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
}
/**
* Synchronous fallback when queue components are unavailable in this deployment.
* @throws Exception
*/
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
{
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
$collected_order_invoices->removeSpecialArrangements();
$collected_order_invoices->setAllItemsToBeIncludedInInvoice();
}
$collected_order_invoices->addToEconomic();
} else {
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
if ($collected_order_invoices->isDraftExisting()) {
throw new Exception('Invoice draft already exists in E-Conomic');
}
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
$collected_order_invoices->addToEconomic(true);
}
return $collected_order_invoices->asArray();
}
private function isEconomicTransferQueueAvailable(): bool
{
return class_exists('\\classes\\economic_transfer_executor')
@@ -1712,6 +1837,192 @@ class orderInvoicesRoute
}
}
private function parseCollectedInvoiceQueueStatuses(): array
{
global $response;
if (!self::isParametersSet(['status'])) {
return [];
}
$status_raw = (string)self::getParameter('status');
$statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw))));
if ($statuses === []) {
return [];
}
$allowed = [
economic_transfer_queue::STATUS_QUEUED,
economic_transfer_queue::STATUS_PROCESSING,
economic_transfer_queue::STATUS_COMPLETED,
economic_transfer_queue::STATUS_FAILED,
];
$normalized = [];
foreach ($statuses as $status) {
$status = strtoupper($status);
if (!in_array($status, $allowed, true)) {
$response->error('status must contain only: ' . implode(', ', $allowed), 400);
}
$normalized[] = $status;
}
return array_values(array_unique($normalized));
}
private function parseCollectedInvoiceQueuePagination(): array
{
global $response;
$limit = 50;
if (self::isParametersSet(['limit'])) {
$limit_raw = self::getParameter('limit');
if (!is_numeric($limit_raw)) {
$response->error('limit must be between 1 and 500', 400);
}
$limit = (int)$limit_raw;
if ($limit < 1 || $limit > 500) {
$response->error('limit must be between 1 and 500', 400);
}
}
$offset = 0;
if (self::isParametersSet(['offset'])) {
$offset_raw = self::getParameter('offset');
if (!is_numeric($offset_raw)) {
$response->error('offset must be at least 0', 400);
}
$offset = (int)$offset_raw;
if ($offset < 0) {
$response->error('offset must be at least 0', 400);
}
}
return [
'limit' => $limit,
'offset' => $offset,
];
}
private function requireCollectedInvoiceQueueJobId(): int
{
global $response;
if (!self::isParametersSet(['job_id'])) {
$response->error('job_id is required', 400);
}
$job_id = self::getParameter('job_id');
if (!is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id must be a positive integer', 400);
}
return (int)$job_id;
}
private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array
{
global $response;
$queue = new economic_transfer_queue();
$job = $queue->getJobById($job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
}
if ($mustBeFailed && (string)($job['status'] ?? '') !== economic_transfer_queue::STATUS_FAILED) {
$response->error('Collected invoice queue job can only be retried when status is FAILED', 409);
}
return $job;
}
private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int
{
$normalized = strtolower(trim($message));
if ($normalized === '') {
return 400;
}
if (str_contains($normalized, 'not found')) {
return 404;
}
$is_conflict = str_contains($normalized, 'only failed jobs can be retried')
|| str_contains($normalized, 'can only be retried when status is failed')
|| str_contains($normalized, 'max retry attempts')
|| str_contains($normalized, 'failed to retry queue job');
return $is_conflict ? 409 : 400;
}
private function runCollectedInvoiceQueueBatch(economic_transfer_queue $queue, int $limit): array
{
if (method_exists($queue, 'processPendingByTransferType')) {
return [
'result' => $queue->processPendingByTransferType(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
$limit
),
'fallback' => false,
];
}
return [
'result' => $queue->processPending($limit),
'fallback' => true,
];
}
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int
{
global $db;
if (method_exists($queue, 'countJobs')) {
return max(0, (int)$queue->countJobs(
$statuses,
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
));
}
$conditions = [
"transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'",
];
if ($statuses !== []) {
$escaped_statuses = array_map(static function (string $status) use ($db): string {
return "'" . $db->escape_string(strtoupper(trim($status))) . "'";
}, $statuses);
$conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')';
}
$sql = 'SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE ' . implode(' AND ', $conditions);
$result = $db->query($sql);
if (!$result instanceof \mysqli_result) {
return 0;
}
$row = $result->fetch_assoc();
if (!is_array($row) || !isset($row['total'])) {
return 0;
}
return max(0, (int)$row['total']);
}
private function withCollectedInvoiceQueueDetailsSummary(array $job): array
{
$job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job);
return $job;
}
private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array
{
return array_values(array_map(function (array $job): array {
return $this->withCollectedInvoiceQueueDetailsSummary($job);
}, $jobs));
}
/**
* @param $collected_order_invoice
* @param users_o $users