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