diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f61f42ce..cecf93aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # NEXT +- #12748 - Fixed order migration so `product.sales` is recalculated from persisted order line items after migrated orders are written. Cancelled orders are excluded from the sales calculation. + # 18.0.0 - Fixed Shopware 6 sales channel migration so additional sales channels, such as Social Shopping, are now migrated correctly when their sales channel types already exist in the target shop before the migration starts diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index c58d31f5d..4a3128cb4 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,5 +1,7 @@ # NEXT +- #12748 - Die Migration von Bestellungen wurde korrigiert, sodass `product.sales` nach dem Schreiben migrierter Bestellungen anhand der persistierten Bestellpositionen neu berechnet wird. Stornierte Bestellungen werden dabei nicht berücksichtigt. + # 18.0.0 - #14435 - Die Migration von Shopware 6 Sales Channel wurde korrigiert, sodass zusätzliche Sales Channel wie Social Shopping jetzt korrekt migriert werden, wenn ihre Sales-Channel-Typen vor dem Start der Migration im Zielshop vorhanden sind. diff --git a/UPGRADE.md b/UPGRADE.md index 40500f5e5..5e79a18f7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,8 @@ # NEXT +- [BREAKING] [#178](https://github.com/shopware/SwagMigrationAssistant/pull/190) - fix!: update product.sales during migration + - [BREAKING] Added required constructor parameter `\SwagMigrationAssistant\Migration\Service\ProductSalesUpdater` to `\SwagMigrationAssistant\Migration\Writer\OrderWriter` + # 18.0.0 - [BREAKING] [#178](https://github.com/shopware/SwagMigrationAssistant/pull/178) - fix!: unsupported sales channel migration diff --git a/src/DependencyInjection/shopware.php b/src/DependencyInjection/shopware.php index 3913c1437..74161f831 100644 --- a/src/DependencyInjection/shopware.php +++ b/src/DependencyInjection/shopware.php @@ -36,6 +36,7 @@ use SwagMigrationAssistant\Migration\Media\Processor\BaseMediaService; use SwagMigrationAssistant\Migration\Media\Processor\HttpDownloadServiceBase; use SwagMigrationAssistant\Migration\MigrationConfiguration; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; use SwagMigrationAssistant\Migration\Writer\AbstractWriter; use SwagMigrationAssistant\Profile\Shopware\Converter\AttributeConverter; use SwagMigrationAssistant\Profile\Shopware\Converter\CategoryAttributeConverter; @@ -997,4 +998,9 @@ service(PromotionDefinition::class), ]) ->tag('shopware.migration.writer'); + + $services->set(ProductSalesUpdater::class) + ->args([ + service(Connection::class), + ]); }; diff --git a/src/DependencyInjection/writer.php b/src/DependencyInjection/writer.php index a44d69298..dc6269d93 100644 --- a/src/DependencyInjection/writer.php +++ b/src/DependencyInjection/writer.php @@ -34,6 +34,7 @@ use Shopware\Core\System\NumberRange\NumberRangeDefinition; use Shopware\Core\System\SalesChannel\SalesChannelDefinition; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationErrorResolutionService; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; use SwagMigrationAssistant\Migration\Writer\AbstractWriter; use SwagMigrationAssistant\Migration\Writer\CategoryAttributeWriter; use SwagMigrationAssistant\Migration\Writer\CategoryWriter; @@ -162,6 +163,7 @@ service(EntityWriter::class), service(OrderDefinition::class), service(StructNormalizer::class), + service(ProductSalesUpdater::class), ]) ->tag('shopware.migration.writer'); diff --git a/src/Migration/Service/ProductSalesUpdater.php b/src/Migration/Service/ProductSalesUpdater.php new file mode 100644 index 000000000..61c5f0264 --- /dev/null +++ b/src/Migration/Service/ProductSalesUpdater.php @@ -0,0 +1,156 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Service; + +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; + +#[Package('fundamentals@after-sales')] +final readonly class ProductSalesUpdater +{ + public function __construct( + private Connection $connection, + ) { + } + + /** + * @param array $orderIds + * + * @return array + */ + public function getProductIdsForOrders(array $orderIds): array + { + $orderIds = $this->filterIds($orderIds); + + if ($orderIds === []) { + return []; + } + + $sql = <<<'SQL' + SELECT DISTINCT order_line_item.product_id + FROM order_line_item + WHERE order_line_item.order_id IN (:orderIds) + AND order_line_item.version_id = :liveVersion + AND order_line_item.order_version_id = :liveVersion + AND order_line_item.product_version_id = :liveVersion + AND order_line_item.type = :lineItemType + AND order_line_item.product_id IS NOT NULL + SQL; + + $productIds = $this->connection->fetchFirstColumn( + $sql, + [ + 'orderIds' => Uuid::fromHexToBytesList($orderIds), + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'lineItemType' => LineItem::PRODUCT_LINE_ITEM_TYPE, + ], + [ + 'orderIds' => ArrayParameterType::BINARY, + ] + ); + + return \array_map( + static fn (string $productId): string => Uuid::fromBytesToHex($productId), + $productIds + ); + } + + /** + * Recalculates the denormalized product.sales value from persisted live order line items. + * + * @param array $productIds + */ + public function updateProducts(array $productIds): void + { + $productIds = $this->filterIds($productIds); + + if ($productIds === []) { + return; + } + + $productByteIds = Uuid::fromHexToBytesList($productIds); + + $parameters = [ + 'productIds' => $productByteIds, + 'outerProductIds' => $productByteIds, + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'lineItemType' => LineItem::PRODUCT_LINE_ITEM_TYPE, + 'orderStates' => [ + OrderStates::STATE_OPEN, + OrderStates::STATE_COMPLETED, + ], + 'transactionStates' => [ + OrderTransactionStates::STATE_CANCELLED, + OrderTransactionStates::STATE_REFUNDED, + ], + ]; + + $types = [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + 'orderStates' => ArrayParameterType::STRING, + 'transactionStates' => ArrayParameterType::STRING, + ]; + + $sql = <<<'SQL' + UPDATE product + LEFT JOIN ( + SELECT order_line_item.product_id, SUM(order_line_item.quantity) AS sales + FROM order_line_item + INNER JOIN `order` + ON `order`.id = order_line_item.order_id + AND `order`.version_id = order_line_item.order_version_id + AND `order`.version_id = :liveVersion + INNER JOIN state_machine_state order_state + ON order_state.id = `order`.state_id + INNER JOIN order_transaction primary_transaction + ON primary_transaction.id = `order`.primary_order_transaction_id + AND primary_transaction.version_id = `order`.primary_order_transaction_version_id + AND primary_transaction.order_id = `order`.id + AND primary_transaction.order_version_id = `order`.version_id + INNER JOIN state_machine_state transaction_state + ON transaction_state.id = primary_transaction.state_id + WHERE order_line_item.product_id IN (:productIds) + AND order_line_item.version_id = :liveVersion + AND order_line_item.product_version_id = :liveVersion + AND order_line_item.type = :lineItemType + AND order_state.technical_name IN (:orderStates) + AND transaction_state.technical_name NOT IN (:transactionStates) + GROUP BY order_line_item.product_id + ) product_sales + ON product_sales.product_id = product.id + SET product.sales = COALESCE(product_sales.sales, 0), + product.updated_at = NOW() + WHERE product.id IN (:outerProductIds) + AND product.version_id = :liveVersion + SQL; + + RetryableQuery::retryable($this->connection, function () use ($sql, $parameters, $types): void { + $this->connection->executeStatement($sql, $parameters, $types); + }); + } + + /** + * @param array $ids + * + * @return array + */ + private function filterIds(array $ids): array + { + $filteredIds = \array_filter($ids, static fn (string $id): bool => Uuid::isValid($id)); + + return \array_unique($filteredIds); + } +} diff --git a/src/Migration/Writer/OrderWriter.php b/src/Migration/Writer/OrderWriter.php index 338cf9870..46c53fae3 100644 --- a/src/Migration/Writer/OrderWriter.php +++ b/src/Migration/Writer/OrderWriter.php @@ -7,12 +7,15 @@ namespace SwagMigrationAssistant\Migration\Writer; +use Shopware\Core\Checkout\Order\OrderDefinition; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Struct\Serializer\StructNormalizer; use SwagMigrationAssistant\Migration\DataSelection\DefaultEntities; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; #[Package('fundamentals@after-sales')] class OrderWriter extends AbstractWriter @@ -21,6 +24,7 @@ public function __construct( EntityWriterInterface $entityWriter, EntityDefinition $definition, private readonly StructNormalizer $structNormalizer, + private readonly ProductSalesUpdater $productSalesUpdater, ) { parent::__construct($entityWriter, $definition); } @@ -32,6 +36,8 @@ public function supports(): string public function writeData(array $data, Context $context): array { + // The migration payload still contains serialized transaction amounts. + // Convert them back to price structs before the DAL writes the orders. foreach ($data as &$item) { if (!isset($item['transactions']) || !\is_array($item['transactions'])) { continue; @@ -44,6 +50,91 @@ public function writeData(array $data, Context $context): array } unset($item); - return parent::writeData($data, $context); + // Read the current products for the target orders before the upsert. + // In a re-migration, a line item can switch from product A to product B, + // so product A would no longer be reachable after the write. + $orderIdsBeforeWrite = $this->extractOrderIdsFromPayload($data); + $productIdsBeforeWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIdsBeforeWrite); + + // Let the regular writer create new orders or update already migrated orders. + $result = parent::writeData($data, $context); + + // Collect all written order IDs. The payload IDs cover existing orders, + // while the DAL write result also confirms newly inserted orders. + $writtenOrderIds = $this->extractOrderIdsFromWriteResults($result); + $orderIds = $this->merge($orderIdsBeforeWrite, $writtenOrderIds); + + // Read the products again after the write to catch new or changed line items. + $productIdsAfterWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIds); + $affectedProductIds = $this->merge($productIdsBeforeWrite, $productIdsAfterWrite); + + // Recalculate the final sales value for every affected product. + // The updater derives the value from persisted line items, so repeated + // migration runs replace the sales value instead of incrementing it again. + $this->productSalesUpdater->updateProducts($affectedProductIds); + + return $result; + } + + /** + * Reads the target order IDs from the converted migration payload before the DAL upsert. + * These IDs are needed to find products that may disappear from an existing order during re-migration. + * + * @param array $payload + * + * @return list + */ + private function extractOrderIdsFromPayload(array $payload): array + { + $orderIds = []; + + foreach ($payload as $item) { + $id = $item['id'] ?? null; + + if (\is_string($id)) { + $orderIds[] = $id; + } + } + + return \array_values(\array_unique($orderIds)); + } + + /** + * @param array> $writeResults + * + * @return list + */ + private function extractOrderIdsFromWriteResults(array $writeResults): array + { + $orderIds = []; + + foreach ($writeResults[OrderDefinition::ENTITY_NAME] ?? [] as $writeResult) { + $primaryKey = $writeResult->getPrimaryKey(); + + if (\is_string($primaryKey)) { + $orderIds[] = $primaryKey; + + continue; + } + + $id = $primaryKey['id'] ?? null; + + if (\is_string($id)) { + $orderIds[] = $id; + } + } + + return \array_values(\array_unique($orderIds)); + } + + /** + * @param array $arrayOne + * @param array $arrayTwo + * + * @return list + */ + private function merge(array $arrayOne, array $arrayTwo): array + { + return \array_values(\array_unique(\array_merge($arrayOne, $arrayTwo))); } } diff --git a/tests/Migration/Controller/HistoryControllerTest.php b/tests/Migration/Controller/HistoryControllerTest.php index e08c728b7..2dc01df09 100644 --- a/tests/Migration/Controller/HistoryControllerTest.php +++ b/tests/Migration/Controller/HistoryControllerTest.php @@ -120,8 +120,7 @@ public function testGetGroupedLogsOfRunWithoutUuid(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runUuid" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runUuid')); $this->controller->getGroupedLogsOfRun($request, $this->context); } @@ -146,8 +145,7 @@ public function testDownloadLogsOfRunWithoutUuid(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runUuid" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runUuid')); $this->controller->downloadLogsOfRun($request, $this->context); } @@ -171,8 +169,7 @@ public function testGetLogGroupsWithoutRunId(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getLogGroups($request, $this->context); } @@ -181,8 +178,7 @@ public function testGetLogGroupsWithoutLevel(): void { $request = new Request(['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "level" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('level')); $this->controller->getLogGroups($request, $this->context); } @@ -375,8 +371,7 @@ public function testGetLogEntityIdsWithoutFixWithoutRunId(): void { $request = new Request([], []); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -385,8 +380,7 @@ public function testGetLogEntityIdsWithoutFixWithoutCode(): void { $request = new Request([], ['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "code" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('code')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -398,8 +392,7 @@ public function testGetLogEntityIdsWithoutFixWithoutEntityName(): void 'code' => 'TEST_CODE', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "entityName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('entityName')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -412,8 +405,7 @@ public function testGetLogEntityIdsWithoutFixWithoutFieldName(): void 'entityName' => 'product', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "fieldName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('fieldName')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -718,8 +710,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutRunI { $request = new Request([], []); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -728,8 +719,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutCode { $request = new Request([], ['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "code" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('code')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -741,8 +731,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutEnti 'code' => 'TEST_CODE', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "entityName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('entityName')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -755,8 +744,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutFiel 'entityName' => 'product', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "fieldName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('fieldName')); $this->controller->getUnresolvedLogsBatchInformation($request); } diff --git a/tests/Migration/Controller/StatusControllerTest.php b/tests/Migration/Controller/StatusControllerTest.php index bf94b36cf..7fb7455f8 100644 --- a/tests/Migration/Controller/StatusControllerTest.php +++ b/tests/Migration/Controller/StatusControllerTest.php @@ -782,8 +782,7 @@ public function testAbortMigrationWithoutRunningMigration(): void $this->context ); - $this->expectException(MigrationException::class); - $this->expectExceptionMessage('No migration run found for run with id: "unknown".'); + $this->expectExceptionObject(MigrationException::runNotFound()); $this->controller->abortMigration($this->context); } diff --git a/tests/Migration/Services/ProductSalesUpdaterTest.php b/tests/Migration/Services/ProductSalesUpdaterTest.php new file mode 100644 index 000000000..fbd9c9926 --- /dev/null +++ b/tests/Migration/Services/ProductSalesUpdaterTest.php @@ -0,0 +1,266 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\Migration\Services; + +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; +use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition; +use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; +use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; +use Shopware\Core\Checkout\Order\OrderCollection; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\BasicTestDataBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Test\Integration\Builder\Order\OrderBuilder; +use Shopware\Core\Test\Stub\Framework\IdsCollection; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; + +/** + * @phpstan-type OrderLineItemFixture array{ + * id: string, + * identifier: string, + * referencedId: string, + * productId: string, + * productVersionId: string, + * quantity: int, + * type: string, + * label: string, + * price: CalculatedPrice, + * priceDefinition: QuantityPriceDefinition, + * payload: array{productNumber: string}, + * good: bool, + * removable: bool, + * stackable: bool, + * position: int + * } + */ +#[Package('fundamentals@after-sales')] +class ProductSalesUpdaterTest extends TestCase +{ + use BasicTestDataBehaviour; + use IntegrationTestBehaviour; + + private Connection $connection; + + private ProductSalesUpdater $productSalesUpdater; + + private Context $context; + + /** + * @var EntityRepository + */ + private EntityRepository $orderRepository; + + protected function setUp(): void + { + $this->connection = static::getContainer()->get(Connection::class); + $this->orderRepository = static::getContainer()->get('order.repository'); + $this->context = Context::createDefaultContext(); + $this->productSalesUpdater = new ProductSalesUpdater($this->connection); + } + + public function testUpdatesProductSalesFromPersistedOrderLineItems(): void + { + $soldProductId = Uuid::randomHex(); + $cancelledProductId = Uuid::randomHex(); + $refundedProductId = Uuid::randomHex(); + $transactionCancelledProductId = Uuid::randomHex(); + + $this->createProduct($soldProductId); + $this->createProduct($cancelledProductId); + $this->createProduct($refundedProductId); + $this->createProduct($transactionCancelledProductId); + + $openOrderId = $this->createOrderWithProductLineItem($soldProductId, 3, OrderStates::STATE_OPEN); + $cancelledOrderId = $this->createOrderWithProductLineItem($cancelledProductId, 7, OrderStates::STATE_CANCELLED); + $refundedOrderId = $this->createOrderWithProductLineItem($refundedProductId, 5, OrderStates::STATE_COMPLETED, OrderTransactionStates::STATE_REFUNDED); + $transactionCancelledOrderId = $this->createOrderWithProductLineItem($transactionCancelledProductId, 11, OrderStates::STATE_COMPLETED, OrderTransactionStates::STATE_CANCELLED); + + static::assertEqualsCanonicalizing( + [$soldProductId, $cancelledProductId, $refundedProductId, $transactionCancelledProductId], + $this->productSalesUpdater->getProductIdsForOrders([$openOrderId, $cancelledOrderId, $refundedOrderId, $transactionCancelledOrderId, 'invalid', $openOrderId]) + ); + + $productIds = [$soldProductId, $cancelledProductId, $refundedProductId, $transactionCancelledProductId, 'invalid', $soldProductId]; + + $this->productSalesUpdater->updateProducts($productIds); + + static::assertSame(3, $this->fetchProductSales($soldProductId)); + static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + static::assertSame(0, $this->fetchProductSales($refundedProductId)); + static::assertSame(0, $this->fetchProductSales($transactionCancelledProductId)); + + // A remigration can process the same order data again. The updater recalculates + // from persisted line items and must not increment the existing sales values. + $this->productSalesUpdater->updateProducts($productIds); + + static::assertSame(3, $this->fetchProductSales($soldProductId)); + static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + static::assertSame(0, $this->fetchProductSales($refundedProductId)); + static::assertSame(0, $this->fetchProductSales($transactionCancelledProductId)); + } + + public function testUpdateSumsProductSalesAcrossMultipleOrders(): void + { + $productId = Uuid::randomHex(); + + $this->createProduct($productId); + + $firstOrderId = $this->createOrderWithProductLineItem($productId, 3, OrderStates::STATE_OPEN); + $secondOrderId = $this->createOrderWithProductLineItem($productId, 2, OrderStates::STATE_COMPLETED); + $cancelledOrderId = $this->createOrderWithProductLineItem($productId, 9, OrderStates::STATE_CANCELLED); + + $this->updateProductsForOrders([$firstOrderId, $secondOrderId, $cancelledOrderId]); + + static::assertSame(5, $this->fetchProductSales($productId)); + } + + public function testRemigrationWithChangedQuantityRecalculatesProductSales(): void + { + $productId = Uuid::randomHex(); + + $this->createProduct($productId); + + $orderId = $this->createOrderWithProductLineItem($productId, 3, OrderStates::STATE_OPEN); + + $this->updateProductsForOrders([$orderId]); + + static::assertSame(3, $this->fetchProductSales($productId)); + + // During remigration the persisted line item can be overwritten. The next + // updater run must use the new quantity, not add it to the previous sales value. + $this->updateOrderLineItemQuantity($orderId, $productId, 5); + $this->updateProductsForOrders([$orderId]); + + static::assertSame(5, $this->fetchProductSales($productId)); + } + + private function createProduct(string $productId): void + { + $productNumber = 'product-sales-' . $productId; + + $this->connection->insert('product', [ + 'id' => Uuid::fromHexToBytes($productId), + 'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'product_number' => $productNumber, + 'active' => 1, + 'stock' => 10, + 'available_stock' => 10, + 'available' => 1, + 'sales' => 99, + 'child_count' => 0, + 'created_at' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ]); + } + + private function createOrderWithProductLineItem( + string $productId, + int $quantity, + string $state, + string $transactionState = OrderTransactionStates::STATE_OPEN + ): string { + $ids = new IdsCollection(); + $orderKey = 'order-' . Uuid::randomHex(); + $transactionKey = 'transaction-' . Uuid::randomHex(); + $lineItemId = Uuid::randomHex(); + + $order = (new OrderBuilder($ids, $orderKey)) + ->add('stateId', $this->getStateMachineState(OrderStates::STATE_MACHINE, $state)) + ->add('primaryOrderTransactionId', $ids->get($transactionKey)) + ->addTransaction($transactionKey, [ + 'stateId' => $this->getStateMachineState(OrderTransactionStates::STATE_MACHINE, $transactionState), + ]) + ->add('lineItems', [$this->createProductLineItem($lineItemId, $productId, $quantity)]) + ->build(); + + $this->orderRepository->create([$order], $this->context); + + return $ids->get($orderKey); + } + + private function updateOrderLineItemQuantity(string $orderId, string $productId, int $quantity): void + { + $affectedRows = $this->connection->update('order_line_item', [ + 'quantity' => $quantity, + ], [ + 'order_id' => Uuid::fromHexToBytes($orderId), + 'order_version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'product_id' => Uuid::fromHexToBytes($productId), + 'product_version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + ]); + + static::assertSame(1, $affectedRows); + } + + /** + * @param array $orderIds + */ + private function updateProductsForOrders(array $orderIds): void + { + $this->productSalesUpdater->updateProducts( + $this->productSalesUpdater->getProductIdsForOrders($orderIds) + ); + } + + /** + * @return OrderLineItemFixture + */ + private function createProductLineItem(string $lineItemId, string $productId, int $quantity): array + { + $unitPrice = 10.0; + + return [ + 'id' => $lineItemId, + 'identifier' => $productId, + 'referencedId' => $productId, + 'productId' => $productId, + 'productVersionId' => Defaults::LIVE_VERSION, + 'quantity' => $quantity, + 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE, + 'label' => 'Product sales test product', + 'price' => new CalculatedPrice( + $unitPrice, + $unitPrice * $quantity, + new CalculatedTaxCollection(), + new TaxRuleCollection(), + $quantity + ), + 'priceDefinition' => new QuantityPriceDefinition($unitPrice, new TaxRuleCollection(), $quantity), + 'payload' => ['productNumber' => 'product-sales-' . $productId], + 'good' => true, + 'removable' => true, + 'stackable' => true, + 'position' => 1, + ]; + } + + private function fetchProductSales(string $productId): int + { + $sales = $this->connection->fetchOne(' + SELECT sales + FROM product + WHERE id = :productId + AND version_id = :liveVersion + ', [ + 'productId' => Uuid::fromHexToBytes($productId), + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + ]); + + static::assertNotFalse($sales); + + return (int) $sales; + } +} diff --git a/tests/Migration/Writer/OrderWriterTest.php b/tests/Migration/Writer/OrderWriterTest.php new file mode 100644 index 000000000..13b58a305 --- /dev/null +++ b/tests/Migration/Writer/OrderWriterTest.php @@ -0,0 +1,222 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\Migration\Writer; + +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; +use Shopware\Core\Checkout\Order\OrderDefinition; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; +use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Serializer\StructNormalizer; +use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; +use SwagMigrationAssistant\Migration\Writer\AbstractWriter; +use SwagMigrationAssistant\Migration\Writer\OrderWriter; + +#[Package('fundamentals@after-sales')] +class OrderWriterTest extends TestCase +{ + private EntityWriterInterface&MockObject $entityWriter; + + private Connection&MockObject $connection; + + private OrderWriter $orderWriter; + + protected function setUp(): void + { + $this->entityWriter = $this->createMock(EntityWriterInterface::class); + $this->connection = $this->createMock(Connection::class); + $this->orderWriter = new OrderWriter( + $this->entityWriter, + new OrderDefinition(), + $this->createMock(StructNormalizer::class), + new ProductSalesUpdater($this->connection) + ); + } + + public function testWriteDataUpdatesProductSalesForPersistedOrderLineItems(): void + { + $orderId = Uuid::randomHex(); + $productId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $orderData = [ + [ + 'id' => $orderId, + 'lineItems' => [ + [ + 'productId' => $productId, + 'quantity' => 3, + ], + ], + ], + ]; + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_INSERT + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId])), + self::productSalesParameterTypes() + ) + ->willReturn(1); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls([], [Uuid::fromHexToBytes($productId)]); + + $result = $this->orderWriter->writeData($orderData, $context); + + static::assertSame($writeResult, $result); + static::assertTrue($context->hasExtension(AbstractWriter::EXTENSION_NAME)); + } + + public function testWriteDataExcludesCancelledOrdersFromProductSales(): void + { + $orderId = Uuid::randomHex(); + $productId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_INSERT + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'order_state.technical_name IN (:orderStates)') + && \str_contains($sql, 'transaction_state.technical_name NOT IN (:transactionStates)') + && \str_contains($sql, 'INNER JOIN order_transaction primary_transaction')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId]) + && ($parameters['orderStates'] ?? null) === [OrderStates::STATE_OPEN, OrderStates::STATE_COMPLETED] + && ($parameters['transactionStates'] ?? null) === [OrderTransactionStates::STATE_CANCELLED, OrderTransactionStates::STATE_REFUNDED]), + self::productSalesParameterTypes() + ) + ->willReturn(1); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls([], [Uuid::fromHexToBytes($productId)]); + + $this->orderWriter->writeData([['id' => $orderId]], $context); + } + + public function testWriteDataUpdatesPreviousAndCurrentProductsWhenExistingLineItemChangesProduct(): void + { + $orderId = Uuid::randomHex(); + $previousProductId = Uuid::randomHex(); + $currentProductId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_UPDATE + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls( + [Uuid::fromHexToBytes($previousProductId)], + [Uuid::fromHexToBytes($currentProductId)] + ); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$previousProductId, $currentProductId])), + self::productSalesParameterTypes() + ) + ->willReturn(1); + + $this->orderWriter->writeData([ + [ + 'id' => $orderId, + 'lineItems' => [ + [ + 'productId' => $currentProductId, + ], + ], + ], + ], $context); + } + + /** + * @param array|string> $parameters + * @param list $expectedProductIds + */ + private static function containsProductIds(array $parameters, array $expectedProductIds): bool + { + $productIds = $parameters['productIds'] ?? null; + + if (!\is_array($productIds)) { + return false; + } + + $actualProductIds = \array_map( + static fn (string $productId): string => Uuid::fromBytesToHex($productId), + $productIds + ); + + \sort($actualProductIds); + \sort($expectedProductIds); + + return $actualProductIds === $expectedProductIds; + } + + /** + * @return array + */ + private static function productSalesParameterTypes(): array + { + return [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + 'orderStates' => ArrayParameterType::STRING, + 'transactionStates' => ArrayParameterType::STRING, + ]; + } +} diff --git a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php index cf5599734..1d103b0d6 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php @@ -283,8 +283,7 @@ public function testCreateFromDatabaseQuery(): void #[DataProvider('dataWithMissingKeys')] public function testCreateFromDatabaseQueryWithErrors(array $data, string $expectedMissingKey): void { - $this->expectException(MigrationException::class); - $this->expectExceptionMessage(\sprintf('Missing key "%s" to construct MigrationFix.', $expectedMissingKey)); + $this->expectExceptionObject(MigrationException::couldNotConvertFix($expectedMissingKey)); MigrationFix::fromDatabaseQuery($data); } diff --git a/tests/unit/Migration/History/HistoryServiceTest.php b/tests/unit/Migration/History/HistoryServiceTest.php index 3dfe00fe2..6753f2922 100644 --- a/tests/unit/Migration/History/HistoryServiceTest.php +++ b/tests/unit/Migration/History/HistoryServiceTest.php @@ -46,8 +46,7 @@ class HistoryServiceTest extends TestCase { public function testShouldThrowIfRunCantBeFound(): void { - static::expectException(MigrationException::class); - static::expectExceptionMessage('No SwagMigrationAssistant\Migration\Run\SwagMigrationRunEntity with UUID run-id found. Make sure the entity with the UUID exists.'); + static::expectExceptionObject(MigrationException::entityNotExists(SwagMigrationRunEntity::class, 'run-id')); $this->createHistoryService([], [])->downloadLogsOfRun( 'run-id', diff --git a/tests/unit/Migration/History/LogGroupingServiceTest.php b/tests/unit/Migration/History/LogGroupingServiceTest.php index cb1efee85..586b2f207 100644 --- a/tests/unit/Migration/History/LogGroupingServiceTest.php +++ b/tests/unit/Migration/History/LogGroupingServiceTest.php @@ -39,8 +39,7 @@ public function testGetGroupedLogsByCodeAndEntityThrowsExceptionWhenConnectionNo { $this->connection->method('fetchOne')->willReturn(false); - static::expectException(MigrationException::class); - static::expectExceptionMessage('No connection found.'); + static::expectExceptionObject(MigrationException::noConnectionFound()); $this->logGroupingService->getGroupedLogsByCodeAndEntity( Uuid::randomHex(),