Add invoice period review workflow (#336)

Improve the superuser invoice-period review API, stale-preview protection, queue visibility, review blockers, and e-conomic eligibility.
This commit is contained in:
Jeppe B
2026-08-02 19:20:50 +02:00
committed by GitHub
parent 1e0e051775
commit c795df4aad
16 changed files with 1377 additions and 39 deletions
@@ -43,6 +43,7 @@ class economic_transfer_queue
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by); $active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
if ($active_job !== null) { if ($active_job !== null) {
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
$target_label = $this->buildTargetLabel($transfer_type, $payload); $target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent( $this->logQueueEvent(
1, 1,
@@ -75,6 +76,8 @@ class economic_transfer_queue
$job_id = (int)$db->insert_id(); $job_id = (int)$db->insert_id();
$stmt->close(); $stmt->close();
$this->registerJobRequester($job_id, $created_by);
$this->logQueueEvent( $this->logQueueEvent(
1, 1,
$created_by, $created_by,
@@ -145,12 +148,19 @@ class economic_transfer_queue
return null; return null;
} }
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1"); $stmt = $db->prepare(
"SELECT q.*
FROM economic_transfer_queue_jobs q
LEFT JOIN economic_transfer_queue_job_requesters r
ON r.queue_job_id = q.id AND r.user_id = ?
WHERE q.id = ? AND (q.created_by = ? OR r.user_id = ?)
LIMIT 1"
);
if (!$stmt) { if (!$stmt) {
return null; return null;
} }
$stmt->bind_param('ii', $job_id, $created_by); $stmt->bind_param('iiii', $created_by, $job_id, $created_by, $created_by);
if (!$stmt->execute()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
return null; return null;
@@ -179,7 +189,8 @@ class economic_transfer_queue
$offset = max(0, $offset); $offset = max(0, $offset);
$where = $this->buildListJobsWhereClause($statuses, $transfer_type); $where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; $visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql); $result = $db->query($sql);
if (!$result instanceof mysqli_result) { if (!$result instanceof mysqli_result) {
@@ -203,7 +214,8 @@ class economic_transfer_queue
} }
$where = $this->buildListJobsWhereClause($statuses, $transfer_type); $where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; $visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where"; $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
$result = $db->query($sql); $result = $db->query($sql);
if (!$result instanceof mysqli_result) { if (!$result instanceof mysqli_result) {
@@ -242,6 +254,9 @@ class economic_transfer_queue
global $db; global $db;
$user_id = max(0, $user_id); $user_id = max(0, $user_id);
if ($user_id < 1) {
return [];
}
$limit = max(1, min(100, $limit)); $limit = max(1, min(100, $limit));
try { try {
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
@@ -262,7 +277,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status AND d.dismissed_status = q.status
WHERE q.created_by = $user_id WHERE " . $this->jobVisibilitySql('q', $user_id) . "
$transfer_condition $transfer_condition
AND ( AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
@@ -355,7 +370,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status AND d.dismissed_status = q.status
WHERE q.created_by = $user_id WHERE " . $this->jobVisibilitySql('q', $user_id) . "
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
$transfer_condition $transfer_condition
AND d.queue_job_id IS NULL AND d.queue_job_id IS NULL
@@ -389,6 +404,9 @@ class economic_transfer_queue
if ($existing_job === null) { if ($existing_job === null) {
throw new Exception('Queue job not found'); throw new Exception('Queue job not found');
} }
if ($created_by !== null && (int)($existing_job['created_by'] ?? 0) !== $created_by) {
throw new Exception('Only the queue job creator can retry this job');
}
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) { if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
throw new Exception('Only failed jobs can be retried'); throw new Exception('Only failed jobs can be retried');
} }
@@ -727,6 +745,38 @@ class economic_transfer_queue
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
} }
private function registerJobRequester(int $job_id, int $user_id): void
{
global $db;
if ($job_id < 1 || $user_id < 1) {
return;
}
$stmt = $db->prepare(
"INSERT INTO economic_transfer_queue_job_requesters (queue_job_id, user_id, requested_at)
VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE requested_at = VALUES(requested_at)"
);
if (!$stmt) {
throw new Exception('Failed to prepare queue requester registration');
}
$stmt->bind_param('ii', $job_id, $user_id);
if (!$stmt->execute()) {
$stmt->close();
throw new Exception('Failed to register queue requester');
}
$stmt->close();
}
private function jobVisibilitySql(string $alias, int $user_id): string
{
$user_id = max(0, $user_id);
return "($alias.created_by = $user_id OR EXISTS (
SELECT 1 FROM economic_transfer_queue_job_requesters requester
WHERE requester.queue_job_id = $alias.id AND requester.user_id = $user_id
))";
}
/** /**
* Release jobs stuck in PROCESSING due to crashes or killed workers. * Release jobs stuck in PROCESSING due to crashes or killed workers.
*/ */
@@ -843,8 +893,8 @@ class economic_transfer_queue
{ {
global $db; global $db;
$created_by = max(0, $created_by); // Active work is unique by transfer type and business target across all requesting users.
if ($target_value < 1 || $created_by < 1) { if ($target_value < 1) {
return null; return null;
} }
@@ -854,7 +904,6 @@ class economic_transfer_queue
WHERE transfer_type = ? WHERE transfer_type = ?
AND status IN (?, ?) AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
AND created_by = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT 1" LIMIT 1"
); );
@@ -864,7 +913,7 @@ class economic_transfer_queue
$queued = self::STATUS_QUEUED; $queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING; $processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by); $stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
if (!$stmt->execute()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
return null; return null;
@@ -54,6 +54,17 @@ class economic_transfer_queue_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
); );
$db->query(
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters (
queue_job_id BIGINT UNSIGNED NOT NULL,
user_id INT NOT NULL,
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (queue_job_id, user_id),
INDEX idx_economic_transfer_queue_job_requesters_user (user_id, queue_job_id),
INDEX idx_economic_transfer_queue_job_requesters_job (queue_job_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true; self::$initialized = true;
} }
} }
@@ -10,6 +10,10 @@ use objects\orders_o;
use objects\products_o; use objects\products_o;
use objects\users_o; use objects\users_o;
class invoice_collection_bulk_action_conflict extends Exception
{
}
class invoice_collection_bulk_action_service class invoice_collection_bulk_action_service
{ {
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations'; public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
@@ -35,6 +39,7 @@ class invoice_collection_bulk_action_service
$options = $this->normalizeOptions($options); $options = $this->normalizeOptions($options);
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale); $preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
$preview['content_digest'] = $this->previewContentDigest($preview);
$previewId = $this->previewId(); $previewId = $this->previewId();
$preview['preview_id'] = $previewId; $preview['preview_id'] = $previewId;
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options); $preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
@@ -78,10 +83,6 @@ class invoice_collection_bulk_action_service
throw new Exception('Confirmation text does not match.'); throw new Exception('Confirmation text does not match.');
} }
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$lockedCollectionIds = [ $lockedCollectionIds = [
...$invoiceCollectionIds, ...$invoiceCollectionIds,
(int)($options['target_invoice_collection_id'] ?? 0), (int)($options['target_invoice_collection_id'] ?? 0),
@@ -95,6 +96,24 @@ class invoice_collection_bulk_action_service
); );
} }
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
$freshDigest = $this->previewContentDigest($freshPreview);
if (!hash_equals((string)($cached['preview']['content_digest'] ?? ''), $freshDigest)) {
throw new invoice_collection_bulk_action_conflict(
'The invoice collections changed after preview. Refresh the preview before applying this action.'
);
}
$freshPreview['content_digest'] = $freshDigest;
if (!empty($freshPreview['blockers'])) {
throw new Exception((string)($freshPreview['blockers'][0]['message']
?? 'Action cannot be applied while blockers are present.'));
}
// Construct before the transaction because the queue bootstrap may run DDL on first use.
$economicQueue = $action === self::ACTION_QUEUE_ECONOMIC
? new economic_transfer_queue()
: null;
$db->conn()->begin_transaction(); $db->conn()->begin_transaction();
try { try {
$result = match ($action) { $result = match ($action) {
@@ -102,10 +121,12 @@ class invoice_collection_bulk_action_service
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options), self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview), self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview), self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => [ self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(
'queued_invoice_collection_ids' => $invoiceCollectionIds, $invoiceCollectionIds,
'changed_count' => count($invoiceCollectionIds), $options,
], $actorUserId,
$economicQueue
),
default => throw new Exception('Unsupported action'), default => throw new Exception('Unsupported action'),
}; };
(new logs_o())->add( (new logs_o())->add(
@@ -354,11 +375,15 @@ class invoice_collection_bulk_action_service
{ {
$blockers = []; $blockers = [];
foreach ($collections as $collection) { foreach ($collections as $collection) {
if (!empty($collection->booked_invoice_id->value())) { try {
self::assertCollectionCanQueueEconomic($collection);
} catch (\Throwable $e) {
$blockers[] = [ $blockers[] = [
'code' => 'collection_booked', 'code' => $e->getMessage() === economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE
? 'draft_customer_export_blocked'
: 'collection_export_ineligible',
'invoice_collection_id' => (int)$collection->id, 'invoice_collection_id' => (int)$collection->id,
'message' => 'Invoice collection is already booked.', 'message' => $e->getMessage(),
]; ];
} }
} }
@@ -443,6 +468,37 @@ class invoice_collection_bulk_action_service
return ['changed_count' => count($changed), 'order_item_ids' => $changed]; return ['changed_count' => count($changed), 'order_item_ids' => $changed];
} }
private function applyQueueEconomic(
array $invoiceCollectionIds,
array $options,
int $actorUserId,
?economic_transfer_queue $queue
): array
{
if ($queue === null) {
throw new Exception('E-conomic transfer queue is unavailable.');
}
$jobs = [];
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$jobs[] = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => (int)$invoiceCollectionId,
'send_as_is' => (bool)($options['send_as_is'] ?? false),
'requested_by' => $actorUserId,
],
$actorUserId
);
}
return [
'queued_invoice_collection_ids' => array_values(array_map('intval', $invoiceCollectionIds)),
'queue_job_ids' => array_values(array_map(static fn(array $job): int => (int)($job['id'] ?? 0), $jobs)),
'jobs' => $jobs,
'changed_count' => count($jobs),
];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array private function contentMutationBlockers(collected_order_invoices_o $collection): array
{ {
$blockers = []; $blockers = [];
@@ -522,8 +578,9 @@ class invoice_collection_bulk_action_service
private function collectionSummary(collected_order_invoices_o $collection): array private function collectionSummary(collected_order_invoices_o $collection): array
{ {
$invoiceCollectionId = (int)$collection->id;
return [ return [
'id' => (int)$collection->id, 'id' => $invoiceCollectionId,
'customer_number' => (int)$collection->customer_number->value(), 'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(), 'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(), 'created_at' => (string)$collection->created_at->value(),
@@ -531,9 +588,77 @@ class invoice_collection_bulk_action_service
'booked_invoice_id' => $collection->booked_invoice_id->value(), 'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(), 'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true), 'order_count' => (int)$collection->getOrders(true),
'export_content_digest' => $this->collectionExportContentDigest($invoiceCollectionId),
]; ];
} }
private function collectionExportContentDigest(int $invoiceCollectionId): string
{
global $db;
$invoiceCollectionId = max(0, $invoiceCollectionId);
if ($invoiceCollectionId < 1) {
throw new Exception('Invoice collection id is required for preview content validation.');
}
$result = $db->query(
"SELECT
o.id AS order_id,
o.customer_id,
o.reference AS order_reference,
o.notes AS order_notes,
o.department_id,
o.reg_1,
o.reg_2,
o.reg_3,
o.created_at,
o.completed_at,
o.include_in_invoice AS order_include_in_invoice,
o.po,
o.safety_seal,
oi.id AS order_item_id,
oi.product_id,
oi.reference AS order_item_reference,
oi.notes AS order_item_notes,
oi.price,
oi.quantity,
oi.related_item_id,
oi.include_in_invoice AS order_item_include_in_invoice,
p.name AS product_name,
p.economic_product_id
FROM orders o
LEFT JOIN order_items oi
ON oi.order_id = o.id AND oi.deleted_at IS NULL
LEFT JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
ORDER BY o.id ASC, oi.id ASC"
);
if (!$result) {
throw new Exception('Failed to load invoice collection contents for preview validation.');
}
$rows = [];
while (($row = $result->fetch_assoc()) !== null) {
$rows[] = $row;
}
$encoded = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new Exception('Failed to serialize invoice collection contents for preview validation.');
}
return hash('sha256', $encoded);
}
public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void
{
$collection->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collection->customer_number->value());
if ($collection->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
}
private function normalizeAction(string $action): string private function normalizeAction(string $action): string
{ {
$action = trim($action); $action = trim($action);
@@ -578,6 +703,18 @@ class invoice_collection_bulk_action_service
if (isset($options['target_invoice_collection_id'])) { if (isset($options['target_invoice_collection_id'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id']; $options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
} }
if (array_key_exists('send_as_is', $options)) {
$value = $options['send_as_is'];
if (is_bool($value)) {
$options['send_as_is'] = $value;
} elseif ((is_int($value) || is_string($value)) && in_array((string)$value, ['0', '1'], true)) {
$options['send_as_is'] = (string)$value === '1';
} elseif (is_string($value) && in_array(strtolower(trim($value)), ['true', 'false'], true)) {
$options['send_as_is'] = strtolower(trim($value)) === 'true';
} else {
throw new Exception('options.send_as_is must be a boolean.');
}
}
ksort($options); ksort($options);
return $options; return $options;
} }
@@ -591,6 +728,18 @@ class invoice_collection_bulk_action_service
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} }
private function previewContentDigest(array $preview): string
{
unset(
$preview['preview_id'],
$preview['selection_hash'],
$preview['confirmation_phrase'],
$preview['content_digest']
);
return hash('sha256', json_encode($preview, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string private function confirmationPhrase(string $locale): string
{ {
$language = strtolower(substr(trim($locale), 0, 2)); $language = strtolower(substr(trim($locale), 0, 2));
@@ -76,8 +76,11 @@ class invoice_period_flag_service
$this->nullableIntSql($userId > 0 ? $userId : null) $this->nullableIntSql($userId > 0 ? $userId : null)
); );
$db->query($sql); $db->query($sql);
$flagId = (int)$db->insert_id();
return $this->getStoredFlag((int)$db->insert_id()); $this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($flagId);
} }
public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array
@@ -107,6 +110,8 @@ class invoice_period_flag_service
); );
$db->query($sql); $db->query($sql);
$this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($id); return $this->getStoredFlag($id);
} }
@@ -227,6 +232,61 @@ class invoice_period_flag_service
return $types; return $types;
} }
/**
* Add only aggregate active manual-flag counts for readiness derivation.
* Flag details remain absent when the caller lacks list_invoice_period_flags.
*
* @param array<string,array<int,array<string,mixed>>> $types
* @param int[]|null $onlyCustomerNumbers
* @return array<string,array<int,array<string,mixed>>>
*/
public function applyManualFlagCountsToPeriodTypes(
array $types,
string $dateFrom,
string $dateTo,
?array $onlyCustomerNumbers = null
): array {
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
return $this->applyManualFlagCounts($types, $manualFlags);
}
private function applyManualFlagCounts(array $types, array $manualFlags): array
{
$flagsByCustomerNumber = [];
foreach ($manualFlags as $flag) {
$customerNumber = (int)($flag['customer_number'] ?? 0);
if ($customerNumber > 0) {
$flagsByCustomerNumber[$customerNumber][] = $flag;
}
}
foreach ($types as $typeName => $customers) {
foreach ($customers as $index => $customer) {
$customerNumber = (int)($customer['customer_number'] ?? 0);
$customerFlags = $this->flagsForCustomerCard(
$customer,
$flagsByCustomerNumber[$customerNumber] ?? [],
(string)$typeName
);
$activeManualCount = count(array_filter(
$customerFlags,
static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL
&& ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE
));
$existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0);
$types[$typeName][$index]['flag_counts'] = [
'manual' => $activeManualCount,
'automatic' => $existingAutomaticCount,
'total' => $activeManualCount + $existingAutomaticCount,
];
unset($types[$typeName][$index]['flags']);
}
}
return $types;
}
private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
{ {
$transactionIds = []; $transactionIds = [];
@@ -331,6 +391,12 @@ class invoice_period_flag_service
} }
} }
private function refreshManualFlagsCacheAfterMutation(): void
{
$this->manualFlagsInstanceCache = null;
$this->warmManualFlagsCache();
}
private function fetchActiveManualFlagsFromDb(): array private function fetchActiveManualFlagsFromDb(): array
{ {
global $db; global $db;
+163 -2
View File
@@ -7592,7 +7592,19 @@ paths:
description: Bulk action preview created successfully description: Bulk action preview created successfully
content: content:
application/json: application/json:
schema: {} schema:
type: object
additionalProperties: true
properties:
data:
type: object
additionalProperties: true
required: [preview_id, content_digest, selection_hash, confirmation_phrase]
properties:
preview_id: {type: string}
content_digest: {type: string, pattern: '^[a-f0-9]{64}$'}
selection_hash: {type: string, pattern: '^[a-f0-9]{64}$'}
confirmation_phrase: {type: string}
/collected-invoices/bulk-actions/apply: /collected-invoices/bulk-actions/apply:
post: post:
tags: tags:
@@ -7645,6 +7657,7 @@ paths:
content: content:
application/json: application/json:
schema: {} schema: {}
'409': {$ref: '#/components/responses/Conflict'}
/collected-invoices/economic: /collected-invoices/economic:
post: post:
@@ -8242,6 +8255,79 @@ paths:
in: query in: query
required: true required: true
schema: {type: string, format: date} schema: {type: string, format: date}
- name: periodView
in: query
schema:
type: string
enum: [all, vehicle_subscriptions, fixed_pricing, tank_cleaning, special_arrangements, invoice_per_order, possible_duplicates]
default: all
- name: page
in: query
schema: {type: integer, minimum: 1, default: 1}
- name: limit
in: query
description: Page size from 1 through 500, or `all` for the complete selected view.
schema:
oneOf:
- {type: integer, minimum: 1, maximum: 500}
- {type: string, enum: [all]}
- name: search
in: query
description: Case-insensitive search across customer, order, collection, queue, flag, and review metadata.
schema: {type: string}
- name: flagTab
in: query
schema:
type: string
enum: [all, red, yellow, none, filters]
default: all
- name: includeRequiresAction
in: query
schema: {type: boolean, default: true}
- name: includeBooked
in: query
schema: {type: boolean, default: true}
- name: reviewState
in: query
description: Repeatable derived workflow states. Comma-separated values are also accepted; multiple values use OR semantics.
style: form
explode: true
schema:
type: array
items: {type: string, enum: [blocked, attention, queued, ready, completed]}
- name: severity
in: query
description: Repeatable derived review severities. Comma-separated values are also accepted; multiple values use OR semantics.
style: form
explode: true
schema:
type: array
items: {type: string, enum: [red, yellow, blue, green]}
- name: invoiceState
in: query
description: Repeatable invoice states present on a customer card. Comma-separated values are also accepted.
style: form
explode: true
schema:
type: array
items: {type: string, enum: [open, closed, economic_draft, economic_booked]}
- name: departmentId
in: query
description: Repeatable department IDs present on a customer card. Comma-separated values are also accepted.
style: form
explode: true
schema:
type: array
items: {type: integer, minimum: 1}
- name: sort
in: query
schema:
type: string
enum: [priority, customer_name, customer_number, total_amount]
default: customer_name
- name: direction
in: query
schema: {type: string, enum: [asc, desc], default: asc}
responses: responses:
'200': '200':
description: Invoicing periods retrieved successfully description: Invoicing periods retrieved successfully
@@ -20562,6 +20648,11 @@ components:
$ref: '#/components/schemas/InvoicingPeriodData' $ref: '#/components/schemas/InvoicingPeriodData'
meta: meta:
type: object type: object
properties:
date_from: {type: string, format: date-time}
date_to: {type: string, format: date-time}
pagination:
$ref: '#/components/schemas/InvoicingPeriodPagination'
additionalProperties: true additionalProperties: true
includes: includes:
type: object type: object
@@ -20586,7 +20677,7 @@ components:
InvoicingPeriodCustomer: InvoicingPeriodCustomer:
type: object type: object
required: [customer_number, customer_name, transactions, invoice_collections] required: [customer_number, customer_name, transactions, invoice_collections, review]
additionalProperties: true additionalProperties: true
properties: properties:
customer_number: customer_number:
@@ -20601,6 +20692,73 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection' $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
review:
$ref: '#/components/schemas/InvoicingPeriodReview'
InvoicingPeriodReview:
type: object
required: [state, severity, reasons, next_action, is_actionable, counts]
properties:
state: {type: string, enum: [blocked, attention, queued, ready, completed]}
severity: {type: string, enum: [red, yellow, blue, green]}
reasons:
type: array
items:
type: object
required: [code, severity, count]
properties:
code: {type: string}
severity: {type: string, enum: [red, yellow, blue, green]}
count: {type: integer, minimum: 1}
next_action:
type: string
enum: [resolve_manual_flags, resolve_collection_errors, resolve_draft, review_warnings, wait_for_export, create_invoice, none]
is_actionable: {type: boolean}
counts:
type: object
required: [active_manual_flags, active_automatic_flags, collection_errors, active_queue_jobs, booked_transactions, unbooked_transactions]
additionalProperties:
type: integer
minimum: 0
InvoicingPeriodPagination:
type: object
required: [page, per_page, total, total_pages, search, filters, order, facets]
properties:
page: {type: integer, minimum: 1}
per_page:
oneOf:
- {type: integer, minimum: 1, maximum: 500}
- {type: string, enum: [all]}
total: {type: integer, minimum: 0}
total_pages: {type: integer, minimum: 1}
search: {type: string}
filters:
type: object
additionalProperties: true
order:
type: object
required: [field, direction]
properties:
field: {type: string, enum: [priority, customer_name, customer_number, total_amount]}
direction: {type: string, enum: [asc, desc]}
facets:
type: object
required: [review_states, severities, invoice_states, department_ids]
properties:
review_states: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'}
severities: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'}
invoice_states: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'}
department_ids: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'}
InvoicingPeriodFacetValues:
type: array
items:
type: object
required: [value, count]
properties:
value: {type: string}
count: {type: integer, minimum: 0}
InvoicingPeriodTransaction: InvoicingPeriodTransaction:
type: object type: object
@@ -20651,6 +20809,9 @@ components:
booked_invoice_id: booked_invoice_id:
type: integer type: integer
nullable: true nullable: true
error_message:
type: string
nullable: true
state: state:
type: string type: string
enum: [open, closed, economic_draft, economic_booked] enum: [open, closed, economic_draft, economic_booked]
@@ -350,8 +350,15 @@ class InvoicingPeriodRoute
'page', 'page',
'limit', 'limit',
'search', 'search',
'flagTab',
'includeRequiresAction', 'includeRequiresAction',
'includeBooked', 'includeBooked',
'reviewState',
'severity',
'invoiceState',
'departmentId',
'sort',
'direction',
]; ];
$isPaginatedRequest = false; $isPaginatedRequest = false;
@@ -366,7 +373,34 @@ class InvoicingPeriodRoute
return null; return null;
} }
return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); $parameters = $response->getAllRequestParameters();
foreach (['reviewState', 'severity', 'invoiceState', 'departmentId'] as $repeatableKey) {
$repeatedValues = self::getRepeatedPeriodQueryValues($repeatableKey);
if ($repeatedValues !== []) {
$parameters[$repeatableKey] = $repeatedValues;
}
}
return self::normalizePeriodPaginationOptions($parameters);
}
private static function getRepeatedPeriodQueryValues(string $key): array
{
$queryString = (string)($_SERVER['QUERY_STRING'] ?? '');
if ($queryString === '') {
return [];
}
$values = [];
foreach (explode('&', $queryString) as $part) {
[$rawName, $rawValue] = array_pad(explode('=', $part, 2), 2, '');
$name = urldecode($rawName);
if ($name === $key || $name === $key . '[]') {
$values[] = urldecode(str_replace('+', ' ', $rawValue));
}
}
return $values;
} }
private static function normalizePeriodPaginationOptions(array $parameters): array private static function normalizePeriodPaginationOptions(array $parameters): array
@@ -399,12 +433,37 @@ class InvoicingPeriodRoute
$flagTab = 'all'; $flagTab = 'all';
} }
$allowedSortFields = ['priority', 'customer_name', 'customer_number', 'total_amount'];
$sort = trim((string)($parameters['sort'] ?? 'customer_name'));
if (!in_array($sort, $allowedSortFields, true)) {
$sort = 'customer_name';
}
$direction = strtolower(trim((string)($parameters['direction'] ?? 'asc')));
if (!in_array($direction, ['asc', 'desc'], true)) {
$direction = 'asc';
}
return [ return [
'periodView' => $periodView, 'periodView' => $periodView,
'page' => $page, 'page' => $page,
'limit' => $limit, 'limit' => $limit,
'search' => trim((string)($parameters['search'] ?? '')), 'search' => trim((string)($parameters['search'] ?? '')),
'flagTab' => $flagTab, 'flagTab' => $flagTab,
'reviewStates' => self::normalizePeriodFilterValues(
$parameters['reviewState'] ?? null,
['blocked', 'attention', 'queued', 'ready', 'completed']
),
'severities' => self::normalizePeriodFilterValues(
$parameters['severity'] ?? null,
['red', 'yellow', 'blue', 'green']
),
'invoiceStates' => self::normalizePeriodFilterValues(
$parameters['invoiceState'] ?? null,
['open', 'closed', 'economic_draft', 'economic_booked']
),
'departmentIds' => self::normalizePeriodIntegerFilterValues($parameters['departmentId'] ?? null),
'sort' => $sort,
'direction' => $direction,
'includeRequiresAction' => self::parsePeriodBooleanOption( 'includeRequiresAction' => self::parsePeriodBooleanOption(
$parameters['includeRequiresAction'] ?? null, $parameters['includeRequiresAction'] ?? null,
true true
@@ -413,6 +472,38 @@ class InvoicingPeriodRoute
]; ];
} }
private static function normalizePeriodFilterValues(mixed $value, array $allowed): array
{
$values = is_array($value) ? $value : [$value];
$normalized = [];
foreach ($values as $entry) {
foreach (explode(',', (string)$entry) as $candidate) {
$candidate = strtolower(trim($candidate));
if ($candidate !== '' && in_array($candidate, $allowed, true)) {
$normalized[$candidate] = true;
}
}
}
return array_keys($normalized);
}
private static function normalizePeriodIntegerFilterValues(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
$normalized = [];
foreach ($values as $entry) {
foreach (explode(',', (string)$entry) as $candidate) {
$candidate = (int)trim($candidate);
if ($candidate > 0) {
$normalized[$candidate] = true;
}
}
}
return array_map('intval', array_keys($normalized));
}
private static function parsePeriodBooleanOption(mixed $value, bool $default): bool private static function parsePeriodBooleanOption(mixed $value, bool $default): bool
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {
@@ -439,6 +530,7 @@ class InvoicingPeriodRoute
$types = is_array($period['types'] ?? null) ? $period['types'] : []; $types = is_array($period['types'] ?? null) ? $period['types'] : [];
$types = self::ensurePeriodTypeKeys($types); $types = self::ensurePeriodTypeKeys($types);
$types = self::enrichPeriodCustomerMetaFromTypes($types); $types = self::enrichPeriodCustomerMetaFromTypes($types);
$types = self::enrichPeriodCustomerReview($types);
$types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? ''));
$types = self::filterPeriodTypesByVisibility( $types = self::filterPeriodTypesByVisibility(
$types, $types,
@@ -452,10 +544,17 @@ class InvoicingPeriodRoute
} }
$typeCounts = self::summarizePeriodTypes($types); $typeCounts = self::summarizePeriodTypes($types);
$facets = self::summarizePeriodReviewFacets($types[$periodView] ?? []);
if (!empty($options['flagTab'])) { if (!empty($options['flagTab'])) {
$types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']);
} }
$types = self::filterPeriodTypesByWorkflow($types, $options);
$types = self::sortPeriodTypes(
$types,
(string)($options['sort'] ?? 'customer_name'),
(string)($options['direction'] ?? 'asc')
);
$total = count($types[$periodView] ?? []); $total = count($types[$periodView] ?? []);
$limit = $options['limit'] ?? 100; $limit = $options['limit'] ?? 100;
@@ -488,15 +587,121 @@ class InvoicingPeriodRoute
'filters' => [ 'filters' => [
'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true),
'includeBooked' => (bool)($options['includeBooked'] ?? true), 'includeBooked' => (bool)($options['includeBooked'] ?? true),
'flagTab' => (string)($options['flagTab'] ?? 'all'),
'reviewState' => array_values($options['reviewStates'] ?? []),
'severity' => array_values($options['severities'] ?? []),
'invoiceState' => array_values($options['invoiceStates'] ?? []),
'departmentId' => array_values($options['departmentIds'] ?? []),
], ],
'order' => [ 'order' => [
'field' => 'customer_name', 'field' => (string)($options['sort'] ?? 'customer_name'),
'direction' => 'asc', 'direction' => (string)($options['direction'] ?? 'asc'),
], ],
'facets' => $facets,
], ],
]; ];
} }
private static function enrichPeriodCustomerReview(array $types): array
{
foreach ($types as $typeName => $customers) {
foreach ((array)$customers as $index => $customer) {
if (is_array($customer)) {
$types[$typeName][$index]['review'] = self::derivePeriodCustomerReview($customer);
}
}
}
return $types;
}
private static function derivePeriodCustomerReview(array $customer): array
{
$flagCounts = self::getActivePeriodFlagCounts($customer);
$counts = [
'active_manual_flags' => $flagCounts['manual'],
'active_automatic_flags' => $flagCounts['automatic'],
'collection_errors' => 0,
'active_queue_jobs' => 0,
'booked_transactions' => 0,
'unbooked_transactions' => 0,
];
$reasons = [];
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
if (is_array($collection) && trim((string)($collection['error_message'] ?? '')) !== '') {
$counts['collection_errors']++;
}
}
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
if (!is_array($transaction) || (bool)($transaction['excluded'] ?? false)) {
continue;
}
$key = (bool)($transaction['booked'] ?? false) ? 'booked_transactions' : 'unbooked_transactions';
$counts[$key]++;
}
if ((bool)($customer['queue']['has_active_job'] ?? false)) {
$counts['active_queue_jobs'] = max(
1,
count((array)($customer['queue']['invoice_collection_ids'] ?? []))
);
}
if ($counts['active_manual_flags'] > 0) {
$reasons[] = self::periodReviewReason('manual_flags', 'red', $counts['active_manual_flags']);
}
if ($counts['collection_errors'] > 0) {
$reasons[] = self::periodReviewReason('collection_errors', 'red', $counts['collection_errors']);
}
if ((bool)($customer['draft']['is_action_blocked'] ?? false)) {
$reasons[] = self::periodReviewReason('draft_blocks_action', 'red', 1);
}
$requiresAttention = (bool)($customer['requires_action'] ?? false)
&& $counts['unbooked_transactions'] === 0;
if ($requiresAttention) {
$reasons[] = self::periodReviewReason('requires_action', 'yellow', 1);
}
if ($counts['active_automatic_flags'] > 0) {
$reasons[] = self::periodReviewReason('automatic_warnings', 'yellow', $counts['active_automatic_flags']);
}
if ($counts['active_queue_jobs'] > 0) {
$reasons[] = self::periodReviewReason('export_in_progress', 'blue', $counts['active_queue_jobs']);
}
if ($counts['unbooked_transactions'] > 0) {
$reasons[] = self::periodReviewReason('unbooked_transactions', 'green', $counts['unbooked_transactions']);
}
if ($counts['active_manual_flags'] > 0) {
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_manual_flags'];
} elseif ($counts['collection_errors'] > 0) {
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_collection_errors'];
} elseif ((bool)($customer['draft']['is_action_blocked'] ?? false)) {
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_draft'];
} elseif ($counts['active_automatic_flags'] > 0 || $requiresAttention) {
[$state, $severity, $nextAction] = ['attention', 'yellow', 'review_warnings'];
} elseif ($counts['active_queue_jobs'] > 0) {
[$state, $severity, $nextAction] = ['queued', 'blue', 'wait_for_export'];
} elseif ($counts['booked_transactions'] > 0 && $counts['unbooked_transactions'] === 0) {
[$state, $severity, $nextAction] = ['completed', 'green', 'none'];
} else {
[$state, $severity, $nextAction] = ['ready', 'green', 'create_invoice'];
}
return [
'state' => $state,
'severity' => $severity,
'reasons' => $reasons,
'next_action' => $nextAction,
'is_actionable' => in_array($state, ['blocked', 'attention', 'ready'], true),
'counts' => $counts,
];
}
private static function periodReviewReason(string $code, string $severity, int $count): array
{
return ['code' => $code, 'severity' => $severity, 'count' => $count];
}
private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array
{ {
if (in_array($flagTab, ['all', 'filters', ''], true)) { if (in_array($flagTab, ['all', 'filters', ''], true)) {
@@ -523,6 +728,174 @@ class InvoicingPeriodRoute
return $types; return $types;
} }
private static function filterPeriodTypesByWorkflow(array $types, array $options): array
{
$reviewStates = array_fill_keys((array)($options['reviewStates'] ?? []), true);
$severities = array_fill_keys((array)($options['severities'] ?? []), true);
$invoiceStates = array_fill_keys((array)($options['invoiceStates'] ?? []), true);
$departmentIds = array_fill_keys(array_map('intval', (array)($options['departmentIds'] ?? [])), true);
if ($reviewStates === [] && $severities === [] && $invoiceStates === [] && $departmentIds === []) {
return $types;
}
foreach ($types as $typeName => $customers) {
$types[$typeName] = array_values(array_filter(
is_array($customers) ? $customers : [],
static function (array $customer) use (
$reviewStates,
$severities,
$invoiceStates,
$departmentIds
): bool {
if ($reviewStates !== [] && !isset($reviewStates[(string)($customer['review']['state'] ?? '')])) {
return false;
}
if ($severities !== [] && !isset($severities[(string)($customer['review']['severity'] ?? '')])) {
return false;
}
if ($invoiceStates !== [] && !self::periodCustomerMatchesInvoiceStates($customer, $invoiceStates)) {
return false;
}
if ($departmentIds !== [] && !self::periodCustomerMatchesDepartmentIds($customer, $departmentIds)) {
return false;
}
return true;
}
));
}
return $types;
}
private static function periodCustomerMatchesInvoiceStates(array $customer, array $allowed): bool
{
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
if (is_array($collection) && isset($allowed[(string)($collection['state'] ?? '')])) {
return true;
}
}
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
if (is_array($transaction) && isset($allowed[(string)($transaction['invoice_state'] ?? '')])) {
return true;
}
}
return false;
}
private static function periodCustomerMatchesDepartmentIds(array $customer, array $allowed): bool
{
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
if (is_array($transaction) && isset($allowed[(int)($transaction['department_id'] ?? 0)])) {
return true;
}
}
return false;
}
private static function sortPeriodTypes(array $types, string $field, string $direction): array
{
foreach ($types as $typeName => $customers) {
$customers = is_array($customers) ? array_values($customers) : [];
usort($customers, static function (array $a, array $b) use ($field, $direction): int {
$comparison = self::comparePeriodCustomers($a, $b, $field);
if ($comparison !== 0) {
return $direction === 'desc' ? -$comparison : $comparison;
}
return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0));
});
$types[$typeName] = $customers;
}
return $types;
}
private static function comparePeriodCustomers(array $a, array $b, string $field): int
{
if ($field === 'priority') {
$rank = ['blocked' => 0, 'attention' => 1, 'queued' => 2, 'ready' => 3, 'completed' => 4];
return ($rank[(string)($a['review']['state'] ?? '')] ?? 99)
<=> ($rank[(string)($b['review']['state'] ?? '')] ?? 99);
}
if ($field === 'customer_number') {
return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0));
}
if ($field === 'total_amount') {
return self::getPeriodCustomerTotalAmount($a) <=> self::getPeriodCustomerTotalAmount($b);
}
return strnatcasecmp((string)($a['customer_name'] ?? ''), (string)($b['customer_name'] ?? ''));
}
private static function summarizePeriodReviewFacets(array $customers): array
{
$facets = [
'review_states' => [],
'severities' => [],
'invoice_states' => [],
'department_ids' => [],
];
foreach ($customers as $customer) {
if (!is_array($customer)) {
continue;
}
self::incrementPeriodFacet($facets['review_states'], (string)($customer['review']['state'] ?? ''));
self::incrementPeriodFacet($facets['severities'], (string)($customer['review']['severity'] ?? ''));
$customerInvoiceStates = [];
$customerDepartmentIds = [];
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
if (is_array($collection)) {
$state = (string)($collection['state'] ?? '');
if ($state !== '') {
$customerInvoiceStates[$state] = true;
}
}
}
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
if (!is_array($transaction)) {
continue;
}
$state = (string)($transaction['invoice_state'] ?? '');
if ($state !== '') {
$customerInvoiceStates[$state] = true;
}
$departmentId = (int)($transaction['department_id'] ?? 0);
if ($departmentId > 0) {
$customerDepartmentIds[$departmentId] = true;
}
}
foreach (array_keys($customerInvoiceStates) as $state) {
self::incrementPeriodFacet($facets['invoice_states'], (string)$state);
}
foreach (array_keys($customerDepartmentIds) as $departmentId) {
self::incrementPeriodFacet($facets['department_ids'], (string)$departmentId);
}
}
foreach ($facets as $name => $counts) {
ksort($counts, SORT_NATURAL);
$facets[$name] = array_map(
static fn(string|int $value, int $count): array => ['value' => (string)$value, 'count' => $count],
array_keys($counts),
array_values($counts)
);
}
return $facets;
}
private static function incrementPeriodFacet(array &$counts, string $value): void
{
if ($value !== '') {
$counts[$value] = ($counts[$value] ?? 0) + 1;
}
}
private static function ensurePeriodTypeKeys(array $types): array private static function ensurePeriodTypeKeys(array $types): array
{ {
foreach (self::periodTypeNames() as $typeName) { foreach (self::periodTypeNames() as $typeName) {
@@ -637,6 +1010,10 @@ class InvoicingPeriodRoute
} }
} }
foreach (['invoice_collections', 'flags', 'review', 'queue', 'draft', 'meta', 'flag_counts'] as $field) {
self::appendPeriodSearchValues($values, $customer[$field] ?? null);
}
foreach ($values as $value) { foreach ($values as $value) {
if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) {
return true; return true;
@@ -646,6 +1023,19 @@ class InvoicingPeriodRoute
return false; return false;
} }
private static function appendPeriodSearchValues(array &$values, mixed $value): void
{
if (is_array($value)) {
foreach ($value as $entry) {
self::appendPeriodSearchValues($values, $entry);
}
return;
}
if (is_scalar($value)) {
$values[] = $value;
}
}
private static function normalizePeriodSearchTerm(string $value): string private static function normalizePeriodSearchTerm(string $value): string
{ {
return mb_strtolower(trim($value), 'UTF-8'); return mb_strtolower(trim($value), 'UTF-8');
@@ -1895,7 +2285,17 @@ class InvoicingPeriodRoute
$onlyCustomerNumbers $onlyCustomerNumbers
); );
}, 'invoice_period_flags'); }, 'invoice_period_flags');
} else {
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
return (new invoice_period_flag_service())->applyManualFlagCountsToPeriodTypes(
$types,
$dateFrom,
$dateTo,
$onlyCustomerNumbers
);
}, 'invoice_period_manual_flag_counts');
} }
$types = self::enrichPeriodCustomerReview($types);
return [ return [
'dateFrom' => $dateFrom, 'dateFrom' => $dateFrom,
'dateTo' => $dateTo, 'dateTo' => $dateTo,
@@ -11,6 +11,7 @@ use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service; use classes\economic_v2_revenue_statistics_service;
use classes\invoice_store; use classes\invoice_store;
use classes\invoice_collection_bulk_action_service; use classes\invoice_collection_bulk_action_service;
use classes\invoice_collection_bulk_action_conflict;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\response; use classes\response;
use classes\router; use classes\router;
@@ -818,6 +819,8 @@ class orderInvoicesRoute
(int)$user->id, (int)$user->id,
$locale $locale
); );
} catch (invoice_collection_bulk_action_conflict $e) {
$response->error($e->getMessage(), 409);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$response->error($e->getMessage(), 400); $response->error($e->getMessage(), 400);
} }
@@ -865,15 +868,11 @@ class orderInvoicesRoute
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected(); $collected_order_invoices->requireSelected();
try { try {
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$response->error($e->getMessage(), 400); $response->error($e->getMessage(), 400);
} }
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
}
if (!$this->isEconomicTransferQueueAvailable()) { if (!$this->isEconomicTransferQueueAvailable()) {
try { try {
$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is); $result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);
@@ -2736,8 +2735,7 @@ class orderInvoicesRoute
*/ */
private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void
{ {
$collected_order_invoices->requireSelected(); invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices);
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
} }
private function requireCollectedInvoiceBulkActionPermission(string $action): void private function requireCollectedInvoiceBulkActionPermission(string $action): void
@@ -252,3 +252,92 @@ it('merges selected invoice collections into the explicit target after confirmat
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id']) expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']); ->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']);
}); });
it('rejects apply when export-relevant order item content changed after preview', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'stale-export-content');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'stale-export-content');
$customer = api_fixtures()->createUser(['display_name' => 'Stale Bulk Preview Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]);
$sourceCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$product = api_fixtures()->createProduct(['name' => 'Digest Product', 'price' => 100]);
$item = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 100,
'quantity' => 1,
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse->assertStatus(200)->assertEnvelope()->assertSuccess();
$preview = $previewResponse->data();
api_test_runtime()->db()->query('UPDATE order_items SET price = 125 WHERE id = ' . (int)$item['id']);
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('The invoice collections changed after preview. Refresh the preview before applying this action.');
});
it('blocks queue economic preview and apply for the configured draft customer', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'draft-customer');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'draft-customer');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Bulk Draft Customer']);
$collection = api_fixtures()->createInvoiceCollection(['customer_number' => $draftCustomer['customer_number']]);
api_fixtures()->setModuleConfig(
'economic',
'transactionDraftCustomerNumber',
(string)$draftCustomer['customer_number'],
'int'
);
$session = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'queue_economic',
'invoice_collection_ids' => [$collection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse->assertStatus(200)->assertEnvelope()->assertSuccess();
$preview = $previewResponse->data();
expect($preview['blockers'][0] ?? null)->toMatchArray([
'code' => 'draft_customer_export_blocked',
'invoice_collection_id' => (int)$collection['id'],
'message' => \classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE,
]);
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'queue_economic',
'invoice_collection_ids' => [$collection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
});
@@ -97,6 +97,11 @@ function economic_transfer_queue_integration_db(): db
function economic_transfer_queue_cleanup_for_created_by(db $db, int $created_by): void function economic_transfer_queue_cleanup_for_created_by(db $db, int $created_by): void
{ {
$db->query(
"DELETE requester FROM economic_transfer_queue_job_requesters requester
LEFT JOIN economic_transfer_queue_jobs job ON job.id = requester.queue_job_id
WHERE requester.user_id = $created_by OR job.created_by = $created_by"
);
$db->query("DELETE FROM economic_transfer_queue_jobs WHERE created_by = $created_by"); $db->query("DELETE FROM economic_transfer_queue_jobs WHERE created_by = $created_by");
} }
@@ -181,6 +186,43 @@ it('deduplicates active jobs per transfer target', function (): void {
} }
}); });
it('registers later deduplicated requesters for narrowly scoped queue monitoring', function (): void {
$db = economic_transfer_queue_integration_db();
$creator = 921200 + random_int(1000, 9999);
$requester = 941200 + random_int(1000, 9999);
$unrelated = 951200 + random_int(1000, 9999);
$collected_invoice_id = 961200 + random_int(1000, 9999);
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
try {
economic_transfer_queue_cleanup_for_created_by($db, $creator);
economic_transfer_queue_cleanup_for_created_by($db, $requester);
$first = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
['collected_invoice_id' => $collected_invoice_id, 'requested_by' => $creator],
$creator
);
$deduplicated = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
['collected_invoice_id' => $collected_invoice_id, 'requested_by' => $requester],
$requester
);
expect((int)$deduplicated['id'])->toBe((int)$first['id'])
->and($queue->getJobByIdForUser((int)$first['id'], $requester))->not->toBeNull()
->and(array_column($queue->listJobsForCreatedBy([], 50, 0, null, $requester), 'id'))
->toContain((int)$first['id'])
->and(array_column($queue->listMonitorJobsForUser($requester), 'id'))
->toContain((int)$first['id'])
->and($queue->getJobByIdForUser((int)$first['id'], $unrelated))->toBeNull();
} finally {
economic_transfer_queue_cleanup_for_created_by($db, $requester);
economic_transfer_queue_cleanup_for_created_by($db, $creator);
$db->close();
}
});
it('processes only collected-invoice jobs and respects the manual batch limit', function (): void { it('processes only collected-invoice jobs and respects the manual batch limit', function (): void {
$db = economic_transfer_queue_integration_db(); $db = economic_transfer_queue_integration_db();
$created_by = 921500 + random_int(1000, 9999); $created_by = 921500 + random_int(1000, 9999);
@@ -18,7 +18,10 @@ it('scopes economic transfer queue route reads and retries to the current user',
expect($collected_invoice_route)->toContain('$queue->getJobByIdForUser($job_id, $created_by)'); expect($collected_invoice_route)->toContain('$queue->getJobByIdForUser($job_id, $created_by)');
expect($collected_invoice_route)->toContain('$queue->retryJobForUser($job_id, (int)$user->id)'); expect($collected_invoice_route)->toContain('$queue->retryJobForUser($job_id, (int)$user->id)');
expect($queue)->toContain('SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1'); expect($queue)->toContain('LEFT JOIN economic_transfer_queue_job_requesters r')
expect($queue)->toContain('WHERE q.created_by = $user_id'); ->and($queue)->toContain('q.created_by = ? OR r.user_id = ?')
expect($queue)->toContain('AND created_by = ?'); ->and($queue)->toContain('private function jobVisibilitySql(string $alias, int $user_id): string')
->and($queue)->toContain('requester.queue_job_id = $alias.id AND requester.user_id = $user_id')
->and($queue)->toContain('$this->registerJobRequester((int)($active_job[\'id\'] ?? 0), $created_by);')
->and($queue)->toContain('Only the queue job creator can retry this job');
}); });
@@ -15,6 +15,8 @@ it('defines economic transfer queue jobs schema bootstrap table and tracking col
expect($content)->toContain('user_id INT NOT NULL'); expect($content)->toContain('user_id INT NOT NULL');
expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL'); expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL');
expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)'); expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)');
expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters');
expect($content)->toContain('idx_economic_transfer_queue_job_requesters_user');
}); });
it('provides queue processor class constants and processing entrypoint', function (): void { it('provides queue processor class constants and processing entrypoint', function (): void {
@@ -0,0 +1,94 @@
<?php
app_require('classes/invoice_collection_bulk_action_service.php');
use classes\invoice_collection_bulk_action_service;
it('rejects stale bulk-action previews with a content digest and conflict response', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($service)
->toContain('$preview[\'content_digest\'] = $this->previewContentDigest($preview);')
->toContain('hash_equals((string)($cached[\'preview\'][\'content_digest\'] ?? \'\'), $freshDigest)')
->toContain('throw new invoice_collection_bulk_action_conflict(')
->and($route)
->toContain('catch (invoice_collection_bulk_action_conflict $e)')
->toContain('$response->error($e->getMessage(), 409);');
});
it('changes the stale-preview digest when an export-relevant line changes', function (): void {
global $db;
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$db = new class {
public array $rows = [];
public function query(string $sql): object
{
expect($sql)
->toContain('oi.price')
->toContain('oi.quantity')
->toContain('p.economic_product_id')
->toContain('o.reference AS order_reference');
return new class($this->rows) {
private int $index = 0;
public function __construct(private readonly array $rows)
{
}
public function fetch_assoc(): ?array
{
return $this->rows[$this->index++] ?? null;
}
};
}
};
try {
$service = (new ReflectionClass(invoice_collection_bulk_action_service::class))->newInstanceWithoutConstructor();
$method = (new ReflectionClass(invoice_collection_bulk_action_service::class))
->getMethod('collectionExportContentDigest');
$db->rows = [[
'order_id' => 91,
'order_item_id' => 191,
'product_id' => 7,
'price' => 100,
'quantity' => 1,
'product_name' => 'Wash',
'economic_product_id' => 700,
]];
$before = $method->invoke($service, 41);
$db->rows[0]['price'] = 125;
$after = $method->invoke($service, 41);
expect($before)->toMatch('/^[a-f0-9]{64}$/')
->and($after)->toMatch('/^[a-f0-9]{64}$/')
->and($after)->not->toBe($before);
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('enqueues bulk E-conomic transfers durably and deduplicates active jobs by global target', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$queue = (string)file_get_contents(app_path('classes/economic_transfer_queue.php'));
expect($service)
->toContain('self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(')
->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT')
->toContain("'queue_job_ids'")
->toContain('public static function assertCollectionCanQueueEconomic(')
->toContain('(new economic())->assertCustomerNumberIsNotDraft(')
->and($queue)
->toContain('Active work is unique by transfer type and business target across all requesting users.')
->toContain('CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, \'$json_path\')) AS UNSIGNED) = ?')
->not->toContain("CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$" . "json_path')) AS UNSIGNED) = ?\n AND created_by = ?");
});
@@ -1077,6 +1077,39 @@ it('validates supported manual flag fields by target type', function (): void {
invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']); invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']);
})->throws(InvalidArgumentException::class, 'Invalid order flag field.'); })->throws(InvalidArgumentException::class, 'Invalid order flag field.');
it('refreshes the shared manual flag cache immediately after create and status mutations', function (): void {
$content = (string)file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect(substr_count($content, '$this->refreshManualFlagsCacheAfterMutation();'))->toBe(2)
->and($content)->toContain('$this->manualFlagsInstanceCache = null;')
->and($content)->toContain('$this->warmManualFlagsCache();');
});
it('derives aggregate manual flag counts without returning restricted flag details', function (): void {
$types = [
'all' => [[
'customer_number' => 7701,
'transactions' => [['id' => 8801, 'invoice_collection_id' => 9901]],
]],
];
$manualFlags = [[
'id' => 41,
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
'target_id' => 7701,
'customer_number' => 7701,
]];
$result = invoice_period_flag_service_invoke('applyManualFlagCounts', [$types, $manualFlags]);
expect($result['all'][0]['flag_counts'])->toBe([
'manual' => 1,
'automatic' => 0,
'total' => 1,
])->and($result['all'][0])->not->toHaveKey('flags');
});
it('wires invoice period flag routes with explicit list create and update permissions', function (): void { it('wires invoice period flag routes with explicit list create and update permissions', function (): void {
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
@@ -23,3 +23,19 @@ it('documents period invoice states and authenticated attachment content', funct
expect($content)->toContain('enum: [draft, booked, none]'); expect($content)->toContain('enum: [draft, booked, none]');
} }
}); });
it('documents the exception-first period review contract and workflow queries', function (): void {
$content = (string)file_get_contents(app_path('openapi.yaml'));
expect($content)
->toContain('- name: reviewState')
->toContain('- name: severity')
->toContain('- name: invoiceState')
->toContain('- name: departmentId')
->toContain('enum: [priority, customer_name, customer_number, total_amount]')
->toContain('InvoicingPeriodReview:')
->toContain('required: [state, severity, reasons, next_action, is_actionable, counts]')
->toContain('enum: [blocked, attention, queued, ready, completed]')
->toContain('InvoicingPeriodPagination:')
->toContain('required: [review_states, severities, invoice_states, department_ids]');
});
@@ -69,6 +69,7 @@ it('detects paginated period mode only when pagination parameters are present',
$previousResponse = $GLOBALS['response'] ?? null; $previousResponse = $GLOBALS['response'] ?? null;
$previousGet = $_GET; $previousGet = $_GET;
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
$previousQueryString = $_SERVER['QUERY_STRING'] ?? null;
$response = new response(); $response = new response();
try { try {
@@ -80,6 +81,22 @@ it('detects paginated period mode only when pagination parameters are present',
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull(); expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull();
$_GET['flagTab'] = 'yellow';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'flagTab' => 'yellow',
]);
unset($_GET['flagTab']);
$_SERVER['QUERY_STRING'] = 'reviewState=blocked&reviewState=ready&departmentId=4%2C8';
$_GET['reviewState'] = 'ready';
$_GET['departmentId'] = '4,8';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'reviewStates' => ['blocked', 'ready'],
'departmentIds' => [4, 8],
]);
$_SERVER['QUERY_STRING'] = '';
unset($_GET['reviewState'], $_GET['departmentId']);
$_GET['page'] = '2'; $_GET['page'] = '2';
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
'periodView' => 'all', 'periodView' => 'all',
@@ -96,6 +113,11 @@ it('detects paginated period mode only when pagination parameters are present',
} else { } else {
$_SERVER['REQUEST_METHOD'] = $previousMethod; $_SERVER['REQUEST_METHOD'] = $previousMethod;
} }
if ($previousQueryString === null) {
unset($_SERVER['QUERY_STRING']);
} else {
$_SERVER['QUERY_STRING'] = $previousQueryString;
}
if ($previousResponse === null) { if ($previousResponse === null) {
unset($GLOBALS['response']); unset($GLOBALS['response']);
} else { } else {
@@ -104,6 +126,26 @@ it('detects paginated period mode only when pagination parameters are present',
} }
}); });
it('normalizes workflow filters and deterministic review sorting', function (): void {
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'reviewState' => ['blocked,ready', 'unknown'],
'severity' => 'red,green,invalid',
'invoiceState' => 'open,economic_booked',
'departmentId' => ['4,8', '0', '-1'],
'sort' => 'priority',
'direction' => 'DESC',
]]);
expect($options)->toMatchArray([
'reviewStates' => ['blocked', 'ready'],
'severities' => ['red', 'green'],
'invoiceStates' => ['open', 'economic_booked'],
'departmentIds' => [4, 8],
'sort' => 'priority',
'direction' => 'desc',
]);
});
it('normalizes period pagination options and clamps invalid page and limit values', function (): void { it('normalizes period pagination options and clamps invalid page and limit values', function (): void {
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
'periodView' => 'not-a-view', 'periodView' => 'not-a-view',
@@ -121,6 +163,12 @@ it('normalizes period pagination options and clamps invalid page and limit value
'limit' => 500, 'limit' => 500,
'search' => 'Nordic', 'search' => 'Nordic',
'flagTab' => 'all', 'flagTab' => 'all',
'reviewStates' => [],
'severities' => [],
'invoiceStates' => [],
'departmentIds' => [],
'sort' => 'customer_name',
'direction' => 'asc',
'includeRequiresAction' => false, 'includeRequiresAction' => false,
'includeBooked' => false, 'includeBooked' => false,
]); ]);
@@ -465,3 +513,177 @@ it('filters period flag tabs using active customer-scoped flag counts', function
'total' => 3, 'total' => 3,
]); ]);
}); });
it('derives review workflow metadata, facets and priority order without persisted review state', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5105, 'Completed', [
invoicing_period_transaction([
'customer_number' => 5105,
'booked' => true,
'invoice_state' => 'economic_booked',
'department_id' => 5,
]),
]),
invoicing_period_customer_card(5104, 'Ready', [
invoicing_period_transaction([
'customer_number' => 5104,
'invoice_state' => 'open',
'department_id' => 4,
]),
]),
invoicing_period_customer_card(5103, 'Queued', [
invoicing_period_transaction(['customer_number' => 5103, 'invoice_state' => 'closed']),
], false, [
'queue' => [
'has_active_job' => true,
'job_ids' => [71],
'statuses' => ['pending'],
'is_action_blocked' => true,
],
]),
invoicing_period_customer_card(5102, 'Attention', [
invoicing_period_transaction(['customer_number' => 5102, 'invoice_state' => 'economic_draft']),
], false, [
'flags' => [['source' => 'automatic', 'status' => 'active']],
]),
invoicing_period_customer_card(5101, 'Blocked', [
invoicing_period_transaction(['customer_number' => 5101, 'invoice_state' => 'open']),
], false, [
'flags' => [['source' => 'manual', 'status' => 'active']],
'invoice_collections' => [[
'id' => 9101,
'state' => 'open',
'external_id' => 'DRAFT-ERROR-9101',
'error_message' => 'E-conomic rejected VAT code',
]],
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'sort' => 'priority',
'direction' => 'asc',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))
->toBe([5101, 5102, 5103, 5104, 5105]);
expect($result['period']['types']['all'][0]['review'])->toMatchArray([
'state' => 'blocked',
'severity' => 'red',
'next_action' => 'resolve_manual_flags',
'is_actionable' => true,
'counts' => [
'active_manual_flags' => 1,
'active_automatic_flags' => 0,
'collection_errors' => 1,
'active_queue_jobs' => 0,
'booked_transactions' => 0,
'unbooked_transactions' => 1,
],
]);
expect(array_column($result['pagination']['facets']['review_states'], 'value'))
->toBe(['attention', 'blocked', 'completed', 'queued', 'ready']);
expect($result['pagination']['order'])->toBe(['field' => 'priority', 'direction' => 'asc']);
});
it('filters workflow facets and searches collection identifiers and error details', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5201, 'Collection Failure', [
invoicing_period_transaction([
'customer_number' => 5201,
'invoice_state' => 'open',
'department_id' => 12,
]),
], false, [
'invoice_collections' => [[
'id' => 44001,
'invoice_collection_id' => 44001,
'external_id' => 'EXT-REVIEW-7788',
'booked_invoice_id' => null,
'error_message' => 'VAT account missing',
'state' => 'open',
]],
]),
invoicing_period_customer_card(5202, 'Other Customer', [
invoicing_period_transaction([
'customer_number' => 5202,
'booked' => true,
'invoice_state' => 'economic_booked',
'department_id' => 13,
]),
]),
],
],
];
$searched = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'vat account missing',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($searched['period']['types']['all'], 'customer_number'))->toBe([5201]);
$filtered = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'reviewStates' => ['completed'],
'severities' => ['green'],
'invoiceStates' => ['economic_booked'],
'departmentIds' => [13],
'sort' => 'customer_number',
'direction' => 'asc',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($filtered['pagination']['total'])->toBe(1)
->and($filtered['period']['types']['all'][0]['customer_number'])->toBe(5202)
->and($filtered['pagination']['filters'])->toMatchArray([
'reviewState' => ['completed'],
'severity' => ['green'],
'invoiceState' => ['economic_booked'],
'departmentId' => [13],
]);
});
it('blocks review from aggregate manual counts when restricted flag details are absent', function (): void {
$period = [
'types' => [
'all' => [
invoicing_period_customer_card(5301, 'Restricted Flag Details', [
invoicing_period_transaction(['customer_number' => 5301]),
], false, [
'flag_counts' => ['manual' => 2, 'automatic' => 0, 'total' => 2],
]),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['period']['types']['all'][0])->not->toHaveKey('flags')
->and($result['period']['types']['all'][0]['review'])->toMatchArray([
'state' => 'blocked',
'severity' => 'red',
'next_action' => 'resolve_manual_flags',
]);
});
@@ -54,6 +54,7 @@ it('streams the main period response instead of encoding the full payload at onc
it('only includes invoice period flags when the list permission is granted', function (): void { it('only includes invoice period flags when the list permission is granted', function (): void {
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
$flagService = (string)file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse(); expect($content)->not->toBeFalse();
$content = (string)$content; $content = (string)$content;
@@ -62,7 +63,9 @@ it('only includes invoice period flags when the list permission is granted', fun
->toContain("\$includeInvoicePeriodFlags = \$this->hasPermission('list_invoice_period_flags');") ->toContain("\$includeInvoicePeriodFlags = \$this->hasPermission('list_invoice_period_flags');")
->and($content)->toContain('bool $includeInvoicePeriodFlags = false') ->and($content)->toContain('bool $includeInvoicePeriodFlags = false')
->and($content)->toContain('if ($includeInvoicePeriodFlags) {') ->and($content)->toContain('if ($includeInvoicePeriodFlags) {')
->and($content)->toContain('applyFlagsToPeriodTypes('); ->and($content)->toContain('applyFlagsToPeriodTypes(')
->and($content)->toContain('applyManualFlagCountsToPeriodTypes(')
->and($flagService)->toContain("unset(\$types[\$typeName][\$index]['flags']);");
}); });
it('maps batched period transaction rows to the legacy transaction response shape', function (): void { it('maps batched period transaction rows to the legacy transaction response shape', function (): void {