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);
|
$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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
+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
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user