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:
@@ -43,6 +43,7 @@ class economic_transfer_queue
|
||||
|
||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
|
||||
if ($active_job !== null) {
|
||||
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
|
||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
@@ -75,6 +76,8 @@ class economic_transfer_queue
|
||||
$job_id = (int)$db->insert_id();
|
||||
$stmt->close();
|
||||
|
||||
$this->registerJobRequester($job_id, $created_by);
|
||||
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
$created_by,
|
||||
@@ -145,12 +148,19 @@ class economic_transfer_queue
|
||||
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) {
|
||||
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()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
@@ -179,7 +189,8 @@ class economic_transfer_queue
|
||||
$offset = max(0, $offset);
|
||||
|
||||
$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";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -203,7 +214,8 @@ class economic_transfer_queue
|
||||
}
|
||||
|
||||
$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";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -242,6 +254,9 @@ class economic_transfer_queue
|
||||
global $db;
|
||||
|
||||
$user_id = max(0, $user_id);
|
||||
if ($user_id < 1) {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(100, $limit));
|
||||
try {
|
||||
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||
@@ -262,7 +277,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE q.created_by = $user_id
|
||||
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
|
||||
$transfer_condition
|
||||
AND (
|
||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||
@@ -355,7 +370,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
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 . "')
|
||||
$transfer_condition
|
||||
AND d.queue_job_id IS NULL
|
||||
@@ -389,6 +404,9 @@ class economic_transfer_queue
|
||||
if ($existing_job === null) {
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -843,8 +893,8 @@ class economic_transfer_queue
|
||||
{
|
||||
global $db;
|
||||
|
||||
$created_by = max(0, $created_by);
|
||||
if ($target_value < 1 || $created_by < 1) {
|
||||
// Active work is unique by transfer type and business target across all requesting users.
|
||||
if ($target_value < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -854,7 +904,6 @@ class economic_transfer_queue
|
||||
WHERE transfer_type = ?
|
||||
AND status IN (?, ?)
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||
AND created_by = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -864,7 +913,7 @@ class economic_transfer_queue
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$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()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
|
||||
@@ -54,6 +54,17 @@ 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_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class invoice_collection_bulk_action_conflict extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
class invoice_collection_bulk_action_service
|
||||
{
|
||||
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
|
||||
@@ -35,6 +39,7 @@ class invoice_collection_bulk_action_service
|
||||
$options = $this->normalizeOptions($options);
|
||||
|
||||
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
||||
$preview['content_digest'] = $this->previewContentDigest($preview);
|
||||
$previewId = $this->previewId();
|
||||
$preview['preview_id'] = $previewId;
|
||||
$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.');
|
||||
}
|
||||
|
||||
$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 = [
|
||||
...$invoiceCollectionIds,
|
||||
(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();
|
||||
try {
|
||||
$result = match ($action) {
|
||||
@@ -102,10 +121,12 @@ class invoice_collection_bulk_action_service
|
||||
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
|
||||
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
|
||||
self::ACTION_QUEUE_ECONOMIC => [
|
||||
'queued_invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'changed_count' => count($invoiceCollectionIds),
|
||||
],
|
||||
self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(
|
||||
$invoiceCollectionIds,
|
||||
$options,
|
||||
$actorUserId,
|
||||
$economicQueue
|
||||
),
|
||||
default => throw new Exception('Unsupported action'),
|
||||
};
|
||||
(new logs_o())->add(
|
||||
@@ -354,11 +375,15 @@ class invoice_collection_bulk_action_service
|
||||
{
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
try {
|
||||
self::assertCollectionCanQueueEconomic($collection);
|
||||
} catch (\Throwable $e) {
|
||||
$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,
|
||||
'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];
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$blockers = [];
|
||||
@@ -522,8 +578,9 @@ class invoice_collection_bulk_action_service
|
||||
|
||||
private function collectionSummary(collected_order_invoices_o $collection): array
|
||||
{
|
||||
$invoiceCollectionId = (int)$collection->id;
|
||||
return [
|
||||
'id' => (int)$collection->id,
|
||||
'id' => $invoiceCollectionId,
|
||||
'customer_number' => (int)$collection->customer_number->value(),
|
||||
'name' => (string)$collection->name->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(),
|
||||
'external_id' => $collection->external_id->value(),
|
||||
'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
|
||||
{
|
||||
$action = trim($action);
|
||||
@@ -578,6 +703,18 @@ class invoice_collection_bulk_action_service
|
||||
if (isset($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);
|
||||
return $options;
|
||||
}
|
||||
@@ -591,6 +728,18 @@ class invoice_collection_bulk_action_service
|
||||
], 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
|
||||
{
|
||||
$language = strtolower(substr(trim($locale), 0, 2));
|
||||
|
||||
@@ -76,8 +76,11 @@ class invoice_period_flag_service
|
||||
$this->nullableIntSql($userId > 0 ? $userId : null)
|
||||
);
|
||||
$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
|
||||
@@ -107,6 +110,8 @@ class invoice_period_flag_service
|
||||
);
|
||||
$db->query($sql);
|
||||
|
||||
$this->refreshManualFlagsCacheAfterMutation();
|
||||
|
||||
return $this->getStoredFlag($id);
|
||||
}
|
||||
|
||||
@@ -227,6 +232,61 @@ class invoice_period_flag_service
|
||||
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
|
||||
{
|
||||
$transactionIds = [];
|
||||
@@ -331,6 +391,12 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshManualFlagsCacheAfterMutation(): void
|
||||
{
|
||||
$this->manualFlagsInstanceCache = null;
|
||||
$this->warmManualFlagsCache();
|
||||
}
|
||||
|
||||
private function fetchActiveManualFlagsFromDb(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -7592,7 +7592,19 @@ paths:
|
||||
description: Bulk action preview created successfully
|
||||
content:
|
||||
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:
|
||||
post:
|
||||
tags:
|
||||
@@ -7645,6 +7657,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
|
||||
/collected-invoices/economic:
|
||||
post:
|
||||
@@ -8242,6 +8255,79 @@ paths:
|
||||
in: query
|
||||
required: true
|
||||
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:
|
||||
'200':
|
||||
description: Invoicing periods retrieved successfully
|
||||
@@ -20562,6 +20648,11 @@ components:
|
||||
$ref: '#/components/schemas/InvoicingPeriodData'
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
date_from: {type: string, format: date-time}
|
||||
date_to: {type: string, format: date-time}
|
||||
pagination:
|
||||
$ref: '#/components/schemas/InvoicingPeriodPagination'
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
@@ -20586,7 +20677,7 @@ components:
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
required: [customer_number, customer_name, transactions, invoice_collections, review]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
@@ -20601,6 +20692,73 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$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:
|
||||
type: object
|
||||
@@ -20651,6 +20809,9 @@ components:
|
||||
booked_invoice_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
error_message:
|
||||
type: string
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
|
||||
@@ -350,8 +350,15 @@ class InvoicingPeriodRoute
|
||||
'page',
|
||||
'limit',
|
||||
'search',
|
||||
'flagTab',
|
||||
'includeRequiresAction',
|
||||
'includeBooked',
|
||||
'reviewState',
|
||||
'severity',
|
||||
'invoiceState',
|
||||
'departmentId',
|
||||
'sort',
|
||||
'direction',
|
||||
];
|
||||
|
||||
$isPaginatedRequest = false;
|
||||
@@ -366,7 +373,34 @@ class InvoicingPeriodRoute
|
||||
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
|
||||
@@ -399,12 +433,37 @@ class InvoicingPeriodRoute
|
||||
$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 [
|
||||
'periodView' => $periodView,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'search' => trim((string)($parameters['search'] ?? '')),
|
||||
'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(
|
||||
$parameters['includeRequiresAction'] ?? null,
|
||||
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
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@@ -439,6 +530,7 @@ class InvoicingPeriodRoute
|
||||
$types = is_array($period['types'] ?? null) ? $period['types'] : [];
|
||||
$types = self::ensurePeriodTypeKeys($types);
|
||||
$types = self::enrichPeriodCustomerMetaFromTypes($types);
|
||||
$types = self::enrichPeriodCustomerReview($types);
|
||||
$types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? ''));
|
||||
$types = self::filterPeriodTypesByVisibility(
|
||||
$types,
|
||||
@@ -452,10 +544,17 @@ class InvoicingPeriodRoute
|
||||
}
|
||||
|
||||
$typeCounts = self::summarizePeriodTypes($types);
|
||||
$facets = self::summarizePeriodReviewFacets($types[$periodView] ?? []);
|
||||
|
||||
if (!empty($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] ?? []);
|
||||
$limit = $options['limit'] ?? 100;
|
||||
@@ -488,15 +587,121 @@ class InvoicingPeriodRoute
|
||||
'filters' => [
|
||||
'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? 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' => [
|
||||
'field' => 'customer_name',
|
||||
'direction' => 'asc',
|
||||
'field' => (string)($options['sort'] ?? 'customer_name'),
|
||||
'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
|
||||
{
|
||||
if (in_array($flagTab, ['all', 'filters', ''], true)) {
|
||||
@@ -523,6 +728,174 @@ class InvoicingPeriodRoute
|
||||
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
|
||||
{
|
||||
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) {
|
||||
if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) {
|
||||
return true;
|
||||
@@ -646,6 +1023,19 @@ class InvoicingPeriodRoute
|
||||
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
|
||||
{
|
||||
return mb_strtolower(trim($value), 'UTF-8');
|
||||
@@ -1895,7 +2285,17 @@ class InvoicingPeriodRoute
|
||||
$onlyCustomerNumbers
|
||||
);
|
||||
}, '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 [
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
|
||||
@@ -11,6 +11,7 @@ use classes\economic_v2_line_normalizer;
|
||||
use classes\economic_v2_revenue_statistics_service;
|
||||
use classes\invoice_store;
|
||||
use classes\invoice_collection_bulk_action_service;
|
||||
use classes\invoice_collection_bulk_action_conflict;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
@@ -818,6 +819,8 @@ class orderInvoicesRoute
|
||||
(int)$user->id,
|
||||
$locale
|
||||
);
|
||||
} catch (invoice_collection_bulk_action_conflict $e) {
|
||||
$response->error($e->getMessage(), 409);
|
||||
} catch (\Throwable $e) {
|
||||
$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->requireSelected();
|
||||
try {
|
||||
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
|
||||
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices);
|
||||
} catch (\Throwable $e) {
|
||||
$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()) {
|
||||
try {
|
||||
$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
|
||||
{
|
||||
$collected_order_invoices->requireSelected();
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
|
||||
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices);
|
||||
}
|
||||
|
||||
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'])
|
||||
->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);
|
||||
});
|
||||
|
||||
+42
@@ -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
|
||||
{
|
||||
$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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
$db = economic_transfer_queue_integration_db();
|
||||
$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->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('WHERE q.created_by = $user_id');
|
||||
expect($queue)->toContain('AND created_by = ?');
|
||||
expect($queue)->toContain('LEFT JOIN economic_transfer_queue_job_requesters r')
|
||||
->and($queue)->toContain('q.created_by = ? OR r.user_id = ?')
|
||||
->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('dismissed_status VARCHAR(32) NOT NULL');
|
||||
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 {
|
||||
|
||||
@@ -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']);
|
||||
})->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 {
|
||||
$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]');
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
$previousGet = $_GET;
|
||||
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
|
||||
$previousQueryString = $_SERVER['QUERY_STRING'] ?? null;
|
||||
$response = new response();
|
||||
|
||||
try {
|
||||
@@ -80,6 +81,22 @@ it('detects paginated period mode only when pagination parameters are present',
|
||||
|
||||
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';
|
||||
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
|
||||
'periodView' => 'all',
|
||||
@@ -96,6 +113,11 @@ it('detects paginated period mode only when pagination parameters are present',
|
||||
} else {
|
||||
$_SERVER['REQUEST_METHOD'] = $previousMethod;
|
||||
}
|
||||
if ($previousQueryString === null) {
|
||||
unset($_SERVER['QUERY_STRING']);
|
||||
} else {
|
||||
$_SERVER['QUERY_STRING'] = $previousQueryString;
|
||||
}
|
||||
if ($previousResponse === null) {
|
||||
unset($GLOBALS['response']);
|
||||
} 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 {
|
||||
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
||||
'periodView' => 'not-a-view',
|
||||
@@ -121,6 +163,12 @@ it('normalizes period pagination options and clamps invalid page and limit value
|
||||
'limit' => 500,
|
||||
'search' => 'Nordic',
|
||||
'flagTab' => 'all',
|
||||
'reviewStates' => [],
|
||||
'severities' => [],
|
||||
'invoiceStates' => [],
|
||||
'departmentIds' => [],
|
||||
'sort' => 'customer_name',
|
||||
'direction' => 'asc',
|
||||
'includeRequiresAction' => false,
|
||||
'includeBooked' => false,
|
||||
]);
|
||||
@@ -465,3 +513,177 @@ it('filters period flag tabs using active customer-scoped flag counts', function
|
||||
'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 {
|
||||
$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();
|
||||
$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');")
|
||||
->and($content)->toContain('bool $includeInvoicePeriodFlags = false')
|
||||
->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 {
|
||||
|
||||
Reference in New Issue
Block a user