Add bulk action preview and apply endpoints for collected invoices
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
<?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_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 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'): array
|
||||
{
|
||||
$action = $this->normalizeAction($action);
|
||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||
$options = $this->normalizeOptions($options);
|
||||
|
||||
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
||||
$previewId = $this->previewId();
|
||||
$preview['preview_id'] = $previewId;
|
||||
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
||||
|
||||
$this->cachePreview($previewId, [
|
||||
'action' => $action,
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'options' => $options,
|
||||
'locale' => $locale,
|
||||
'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'
|
||||
): 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 Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
|
||||
}
|
||||
|
||||
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
||||
if (trim($confirmationText) !== $expectedConfirmation) {
|
||||
throw new Exception('Confirmation text does not match.');
|
||||
}
|
||||
|
||||
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
||||
if (!empty($freshPreview['blockers'])) {
|
||||
throw new Exception('Action cannot be applied while blockers are present.');
|
||||
}
|
||||
|
||||
$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_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
|
||||
self::ACTION_QUEUE_ECONOMIC => [
|
||||
'queued_invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'changed_count' => count($invoiceCollectionIds),
|
||||
],
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
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)];
|
||||
$rows = $this->orderItemRows((int)$collection->id);
|
||||
$violatingItemIds = [];
|
||||
$includedItemIds = [];
|
||||
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;
|
||||
}
|
||||
$violatingItemIds[] = (int)$row['order_item_id'];
|
||||
$includedItemIds[] = (int)$row['order_item_id'];
|
||||
$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',
|
||||
'price' => (int)$row['price'],
|
||||
'quantity' => (int)$row['quantity'],
|
||||
'will_soft_delete' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'order_items' => $items,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'order_items' => count($items),
|
||||
'changed_count' => count($items),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
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 = [];
|
||||
foreach ($collections as $collection) {
|
||||
if ((int)$collection->id === $targetId) {
|
||||
continue;
|
||||
}
|
||||
foreach ($collection->getOrderIds() as $orderIdRow) {
|
||||
$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,
|
||||
'orders' => $ordersToMove,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'orders_to_move' => count($ordersToMove),
|
||||
'changed_count' => count($ordersToMove),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewSplitByMonth(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$changed = [];
|
||||
$skipped = [];
|
||||
foreach ($collections as $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),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
$blockers[] = [
|
||||
'code' => 'collection_booked',
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'message' => 'Invoice collection is already booked.',
|
||||
];
|
||||
}
|
||||
}
|
||||
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): 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->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, '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 contentMutationBlockers(collected_order_invoices_o $collection): array
|
||||
{
|
||||
$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.'];
|
||||
}
|
||||
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) : [];
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return [
|
||||
'id' => (int)$collection->id,
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
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 Exception('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 Exception('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.');
|
||||
}
|
||||
$normalized[$parsed] = $parsed;
|
||||
}
|
||||
$normalized = array_values($normalized);
|
||||
sort($normalized);
|
||||
if ($normalized === []) {
|
||||
throw new Exception('invoice_collection_ids must contain at least one id.');
|
||||
}
|
||||
if (count($normalized) > self::MAX_COLLECTIONS) {
|
||||
throw new Exception('Too many invoice collections selected.');
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeOptions(array $options): array
|
||||
{
|
||||
if (isset($options['target_invoice_collection_id'])) {
|
||||
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
|
||||
}
|
||||
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 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
|
||||
{
|
||||
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,12 @@ class xlvask_automation_service
|
||||
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
|
||||
}
|
||||
|
||||
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
|
||||
{
|
||||
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
|
||||
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
|
||||
}
|
||||
|
||||
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
|
||||
{
|
||||
$usageBag = self::productBagForAutomation($usageItems);
|
||||
@@ -600,18 +606,52 @@ class xlvask_automation_service
|
||||
|
||||
if ($action === self::ACTION_ATTACH) {
|
||||
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
||||
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
|
||||
}
|
||||
|
||||
if ($action === self::ACTION_CREATE) {
|
||||
return $xlvask->config->automatic_order_creation_enabled->isTrue()
|
||||
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
|
||||
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
|
||||
{
|
||||
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
|
||||
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
|
||||
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$usageItems = $context['items'] ?? [];
|
||||
$orderItems = $candidateOrder['order_items'] ?? [];
|
||||
return is_array($usageItems)
|
||||
&& is_array($orderItems)
|
||||
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
|
||||
}
|
||||
|
||||
private function candidateOrderFromSuggestion(array $suggestion): ?array
|
||||
{
|
||||
$candidateOrder = $suggestion['candidate_order'] ?? null;
|
||||
if (is_array($candidateOrder)) {
|
||||
return $candidateOrder;
|
||||
}
|
||||
|
||||
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
|
||||
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($candidateOrderJson, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
||||
{
|
||||
try {
|
||||
@@ -646,7 +686,7 @@ class xlvask_automation_service
|
||||
|
||||
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
||||
if ($automatic) {
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
|
||||
}
|
||||
|
||||
return $this->formatSuggestion($latest);
|
||||
@@ -660,6 +700,15 @@ class xlvask_automation_service
|
||||
}
|
||||
}
|
||||
|
||||
private function automaticFeedbackReason(array $suggestion, array $context): string
|
||||
{
|
||||
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
||||
return 'Automatisk accepteret: Prisoverensstemmelse.';
|
||||
}
|
||||
|
||||
return 'Automatisk accepteret.';
|
||||
}
|
||||
|
||||
private function createOrderFromContext(array $context): orders_o
|
||||
{
|
||||
$orderData = $context['proposed_order'];
|
||||
|
||||
@@ -6835,6 +6835,105 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/collected-invoices/bulk-actions/preview:
|
||||
post:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Preview selected collected invoice bulk action
|
||||
description: Preview a selected invoice collection bulk action before any mutation is allowed.
|
||||
operationId: previewCollectedInvoiceBulkAction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
- invoice_collection_ids
|
||||
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
|
||||
options:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
target_invoice_collection_id:
|
||||
type: integer
|
||||
description: Required for merge_collections and must be one of the selected invoice collection ids.
|
||||
locale:
|
||||
type: string
|
||||
example: da
|
||||
responses:
|
||||
'200':
|
||||
description: Bulk action preview created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
/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.
|
||||
operationId: applyCollectedInvoiceBulkAction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- preview_id
|
||||
- action
|
||||
- invoice_collection_ids
|
||||
- confirmation_text
|
||||
properties:
|
||||
preview_id:
|
||||
type: string
|
||||
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
|
||||
options:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
target_invoice_collection_id:
|
||||
type: integer
|
||||
confirmation_text:
|
||||
type: string
|
||||
example: Bekræft
|
||||
locale:
|
||||
type: string
|
||||
example: da
|
||||
responses:
|
||||
'200':
|
||||
description: Bulk action applied successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/collected-invoices/economic:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
|
||||
use classes\economic_v2_compare_engine;
|
||||
use classes\economic_v2_line_normalizer;
|
||||
use classes\economic_v2_revenue_statistics_service;
|
||||
use classes\invoice_collection_bulk_action_service;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
@@ -711,6 +712,105 @@ class orderInvoicesRoute
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > Bulk action preview > POST */
|
||||
$this->post('/collected-invoices/bulk-actions/preview', function () {
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['action', 'invoice_collection_ids']);
|
||||
$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);
|
||||
}
|
||||
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
|
||||
if (!is_array($options)) {
|
||||
$response->error('options must be an object', 400);
|
||||
}
|
||||
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
||||
|
||||
try {
|
||||
$preview = (new invoice_collection_bulk_action_service())->preview(
|
||||
$action,
|
||||
$invoice_collection_ids,
|
||||
$options,
|
||||
$locale
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
}
|
||||
|
||||
(new logs_o())->add(
|
||||
'orderInvoices',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'PREVIEW_COLLECTED_INVOICE_BULK_ACTION',
|
||||
'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections'
|
||||
);
|
||||
$response->success($preview);
|
||||
},
|
||||
[
|
||||
'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.',
|
||||
'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.',
|
||||
'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.',
|
||||
'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.',
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > Bulk action apply > POST */
|
||||
$this->post('/collected-invoices/bulk-actions/apply', function () {
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
|
||||
if (!is_array($options)) {
|
||||
$response->error('options must be an object', 400);
|
||||
}
|
||||
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
|
||||
|
||||
try {
|
||||
$result = (new invoice_collection_bulk_action_service())->apply(
|
||||
(string)self::getParameter('preview_id'),
|
||||
$action,
|
||||
$invoice_collection_ids,
|
||||
$options,
|
||||
(string)self::getParameter('confirmation_text'),
|
||||
(int)$user->id,
|
||||
$locale
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
}
|
||||
|
||||
$response->success($result);
|
||||
},
|
||||
[
|
||||
'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.',
|
||||
'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.',
|
||||
'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.',
|
||||
'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.',
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > E-Conomic > POST (queued) */
|
||||
$this->post('/collected-invoices/economic', function () {
|
||||
global $response;
|
||||
@@ -2521,6 +2621,20 @@ class orderInvoicesRoute
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
|
||||
}
|
||||
|
||||
private function requireCollectedInvoiceBulkActionPermission(string $action): void
|
||||
{
|
||||
$permission = match ($action) {
|
||||
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES,
|
||||
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => 'reset_collected_invoice_economic',
|
||||
invoice_collection_bulk_action_service::ACTION_MERGE => 'move_collected_invoice',
|
||||
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => 'split_collected_invoice',
|
||||
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => 'add_collected_invoice_economic',
|
||||
default => throw new Exception('Invalid invoice collection bulk action.'),
|
||||
};
|
||||
|
||||
self::requirePermission($permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $collected_order_invoice
|
||||
* @param users_o $users
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function bulk_action_order_item_deleted_at(int $orderItemId): ?string
|
||||
{
|
||||
$row = api_test_runtime()->queryOne('SELECT deleted_at FROM order_items WHERE id = ' . $orderItemId . ' LIMIT 1');
|
||||
return $row['deleted_at'] ?? null;
|
||||
}
|
||||
|
||||
function bulk_action_order_invoice_collection_id(int $orderId): int
|
||||
{
|
||||
$row = api_test_runtime()->queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
|
||||
return (int)($row['invoice_collection_id'] ?? 0);
|
||||
}
|
||||
|
||||
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $invoiceCollection['id'],
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Spot Free rinse',
|
||||
'price' => 80,
|
||||
]);
|
||||
$orderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['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', [
|
||||
'action' => 'remove_customer_rule_violations',
|
||||
'invoice_collection_ids' => [$invoiceCollection['id']],
|
||||
'locale' => 'da',
|
||||
], $session['headers']);
|
||||
|
||||
$previewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$preview = $previewResponse->data();
|
||||
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();
|
||||
|
||||
api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||
'preview_id' => $preview['preview_id'],
|
||||
'action' => 'remove_customer_rule_violations',
|
||||
'invoice_collection_ids' => [$invoiceCollection['id']],
|
||||
'confirmation_text' => 'Bekraeft',
|
||||
'locale' => 'da',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
expect(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
|
||||
|
||||
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||
'preview_id' => $preview['preview_id'],
|
||||
'action' => 'remove_customer_rule_violations',
|
||||
'invoice_collection_ids' => [$invoiceCollection['id']],
|
||||
'confirmation_text' => 'Bekræft',
|
||||
'locale' => 'da',
|
||||
], $session['headers']);
|
||||
|
||||
$applyResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$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();
|
||||
});
|
||||
|
||||
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'merge');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Merge Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$sourceCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$targetOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
]);
|
||||
$sourceOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $sourceCollection['id'],
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
|
||||
|
||||
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
|
||||
'action' => 'merge_collections',
|
||||
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
|
||||
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
|
||||
'locale' => 'en',
|
||||
], $session['headers']);
|
||||
|
||||
$previewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$preview = $previewResponse->data();
|
||||
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']);
|
||||
|
||||
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
|
||||
'preview_id' => $preview['preview_id'],
|
||||
'action' => 'merge_collections',
|
||||
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
|
||||
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
|
||||
'confirmation_text' => 'Confirm',
|
||||
'locale' => 'en',
|
||||
], $session['headers']);
|
||||
|
||||
$applyResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
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']);
|
||||
});
|
||||
@@ -24,6 +24,38 @@ it('builds stable XL Vask automation item signatures', function (): void {
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies strict price agreement matches by product, quantity, and total', function (): void {
|
||||
$usageItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39],
|
||||
['product_id' => 21, 'quantity' => 1, 'price' => 79],
|
||||
];
|
||||
$orderItems = [
|
||||
['product_id' => 21, 'quantity' => 1, 'price' => 79],
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39],
|
||||
];
|
||||
|
||||
expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects price agreement automation when product lines differ despite equal total', function (): void {
|
||||
$usageItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39],
|
||||
];
|
||||
$orderItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519],
|
||||
['product_id' => 50, 'quantity' => 1, 'price' => 39],
|
||||
];
|
||||
|
||||
expect(xlvask_automation_service::itemsTotalForAutomation($usageItems))
|
||||
->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems))
|
||||
->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
|
||||
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
|
||||
'id' => 47086,
|
||||
@@ -107,6 +139,23 @@ it('declares cached amount summary columns for XL Vask usage logs', function ():
|
||||
->toContain('cached_amount_at');
|
||||
});
|
||||
|
||||
it('keeps automatic XL Vask execution scoped to exact attachments', function (): void {
|
||||
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||
|
||||
expect($serviceContent)
|
||||
->toContain('&& $this->isExactAttachSuggestionForContext($suggestion, $context)')
|
||||
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
|
||||
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
|
||||
});
|
||||
|
||||
it('does not automatically create XL Vask orders', function (): void {
|
||||
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||
|
||||
expect($serviceContent)
|
||||
->toContain('if ($action === self::ACTION_CREATE) {')
|
||||
->toContain('return false;');
|
||||
});
|
||||
|
||||
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
|
||||
$usageItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
|
||||
|
||||
Reference in New Issue
Block a user