Complete selected-customer invoice period tree (#338)
Add the authoritative revision-bound invoice collection tree and guarded cleanup, merge, price-reset, and transfer operations.
This commit is contained in:
@@ -40,6 +40,10 @@ class economic_transfer_queue
|
|||||||
$max_attempts = max(1, min(10, $max_attempts));
|
$max_attempts = max(1, min(10, $max_attempts));
|
||||||
$transfer_type = $this->validateTransferType($transfer_type);
|
$transfer_type = $this->validateTransferType($transfer_type);
|
||||||
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
||||||
|
$collected_invoice_lock = $this->acquireCollectedInvoiceExportLock($transfer_type, $payload);
|
||||||
|
if ($collected_invoice_lock !== null) {
|
||||||
|
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||||
|
}
|
||||||
|
|
||||||
$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) {
|
||||||
@@ -628,11 +632,53 @@ class economic_transfer_queue
|
|||||||
if ($collected_invoice_id < 1) {
|
if ($collected_invoice_id < 1) {
|
||||||
throw new Exception('collected_invoice_id is required');
|
throw new Exception('collected_invoice_id is required');
|
||||||
}
|
}
|
||||||
|
$collection_lock = $this->acquireCollectedInvoiceExportLock(
|
||||||
|
self::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||||
|
$payload
|
||||||
|
);
|
||||||
|
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||||
$send_as_is = (bool)($payload['send_as_is'] ?? false);
|
$send_as_is = (bool)($payload['send_as_is'] ?? false);
|
||||||
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
|
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
|
||||||
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
|
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue, worker execution, payments, and invoice-tree mutations share the
|
||||||
|
* same collection lock. The returned object intentionally stays in scope
|
||||||
|
* for the complete enqueue/export operation and releases in its destructor.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function acquireCollectedInvoiceExportLock(string $transfer_type, array $payload): ?order_payment_lock
|
||||||
|
{
|
||||||
|
if ($transfer_type !== self::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
|
||||||
|
if ($collected_invoice_id < 1) {
|
||||||
|
throw new Exception('collected_invoice_id is required');
|
||||||
|
}
|
||||||
|
$lock = order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id);
|
||||||
|
if ($lock === null) {
|
||||||
|
throw new Exception('Invoice collection is currently being changed or paid. Try again.');
|
||||||
|
}
|
||||||
|
return $lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read eligibility after acquiring the collection lock so a queued job
|
||||||
|
* cannot export a collection that was booked or superseded while waiting.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function assertCollectedInvoiceExportIsStillEligible(array $payload): void
|
||||||
|
{
|
||||||
|
$collection = (new \objects\collected_order_invoices_o())->select(
|
||||||
|
(int)($payload['collected_invoice_id'] ?? 0)
|
||||||
|
);
|
||||||
|
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection);
|
||||||
|
}
|
||||||
|
|
||||||
private function updateProgress(int $job_id, int $percent, string $message): void
|
private function updateProgress(int $job_id, int $percent, string $message): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class invoice_collection_bulk_action_conflict extends Exception
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class invoice_collection_bulk_action_validation 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';
|
||||||
@@ -32,24 +36,63 @@ class invoice_collection_bulk_action_service
|
|||||||
'de' => 'Bestätigen',
|
'de' => 'Bestätigen',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
|
public function preview(
|
||||||
|
string $action,
|
||||||
|
array $invoiceCollectionIds,
|
||||||
|
array $options = [],
|
||||||
|
string $locale = 'da',
|
||||||
|
int $actorUserId = 0,
|
||||||
|
?int $customerNumber = null,
|
||||||
|
?string $snapshotRevision = null,
|
||||||
|
bool $requireSnapshot = false
|
||||||
|
): array
|
||||||
{
|
{
|
||||||
$action = $this->normalizeAction($action);
|
$action = $this->normalizeAction($action);
|
||||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||||
$options = $this->normalizeOptions($options);
|
$options = $this->normalizeOptions($options);
|
||||||
|
|
||||||
|
$snapshotBinding = null;
|
||||||
|
if ($snapshotRevision !== null || $customerNumber !== null || $requireSnapshot) {
|
||||||
|
if ($actorUserId < 1 || $customerNumber === null || $customerNumber < 1 || trim((string)$snapshotRevision) === '') {
|
||||||
|
throw new invoice_collection_bulk_action_validation(
|
||||||
|
'customer_number and snapshot_revision are required for invoice-period tree actions.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$snapshotBinding = $this->validateSnapshotBinding(
|
||||||
|
(string)$snapshotRevision,
|
||||||
|
$actorUserId,
|
||||||
|
$customerNumber,
|
||||||
|
$invoiceCollectionIds,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
||||||
|
if ($snapshotBinding !== null) {
|
||||||
|
$preview['off_period_impact'] = $this->calculateOffPeriodImpact(
|
||||||
|
$invoiceCollectionIds,
|
||||||
|
(string)$snapshotBinding['date_from'],
|
||||||
|
(string)$snapshotBinding['date_to']
|
||||||
|
);
|
||||||
|
}
|
||||||
$preview['content_digest'] = $this->previewContentDigest($preview);
|
$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);
|
||||||
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
||||||
|
$preview['customer_number'] = $snapshotBinding !== null
|
||||||
|
? (int)$snapshotBinding['customer_number']
|
||||||
|
: $this->singleCustomerNumberForCollections($invoiceCollectionIds);
|
||||||
|
$preview['snapshot_revision'] = $snapshotBinding['snapshot_revision'] ?? null;
|
||||||
|
|
||||||
$this->cachePreview($previewId, [
|
$this->cachePreview($previewId, [
|
||||||
|
'actor_user_id' => $actorUserId,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||||
'options' => $options,
|
'options' => $options,
|
||||||
'locale' => $locale,
|
'locale' => $locale,
|
||||||
|
'customer_number' => $preview['customer_number'],
|
||||||
|
'snapshot_revision' => $preview['snapshot_revision'],
|
||||||
'selection_hash' => $preview['selection_hash'],
|
'selection_hash' => $preview['selection_hash'],
|
||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
]);
|
]);
|
||||||
@@ -64,7 +107,9 @@ class invoice_collection_bulk_action_service
|
|||||||
array $options,
|
array $options,
|
||||||
string $confirmationText,
|
string $confirmationText,
|
||||||
int $actorUserId,
|
int $actorUserId,
|
||||||
string $locale = 'da'
|
string $locale = 'da',
|
||||||
|
?int $customerNumber = null,
|
||||||
|
?string $snapshotRevision = null
|
||||||
): array {
|
): array {
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -75,12 +120,37 @@ class invoice_collection_bulk_action_service
|
|||||||
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||||
|
|
||||||
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
|
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
|
||||||
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
|
throw new invoice_collection_bulk_action_conflict(
|
||||||
|
'Preview is missing, expired, or no longer matches the selected invoice collections.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((int)($cached['actor_user_id'] ?? 0) !== $actorUserId) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Preview belongs to another user. Create a new preview.');
|
||||||
|
}
|
||||||
|
$cachedCustomerNumber = isset($cached['customer_number']) ? (int)$cached['customer_number'] : null;
|
||||||
|
$cachedSnapshotRevision = isset($cached['snapshot_revision'])
|
||||||
|
? trim((string)$cached['snapshot_revision'])
|
||||||
|
: null;
|
||||||
|
if ($customerNumber !== null && $cachedCustomerNumber !== $customerNumber) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Preview no longer matches the selected customer.');
|
||||||
|
}
|
||||||
|
if ($snapshotRevision !== null && $cachedSnapshotRevision !== trim($snapshotRevision)) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Preview no longer matches the selected snapshot.');
|
||||||
|
}
|
||||||
|
$snapshotBinding = null;
|
||||||
|
if ($cachedSnapshotRevision !== null && $cachedSnapshotRevision !== '') {
|
||||||
|
$snapshotBinding = $this->validateSnapshotBinding(
|
||||||
|
$cachedSnapshotRevision,
|
||||||
|
$actorUserId,
|
||||||
|
(int)$cachedCustomerNumber,
|
||||||
|
$invoiceCollectionIds,
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
||||||
if (trim($confirmationText) !== $expectedConfirmation) {
|
if (trim($confirmationText) !== $expectedConfirmation) {
|
||||||
throw new Exception('Confirmation text does not match.');
|
throw new invoice_collection_bulk_action_validation('Confirmation text does not match.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$lockedCollectionIds = [
|
$lockedCollectionIds = [
|
||||||
@@ -91,12 +161,19 @@ class invoice_collection_bulk_action_service
|
|||||||
$lockedCollectionIds
|
$lockedCollectionIds
|
||||||
);
|
);
|
||||||
if ($paymentMutationLock === null) {
|
if ($paymentMutationLock === null) {
|
||||||
throw new Exception(
|
throw new invoice_collection_bulk_action_conflict(
|
||||||
'An invoice collection is currently being changed or paid. Try again.'
|
'An invoice collection is currently being changed or paid. Try again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
||||||
|
if ($snapshotBinding !== null) {
|
||||||
|
$freshPreview['off_period_impact'] = $this->calculateOffPeriodImpact(
|
||||||
|
$invoiceCollectionIds,
|
||||||
|
(string)$snapshotBinding['date_from'],
|
||||||
|
(string)$snapshotBinding['date_to']
|
||||||
|
);
|
||||||
|
}
|
||||||
$freshDigest = $this->previewContentDigest($freshPreview);
|
$freshDigest = $this->previewContentDigest($freshPreview);
|
||||||
if (!hash_equals((string)($cached['preview']['content_digest'] ?? ''), $freshDigest)) {
|
if (!hash_equals((string)($cached['preview']['content_digest'] ?? ''), $freshDigest)) {
|
||||||
throw new invoice_collection_bulk_action_conflict(
|
throw new invoice_collection_bulk_action_conflict(
|
||||||
@@ -105,7 +182,7 @@ class invoice_collection_bulk_action_service
|
|||||||
}
|
}
|
||||||
$freshPreview['content_digest'] = $freshDigest;
|
$freshPreview['content_digest'] = $freshDigest;
|
||||||
if (!empty($freshPreview['blockers'])) {
|
if (!empty($freshPreview['blockers'])) {
|
||||||
throw new Exception((string)($freshPreview['blockers'][0]['message']
|
throw new invoice_collection_bulk_action_validation((string)($freshPreview['blockers'][0]['message']
|
||||||
?? 'Action cannot be applied while blockers are present.'));
|
?? 'Action cannot be applied while blockers are present.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +190,17 @@ class invoice_collection_bulk_action_service
|
|||||||
$economicQueue = $action === self::ACTION_QUEUE_ECONOMIC
|
$economicQueue = $action === self::ACTION_QUEUE_ECONOMIC
|
||||||
? new economic_transfer_queue()
|
? new economic_transfer_queue()
|
||||||
: null;
|
: null;
|
||||||
|
if ($action === self::ACTION_MERGE) {
|
||||||
|
// Flag schema initialization can execute DDL on first use, so keep it
|
||||||
|
// outside the atomic collection/order mutation transaction.
|
||||||
|
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
$db->conn()->begin_transaction();
|
$db->conn()->begin_transaction();
|
||||||
try {
|
try {
|
||||||
$result = match ($action) {
|
$result = match ($action) {
|
||||||
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
|
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
|
||||||
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
|
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options, $actorUserId),
|
||||||
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 => $this->applyQueueEconomic(
|
self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(
|
||||||
@@ -152,6 +234,251 @@ class invoice_collection_bulk_action_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize the complete selected collections outside the date window that
|
||||||
|
* made them visible. This impact is part of the cached preview digest.
|
||||||
|
*
|
||||||
|
* @param int[] $invoiceCollectionIds
|
||||||
|
* @return array{order_count:int,total_net_amount:float}
|
||||||
|
*/
|
||||||
|
private function calculateOffPeriodImpact(
|
||||||
|
array $invoiceCollectionIds,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo
|
||||||
|
): array {
|
||||||
|
global $db;
|
||||||
|
$ids = implode(',', array_map('intval', $invoiceCollectionIds));
|
||||||
|
$safeFrom = $db->escape_string($dateFrom);
|
||||||
|
$safeTo = $db->escape_string($dateTo);
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT COUNT(DISTINCT o.id) AS order_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN oi.include_in_invoice = 1
|
||||||
|
THEN COALESCE(oi.price, 0) * COALESCE(oi.quantity, 1)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0) AS total_net_amount
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
|
||||||
|
WHERE o.invoice_collection_id IN ({$ids})
|
||||||
|
AND o.deleted_at IS NULL
|
||||||
|
AND (o.created_at < '{$safeFrom}' OR o.created_at > '{$safeTo}')"
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
throw new Exception('Failed to calculate off-period invoice collection impact.');
|
||||||
|
}
|
||||||
|
$row = $result->fetch_assoc() ?: [];
|
||||||
|
return [
|
||||||
|
'order_count' => (int)($row['order_count'] ?? 0),
|
||||||
|
'total_net_amount' => (float)($row['total_net_amount'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyCachedPreview(
|
||||||
|
string $previewId,
|
||||||
|
string $confirmationText,
|
||||||
|
int $actorUserId
|
||||||
|
): array {
|
||||||
|
$cached = $this->getCachedPreview($previewId);
|
||||||
|
if ($cached === null) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Preview is missing or expired. Create a new preview.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->apply(
|
||||||
|
$previewId,
|
||||||
|
(string)($cached['action'] ?? ''),
|
||||||
|
is_array($cached['invoice_collection_ids'] ?? null) ? $cached['invoice_collection_ids'] : [],
|
||||||
|
is_array($cached['options'] ?? null) ? $cached['options'] : [],
|
||||||
|
$confirmationText,
|
||||||
|
$actorUserId,
|
||||||
|
(string)($cached['locale'] ?? 'da'),
|
||||||
|
isset($cached['customer_number']) ? (int)$cached['customer_number'] : null,
|
||||||
|
isset($cached['snapshot_revision']) ? (string)$cached['snapshot_revision'] : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cachedPreviewAction(string $previewId): string
|
||||||
|
{
|
||||||
|
$cached = $this->getCachedPreview($previewId);
|
||||||
|
if ($cached === null) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Preview is missing or expired. Create a new preview.');
|
||||||
|
}
|
||||||
|
return $this->normalizeAction((string)($cached['action'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache an actor/customer/date-bound revision for the exact invoice-period tree snapshot.
|
||||||
|
*
|
||||||
|
* @param int[] $invoiceCollectionIds
|
||||||
|
* @return array{actor_user_id:int,customer_number:int,date_from:string,date_to:string,invoice_collection_ids:int[],snapshot_revision:string}
|
||||||
|
*/
|
||||||
|
public function createSnapshotBinding(
|
||||||
|
int $actorUserId,
|
||||||
|
int $customerNumber,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
array $invoiceCollectionIds
|
||||||
|
): array {
|
||||||
|
if ($actorUserId < 1 || $customerNumber < 1) {
|
||||||
|
throw new invoice_collection_bulk_action_validation('A valid actor and customer_number are required.');
|
||||||
|
}
|
||||||
|
$range = invoicing_period_utils::normalizeDateRange($dateFrom, $dateTo);
|
||||||
|
$invoiceCollectionIds = $this->normalizeSnapshotCollectionIds($invoiceCollectionIds);
|
||||||
|
$this->assertCollectionsBelongToCustomer($invoiceCollectionIds, $customerNumber);
|
||||||
|
|
||||||
|
$revision = $this->calculateSnapshotRevision(
|
||||||
|
$customerNumber,
|
||||||
|
$range['dateFrom'],
|
||||||
|
$range['dateTo'],
|
||||||
|
$invoiceCollectionIds
|
||||||
|
);
|
||||||
|
$binding = [
|
||||||
|
'actor_user_id' => $actorUserId,
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'date_from' => $range['dateFrom'],
|
||||||
|
'date_to' => $range['dateTo'],
|
||||||
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||||
|
'snapshot_revision' => $revision,
|
||||||
|
];
|
||||||
|
$encoded = json_encode($binding, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new Exception('Failed to serialize invoice-period tree snapshot binding.');
|
||||||
|
}
|
||||||
|
(new redis())->setEx(
|
||||||
|
$this->snapshotCacheKey($actorUserId, $revision),
|
||||||
|
$encoded,
|
||||||
|
self::PREVIEW_TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
|
return $binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $selectedInvoiceCollectionIds
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function validateSnapshotBinding(
|
||||||
|
string $snapshotRevision,
|
||||||
|
int $actorUserId,
|
||||||
|
int $customerNumber,
|
||||||
|
array $selectedInvoiceCollectionIds,
|
||||||
|
bool $requireFresh
|
||||||
|
): array {
|
||||||
|
$snapshotRevision = strtolower(trim($snapshotRevision));
|
||||||
|
if ($actorUserId < 1 || $customerNumber < 1 || !preg_match('/^[a-f0-9]{64}$/', $snapshotRevision)) {
|
||||||
|
throw new invoice_collection_bulk_action_validation('Invalid invoice-period snapshot binding.');
|
||||||
|
}
|
||||||
|
$raw = (new redis())->get($this->snapshotCacheKey($actorUserId, $snapshotRevision));
|
||||||
|
$binding = $raw ? json_decode((string)$raw, true) : null;
|
||||||
|
if (!is_array($binding)) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Invoice-period snapshot is missing or expired. Refresh the customer tree.');
|
||||||
|
}
|
||||||
|
if ((int)($binding['actor_user_id'] ?? 0) !== $actorUserId
|
||||||
|
|| (int)($binding['customer_number'] ?? 0) !== $customerNumber
|
||||||
|
|| !hash_equals((string)($binding['snapshot_revision'] ?? ''), $snapshotRevision)) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Invoice-period snapshot no longer matches the current user or customer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshotCollectionIds = $this->normalizeSnapshotCollectionIds(
|
||||||
|
is_array($binding['invoice_collection_ids'] ?? null) ? $binding['invoice_collection_ids'] : []
|
||||||
|
);
|
||||||
|
foreach ($selectedInvoiceCollectionIds as $invoiceCollectionId) {
|
||||||
|
if (!in_array((int)$invoiceCollectionId, $snapshotCollectionIds, true)) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('A selected invoice collection is not part of this customer snapshot.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($requireFresh) {
|
||||||
|
$freshRevision = $this->calculateSnapshotRevision(
|
||||||
|
$customerNumber,
|
||||||
|
(string)($binding['date_from'] ?? ''),
|
||||||
|
(string)($binding['date_to'] ?? ''),
|
||||||
|
$snapshotCollectionIds
|
||||||
|
);
|
||||||
|
if (!hash_equals($snapshotRevision, $freshRevision)) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('Invoice-period data changed. Refresh the customer tree before continuing.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $invoiceCollectionIds
|
||||||
|
*/
|
||||||
|
private function calculateSnapshotRevision(
|
||||||
|
int $customerNumber,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
array $invoiceCollectionIds
|
||||||
|
): string {
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||||
|
throw new invoice_collection_bulk_action_validation('Invalid invoice-period snapshot date range.');
|
||||||
|
}
|
||||||
|
$this->assertCollectionsBelongToCustomer($invoiceCollectionIds, $customerNumber);
|
||||||
|
$safeDateFrom = $db->escape_string($dateFrom);
|
||||||
|
$safeDateTo = $db->escape_string($dateTo);
|
||||||
|
$periodResult = $db->query(
|
||||||
|
"SELECT
|
||||||
|
o.id,
|
||||||
|
o.invoice_collection_id,
|
||||||
|
o.customer_id,
|
||||||
|
o.reference,
|
||||||
|
o.notes,
|
||||||
|
o.department_id,
|
||||||
|
o.reg_1,
|
||||||
|
o.reg_2,
|
||||||
|
o.reg_3,
|
||||||
|
o.created_at,
|
||||||
|
o.completed_at,
|
||||||
|
o.include_in_invoice,
|
||||||
|
o.po,
|
||||||
|
o.safety_seal,
|
||||||
|
o.booking_id,
|
||||||
|
o.wash_id,
|
||||||
|
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
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
|
||||||
|
WHERE o.customer_id = {$customerNumber}
|
||||||
|
AND o.created_at BETWEEN '{$safeDateFrom}' AND '{$safeDateTo}'
|
||||||
|
AND o.deleted_at IS NULL
|
||||||
|
ORDER BY o.id ASC, oi.id ASC"
|
||||||
|
);
|
||||||
|
if (!$periodResult) {
|
||||||
|
throw new Exception('Failed to load invoice-period data for snapshot validation.');
|
||||||
|
}
|
||||||
|
$periodRows = $periodResult->fetch_all(MYSQLI_ASSOC);
|
||||||
|
|
||||||
|
$collections = [];
|
||||||
|
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||||
|
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||||
|
if (!$collection->exists() || (int)$collection->customer_number->value() !== $customerNumber) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('An invoice collection moved or no longer belongs to the selected customer.');
|
||||||
|
}
|
||||||
|
$collections[] = $this->collectionSummary($collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode([
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'date_from' => $dateFrom,
|
||||||
|
'date_to' => $dateTo,
|
||||||
|
'collections' => $collections,
|
||||||
|
'period_rows' => $periodRows,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new Exception('Failed to serialize invoice-period snapshot revision.');
|
||||||
|
}
|
||||||
|
return hash('sha256', $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
|
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
|
||||||
{
|
{
|
||||||
$collections = $this->loadCollections($invoiceCollectionIds);
|
$collections = $this->loadCollections($invoiceCollectionIds);
|
||||||
@@ -181,9 +508,11 @@ class invoice_collection_bulk_action_service
|
|||||||
$blockers = [];
|
$blockers = [];
|
||||||
foreach ($collections as $collection) {
|
foreach ($collections as $collection) {
|
||||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||||
$rows = $this->orderItemRows((int)$collection->id);
|
// The object tree can contain hidden and arbitrarily nested related items. Load the
|
||||||
$violatingItemIds = [];
|
// complete active tree so deleting a violating parent cannot leave hidden/orphaned
|
||||||
$includedItemIds = [];
|
// descendants behind.
|
||||||
|
$rows = $this->orderItemRows((int)$collection->id, true);
|
||||||
|
$violationsByItemId = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
|
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
|
||||||
(int)$row['order_id'],
|
(int)$row['order_id'],
|
||||||
@@ -193,37 +522,22 @@ class invoice_collection_bulk_action_service
|
|||||||
if ($violation === null) {
|
if ($violation === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$violatingItemIds[] = (int)$row['order_item_id'];
|
$violationsByItemId[(int)$row['order_item_id']] = (string)$violation['rule'];
|
||||||
$includedItemIds[] = (int)$row['order_item_id'];
|
}
|
||||||
|
|
||||||
|
foreach ($this->expandCleanupRows($rows, $violationsByItemId) as $cleanupRow) {
|
||||||
|
$row = $cleanupRow['row'];
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'invoice_collection_id' => (int)$collection->id,
|
'invoice_collection_id' => (int)$collection->id,
|
||||||
'order_id' => (int)$row['order_id'],
|
'order_id' => (int)$row['order_id'],
|
||||||
'order_item_id' => (int)$row['order_item_id'],
|
'order_item_id' => (int)$row['order_item_id'],
|
||||||
'product_id' => (int)$row['product_id'],
|
'product_id' => (int)$row['product_id'],
|
||||||
'product_name' => (string)$row['product_name'],
|
'product_name' => (string)$row['product_name'],
|
||||||
'rule' => (string)$violation['rule'],
|
'rule' => (string)$cleanupRow['rule'],
|
||||||
'price' => (int)$row['price'],
|
|
||||||
'quantity' => (int)$row['quantity'],
|
|
||||||
'will_soft_delete' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$orderItemId = (int)$row['order_item_id'];
|
|
||||||
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
|
|
||||||
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$includedItemIds[] = $orderItemId;
|
|
||||||
$items[] = [
|
|
||||||
'invoice_collection_id' => (int)$collection->id,
|
|
||||||
'order_id' => (int)$row['order_id'],
|
|
||||||
'order_item_id' => $orderItemId,
|
|
||||||
'product_id' => (int)$row['product_id'],
|
|
||||||
'product_name' => (string)$row['product_name'],
|
|
||||||
'rule' => 'related_to_removed_item',
|
|
||||||
'price' => (int)$row['price'],
|
'price' => (int)$row['price'],
|
||||||
'quantity' => (int)$row['quantity'],
|
'quantity' => (int)$row['quantity'],
|
||||||
|
'include_in_invoice' => (int)$row['include_in_invoice'],
|
||||||
|
'related_item_id' => empty($row['related_item_id']) ? null : (int)$row['related_item_id'],
|
||||||
'will_soft_delete' => true,
|
'will_soft_delete' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -241,6 +555,63 @@ class invoice_collection_bulk_action_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand directly violating order items to their complete descendant closure.
|
||||||
|
* A visited set makes malformed/cyclic legacy relations safe and deterministic.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $rows
|
||||||
|
* @param array<int,string> $violationsByItemId
|
||||||
|
* @return array<int,array{row:array<string,mixed>,rule:string}>
|
||||||
|
*/
|
||||||
|
private function expandCleanupRows(array $rows, array $violationsByItemId): array
|
||||||
|
{
|
||||||
|
$rowsById = [];
|
||||||
|
$childrenByOrderAndParentId = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$itemId = (int)($row['order_item_id'] ?? 0);
|
||||||
|
if ($itemId < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rowsById[$itemId] = $row;
|
||||||
|
$parentId = (int)($row['related_item_id'] ?? 0);
|
||||||
|
if ($parentId > 0) {
|
||||||
|
$edgeKey = (int)($row['order_id'] ?? 0) . ':' . $parentId;
|
||||||
|
$childrenByOrderAndParentId[$edgeKey] = $childrenByOrderAndParentId[$edgeKey] ?? [];
|
||||||
|
$childrenByOrderAndParentId[$edgeKey][] = $itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$directIds = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', array_keys($violationsByItemId)),
|
||||||
|
static fn(int $itemId): bool => $itemId > 0 && isset($rowsById[$itemId])
|
||||||
|
)));
|
||||||
|
sort($directIds, SORT_NUMERIC);
|
||||||
|
$queue = $directIds;
|
||||||
|
$visited = [];
|
||||||
|
while ($queue !== []) {
|
||||||
|
$itemId = array_shift($queue);
|
||||||
|
if (isset($visited[$itemId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$visited[$itemId] = true;
|
||||||
|
$edgeKey = (int)($rowsById[$itemId]['order_id'] ?? 0) . ':' . $itemId;
|
||||||
|
foreach ($childrenByOrderAndParentId[$edgeKey] ?? [] as $childId) {
|
||||||
|
if (!isset($visited[$childId])) {
|
||||||
|
$queue[] = $childId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemIds = array_map('intval', array_keys($visited));
|
||||||
|
sort($itemIds, SORT_NUMERIC);
|
||||||
|
return array_values(array_map(static function (int $itemId) use ($rowsById, $violationsByItemId): array {
|
||||||
|
return [
|
||||||
|
'row' => $rowsById[$itemId],
|
||||||
|
'rule' => $violationsByItemId[$itemId] ?? 'related_to_removed_item',
|
||||||
|
];
|
||||||
|
}, $itemIds));
|
||||||
|
}
|
||||||
|
|
||||||
private function previewMerge(array $preview, array $collections, array $options): array
|
private function previewMerge(array $preview, array $collections, array $options): array
|
||||||
{
|
{
|
||||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||||
@@ -260,11 +631,21 @@ class invoice_collection_bulk_action_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ordersToMove = [];
|
$ordersToMove = [];
|
||||||
|
$sourceInvoiceCollectionIds = [];
|
||||||
foreach ($collections as $collection) {
|
foreach ($collections as $collection) {
|
||||||
if ((int)$collection->id === $targetId) {
|
if ((int)$collection->id === $targetId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($collection->getOrderIds() as $orderIdRow) {
|
$sourceInvoiceCollectionIds[] = (int)$collection->id;
|
||||||
|
foreach ($this->allCollectionOrderRows((int)$collection->id) as $orderIdRow) {
|
||||||
|
if ((int)$orderIdRow['customer_id'] !== (int)$collection->customer_number->value()) {
|
||||||
|
$blockers[] = [
|
||||||
|
'code' => 'merge_order_customer_mismatch',
|
||||||
|
'invoice_collection_id' => (int)$collection->id,
|
||||||
|
'order_id' => (int)$orderIdRow['id'],
|
||||||
|
'message' => 'An assigned order belongs to another customer and cannot be merged.',
|
||||||
|
];
|
||||||
|
}
|
||||||
$ordersToMove[] = [
|
$ordersToMove[] = [
|
||||||
'order_id' => (int)$orderIdRow['id'],
|
'order_id' => (int)$orderIdRow['id'],
|
||||||
'source_invoice_collection_id' => (int)$collection->id,
|
'source_invoice_collection_id' => (int)$collection->id,
|
||||||
@@ -276,10 +657,17 @@ class invoice_collection_bulk_action_service
|
|||||||
return [
|
return [
|
||||||
...$preview,
|
...$preview,
|
||||||
'target_invoice_collection_id' => $targetId,
|
'target_invoice_collection_id' => $targetId,
|
||||||
|
'source_invoice_collection_ids' => $sourceInvoiceCollectionIds,
|
||||||
|
'supersession' => array_map(static fn(int $sourceId): array => [
|
||||||
|
'source_invoice_collection_id' => $sourceId,
|
||||||
|
'target_invoice_collection_id' => $targetId,
|
||||||
|
'will_mark_superseded' => true,
|
||||||
|
], $sourceInvoiceCollectionIds),
|
||||||
'orders' => $ordersToMove,
|
'orders' => $ordersToMove,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'collections' => count($collections),
|
'collections' => count($collections),
|
||||||
'orders_to_move' => count($ordersToMove),
|
'orders_to_move' => count($ordersToMove),
|
||||||
|
'sources_to_supersede' => count($sourceInvoiceCollectionIds),
|
||||||
'changed_count' => count($ordersToMove),
|
'changed_count' => count($ordersToMove),
|
||||||
],
|
],
|
||||||
'blockers' => $blockers,
|
'blockers' => $blockers,
|
||||||
@@ -291,7 +679,9 @@ class invoice_collection_bulk_action_service
|
|||||||
$items = [];
|
$items = [];
|
||||||
$changed = [];
|
$changed = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
|
$blockers = [];
|
||||||
foreach ($collections as $collection) {
|
foreach ($collections as $collection) {
|
||||||
|
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||||
try {
|
try {
|
||||||
$item = [
|
$item = [
|
||||||
'invoice_collection_id' => (int)$collection->id,
|
'invoice_collection_id' => (int)$collection->id,
|
||||||
@@ -325,6 +715,7 @@ class invoice_collection_bulk_action_service
|
|||||||
'changed_count' => count($changed),
|
'changed_count' => count($changed),
|
||||||
'skipped_count' => count($skipped),
|
'skipped_count' => count($skipped),
|
||||||
],
|
],
|
||||||
|
'blockers' => $blockers,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +804,7 @@ class invoice_collection_bulk_action_service
|
|||||||
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
|
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyMerge(array $preview, array $options): array
|
private function applyMerge(array $preview, array $options, int $actorUserId): array
|
||||||
{
|
{
|
||||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||||
$moved = [];
|
$moved = [];
|
||||||
@@ -425,8 +816,23 @@ class invoice_collection_bulk_action_service
|
|||||||
$order->assignToInvoiceCollection($targetId);
|
$order->assignToInvoiceCollection($targetId);
|
||||||
$moved[] = (int)$order->id;
|
$moved[] = (int)$order->id;
|
||||||
}
|
}
|
||||||
|
$this->retargetActiveFlagsForMovedOrders($moved, $targetId, $actorUserId);
|
||||||
|
$superseded = [];
|
||||||
|
foreach ($preview['source_invoice_collection_ids'] ?? [] as $sourceId) {
|
||||||
|
$source = (new collected_order_invoices_o())->select((int)$sourceId);
|
||||||
|
if (!$source->exists()) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict('A source invoice collection disappeared during merge.');
|
||||||
|
}
|
||||||
|
$source->markSupersededBy($targetId, $actorUserId);
|
||||||
|
$superseded[] = (int)$source->id;
|
||||||
|
}
|
||||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||||
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
|
return [
|
||||||
|
'changed_count' => count($moved),
|
||||||
|
'moved_order_ids' => $moved,
|
||||||
|
'superseded_invoice_collection_ids' => $superseded,
|
||||||
|
'target_invoice_collection_id' => $targetId,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applySplitByMonth(array $preview): array
|
private function applySplitByMonth(array $preview): array
|
||||||
@@ -501,6 +907,7 @@ class invoice_collection_bulk_action_service
|
|||||||
|
|
||||||
private function contentMutationBlockers(collected_order_invoices_o $collection): array
|
private function contentMutationBlockers(collected_order_invoices_o $collection): array
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
$blockers = [];
|
$blockers = [];
|
||||||
if (!empty($collection->booked_invoice_id->value())) {
|
if (!empty($collection->booked_invoice_id->value())) {
|
||||||
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
|
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
|
||||||
@@ -508,6 +915,37 @@ class invoice_collection_bulk_action_service
|
|||||||
if (!empty($collection->external_id->value())) {
|
if (!empty($collection->external_id->value())) {
|
||||||
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
|
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
|
||||||
}
|
}
|
||||||
|
if ($collection->getSupersessionMetadata() !== null) {
|
||||||
|
$blockers[] = [
|
||||||
|
'code' => 'collection_superseded',
|
||||||
|
'invoice_collection_id' => (int)$collection->id,
|
||||||
|
'message' => 'Invoice collection has already been superseded.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
economic_transfer_queue_schema_bootstrap::ensureTables();
|
||||||
|
$collectionId = (int)$collection->id;
|
||||||
|
$activeQueueResult = $db->query(
|
||||||
|
"SELECT id, status
|
||||||
|
FROM economic_transfer_queue_jobs
|
||||||
|
WHERE transfer_type = '" . economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT . "'
|
||||||
|
AND status IN ('" . economic_transfer_queue::STATUS_QUEUED . "', '" . economic_transfer_queue::STATUS_PROCESSING . "')
|
||||||
|
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.collected_invoice_id')) AS UNSIGNED) = {$collectionId}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
if (!$activeQueueResult) {
|
||||||
|
throw new Exception('Failed to validate active e-conomic transfer jobs.');
|
||||||
|
}
|
||||||
|
$activeQueue = $activeQueueResult->fetch_assoc();
|
||||||
|
if (is_array($activeQueue)) {
|
||||||
|
$blockers[] = [
|
||||||
|
'code' => 'collection_export_queued',
|
||||||
|
'invoice_collection_id' => $collectionId,
|
||||||
|
'queue_job_id' => (int)($activeQueue['id'] ?? 0),
|
||||||
|
'queue_status' => (string)($activeQueue['status'] ?? ''),
|
||||||
|
'message' => 'Invoice collection has an active e-conomic export job.',
|
||||||
|
];
|
||||||
|
}
|
||||||
return $blockers;
|
return $blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +976,52 @@ class invoice_collection_bulk_action_service
|
|||||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array{id:int,customer_id:int}> */
|
||||||
|
private function allCollectionOrderRows(int $invoiceCollectionId): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT id, customer_id
|
||||||
|
FROM orders
|
||||||
|
WHERE invoice_collection_id = {$invoiceCollectionId}
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC"
|
||||||
|
);
|
||||||
|
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param int[] $orderIds */
|
||||||
|
private function retargetActiveFlagsForMovedOrders(array $orderIds, int $targetInvoiceCollectionId, int $actorUserId): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$orderIds = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $orderIds),
|
||||||
|
static fn(int $orderId): bool => $orderId > 0
|
||||||
|
)));
|
||||||
|
if ($orderIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = implode(',', $orderIds);
|
||||||
|
$itemResult = $db->query("SELECT id FROM order_items WHERE order_id IN ({$ids})");
|
||||||
|
$itemIds = $itemResult
|
||||||
|
? array_values(array_map('intval', array_column($itemResult->fetch_all(MYSQLI_ASSOC), 'id')))
|
||||||
|
: [];
|
||||||
|
$itemCondition = $itemIds === []
|
||||||
|
? ''
|
||||||
|
: ' OR order_item_id IN (' . implode(',', $itemIds) . ')';
|
||||||
|
$db->query(
|
||||||
|
"UPDATE invoice_period_flags
|
||||||
|
SET invoice_collection_id = {$targetInvoiceCollectionId},
|
||||||
|
context_json = JSON_SET(
|
||||||
|
CASE WHEN JSON_VALID(context_json) THEN context_json ELSE JSON_OBJECT() END,
|
||||||
|
'$.invoice_collection_id', {$targetInvoiceCollectionId},
|
||||||
|
'$.retargeted_by_user_id', {$actorUserId}
|
||||||
|
)
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND (order_id IN ({$ids}){$itemCondition})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function touchOrdersForItems(array $itemIds): void
|
private function touchOrdersForItems(array $itemIds): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
@@ -653,6 +1137,9 @@ class invoice_collection_bulk_action_service
|
|||||||
public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void
|
public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void
|
||||||
{
|
{
|
||||||
$collection->requireSelected();
|
$collection->requireSelected();
|
||||||
|
if ($collection->getSupersessionMetadata() !== null) {
|
||||||
|
throw new Exception('Invoice collection has been superseded and cannot be exported.');
|
||||||
|
}
|
||||||
(new economic())->assertCustomerNumberIsNotDraft((int)$collection->customer_number->value());
|
(new economic())->assertCustomerNumberIsNotDraft((int)$collection->customer_number->value());
|
||||||
if ($collection->booked_invoice_id->value() !== null) {
|
if ($collection->booked_invoice_id->value() !== null) {
|
||||||
throw new Exception('Invoice has already been booked');
|
throw new Exception('Invoice has already been booked');
|
||||||
@@ -669,7 +1156,7 @@ class invoice_collection_bulk_action_service
|
|||||||
self::ACTION_RESET_HIDDEN_PRICES,
|
self::ACTION_RESET_HIDDEN_PRICES,
|
||||||
self::ACTION_QUEUE_ECONOMIC,
|
self::ACTION_QUEUE_ECONOMIC,
|
||||||
], true)) {
|
], true)) {
|
||||||
throw new Exception('Invalid invoice collection bulk action.');
|
throw new invoice_collection_bulk_action_validation('Invalid invoice collection bulk action.');
|
||||||
}
|
}
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
@@ -679,25 +1166,78 @@ class invoice_collection_bulk_action_service
|
|||||||
$normalized = [];
|
$normalized = [];
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
if (is_array($id) || is_object($id) || !is_numeric($id)) {
|
if (is_array($id) || is_object($id) || !is_numeric($id)) {
|
||||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
throw new invoice_collection_bulk_action_validation('invoice_collection_ids must contain only positive integer ids.');
|
||||||
}
|
}
|
||||||
$parsed = (int)$id;
|
$parsed = (int)$id;
|
||||||
if ($parsed < 1 || $parsed > 999999999) {
|
if ($parsed < 1 || $parsed > 999999999) {
|
||||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
throw new invoice_collection_bulk_action_validation('invoice_collection_ids must contain only positive integer ids.');
|
||||||
}
|
}
|
||||||
$normalized[$parsed] = $parsed;
|
$normalized[$parsed] = $parsed;
|
||||||
}
|
}
|
||||||
$normalized = array_values($normalized);
|
$normalized = array_values($normalized);
|
||||||
sort($normalized);
|
sort($normalized);
|
||||||
if ($normalized === []) {
|
if ($normalized === []) {
|
||||||
throw new Exception('invoice_collection_ids must contain at least one id.');
|
throw new invoice_collection_bulk_action_validation('invoice_collection_ids must contain at least one id.');
|
||||||
}
|
}
|
||||||
if (count($normalized) > self::MAX_COLLECTIONS) {
|
if (count($normalized) > self::MAX_COLLECTIONS) {
|
||||||
throw new Exception('Too many invoice collections selected.');
|
throw new invoice_collection_bulk_action_validation('Too many invoice collections selected.');
|
||||||
}
|
}
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param mixed[] $ids @return int[] */
|
||||||
|
private function normalizeSnapshotCollectionIds(array $ids): array
|
||||||
|
{
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return $this->normalizeInvoiceCollectionIds($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param int[] $invoiceCollectionIds */
|
||||||
|
private function assertCollectionsBelongToCustomer(array $invoiceCollectionIds, int $customerNumber): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||||
|
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||||
|
if (!$collection->exists() || (int)$collection->customer_number->value() !== $customerNumber) {
|
||||||
|
throw new invoice_collection_bulk_action_validation(
|
||||||
|
'Every invoice collection in the snapshot must belong to customer_number.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$foreignOrder = $db->query(
|
||||||
|
"SELECT id
|
||||||
|
FROM orders
|
||||||
|
WHERE invoice_collection_id = {$invoiceCollectionId}
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (customer_id IS NULL OR customer_id <> {$customerNumber})
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
if (!$foreignOrder) {
|
||||||
|
throw new Exception('Failed to validate invoice collection customer isolation.');
|
||||||
|
}
|
||||||
|
if ($foreignOrder->num_rows > 0) {
|
||||||
|
throw new invoice_collection_bulk_action_conflict(
|
||||||
|
'An invoice collection contains an order for another customer. Repair it before continuing.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param int[] $invoiceCollectionIds */
|
||||||
|
private function singleCustomerNumberForCollections(array $invoiceCollectionIds): ?int
|
||||||
|
{
|
||||||
|
$customerNumbers = [];
|
||||||
|
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||||
|
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||||
|
if (!$collection->exists()) {
|
||||||
|
throw new invoice_collection_bulk_action_validation('An invoice collection was not found.');
|
||||||
|
}
|
||||||
|
$customerNumbers[(int)$collection->customer_number->value()] = true;
|
||||||
|
}
|
||||||
|
return count($customerNumbers) === 1 ? (int)array_key_first($customerNumbers) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeOptions(array $options): array
|
private function normalizeOptions(array $options): array
|
||||||
{
|
{
|
||||||
if (isset($options['target_invoice_collection_id'])) {
|
if (isset($options['target_invoice_collection_id'])) {
|
||||||
@@ -712,7 +1252,7 @@ class invoice_collection_bulk_action_service
|
|||||||
} elseif (is_string($value) && in_array(strtolower(trim($value)), ['true', 'false'], true)) {
|
} elseif (is_string($value) && in_array(strtolower(trim($value)), ['true', 'false'], true)) {
|
||||||
$options['send_as_is'] = strtolower(trim($value)) === 'true';
|
$options['send_as_is'] = strtolower(trim($value)) === 'true';
|
||||||
} else {
|
} else {
|
||||||
throw new Exception('options.send_as_is must be a boolean.');
|
throw new invoice_collection_bulk_action_validation('options.send_as_is must be a boolean.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ksort($options);
|
ksort($options);
|
||||||
@@ -753,7 +1293,16 @@ class invoice_collection_bulk_action_service
|
|||||||
|
|
||||||
private function previewCacheKey(string $previewId): string
|
private function previewCacheKey(string $previewId): string
|
||||||
{
|
{
|
||||||
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
|
$previewId = strtolower(trim($previewId));
|
||||||
|
if (!preg_match('/^[a-f0-9]{32}$/', $previewId)) {
|
||||||
|
throw new invoice_collection_bulk_action_validation('Invalid preview_id.');
|
||||||
|
}
|
||||||
|
return 'collected_invoice_bulk_action_preview:' . $previewId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotCacheKey(int $actorUserId, string $snapshotRevision): string
|
||||||
|
{
|
||||||
|
return 'invoice_period_tree_snapshot:' . $actorUserId . ':' . $snapshotRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cachePreview(string $previewId, array $payload): void
|
private function cachePreview(string $previewId, array $payload): void
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class collected_order_invoices_o extends db
|
|||||||
{
|
{
|
||||||
use db_object_t;
|
use db_object_t;
|
||||||
|
|
||||||
|
private const SUPERSESSION_MARKER_PATTERN = '/\n?\[\[invoice_collection_superseded:(\{.*?\})\]\]/';
|
||||||
|
private static array $knownColumns = [];
|
||||||
|
|
||||||
public object_property $customer_number;
|
public object_property $customer_number;
|
||||||
public object_property $name;
|
public object_property $name;
|
||||||
public object_property $notes;
|
public object_property $notes;
|
||||||
@@ -78,6 +81,9 @@ class collected_order_invoices_o extends db
|
|||||||
$this->id = $collection['id'];
|
$this->id = $collection['id'];
|
||||||
self::getObjectProperties();
|
self::getObjectProperties();
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
if ($this->getSupersessionMetadata() !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$result[] = self::asArray();
|
$result[] = self::asArray();
|
||||||
}
|
}
|
||||||
return $result;
|
return $result;
|
||||||
@@ -98,6 +104,27 @@ class collected_order_invoices_o extends db
|
|||||||
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'string', false);
|
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'string', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function hasColumn(string $column): bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
if (array_key_exists($column, self::$knownColumns)) {
|
||||||
|
return self::$knownColumns[$column];
|
||||||
|
}
|
||||||
|
$safeColumn = $db->escape_string($column);
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM collected_order_invoices LIKE '{$safeColumn}'");
|
||||||
|
self::$knownColumns[$column] = $result && $result->num_rows > 0;
|
||||||
|
return self::$knownColumns[$column];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function additiveSchemaDefinitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'superseded_by_collection_id' => 'INT NULL',
|
||||||
|
'superseded_at' => 'DATETIME NULL',
|
||||||
|
'superseded_by_user_id' => 'INT NULL',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the invoice collection as an array
|
* Get the invoice collection as an array
|
||||||
* @throws ApiErrorException If the payment method is Stripe and the request fails
|
* @throws ApiErrorException If the payment method is Stripe and the request fails
|
||||||
@@ -242,6 +269,123 @@ class collected_order_invoices_o extends db
|
|||||||
return $fields;
|
return $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an emptied merge source as superseded without requiring a schema migration.
|
||||||
|
* Existing collection metadata is preserved; the target collection remains authoritative.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
self::requireSelected();
|
||||||
|
if ($this->getSupersessionMetadata() !== null) {
|
||||||
|
throw new Exception('The source invoice collection has already been superseded.');
|
||||||
|
}
|
||||||
|
if ($targetInvoiceCollectionId < 1 || $targetInvoiceCollectionId === (int)$this->id) {
|
||||||
|
throw new Exception('A different merge target invoice collection is required.');
|
||||||
|
}
|
||||||
|
if ($actorUserId < 1) {
|
||||||
|
throw new Exception('A valid actor is required when marking an invoice collection as superseded.');
|
||||||
|
}
|
||||||
|
$target = (new self())->select($targetInvoiceCollectionId);
|
||||||
|
$target->requireSelected();
|
||||||
|
if ($target->getSupersessionMetadata() !== null) {
|
||||||
|
throw new Exception('The target invoice collection has already been superseded.');
|
||||||
|
}
|
||||||
|
if ((int)$target->customer_number->value() !== (int)$this->customer_number->value()) {
|
||||||
|
throw new Exception('A superseding invoice collection must belong to the same customer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = (int)$this->id;
|
||||||
|
$remaining = $db->query(
|
||||||
|
"SELECT id FROM orders
|
||||||
|
WHERE invoice_collection_id = {$sourceId}
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
if ($remaining && $remaining->num_rows > 0) {
|
||||||
|
throw new Exception('The source invoice collection still contains active orders.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'target_invoice_collection_id' => $targetInvoiceCollectionId,
|
||||||
|
'superseded_by_user_id' => $actorUserId,
|
||||||
|
'superseded_at' => date('c'),
|
||||||
|
];
|
||||||
|
if (self::hasColumn('superseded_by_collection_id')) {
|
||||||
|
$assignments = ['superseded_by_collection_id = ' . $targetInvoiceCollectionId];
|
||||||
|
if (self::hasColumn('superseded_at')) {
|
||||||
|
$assignments[] = "superseded_at = '" . $db->escape_string(date('Y-m-d H:i:s')) . "'";
|
||||||
|
}
|
||||||
|
if (self::hasColumn('superseded_by_user_id')) {
|
||||||
|
$assignments[] = 'superseded_by_user_id = ' . $actorUserId;
|
||||||
|
}
|
||||||
|
$db->query(
|
||||||
|
'UPDATE collected_order_invoices SET ' . implode(', ', $assignments)
|
||||||
|
. ' WHERE id = ' . $sourceId
|
||||||
|
);
|
||||||
|
$notes = preg_replace(self::SUPERSESSION_MARKER_PATTERN, '', (string)$this->notes->value());
|
||||||
|
if ($notes !== (string)$this->notes->value()) {
|
||||||
|
$this->notes->set(rtrim((string)$notes));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$marker = '[[invoice_collection_superseded:' . json_encode(
|
||||||
|
$metadata,
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||||
|
) . ']]';
|
||||||
|
$notes = preg_replace(self::SUPERSESSION_MARKER_PATTERN, '', (string)$this->notes->value());
|
||||||
|
$notes = rtrim((string)$notes);
|
||||||
|
$this->notes->set(($notes === '' ? '' : $notes . "\n") . $marker);
|
||||||
|
}
|
||||||
|
if (empty($this->closed_at->value())) {
|
||||||
|
$this->closed_at->set(date('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
self::objectChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{target_invoice_collection_id:int,superseded_by_user_id:int,superseded_at:?string}|null */
|
||||||
|
public function getSupersessionMetadata(): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
self::requireSelected();
|
||||||
|
if (self::hasColumn('superseded_by_collection_id')) {
|
||||||
|
$fields = ['superseded_by_collection_id'];
|
||||||
|
if (self::hasColumn('superseded_by_user_id')) {
|
||||||
|
$fields[] = 'superseded_by_user_id';
|
||||||
|
}
|
||||||
|
if (self::hasColumn('superseded_at')) {
|
||||||
|
$fields[] = 'superseded_at';
|
||||||
|
}
|
||||||
|
$result = $db->query(
|
||||||
|
'SELECT ' . implode(', ', $fields)
|
||||||
|
. ' FROM collected_order_invoices WHERE id = ' . (int)$this->id . ' LIMIT 1'
|
||||||
|
);
|
||||||
|
$row = $result ? $result->fetch_assoc() : null;
|
||||||
|
if (is_array($row) && (int)($row['superseded_by_collection_id'] ?? 0) > 0) {
|
||||||
|
return [
|
||||||
|
'target_invoice_collection_id' => (int)$row['superseded_by_collection_id'],
|
||||||
|
'superseded_by_user_id' => (int)($row['superseded_by_user_id'] ?? 0),
|
||||||
|
'superseded_at' => isset($row['superseded_at']) ? (string)$row['superseded_at'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!preg_match(self::SUPERSESSION_MARKER_PATTERN, (string)$this->notes->value(), $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$metadata = json_decode($matches[1] ?? '', true);
|
||||||
|
if (!is_array($metadata) || (int)($metadata['target_invoice_collection_id'] ?? 0) < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'target_invoice_collection_id' => (int)$metadata['target_invoice_collection_id'],
|
||||||
|
'superseded_by_user_id' => (int)($metadata['superseded_by_user_id'] ?? 0),
|
||||||
|
'superseded_at' => isset($metadata['superseded_at']) ? (string)$metadata['superseded_at'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the invoice draft is existing in E-conomic
|
* Check if the invoice draft is existing in E-conomic
|
||||||
* @returns bool If the invoice draft is existing
|
* @returns bool If the invoice draft is existing
|
||||||
|
|||||||
@@ -1340,7 +1340,7 @@ class orders_o extends db
|
|||||||
WHERE customer_number IS NOT NULL AND customer_number <> 0
|
WHERE customer_number IS NOT NULL AND customer_number <> 0
|
||||||
GROUP BY customer_number
|
GROUP BY customer_number
|
||||||
) customer_user ON customer_user.customer_number = o.customer_id
|
) customer_user ON customer_user.customer_number = o.customer_id
|
||||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
|
||||||
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
|
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
|
||||||
LEFT JOIN economic_module_orders emo ON emo.id = o.id
|
LEFT JOIN economic_module_orders emo ON emo.id = o.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|||||||
@@ -7584,6 +7584,13 @@ paths:
|
|||||||
target_invoice_collection_id:
|
target_invoice_collection_id:
|
||||||
type: integer
|
type: integer
|
||||||
description: Required for merge_collections and must be one of the selected invoice collection ids.
|
description: Required for merge_collections and must be one of the selected invoice collection ids.
|
||||||
|
customer_number:
|
||||||
|
type: integer
|
||||||
|
description: Required with snapshot_revision for actor-bound invoice-period tree previews.
|
||||||
|
snapshot_revision:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-f0-9]{64}$'
|
||||||
|
description: Actor/customer-bound revision returned by the selected-customer tree snapshot.
|
||||||
locale:
|
locale:
|
||||||
type: string
|
type: string
|
||||||
example: da
|
example: da
|
||||||
@@ -7605,12 +7612,14 @@ paths:
|
|||||||
content_digest: {type: string, pattern: '^[a-f0-9]{64}$'}
|
content_digest: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||||
selection_hash: {type: string, pattern: '^[a-f0-9]{64}$'}
|
selection_hash: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||||
confirmation_phrase: {type: string}
|
confirmation_phrase: {type: string}
|
||||||
|
customer_number: {type: integer}
|
||||||
|
snapshot_revision: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||||
/collected-invoices/bulk-actions/apply:
|
/collected-invoices/bulk-actions/apply:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Invoices
|
- Invoices
|
||||||
summary: Apply selected collected invoice bulk action
|
summary: Apply selected collected invoice bulk action
|
||||||
description: Applies a previously previewed bulk action only when the preview still matches the selection and confirmation_text matches the localized confirmation phrase.
|
description: Applies a previously previewed bulk action only when the supplied action and selection still match the cached preview and confirmation_text matches the localized confirmation phrase.
|
||||||
operationId: applyCollectedInvoiceBulkAction
|
operationId: applyCollectedInvoiceBulkAction
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -7626,6 +7635,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
preview_id:
|
preview_id:
|
||||||
type: string
|
type: string
|
||||||
|
description: Preview id returned by previewCollectedInvoiceBulkAction.
|
||||||
action:
|
action:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -7659,6 +7669,93 @@ paths:
|
|||||||
schema: {}
|
schema: {}
|
||||||
'409': {$ref: '#/components/responses/Conflict'}
|
'409': {$ref: '#/components/responses/Conflict'}
|
||||||
|
|
||||||
|
/superuser/invoicing/period/tree-actions/preview:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Preview an action against a selected-customer invoice-period tree
|
||||||
|
description: Requires the actor-bound customer snapshot revision returned by the dedicated invoice-period tree route. The preview is cached for the authenticated actor and must be applied before it expires or the snapshot changes.
|
||||||
|
operationId: previewInvoicingPeriodTreeAction
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [action, invoice_collection_ids, customer_number, snapshot_revision]
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- remove_customer_rule_violations
|
||||||
|
- merge_collections
|
||||||
|
- split_by_month
|
||||||
|
- reset_hidden_item_prices
|
||||||
|
- queue_economic
|
||||||
|
invoice_collection_ids:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items: {type: integer, minimum: 1}
|
||||||
|
customer_number:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
snapshot_revision:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-f0-9]{64}$'
|
||||||
|
options:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
target_invoice_collection_id:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
locale:
|
||||||
|
type: string
|
||||||
|
example: da
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Actor/customer/revision-bound action preview created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/InvoiceCollectionTreeActionPreviewEnvelope'
|
||||||
|
'400': {$ref: '#/components/responses/BadRequest'}
|
||||||
|
'403': {$ref: '#/components/responses/Forbidden'}
|
||||||
|
'409': {$ref: '#/components/responses/Conflict'}
|
||||||
|
'500': {$ref: '#/components/responses/InternalServerError'}
|
||||||
|
|
||||||
|
/superuser/invoicing/period/tree-actions/apply:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Apply a cached selected-customer invoice-period tree action
|
||||||
|
description: Applies the exact actor-bound cached preview. The action, selection, options, customer, and snapshot revision are resolved from the preview; clients submit only its id and the confirmation phrase.
|
||||||
|
operationId: applyInvoicingPeriodTreeAction
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [preview_id, confirmation_text]
|
||||||
|
properties:
|
||||||
|
preview_id:
|
||||||
|
type: string
|
||||||
|
pattern: '^[a-f0-9]{32}$'
|
||||||
|
confirmation_text:
|
||||||
|
type: string
|
||||||
|
example: Bekræft
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Cached invoice-period tree action applied successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'400': {$ref: '#/components/responses/BadRequest'}
|
||||||
|
'403': {$ref: '#/components/responses/Forbidden'}
|
||||||
|
'409': {$ref: '#/components/responses/Conflict'}
|
||||||
|
'500': {$ref: '#/components/responses/InternalServerError'}
|
||||||
|
|
||||||
/collected-invoices/economic:
|
/collected-invoices/economic:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -8336,6 +8433,35 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
|
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
|
||||||
|
|
||||||
|
/superuser/invoicing/period/tree:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Invoices
|
||||||
|
summary: Get selected-customer invoice-period object tree
|
||||||
|
description: Returns a complete actor-bound snapshot for one selected customer's invoice-period object tree.
|
||||||
|
operationId: getInvoicingPeriodCustomerTree
|
||||||
|
parameters:
|
||||||
|
- name: customerNumber
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: {type: integer, minimum: 1}
|
||||||
|
- name: dateFrom
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: {type: string, format: date}
|
||||||
|
- name: dateTo
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: {type: string, format: date}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Complete selected-customer tree snapshot
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/InvoicingPeriodTreeSnapshotEnvelope'
|
||||||
|
'404': {$ref: '#/components/responses/NotFound'}
|
||||||
|
|
||||||
/superuser/invoicing/period/distribution/fixed-pricing:
|
/superuser/invoicing/period/distribution/fixed-pricing:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -20825,6 +20951,113 @@ components:
|
|||||||
total_net_amount:
|
total_net_amount:
|
||||||
type: number
|
type: number
|
||||||
format: float
|
format: float
|
||||||
|
selected_period_total_net_amount:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
InvoicingPeriodTreeSnapshotEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [success, data, meta, includes]
|
||||||
|
properties:
|
||||||
|
success: {type: boolean, enum: [true]}
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/InvoicingPeriodTreeSnapshot'
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
InvoicingPeriodTreeSnapshot:
|
||||||
|
type: object
|
||||||
|
required: [complete, customer_number, date_from, date_to, snapshot_revision, capabilities, customer, collections]
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
complete: {type: boolean, enum: [true]}
|
||||||
|
customer_number: {type: integer}
|
||||||
|
date_from: {type: string, format: date}
|
||||||
|
date_to: {type: string, format: date}
|
||||||
|
snapshot_revision: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||||
|
capabilities:
|
||||||
|
$ref: '#/components/schemas/InvoicingPeriodTreeCapabilities'
|
||||||
|
customer:
|
||||||
|
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||||
|
collections:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||||
|
- type: object
|
||||||
|
required: [orders, complete_order_count, complete_total_net_amount, period_order_count, period_total_net_amount]
|
||||||
|
properties:
|
||||||
|
orders:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/InvoicingPeriodTransaction'
|
||||||
|
complete_order_count: {type: integer, minimum: 0}
|
||||||
|
complete_total_net_amount: {type: number, format: float}
|
||||||
|
period_order_count: {type: integer, minimum: 0}
|
||||||
|
period_total_net_amount: {type: number, format: float}
|
||||||
|
|
||||||
|
InvoicingPeriodTreeCapabilities:
|
||||||
|
type: object
|
||||||
|
required: [object_tree_v2, actions]
|
||||||
|
properties:
|
||||||
|
object_tree_v2: {type: boolean}
|
||||||
|
actions:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
InvoiceCollectionTreeActionPreviewEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [success, data, meta, includes]
|
||||||
|
properties:
|
||||||
|
success: {type: boolean, enum: [true]}
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/InvoiceCollectionTreeActionPreview'
|
||||||
|
meta: {type: object, additionalProperties: true}
|
||||||
|
includes: {type: object, additionalProperties: true}
|
||||||
|
|
||||||
|
InvoiceCollectionTreeActionPreview:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- preview_id
|
||||||
|
- action
|
||||||
|
- invoice_collection_ids
|
||||||
|
- customer_number
|
||||||
|
- snapshot_revision
|
||||||
|
- confirmation_phrase
|
||||||
|
- summary
|
||||||
|
- blockers
|
||||||
|
- off_period_impact
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
preview_id: {type: string}
|
||||||
|
action: {type: string}
|
||||||
|
invoice_collection_ids:
|
||||||
|
type: array
|
||||||
|
items: {type: integer, minimum: 1}
|
||||||
|
customer_number: {type: integer, minimum: 1}
|
||||||
|
snapshot_revision: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||||
|
confirmation_phrase: {type: string}
|
||||||
|
summary:
|
||||||
|
type: object
|
||||||
|
required: [changed_count]
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
collections: {type: integer, minimum: 0}
|
||||||
|
changed_count: {type: integer, minimum: 0}
|
||||||
|
skipped_count: {type: integer, minimum: 0}
|
||||||
|
blockers:
|
||||||
|
type: array
|
||||||
|
items: {type: object, additionalProperties: true}
|
||||||
|
off_period_impact:
|
||||||
|
type: object
|
||||||
|
required: [order_count, total_net_amount]
|
||||||
|
properties:
|
||||||
|
order_count: {type: integer, minimum: 0}
|
||||||
|
total_net_amount: {type: number, format: float}
|
||||||
|
|
||||||
CollectedInvoiceEconomicCompareResponse:
|
CollectedInvoiceEconomicCompareResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use classes\economic;
|
|||||||
use classes\economic_transfer_queue;
|
use classes\economic_transfer_queue;
|
||||||
use classes\economic_v2_distribution_service;
|
use classes\economic_v2_distribution_service;
|
||||||
use classes\economic_v2_versioning_service;
|
use classes\economic_v2_versioning_service;
|
||||||
|
use classes\invoice_collection_bulk_action_conflict;
|
||||||
|
use classes\invoice_collection_bulk_action_service;
|
||||||
|
use classes\invoice_collection_bulk_action_validation;
|
||||||
use classes\invoice_period_flag_service;
|
use classes\invoice_period_flag_service;
|
||||||
use classes\invoicing_period_utils;
|
use classes\invoicing_period_utils;
|
||||||
use classes\slack;
|
use classes\slack;
|
||||||
@@ -37,6 +40,7 @@ class InvoicingPeriodRoute
|
|||||||
private static array $departmentExcludedFromInvoicingCache = [];
|
private static array $departmentExcludedFromInvoicingCache = [];
|
||||||
|
|
||||||
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
|
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
|
||||||
|
private static bool $suppressInvoicePeriodExternalEffects = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local-only booked status caches used by the period response.
|
* Local-only booked status caches used by the period response.
|
||||||
@@ -89,6 +93,9 @@ class InvoicingPeriodRoute
|
|||||||
*/
|
*/
|
||||||
private static function shouldSendSlackSummary(): bool
|
private static function shouldSendSlackSummary(): bool
|
||||||
{
|
{
|
||||||
|
if (self::$suppressInvoicePeriodExternalEffects) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$requestOverride = $_GET['sendSlackSummary'] ?? null;
|
$requestOverride = $_GET['sendSlackSummary'] ?? null;
|
||||||
if ($requestOverride !== null) {
|
if ($requestOverride !== null) {
|
||||||
return in_array(strtolower((string)$requestOverride), ['1', 'true', 'yes'], true);
|
return in_array(strtolower((string)$requestOverride), ['1', 'true', 'yes'], true);
|
||||||
@@ -102,6 +109,88 @@ class InvoicingPeriodRoute
|
|||||||
return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true);
|
return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the backend-controlled object-tree rollout. Missing config is deliberately disabled.
|
||||||
|
* Module: InvoicingPeriod. Variables: object_tree_v2_enabled and
|
||||||
|
* object_tree_v2_superuser_allowlist (JSON array or comma-separated IDs).
|
||||||
|
*/
|
||||||
|
public static function isInvoicePeriodObjectTreeV2Enabled(int $actorUserId): bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
if ($actorUserId < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT variable, value
|
||||||
|
FROM module_config
|
||||||
|
WHERE module = 'InvoicingPeriod'
|
||||||
|
AND variable IN (
|
||||||
|
'object_tree_v2_enabled',
|
||||||
|
'object_tree_v2_superuser_allowlist',
|
||||||
|
'object_tree_v2_allowlisted_user_ids'
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
if ($result) {
|
||||||
|
$config = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$config[(string)$row['variable']] = $row['value'];
|
||||||
|
}
|
||||||
|
if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$allowlist = $config['object_tree_v2_superuser_allowlist']
|
||||||
|
?? $config['object_tree_v2_allowlisted_user_ids']
|
||||||
|
?? null;
|
||||||
|
if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($config !== []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Deployment/test fallback below; the default remains disabled.
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isTruthyObjectTreeConfigValue(mixed $value): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] */
|
||||||
|
private static function parseObjectTreeIntegerList(mixed $value): array
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$decoded = is_string($value) ? json_decode($value, true) : null;
|
||||||
|
$values = is_array($decoded) ? $decoded : explode(',', (string)$value);
|
||||||
|
return array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $values),
|
||||||
|
static fn(int $id): bool => $id > 0
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyInvoicePeriodObjectTreeCapability(array $period, bool $enabled): array
|
||||||
|
{
|
||||||
|
foreach (($period['types'] ?? []) as $type => $customers) {
|
||||||
|
if (!is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($customers as $index => $customer) {
|
||||||
|
if (is_array($customer)) {
|
||||||
|
$period['types'][$type][$index]['capabilities']['object_tree_v2'] = $enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$period['capabilities']['object_tree_v2'] = $enabled;
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{dateFrom:string,dateTo:string}
|
* @return array{dateFrom:string,dateTo:string}
|
||||||
*/
|
*/
|
||||||
@@ -1213,8 +1302,11 @@ class InvoicingPeriodRoute
|
|||||||
}
|
}
|
||||||
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
|
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
|
||||||
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
|
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
|
||||||
|
$objectTreeV2Enabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id);
|
||||||
// Get the invoicing period for the user
|
// Get the invoicing period for the user
|
||||||
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
|
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
|
||||||
|
$period = self::applyInvoicePeriodObjectTreeCapability($period, $objectTreeV2Enabled);
|
||||||
|
$response->add_meta('invoice_period_object_tree_v2', $objectTreeV2Enabled);
|
||||||
if ($paginationOptions !== null) {
|
if ($paginationOptions !== null) {
|
||||||
$paginated = self::applyPeriodPagination($period, $paginationOptions);
|
$paginated = self::applyPeriodPagination($period, $paginationOptions);
|
||||||
$period = $paginated['period'];
|
$period = $paginated['period'];
|
||||||
@@ -1234,6 +1326,45 @@ class InvoicingPeriodRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->get('/superuser/invoicing/period/tree', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('superuser_invoicing_period');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
if (!self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) {
|
||||||
|
$response->error('Invoice-period object tree is not enabled.', 403);
|
||||||
|
}
|
||||||
|
self::requireParameters(['customerNumber', 'dateFrom', 'dateTo']);
|
||||||
|
$customerNumber = (int)$this->getParameter('customerNumber');
|
||||||
|
if ($customerNumber < 1) {
|
||||||
|
$response->error('customerNumber must be a positive integer', 400);
|
||||||
|
}
|
||||||
|
$dateRange = $this->requireAndNormalizeDateRange();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tree = $this->buildInvoicePeriodTreeSnapshot(
|
||||||
|
$customerNumber,
|
||||||
|
$dateRange['dateFrom'],
|
||||||
|
$dateRange['dateTo'],
|
||||||
|
(int)$user->id
|
||||||
|
);
|
||||||
|
$response->success($tree);
|
||||||
|
} catch (invoice_collection_bulk_action_validation $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
} catch (invoice_collection_bulk_action_conflict $e) {
|
||||||
|
$response->error($e->getMessage(), 409);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'superuser_invoicing_period' => 'Get one selected customer invoice-period object tree snapshot.',
|
||||||
|
'list_invoice_period_flags' => 'Include invoice period flags when permitted.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->post('/superuser/invoicing/period/flags', function () {
|
$this->post('/superuser/invoicing/period/flags', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('add_invoice_period_flag');
|
$this->requirePermission('add_invoice_period_flag');
|
||||||
@@ -1841,7 +1972,9 @@ class InvoicingPeriodRoute
|
|||||||
$message .= "Difference: " . $difference . "\n";
|
$message .= "Difference: " . $difference . "\n";
|
||||||
$message .= "Debug info: " . print_r($debug_info, true);
|
$message .= "Debug info: " . print_r($debug_info, true);
|
||||||
// Describe what to check
|
// Describe what to check
|
||||||
|
if (!self::$suppressInvoicePeriodExternalEffects) {
|
||||||
(new slack())->send_message($message, 'Subscription Price Distribution Mismatch');
|
(new slack())->send_message($message, 'Subscription Price Distribution Mismatch');
|
||||||
|
}
|
||||||
// Throw an error
|
// Throw an error
|
||||||
throw new Exception('Subscription price distribution does not equal total subscription price for customer ' . $customer['customer_number'] . '. Difference: ' . $difference);
|
throw new Exception('Subscription price distribution does not equal total subscription price for customer ' . $customer['customer_number'] . '. Difference: ' . $difference);
|
||||||
}
|
}
|
||||||
@@ -3235,6 +3368,610 @@ class InvoicingPeriodRoute
|
|||||||
return $customer;
|
return $customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<int,array<string,mixed>>>
|
||||||
|
*/
|
||||||
|
private static function getInvoicePeriodTreeSnapshotRows(
|
||||||
|
array $invoiceCollectionIds,
|
||||||
|
?int $customerNumber = null,
|
||||||
|
?string $dateFrom = null,
|
||||||
|
?string $dateTo = null
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$invoiceCollectionIds = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds))));
|
||||||
|
if ($invoiceCollectionIds === [] && ($customerNumber === null || $dateFrom === null || $dateTo === null)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$collectionCondition = $invoiceCollectionIds === []
|
||||||
|
? '0 = 1'
|
||||||
|
: 'o.invoice_collection_id IN (' . implode(',', $invoiceCollectionIds) . ')';
|
||||||
|
if ($customerNumber !== null && $customerNumber > 0 && $dateFrom !== null && $dateTo !== null) {
|
||||||
|
$safeFrom = $db->escape_string($dateFrom);
|
||||||
|
$safeTo = $db->escape_string($dateTo);
|
||||||
|
$collectionCondition = '(' . $collectionCondition . ")
|
||||||
|
OR (
|
||||||
|
o.customer_id = {$customerNumber}
|
||||||
|
AND (o.invoice_collection_id IS NULL OR o.invoice_collection_id = 0)
|
||||||
|
AND o.created_at BETWEEN '{$safeFrom}' AND '{$safeTo}'
|
||||||
|
)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
o.id AS order_id,
|
||||||
|
o.invoice_collection_id,
|
||||||
|
o.customer_id,
|
||||||
|
o.reference,
|
||||||
|
o.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,
|
||||||
|
o.booking_id,
|
||||||
|
o.wash_id,
|
||||||
|
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
|
||||||
|
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 ({$collectionCondition})
|
||||||
|
AND o.deleted_at IS NULL
|
||||||
|
ORDER BY o.invoice_collection_id ASC, o.id ASC, oi.id ASC";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result) {
|
||||||
|
throw new \RuntimeException('Failed to load the complete invoice-period collection order tree.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsByCollection = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$collectionId = (int)($row['invoice_collection_id'] ?? 0);
|
||||||
|
$rowsByCollection[$collectionId > 0 ? $collectionId : 0][] = $row;
|
||||||
|
}
|
||||||
|
return $rowsByCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $rows
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function buildInvoicePeriodTreeSnapshotOrders(array $rows, int $customerNumber): array
|
||||||
|
{
|
||||||
|
$orders = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ((int)($row['customer_id'] ?? 0) !== $customerNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$orderId = (int)($row['order_id'] ?? 0);
|
||||||
|
if ($orderId < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($orders[$orderId])) {
|
||||||
|
$completedAt = !empty($row['completed_at']) ? (string)$row['completed_at'] : null;
|
||||||
|
$orders[$orderId] = [
|
||||||
|
'id' => $orderId,
|
||||||
|
'date' => (string)($row['created_at'] ?? ''),
|
||||||
|
'amount' => 0.0,
|
||||||
|
'booked' => false,
|
||||||
|
'department_id' => (int)($row['department_id'] ?? 0),
|
||||||
|
'customer_number' => (int)($row['customer_id'] ?? 0),
|
||||||
|
'reference' => (string)($row['reference'] ?? ''),
|
||||||
|
'po' => (string)($row['po'] ?? ''),
|
||||||
|
'notes' => (string)($row['notes'] ?? ''),
|
||||||
|
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||||
|
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||||
|
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||||
|
'completed_at' => $completedAt,
|
||||||
|
'excluded' => (int)($row['order_include_in_invoice'] ?? 1) !== 1,
|
||||||
|
'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0),
|
||||||
|
'booking_id' => (int)($row['booking_id'] ?? 0) ?: null,
|
||||||
|
'wash_id' => !empty($row['wash_id']) ? (string)$row['wash_id'] : null,
|
||||||
|
'safety_seal' => (string)($row['safety_seal'] ?? ''),
|
||||||
|
'invoice_state' => self::periodOrderState(false, $completedAt),
|
||||||
|
'queue_status' => null,
|
||||||
|
'queue_job_id' => null,
|
||||||
|
'order_items' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$itemId = (int)($row['order_item_id'] ?? 0);
|
||||||
|
if ($itemId > 0) {
|
||||||
|
$quantity = (float)($row['quantity'] ?? 0);
|
||||||
|
$price = (float)($row['price'] ?? 0);
|
||||||
|
$includeInInvoice = (int)($row['order_item_include_in_invoice'] ?? 1) === 1;
|
||||||
|
$orders[$orderId]['order_items'][] = [
|
||||||
|
'id' => $itemId,
|
||||||
|
'order_item_id' => $itemId,
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'product_id' => (int)($row['product_id'] ?? 0),
|
||||||
|
'product_name' => (string)($row['product_name'] ?? ''),
|
||||||
|
'reference' => (string)($row['order_item_reference'] ?? ''),
|
||||||
|
'notes' => (string)($row['order_item_notes'] ?? ''),
|
||||||
|
'price' => $price,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'related_item_id' => !empty($row['related_item_id']) ? (int)$row['related_item_id'] : null,
|
||||||
|
'include_in_invoice' => $includeInInvoice ? 1 : 0,
|
||||||
|
];
|
||||||
|
if ($includeInInvoice) {
|
||||||
|
$orders[$orderId]['amount'] += $price * $quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInvoicePeriodTreeSnapshot(
|
||||||
|
int $customerNumber,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
int $actorUserId
|
||||||
|
): array {
|
||||||
|
$previousSideEffectSuppression = self::$suppressInvoicePeriodExternalEffects;
|
||||||
|
self::$suppressInvoicePeriodExternalEffects = true;
|
||||||
|
try {
|
||||||
|
$period = self::getInvoicingPeriod(
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
[$customerNumber],
|
||||||
|
$this->hasPermission('list_invoice_period_flags')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
self::$suppressInvoicePeriodExternalEffects = $previousSideEffectSuppression;
|
||||||
|
}
|
||||||
|
$customer = self::mergeInvoicePeriodTreeCustomer($period['types'] ?? [], $customerNumber);
|
||||||
|
$collectionMetadata = self::invoicePeriodTreeCollectionMetadata(
|
||||||
|
$period['types'] ?? [],
|
||||||
|
$customerNumber
|
||||||
|
);
|
||||||
|
$collectionIds = array_keys($collectionMetadata);
|
||||||
|
$rowsByCollection = self::getInvoicePeriodTreeSnapshotRows(
|
||||||
|
$collectionIds,
|
||||||
|
$customerNumber,
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo
|
||||||
|
);
|
||||||
|
|
||||||
|
$ordersByCollection = [];
|
||||||
|
foreach ($collectionIds as $collectionId) {
|
||||||
|
$ordersByCollection[$collectionId] = self::buildInvoicePeriodTreeSnapshotOrders(
|
||||||
|
$rowsByCollection[$collectionId] ?? [],
|
||||||
|
$customerNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$uncollectedOrders = self::buildInvoicePeriodTreeSnapshotOrders(
|
||||||
|
$rowsByCollection[0] ?? [],
|
||||||
|
$customerNumber
|
||||||
|
);
|
||||||
|
self::enrichInvoicePeriodTreeOrders($ordersByCollection, $uncollectedOrders);
|
||||||
|
|
||||||
|
$collections = [];
|
||||||
|
foreach ($collectionMetadata as $collectionId => $metadata) {
|
||||||
|
$orders = $ordersByCollection[$collectionId] ?? [];
|
||||||
|
foreach ($orders as &$order) {
|
||||||
|
$createdAt = (string)($order['date'] ?? '');
|
||||||
|
$order['in_selected_period'] = $createdAt >= $dateFrom && $createdAt <= $dateTo;
|
||||||
|
$order['total_net_amount'] = (float)($order['amount'] ?? 0);
|
||||||
|
$order['items'] = $order['order_items'] ?? [];
|
||||||
|
unset($order['order_items']);
|
||||||
|
}
|
||||||
|
unset($order);
|
||||||
|
$periodOrders = array_values(array_filter(
|
||||||
|
$orders,
|
||||||
|
static fn(array $order): bool => !empty($order['in_selected_period'])
|
||||||
|
));
|
||||||
|
$collectionObject = (new collected_order_invoices_o())->select((int)$collectionId);
|
||||||
|
$supersession = $collectionObject->exists()
|
||||||
|
? $collectionObject->getSupersessionMetadata()
|
||||||
|
: null;
|
||||||
|
$notes = preg_replace(
|
||||||
|
'/\n?\[\[invoice_collection_superseded:(\{.*?\})\]\]/',
|
||||||
|
'',
|
||||||
|
(string)($metadata['notes'] ?? '')
|
||||||
|
);
|
||||||
|
$collections[] = [
|
||||||
|
'id' => (int)$collectionId,
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'name' => (string)($metadata['name'] ?? ''),
|
||||||
|
'notes' => rtrim((string)$notes),
|
||||||
|
'processor' => (int)($metadata['processor'] ?? 0),
|
||||||
|
'external_id' => !empty($metadata['external_id']) ? (string)$metadata['external_id'] : null,
|
||||||
|
'booked_invoice_id' => (int)($metadata['booked_invoice_id'] ?? 0) ?: null,
|
||||||
|
'po_number' => !empty($metadata['po_number']) ? (string)$metadata['po_number'] : null,
|
||||||
|
'error_message' => !empty($metadata['error_message']) ? (string)$metadata['error_message'] : null,
|
||||||
|
'closed_at' => !empty($metadata['closed_at']) ? (string)$metadata['closed_at'] : null,
|
||||||
|
'created_at' => $metadata['created_at'] ?? null,
|
||||||
|
'updated_at' => $metadata['updated_at'] ?? null,
|
||||||
|
'state' => (string)($metadata['state'] ?? self::periodInvoiceCollectionState($metadata)),
|
||||||
|
'in_selected_period' => $periodOrders !== [],
|
||||||
|
'complete_order_count' => count($orders),
|
||||||
|
'complete_total_net_amount' => array_sum(array_column($orders, 'total_net_amount')),
|
||||||
|
'period_order_count' => count($periodOrders),
|
||||||
|
'period_total_net_amount' => array_sum(array_column($periodOrders, 'total_net_amount')),
|
||||||
|
'superseded_by_invoice_collection_id' => $supersession['target_invoice_collection_id'] ?? null,
|
||||||
|
'superseded_by_user_id' => $supersession['superseded_by_user_id'] ?? null,
|
||||||
|
'superseded_at' => $supersession['superseded_at'] ?? null,
|
||||||
|
'orders' => $orders,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
foreach ($uncollectedOrders as &$order) {
|
||||||
|
$order['in_selected_period'] = true;
|
||||||
|
$order['total_net_amount'] = (float)($order['amount'] ?? 0);
|
||||||
|
$order['items'] = $order['order_items'] ?? [];
|
||||||
|
unset($order['order_items']);
|
||||||
|
}
|
||||||
|
unset($order);
|
||||||
|
|
||||||
|
$binding = (new invoice_collection_bulk_action_service())->createSnapshotBinding(
|
||||||
|
$actorUserId,
|
||||||
|
$customerNumber,
|
||||||
|
substr($dateFrom, 0, 10),
|
||||||
|
substr($dateTo, 0, 10),
|
||||||
|
array_map(static fn(array $collection): int => (int)$collection['id'], $collections)
|
||||||
|
);
|
||||||
|
$agreements = self::invoicePeriodTreeAgreements($period['types'] ?? [], $customerNumber);
|
||||||
|
$payments = [];
|
||||||
|
foreach ([...array_values($ordersByCollection), $uncollectedOrders] as $orderGroup) {
|
||||||
|
foreach ($orderGroup as $order) {
|
||||||
|
foreach (($order['payments'] ?? []) as $payment) {
|
||||||
|
$payment['invoice_collection_id'] = (int)($order['invoice_collection_id'] ?? 0) ?: null;
|
||||||
|
$payments[] = $payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'complete' => true,
|
||||||
|
'snapshot_revision' => (string)$binding['snapshot_revision'],
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
// Keep the public snapshot identity aligned with the Y-m-d request
|
||||||
|
// contract; full-day timestamps are internal query boundaries.
|
||||||
|
'date_from' => substr($dateFrom, 0, 10),
|
||||||
|
'date_to' => substr($dateTo, 0, 10),
|
||||||
|
'capabilities' => [
|
||||||
|
'object_tree_v2' => true,
|
||||||
|
'actions' => [
|
||||||
|
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES =>
|
||||||
|
$this->hasPermission('reset_collected_invoice_economic'),
|
||||||
|
invoice_collection_bulk_action_service::ACTION_MERGE =>
|
||||||
|
$this->hasPermission('move_collected_invoice'),
|
||||||
|
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH =>
|
||||||
|
$this->hasPermission('split_collected_invoice'),
|
||||||
|
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES =>
|
||||||
|
$this->hasPermission('reset_collected_invoice_economic'),
|
||||||
|
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC =>
|
||||||
|
$this->hasPermission('add_collected_invoice_economic'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'customer' => $customer,
|
||||||
|
'collections' => $collections,
|
||||||
|
'uncollected_orders' => $uncollectedOrders,
|
||||||
|
'agreements' => $agreements,
|
||||||
|
'payments' => $payments,
|
||||||
|
'economic_invoices' => array_values(array_map(static fn(array $collection): array => [
|
||||||
|
'invoice_collection_id' => (int)$collection['id'],
|
||||||
|
'state' => (string)$collection['state'],
|
||||||
|
'external_id' => $collection['external_id'],
|
||||||
|
'booked_invoice_id' => $collection['booked_invoice_id'],
|
||||||
|
'available_type' => !empty($collection['booked_invoice_id'])
|
||||||
|
? 'booked'
|
||||||
|
: (!empty($collection['external_id']) ? 'draft' : null),
|
||||||
|
], $collections)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mergeInvoicePeriodTreeCustomer(array $types, int $customerNumber): array
|
||||||
|
{
|
||||||
|
$matches = [];
|
||||||
|
foreach ($types as $customers) {
|
||||||
|
if (!is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (is_array($customer) && (int)($customer['customer_number'] ?? 0) === $customerNumber) {
|
||||||
|
$matches[] = $customer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$customer = $matches[0] ?? [
|
||||||
|
'id' => null,
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'customer_name' => self::getLocalCustomerName($customerNumber),
|
||||||
|
'requires_action' => false,
|
||||||
|
'meta' => [],
|
||||||
|
];
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$customer['requires_action'] = !empty($customer['requires_action']) || !empty($match['requires_action']);
|
||||||
|
$customer['meta'] = array_replace_recursive(
|
||||||
|
is_array($customer['meta'] ?? null) ? $customer['meta'] : [],
|
||||||
|
is_array($match['meta'] ?? null) ? $match['meta'] : []
|
||||||
|
);
|
||||||
|
foreach (['flags', 'flag_counts', 'review', 'queue', 'draft'] as $key) {
|
||||||
|
if (!empty($match[$key])) {
|
||||||
|
$customer[$key] = $match[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($customer['transactions'], $customer['invoice_collections'], $customer['tree_snapshot']);
|
||||||
|
$customer['capabilities']['object_tree_v2'] = true;
|
||||||
|
return $customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> */
|
||||||
|
private static function invoicePeriodTreeCollectionMetadata(array $types, int $customerNumber): array
|
||||||
|
{
|
||||||
|
$metadata = [];
|
||||||
|
foreach ($types as $customers) {
|
||||||
|
if (!is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (($customer['invoice_collections'] ?? []) as $collection) {
|
||||||
|
$id = (int)($collection['id'] ?? $collection['invoice_collection_id'] ?? 0);
|
||||||
|
if ($id > 0 && (int)($collection['customer_number'] ?? $customerNumber) === $customerNumber) {
|
||||||
|
$metadata[$id] = is_array($collection) ? $collection : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($metadata, SORT_NUMERIC);
|
||||||
|
return $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function invoicePeriodTreeAgreements(array $types, int $customerNumber): array
|
||||||
|
{
|
||||||
|
$agreements = [];
|
||||||
|
foreach ($types as $type => $customers) {
|
||||||
|
if ($type === 'all' || !is_array($customers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : [];
|
||||||
|
if ($meta !== []) {
|
||||||
|
$agreements[] = [
|
||||||
|
'type' => (string)$type,
|
||||||
|
'requires_action' => (bool)($customer['requires_action'] ?? false),
|
||||||
|
'meta' => $meta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $agreements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add locally available child domains without exposing attachment storage object names.
|
||||||
|
*
|
||||||
|
* @param array<int,array<int,array<string,mixed>>> $ordersByCollection
|
||||||
|
* @param array<int,array<string,mixed>> $uncollectedOrders
|
||||||
|
*/
|
||||||
|
private static function enrichInvoicePeriodTreeOrders(
|
||||||
|
array &$ordersByCollection,
|
||||||
|
array &$uncollectedOrders
|
||||||
|
): void {
|
||||||
|
global $db;
|
||||||
|
$orderIds = [];
|
||||||
|
foreach ($ordersByCollection as $orders) {
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
$orderIds[(int)$order['id']] = (int)$order['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($uncollectedOrders as $order) {
|
||||||
|
$orderIds[(int)$order['id']] = (int)$order['id'];
|
||||||
|
}
|
||||||
|
$orderIds = array_values(array_filter($orderIds));
|
||||||
|
if ($orderIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = implode(',', $orderIds);
|
||||||
|
$attachments = [];
|
||||||
|
$attachmentResult = $db->query(
|
||||||
|
"SELECT id, object_id, content, created_at, updated_at
|
||||||
|
FROM object_attachments
|
||||||
|
WHERE object_id IN ({$ids})
|
||||||
|
AND object_type = 'orders'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY object_id ASC, id ASC"
|
||||||
|
);
|
||||||
|
if (!$attachmentResult) {
|
||||||
|
throw new \RuntimeException('Failed to load invoice-period tree attachments.');
|
||||||
|
}
|
||||||
|
if ($attachmentResult) {
|
||||||
|
while ($row = $attachmentResult->fetch_assoc()) {
|
||||||
|
$content = json_decode((string)($row['content'] ?? ''), true);
|
||||||
|
$content = is_array($content) ? $content : [];
|
||||||
|
$other = is_scalar($content['other'] ?? null) ? (string)$content['other'] : null;
|
||||||
|
$isWashCertificate = strtolower((string)$other) === 'wash_certificate';
|
||||||
|
$attachments[(int)$row['object_id']][] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'kind' => !empty($content['document'])
|
||||||
|
? 'document'
|
||||||
|
: (!empty($content['image']) ? 'image' : 'other'),
|
||||||
|
'filename' => !$isWashCertificate && $other !== '' ? $other : null,
|
||||||
|
'has_file' => !empty($content['document']) || !empty($content['image']),
|
||||||
|
'is_wash_certificate' => $isWashCertificate,
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
'updated_at' => $row['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookings = [];
|
||||||
|
$bookingResult = $db->query(
|
||||||
|
"SELECT id, customer_number, department, reg_1, reg_2, reg_3, datetime,
|
||||||
|
note, reference, po, pickup, items, order_id, created_at, updated_at
|
||||||
|
FROM order_bookings
|
||||||
|
WHERE order_id IN ({$ids}) AND deleted_at IS NULL
|
||||||
|
ORDER BY order_id ASC, id ASC"
|
||||||
|
);
|
||||||
|
if (!$bookingResult) {
|
||||||
|
throw new \RuntimeException('Failed to load invoice-period tree bookings.');
|
||||||
|
}
|
||||||
|
if ($bookingResult) {
|
||||||
|
while ($row = $bookingResult->fetch_assoc()) {
|
||||||
|
$items = json_decode((string)($row['items'] ?? ''), true);
|
||||||
|
$bookings[(int)$row['order_id']][] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'customer_number' => (int)$row['customer_number'],
|
||||||
|
'department_id' => (int)$row['department'],
|
||||||
|
'registrations' => array_values(array_filter([
|
||||||
|
(string)($row['reg_1'] ?? ''),
|
||||||
|
(string)($row['reg_2'] ?? ''),
|
||||||
|
(string)($row['reg_3'] ?? ''),
|
||||||
|
], static fn(string $reg): bool => $reg !== '')),
|
||||||
|
'datetime' => $row['datetime'] ?? null,
|
||||||
|
'note' => (string)($row['note'] ?? ''),
|
||||||
|
'reference' => (string)($row['reference'] ?? ''),
|
||||||
|
'po' => (string)($row['po'] ?? ''),
|
||||||
|
'pickup' => (bool)($row['pickup'] ?? false),
|
||||||
|
'items' => is_array($items) ? $items : [],
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
'updated_at' => $row['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payments = [];
|
||||||
|
$economicResult = $db->query(
|
||||||
|
"SELECT id, invoice_draft_id, invoice_id, created_at, updated_at
|
||||||
|
FROM economic_module_orders WHERE id IN ({$ids})"
|
||||||
|
);
|
||||||
|
if (!$economicResult) {
|
||||||
|
throw new \RuntimeException('Failed to load invoice-period tree e-conomic links.');
|
||||||
|
}
|
||||||
|
if ($economicResult) {
|
||||||
|
while ($row = $economicResult->fetch_assoc()) {
|
||||||
|
$orderId = (int)$row['id'];
|
||||||
|
$payments[$orderId][] = [
|
||||||
|
'provider' => 'economic',
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'state' => !empty($row['invoice_id'])
|
||||||
|
? 'booked'
|
||||||
|
: (!empty($row['invoice_draft_id']) ? 'draft' : 'unlinked'),
|
||||||
|
'invoice_draft_id' => (int)($row['invoice_draft_id'] ?? 0) ?: null,
|
||||||
|
'invoice_id' => (int)($row['invoice_id'] ?? 0) ?: null,
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
'updated_at' => $row['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stripeResult = $db->query(
|
||||||
|
"SELECT smo.id, smo.invoice_id, smo.customer_id, smo.email_sent, smo.created_at,
|
||||||
|
spi.payment_intent_id
|
||||||
|
FROM stripe_module_orders smo
|
||||||
|
LEFT JOIN stripe_payment_intents spi ON spi.order_id = smo.id
|
||||||
|
WHERE smo.id IN ({$ids})"
|
||||||
|
);
|
||||||
|
if (!$stripeResult) {
|
||||||
|
throw new \RuntimeException('Failed to load invoice-period tree Stripe links.');
|
||||||
|
}
|
||||||
|
if ($stripeResult) {
|
||||||
|
while ($row = $stripeResult->fetch_assoc()) {
|
||||||
|
$orderId = (int)$row['id'];
|
||||||
|
$payments[$orderId][] = [
|
||||||
|
'provider' => 'stripe',
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'state' => !empty($row['invoice_id']) || !empty($row['payment_intent_id'])
|
||||||
|
? 'linked'
|
||||||
|
: 'unlinked',
|
||||||
|
'invoice_id' => $row['invoice_id'] ?? null,
|
||||||
|
'payment_intent_id' => $row['payment_intent_id'] ?? null,
|
||||||
|
'email_sent_at' => $row['email_sent'] ?? null,
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$xlvask = [];
|
||||||
|
$washIds = [];
|
||||||
|
$allOrders = [];
|
||||||
|
foreach ($ordersByCollection as $orders) {
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
$allOrders[] = $order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$allOrders = [...$allOrders, ...$uncollectedOrders];
|
||||||
|
foreach ($allOrders as $order) {
|
||||||
|
if (!empty($order['wash_id'])) {
|
||||||
|
$washIds[(string)$order['wash_id']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($washIds !== []) {
|
||||||
|
$quoted = implode(',', array_map(
|
||||||
|
static fn(string $washId): string => "'" . $db->escape_string($washId) . "'",
|
||||||
|
array_keys($washIds)
|
||||||
|
));
|
||||||
|
$xlvaskResult = $db->query(
|
||||||
|
"SELECT id, WashId, CustomerId, Customer, Location, Hall, HallId, StartTime,
|
||||||
|
FinishTime, RegistrationNumber, VehicleType, FinishStatus,
|
||||||
|
cached_total_net_amount, cached_primary_product_name, cached_amount_at
|
||||||
|
FROM xlvask_usage_logs WHERE WashId IN ({$quoted}) ORDER BY id ASC"
|
||||||
|
);
|
||||||
|
if (!$xlvaskResult) {
|
||||||
|
throw new \RuntimeException('Failed to load invoice-period tree XL Vask usage.');
|
||||||
|
}
|
||||||
|
if ($xlvaskResult) {
|
||||||
|
while ($row = $xlvaskResult->fetch_assoc()) {
|
||||||
|
$xlvask[(string)$row['WashId']][] = [
|
||||||
|
'usage_log_id' => (int)$row['id'],
|
||||||
|
'wash_id' => (string)$row['WashId'],
|
||||||
|
'customer_id' => $row['CustomerId'] ?? null,
|
||||||
|
'customer_name' => $row['Customer'] ?? null,
|
||||||
|
'location' => $row['Location'] ?? null,
|
||||||
|
'hall' => $row['Hall'] ?? null,
|
||||||
|
'hall_id' => $row['HallId'] ?? null,
|
||||||
|
'start_time' => $row['StartTime'] ?? null,
|
||||||
|
'finish_time' => $row['FinishTime'] ?? null,
|
||||||
|
'registration_number' => $row['RegistrationNumber'] ?? null,
|
||||||
|
'vehicle_type' => $row['VehicleType'] ?? null,
|
||||||
|
'finish_status' => $row['FinishStatus'] ?? null,
|
||||||
|
'total_net_amount' => $row['cached_total_net_amount'] === null
|
||||||
|
? null
|
||||||
|
: (float)$row['cached_total_net_amount'],
|
||||||
|
'primary_product_name' => $row['cached_primary_product_name'] ?? null,
|
||||||
|
'amount_cached_at' => $row['cached_amount_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$enrich = static function (array &$orders) use ($attachments, $bookings, $payments, $xlvask): void {
|
||||||
|
foreach ($orders as &$order) {
|
||||||
|
$orderId = (int)$order['id'];
|
||||||
|
$order['attachments'] = $attachments[$orderId] ?? [];
|
||||||
|
$order['bookings'] = $bookings[$orderId] ?? [];
|
||||||
|
$order['payments'] = $payments[$orderId] ?? [];
|
||||||
|
$order['xlvask'] = !empty($order['wash_id'])
|
||||||
|
? ($xlvask[(string)$order['wash_id']] ?? [])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
unset($order);
|
||||||
|
};
|
||||||
|
foreach ($ordersByCollection as &$orders) {
|
||||||
|
$enrich($orders);
|
||||||
|
}
|
||||||
|
unset($orders);
|
||||||
|
$enrich($uncollectedOrders);
|
||||||
|
}
|
||||||
|
|
||||||
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
||||||
{
|
{
|
||||||
$meta = $customer['meta'] ?? [];
|
$meta = $customer['meta'] ?? [];
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ 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\invoice_collection_bulk_action_conflict;
|
||||||
|
use classes\invoice_collection_bulk_action_validation;
|
||||||
use classes\invoicing_period_utils;
|
use classes\invoicing_period_utils;
|
||||||
|
use classes\order_payment_lock;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\router;
|
use classes\router;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -756,14 +758,27 @@ class orderInvoicesRoute
|
|||||||
$response->error('options must be an object', 400);
|
$response->error('options must be an object', 400);
|
||||||
}
|
}
|
||||||
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
||||||
|
$customer_number = self::isParametersSet(['customer_number'])
|
||||||
|
? (int)self::getParameter('customer_number')
|
||||||
|
: null;
|
||||||
|
$snapshot_revision = self::isParametersSet(['snapshot_revision'])
|
||||||
|
? (string)self::getParameter('snapshot_revision')
|
||||||
|
: null;
|
||||||
|
$require_snapshot = $snapshot_revision !== null || $customer_number !== null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$preview = (new invoice_collection_bulk_action_service())->preview(
|
$preview = (new invoice_collection_bulk_action_service())->preview(
|
||||||
$action,
|
$action,
|
||||||
$invoice_collection_ids,
|
$invoice_collection_ids,
|
||||||
$options,
|
$options,
|
||||||
$locale
|
$locale,
|
||||||
|
(int)$user->id,
|
||||||
|
$customer_number,
|
||||||
|
$snapshot_revision,
|
||||||
|
$require_snapshot
|
||||||
);
|
);
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
@@ -798,7 +813,6 @@ class orderInvoicesRoute
|
|||||||
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
|
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
|
||||||
$action = (string)self::getParameter('action');
|
$action = (string)self::getParameter('action');
|
||||||
$this->requireCollectedInvoiceBulkActionPermission($action);
|
$this->requireCollectedInvoiceBulkActionPermission($action);
|
||||||
|
|
||||||
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
|
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
|
||||||
if (!is_array($invoice_collection_ids)) {
|
if (!is_array($invoice_collection_ids)) {
|
||||||
$response->error('invoice_collection_ids must be an array', 400);
|
$response->error('invoice_collection_ids must be an array', 400);
|
||||||
@@ -808,6 +822,12 @@ class orderInvoicesRoute
|
|||||||
$response->error('options must be an object', 400);
|
$response->error('options must be an object', 400);
|
||||||
}
|
}
|
||||||
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
||||||
|
$customer_number = self::isParametersSet(['customer_number'])
|
||||||
|
? (int)self::getParameter('customer_number')
|
||||||
|
: null;
|
||||||
|
$snapshot_revision = self::isParametersSet(['snapshot_revision'])
|
||||||
|
? (string)self::getParameter('snapshot_revision')
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = (new invoice_collection_bulk_action_service())->apply(
|
$result = (new invoice_collection_bulk_action_service())->apply(
|
||||||
@@ -817,12 +837,16 @@ class orderInvoicesRoute
|
|||||||
$options,
|
$options,
|
||||||
(string)self::getParameter('confirmation_text'),
|
(string)self::getParameter('confirmation_text'),
|
||||||
(int)$user->id,
|
(int)$user->id,
|
||||||
$locale
|
$locale,
|
||||||
|
$customer_number,
|
||||||
|
$snapshot_revision
|
||||||
);
|
);
|
||||||
} catch (invoice_collection_bulk_action_conflict $e) {
|
} catch (invoice_collection_bulk_action_conflict $e) {
|
||||||
$response->error($e->getMessage(), 409);
|
$response->error($e->getMessage(), 409);
|
||||||
} catch (\Throwable $e) {
|
} catch (invoice_collection_bulk_action_validation $e) {
|
||||||
$response->error($e->getMessage(), 400);
|
$response->error($e->getMessage(), 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success($result);
|
$response->success($result);
|
||||||
@@ -835,6 +859,100 @@ class orderInvoicesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->post('/superuser/invoicing/period/tree-actions/preview', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('superuser_invoicing_period');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
if (!InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) {
|
||||||
|
$response->error('Invoice-period object tree is not enabled.', 403);
|
||||||
|
}
|
||||||
|
self::requireParameters([
|
||||||
|
'action',
|
||||||
|
'invoice_collection_ids',
|
||||||
|
'customer_number',
|
||||||
|
'snapshot_revision',
|
||||||
|
]);
|
||||||
|
$action = (string)self::getParameter('action');
|
||||||
|
$this->requireCollectedInvoiceBulkActionPermission($action);
|
||||||
|
$invoiceCollectionIds = self::getParameter('invoice_collection_ids');
|
||||||
|
if (!is_array($invoiceCollectionIds)) {
|
||||||
|
$response->error('invoice_collection_ids must be an array', 400);
|
||||||
|
}
|
||||||
|
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
|
||||||
|
if (!is_array($options)) {
|
||||||
|
$response->error('options must be an object', 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$preview = (new invoice_collection_bulk_action_service())->preview(
|
||||||
|
$action,
|
||||||
|
$invoiceCollectionIds,
|
||||||
|
$options,
|
||||||
|
self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da',
|
||||||
|
(int)$user->id,
|
||||||
|
(int)self::getParameter('customer_number'),
|
||||||
|
(string)self::getParameter('snapshot_revision'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$response->success($preview);
|
||||||
|
} catch (invoice_collection_bulk_action_conflict $e) {
|
||||||
|
$response->error($e->getMessage(), 409);
|
||||||
|
} catch (invoice_collection_bulk_action_validation $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'superuser_invoicing_period' => 'Preview a selected-customer invoice-period tree action.',
|
||||||
|
'reset_collected_invoice_economic' => 'Preview invoice collection cleanup and price reset actions.',
|
||||||
|
'move_collected_invoice' => 'Preview invoice collection merge actions.',
|
||||||
|
'split_collected_invoice' => 'Preview invoice collection monthly split actions.',
|
||||||
|
'add_collected_invoice_economic' => 'Preview invoice collection e-conomic queue actions.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->post('/superuser/invoicing/period/tree-actions/apply', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('superuser_invoicing_period');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
if (!InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) {
|
||||||
|
$response->error('Invoice-period object tree is not enabled.', 403);
|
||||||
|
}
|
||||||
|
self::requireParameters(['preview_id', 'confirmation_text']);
|
||||||
|
$service = new invoice_collection_bulk_action_service();
|
||||||
|
try {
|
||||||
|
$this->requireCollectedInvoiceBulkActionPermission(
|
||||||
|
$service->cachedPreviewAction((string)self::getParameter('preview_id'))
|
||||||
|
);
|
||||||
|
$result = $service->applyCachedPreview(
|
||||||
|
(string)self::getParameter('preview_id'),
|
||||||
|
(string)self::getParameter('confirmation_text'),
|
||||||
|
(int)$user->id
|
||||||
|
);
|
||||||
|
$response->success($result);
|
||||||
|
} catch (invoice_collection_bulk_action_conflict $e) {
|
||||||
|
$response->error($e->getMessage(), 409);
|
||||||
|
} catch (invoice_collection_bulk_action_validation $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'superuser_invoicing_period' => 'Apply a cached selected-customer invoice-period tree action.',
|
||||||
|
'reset_collected_invoice_economic' => 'Apply invoice collection cleanup and price reset actions.',
|
||||||
|
'move_collected_invoice' => 'Apply invoice collection merge actions.',
|
||||||
|
'split_collected_invoice' => 'Apply invoice collection monthly split actions.',
|
||||||
|
'add_collected_invoice_economic' => 'Apply invoice collection e-conomic queue actions.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
/** Collected order invoices > E-Conomic > POST (queued) */
|
/** Collected order invoices > E-Conomic > POST (queued) */
|
||||||
$this->post('/collected-invoices/economic', function () {
|
$this->post('/collected-invoices/economic', function () {
|
||||||
global $response;
|
global $response;
|
||||||
@@ -865,6 +983,10 @@ class orderInvoicesRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$collection_export_lock = order_payment_lock::tryAcquireInvoiceCollection((int)self::getParameter('id'));
|
||||||
|
if ($collection_export_lock === null) {
|
||||||
|
$response->error('Invoice collection is currently being changed or paid. Try again.', 409);
|
||||||
|
}
|
||||||
$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 {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ function bulk_action_order_invoice_collection_id(int $orderId): int
|
|||||||
return (int)($row['invoice_collection_id'] ?? 0);
|
return (int)($row['invoice_collection_id'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bulk_action_invoice_collection_row(int $invoiceCollectionId): array
|
||||||
|
{
|
||||||
|
return api_test_runtime()->queryOne(
|
||||||
|
'SELECT name, notes, closed_at FROM collected_order_invoices WHERE id = '
|
||||||
|
. $invoiceCollectionId . ' LIMIT 1'
|
||||||
|
) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function bulk_action_configure_rule_product(string $attribute, int $productId): void
|
function bulk_action_configure_rule_product(string $attribute, int $productId): void
|
||||||
{
|
{
|
||||||
new \classes\customer_rule_product_restriction_service();
|
new \classes\customer_rule_product_restriction_service();
|
||||||
@@ -24,7 +32,21 @@ function bulk_action_configure_rule_product(string $attribute, int $productId):
|
|||||||
$result = $db->query(
|
$result = $db->query(
|
||||||
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
||||||
);
|
);
|
||||||
$collectionId = (int)$result->fetch_assoc()['id'];
|
$row = $result ? $result->fetch_assoc() : null;
|
||||||
|
if (!is_array($row)) {
|
||||||
|
$db->query(
|
||||||
|
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||||
|
VALUES ('{$safeAttribute}', 1)"
|
||||||
|
);
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||||
|
VALUES ('{$safeAttribute}', 'Invoice tree API fixture', 0)"
|
||||||
|
);
|
||||||
|
$collectionId = (int)$db->insert_id;
|
||||||
|
} else {
|
||||||
|
$collectionId = (int)$row['id'];
|
||||||
|
}
|
||||||
|
expect($collectionId)->toBeGreaterThan(0);
|
||||||
$db->query(
|
$db->query(
|
||||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||||
VALUES ({$collectionId}, {$productId})"
|
VALUES ({$collectionId}, {$productId})"
|
||||||
@@ -50,6 +72,12 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
|||||||
'department_id' => $department['id'],
|
'department_id' => $department['id'],
|
||||||
'invoice_collection_id' => $invoiceCollection['id'],
|
'invoice_collection_id' => $invoiceCollection['id'],
|
||||||
]);
|
]);
|
||||||
|
$outsidePeriodOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $invoiceCollection['id'],
|
||||||
|
'created_at' => '2025-01-15 12:00:00',
|
||||||
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'name' => 'Spot Free rinse',
|
'name' => 'Spot Free rinse',
|
||||||
'price' => 80,
|
'price' => 80,
|
||||||
@@ -61,6 +89,39 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
|||||||
'cashier_id' => 1,
|
'cashier_id' => 1,
|
||||||
'price' => 80,
|
'price' => 80,
|
||||||
]);
|
]);
|
||||||
|
$regularProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Allowed related product',
|
||||||
|
'price' => 10,
|
||||||
|
]);
|
||||||
|
$childItem = api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $regularProduct['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 10,
|
||||||
|
'related_item_id' => $orderItem['id'],
|
||||||
|
'include_in_invoice' => 0,
|
||||||
|
]);
|
||||||
|
$grandchildItem = api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $regularProduct['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 5,
|
||||||
|
'related_item_id' => $childItem['id'],
|
||||||
|
'include_in_invoice' => 0,
|
||||||
|
]);
|
||||||
|
$unrelatedHiddenItem = api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $regularProduct['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 7,
|
||||||
|
'include_in_invoice' => 0,
|
||||||
|
]);
|
||||||
|
$outsidePeriodViolation = api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $outsidePeriodOrder['id'],
|
||||||
|
'product_id' => $product['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 80,
|
||||||
|
]);
|
||||||
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
|
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
|
||||||
|
|
||||||
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
||||||
@@ -75,11 +136,24 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
|||||||
->assertSuccess();
|
->assertSuccess();
|
||||||
|
|
||||||
$preview = $previewResponse->data();
|
$preview = $previewResponse->data();
|
||||||
|
$previewItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
|
||||||
|
sort($previewItemIds);
|
||||||
|
$expectedCleanupIds = array_map('intval', [
|
||||||
|
$orderItem['id'],
|
||||||
|
$childItem['id'],
|
||||||
|
$grandchildItem['id'],
|
||||||
|
$outsidePeriodViolation['id'],
|
||||||
|
]);
|
||||||
|
sort($expectedCleanupIds);
|
||||||
expect($preview['preview_id'] ?? null)->toBeString()
|
expect($preview['preview_id'] ?? null)->toBeString()
|
||||||
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
|
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
|
||||||
->and($preview['summary']['changed_count'] ?? null)->toBe(1)
|
->and($preview['summary']['changed_count'] ?? null)->toBe(4)
|
||||||
->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id'])
|
->and($previewItemIds)->toBe($expectedCleanupIds)
|
||||||
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
|
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$childItem['id']))->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$grandchildItem['id']))->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$outsidePeriodViolation['id']))->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$unrelatedHiddenItem['id']))->toBeNull();
|
||||||
|
|
||||||
api_client()->post('/collected-invoices/bulk-actions/apply', [
|
api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||||
'preview_id' => $preview['preview_id'],
|
'preview_id' => $preview['preview_id'],
|
||||||
@@ -109,9 +183,204 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
|||||||
|
|
||||||
$applied = $applyResponse->data();
|
$applied = $applyResponse->data();
|
||||||
expect($applied['preview'] ?? null)->toBeFalse()
|
expect($applied['preview'] ?? null)->toBeFalse()
|
||||||
->and($applied['result']['changed_count'] ?? null)->toBe(1)
|
->and($applied['result']['changed_count'] ?? null)->toBe(4)
|
||||||
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull();
|
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull()
|
||||||
});
|
->and(bulk_action_order_item_deleted_at((int)$childItem['id']))->not->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$grandchildItem['id']))->not->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$outsidePeriodViolation['id']))->not->toBeNull()
|
||||||
|
->and(bulk_action_order_item_deleted_at((int)$unrelatedHiddenItem['id']))->toBeNull();
|
||||||
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
|
it('binds tree previews to complete off-period impact and rejects foreign assigned orders', function (): void {
|
||||||
|
api_test_covers('POST /superuser/invoicing/period/tree-actions/preview', 'snapshot-bound-off-period-impact');
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Bound Tree Preview Customer']);
|
||||||
|
$foreignCustomer = api_fixtures()->createUser(['display_name' => 'Foreign Bound Tree Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$collection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
]);
|
||||||
|
$periodOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $collection['id'],
|
||||||
|
'created_at' => '2026-07-15 12:00:00',
|
||||||
|
]);
|
||||||
|
$outsideOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $collection['id'],
|
||||||
|
'created_at' => '2026-06-15 12:00:00',
|
||||||
|
]);
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Bound Spot Free', 'price' => 60]);
|
||||||
|
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
|
||||||
|
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
|
||||||
|
api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $periodOrder['id'],
|
||||||
|
'product_id' => $product['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 60,
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $outsideOrder['id'],
|
||||||
|
'product_id' => $product['id'],
|
||||||
|
'cashier_id' => 1,
|
||||||
|
'price' => 60,
|
||||||
|
]);
|
||||||
|
api_fixtures()->setModuleConfig('InvoicingPeriod', 'object_tree_v2_enabled', 'true');
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'superuser_invoicing_period',
|
||||||
|
'reset_collected_invoice_economic',
|
||||||
|
]);
|
||||||
|
$hadDb = array_key_exists('db', $GLOBALS);
|
||||||
|
$previousDb = $GLOBALS['db'] ?? null;
|
||||||
|
$hadRedisConfig = array_key_exists('REDIS_CONFIG', $GLOBALS);
|
||||||
|
$previousRedisConfig = $GLOBALS['REDIS_CONFIG'] ?? null;
|
||||||
|
$testDb = new \classes\db([
|
||||||
|
'host' => getenv('CONFIG_DB_HOST') ?: 'mysql-debug',
|
||||||
|
'user' => getenv('CONFIG_DB_USER') ?: 'root',
|
||||||
|
'password' => getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password',
|
||||||
|
'database' => getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug',
|
||||||
|
'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306),
|
||||||
|
]);
|
||||||
|
$testDb->connect();
|
||||||
|
$GLOBALS['db'] = $testDb;
|
||||||
|
$GLOBALS['REDIS_CONFIG'] = [
|
||||||
|
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
|
||||||
|
'port' => (int)(getenv('REDIS_CONFIG_PORT') ?: 6379),
|
||||||
|
'database' => (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0),
|
||||||
|
'user' => '',
|
||||||
|
'password' => '',
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
$service = new \classes\invoice_collection_bulk_action_service();
|
||||||
|
$binding = $service->createSnapshotBinding(
|
||||||
|
(int)$session['user']['id'],
|
||||||
|
(int)$customer['customer_number'],
|
||||||
|
'2026-07-01',
|
||||||
|
'2026-07-31',
|
||||||
|
[(int)$collection['id']]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if ($hadDb) {
|
||||||
|
$GLOBALS['db'] = $previousDb;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
}
|
||||||
|
if ($hadRedisConfig) {
|
||||||
|
$GLOBALS['REDIS_CONFIG'] = $previousRedisConfig;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['REDIS_CONFIG']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$previewResponse = api_client()->post('/superuser/invoicing/period/tree-actions/preview', [
|
||||||
|
'action' => 'remove_customer_rule_violations',
|
||||||
|
'invoice_collection_ids' => [(int)$collection['id']],
|
||||||
|
'options' => [],
|
||||||
|
'locale' => 'en',
|
||||||
|
'customer_number' => (int)$customer['customer_number'],
|
||||||
|
'snapshot_revision' => (string)$binding['snapshot_revision'],
|
||||||
|
], $session['headers']);
|
||||||
|
$previewResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
$preview = $previewResponse->data();
|
||||||
|
|
||||||
|
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
|
||||||
|
->and($preview['off_period_impact']['order_count'] ?? null)->toBe(1)
|
||||||
|
->and($preview['off_period_impact']['total_net_amount'] ?? null)->toBe(60);
|
||||||
|
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $foreignCustomer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $collection['id'],
|
||||||
|
'created_at' => '2026-07-20 12:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$GLOBALS['db'] = $testDb;
|
||||||
|
$GLOBALS['REDIS_CONFIG'] = [
|
||||||
|
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
|
||||||
|
'port' => (int)(getenv('REDIS_CONFIG_PORT') ?: 6379),
|
||||||
|
'database' => (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0),
|
||||||
|
'user' => '',
|
||||||
|
'password' => '',
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
expect(fn() => $service->createSnapshotBinding(
|
||||||
|
(int)$session['user']['id'],
|
||||||
|
(int)$customer['customer_number'],
|
||||||
|
'2026-07-01',
|
||||||
|
'2026-07-31',
|
||||||
|
[(int)$collection['id']]
|
||||||
|
))->toThrow(\classes\invoice_collection_bulk_action_conflict::class);
|
||||||
|
} finally {
|
||||||
|
$testDb->close();
|
||||||
|
if ($hadDb) {
|
||||||
|
$GLOBALS['db'] = $previousDb;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
}
|
||||||
|
if ($hadRedisConfig) {
|
||||||
|
$GLOBALS['REDIS_CONFIG'] = $previousRedisConfig;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['REDIS_CONFIG']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
|
it('blocks collection mutations including monthly split while an e-conomic export job is active', function (): void {
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Queued Tree Mutation Customer']);
|
||||||
|
$collection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
]);
|
||||||
|
$db = api_test_runtime()->db();
|
||||||
|
$payload = $db->real_escape_string(json_encode([
|
||||||
|
'collected_invoice_id' => (int)$collection['id'],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO economic_transfer_queue_jobs
|
||||||
|
(transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by)
|
||||||
|
VALUES ('COLLECTED_INVOICE_EXPORT', '{$payload}', 'QUEUED', 0, 'Queued', 0, 3, 1)"
|
||||||
|
);
|
||||||
|
$queueJobId = (int)$db->insert_id;
|
||||||
|
api_fixtures()->cleanupDeleteWhere('economic_transfer_queue_jobs', ['id' => $queueJobId]);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'reset_collected_invoice_economic',
|
||||||
|
'split_collected_invoice',
|
||||||
|
]);
|
||||||
|
foreach (['remove_customer_rule_violations', 'split_by_month'] as $action) {
|
||||||
|
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
||||||
|
'action' => $action,
|
||||||
|
'invoice_collection_ids' => [(int)$collection['id']],
|
||||||
|
'options' => [],
|
||||||
|
'locale' => 'en',
|
||||||
|
], $session['headers']);
|
||||||
|
$previewResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
$preview = $previewResponse->data();
|
||||||
|
expect(array_column($preview['blockers'] ?? [], 'code'))->toContain('collection_export_queued')
|
||||||
|
->and($preview['blockers'][0]['queue_job_id'] ?? null)->toBe($queueJobId);
|
||||||
|
|
||||||
|
if ($action === 'split_by_month') {
|
||||||
|
api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||||
|
'preview_id' => $preview['preview_id'],
|
||||||
|
'action' => $action,
|
||||||
|
'invoice_collection_ids' => [(int)$collection['id']],
|
||||||
|
'options' => [],
|
||||||
|
'confirmation_text' => 'Confirm',
|
||||||
|
'locale' => 'en',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Invoice collection has an active e-conomic export job.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
|
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
|
||||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
|
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
|
||||||
@@ -191,7 +460,7 @@ it('previews and applies customer rule cleanup for both spotfree addon products'
|
|||||||
|
|
||||||
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
|
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
|
||||||
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
|
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
|
||||||
});
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
|
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
|
||||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
|
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
|
||||||
@@ -201,21 +470,39 @@ it('merges selected invoice collections into the explicit target after confirmat
|
|||||||
$department = api_fixtures()->createDepartment();
|
$department = api_fixtures()->createDepartment();
|
||||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||||
'customer_number' => $customer['customer_number'],
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'name' => 'Authoritative target',
|
||||||
|
'notes' => 'Keep target metadata',
|
||||||
]);
|
]);
|
||||||
$sourceCollection = api_fixtures()->createInvoiceCollection([
|
$sourceCollection = api_fixtures()->createInvoiceCollection([
|
||||||
'customer_number' => $customer['customer_number'],
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'name' => 'Merge source',
|
||||||
|
'notes' => 'Keep source audit note',
|
||||||
]);
|
]);
|
||||||
$targetOrder = api_fixtures()->createOrder([
|
$targetOrder = api_fixtures()->createOrder([
|
||||||
'customer_id' => $customer['customer_number'],
|
'customer_id' => $customer['customer_number'],
|
||||||
'department_id' => $department['id'],
|
'department_id' => $department['id'],
|
||||||
'invoice_collection_id' => $targetCollection['id'],
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
]);
|
]);
|
||||||
|
$targetOutsidePeriodOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2025-01-10 12:00:00',
|
||||||
|
]);
|
||||||
$sourceOrder = api_fixtures()->createOrder([
|
$sourceOrder = api_fixtures()->createOrder([
|
||||||
'customer_id' => $customer['customer_number'],
|
'customer_id' => $customer['customer_number'],
|
||||||
'department_id' => $department['id'],
|
'department_id' => $department['id'],
|
||||||
'invoice_collection_id' => $sourceCollection['id'],
|
'invoice_collection_id' => $sourceCollection['id'],
|
||||||
]);
|
]);
|
||||||
|
$sourceOutsidePeriodOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $sourceCollection['id'],
|
||||||
|
'created_at' => '2025-01-20 12:00:00',
|
||||||
|
'include_in_invoice' => 0,
|
||||||
|
]);
|
||||||
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
|
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
|
||||||
|
$otherActorSession = api_fixtures()->createUserSession(['move_collected_invoice']);
|
||||||
|
|
||||||
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
||||||
'action' => 'merge_collections',
|
'action' => 'merge_collections',
|
||||||
@@ -232,8 +519,24 @@ it('merges selected invoice collections into the explicit target after confirmat
|
|||||||
$preview = $previewResponse->data();
|
$preview = $previewResponse->data();
|
||||||
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
|
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
|
||||||
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
|
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
|
||||||
->and($preview['summary']['orders_to_move'] ?? null)->toBe(1)
|
->and($preview['summary']['orders_to_move'] ?? null)->toBe(2)
|
||||||
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']);
|
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id'])
|
||||||
|
->and(bulk_action_order_invoice_collection_id((int)$sourceOutsidePeriodOrder['id']))->toBe((int)$sourceCollection['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',
|
||||||
|
], $otherActorSession['headers'])
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
|
||||||
|
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id'])
|
||||||
|
->and(bulk_action_order_invoice_collection_id((int)$sourceOutsidePeriodOrder['id']))->toBe((int)$sourceCollection['id']);
|
||||||
|
|
||||||
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
|
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||||
'preview_id' => $preview['preview_id'],
|
'preview_id' => $preview['preview_id'],
|
||||||
@@ -249,9 +552,42 @@ it('merges selected invoice collections into the explicit target after confirmat
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess();
|
->assertSuccess();
|
||||||
|
|
||||||
|
$applied = $applyResponse->data();
|
||||||
|
$targetRow = bulk_action_invoice_collection_row((int)$targetCollection['id']);
|
||||||
|
$sourceRow = bulk_action_invoice_collection_row((int)$sourceCollection['id']);
|
||||||
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)$sourceOutsidePeriodOrder['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)$targetOutsidePeriodOrder['id']))->toBe((int)$targetCollection['id'])
|
||||||
|
->and($targetRow['name'] ?? null)->toBe('Authoritative target')
|
||||||
|
->and($targetRow['notes'] ?? null)->toBe('Keep target metadata')
|
||||||
|
->and($sourceRow['notes'] ?? '')->toContain('[[invoice_collection_superseded:')
|
||||||
|
->and($sourceRow['closed_at'] ?? null)->not->toBeNull()
|
||||||
|
->and($applied['result']['superseded_invoice_collection_ids'] ?? [])
|
||||||
|
->toBe([(int)$sourceCollection['id']]);
|
||||||
|
|
||||||
|
$exportSession = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
|
||||||
|
api_client()->post('/collected-invoices/economic', [
|
||||||
|
'id' => (int)$sourceCollection['id'],
|
||||||
|
], $exportSession['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Invoice collection has been superseded and cannot be exported.');
|
||||||
|
|
||||||
|
$supersededMergePreview = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
||||||
|
'action' => 'merge_collections',
|
||||||
|
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
|
||||||
|
'options' => ['target_invoice_collection_id' => $sourceCollection['id']],
|
||||||
|
'locale' => 'en',
|
||||||
|
], $session['headers']);
|
||||||
|
$supersededMergePreview
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
expect(array_column($supersededMergePreview->data()['blockers'] ?? [], 'code'))
|
||||||
|
->toContain('collection_superseded');
|
||||||
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
it('rejects apply when export-relevant order item content changed after preview', function (): void {
|
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/preview', 'stale-export-content');
|
||||||
@@ -299,7 +635,7 @@ it('rejects apply when export-relevant order item content changed after preview'
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessage('The invoice collections changed after preview. Refresh the preview before applying this action.');
|
->assertMessage('The invoice collections changed after preview. Refresh the preview before applying this action.');
|
||||||
});
|
})->group('invoice-tree-v2');
|
||||||
|
|
||||||
it('blocks queue economic preview and apply for the configured draft customer', function (): void {
|
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/preview', 'draft-customer');
|
||||||
|
|||||||
+39
-4
@@ -105,6 +105,32 @@ function economic_transfer_queue_cleanup_for_created_by(db $db, int $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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function economic_transfer_queue_create_invoice_collection(db $db, int $customer_number): int
|
||||||
|
{
|
||||||
|
$name = $db->escape_string('Queue integration fixture ' . $customer_number);
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO collected_order_invoices
|
||||||
|
(customer_number, name, notes, processor, external_id, booked_invoice_id, po_number, error_message, closed_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
($customer_number, '$name', '', 1, NULL, NULL, NULL, NULL, NULL, NOW(), NOW())"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int)$db->insert_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param int[] $invoice_collection_ids */
|
||||||
|
function economic_transfer_queue_cleanup_invoice_collections(db $db, array $invoice_collection_ids): void
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $invoice_collection_ids),
|
||||||
|
static fn(int $id): bool => $id > 0
|
||||||
|
)));
|
||||||
|
if ($ids === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db->query('DELETE FROM collected_order_invoices WHERE id IN (' . implode(',', $ids) . ')');
|
||||||
|
}
|
||||||
|
|
||||||
it('processes queued transfer jobs to completion', function (): void {
|
it('processes queued transfer jobs to completion', function (): void {
|
||||||
$db = economic_transfer_queue_integration_db();
|
$db = economic_transfer_queue_integration_db();
|
||||||
$created_by = 920000 + random_int(1000, 9999);
|
$created_by = 920000 + random_int(1000, 9999);
|
||||||
@@ -144,7 +170,8 @@ it('processes queued transfer jobs to completion', function (): void {
|
|||||||
it('deduplicates active jobs per transfer target', function (): void {
|
it('deduplicates active jobs per transfer target', function (): void {
|
||||||
$db = economic_transfer_queue_integration_db();
|
$db = economic_transfer_queue_integration_db();
|
||||||
$created_by = 921000 + random_int(1000, 9999);
|
$created_by = 921000 + random_int(1000, 9999);
|
||||||
$collected_invoice_id = 931000 + random_int(1000, 9999);
|
$customer_number = 931000 + random_int(1000, 9999);
|
||||||
|
$collected_invoice_id = economic_transfer_queue_create_invoice_collection($db, $customer_number);
|
||||||
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +209,7 @@ it('deduplicates active jobs per transfer target', function (): void {
|
|||||||
expect((int)($row['cnt'] ?? 0))->toBe(1);
|
expect((int)($row['cnt'] ?? 0))->toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
|
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
|
||||||
|
economic_transfer_queue_cleanup_invoice_collections($db, [$collected_invoice_id]);
|
||||||
$db->close();
|
$db->close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -191,7 +219,8 @@ it('registers later deduplicated requesters for narrowly scoped queue monitoring
|
|||||||
$creator = 921200 + random_int(1000, 9999);
|
$creator = 921200 + random_int(1000, 9999);
|
||||||
$requester = 941200 + random_int(1000, 9999);
|
$requester = 941200 + random_int(1000, 9999);
|
||||||
$unrelated = 951200 + random_int(1000, 9999);
|
$unrelated = 951200 + random_int(1000, 9999);
|
||||||
$collected_invoice_id = 961200 + random_int(1000, 9999);
|
$customer_number = 961200 + random_int(1000, 9999);
|
||||||
|
$collected_invoice_id = economic_transfer_queue_create_invoice_collection($db, $customer_number);
|
||||||
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -219,6 +248,7 @@ it('registers later deduplicated requesters for narrowly scoped queue monitoring
|
|||||||
} finally {
|
} finally {
|
||||||
economic_transfer_queue_cleanup_for_created_by($db, $requester);
|
economic_transfer_queue_cleanup_for_created_by($db, $requester);
|
||||||
economic_transfer_queue_cleanup_for_created_by($db, $creator);
|
economic_transfer_queue_cleanup_for_created_by($db, $creator);
|
||||||
|
economic_transfer_queue_cleanup_invoice_collections($db, [$collected_invoice_id]);
|
||||||
$db->close();
|
$db->close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -227,8 +257,9 @@ it('processes only collected-invoice jobs and respects the manual batch limit',
|
|||||||
$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);
|
||||||
$order_id = 932000 + random_int(1000, 9999);
|
$order_id = 932000 + random_int(1000, 9999);
|
||||||
$first_collected_invoice_id = 932500 + random_int(1000, 9999);
|
$customer_number = 932500 + random_int(1000, 9999);
|
||||||
$second_collected_invoice_id = 933000 + random_int(1000, 9999);
|
$first_collected_invoice_id = economic_transfer_queue_create_invoice_collection($db, $customer_number);
|
||||||
|
$second_collected_invoice_id = economic_transfer_queue_create_invoice_collection($db, $customer_number);
|
||||||
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
$queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -289,6 +320,10 @@ it('processes only collected-invoice jobs and respects the manual batch limit',
|
|||||||
expect((string)$queued_order_job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED);
|
expect((string)$queued_order_job['status'])->toBe(economic_transfer_queue::STATUS_QUEUED);
|
||||||
} finally {
|
} finally {
|
||||||
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
|
economic_transfer_queue_cleanup_for_created_by($db, $created_by);
|
||||||
|
economic_transfer_queue_cleanup_invoice_collections(
|
||||||
|
$db,
|
||||||
|
[$first_collected_invoice_id, $second_collected_invoice_id]
|
||||||
|
);
|
||||||
$db->close();
|
$db->close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,77 @@ it('rejects stale bulk-action previews with a content digest and conflict respon
|
|||||||
->toContain('$response->error($e->getMessage(), 409);');
|
->toContain('$response->error($e->getMessage(), 409);');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('binds invoice-period tree bulk previews to the actor and cached preview action', 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("'actor_user_id' => \$actorUserId")
|
||||||
|
->toContain('validateSnapshotBinding(')
|
||||||
|
->toContain('public function cachedPreviewAction(string $previewId): string')
|
||||||
|
->toContain('Preview belongs to another user. Create a new preview.')
|
||||||
|
->and($route)
|
||||||
|
->toContain("\$service->cachedPreviewAction((string)self::getParameter('preview_id'))")
|
||||||
|
->toContain("'customer_number'")
|
||||||
|
->toContain("'snapshot_revision'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews whole-tree cleanup and merge supersession semantics', function (): void {
|
||||||
|
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
|
||||||
|
$object = (string)file_get_contents(app_path('objects/collected_order_invoices_o.php'));
|
||||||
|
|
||||||
|
expect($service)
|
||||||
|
->toContain('$this->orderItemRows((int)$collection->id, true)')
|
||||||
|
->toContain('private function expandCleanupRows(array $rows, array $violationsByItemId): array')
|
||||||
|
->toContain('foreach ($this->allCollectionOrderRows((int)$collection->id) as $orderIdRow)')
|
||||||
|
->toContain("'supersession' => array_map")
|
||||||
|
->toContain('$source->markSupersededBy($targetId, $actorUserId)')
|
||||||
|
->and($object)
|
||||||
|
->toContain('public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void')
|
||||||
|
->toContain('SUPERSESSION_MARKER_PATTERN')
|
||||||
|
->toContain('public function getSupersessionMetadata(): ?array')
|
||||||
|
->toContain('The source invoice collection has already been superseded.')
|
||||||
|
->toContain('The target invoice collection has already been superseded.')
|
||||||
|
->toContain('if ($this->getSupersessionMetadata() !== null)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands hidden cleanup descendants recursively and remains cycle safe', function (): void {
|
||||||
|
$service = (new ReflectionClass(invoice_collection_bulk_action_service::class))
|
||||||
|
->newInstanceWithoutConstructor();
|
||||||
|
$method = (new ReflectionClass(invoice_collection_bulk_action_service::class))
|
||||||
|
->getMethod('expandCleanupRows');
|
||||||
|
$rows = [
|
||||||
|
['order_item_id' => 10, 'order_id' => 1, 'related_item_id' => 13],
|
||||||
|
['order_item_id' => 11, 'order_id' => 1, 'related_item_id' => 10, 'include_in_invoice' => 0],
|
||||||
|
['order_item_id' => 12, 'order_id' => 1, 'related_item_id' => 11, 'include_in_invoice' => 0],
|
||||||
|
['order_item_id' => 13, 'order_id' => 1, 'related_item_id' => 12, 'include_in_invoice' => 0],
|
||||||
|
['order_item_id' => 14, 'order_id' => 2, 'related_item_id' => 10, 'include_in_invoice' => 0],
|
||||||
|
['order_item_id' => 99, 'order_id' => 1, 'related_item_id' => null],
|
||||||
|
];
|
||||||
|
|
||||||
|
$expanded = $method->invoke($service, $rows, [10 => 'customer_product_restricted']);
|
||||||
|
|
||||||
|
expect(array_column(array_column($expanded, 'row'), 'order_item_id'))->toBe([10, 11, 12, 13])
|
||||||
|
->and(array_column($expanded, 'rule'))->toBe([
|
||||||
|
'customer_product_restricted',
|
||||||
|
'related_to_removed_item',
|
||||||
|
'related_to_removed_item',
|
||||||
|
'related_to_removed_item',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legacy full-payload apply and exposes cached tree-action aliases', function (): void {
|
||||||
|
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||||
|
|
||||||
|
expect($route)
|
||||||
|
->toMatch("/\\/collected-invoices\\/bulk-actions\\/apply'.*?requireParameters\\(\\['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text'\\]\\)/s")
|
||||||
|
->toContain("\$this->post('/superuser/invoicing/period/tree-actions/preview'")
|
||||||
|
->toContain("\$this->post('/superuser/invoicing/period/tree-actions/apply'")
|
||||||
|
->toContain('InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)')
|
||||||
|
->toContain('$service->applyCachedPreview(')
|
||||||
|
->toContain('$response->error($e->getMessage(), 409);');
|
||||||
|
});
|
||||||
|
|
||||||
it('changes the stale-preview digest when an export-relevant line changes', function (): void {
|
it('changes the stale-preview digest when an export-relevant line changes', function (): void {
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -92,3 +163,35 @@ it('enqueues bulk E-conomic transfers durably and deduplicates active jobs by gl
|
|||||||
->toContain('CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, \'$json_path\')) AS UNSIGNED) = ?')
|
->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 = ?");
|
->not->toContain("CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$" . "json_path')) AS UNSIGNED) = ?\n AND created_by = ?");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes collected-invoice enqueue and worker export with collection mutations', function (): void {
|
||||||
|
$queue = (string)file_get_contents(app_path('classes/economic_transfer_queue.php'));
|
||||||
|
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||||
|
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
|
||||||
|
|
||||||
|
$enqueueStart = strpos($queue, 'public function enqueue(');
|
||||||
|
$activeJobCheck = strpos($queue, '$active_job = $this->findActiveJobByTarget', (int)$enqueueStart);
|
||||||
|
$enqueueLock = strpos($queue, '$this->acquireCollectedInvoiceExportLock($transfer_type, $payload)', (int)$enqueueStart);
|
||||||
|
$workerStart = strpos($queue, 'private function executeCollectedInvoiceExport(');
|
||||||
|
$workerLock = strpos($queue, '$this->acquireCollectedInvoiceExportLock(', (int)$workerStart);
|
||||||
|
$workerRevalidation = strpos($queue, '$this->assertCollectedInvoiceExportIsStillEligible($payload)', (int)$workerStart);
|
||||||
|
$workerExport = strpos($queue, '$this->executor->exportCollectedInvoice(', (int)$workerStart);
|
||||||
|
$routeStart = strpos($route, "\$this->post('/collected-invoices/economic'");
|
||||||
|
$routeEnd = strpos($route, "\$this->get('/collected-invoices/economic/queue'", (int)$routeStart);
|
||||||
|
$routeBlock = substr($route, (int)$routeStart, (int)$routeEnd - (int)$routeStart);
|
||||||
|
$routeLock = strpos($routeBlock, '$collection_export_lock = order_payment_lock::tryAcquireInvoiceCollection(');
|
||||||
|
$routeEligibility = strpos($routeBlock, 'invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic(');
|
||||||
|
|
||||||
|
expect($enqueueStart)->not->toBeFalse()
|
||||||
|
->and($enqueueLock)->not->toBeFalse()->toBeLessThan($activeJobCheck)
|
||||||
|
->and($workerStart)->not->toBeFalse()
|
||||||
|
->and($workerLock)->not->toBeFalse()->toBeLessThan($workerRevalidation)
|
||||||
|
->and($workerRevalidation)->not->toBeFalse()->toBeLessThan($workerExport)
|
||||||
|
->and($queue)->toContain('order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id)')
|
||||||
|
->toContain('invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection)')
|
||||||
|
->and($route)->toContain('use classes\\order_payment_lock;')
|
||||||
|
->and($routeStart)->not->toBeFalse()
|
||||||
|
->and($routeEnd)->not->toBeFalse()
|
||||||
|
->and($routeLock)->not->toBeFalse()->toBeLessThan($routeEligibility)
|
||||||
|
->and($service)->toContain("Invoice collection has been superseded and cannot be exported.");
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,3 +39,24 @@ it('documents the exception-first period review contract and workflow queries',
|
|||||||
->toContain('InvoicingPeriodPagination:')
|
->toContain('InvoicingPeriodPagination:')
|
||||||
->toContain('required: [review_states, severities, invoice_states, department_ids]');
|
->toContain('required: [review_states, severities, invoice_states, department_ids]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('documents selected-customer object-tree snapshots and actor-bound bulk action fields', function (): void {
|
||||||
|
$content = (string)file_get_contents(app_path('openapi.yaml'));
|
||||||
|
|
||||||
|
expect($content)
|
||||||
|
->toContain('/superuser/invoicing/period/tree:')
|
||||||
|
->toContain('/superuser/invoicing/period/tree-actions/preview:')
|
||||||
|
->toContain('/superuser/invoicing/period/tree-actions/apply:')
|
||||||
|
->not->toContain('- name: includeTreeSnapshot')
|
||||||
|
->toContain('InvoicingPeriodTreeSnapshotEnvelope:')
|
||||||
|
->toContain('InvoicingPeriodTreeCapabilities:')
|
||||||
|
->toContain('InvoiceCollectionTreeActionPreviewEnvelope:')
|
||||||
|
->toContain('InvoiceCollectionTreeActionPreview:')
|
||||||
|
->toContain('off_period_impact:')
|
||||||
|
->toContain('date_from: {type: string, format: date}')
|
||||||
|
->toContain('complete_order_count')
|
||||||
|
->toContain('period_total_net_amount')
|
||||||
|
->toContain('snapshot_revision:')
|
||||||
|
->toContain('customer_number:')
|
||||||
|
->toContain('supplied action and selection still match the cached preview');
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ it('only includes invoice period flags when the list permission is granted', fun
|
|||||||
->and($flagService)->toContain("unset(\$types[\$typeName][\$index]['flags']);");
|
->and($flagService)->toContain("unset(\$types[\$typeName][\$index]['flags']);");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('advertises a server-gated selected-customer tree and fails the dedicated route closed', function (): void {
|
||||||
|
$route = (string)file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
|
||||||
|
|
||||||
|
expect($route)
|
||||||
|
->toContain('INVOICING_PERIOD_OBJECT_TREE_V2')
|
||||||
|
->toContain("module = 'InvoicingPeriod'")
|
||||||
|
->toContain("'object_tree_v2_enabled'")
|
||||||
|
->toContain("'object_tree_v2_superuser_allowlist'")
|
||||||
|
->not->toContain("includeTreeSnapshot")
|
||||||
|
->toContain("\$this->get('/superuser/invoicing/period/tree'")
|
||||||
|
->toContain("Invoice-period object tree is not enabled.")
|
||||||
|
->toContain('$response->error(\'Invoice-period object tree is not enabled.\', 403);')
|
||||||
|
->toContain('buildInvoicePeriodTreeSnapshot(')
|
||||||
|
->toContain("'complete_order_count'")
|
||||||
|
->toContain("'period_total_net_amount'")
|
||||||
|
->toContain("'date_from' => substr(\$dateFrom, 0, 10)")
|
||||||
|
->toContain("'date_to' => substr(\$dateTo, 0, 10)")
|
||||||
|
->toContain("\$period['capabilities']['object_tree_v2'] = \$enabled;");
|
||||||
|
});
|
||||||
|
|
||||||
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 {
|
||||||
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
||||||
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
|
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
|
||||||
|
|||||||
Reference in New Issue
Block a user