Add unit and integration tests for collected invoice queue handling, route hardening, lifecycle validation, and manual batch processing logic.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user