Files
api/services/nginx/app/classes/invoice_collection_bulk_action_service.php
T
bd2ff1be9a Fix false 409 snapshot expiry in invoice-period tree preview (#350)
## Problem
`POST /superuser/invoicing/period/tree-actions/preview` could return
`409 Invoice-period snapshot is missing or expired` during normal UI
flows when a snapshot binding aged out before the user triggered the
action.

## Fix
- introduce a dedicated snapshot cache TTL
(`SNAPSHOT_BINDING_TTL_SECONDS`)
- keep preview cache TTL unchanged (`PREVIEW_TTL_SECONDS`)
- use the longer snapshot TTL for actor/customer snapshot binding writes

This preserves existing safety because snapshot bindings are still
revalidated against fresh revision data before use.

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php
--colors=never`
- `vendor/bin/pest tests/Api/CollectedInvoiceBulkActionsApiTest.php
--colors=never` (suite present; skipped without `RUN_API_TESTS=1`)

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 12:55:06 +00:00

1329 lines
54 KiB
PHP

<?php
namespace classes;
use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class invoice_collection_bulk_action_conflict extends Exception
{
}
class invoice_collection_bulk_action_validation extends Exception
{
}
class invoice_collection_bulk_action_service
{
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
public const ACTION_MERGE = 'merge_collections';
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
private const PREVIEW_TTL_SECONDS = 600;
private const SNAPSHOT_BINDING_TTL_SECONDS = 7200;
private const MAX_COLLECTIONS = 100;
private const CONFIRMATION_PHRASES = [
'da' => 'Bekræft',
'en' => 'Confirm',
'sv' => 'Bekräfta',
'no' => 'Bekreft',
'de' => 'Bestätigen',
];
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,
]);
return $preview;
}
public function apply(
string $previewId,
string $action,
array $invoiceCollectionIds,
array $options,
string $confirmationText,
int $actorUserId,
string $locale = 'da',
?int $customerNumber = null,
?string $snapshotRevision = null
): array {
global $db;
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$cached = $this->getCachedPreview($previewId);
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
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 invoice_collection_bulk_action_validation('Confirmation text does not match.');
}
$lockedCollectionIds = [
...$invoiceCollectionIds,
(int)($options['target_invoice_collection_id'] ?? 0),
];
$paymentMutationLock = order_payment_lock::tryAcquireInvoiceCollections(
$lockedCollectionIds
);
if ($paymentMutationLock === null) {
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(
'The invoice collections changed after preview. Refresh the preview before applying this action.'
);
}
$freshPreview['content_digest'] = $freshDigest;
if (!empty($freshPreview['blockers'])) {
throw new invoice_collection_bulk_action_validation((string)($freshPreview['blockers'][0]['message']
?? 'Action cannot be applied while blockers are present.'));
}
// Construct before the transaction because the queue bootstrap may run DDL on first use.
$economicQueue = $action === self::ACTION_QUEUE_ECONOMIC
? new economic_transfer_queue()
: null;
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, $actorUserId),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(
$invoiceCollectionIds,
$options,
$actorUserId,
$economicQueue
),
default => throw new Exception('Unsupported action'),
};
(new logs_o())->add(
'orderInvoices',
'global',
1,
$actorUserId,
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
);
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
$this->deleteCachedPreview($previewId);
return [
...$freshPreview,
'preview' => false,
'result' => $result,
];
}
/**
* 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::SNAPSHOT_BINDING_TTL_SECONDS
);
return $binding;
}
/**
* @param int[] $selectedInvoiceCollectionIds
* @return array<string,mixed>
*/
private function validateSnapshotBinding(
string $snapshotRevision,
int $actorUserId,
int $customerNumber,
array $selectedInvoiceCollectionIds,
bool $requireFresh
): array {
$snapshotRevision = strtolower(trim($snapshotRevision));
if ($actorUserId < 1 || $customerNumber < 1 || !preg_match('/^[a-f0-9]{64}$/', $snapshotRevision)) {
throw new invoice_collection_bulk_action_validation('Invalid invoice-period snapshot binding.');
}
$raw = (new redis())->get($this->snapshotCacheKey($actorUserId, $snapshotRevision));
$binding = $raw ? json_decode((string)$raw, true) : null;
if (!is_array($binding)) {
throw new invoice_collection_bulk_action_conflict('Invoice-period snapshot is missing or expired. Refresh the customer tree.');
}
if ((int)($binding['actor_user_id'] ?? 0) !== $actorUserId
|| (int)($binding['customer_number'] ?? 0) !== $customerNumber
|| !hash_equals((string)($binding['snapshot_revision'] ?? ''), $snapshotRevision)) {
throw new invoice_collection_bulk_action_conflict('Invoice-period snapshot no longer matches the current user or customer.');
}
$snapshotCollectionIds = $this->normalizeSnapshotCollectionIds(
is_array($binding['invoice_collection_ids'] ?? null) ? $binding['invoice_collection_ids'] : []
);
foreach ($selectedInvoiceCollectionIds as $invoiceCollectionId) {
if (!in_array((int)$invoiceCollectionId, $snapshotCollectionIds, true)) {
throw new invoice_collection_bulk_action_conflict('A selected invoice collection is not part of this customer snapshot.');
}
}
if ($requireFresh) {
$freshRevision = $this->calculateSnapshotRevision(
$customerNumber,
(string)($binding['date_from'] ?? ''),
(string)($binding['date_to'] ?? ''),
$snapshotCollectionIds
);
if (!hash_equals($snapshotRevision, $freshRevision)) {
throw new invoice_collection_bulk_action_conflict('Invoice-period data changed. Refresh the customer tree before continuing.');
}
}
return $binding;
}
/**
* @param int[] $invoiceCollectionIds
*/
private function calculateSnapshotRevision(
int $customerNumber,
string $dateFrom,
string $dateTo,
array $invoiceCollectionIds
): string {
global $db;
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
throw new invoice_collection_bulk_action_validation('Invalid invoice-period snapshot date range.');
}
$this->assertCollectionsBelongToCustomer($invoiceCollectionIds, $customerNumber);
$safeDateFrom = $db->escape_string($dateFrom);
$safeDateTo = $db->escape_string($dateTo);
$periodResult = $db->query(
"SELECT
o.id,
o.invoice_collection_id,
o.customer_id,
o.reference,
o.notes,
o.department_id,
o.reg_1,
o.reg_2,
o.reg_3,
o.created_at,
o.completed_at,
o.include_in_invoice,
o.po,
o.safety_seal,
o.booking_id,
o.wash_id,
oi.id AS order_item_id,
oi.product_id,
oi.reference AS order_item_reference,
oi.notes AS order_item_notes,
oi.price,
oi.quantity,
oi.related_item_id,
oi.include_in_invoice AS order_item_include_in_invoice
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
WHERE o.customer_id = {$customerNumber}
AND o.created_at BETWEEN '{$safeDateFrom}' AND '{$safeDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.id ASC, oi.id ASC"
);
if (!$periodResult) {
throw new Exception('Failed to load invoice-period data for snapshot validation.');
}
$periodRows = $periodResult->fetch_all(MYSQLI_ASSOC);
$collections = [];
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$collection->exists() || (int)$collection->customer_number->value() !== $customerNumber) {
throw new invoice_collection_bulk_action_conflict('An invoice collection moved or no longer belongs to the selected customer.');
}
$collections[] = $this->collectionSummary($collection);
}
$encoded = json_encode([
'customer_number' => $customerNumber,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'collections' => $collections,
'period_rows' => $periodRows,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new Exception('Failed to serialize invoice-period snapshot revision.');
}
return hash('sha256', $encoded);
}
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
{
$collections = $this->loadCollections($invoiceCollectionIds);
$base = [
'action' => $action,
'preview' => true,
'confirmation_phrase' => $this->confirmationPhrase($locale),
'invoice_collection_ids' => $invoiceCollectionIds,
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
'warnings' => [],
'blockers' => [],
];
return match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
default => throw new Exception('Unsupported action'),
};
}
private function previewCleanCustomerRules(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
// 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'],
(int)$row['product_id'],
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
);
if ($violation === null) {
continue;
}
$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)$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,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
/**
* Expand directly violating order items to their complete descendant closure.
* A visited set makes malformed/cyclic legacy relations safe and deterministic.
*
* @param array<int,array<string,mixed>> $rows
* @param array<int,string> $violationsByItemId
* @return array<int,array{row:array<string,mixed>,rule:string}>
*/
private function expandCleanupRows(array $rows, array $violationsByItemId): array
{
$rowsById = [];
$childrenByOrderAndParentId = [];
foreach ($rows as $row) {
$itemId = (int)($row['order_item_id'] ?? 0);
if ($itemId < 1) {
continue;
}
$rowsById[$itemId] = $row;
$parentId = (int)($row['related_item_id'] ?? 0);
if ($parentId > 0) {
$edgeKey = (int)($row['order_id'] ?? 0) . ':' . $parentId;
$childrenByOrderAndParentId[$edgeKey] = $childrenByOrderAndParentId[$edgeKey] ?? [];
$childrenByOrderAndParentId[$edgeKey][] = $itemId;
}
}
$directIds = array_values(array_unique(array_filter(
array_map('intval', array_keys($violationsByItemId)),
static fn(int $itemId): bool => $itemId > 0 && isset($rowsById[$itemId])
)));
sort($directIds, SORT_NUMERIC);
$queue = $directIds;
$visited = [];
while ($queue !== []) {
$itemId = array_shift($queue);
if (isset($visited[$itemId])) {
continue;
}
$visited[$itemId] = true;
$edgeKey = (int)($rowsById[$itemId]['order_id'] ?? 0) . ':' . $itemId;
foreach ($childrenByOrderAndParentId[$edgeKey] ?? [] as $childId) {
if (!isset($visited[$childId])) {
$queue[] = $childId;
}
}
}
$itemIds = array_map('intval', array_keys($visited));
sort($itemIds, SORT_NUMERIC);
return array_values(array_map(static function (int $itemId) use ($rowsById, $violationsByItemId): array {
return [
'row' => $rowsById[$itemId],
'rule' => $violationsByItemId[$itemId] ?? 'related_to_removed_item',
];
}, $itemIds));
}
private function previewMerge(array $preview, array $collections, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$blockers = [];
if (count($collections) < 2) {
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
}
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
}
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
if (count($customerNumbers) !== 1) {
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
}
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
}
$ordersToMove = [];
$sourceInvoiceCollectionIds = [];
foreach ($collections as $collection) {
if ((int)$collection->id === $targetId) {
continue;
}
$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,
'target_invoice_collection_id' => $targetId,
];
}
}
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,
];
}
private function previewSplitByMonth(array $preview, array $collections): array
{
$items = [];
$changed = [];
$skipped = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
try {
$item = [
'invoice_collection_id' => (int)$collection->id,
...$collection->previewSplitByOrderMonth(),
];
if (($item['status'] ?? '') === 'changed') {
$changed[] = $item;
} else {
$skipped[] = $item;
}
$items[] = $item;
} catch (\Throwable $e) {
$item = [
'status' => 'skipped',
'invoice_collection_id' => (int)$collection->id,
'reason' => 'not_splittable',
'message' => $e->getMessage(),
];
$skipped[] = $item;
$items[] = $item;
}
}
return [
...$preview,
'items' => $items,
'changed' => $changed,
'skipped' => $skipped,
'summary' => [
'collections' => count($collections),
'changed_count' => count($changed),
'skipped_count' => count($skipped),
],
'blockers' => $blockers,
];
}
private function previewResetHiddenPrices(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
if ((int)$row['include_in_invoice'] !== 0) {
continue;
}
$order = (new orders_o())->select((int)$row['order_id']);
$product = (new products_o())->select((int)$row['product_id']);
if (!$order->exists() || !$product->exists()) {
continue;
}
$newPrice = (int)$order->getCustomerProductPrice($product);
if ((int)$row['price'] === $newPrice) {
continue;
}
$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'],
'current_price' => (int)$row['price'],
'new_price' => $newPrice,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewQueueEconomic(array $preview, array $collections): array
{
$blockers = [];
foreach ($collections as $collection) {
try {
self::assertCollectionCanQueueEconomic($collection);
} catch (\Throwable $e) {
$blockers[] = [
'code' => $e->getMessage() === economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE
? 'draft_customer_export_blocked'
: 'collection_export_ineligible',
'invoice_collection_id' => (int)$collection->id,
'message' => $e->getMessage(),
];
}
}
return [
...$preview,
'summary' => [
'collections' => count($collections),
'changed_count' => count($collections),
],
'blockers' => $blockers,
];
}
private function applyCleanCustomerRules(array $preview): array
{
global $db;
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
if ($itemIds === []) {
return ['changed_count' => 0, 'order_item_ids' => []];
}
$ids = implode(',', array_map('intval', $itemIds));
$now = date('Y-m-d H:i:s');
$safeNow = $db->escape_string($now);
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
$this->touchOrdersForItems($itemIds);
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
}
private function applyMerge(array $preview, array $options, int $actorUserId): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$moved = [];
foreach ($preview['orders'] ?? [] as $row) {
$order = (new orders_o())->select((int)$row['order_id']);
if (!$order->exists()) {
continue;
}
$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,
'superseded_invoice_collection_ids' => $superseded,
'target_invoice_collection_id' => $targetId,
];
}
private function applySplitByMonth(array $preview): array
{
$changed = [];
$skipped = [];
foreach ($preview['items'] ?? [] as $item) {
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
$skipped[] = $item;
continue;
}
try {
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
} catch (\Throwable $e) {
$skipped[] = [
'invoice_collection_id' => $invoiceCollectionId,
'status' => 'skipped',
'message' => $e->getMessage(),
];
}
}
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
}
private function applyResetHiddenPrices(array $preview): array
{
$changed = [];
foreach ($preview['order_items'] ?? [] as $item) {
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
if (!$orderItem->exists()) {
continue;
}
$orderItem->price->set((int)$item['new_price']);
$orderItem->objectChanged();
$changed[] = (int)$orderItem->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
}
private function applyQueueEconomic(
array $invoiceCollectionIds,
array $options,
int $actorUserId,
?economic_transfer_queue $queue
): array
{
if ($queue === null) {
throw new Exception('E-conomic transfer queue is unavailable.');
}
$jobs = [];
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$jobs[] = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => (int)$invoiceCollectionId,
'send_as_is' => (bool)($options['send_as_is'] ?? false),
'requested_by' => $actorUserId,
],
$actorUserId
);
}
return [
'queued_invoice_collection_ids' => array_values(array_map('intval', $invoiceCollectionIds)),
'queue_job_ids' => array_values(array_map(static fn(array $job): int => (int)($job['id'] ?? 0), $jobs)),
'jobs' => $jobs,
'changed_count' => count($jobs),
];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array
{
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.'];
}
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;
}
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
{
global $db;
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
$sql = "
SELECT
oi.id AS order_item_id,
oi.order_id,
oi.product_id,
oi.related_item_id,
oi.include_in_invoice,
oi.price,
oi.quantity,
p.name AS product_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
{$hiddenCondition}
ORDER BY o.id ASC, oi.id ASC
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
/** @return array<int,array{id:int,customer_id:int}> */
private function allCollectionOrderRows(int $invoiceCollectionId): array
{
global $db;
$result = $db->query(
"SELECT id, customer_id
FROM orders
WHERE invoice_collection_id = {$invoiceCollectionId}
AND deleted_at IS NULL
ORDER BY id ASC"
);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
/** @param int[] $orderIds */
private function retargetActiveFlagsForMovedOrders(array $orderIds, int $targetInvoiceCollectionId, int $actorUserId): void
{
global $db;
$orderIds = array_values(array_unique(array_filter(
array_map('intval', $orderIds),
static fn(int $orderId): bool => $orderId > 0
)));
if ($orderIds === []) {
return;
}
$ids = implode(',', $orderIds);
$itemResult = $db->query("SELECT id FROM order_items WHERE order_id IN ({$ids})");
$itemIds = $itemResult
? array_values(array_map('intval', array_column($itemResult->fetch_all(MYSQLI_ASSOC), 'id')))
: [];
$itemCondition = $itemIds === []
? ''
: ' OR order_item_id IN (' . implode(',', $itemIds) . ')';
$db->query(
"UPDATE invoice_period_flags
SET invoice_collection_id = {$targetInvoiceCollectionId},
context_json = JSON_SET(
CASE WHEN JSON_VALID(context_json) THEN context_json ELSE JSON_OBJECT() END,
'$.invoice_collection_id', {$targetInvoiceCollectionId},
'$.retargeted_by_user_id', {$actorUserId}
)
WHERE status = 'active'
AND (order_id IN ({$ids}){$itemCondition})"
);
}
private function touchOrdersForItems(array $itemIds): void
{
global $db;
if ($itemIds === []) {
return;
}
$ids = implode(',', array_map('intval', $itemIds));
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
if (!$result) {
return;
}
while ($row = $result->fetch_assoc()) {
$order = (new orders_o())->select((int)$row['order_id']);
if ($order->exists()) {
$order->objectChanged();
}
}
}
private function touchCollections(array $invoiceCollectionIds): void
{
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
if ($collection->exists()) {
$collection->objectChanged();
}
}
}
private function loadCollections(array $invoiceCollectionIds): array
{
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
$collection->requireSelected();
return $collection;
}, $invoiceCollectionIds);
}
private function collectionSummary(collected_order_invoices_o $collection): array
{
$invoiceCollectionId = (int)$collection->id;
return [
'id' => $invoiceCollectionId,
'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(),
'closed_at' => $collection->closed_at->value(),
'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true),
'export_content_digest' => $this->collectionExportContentDigest($invoiceCollectionId),
];
}
private function collectionExportContentDigest(int $invoiceCollectionId): string
{
global $db;
$invoiceCollectionId = max(0, $invoiceCollectionId);
if ($invoiceCollectionId < 1) {
throw new Exception('Invoice collection id is required for preview content validation.');
}
$result = $db->query(
"SELECT
o.id AS order_id,
o.customer_id,
o.reference AS order_reference,
o.notes AS order_notes,
o.department_id,
o.reg_1,
o.reg_2,
o.reg_3,
o.created_at,
o.completed_at,
o.include_in_invoice AS order_include_in_invoice,
o.po,
o.safety_seal,
oi.id AS order_item_id,
oi.product_id,
oi.reference AS order_item_reference,
oi.notes AS order_item_notes,
oi.price,
oi.quantity,
oi.related_item_id,
oi.include_in_invoice AS order_item_include_in_invoice,
p.name AS product_name,
p.economic_product_id
FROM orders o
LEFT JOIN order_items oi
ON oi.order_id = o.id AND oi.deleted_at IS NULL
LEFT JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
ORDER BY o.id ASC, oi.id ASC"
);
if (!$result) {
throw new Exception('Failed to load invoice collection contents for preview validation.');
}
$rows = [];
while (($row = $result->fetch_assoc()) !== null) {
$rows[] = $row;
}
$encoded = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new Exception('Failed to serialize invoice collection contents for preview validation.');
}
return hash('sha256', $encoded);
}
public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void
{
$collection->requireSelected();
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');
}
}
private function normalizeAction(string $action): string
{
$action = trim($action);
if (!in_array($action, [
self::ACTION_CLEAN_CUSTOMER_RULES,
self::ACTION_MERGE,
self::ACTION_SPLIT_BY_MONTH,
self::ACTION_RESET_HIDDEN_PRICES,
self::ACTION_QUEUE_ECONOMIC,
], true)) {
throw new invoice_collection_bulk_action_validation('Invalid invoice collection bulk action.');
}
return $action;
}
private function normalizeInvoiceCollectionIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_array($id) || is_object($id) || !is_numeric($id)) {
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 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 invoice_collection_bulk_action_validation('invoice_collection_ids must contain at least one id.');
}
if (count($normalized) > self::MAX_COLLECTIONS) {
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'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
}
if (array_key_exists('send_as_is', $options)) {
$value = $options['send_as_is'];
if (is_bool($value)) {
$options['send_as_is'] = $value;
} elseif ((is_int($value) || is_string($value)) && in_array((string)$value, ['0', '1'], true)) {
$options['send_as_is'] = (string)$value === '1';
} elseif (is_string($value) && in_array(strtolower(trim($value)), ['true', 'false'], true)) {
$options['send_as_is'] = strtolower(trim($value)) === 'true';
} else {
throw new invoice_collection_bulk_action_validation('options.send_as_is must be a boolean.');
}
}
ksort($options);
return $options;
}
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
{
return hash('sha256', json_encode([
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function previewContentDigest(array $preview): string
{
unset(
$preview['preview_id'],
$preview['selection_hash'],
$preview['confirmation_phrase'],
$preview['content_digest']
);
return hash('sha256', json_encode($preview, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string
{
$language = strtolower(substr(trim($locale), 0, 2));
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
}
private function previewId(): string
{
return bin2hex(random_bytes(16));
}
private function previewCacheKey(string $previewId): string
{
$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
{
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
}
private function getCachedPreview(string $previewId): ?array
{
$raw = (new redis())->get($this->previewCacheKey($previewId));
if (!$raw) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function deleteCachedPreview(string $previewId): void
{
(new redis())->delete($this->previewCacheKey($previewId));
}
}