From 068f9e254f2aeb24a69313a6ad41f7f58a44871b Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 3 Aug 2026 12:02:34 +0200 Subject: [PATCH] Complete selected-customer invoice period tree (#338) Add the authoritative revision-bound invoice collection tree and guarded cleanup, merge, price-reset, and transfer operations. --- .../app/classes/economic_transfer_queue.php | 46 ++ ...invoice_collection_bulk_action_service.php | 635 ++++++++++++++- .../objects/collected_order_invoices_o.php | 144 ++++ services/nginx/app/objects/orders_o.php | 2 +- services/nginx/app/openapi.yaml | 235 +++++- .../nginx/app/routes/InvoicingPeriodRoute.php | 739 +++++++++++++++++- .../nginx/app/routes/orderInvoicesRoute.php | 130 ++- .../CollectedInvoiceBulkActionsApiTest.php | 362 ++++++++- .../EconomicTransferQueueIntegrationTest.php | 43 +- .../InvoiceCollectionBulkActionSafetyTest.php | 103 +++ .../InvoicingPeriodBackendOpenApiTest.php | 21 + .../InvoicingPeriodRouteGuardsTest.php | 20 + 12 files changed, 2413 insertions(+), 67 deletions(-) diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php index 57e3a385..2093e969 100644 --- a/services/nginx/app/classes/economic_transfer_queue.php +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -40,6 +40,10 @@ class economic_transfer_queue $max_attempts = max(1, min(10, $max_attempts)); $transfer_type = $this->validateTransferType($transfer_type); $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); if ($active_job !== null) { @@ -628,11 +632,53 @@ class economic_transfer_queue if ($collected_invoice_id < 1) { 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); $this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice'); 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 { global $db; diff --git a/services/nginx/app/classes/invoice_collection_bulk_action_service.php b/services/nginx/app/classes/invoice_collection_bulk_action_service.php index f9c0b8c5..022d3835 100644 --- a/services/nginx/app/classes/invoice_collection_bulk_action_service.php +++ b/services/nginx/app/classes/invoice_collection_bulk_action_service.php @@ -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 { public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations'; @@ -32,24 +36,63 @@ class invoice_collection_bulk_action_service '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); $invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds); $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); + if ($snapshotBinding !== null) { + $preview['off_period_impact'] = $this->calculateOffPeriodImpact( + $invoiceCollectionIds, + (string)$snapshotBinding['date_from'], + (string)$snapshotBinding['date_to'] + ); + } $preview['content_digest'] = $this->previewContentDigest($preview); $previewId = $this->previewId(); $preview['preview_id'] = $previewId; $preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options); $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, [ + 'actor_user_id' => $actorUserId, 'action' => $action, 'invoice_collection_ids' => $invoiceCollectionIds, 'options' => $options, 'locale' => $locale, + 'customer_number' => $preview['customer_number'], + 'snapshot_revision' => $preview['snapshot_revision'], 'selection_hash' => $preview['selection_hash'], 'preview' => $preview, ]); @@ -64,7 +107,9 @@ class invoice_collection_bulk_action_service array $options, string $confirmationText, int $actorUserId, - string $locale = 'da' + string $locale = 'da', + ?int $customerNumber = null, + ?string $snapshotRevision = null ): array { global $db; @@ -75,12 +120,37 @@ class invoice_collection_bulk_action_service $selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options); 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)); 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 = [ @@ -91,12 +161,19 @@ class invoice_collection_bulk_action_service $lockedCollectionIds ); if ($paymentMutationLock === null) { - throw new Exception( + throw new invoice_collection_bulk_action_conflict( 'An invoice collection is currently being changed or paid. Try again.' ); } $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); if (!hash_equals((string)($cached['preview']['content_digest'] ?? ''), $freshDigest)) { throw new invoice_collection_bulk_action_conflict( @@ -105,7 +182,7 @@ class invoice_collection_bulk_action_service } $freshPreview['content_digest'] = $freshDigest; 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.')); } @@ -113,12 +190,17 @@ class invoice_collection_bulk_action_service $economicQueue = $action === self::ACTION_QUEUE_ECONOMIC ? new economic_transfer_queue() : 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(); try { $result = match ($action) { 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_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview), 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 + */ + 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 { $collections = $this->loadCollections($invoiceCollectionIds); @@ -181,9 +508,11 @@ class invoice_collection_bulk_action_service $blockers = []; foreach ($collections as $collection) { $blockers = [...$blockers, ...$this->contentMutationBlockers($collection)]; - $rows = $this->orderItemRows((int)$collection->id); - $violatingItemIds = []; - $includedItemIds = []; + // The object tree can contain hidden and arbitrarily nested related items. Load the + // complete active tree so deleting a violating parent cannot leave hidden/orphaned + // descendants behind. + $rows = $this->orderItemRows((int)$collection->id, true); + $violationsByItemId = []; foreach ($rows as $row) { $violation = (new customer_product_rule_service())->firstViolationForOrderItem( (int)$row['order_id'], @@ -193,37 +522,22 @@ class invoice_collection_bulk_action_service if ($violation === null) { continue; } - $violatingItemIds[] = (int)$row['order_item_id']; - $includedItemIds[] = (int)$row['order_item_id']; + $violationsByItemId[(int)$row['order_item_id']] = (string)$violation['rule']; + } + + foreach ($this->expandCleanupRows($rows, $violationsByItemId) as $cleanupRow) { + $row = $cleanupRow['row']; $items[] = [ 'invoice_collection_id' => (int)$collection->id, 'order_id' => (int)$row['order_id'], 'order_item_id' => (int)$row['order_item_id'], 'product_id' => (int)$row['product_id'], 'product_name' => (string)$row['product_name'], - 'rule' => (string)$violation['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', + 'rule' => (string)$cleanupRow['rule'], 'price' => (int)$row['price'], '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, ]; } @@ -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> $rows + * @param array $violationsByItemId + * @return array,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 { $targetId = (int)($options['target_invoice_collection_id'] ?? 0); @@ -260,11 +631,21 @@ class invoice_collection_bulk_action_service } $ordersToMove = []; + $sourceInvoiceCollectionIds = []; foreach ($collections as $collection) { if ((int)$collection->id === $targetId) { 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[] = [ 'order_id' => (int)$orderIdRow['id'], 'source_invoice_collection_id' => (int)$collection->id, @@ -276,10 +657,17 @@ class invoice_collection_bulk_action_service return [ ...$preview, '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, 'summary' => [ 'collections' => count($collections), 'orders_to_move' => count($ordersToMove), + 'sources_to_supersede' => count($sourceInvoiceCollectionIds), 'changed_count' => count($ordersToMove), ], 'blockers' => $blockers, @@ -291,7 +679,9 @@ class invoice_collection_bulk_action_service $items = []; $changed = []; $skipped = []; + $blockers = []; foreach ($collections as $collection) { + $blockers = [...$blockers, ...$this->contentMutationBlockers($collection)]; try { $item = [ 'invoice_collection_id' => (int)$collection->id, @@ -325,6 +715,7 @@ class invoice_collection_bulk_action_service 'changed_count' => count($changed), '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]; } - 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); $moved = []; @@ -425,8 +816,23 @@ class invoice_collection_bulk_action_service $order->assignToInvoiceCollection($targetId); $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'] ?? []); - 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 @@ -501,6 +907,7 @@ class invoice_collection_bulk_action_service private function contentMutationBlockers(collected_order_invoices_o $collection): array { + global $db; $blockers = []; if (!empty($collection->booked_invoice_id->value())) { $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())) { $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; } @@ -538,6 +976,52 @@ class invoice_collection_bulk_action_service return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; } + /** @return array */ + 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 { global $db; @@ -653,6 +1137,9 @@ class invoice_collection_bulk_action_service public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void { $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()); if ($collection->booked_invoice_id->value() !== null) { 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_QUEUE_ECONOMIC, ], true)) { - throw new Exception('Invalid invoice collection bulk action.'); + throw new invoice_collection_bulk_action_validation('Invalid invoice collection bulk action.'); } return $action; } @@ -679,25 +1166,78 @@ class invoice_collection_bulk_action_service $normalized = []; foreach ($ids as $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; 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 = array_values($normalized); sort($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) { - throw new Exception('Too many invoice collections selected.'); + throw new invoice_collection_bulk_action_validation('Too many invoice collections selected.'); } 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 { 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)) { $options['send_as_is'] = strtolower(trim($value)) === 'true'; } 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); @@ -753,7 +1293,16 @@ class invoice_collection_bulk_action_service 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 diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 48f57852..f7a81ca3 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -26,6 +26,9 @@ class collected_order_invoices_o extends db { 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 $name; public object_property $notes; @@ -78,6 +81,9 @@ class collected_order_invoices_o extends db $this->id = $collection['id']; self::getObjectProperties(); self::requireSelected(); + if ($this->getSupersessionMetadata() !== null) { + continue; + } $result[] = self::asArray(); } 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); } + 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 * @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; } + /** + * 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 * @returns bool If the invoice draft is existing diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index b9a87a69..7a4c9b9b 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1340,7 +1340,7 @@ class orders_o extends db WHERE customer_number IS NOT NULL AND customer_number <> 0 GROUP BY customer_number ) 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 economic_module_orders emo ON emo.id = o.id LEFT JOIN ( diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 101b7652..d221f796 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -7584,6 +7584,13 @@ paths: target_invoice_collection_id: type: integer 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: type: string example: da @@ -7605,12 +7612,14 @@ paths: content_digest: {type: string, pattern: '^[a-f0-9]{64}$'} selection_hash: {type: string, pattern: '^[a-f0-9]{64}$'} confirmation_phrase: {type: string} + customer_number: {type: integer} + snapshot_revision: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} /collected-invoices/bulk-actions/apply: post: tags: - Invoices 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 requestBody: required: true @@ -7626,6 +7635,7 @@ paths: properties: preview_id: type: string + description: Preview id returned by previewCollectedInvoiceBulkAction. action: type: string enum: @@ -7659,6 +7669,93 @@ paths: schema: {} '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: post: tags: @@ -8336,6 +8433,35 @@ paths: schema: $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: get: tags: @@ -20825,6 +20951,113 @@ components: total_net_amount: type: number 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: type: object diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 160c0e5d..5edc237b 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -7,6 +7,9 @@ use classes\economic; use classes\economic_transfer_queue; use classes\economic_v2_distribution_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\invoicing_period_utils; use classes\slack; @@ -37,6 +40,7 @@ class InvoicingPeriodRoute private static array $departmentExcludedFromInvoicingCache = []; private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; + private static bool $suppressInvoicePeriodExternalEffects = false; /** * Local-only booked status caches used by the period response. @@ -89,6 +93,9 @@ class InvoicingPeriodRoute */ private static function shouldSendSlackSummary(): bool { + if (self::$suppressInvoicePeriodExternalEffects) { + return false; + } $requestOverride = $_GET['sendSlackSummary'] ?? null; if ($requestOverride !== null) { 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); } + /** + * 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} */ @@ -1213,8 +1302,11 @@ class InvoicingPeriodRoute } $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); $includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags'); + $objectTreeV2Enabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id); // Get the invoicing period for the user $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags); + $period = self::applyInvoicePeriodObjectTreeCapability($period, $objectTreeV2Enabled); + $response->add_meta('invoice_period_object_tree_v2', $objectTreeV2Enabled); if ($paginationOptions !== null) { $paginated = self::applyPeriodPagination($period, $paginationOptions); $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 () { global $response; $this->requirePermission('add_invoice_period_flag'); @@ -1841,7 +1972,9 @@ class InvoicingPeriodRoute $message .= "Difference: " . $difference . "\n"; $message .= "Debug info: " . print_r($debug_info, true); // Describe what to check - (new slack())->send_message($message, 'Subscription Price Distribution Mismatch'); + if (!self::$suppressInvoicePeriodExternalEffects) { + (new slack())->send_message($message, 'Subscription Price Distribution Mismatch'); + } // Throw an error 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 array>> + */ + 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> $rows + * @return array> + */ + 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> */ + 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>> $ordersByCollection + * @param array> $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 { $meta = $customer['meta'] ?? []; diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index ff180d3c..57d71084 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -12,7 +12,9 @@ use classes\economic_v2_revenue_statistics_service; use classes\invoice_store; use classes\invoice_collection_bulk_action_service; use classes\invoice_collection_bulk_action_conflict; +use classes\invoice_collection_bulk_action_validation; use classes\invoicing_period_utils; +use classes\order_payment_lock; use classes\response; use classes\router; use Exception; @@ -756,14 +758,27 @@ class orderInvoicesRoute $response->error('options must be an object', 400); } $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 { $preview = (new invoice_collection_bulk_action_service())->preview( $action, $invoice_collection_ids, $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) { $response->error($e->getMessage(), 400); } @@ -798,7 +813,6 @@ class orderInvoicesRoute self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']); $action = (string)self::getParameter('action'); $this->requireCollectedInvoiceBulkActionPermission($action); - $invoice_collection_ids = self::getParameter('invoice_collection_ids'); if (!is_array($invoice_collection_ids)) { $response->error('invoice_collection_ids must be an array', 400); @@ -808,6 +822,12 @@ class orderInvoicesRoute $response->error('options must be an object', 400); } $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 { $result = (new invoice_collection_bulk_action_service())->apply( @@ -817,12 +837,16 @@ class orderInvoicesRoute $options, (string)self::getParameter('confirmation_text'), (int)$user->id, - $locale + $locale, + $customer_number, + $snapshot_revision ); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); - } catch (\Throwable $e) { + } catch (invoice_collection_bulk_action_validation $e) { $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); } $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) */ $this->post('/collected-invoices/economic', function () { 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->requireSelected(); try { diff --git a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php index 7a008ed8..29feedc8 100644 --- a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php +++ b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php @@ -16,6 +16,14 @@ function bulk_action_order_invoice_collection_id(int $orderId): int 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 { new \classes\customer_rule_product_restriction_service(); @@ -24,7 +32,21 @@ function bulk_action_configure_rule_product(string $attribute, int $productId): $result = $db->query( "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( "INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id) VALUES ({$collectionId}, {$productId})" @@ -50,6 +72,12 @@ it('previews and applies customer rule cleanup only after exact typed confirmati 'department_id' => $department['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([ 'name' => 'Spot Free rinse', 'price' => 80, @@ -61,6 +89,39 @@ it('previews and applies customer rule cleanup only after exact typed confirmati 'cashier_id' => 1, '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']); $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(); $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() ->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft') - ->and($preview['summary']['changed_count'] ?? null)->toBe(1) - ->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id']) - ->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull(); + ->and($preview['summary']['changed_count'] ?? null)->toBe(4) + ->and($previewItemIds)->toBe($expectedCleanupIds) + ->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', [ 'preview_id' => $preview['preview_id'], @@ -109,9 +183,204 @@ it('previews and applies customer rule cleanup only after exact typed confirmati $applied = $applyResponse->data(); expect($applied['preview'] ?? null)->toBeFalse() - ->and($applied['result']['changed_count'] ?? null)->toBe(1) - ->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull(); -}); + ->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)$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 { 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() ->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 { 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(); $targetCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], + 'name' => 'Authoritative target', + 'notes' => 'Keep target metadata', ]); $sourceCollection = api_fixtures()->createInvoiceCollection([ 'customer_number' => $customer['customer_number'], + 'name' => 'Merge source', + 'notes' => 'Keep source audit note', ]); $targetOrder = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['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([ 'customer_id' => $customer['customer_number'], 'department_id' => $department['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']); + $otherActorSession = api_fixtures()->createUserSession(['move_collected_invoice']); $previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [ 'action' => 'merge_collections', @@ -232,8 +519,24 @@ it('merges selected invoice collections into the explicit target after confirmat $preview = $previewResponse->data(); expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm') ->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id']) - ->and($preview['summary']['orders_to_move'] ?? null)->toBe(1) - ->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']); + ->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)$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', [ 'preview_id' => $preview['preview_id'], @@ -249,9 +552,42 @@ it('merges selected invoice collections into the explicit target after confirmat ->assertEnvelope() ->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']) - ->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 { 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() ->assertSuccess(false) ->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 { api_test_covers('POST /collected-invoices/bulk-actions/preview', 'draft-customer'); diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php index 168f4545..461f9f49 100644 --- a/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php @@ -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"); } +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 { $db = economic_transfer_queue_integration_db(); $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 { $db = economic_transfer_queue_integration_db(); $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()); try { @@ -182,6 +209,7 @@ it('deduplicates active jobs per transfer target', function (): void { expect((int)($row['cnt'] ?? 0))->toBe(1); } finally { economic_transfer_queue_cleanup_for_created_by($db, $created_by); + economic_transfer_queue_cleanup_invoice_collections($db, [$collected_invoice_id]); $db->close(); } }); @@ -191,7 +219,8 @@ it('registers later deduplicated requesters for narrowly scoped queue monitoring $creator = 921200 + random_int(1000, 9999); $requester = 941200 + random_int(1000, 9999); $unrelated = 951200 + random_int(1000, 9999); - $collected_invoice_id = 961200 + random_int(1000, 9999); + $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()); try { @@ -219,6 +248,7 @@ it('registers later deduplicated requesters for narrowly scoped queue monitoring } finally { economic_transfer_queue_cleanup_for_created_by($db, $requester); economic_transfer_queue_cleanup_for_created_by($db, $creator); + economic_transfer_queue_cleanup_invoice_collections($db, [$collected_invoice_id]); $db->close(); } }); @@ -227,8 +257,9 @@ it('processes only collected-invoice jobs and respects the manual batch limit', $db = economic_transfer_queue_integration_db(); $created_by = 921500 + random_int(1000, 9999); $order_id = 932000 + random_int(1000, 9999); - $first_collected_invoice_id = 932500 + random_int(1000, 9999); - $second_collected_invoice_id = 933000 + random_int(1000, 9999); + $customer_number = 932500 + 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()); 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); } finally { 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(); } }); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php index 9e42f663..d7f5f530 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php @@ -17,6 +17,77 @@ it('rejects stale bulk-action previews with a content digest and conflict respon ->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 { 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) = ?') ->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."); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php index 025805c6..71170c41 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php @@ -39,3 +39,24 @@ it('documents the exception-first period review contract and workflow queries', ->toContain('InvoicingPeriodPagination:') ->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'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index 2d0a9aec..bf244360 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -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']);"); }); +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 { $reflection = new ReflectionClass(InvoicingPeriodRoute::class); $method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');