Add unit tests for InvoicingPeriodDraftOverlay and reference suggestion logic, including fake DB integration and aggregation methods
- Implemented `InvoicingPeriodDraftOverlayTest` with coverage for blocking and permitting invoicing actions based on draft states, transactions, and metadata. - Created `ReferenceSuggestionsApiTest` to validate ranked and filtered suggestions across bookings, orders, and vehicles with varied match relevance, context, and frequency. - Added `order_reference_suggestions_service` class, including query methods, normalization utilities, and aggregation logic for reference suggestions. - Enhanced query handling in `InvoicingPeriodDraftOverlayFakeDb` to validate SQL constraints and column cache resets in overlapping invoicing contexts.
This commit is contained in:
+213
@@ -6165,6 +6165,34 @@ paths:
|
|||||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/monitor:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs
|
||||||
|
operationId: monitorCollectedInvoiceEconomicQueueJobs
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 50
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queue monitor state retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueMonitorResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/queue/status:
|
/collected-invoices/economic/queue/status:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -6224,6 +6252,57 @@ paths:
|
|||||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/dismiss:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Clear one completed or failed collected-invoice queue job for the current user
|
||||||
|
operationId: dismissCollectedInvoiceEconomicQueueJob
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [job_id]
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queue job cleared
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueDismissResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/dismiss-terminal:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Clear all visible completed or failed collected-invoice queue jobs for the current user
|
||||||
|
operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Terminal queue jobs cleared
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/queue/run:
|
/collected-invoices/economic/queue/run:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -13881,6 +13960,140 @@ components:
|
|||||||
- meta
|
- meta
|
||||||
- includes
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueMonitorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
jobs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||||
|
counts:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
queued:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
in_progress:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
failed:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
completed:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
required:
|
||||||
|
- queued
|
||||||
|
- in_progress
|
||||||
|
- failed
|
||||||
|
- completed
|
||||||
|
- total
|
||||||
|
progress_percent:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
required:
|
||||||
|
- jobs
|
||||||
|
- counts
|
||||||
|
- progress_percent
|
||||||
|
- limit
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueDismissResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
job:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
- job
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueDismissTerminalResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
dismissed_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
- dismissed_count
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
CollectedInvoiceEconomicCompareResponse:
|
CollectedInvoiceEconomicCompareResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||||
|
|||||||
@@ -154,6 +154,132 @@ class economic_transfer_queue
|
|||||||
return max(0, (int)$row['total']);
|
return max(0, (int)$row['total']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$user_id = max(0, $user_id);
|
||||||
|
$limit = max(1, min(100, $limit));
|
||||||
|
try {
|
||||||
|
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||||
|
? $this->validateTransferType($transfer_type)
|
||||||
|
: null;
|
||||||
|
} catch (Exception) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$transfer_condition = '';
|
||||||
|
if ($normalized_transfer_type !== null) {
|
||||||
|
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT q.*
|
||||||
|
FROM economic_transfer_queue_jobs q
|
||||||
|
LEFT JOIN economic_transfer_queue_job_dismissals d
|
||||||
|
ON d.queue_job_id = q.id
|
||||||
|
AND d.user_id = $user_id
|
||||||
|
AND d.dismissed_status = q.status
|
||||||
|
WHERE 1 = 1
|
||||||
|
$transfer_condition
|
||||||
|
AND (
|
||||||
|
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||||
|
OR d.queue_job_id IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END,
|
||||||
|
q.id DESC
|
||||||
|
LIMIT $limit";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result instanceof mysqli_result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$jobs[] = $this->normalizeJobRow($row);
|
||||||
|
}
|
||||||
|
return $jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function dismissTerminalJobForUser(int $job_id, int $user_id): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$job_id = max(0, $job_id);
|
||||||
|
$user_id = max(0, $user_id);
|
||||||
|
if ($job_id < 1 || $user_id < 1) {
|
||||||
|
throw new Exception('Queue job and user are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $this->getJobById($job_id);
|
||||||
|
if ($job === null) {
|
||||||
|
throw new Exception('Queue job not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = strtoupper((string)($job['status'] ?? ''));
|
||||||
|
if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||||
|
throw new Exception('Only completed or failed queue jobs can be dismissed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
|
||||||
|
VALUES (?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"
|
||||||
|
);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new Exception('Failed to prepare queue dismissal statement');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('iis', $job_id, $user_id, $status);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
$stmt->close();
|
||||||
|
throw new Exception('Failed to dismiss queue job');
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$user_id = max(0, $user_id);
|
||||||
|
if ($user_id < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||||
|
? $this->validateTransferType($transfer_type)
|
||||||
|
: null;
|
||||||
|
} catch (Exception) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transfer_condition = '';
|
||||||
|
if ($normalized_transfer_type !== null) {
|
||||||
|
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
|
||||||
|
SELECT q.id, $user_id, q.status, NOW()
|
||||||
|
FROM economic_transfer_queue_jobs q
|
||||||
|
LEFT JOIN economic_transfer_queue_job_dismissals d
|
||||||
|
ON d.queue_job_id = q.id
|
||||||
|
AND d.user_id = $user_id
|
||||||
|
AND d.dismissed_status = q.status
|
||||||
|
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||||
|
$transfer_condition
|
||||||
|
AND d.queue_job_id IS NULL
|
||||||
|
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
||||||
|
$db->query($sql);
|
||||||
|
return max(0, (int)($db->affected_rows ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -193,6 +319,8 @@ class economic_transfer_queue
|
|||||||
throw new Exception('Failed to retry queue job');
|
throw new Exception('Failed to retry queue job');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->clearDismissalsForJob($job_id);
|
||||||
|
|
||||||
$job = $this->getJobById($job_id);
|
$job = $this->getJobById($job_id);
|
||||||
if ($job === null) {
|
if ($job === null) {
|
||||||
throw new Exception('Retry updated job could not be loaded');
|
throw new Exception('Retry updated job could not be loaded');
|
||||||
@@ -480,6 +608,18 @@ class economic_transfer_queue
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clearDismissalsForJob(int $job_id): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$job_id = max(0, $job_id);
|
||||||
|
if ($job_id < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release jobs stuck in PROCESSING due to crashes or killed workers.
|
* Release jobs stuck in PROCESSING due to crashes or killed workers.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,12 +27,20 @@ class economic_transfer_queue_details_summary
|
|||||||
'customer_number' => self::toPositiveInt(
|
'customer_number' => self::toPositiveInt(
|
||||||
$result['customer_number']
|
$result['customer_number']
|
||||||
?? $result['user']['customer_number']
|
?? $result['user']['customer_number']
|
||||||
|
?? $payload['customer_number']
|
||||||
|
?? $payload['customer']['customer_number']
|
||||||
?? null
|
?? null
|
||||||
),
|
),
|
||||||
'name' => self::toNonEmptyString(
|
'name' => self::toNonEmptyString(
|
||||||
$result['customer_name']
|
$result['customer_name']
|
||||||
|
?? $result['user']['customer_name']
|
||||||
|
?? $result['user']['display_name']
|
||||||
?? $result['user']['name']
|
?? $result['user']['name']
|
||||||
?? $result['user']['company_name']
|
?? $result['user']['company_name']
|
||||||
|
?? $payload['customer_name']
|
||||||
|
?? $payload['customer']['customer_name']
|
||||||
|
?? $payload['customer']['display_name']
|
||||||
|
?? $payload['customer']['name']
|
||||||
?? null
|
?? null
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ class economic_transfer_queue_schema_bootstrap
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals (
|
||||||
|
queue_job_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
dismissed_status VARCHAR(32) NOT NULL,
|
||||||
|
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (queue_job_id, user_id),
|
||||||
|
INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status),
|
||||||
|
INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class order_reference_suggestions_service
|
||||||
|
{
|
||||||
|
private const DEFAULT_LIMIT = 10;
|
||||||
|
private const MAX_LIMIT = 25;
|
||||||
|
private const MAX_SOURCE_ROWS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, bool>
|
||||||
|
*/
|
||||||
|
private array $columnExistsCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* search?: mixed,
|
||||||
|
* department_id?: mixed,
|
||||||
|
* customer_id?: mixed,
|
||||||
|
* reg_1?: mixed,
|
||||||
|
* reg_2?: mixed,
|
||||||
|
* reg_3?: mixed,
|
||||||
|
* limit?: mixed
|
||||||
|
* } $criteria
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function suggest(array $criteria): array
|
||||||
|
{
|
||||||
|
$departmentId = $this->toPositiveInt($criteria['department_id'] ?? null);
|
||||||
|
if ($departmentId === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $this->normalizeText($criteria['search'] ?? '');
|
||||||
|
$customerId = $this->toPositiveInt($criteria['customer_id'] ?? null);
|
||||||
|
$plates = $this->normalizePlates([
|
||||||
|
$criteria['reg_1'] ?? '',
|
||||||
|
$criteria['reg_2'] ?? '',
|
||||||
|
$criteria['reg_3'] ?? '',
|
||||||
|
]);
|
||||||
|
$limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
...$this->fetchBookingRows($departmentId, $search),
|
||||||
|
...$this->fetchOrderRows($departmentId, $search),
|
||||||
|
...$this->fetchVehicleRows($customerId, $plates, $search),
|
||||||
|
];
|
||||||
|
|
||||||
|
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
||||||
|
usort($suggestions, [$this, 'sortSuggestions']);
|
||||||
|
|
||||||
|
return array_slice($suggestions, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchBookingRows(int $departmentId, string $search): array
|
||||||
|
{
|
||||||
|
$where = [
|
||||||
|
'department = :department_id',
|
||||||
|
'reference IS NOT NULL',
|
||||||
|
"TRIM(reference) <> ''",
|
||||||
|
];
|
||||||
|
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
|
||||||
|
array_unshift($where, 'deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
$params = ['department_id' => $departmentId];
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = 'LOWER(reference) LIKE :search';
|
||||||
|
$params['search'] = '%' . $this->lower($search) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
'booking' AS source,
|
||||||
|
id AS origin_id,
|
||||||
|
TRIM(reference) AS reference,
|
||||||
|
datetime AS source_created_at,
|
||||||
|
datetime AS used_at,
|
||||||
|
customer_number AS customer_id,
|
||||||
|
department AS department_id,
|
||||||
|
reg_1,
|
||||||
|
reg_2,
|
||||||
|
reg_3
|
||||||
|
FROM order_bookings
|
||||||
|
WHERE " . implode(' AND ', $where) . "
|
||||||
|
ORDER BY datetime DESC, id DESC
|
||||||
|
LIMIT :source_limit";
|
||||||
|
|
||||||
|
return $this->fetchRows($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchOrderRows(int $departmentId, string $search): array
|
||||||
|
{
|
||||||
|
$where = [
|
||||||
|
'department_id = :department_id',
|
||||||
|
'reference IS NOT NULL',
|
||||||
|
"TRIM(reference) <> ''",
|
||||||
|
];
|
||||||
|
if ($this->tableHasColumn('orders', 'deleted_at')) {
|
||||||
|
array_unshift($where, 'deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
$params = ['department_id' => $departmentId];
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = 'LOWER(reference) LIKE :search';
|
||||||
|
$params['search'] = '%' . $this->lower($search) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
'order' AS source,
|
||||||
|
id AS origin_id,
|
||||||
|
TRIM(reference) AS reference,
|
||||||
|
created_at AS source_created_at,
|
||||||
|
created_at AS used_at,
|
||||||
|
customer_id,
|
||||||
|
department_id,
|
||||||
|
reg_1,
|
||||||
|
reg_2,
|
||||||
|
reg_3
|
||||||
|
FROM orders
|
||||||
|
WHERE " . implode(' AND ', $where) . "
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :source_limit";
|
||||||
|
|
||||||
|
return $this->fetchRows($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $plates
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
|
||||||
|
{
|
||||||
|
$contextWhere = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($customerId !== null) {
|
||||||
|
$contextWhere[] = 'customer_id = :customer_id';
|
||||||
|
$params['customer_id'] = $customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($plates as $index => $plate) {
|
||||||
|
$key = 'plate_' . $index;
|
||||||
|
$contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key";
|
||||||
|
$params[$key] = $plate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contextWhere === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = [
|
||||||
|
'reference IS NOT NULL',
|
||||||
|
"TRIM(reference) <> ''",
|
||||||
|
'(' . implode(' OR ', $contextWhere) . ')',
|
||||||
|
];
|
||||||
|
if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) {
|
||||||
|
array_unshift($where, 'deleted_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = 'LOWER(reference) LIKE :search';
|
||||||
|
$params['search'] = '%' . $this->lower($search) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
'vehicle' AS source,
|
||||||
|
id AS origin_id,
|
||||||
|
TRIM(reference) AS reference,
|
||||||
|
created_at AS source_created_at,
|
||||||
|
created_at AS used_at,
|
||||||
|
customer_id,
|
||||||
|
NULL AS department_id,
|
||||||
|
reg AS reg_1,
|
||||||
|
'' AS reg_2,
|
||||||
|
'' AS reg_3
|
||||||
|
FROM customer_vehicles
|
||||||
|
WHERE " . implode(' AND ', $where) . "
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :source_limit";
|
||||||
|
|
||||||
|
return $this->fetchRows($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchRows(string $sql, array $params): array
|
||||||
|
{
|
||||||
|
$pdo = db::getPDO();
|
||||||
|
$statement = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
$statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
return is_array($rows) ? $rows : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tableHasColumn(string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$cacheKey = $table . '.' . $column;
|
||||||
|
if (array_key_exists($cacheKey, $this->columnExistsCache)) {
|
||||||
|
return $this->columnExistsCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db::getPDO();
|
||||||
|
$statement = $pdo->prepare(
|
||||||
|
'SELECT COUNT(*) AS total
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table_name
|
||||||
|
AND COLUMN_NAME = :column_name'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':table_name', $table, PDO::PARAM_STR);
|
||||||
|
$statement->bindValue(':column_name', $column, PDO::PARAM_STR);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0;
|
||||||
|
return $this->columnExistsCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @param array<int, string> $plates
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$reference = $this->normalizeText($row['reference'] ?? '');
|
||||||
|
if ($reference === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $this->lower($reference);
|
||||||
|
if (!isset($groups[$key])) {
|
||||||
|
$groups[$key] = [
|
||||||
|
'reference' => $reference,
|
||||||
|
'rows' => [],
|
||||||
|
'usage_count' => 0,
|
||||||
|
'last_used_at' => null,
|
||||||
|
'context_boost' => 0,
|
||||||
|
'section' => 'other',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$section = $this->contextSection($row, $customerId, $plates);
|
||||||
|
$groups[$key]['usage_count']++;
|
||||||
|
$groups[$key]['rows'][] = $row;
|
||||||
|
$groups[$key]['last_used_at'] = $this->maxDate(
|
||||||
|
$groups[$key]['last_used_at'],
|
||||||
|
$this->normalizeDate($row['used_at'] ?? null)
|
||||||
|
);
|
||||||
|
$groups[$key]['context_boost'] = max(
|
||||||
|
$groups[$key]['context_boost'],
|
||||||
|
$this->contextBoost($row, $customerId, $plates)
|
||||||
|
);
|
||||||
|
$groups[$key]['section'] = $this->bestSection(
|
||||||
|
(string)$groups[$key]['section'],
|
||||||
|
$section
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggestions = [];
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$bestRow = $this->bestOriginRow($group['rows']);
|
||||||
|
if ($bestRow === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = (string)($bestRow['source'] ?? 'order');
|
||||||
|
$usageCount = (int)$group['usage_count'];
|
||||||
|
$score = $this->matchScore((string)$group['reference'], $search)
|
||||||
|
+ (int)$group['context_boost']
|
||||||
|
+ $this->sectionScore((string)$group['section'])
|
||||||
|
+ $this->sourceScore($source)
|
||||||
|
+ min($usageCount, 20) * 5;
|
||||||
|
|
||||||
|
$suggestions[] = [
|
||||||
|
'source' => $source,
|
||||||
|
'section' => (string)$group['section'],
|
||||||
|
'reference' => (string)$group['reference'],
|
||||||
|
'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null),
|
||||||
|
'last_used_at' => $group['last_used_at'],
|
||||||
|
'usage_count' => $usageCount,
|
||||||
|
'origin_id' => (int)($bestRow['origin_id'] ?? 0),
|
||||||
|
'score' => $score,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function bestOriginRow(array $rows): ?array
|
||||||
|
{
|
||||||
|
usort($rows, function (array $left, array $right): int {
|
||||||
|
$sourceCompare = $this->sourceScore((string)($right['source'] ?? ''))
|
||||||
|
<=> $this->sourceScore((string)($left['source'] ?? ''));
|
||||||
|
if ($sourceCompare !== 0) {
|
||||||
|
return $sourceCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateCompare = strcmp(
|
||||||
|
(string)$this->normalizeDate($right['source_created_at'] ?? null),
|
||||||
|
(string)$this->normalizeDate($left['source_created_at'] ?? null)
|
||||||
|
);
|
||||||
|
if ($dateCompare !== 0) {
|
||||||
|
return $dateCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sortSuggestions(array $left, array $right): int
|
||||||
|
{
|
||||||
|
$scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0));
|
||||||
|
if ($scoreCompare !== 0) {
|
||||||
|
return $scoreCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0));
|
||||||
|
if ($usageCompare !== 0) {
|
||||||
|
return $usageCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionCompare = $this->sectionScore((string)($right['section'] ?? ''))
|
||||||
|
<=> $this->sectionScore((string)($left['section'] ?? ''));
|
||||||
|
if ($sectionCompare !== 0) {
|
||||||
|
return $sectionCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? ''));
|
||||||
|
if ($dateCompare !== 0) {
|
||||||
|
return $dateCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? ''));
|
||||||
|
if ($referenceCompare !== 0) {
|
||||||
|
return $referenceCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $plates
|
||||||
|
*/
|
||||||
|
private function contextBoost(array $row, ?int $customerId, array $plates): int
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
|
||||||
|
$score += 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->rowMatchesAnyPlate($row, $plates)) {
|
||||||
|
$score += 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $plates
|
||||||
|
*/
|
||||||
|
private function contextSection(array $row, ?int $customerId, array $plates): string
|
||||||
|
{
|
||||||
|
if ($this->rowMatchesAnyPlate($row, $plates)) {
|
||||||
|
return 'this_vehicle';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
|
||||||
|
return 'other_customer_vehicle';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $plates
|
||||||
|
*/
|
||||||
|
private function rowMatchesAnyPlate(array $row, array $plates): bool
|
||||||
|
{
|
||||||
|
$rowPlates = $this->normalizePlates([
|
||||||
|
$row['reg_1'] ?? '',
|
||||||
|
$row['reg_2'] ?? '',
|
||||||
|
$row['reg_3'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $plates !== [] && array_intersect($plates, $rowPlates) !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchScore(string $reference, string $search): int
|
||||||
|
{
|
||||||
|
if ($search === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referenceKey = $this->lower($reference);
|
||||||
|
$searchKey = $this->lower($search);
|
||||||
|
|
||||||
|
if ($referenceKey === $searchKey) {
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($referenceKey, $searchKey)) {
|
||||||
|
return 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($referenceKey, $searchKey)) {
|
||||||
|
return 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceScore(string $source): int
|
||||||
|
{
|
||||||
|
return match ($source) {
|
||||||
|
'booking' => 30,
|
||||||
|
'order' => 20,
|
||||||
|
'vehicle' => 10,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sectionScore(string $section): int
|
||||||
|
{
|
||||||
|
return match ($section) {
|
||||||
|
'this_vehicle' => 40,
|
||||||
|
'other_customer_vehicle' => 20,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bestSection(string $left, string $right): string
|
||||||
|
{
|
||||||
|
return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clampLimit(mixed $value): int
|
||||||
|
{
|
||||||
|
$limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT;
|
||||||
|
return max(1, min($limit, self::MAX_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toPositiveInt(mixed $value): ?int
|
||||||
|
{
|
||||||
|
$parsed = filter_var($value, FILTER_VALIDATE_INT);
|
||||||
|
return is_int($parsed) && $parsed > 0 ? $parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeText(mixed $value): string
|
||||||
|
{
|
||||||
|
return trim((string)($value ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lower(string $value): string
|
||||||
|
{
|
||||||
|
return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $values
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizePlates(array $values): array
|
||||||
|
{
|
||||||
|
$plates = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? '')));
|
||||||
|
if ($plate !== '') {
|
||||||
|
$plates[] = $plate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($plates));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDate(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$date = trim((string)($value ?? ''));
|
||||||
|
return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maxDate(?string $left, ?string $right): ?string
|
||||||
|
{
|
||||||
|
if ($left === null) {
|
||||||
|
return $right;
|
||||||
|
}
|
||||||
|
if ($right === null) {
|
||||||
|
return $left;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp($right, $left) > 0 ? $right : $left;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||||
"test:api": [
|
"test:api": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
|
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
|
||||||
],
|
],
|
||||||
"test:api:edge": [
|
"test:api:edge": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ class collected_order_invoices_o extends db
|
|||||||
}
|
}
|
||||||
// Set the processor to E-conomic, if it's not already set to Stripe.
|
// Set the processor to E-conomic, if it's not already set to Stripe.
|
||||||
$this->processor->set(ECONOMIC_PROCESSOR);
|
$this->processor->set(ECONOMIC_PROCESSOR);
|
||||||
|
$this->error_message->nullify();
|
||||||
// Object changed
|
// Object changed
|
||||||
self::objectChanged();
|
self::objectChanged();
|
||||||
return $this;
|
return $this;
|
||||||
|
|||||||
@@ -6507,6 +6507,34 @@ paths:
|
|||||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/monitor:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs
|
||||||
|
operationId: monitorCollectedInvoiceEconomicQueueJobs
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 50
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queue monitor state retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueMonitorResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/queue/status:
|
/collected-invoices/economic/queue/status:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -6566,6 +6594,57 @@ paths:
|
|||||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/dismiss:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Clear one completed or failed collected-invoice queue job for the current user
|
||||||
|
operationId: dismissCollectedInvoiceEconomicQueueJob
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [job_id]
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queue job cleared
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueDismissResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/collected-invoices/economic/queue/dismiss-terminal:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Clear all visible completed or failed collected-invoice queue jobs for the current user
|
||||||
|
operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Terminal queue jobs cleared
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse'
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
/collected-invoices/economic/queue/run:
|
/collected-invoices/economic/queue/run:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -14083,6 +14162,140 @@ components:
|
|||||||
- meta
|
- meta
|
||||||
- includes
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueMonitorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
jobs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||||
|
counts:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
queued:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
in_progress:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
failed:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
completed:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
required:
|
||||||
|
- queued
|
||||||
|
- in_progress
|
||||||
|
- failed
|
||||||
|
- completed
|
||||||
|
- total
|
||||||
|
progress_percent:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
required:
|
||||||
|
- jobs
|
||||||
|
- counts
|
||||||
|
- progress_percent
|
||||||
|
- limit
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueDismissResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
job:
|
||||||
|
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
- job
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
|
EconomicTransferQueueDismissTerminalResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
dismissed_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
- dismissed_count
|
||||||
|
meta:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
items: {}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- data
|
||||||
|
- meta
|
||||||
|
- includes
|
||||||
|
|
||||||
CollectedInvoiceEconomicCompareResponse:
|
CollectedInvoiceEconomicCompareResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class InvoicingPeriodRoute
|
|||||||
*/
|
*/
|
||||||
private static array $departmentExcludedFromInvoicingCache = [];
|
private static array $departmentExcludedFromInvoicingCache = [];
|
||||||
|
|
||||||
|
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +107,64 @@ class InvoicingPeriodRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]|null
|
||||||
|
*/
|
||||||
|
private function getOptionalCustomerNumbersParameter(): ?array
|
||||||
|
{
|
||||||
|
if (!self::isParametersSet(['customerNumbers'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalizeCustomerNumbers(self::getParameter('customerNumbers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function normalizeCustomerNumbers(mixed $customerNumbers): array
|
||||||
|
{
|
||||||
|
if ($customerNumbers === null || $customerNumbers === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawValues = is_array($customerNumbers)
|
||||||
|
? $customerNumbers
|
||||||
|
: explode(',', (string)$customerNumbers);
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($rawValues as $value) {
|
||||||
|
$parsed = (int)trim((string)$value);
|
||||||
|
if ($parsed < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalized[$parsed] = $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $onlyCustomerNumbers
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array
|
||||||
|
{
|
||||||
|
$customerNumbers = self::normalizeCustomerNumbers($customerNumbers);
|
||||||
|
if ($onlyCustomerNumbers === null) {
|
||||||
|
return $customerNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true);
|
||||||
|
if (empty($allowed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool {
|
||||||
|
return isset($allowed[$customerNumber]);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response cache TTL (seconds) for v2 distribution endpoints.
|
* Response cache TTL (seconds) for v2 distribution endpoints.
|
||||||
* Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override.
|
* Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override.
|
||||||
@@ -196,11 +256,15 @@ class InvoicingPeriodRoute
|
|||||||
$dateRange = $this->requireAndNormalizeDateRange();
|
$dateRange = $this->requireAndNormalizeDateRange();
|
||||||
$dateFrom = $dateRange['dateFrom'];
|
$dateFrom = $dateRange['dateFrom'];
|
||||||
$dateTo = $dateRange['dateTo'];
|
$dateTo = $dateRange['dateTo'];
|
||||||
|
$customerNumbers = $this->getOptionalCustomerNumbersParameter();
|
||||||
// Add date from and date to to the response meta
|
// Add date from and date to to the response meta
|
||||||
$response->add_meta('date_from', $dateFrom);
|
$response->add_meta('date_from', $dateFrom);
|
||||||
$response->add_meta('date_to', $dateTo);
|
$response->add_meta('date_to', $dateTo);
|
||||||
|
if ($customerNumbers !== null) {
|
||||||
|
$response->add_meta('customer_numbers', $customerNumbers);
|
||||||
|
}
|
||||||
// Get the invoicing period for the user
|
// Get the invoicing period for the user
|
||||||
$response->success([...self::getInvoicingPeriod($dateFrom, $dateTo)]);
|
$response->success([...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)]);
|
||||||
} else {
|
} else {
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session');
|
(new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session');
|
||||||
@@ -1103,33 +1167,36 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getInvoicingPeriod(string $dateFrom, string $dateTo): array
|
private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
|
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
|
||||||
|
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
||||||
|
? self::normalizeCustomerNumbers($onlyCustomerNumbers)
|
||||||
|
: null;
|
||||||
|
|
||||||
$customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
$customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) {
|
||||||
return self::getCustomersWithTransactions($dateFrom, $dateTo);
|
return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}, 'customers_with_transactions');
|
}, 'customers_with_transactions');
|
||||||
$types = [];
|
$types = [];
|
||||||
// Add the customers with transactions to the types array
|
// Add the customers with transactions to the types array
|
||||||
$types['all'] = $customersWithTransactions;
|
$types['all'] = $customersWithTransactions;
|
||||||
$types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'vehicle_subscriptions');
|
}, 'vehicle_subscriptions');
|
||||||
$types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'fixed_pricing');
|
}, 'fixed_pricing');
|
||||||
$types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'tank_cleaning');
|
}, 'tank_cleaning');
|
||||||
$types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'special_arrangements');
|
}, 'special_arrangements');
|
||||||
$types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'invoice_per_order');
|
}, 'invoice_per_order');
|
||||||
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
|
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
||||||
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions);
|
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
||||||
}, 'possible_duplicates');
|
}, 'possible_duplicates');
|
||||||
$queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
$queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
||||||
return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo);
|
return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo);
|
||||||
@@ -1139,6 +1206,14 @@ class InvoicingPeriodRoute
|
|||||||
$queueOverlay['by_collection_id'] ?? [],
|
$queueOverlay['by_collection_id'] ?? [],
|
||||||
$queueOverlay['by_customer_number'] ?? [],
|
$queueOverlay['by_customer_number'] ?? [],
|
||||||
);
|
);
|
||||||
|
$draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) {
|
||||||
|
return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo);
|
||||||
|
}, 'valid_collected_invoice_draft_overlay');
|
||||||
|
$types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes(
|
||||||
|
$types,
|
||||||
|
$draftOverlay['by_collection_id'] ?? [],
|
||||||
|
$draftOverlay['by_customer_number'] ?? [],
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
'dateFrom' => $dateFrom,
|
'dateFrom' => $dateFrom,
|
||||||
'dateTo' => $dateTo,
|
'dateTo' => $dateTo,
|
||||||
@@ -1173,6 +1248,13 @@ class InvoicingPeriodRoute
|
|||||||
*/
|
*/
|
||||||
private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
|
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
||||||
|
? self::normalizeCustomerNumbers($onlyCustomerNumbers)
|
||||||
|
: null;
|
||||||
|
if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Define the customers with orders in the specified date range
|
// Define the customers with orders in the specified date range
|
||||||
$customers = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
$customers = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
||||||
return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo);
|
return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo);
|
||||||
@@ -1311,6 +1393,7 @@ class InvoicingPeriodRoute
|
|||||||
'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action),
|
'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action),
|
||||||
'meta' => $meta ?? [],
|
'meta' => $meta ?? [],
|
||||||
'queue' => self::getDefaultQueueSummary(),
|
'queue' => self::getDefaultQueueSummary(),
|
||||||
|
'draft' => self::getDefaultDraftSummary(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1360,6 +1443,15 @@ class InvoicingPeriodRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function getDefaultDraftSummary(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'has_valid_draft' => false,
|
||||||
|
'invoice_collection_ids' => [],
|
||||||
|
'is_action_blocked' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array
|
private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array
|
||||||
{
|
{
|
||||||
$overlay = [
|
$overlay = [
|
||||||
@@ -1467,6 +1559,24 @@ class InvoicingPeriodRoute
|
|||||||
return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo;
|
return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function collectedOrderInvoicesHasDeletedAtColumn(): bool
|
||||||
|
{
|
||||||
|
if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) {
|
||||||
|
return self::$collectedOrderInvoicesHasDeletedAtColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'");
|
||||||
|
self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
self::$collectedOrderInvoicesHasDeletedAtColumn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$collectedOrderInvoicesHasDeletedAtColumn;
|
||||||
|
}
|
||||||
|
|
||||||
private static function applyCollectedInvoiceQueueOverlayToPeriodTypes(
|
private static function applyCollectedInvoiceQueueOverlayToPeriodTypes(
|
||||||
array $types,
|
array $types,
|
||||||
array $queueJobsByCollectionId,
|
array $queueJobsByCollectionId,
|
||||||
@@ -1582,6 +1692,235 @@ class InvoicingPeriodRoute
|
|||||||
return $customer;
|
return $customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
$overlay = [
|
||||||
|
'by_collection_id' => [],
|
||||||
|
'by_customer_number' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$candidateInvoiceCollectionIds = [];
|
||||||
|
$customerLevelCandidateNumbers = [];
|
||||||
|
|
||||||
|
foreach ($types as $customers) {
|
||||||
|
if (!is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (!is_array($customer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||||
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||||
|
if ($invoiceCollectionId > 0) {
|
||||||
|
$candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||||
|
if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) {
|
||||||
|
$customerLevelCandidateNumbers[$customerNumber] = $customerNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) {
|
||||||
|
return $overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$whereCandidates = [];
|
||||||
|
if (!empty($candidateInvoiceCollectionIds)) {
|
||||||
|
$whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')';
|
||||||
|
}
|
||||||
|
if (!empty($customerLevelCandidateNumbers)) {
|
||||||
|
$dateFromEscaped = $db->escape_string($dateFrom);
|
||||||
|
$dateToEscaped = $db->escape_string($dateTo);
|
||||||
|
$whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ')
|
||||||
|
AND (
|
||||||
|
DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\'
|
||||||
|
OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\'
|
||||||
|
))';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('\objects\ECONOMIC_PROCESSOR')) {
|
||||||
|
class_exists(collected_order_invoices_o::class);
|
||||||
|
}
|
||||||
|
$processor = defined('\objects\ECONOMIC_PROCESSOR')
|
||||||
|
? (int)constant('\objects\ECONOMIC_PROCESSOR')
|
||||||
|
: 1;
|
||||||
|
$deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn()
|
||||||
|
? 'deleted_at IS NULL
|
||||||
|
AND '
|
||||||
|
: '';
|
||||||
|
$sql = "SELECT id, customer_number, created_at, closed_at
|
||||||
|
FROM collected_order_invoices
|
||||||
|
WHERE {$deletedAtFilter}processor = $processor
|
||||||
|
AND external_id IS NOT NULL
|
||||||
|
AND external_id <> ''
|
||||||
|
AND booked_invoice_id IS NULL
|
||||||
|
AND error_message IS NULL
|
||||||
|
AND (" . implode(' OR ', $whereCandidates) . ")";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result) {
|
||||||
|
return $overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$invoiceCollectionId = (int)($row['id'] ?? 0);
|
||||||
|
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||||
|
if ($invoiceCollectionId < 1 || $customerNumber < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedDraft = [
|
||||||
|
'invoice_collection_id' => $invoiceCollectionId,
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'created_at' => (string)($row['created_at'] ?? ''),
|
||||||
|
'closed_at' => (string)($row['closed_at'] ?? ''),
|
||||||
|
'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod(
|
||||||
|
(string)($row['created_at'] ?? ''),
|
||||||
|
(string)($row['closed_at'] ?? ''),
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft;
|
||||||
|
$overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? [];
|
||||||
|
$overlay['by_customer_number'][$customerNumber][] = $normalizedDraft;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyCollectedInvoiceDraftOverlayToPeriodTypes(
|
||||||
|
array $types,
|
||||||
|
array $draftsByCollectionId,
|
||||||
|
array $draftsByCustomerNumber
|
||||||
|
): array {
|
||||||
|
foreach ($types as $type => $customers) {
|
||||||
|
if (!is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) {
|
||||||
|
if (!is_array($customer)) {
|
||||||
|
return $customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::applyCollectedInvoiceDraftOverlayToCustomer(
|
||||||
|
$customer,
|
||||||
|
$draftsByCollectionId,
|
||||||
|
$draftsByCustomerNumber
|
||||||
|
);
|
||||||
|
}, $customers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyCollectedInvoiceDraftOverlayToCustomer(
|
||||||
|
array $customer,
|
||||||
|
array $draftsByCollectionId,
|
||||||
|
array $draftsByCustomerNumber
|
||||||
|
): array {
|
||||||
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||||
|
$activeCustomerDrafts = array_values(array_filter(
|
||||||
|
$draftsByCustomerNumber[$customerNumber] ?? [],
|
||||||
|
static function ($draft): bool {
|
||||||
|
return !empty($draft['is_period_relevant']);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
$transactions = [];
|
||||||
|
$actionableTransactionCount = 0;
|
||||||
|
$coveredActionableTransactionCount = 0;
|
||||||
|
$queuedActionableTransactionCount = 0;
|
||||||
|
$draftActionableTransactionCount = 0;
|
||||||
|
$invoiceCollectionIds = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
$isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false);
|
||||||
|
if (!$isActionable) {
|
||||||
|
$transactions[] = $transaction;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actionableTransactionCount++;
|
||||||
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||||
|
$isQueued = !empty($transaction['queue_status']);
|
||||||
|
$isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]);
|
||||||
|
|
||||||
|
if ($isQueued || $isDraft) {
|
||||||
|
$coveredActionableTransactionCount++;
|
||||||
|
}
|
||||||
|
if ($isQueued) {
|
||||||
|
$queuedActionableTransactionCount++;
|
||||||
|
}
|
||||||
|
if ($isDraft) {
|
||||||
|
$draftActionableTransactionCount++;
|
||||||
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions[] = $transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($activeCustomerDrafts as $draft) {
|
||||||
|
$invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0);
|
||||||
|
if ($invoiceCollectionId > 0) {
|
||||||
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDraftActionBlocked = false;
|
||||||
|
$queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary();
|
||||||
|
if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) {
|
||||||
|
if ($queuedActionableTransactionCount > 0) {
|
||||||
|
$queue['is_action_blocked'] = true;
|
||||||
|
} elseif ($draftActionableTransactionCount > 0) {
|
||||||
|
$isDraftActionBlocked = true;
|
||||||
|
}
|
||||||
|
} elseif (
|
||||||
|
$actionableTransactionCount === 0
|
||||||
|
&& self::customerSupportsCustomerLevelQueueBlocking($customer)
|
||||||
|
&& !empty($activeCustomerDrafts)
|
||||||
|
&& empty($queue['is_action_blocked'])
|
||||||
|
) {
|
||||||
|
$isDraftActionBlocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer['transactions'] = $transactions;
|
||||||
|
$customer['queue'] = $queue;
|
||||||
|
$customer['draft'] = [
|
||||||
|
'has_valid_draft' => !empty($invoiceCollectionIds),
|
||||||
|
'invoice_collection_ids' => array_values($invoiceCollectionIds),
|
||||||
|
'is_action_blocked' => $isDraftActionBlocked,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) {
|
||||||
|
$customer['requires_action'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $customer;
|
||||||
|
}
|
||||||
|
|
||||||
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
||||||
{
|
{
|
||||||
$meta = $customer['meta'] ?? [];
|
$meta = $customer['meta'] ?? [];
|
||||||
@@ -1594,14 +1933,17 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}
|
}
|
||||||
// Since these are monthly subscriptions, we don't need to filter by transactions
|
// Since these are monthly subscriptions, we don't need to filter by transactions
|
||||||
$customer_numbers = (new \objects\users_o())->getCustomersWithVehicleSubscriptions();
|
$customer_numbers = self::filterCustomerNumbers(
|
||||||
|
(new \objects\users_o())->getCustomersWithVehicleSubscriptions(),
|
||||||
|
$onlyCustomerNumbers
|
||||||
|
);
|
||||||
// Get all customers with vehicle subscriptions
|
// Get all customers with vehicle subscriptions
|
||||||
$subscriptions = [];
|
$subscriptions = [];
|
||||||
/** @var int $customer_number */
|
/** @var int $customer_number */
|
||||||
@@ -1651,10 +1993,13 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// Get all customers with fixed pricing
|
// Get all customers with fixed pricing
|
||||||
$customer_numbers = (new \objects\users_o())->getCustomersWithFixedPricing();
|
$customer_numbers = self::filterCustomerNumbers(
|
||||||
|
(new \objects\users_o())->getCustomersWithFixedPricing(),
|
||||||
|
$onlyCustomerNumbers
|
||||||
|
);
|
||||||
|
|
||||||
// If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers.
|
// If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers.
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
@@ -1727,14 +2072,17 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}
|
}
|
||||||
// Filter out customers that do not have any transactions in the specified date range
|
// Filter out customers that do not have any transactions in the specified date range
|
||||||
$customer_numbers = (new \objects\users_o())->getCustomersWithTankCleaning();
|
$customer_numbers = self::filterCustomerNumbers(
|
||||||
|
(new \objects\users_o())->getCustomersWithTankCleaning(),
|
||||||
|
$onlyCustomerNumbers
|
||||||
|
);
|
||||||
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
||||||
// Get all customers with tank cleaning
|
// Get all customers with tank cleaning
|
||||||
$tank_cleaning = [];
|
$tank_cleaning = [];
|
||||||
@@ -1772,14 +2120,17 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}
|
}
|
||||||
// Get all customers with tank cleaning
|
// Get all customers with tank cleaning
|
||||||
$customer_numbers = (new \objects\users_o())->getCustomersWithSpecialArrangements();
|
$customer_numbers = self::filterCustomerNumbers(
|
||||||
|
(new \objects\users_o())->getCustomersWithSpecialArrangements(),
|
||||||
|
$onlyCustomerNumbers
|
||||||
|
);
|
||||||
// Filter out customers that do not have any transactions in the specified date range
|
// Filter out customers that do not have any transactions in the specified date range
|
||||||
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
||||||
$special_arrangements = [];
|
$special_arrangements = [];
|
||||||
@@ -1796,13 +2147,16 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// Get all customers with the invoicing per order attribute
|
// Get all customers with the invoicing per order attribute
|
||||||
$customer_numbers = (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']);
|
$customer_numbers = self::filterCustomerNumbers(
|
||||||
|
(new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']),
|
||||||
|
$onlyCustomerNumbers
|
||||||
|
);
|
||||||
// Filter out customers that do not have any transactions in the specified date range
|
// Filter out customers that do not have any transactions in the specified date range
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}
|
}
|
||||||
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
||||||
|
|
||||||
@@ -1818,12 +2172,15 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array
|
private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
||||||
{
|
{
|
||||||
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
||||||
if ($customersWithTransactions === null) {
|
if ($customersWithTransactions === null) {
|
||||||
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||||
}
|
}
|
||||||
|
$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
|
// Get orders with the same reg_1, that has been created within 24 hours of each other
|
||||||
$orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo);
|
$orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo);
|
||||||
// Get the customer numbers from the orders
|
// Get the customer numbers from the orders
|
||||||
@@ -1834,6 +2191,9 @@ class InvoicingPeriodRoute
|
|||||||
foreach ( $orders as $order ) {
|
foreach ( $orders as $order ) {
|
||||||
// Get the customer number from the order
|
// Get the customer number from the order
|
||||||
$customer_number = (int)$order[0]['object']->customer_id->value();
|
$customer_number = (int)$order[0]['object']->customer_id->value();
|
||||||
|
if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Check if the customer number is already in the array
|
// Check if the customer number is already in the array
|
||||||
if (isset($tmp_customer_arr[$customer_number])) {
|
if (isset($tmp_customer_arr[$customer_number])) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -794,6 +794,29 @@ class orderInvoicesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->get('/collected-invoices/economic/queue/monitor', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('add_collected_invoice_economic');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
|
$limit = $this->parseCollectedInvoiceQueueMonitorLimit();
|
||||||
|
$queue = new economic_transfer_queue();
|
||||||
|
|
||||||
|
$response->success($this->buildCollectedInvoiceQueueMonitorPayload(
|
||||||
|
$queue,
|
||||||
|
(int)$user->id,
|
||||||
|
$limit
|
||||||
|
));
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'add_collected_invoice_economic' => 'Monitor visible collected invoice transfer queue jobs.'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->post('/collected-invoices/economic/queue/retry', function () {
|
$this->post('/collected-invoices/economic/queue/retry', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('add_collected_invoice_economic');
|
self::requirePermission('add_collected_invoice_economic');
|
||||||
@@ -828,6 +851,67 @@ class orderInvoicesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->post('/collected-invoices/economic/queue/dismiss', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('add_collected_invoice_economic');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
|
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
||||||
|
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
|
||||||
|
$status = strtoupper((string)($job['status'] ?? ''));
|
||||||
|
if (!in_array($status, [
|
||||||
|
economic_transfer_queue::STATUS_COMPLETED,
|
||||||
|
economic_transfer_queue::STATUS_FAILED,
|
||||||
|
], true)) {
|
||||||
|
$response->error('Only completed or failed collected invoice queue jobs can be cleared', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$queue = new economic_transfer_queue();
|
||||||
|
$dismissed = $queue->dismissTerminalJobForUser($job_id, (int)$user->id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error('Failed to clear collected invoice queue job: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->success([
|
||||||
|
'message' => 'Collected invoice queue job cleared',
|
||||||
|
'job' => $this->withCollectedInvoiceQueueDetailsSummary($dismissed),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'add_collected_invoice_economic' => 'Clear one completed or failed queued collected invoice transfer job for the current user.'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->post('/collected-invoices/economic/queue/dismiss-terminal', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('add_collected_invoice_economic');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
|
$queue = new economic_transfer_queue();
|
||||||
|
$dismissed_count = $queue->dismissTerminalJobsForUser(
|
||||||
|
(int)$user->id,
|
||||||
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->success([
|
||||||
|
'message' => 'Completed and failed collected invoice queue jobs cleared',
|
||||||
|
'dismissed_count' => $dismissed_count,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'add_collected_invoice_economic' => 'Clear all visible completed or failed queued collected invoice transfer jobs for the current user.'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->post('/collected-invoices/economic/queue/run', function () {
|
$this->post('/collected-invoices/economic/queue/run', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('add_collected_invoice_economic');
|
self::requirePermission('add_collected_invoice_economic');
|
||||||
@@ -2034,6 +2118,25 @@ class orderInvoicesRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseCollectedInvoiceQueueMonitorLimit(): int
|
||||||
|
{
|
||||||
|
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 100', 400);
|
||||||
|
}
|
||||||
|
$limit = (int)$limit_raw;
|
||||||
|
if ($limit < 1 || $limit > 100) {
|
||||||
|
$response->error('limit must be between 1 and 100', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $limit;
|
||||||
|
}
|
||||||
|
|
||||||
private function requireCollectedInvoiceQueueJobId(): int
|
private function requireCollectedInvoiceQueueJobId(): int
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
@@ -2104,6 +2207,62 @@ class orderInvoicesRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array
|
||||||
|
{
|
||||||
|
$jobs = $queue->listMonitorJobsForUser(
|
||||||
|
$user_id,
|
||||||
|
$limit,
|
||||||
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
|
||||||
|
);
|
||||||
|
$jobs = $this->withCollectedInvoiceQueueDetailsSummaryList($jobs);
|
||||||
|
|
||||||
|
$counts = [
|
||||||
|
'queued' => 0,
|
||||||
|
'in_progress' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'completed' => 0,
|
||||||
|
'total' => count($jobs),
|
||||||
|
];
|
||||||
|
$progress_sum = 0;
|
||||||
|
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
$status = strtoupper((string)($job['status'] ?? ''));
|
||||||
|
$job_progress = max(0, min(100, (int)($job['progress_percent'] ?? 0)));
|
||||||
|
|
||||||
|
if ($status === economic_transfer_queue::STATUS_QUEUED) {
|
||||||
|
$counts['queued']++;
|
||||||
|
$progress_sum += 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === economic_transfer_queue::STATUS_PROCESSING) {
|
||||||
|
$counts['in_progress']++;
|
||||||
|
$progress_sum += $job_progress;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === economic_transfer_queue::STATUS_FAILED) {
|
||||||
|
$counts['failed']++;
|
||||||
|
$progress_sum += 100;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === economic_transfer_queue::STATUS_COMPLETED) {
|
||||||
|
$counts['completed']++;
|
||||||
|
$progress_sum += 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'jobs' => $jobs,
|
||||||
|
'counts' => $counts,
|
||||||
|
'progress_percent' => $counts['total'] > 0
|
||||||
|
? (int)round($progress_sum / $counts['total'])
|
||||||
|
: 0,
|
||||||
|
'limit' => $limit,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int
|
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
@@ -2143,6 +2302,13 @@ class orderInvoicesRoute
|
|||||||
private function withCollectedInvoiceQueueDetailsSummary(array $job): array
|
private function withCollectedInvoiceQueueDetailsSummary(array $job): array
|
||||||
{
|
{
|
||||||
$job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job);
|
$job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job);
|
||||||
|
$collected_invoice_id = (int)($job['details_summary']['target']['collected_invoice_id'] ?? 0);
|
||||||
|
if ($collected_invoice_id > 0) {
|
||||||
|
$job['details_summary']['customer'] = $this->resolveCollectedInvoiceQueueCustomerSummary(
|
||||||
|
$job['details_summary']['customer'] ?? [],
|
||||||
|
$collected_invoice_id
|
||||||
|
);
|
||||||
|
}
|
||||||
return $job;
|
return $job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,6 +2319,72 @@ class orderInvoicesRoute
|
|||||||
}, $jobs));
|
}, $jobs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$customer_number = isset($customer['customer_number']) && is_numeric($customer['customer_number'])
|
||||||
|
? (int)$customer['customer_number']
|
||||||
|
: null;
|
||||||
|
$customer_name = is_string($customer['name'] ?? null) && trim((string)$customer['name']) !== ''
|
||||||
|
? trim((string)$customer['name'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($customer_number !== null && $customer_name !== null) {
|
||||||
|
return [
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'name' => $customer_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$collected_invoice_id = max(0, $collected_invoice_id);
|
||||||
|
if ($collected_invoice_id < 1) {
|
||||||
|
return [
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'name' => $customer_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT coi.customer_number, coi.name AS invoice_name, u.display_name
|
||||||
|
FROM collected_order_invoices coi
|
||||||
|
LEFT JOIN users u ON u.customer_number = coi.customer_number
|
||||||
|
WHERE coi.id = $collected_invoice_id
|
||||||
|
LIMIT 1";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result instanceof \mysqli_result) {
|
||||||
|
return [
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'name' => $customer_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return [
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'name' => $customer_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved_customer_number = isset($row['customer_number']) && is_numeric($row['customer_number'])
|
||||||
|
? (int)$row['customer_number']
|
||||||
|
: $customer_number;
|
||||||
|
$display_name = trim((string)($row['display_name'] ?? ''));
|
||||||
|
$invoice_name = trim((string)($row['invoice_name'] ?? ''));
|
||||||
|
$resolved_name = $customer_name;
|
||||||
|
if ($resolved_name === null && $display_name !== '' && strtolower($display_name) !== 'unnamed') {
|
||||||
|
$resolved_name = $display_name;
|
||||||
|
}
|
||||||
|
if ($resolved_name === null && $invoice_name !== '') {
|
||||||
|
$resolved_name = $invoice_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'customer_number' => $resolved_customer_number,
|
||||||
|
'name' => $resolved_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use classes\attachment_store;
|
|||||||
use classes\attachments;
|
use classes\attachments;
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\economic;
|
use classes\economic;
|
||||||
|
use classes\order_reference_suggestions_service;
|
||||||
use classes\orders_input_normalizer;
|
use classes\orders_input_normalizer;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\stripe;
|
use classes\stripe;
|
||||||
@@ -28,6 +29,52 @@ class ordersRoute
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
|
$this->get('/orders/reference-suggestions', function () {
|
||||||
|
global $response;
|
||||||
|
$auth = new authentication();
|
||||||
|
$user = $auth->get_user();
|
||||||
|
if ($user === false) {
|
||||||
|
(new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->requirePermission('list_orders');
|
||||||
|
self::requireParameters(['department_id']);
|
||||||
|
$departmentId = (int)self::getParameter('department_id');
|
||||||
|
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||||
|
self::requireDepartmentAccess((string)$departmentId);
|
||||||
|
|
||||||
|
$search = trim((string)(self::getParameter('search') ?? ''));
|
||||||
|
if (strlen($search) > 255) {
|
||||||
|
$response->error('Parameter search must be at most 255 characters long', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['reg_1', 'reg_2', 'reg_3'] as $plateParameter) {
|
||||||
|
$plateValue = (string)(self::getParameter($plateParameter) ?? '');
|
||||||
|
if (strlen($plateValue) > 32) {
|
||||||
|
$response->error('Parameter ' . $plateParameter . ' must be at most 32 characters long', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggestions = (new order_reference_suggestions_service())->suggest([
|
||||||
|
'search' => $search,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'customer_id' => self::getParameter('customer_id') ?? null,
|
||||||
|
'reg_1' => self::getParameter('reg_1') ?? '',
|
||||||
|
'reg_2' => self::getParameter('reg_2') ?? '',
|
||||||
|
'reg_3' => self::getParameter('reg_3') ?? '',
|
||||||
|
'limit' => self::getParameter('limit') ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new logs_o())->add('orders', (string)$departmentId, 1, (int)$user->id, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'Successfully listed POS reference suggestions');
|
||||||
|
$response->success($suggestions);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'list_orders' => 'List POS order reference suggestions',
|
||||||
|
'department_access_:id' => 'Access to the department used for reference suggestions',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->get('/orders', function () {
|
$this->get('/orders', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -70,6 +70,50 @@ it('removes generated customer traces during fixture cleanup', function (): void
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores preserved module config rows during fixture cleanup', function (): void {
|
||||||
|
$db = api_test_runtime()->db();
|
||||||
|
$module = 'fixture_cleanup';
|
||||||
|
$variable = 'preserve_module_config_' . bin2hex(random_bytes(4));
|
||||||
|
$moduleEscaped = $db->real_escape_string($module);
|
||||||
|
$variableEscaped = $db->real_escape_string($variable);
|
||||||
|
$ended = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO `module_config` (`module`, `variable`, `value`, `type`, `created_at`, `updated_at`) " .
|
||||||
|
"VALUES ('{$moduleEscaped}', '{$variableEscaped}', '600100', 'int', '2026-04-14 12:00:00', '2026-04-14 12:00:00')"
|
||||||
|
);
|
||||||
|
|
||||||
|
api_fixtures()->preserveModuleConfig($module, $variable);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"UPDATE `module_config` " .
|
||||||
|
"SET `value` = NULL, `type` = 'string', `updated_at` = '2026-04-14 12:05:00' " .
|
||||||
|
"WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
api_test_runtime()->endTest();
|
||||||
|
$ended = true;
|
||||||
|
|
||||||
|
$row = api_fixture_cleanup_module_config_row($db, $module, $variable);
|
||||||
|
|
||||||
|
expect($row)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($row['value'] ?? null)->toBe('600100')
|
||||||
|
->and($row['type'] ?? null)->toBe('int')
|
||||||
|
->and($row['created_at'] ?? null)->toBe('2026-04-14 12:00:00')
|
||||||
|
->and($row['updated_at'] ?? null)->toBe('2026-04-14 12:00:00');
|
||||||
|
} finally {
|
||||||
|
if (!$ended) {
|
||||||
|
api_test_runtime()->endTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"DELETE FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void
|
function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void
|
||||||
{
|
{
|
||||||
$statements = [
|
$statements = [
|
||||||
@@ -450,6 +494,24 @@ function api_fixture_cleanup_count(mysqli $db, string $sql): int
|
|||||||
return (int)($row['c'] ?? 0);
|
return (int)($row['c'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function api_fixture_cleanup_module_config_row(mysqli $db, string $module, string $variable): ?array
|
||||||
|
{
|
||||||
|
$moduleEscaped = $db->real_escape_string($module);
|
||||||
|
$variableEscaped = $db->real_escape_string($variable);
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT * FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
throw new RuntimeException('Fixture cleanup module_config query failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$result->free();
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ it('lists the draft customer config entry in economic config responses', functio
|
|||||||
it('round-trips the draft customer config value through economic config updates', function (): void {
|
it('round-trips the draft customer config value through economic config updates', function (): void {
|
||||||
api_test_covers('POST /economic/config', 'happy');
|
api_test_covers('POST /economic/config', 'happy');
|
||||||
|
|
||||||
|
api_fixtures()->preserveModuleConfig('economic', 'transactionDraftCustomerNumber');
|
||||||
$session = api_fixtures()->createUserSession(['economic_config']);
|
$session = api_fixtures()->createUserSession(['economic_config']);
|
||||||
|
|
||||||
api_client()->post('/economic/config', [
|
api_client()->post('/economic/config', [
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function reference_suggestion_by_reference(array $suggestions, string $reference): ?array
|
||||||
|
{
|
||||||
|
foreach ($suggestions as $suggestion) {
|
||||||
|
if (($suggestion['reference'] ?? null) === $reference) {
|
||||||
|
return $suggestion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns ranked POS reference suggestions from bookings, orders, and customer vehicles', function (): void {
|
||||||
|
api_test_covers('GET /orders/reference-suggestions', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Cashier']);
|
||||||
|
|
||||||
|
api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'reference' => 'REF-BOOKING',
|
||||||
|
'datetime' => '2026-05-13 09:00:00',
|
||||||
|
'created_at' => '2026-05-01 08:00:00',
|
||||||
|
'reg_1' => 'BOOK1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'REF-HISTORY',
|
||||||
|
'created_at' => '2026-05-10 10:00:00',
|
||||||
|
'reg_1' => 'HIST1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createVehicle([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'VEH1',
|
||||||
|
'reference' => 'REF-VEHICLE',
|
||||||
|
'created_at' => '2026-05-08 12:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'reference' => 'REF-SHARED',
|
||||||
|
'datetime' => '2026-05-15 11:00:00',
|
||||||
|
'reg_1' => 'SHARED1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'REF-SHARED',
|
||||||
|
'created_at' => '2026-05-12 10:00:00',
|
||||||
|
'reg_1' => 'SHARED1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createVehicle([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'SHARED1',
|
||||||
|
'reference' => 'REF-SHARED',
|
||||||
|
'created_at' => '2026-05-09 12:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deletedOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'REF-DELETED',
|
||||||
|
]);
|
||||||
|
api_test_runtime()->db()
|
||||||
|
->query("UPDATE orders SET deleted_at = '2026-05-10 12:00:00' WHERE id = " . (int)$deletedOrder['id']);
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'list_orders',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->get('/orders/reference-suggestions?' . http_build_query([
|
||||||
|
'search' => 'REF',
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'reg_1' => 'SHARED1',
|
||||||
|
'limit' => 10,
|
||||||
|
]), $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$suggestions = $response->data();
|
||||||
|
expect($suggestions)->toBeArray();
|
||||||
|
|
||||||
|
$booking = reference_suggestion_by_reference($suggestions, 'REF-BOOKING');
|
||||||
|
$history = reference_suggestion_by_reference($suggestions, 'REF-HISTORY');
|
||||||
|
$vehicle = reference_suggestion_by_reference($suggestions, 'REF-VEHICLE');
|
||||||
|
$shared = reference_suggestion_by_reference($suggestions, 'REF-SHARED');
|
||||||
|
|
||||||
|
expect($booking['source'] ?? null)->toBe('booking');
|
||||||
|
expect($booking['source_created_at'] ?? null)->toBe('2026-05-13 09:00:00');
|
||||||
|
expect($history['source'] ?? null)->toBe('order');
|
||||||
|
expect($vehicle['source'] ?? null)->toBe('vehicle');
|
||||||
|
expect($shared['source'] ?? null)->toBe('booking');
|
||||||
|
expect($shared['section'] ?? null)->toBe('this_vehicle');
|
||||||
|
expect($booking['section'] ?? null)->toBe('other_customer_vehicle');
|
||||||
|
expect($history['section'] ?? null)->toBe('other_customer_vehicle');
|
||||||
|
expect($vehicle['section'] ?? null)->toBe('other_customer_vehicle');
|
||||||
|
expect($shared['usage_count'] ?? null)->toBe(3);
|
||||||
|
expect($shared['last_used_at'] ?? null)->toBe('2026-05-15 11:00:00');
|
||||||
|
expect(reference_suggestion_by_reference($suggestions, 'REF-DELETED'))->toBeNull();
|
||||||
|
expect(reference_suggestion_by_reference($suggestions, ''))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders reference suggestions by match relevance before context and frequency', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Reference Ranking Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Reference Ranking Cashier']);
|
||||||
|
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ABC',
|
||||||
|
'created_at' => '2026-05-01 08:00:00',
|
||||||
|
'reg_1' => 'OTHER1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'reference' => 'ABC-PREFIX',
|
||||||
|
'datetime' => '2026-05-16 08:00:00',
|
||||||
|
'reg_1' => 'MATCH1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'X-ABC-CONTAINS',
|
||||||
|
'created_at' => '2026-05-17 08:00:00',
|
||||||
|
'reg_1' => 'MATCH1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'list_orders',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->get('/orders/reference-suggestions?' . http_build_query([
|
||||||
|
'search' => 'ABC',
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'reg_1' => 'MATCH1',
|
||||||
|
]), $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$references = array_map(
|
||||||
|
static fn(array $suggestion): string => (string)($suggestion['reference'] ?? ''),
|
||||||
|
is_array($response->data()) ? $response->data() : []
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces authentication, list permission, and department access for reference suggestions', function (): void {
|
||||||
|
api_test_covers('GET /orders/reference-suggestions', 'auth');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
|
||||||
|
api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Invalid session');
|
||||||
|
|
||||||
|
$missingListPermission = api_fixtures()->createUserSession([
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingListPermission['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['list_orders']);
|
||||||
|
|
||||||
|
$missingDepartmentAccess = api_fixtures()->createUserSession(['list_orders']);
|
||||||
|
api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingDepartmentAccess['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['department_access_' . $department['id']]);
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ return [
|
|||||||
'manual_operations' => [
|
'manual_operations' => [
|
||||||
'GET /ping',
|
'GET /ping',
|
||||||
'PUT /order',
|
'PUT /order',
|
||||||
|
'GET /orders/reference-suggestions',
|
||||||
],
|
],
|
||||||
'happy_only_operations' => [
|
'happy_only_operations' => [
|
||||||
'GET /ping',
|
'GET /ping',
|
||||||
|
|||||||
@@ -935,13 +935,44 @@ final class ApiFixtures
|
|||||||
return $attributeId;
|
return $attributeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function preserveModuleConfig(string $module, string $variable): void
|
||||||
|
{
|
||||||
|
$existing = $this->fetchModuleConfig($module, $variable);
|
||||||
|
$conditions = [
|
||||||
|
'module' => $module,
|
||||||
|
'variable' => $variable,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->cleanup->add(function () use ($conditions, $existing): void {
|
||||||
|
if ($existing === null) {
|
||||||
|
$this->deleteWhereIfPossible('module_config', $conditions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->fetchModuleConfig((string)$conditions['module'], (string)$conditions['variable']);
|
||||||
|
$data = [
|
||||||
|
'value' => $existing['value'] ?? null,
|
||||||
|
'type' => $existing['type'] ?? null,
|
||||||
|
'created_at' => $existing['created_at'] ?? null,
|
||||||
|
'updated_at' => $existing['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($current === null) {
|
||||||
|
$this->insertRow('module_config', [
|
||||||
|
'module' => $existing['module'] ?? $conditions['module'],
|
||||||
|
'variable' => $existing['variable'] ?? $conditions['variable'],
|
||||||
|
...$data,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateWhere('module_config', $conditions, $data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void
|
public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void
|
||||||
{
|
{
|
||||||
$moduleEscaped = $this->db->real_escape_string($module);
|
$existing = $this->fetchModuleConfig($module, $variable);
|
||||||
$variableEscaped = $this->db->real_escape_string($variable);
|
|
||||||
$existing = $this->queryOneBySql(
|
|
||||||
"SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1"
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
$conditions = [
|
$conditions = [
|
||||||
@@ -1876,6 +1907,16 @@ final class ApiFixtures
|
|||||||
return $row ?: null;
|
return $row ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fetchModuleConfig(string $module, string $variable): ?array
|
||||||
|
{
|
||||||
|
$moduleEscaped = $this->db->real_escape_string($module);
|
||||||
|
$variableEscaped = $this->db->real_escape_string($variable);
|
||||||
|
|
||||||
|
return $this->queryOneBySql(
|
||||||
|
"SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function setRedisJson(string $key, array $payload): void
|
private function setRedisJson(string $key, array $payload): void
|
||||||
{
|
{
|
||||||
if ($this->redis === null) {
|
if ($this->redis === null) {
|
||||||
|
|||||||
@@ -221,6 +221,32 @@ CREATE TABLE IF NOT EXISTS `orders` (
|
|||||||
KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`),
|
KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`),
|
||||||
KEY `idx_orders_reg_1` (`reg_1`)
|
KEY `idx_orders_reg_1` (`reg_1`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'order_bookings' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `order_bookings` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_number` INT NOT NULL,
|
||||||
|
`department` INT NOT NULL,
|
||||||
|
`reg_1` VARCHAR(32) NULL,
|
||||||
|
`reg_2` VARCHAR(32) NULL,
|
||||||
|
`reg_3` VARCHAR(32) NULL,
|
||||||
|
`datetime` DATETIME NULL,
|
||||||
|
`note` TEXT NULL,
|
||||||
|
`reference` VARCHAR(255) NULL,
|
||||||
|
`po` VARCHAR(255) NULL,
|
||||||
|
`pickup` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`items` LONGTEXT NULL,
|
||||||
|
`order_id` INT NULL,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_order_bookings_department` (`department`),
|
||||||
|
KEY `idx_order_bookings_customer_number` (`customer_number`),
|
||||||
|
KEY `idx_order_bookings_order_id` (`order_id`),
|
||||||
|
KEY `idx_order_bookings_reg_1` (`reg_1`),
|
||||||
|
KEY `idx_order_bookings_deleted_at` (`deleted_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'order_items' => <<<'SQL'
|
'order_items' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `order_items` (
|
CREATE TABLE IF NOT EXISTS `order_items` (
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ final class ApiTestRuntime
|
|||||||
return 'API tests are disabled. Run with RUN_API_TESTS=1.';
|
return 'API tests are disabled. Run with RUN_API_TESTS=1.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertApiDatabaseTargetIsSafe();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->bootstrapEnvironment();
|
$this->bootstrapEnvironment();
|
||||||
$this->bootstrapSchemaIfRequested();
|
$this->bootstrapSchemaIfRequested();
|
||||||
@@ -297,6 +299,8 @@ final class ApiTestRuntime
|
|||||||
$database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
$database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
||||||
$port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
$port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
||||||
|
|
||||||
|
$this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port);
|
||||||
|
|
||||||
if ($host === '' || $user === '' || $database === '') {
|
if ($host === '' || $user === '' || $database === '') {
|
||||||
throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.');
|
throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.');
|
||||||
}
|
}
|
||||||
@@ -345,4 +349,47 @@ final class ApiTestRuntime
|
|||||||
|
|
||||||
return $liveValue;
|
return $liveValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertApiDatabaseTargetIsSafe(
|
||||||
|
?string $target = null,
|
||||||
|
?string $host = null,
|
||||||
|
?string $user = null,
|
||||||
|
?string $database = null,
|
||||||
|
?int $port = null
|
||||||
|
): void {
|
||||||
|
if ((string)(getenv('API_TEST_ALLOW_LIVE_DB') ?: '') === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = strtolower(trim((string)($target ?? (getenv('CONFIG_DB_TARGET') ?: 'live'))));
|
||||||
|
if ($target !== 'debug') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Refusing to run API tests against CONFIG_DB_TARGET=live. Use CONFIG_DB_TARGET=debug, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host ??= $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
||||||
|
$user ??= $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
||||||
|
$database ??= $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
||||||
|
$port ??= (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
||||||
|
|
||||||
|
$liveHost = trim((string)(getenv('CONFIG_DB_HOST') ?: ''));
|
||||||
|
$liveUser = trim((string)(getenv('CONFIG_DB_USER') ?: ''));
|
||||||
|
$liveDatabase = trim((string)(getenv('CONFIG_DB_DATABASE') ?: ''));
|
||||||
|
$livePort = (int)(trim((string)(getenv('CONFIG_DB_PORT') ?: '3306')) ?: '3306');
|
||||||
|
|
||||||
|
if (
|
||||||
|
$liveHost !== '' &&
|
||||||
|
$liveUser !== '' &&
|
||||||
|
$liveDatabase !== '' &&
|
||||||
|
$host === $liveHost &&
|
||||||
|
$user === $liveUser &&
|
||||||
|
$database === $liveDatabase &&
|
||||||
|
$port === $livePort
|
||||||
|
) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Refusing to run API tests because CONFIG_DB_TARGET=debug resolves to the configured live database. Point CONFIG_DB_DEBUG_* at an isolated database, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -82,6 +82,25 @@ it('prefers queue error messages for failed jobs and keeps null-safe outcome fie
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses queued job payload customer context before result data exists', function (): void {
|
||||||
|
$summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([
|
||||||
|
'status' => economic_transfer_queue::STATUS_QUEUED,
|
||||||
|
'payload' => [
|
||||||
|
'collected_invoice_id' => 17389,
|
||||||
|
'customer_number' => '778899',
|
||||||
|
'customer_name' => 'Queued Customer A/S',
|
||||||
|
],
|
||||||
|
'result' => null,
|
||||||
|
'progress_message' => 'Queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($summary['target']['collected_invoice_id'])->toBe(17389);
|
||||||
|
expect($summary['customer'])->toBe([
|
||||||
|
'customer_number' => 778899,
|
||||||
|
'name' => 'Queued Customer A/S',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses deterministic status message fallback when no explicit message exists', function (): void {
|
it('uses deterministic status message fallback when no explicit message exists', function (): void {
|
||||||
$expectations = [
|
$expectations = [
|
||||||
[economic_transfer_queue::STATUS_QUEUED, 'Queued'],
|
[economic_transfer_queue::STATUS_QUEUED, 'Queued'],
|
||||||
|
|||||||
@@ -60,11 +60,44 @@ it('enforces retry constraints for collected-invoice queue jobs before retry exe
|
|||||||
expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409");
|
expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409");
|
||||||
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array');
|
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array');
|
||||||
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array');
|
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array');
|
||||||
|
expect($content)->toContain('private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array');
|
||||||
|
expect($content)->toContain('FROM collected_order_invoices coi');
|
||||||
|
expect($content)->toContain('LEFT JOIN users u ON u.customer_number = coi.customer_number');
|
||||||
expect($content)->toContain('private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int');
|
expect($content)->toContain('private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int');
|
||||||
expect($content)->toContain('only failed jobs can be retried');
|
expect($content)->toContain('only failed jobs can be retried');
|
||||||
expect($content)->toContain('max retry attempts');
|
expect($content)->toContain('max retry attempts');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes collected-invoice queue monitor and per-user terminal clear routes', function (): void {
|
||||||
|
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
$monitorStart = strpos($content, "\$this->get('/collected-invoices/economic/queue/monitor'");
|
||||||
|
$dismissStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss'");
|
||||||
|
$dismissTerminalStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss-terminal'");
|
||||||
|
|
||||||
|
expect($monitorStart)->not->toBeFalse();
|
||||||
|
expect($dismissStart)->not->toBeFalse();
|
||||||
|
expect($dismissTerminalStart)->not->toBeFalse();
|
||||||
|
|
||||||
|
expect($content)->toContain('$limit = $this->parseCollectedInvoiceQueueMonitorLimit();');
|
||||||
|
expect($content)->toContain('$this->buildCollectedInvoiceQueueMonitorPayload(');
|
||||||
|
expect($content)->toContain('private function parseCollectedInvoiceQueueMonitorLimit(): int');
|
||||||
|
expect($content)->toContain('limit must be between 1 and 100');
|
||||||
|
expect($content)->toContain('private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array');
|
||||||
|
expect($content)->toContain('$queue->listMonitorJobsForUser(');
|
||||||
|
expect($content)->toContain("'queued' => 0");
|
||||||
|
expect($content)->toContain("'in_progress' => 0");
|
||||||
|
expect($content)->toContain("'progress_percent' => \$counts['total'] > 0");
|
||||||
|
|
||||||
|
expect($content)->toContain('$queue->dismissTerminalJobForUser($job_id, (int)$user->id);');
|
||||||
|
expect($content)->toContain('Only completed or failed collected invoice queue jobs can be cleared');
|
||||||
|
expect($content)->toContain('$queue->dismissTerminalJobsForUser(');
|
||||||
|
expect($content)->toContain("'dismissed_count' => \$dismissed_count");
|
||||||
|
});
|
||||||
|
|
||||||
it('runs collected-invoice queue batches through an explicit manual endpoint', function (): void {
|
it('runs collected-invoice queue batches through an explicit manual endpoint', function (): void {
|
||||||
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -61,12 +61,15 @@ it('keeps queue status endpoints guarded while collected invoice export routes s
|
|||||||
|
|
||||||
$guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content);
|
$guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content);
|
||||||
$queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
|
$queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
|
||||||
expect($guard_count)->toBe(4);
|
expect($guard_count)->toBe(7);
|
||||||
expect($queue_init_count)->toBe(6);
|
expect($queue_init_count)->toBe(9);
|
||||||
|
|
||||||
$queue_endpoint_patterns = [
|
$queue_endpoint_patterns = [
|
||||||
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
|
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/monitor'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
|
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
|
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss-terminal'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/run'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/run'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ it('hardens transfer queue with type validation retry caps and stale lock recove
|
|||||||
expect($content)->toContain('AND attempts < max_attempts');
|
expect($content)->toContain('AND attempts < max_attempts');
|
||||||
expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array');
|
expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array');
|
||||||
expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int');
|
expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int');
|
||||||
|
expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array');
|
||||||
|
expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array');
|
||||||
|
expect($content)->toContain('public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int');
|
||||||
|
expect($content)->toContain('economic_transfer_queue_job_dismissals');
|
||||||
|
expect($content)->toContain("Only completed or failed queue jobs can be dismissed");
|
||||||
|
expect($content)->toContain('$this->clearDismissalsForJob($job_id);');
|
||||||
|
expect($content)->toContain('private function clearDismissalsForJob(int $job_id): void');
|
||||||
expect($content)->toContain('public function processPendingByTransferType(string $transfer_type, int $limit = 10): array');
|
expect($content)->toContain('public function processPendingByTransferType(string $transfer_type, int $limit = 10): array');
|
||||||
expect($content)->toContain('private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array');
|
expect($content)->toContain('private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array');
|
||||||
expect($content)->toContain('private function claimNextJob(?string $transfer_type = null): ?array');
|
expect($content)->toContain('private function claimNextJob(?string $transfer_type = null): ?array');
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ it('documents economic transfer queue paths in openapi', function (): void {
|
|||||||
expect($content)->toContain('/collected-invoices/economic:');
|
expect($content)->toContain('/collected-invoices/economic:');
|
||||||
expect($content)->toContain('/collected-invoices/stripe/book:');
|
expect($content)->toContain('/collected-invoices/stripe/book:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/queue:');
|
expect($content)->toContain('/collected-invoices/economic/queue:');
|
||||||
|
expect($content)->toContain('/collected-invoices/economic/queue/monitor:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/queue/status:');
|
expect($content)->toContain('/collected-invoices/economic/queue/status:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/queue/retry:');
|
expect($content)->toContain('/collected-invoices/economic/queue/retry:');
|
||||||
|
expect($content)->toContain('/collected-invoices/economic/queue/dismiss:');
|
||||||
|
expect($content)->toContain('/collected-invoices/economic/queue/dismiss-terminal:');
|
||||||
expect($content)->toContain('/collected-invoices/economic/queue/run:');
|
expect($content)->toContain('/collected-invoices/economic/queue/run:');
|
||||||
expect($content)->toContain('/economic/invoice/draft/export:');
|
expect($content)->toContain('/economic/invoice/draft/export:');
|
||||||
expect($content)->toContain('/economic/invoice/draft/export/status:');
|
expect($content)->toContain('/economic/invoice/draft/export/status:');
|
||||||
@@ -65,6 +68,9 @@ it('documents economic transfer queue schemas in openapi', function (): void {
|
|||||||
expect($content)->toContain('EconomicTransferQueueRetryResponse:');
|
expect($content)->toContain('EconomicTransferQueueRetryResponse:');
|
||||||
expect($content)->toContain('EconomicTransferQueueRunResponse:');
|
expect($content)->toContain('EconomicTransferQueueRunResponse:');
|
||||||
expect($content)->toContain('EconomicTransferQueueListResponse:');
|
expect($content)->toContain('EconomicTransferQueueListResponse:');
|
||||||
|
expect($content)->toContain('EconomicTransferQueueMonitorResponse:');
|
||||||
|
expect($content)->toContain('EconomicTransferQueueDismissResponse:');
|
||||||
|
expect($content)->toContain('EconomicTransferQueueDismissTerminalResponse:');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents 200 fallback plus 202 queue contracts for export endpoints and keeps 503 on queue lifecycle endpoints', function (): void {
|
it('documents 200 fallback plus 202 queue contracts for export endpoints and keeps 503 on queue lifecycle endpoints', function (): void {
|
||||||
@@ -101,9 +107,12 @@ it('documents 200 fallback plus 202 queue contracts for export endpoints and kee
|
|||||||
}
|
}
|
||||||
|
|
||||||
$queue_blocks = [
|
$queue_blocks = [
|
||||||
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/status:'),
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/monitor:'),
|
||||||
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/monitor:', '/collected-invoices/economic/queue/status:'),
|
||||||
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/status:', '/collected-invoices/economic/queue/retry:'),
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/status:', '/collected-invoices/economic/queue/retry:'),
|
||||||
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/run:'),
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/dismiss:'),
|
||||||
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss:', '/collected-invoices/economic/queue/dismiss-terminal:'),
|
||||||
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss-terminal:', '/collected-invoices/economic/queue/run:'),
|
||||||
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/run:', '/collected-invoices/economic/compare:'),
|
economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/run:', '/collected-invoices/economic/compare:'),
|
||||||
economic_transfer_queue_openapi_block($content, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/export/retry:'),
|
economic_transfer_queue_openapi_block($content, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/export/retry:'),
|
||||||
economic_transfer_queue_openapi_block($content, '/economic/invoice/export/status:', '/economic/invoice/export/retry:'),
|
economic_transfer_queue_openapi_block($content, '/economic/invoice/export/status:', '/economic/invoice/export/retry:'),
|
||||||
@@ -120,7 +129,7 @@ it('documents additive collected-invoice queue pagination metadata and retry con
|
|||||||
$queue_list_block = economic_transfer_queue_openapi_block(
|
$queue_list_block = economic_transfer_queue_openapi_block(
|
||||||
$content,
|
$content,
|
||||||
'/collected-invoices/economic/queue:',
|
'/collected-invoices/economic/queue:',
|
||||||
'/collected-invoices/economic/queue/status:'
|
'/collected-invoices/economic/queue/monitor:'
|
||||||
);
|
);
|
||||||
expect($queue_list_block)->toContain('style: form');
|
expect($queue_list_block)->toContain('style: form');
|
||||||
expect($queue_list_block)->toContain('explode: false');
|
expect($queue_list_block)->toContain('explode: false');
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ it('defines economic transfer queue jobs schema bootstrap table and tracking col
|
|||||||
expect($content)->toContain('error_message TEXT NULL');
|
expect($content)->toContain('error_message TEXT NULL');
|
||||||
expect($content)->toContain('payload_json JSON NOT NULL');
|
expect($content)->toContain('payload_json JSON NOT NULL');
|
||||||
expect($content)->toContain('result_json JSON NULL');
|
expect($content)->toContain('result_json JSON NULL');
|
||||||
|
expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals');
|
||||||
|
expect($content)->toContain('queue_job_id BIGINT UNSIGNED NOT NULL');
|
||||||
|
expect($content)->toContain('user_id INT NOT NULL');
|
||||||
|
expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL');
|
||||||
|
expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides queue processor class constants and processing entrypoint', function (): void {
|
it('provides queue processor class constants and processing entrypoint', function (): void {
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('routes/InvoicingPeriodRoute.php');
|
||||||
|
|
||||||
|
use routes\InvoicingPeriodRoute;
|
||||||
|
|
||||||
|
function invoicing_period_draft_overlay_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_draft_overlay_draft(
|
||||||
|
int $invoice_collection_id,
|
||||||
|
int $customer_number,
|
||||||
|
bool $is_period_relevant = true
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'invoice_collection_id' => $invoice_collection_id,
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'created_at' => '2026-04-01 00:00:01',
|
||||||
|
'closed_at' => '2026-04-01 00:00:01',
|
||||||
|
'is_period_relevant' => $is_period_relevant,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoicing_period_draft_overlay_transaction(
|
||||||
|
int $id,
|
||||||
|
?int $invoice_collection_id,
|
||||||
|
bool $booked = false,
|
||||||
|
bool $excluded = false,
|
||||||
|
?string $queue_status = null
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'booked' => $booked,
|
||||||
|
'excluded' => $excluded,
|
||||||
|
'invoice_collection_id' => $invoice_collection_id,
|
||||||
|
'queue_status' => $queue_status,
|
||||||
|
'queue_job_id' => $queue_status === null ? null : 99,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
||||||
|
$property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoicingPeriodDraftOverlayFakeDbResult
|
||||||
|
{
|
||||||
|
public int $num_rows;
|
||||||
|
|
||||||
|
public function __construct(private array $rows = [])
|
||||||
|
{
|
||||||
|
$this->num_rows = count($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch_assoc(): ?array
|
||||||
|
{
|
||||||
|
return array_shift($this->rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoicingPeriodDraftOverlayFakeDb
|
||||||
|
{
|
||||||
|
public string $selectSql = '';
|
||||||
|
|
||||||
|
public function __construct(private bool $hasDeletedAtColumn)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function escape_string(string $value): string
|
||||||
|
{
|
||||||
|
return addslashes($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $sql): InvoicingPeriodDraftOverlayFakeDbResult|bool
|
||||||
|
{
|
||||||
|
if (str_starts_with($sql, 'SHOW COLUMNS')) {
|
||||||
|
return new InvoicingPeriodDraftOverlayFakeDbResult(
|
||||||
|
$this->hasDeletedAtColumn ? [['Field' => 'deleted_at']] : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->selectSql = $sql;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks invoicing when all actionable transactions are backed by valid e-conomic drafts', function (): void {
|
||||||
|
$draft = invoicing_period_draft_overlay_draft(14578, 42424242);
|
||||||
|
|
||||||
|
$customer = [
|
||||||
|
'customer_number' => 42424242,
|
||||||
|
'customer_name' => 'Draft Customer',
|
||||||
|
'requires_action' => true,
|
||||||
|
'transactions' => [
|
||||||
|
invoicing_period_draft_overlay_transaction(501, 14578),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [
|
||||||
|
$customer,
|
||||||
|
[14578 => $draft],
|
||||||
|
[42424242 => [$draft]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['requires_action'])->toBeFalse();
|
||||||
|
expect($result['queue'])->toBe([
|
||||||
|
'has_active_job' => false,
|
||||||
|
'statuses' => [],
|
||||||
|
'invoice_collection_ids' => [],
|
||||||
|
'is_action_blocked' => false,
|
||||||
|
]);
|
||||||
|
expect($result['draft'])->toBe([
|
||||||
|
'has_valid_draft' => true,
|
||||||
|
'invoice_collection_ids' => [14578],
|
||||||
|
'is_action_blocked' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps invoicing available when valid drafts only cover part of the actionable work', function (): void {
|
||||||
|
$draft = invoicing_period_draft_overlay_draft(2001, 43434343);
|
||||||
|
|
||||||
|
$customer = [
|
||||||
|
'customer_number' => 43434343,
|
||||||
|
'customer_name' => 'Mixed Draft Customer',
|
||||||
|
'requires_action' => true,
|
||||||
|
'transactions' => [
|
||||||
|
invoicing_period_draft_overlay_transaction(601, 2001),
|
||||||
|
invoicing_period_draft_overlay_transaction(602, null),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [
|
||||||
|
$customer,
|
||||||
|
[2001 => $draft],
|
||||||
|
[43434343 => [$draft]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['requires_action'])->toBeTrue();
|
||||||
|
expect($result['draft'])->toBe([
|
||||||
|
'has_valid_draft' => true,
|
||||||
|
'invoice_collection_ids' => [2001],
|
||||||
|
'is_action_blocked' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes errored, booked, deleted, and missing-external-id invoice collections at query time', function (): void {
|
||||||
|
$hadDb = array_key_exists('db', $GLOBALS);
|
||||||
|
$originalDb = $GLOBALS['db'] ?? null;
|
||||||
|
$fakeDb = new InvoicingPeriodDraftOverlayFakeDb(true);
|
||||||
|
invoicing_period_draft_overlay_reset_deleted_at_column_cache();
|
||||||
|
$GLOBALS['db'] = $fakeDb;
|
||||||
|
|
||||||
|
try {
|
||||||
|
invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [
|
||||||
|
[
|
||||||
|
'all' => [
|
||||||
|
[
|
||||||
|
'customer_number' => 45454545,
|
||||||
|
'transactions' => [
|
||||||
|
invoicing_period_draft_overlay_transaction(701, 3001),
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'fixed_pricing' => [
|
||||||
|
'price' => 1200,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'2026-04-01',
|
||||||
|
'2026-04-30',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if ($hadDb) {
|
||||||
|
$GLOBALS['db'] = $originalDb;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
}
|
||||||
|
invoicing_period_draft_overlay_reset_deleted_at_column_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($fakeDb->selectSql)->toContain('deleted_at IS NULL');
|
||||||
|
expect($fakeDb->selectSql)->toContain('processor = 1');
|
||||||
|
expect($fakeDb->selectSql)->toContain('external_id IS NOT NULL');
|
||||||
|
expect($fakeDb->selectSql)->toContain("external_id <> ''");
|
||||||
|
expect($fakeDb->selectSql)->toContain('booked_invoice_id IS NULL');
|
||||||
|
expect($fakeDb->selectSql)->toContain('error_message IS NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still checks valid drafts when the collection table has no deleted marker column', function (): void {
|
||||||
|
$hadDb = array_key_exists('db', $GLOBALS);
|
||||||
|
$originalDb = $GLOBALS['db'] ?? null;
|
||||||
|
$fakeDb = new InvoicingPeriodDraftOverlayFakeDb(false);
|
||||||
|
invoicing_period_draft_overlay_reset_deleted_at_column_cache();
|
||||||
|
$GLOBALS['db'] = $fakeDb;
|
||||||
|
|
||||||
|
try {
|
||||||
|
invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [
|
||||||
|
[
|
||||||
|
'all' => [
|
||||||
|
[
|
||||||
|
'customer_number' => 12345679,
|
||||||
|
'transactions' => [
|
||||||
|
invoicing_period_draft_overlay_transaction(61415, 17389),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'2026-05-11',
|
||||||
|
'2026-05-11',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if ($hadDb) {
|
||||||
|
$GLOBALS['db'] = $originalDb;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
}
|
||||||
|
invoicing_period_draft_overlay_reset_deleted_at_column_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($fakeDb->selectSql)->not->toContain('deleted_at IS NULL');
|
||||||
|
expect($fakeDb->selectSql)->toContain('id IN (17389)');
|
||||||
|
expect($fakeDb->selectSql)->toContain('processor = 1');
|
||||||
|
expect($fakeDb->selectSql)->toContain('error_message IS NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks fixed-pricing and subscription customer-level work when a relevant valid draft exists', function (): void {
|
||||||
|
$draft = invoicing_period_draft_overlay_draft(3001, 45454545, true);
|
||||||
|
|
||||||
|
$customer = [
|
||||||
|
'customer_number' => 45454545,
|
||||||
|
'customer_name' => 'Subscription Draft Customer',
|
||||||
|
'requires_action' => true,
|
||||||
|
'transactions' => [],
|
||||||
|
'meta' => [
|
||||||
|
'fixed_pricing' => [
|
||||||
|
'price' => 1200,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [
|
||||||
|
$customer,
|
||||||
|
[],
|
||||||
|
[45454545 => [$draft]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['requires_action'])->toBeFalse();
|
||||||
|
expect($result['draft'])->toBe([
|
||||||
|
'has_valid_draft' => true,
|
||||||
|
'invoice_collection_ids' => [3001],
|
||||||
|
'is_action_blocked' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps queue blocking ahead of the draft label when all work is covered by queue or draft state', function (): void {
|
||||||
|
$draft = invoicing_period_draft_overlay_draft(5002, 46464646, true);
|
||||||
|
|
||||||
|
$customer = [
|
||||||
|
'customer_number' => 46464646,
|
||||||
|
'customer_name' => 'Queue And Draft Customer',
|
||||||
|
'requires_action' => true,
|
||||||
|
'transactions' => [
|
||||||
|
invoicing_period_draft_overlay_transaction(801, 5001, false, false, 'QUEUED'),
|
||||||
|
invoicing_period_draft_overlay_transaction(802, 5002),
|
||||||
|
],
|
||||||
|
'queue' => [
|
||||||
|
'has_active_job' => true,
|
||||||
|
'statuses' => ['QUEUED'],
|
||||||
|
'invoice_collection_ids' => [5001],
|
||||||
|
'is_action_blocked' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [
|
||||||
|
$customer,
|
||||||
|
[5002 => $draft],
|
||||||
|
[46464646 => [$draft]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result['requires_action'])->toBeFalse();
|
||||||
|
expect($result['queue']['is_action_blocked'])->toBeTrue();
|
||||||
|
expect($result['draft'])->toBe([
|
||||||
|
'has_valid_draft' => true,
|
||||||
|
'invoice_collection_ids' => [5002],
|
||||||
|
'is_action_blocked' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -139,3 +139,22 @@ it('blocks fixed-pricing or subscription customers with no transactions when a r
|
|||||||
'is_action_blocked' => true,
|
'is_action_blocked' => true,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes targeted customer number filters from comma-separated or repeated values', function (): void {
|
||||||
|
expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [
|
||||||
|
'42424242, 43434343,42424242,0,not-a-number',
|
||||||
|
]))->toBe([42424242, 43434343]);
|
||||||
|
|
||||||
|
expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [
|
||||||
|
['45454545', 45454545, '46464646'],
|
||||||
|
]))->toBe([45454545, 46464646]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters period customer number candidates to targeted customers only', function (): void {
|
||||||
|
$result = invoicing_period_queue_overlay_invoke('filterCustomerNumbers', [
|
||||||
|
[42424242, '43434343', 45454545],
|
||||||
|
[43434343, 99999999],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result)->toBe([43434343]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user