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:
Jeppe Bundgaard
2026-05-11 18:18:08 +02:00
parent bea7e5697b
commit 6d4066be1c
26 changed files with 2563 additions and 43 deletions
+213
View File
@@ -6165,6 +6165,34 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'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:
get:
tags:
@@ -6224,6 +6252,57 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'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:
post:
tags:
@@ -13881,6 +13960,140 @@ components:
- meta
- 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:
type: object
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']);
}
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
*/
@@ -193,6 +319,8 @@ class economic_transfer_queue
throw new Exception('Failed to retry queue job');
}
$this->clearDismissalsForJob($job_id);
$job = $this->getJobById($job_id);
if ($job === null) {
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.
*/
@@ -27,12 +27,20 @@ class economic_transfer_queue_details_summary
'customer_number' => self::toPositiveInt(
$result['customer_number']
?? $result['user']['customer_number']
?? $payload['customer_number']
?? $payload['customer']['customer_number']
?? null
),
'name' => self::toNonEmptyString(
$result['customer_name']
?? $result['user']['customer_name']
?? $result['user']['display_name']
?? $result['user']['name']
?? $result['user']['company_name']
?? $payload['customer_name']
?? $payload['customer']['customer_name']
?? $payload['customer']['display_name']
?? $payload['customer']['name']
?? null
),
],
@@ -42,6 +42,18 @@ class economic_transfer_queue_schema_bootstrap
) 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;
}
}
@@ -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;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
"test:api": [
"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": [
"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.
$this->processor->set(ECONOMIC_PROCESSOR);
$this->error_message->nullify();
// Object changed
self::objectChanged();
return $this;
+213
View File
@@ -6507,6 +6507,34 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'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:
get:
tags:
@@ -6566,6 +6594,57 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'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:
post:
tags:
@@ -14083,6 +14162,140 @@ components:
- meta
- 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:
type: object
description: Result of comparing a collected invoice with its E-conomic counterpart
@@ -34,6 +34,8 @@ class InvoicingPeriodRoute
*/
private static array $departmentExcludedFromInvoicingCache = [];
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
/**
* @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.
* Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override.
@@ -196,11 +256,15 @@ class InvoicingPeriodRoute
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$customerNumbers = $this->getOptionalCustomerNumbersParameter();
// Add date from and date to to the response meta
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
if ($customerNumbers !== null) {
$response->add_meta('customer_numbers', $customerNumbers);
}
// Get the invoicing period for the user
$response->success([...self::getInvoicingPeriod($dateFrom, $dateTo)]);
$response->success([...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)]);
} else {
// Log the incident
(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
*/
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)
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
? self::normalizeCustomerNumbers($onlyCustomerNumbers)
: null;
$customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) {
return self::getCustomersWithTransactions($dateFrom, $dateTo);
$customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) {
return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
}, 'customers_with_transactions');
$types = [];
// Add the customers with transactions to the types array
$types['all'] = $customersWithTransactions;
$types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions);
$types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'vehicle_subscriptions');
$types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions);
$types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'fixed_pricing');
$types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions);
$types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'tank_cleaning');
$types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions);
$types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'special_arrangements');
$types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions);
$types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'invoice_per_order');
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) {
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions);
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
}, 'possible_duplicates');
$queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) {
return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo);
@@ -1139,6 +1206,14 @@ class InvoicingPeriodRoute
$queueOverlay['by_collection_id'] ?? [],
$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 [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
@@ -1173,6 +1248,13 @@ class InvoicingPeriodRoute
*/
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
$customers = self::debugGetTime(function () use ($dateFrom, $dateTo) {
return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo);
@@ -1311,6 +1393,7 @@ class InvoicingPeriodRoute
'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action),
'meta' => $meta ?? [],
'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
{
$overlay = [
@@ -1467,6 +1559,24 @@ class InvoicingPeriodRoute
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(
array $types,
array $queueJobsByCollectionId,
@@ -1582,6 +1692,235 @@ class InvoicingPeriodRoute
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
{
$meta = $customer['meta'] ?? [];
@@ -1594,14 +1933,17 @@ class InvoicingPeriodRoute
/**
* @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 === 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
$customer_numbers = (new \objects\users_o())->getCustomersWithVehicleSubscriptions();
$customer_numbers = self::filterCustomerNumbers(
(new \objects\users_o())->getCustomersWithVehicleSubscriptions(),
$onlyCustomerNumbers
);
// Get all customers with vehicle subscriptions
$subscriptions = [];
/** @var int $customer_number */
@@ -1651,10 +1993,13 @@ class InvoicingPeriodRoute
/**
* @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
$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 === null) {
@@ -1727,14 +2072,17 @@ class InvoicingPeriodRoute
/**
* @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 === 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
$customer_numbers = (new \objects\users_o())->getCustomersWithTankCleaning();
$customer_numbers = self::filterCustomerNumbers(
(new \objects\users_o())->getCustomersWithTankCleaning(),
$onlyCustomerNumbers
);
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
// Get all customers with tank cleaning
$tank_cleaning = [];
@@ -1772,14 +2120,17 @@ class InvoicingPeriodRoute
/**
* @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 === null) {
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
}
// 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
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
$special_arrangements = [];
@@ -1796,13 +2147,16 @@ class InvoicingPeriodRoute
/**
* @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
$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
if ($customersWithTransactions === null) {
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo);
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
}
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
@@ -1818,12 +2172,15 @@ class InvoicingPeriodRoute
/**
* @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 === 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
$orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo);
// Get the customer numbers from the orders
@@ -1834,6 +2191,9 @@ class InvoicingPeriodRoute
foreach ( $orders as $order ) {
// Get the customer number from the order
$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
if (isset($tmp_customer_arr[$customer_number])) {
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 () {
global $response;
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 () {
global $response;
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
{
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
{
global $db;
@@ -2143,6 +2302,13 @@ class orderInvoicesRoute
private function withCollectedInvoiceQueueDetailsSummary(array $job): array
{
$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;
}
@@ -2153,6 +2319,72 @@ class orderInvoicesRoute
}, $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
*/
+47
View File
@@ -7,6 +7,7 @@ use classes\attachment_store;
use classes\attachments;
use classes\authentication;
use classes\economic;
use classes\order_reference_suggestions_service;
use classes\orders_input_normalizer;
use classes\response;
use classes\stripe;
@@ -28,6 +29,52 @@ class ordersRoute
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 () {
// Require the user to be logged in
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
{
$statements = [
@@ -450,6 +494,24 @@ function api_fixture_cleanup_count(mysqli $db, string $sql): int
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
*/
@@ -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 {
api_test_covers('POST /economic/config', 'happy');
api_fixtures()->preserveModuleConfig('economic', 'transactionDraftCustomerNumber');
$session = api_fixtures()->createUserSession(['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' => [
'GET /ping',
'PUT /order',
'GET /orders/reference-suggestions',
],
'happy_only_operations' => [
'GET /ping',
@@ -935,13 +935,44 @@ final class ApiFixtures
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
{
$moduleEscaped = $this->db->real_escape_string($module);
$variableEscaped = $this->db->real_escape_string($variable);
$existing = $this->queryOneBySql(
"SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1"
);
$existing = $this->fetchModuleConfig($module, $variable);
if ($existing !== null) {
$conditions = [
@@ -1876,6 +1907,16 @@ final class ApiFixtures
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
{
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_reg_1` (`reg_1`)
) 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,
'order_items' => <<<'SQL'
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.';
}
$this->assertApiDatabaseTargetIsSafe();
try {
$this->bootstrapEnvironment();
$this->bootstrapSchemaIfRequested();
@@ -297,6 +299,8 @@ final class ApiTestRuntime
$database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
$this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port);
if ($host === '' || $user === '' || $database === '') {
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;
}
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.'
);
}
}
}
@@ -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 {
$expectations = [
[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('private function withCollectedInvoiceQueueDetailsSummary(array $job): 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('only failed jobs can be retried');
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 {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
@@ -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);
$queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
expect($guard_count)->toBe(4);
expect($queue_init_count)->toBe(6);
expect($guard_count)->toBe(7);
expect($queue_init_count)->toBe(9);
$queue_endpoint_patterns = [
"/\\\$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->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\\/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('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 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('private function processPendingInternal(int $limit = 5, ?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/stripe/book:');
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/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('/economic/invoice/draft/export:');
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('EconomicTransferQueueRunResponse:');
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 {
@@ -101,9 +107,12 @@ it('documents 200 fallback plus 202 queue contracts for export endpoints and kee
}
$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/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, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/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(
$content,
'/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('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('payload_json JSON NOT 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 {
@@ -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,
]);
});
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]);
});