diff --git a/Classes/Command/OrderItemCleanupCommand.php b/Classes/Command/OrderItemCleanupCommand.php new file mode 100644 index 00000000..d58a49e5 --- /dev/null +++ b/Classes/Command/OrderItemCleanupCommand.php @@ -0,0 +1,67 @@ +setDescription('Will remove all old orders'); + $this->addArgument( + 'cutOffDate', + InputArgument::REQUIRED, + 'cutOffDate' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $cutOffDate = $input->getArgument('cutOffDate'); + + if (is_string($cutOffDate) === false || $this->isCutOffDateValid($cutOffDate) === false) { + $output->writeln('The cutOffDate argument must follow the pattern YYYY-MM-DD.'); + + return Command::FAILURE; + } + + Bootstrap::initializeBackendAuthentication(); + + $this->orderItemCleanupService->run( + new DateTimeImmutable( + $cutOffDate + ) + ); + + return Command::SUCCESS; + } + + private function isCutOffDateValid(string $cutOffDate): bool + { + $pattern = '/^\d{4}-\d{2}-\d{2}$/'; + + return preg_match($pattern, $cutOffDate) === 1; + } +} diff --git a/Classes/Service/OrderItemCleanupService.php b/Classes/Service/OrderItemCleanupService.php new file mode 100644 index 00000000..e6e95059 --- /dev/null +++ b/Classes/Service/OrderItemCleanupService.php @@ -0,0 +1,82 @@ +deleteRecordsFromTable( + 'tx_cart_domain_model_order_item', + $this->getRecordUidsToDelete( + 'tx_cart_domain_model_order_item', + $cutOffDate + ) + ); + } + + private function getRecordUidsToDelete(string $tableName, DateTimeImmutable $cutOffDate): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder + ->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + return $queryBuilder + ->select('uid') + ->from($tableName) + ->where( + $queryBuilder->expr()->lt( + 'crdate', + $queryBuilder->createNamedParameter($cutOffDate->getTimestamp(), Connection::PARAM_INT) + ), + ) + ->executeQuery() + ->fetchFirstColumn(); + } + + private function deleteRecordsFromTable(string $tableName, array $recordUids): void + { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start( + [], + [ + $tableName => array_fill_keys($recordUids, [ + 'delete' => 1, + ]), + ], + Typo3GlobalsUtility::getTypo3BackendUser() + ); + $dataHandler->process_cmdmap(); + + if ($dataHandler->errorLog !== []) { + throw new RuntimeException( + 'Could not properly delete records for table: ' . $tableName . ', got the following errors: ' . implode(', ', $dataHandler->errorLog), + 1751526777 + ); + } + } +} diff --git a/Classes/Utility/Typo3GlobalsUtility.php b/Classes/Utility/Typo3GlobalsUtility.php new file mode 100644 index 00000000..251966d3 --- /dev/null +++ b/Classes/Utility/Typo3GlobalsUtility.php @@ -0,0 +1,29 @@ + 'Cart', 'iconIdentifier' => 'ext-cart-module', - 'navigationComponent' => '@typo3/backend/page-tree/page-tree-element', + 'navigationComponent' => '@typo3/backend/tree/page-tree-element', ], 'cart_cart_orders' => [ 'parent' => 'cart_cart_main', @@ -41,6 +41,6 @@ DocumentController::class => 'download, create', ], 'iconIdentifier' => 'ext-cart-module-order', - 'navigationComponent' => '@typo3/backend/page-tree/page-tree-element', + 'navigationComponent' => '@typo3/backend/tree/page-tree-element', ], ]; diff --git a/Configuration/Services.php b/Configuration/Services.php index 5a65c274..7579a4ec 100644 --- a/Configuration/Services.php +++ b/Configuration/Services.php @@ -28,4 +28,6 @@ $services->set('Extcode\Cart\Hooks\ItemsProcFunc') ->public(); } + + $containerConfigurator->import('Services/ConsoleCommands.php'); }; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index ea2e7ae5..36b69b7c 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -6,7 +6,9 @@ services: Extcode\Cart\: resource: '../Classes/*' - exclude: '../Classes/Widgets/*' + exclude: + - '../Classes/Widgets/*' + - '../Classes/Command/*' Extcode\Cart\EventListener\Template\Components\ModifyButtonBar: tags: diff --git a/Configuration/Services/ConsoleCommands.php b/Configuration/Services/ConsoleCommands.php new file mode 100644 index 00000000..f26b752b --- /dev/null +++ b/Configuration/Services/ConsoleCommands.php @@ -0,0 +1,25 @@ +services() + ->defaults() + ->autowire() + ->autoconfigure() + ; + + $services + ->set(OrderItemCleanupCommand::class) + ->tag( + 'console.command', + [ + 'command' => 'order:cleanup', + ] + ) + ; +}; diff --git a/Documentation/Administrator/Commands/Index.rst b/Documentation/Administrator/Commands/Index.rst new file mode 100644 index 00000000..e96c4aff --- /dev/null +++ b/Documentation/Administrator/Commands/Index.rst @@ -0,0 +1,32 @@ +.. include:: ../../Includes.rst.txt + +======== +Commands +======== + +order:cleanup +============= + +The order:cleanup command requires the date before which all orders are to be +set to deleted (cutOffDate) as an argument. The format for this date only +accepts the format YYYY-MM-DD. + +.. code-block:: bash + + # Remove all orders created before 1st January 2025 + vendor/bin/typo3 order:cleanup 2025-01-01 + +The DataHandler is then used to process all orders before this date. + +.. IMPORTANT:: + Orders are only set to deleted, but not removed from the database. The + database must be cleaned up afterwards using `vendor/bin/typo3 cleanup:deletedrecords`. + +Restrictions: + No special cases such as belonging to a frontend user are taken into account. + There is no interaction with the user of the command. + No execution via the scheduler is planned. + +.. WARNING:: + No Backup? + No Mercy! diff --git a/Documentation/Administrator/Index.rst b/Documentation/Administrator/Index.rst index 40a669dc..983d2707 100644 --- a/Documentation/Administrator/Index.rst +++ b/Documentation/Administrator/Index.rst @@ -14,4 +14,5 @@ Information for the installation and configuration of the extension. Installation/Index Configuration/Index + Commands/Index OtherCartExtensions/Index diff --git a/Documentation/Changelog/11.7/Feature-730-AddOrderItemCleanupCommand.rst b/Documentation/Changelog/11.7/Feature-730-AddOrderItemCleanupCommand.rst new file mode 100644 index 00000000..886e638e --- /dev/null +++ b/Documentation/Changelog/11.7/Feature-730-AddOrderItemCleanupCommand.rst @@ -0,0 +1,40 @@ +.. include:: ../../Includes.rst.txt + +============================================== +Feature: #730 - Add order item cleanup command +============================================== + +See `Issue 730 `__ + +Description +=========== + +After a while, more and more orders will clutter up the database. Depending on +the shop, it may no longer be possible to delete them manually. + +A cleanup command should ensure that older orders created before a certain date +can be deleted automatically. + +Among other things, this serves to conserve data. As a rule, older orders are no +longer needed on the website for further processing, unless you save the orders +for a front-end user and want to keep them available in the account for a long +time. + +The first version should: + +- use the DataHandler to set orders to be deleted +- ignore special cases such as those associated with a frontend user +- have no interaction with the user of the command +- not allow execution via the scheduler + +The user of the command should create a backup of the database beforehand. +The database must be cleaned up afterwards using the +`vendor/bin/typo3 cleanup:deletedrecords`, as the command only sets orders +deleted flag to true, but does not remove them. + +Impact +====== + +No direct impact. + +.. index:: API diff --git a/Documentation/Changelog/11.7/Index.rst b/Documentation/Changelog/11.7/Index.rst new file mode 100644 index 00000000..536cbe5f --- /dev/null +++ b/Documentation/Changelog/11.7/Index.rst @@ -0,0 +1,20 @@ +.. include:: ../../Includes.rst.txt + +11.7 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Features +-------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* diff --git a/Documentation/Changelog/Index.rst b/Documentation/Changelog/Index.rst index e6827568..f4c73d9d 100644 --- a/Documentation/Changelog/Index.rst +++ b/Documentation/Changelog/Index.rst @@ -10,6 +10,7 @@ ChangeLog :maxdepth: 5 :titlesonly: + 11.7/Index 11.3/Index 11.1/Index 11.0/Index diff --git a/Documentation/guides.xml b/Documentation/guides.xml index 11cd7650..712d2e01 100644 --- a/Documentation/guides.xml +++ b/Documentation/guides.xml @@ -11,8 +11,8 @@ interlink-shortcode="extcode/cart" /> diff --git a/Tests/Functional/Command/AbstractCommandTestCase.php b/Tests/Functional/Command/AbstractCommandTestCase.php new file mode 100644 index 00000000..99953e37 --- /dev/null +++ b/Tests/Functional/Command/AbstractCommandTestCase.php @@ -0,0 +1,55 @@ +testExtensionsToLoad = [ + 'extcode/cart', + ]; + + $this->coreExtensionsToLoad = [ + 'typo3/cms-beuser', + ]; + + $this->pathsToLinkInTestInstance['typo3conf/ext/cart/Tests/Functional/Fixtures/Import/Sites/'] = 'typo3conf/sites'; + + parent::setUp(); + + $backendUser = self::createStub(BackendUserAuthentication::class); + $backendUser->method('isAdmin')->willReturn(true); + $backendUser->method('recordEditAccessInternals')->willReturn(true); + $backendUser->workspace = 0; + $backendUser->user = [ + 'uid' => 1, + 'admin' => true, + ]; + $GLOBALS['BE_USER'] = $backendUser; + $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->create('en'); + } + + protected function tearDown(): void + { + unset( + $GLOBALS['BE_USER'], + $GLOBALS['LANG'] + ); + + parent::tearDown(); + } +} diff --git a/Tests/Functional/Command/OrderItemCleanupCommandTest.php b/Tests/Functional/Command/OrderItemCleanupCommandTest.php new file mode 100644 index 00000000..d6c95fcd --- /dev/null +++ b/Tests/Functional/Command/OrderItemCleanupCommandTest.php @@ -0,0 +1,337 @@ +import([ + 'tx_cart_domain_model_order_item' => [ + [ + 'crdate' => (int)(new DateTimeImmutable('2026-01-21'))->format('U'), + ], + [ + 'crdate' => (int)(new DateTimeImmutable('2025-01-21'))->format('U'), + ], + [ + 'crdate' => (int)(new DateTimeImmutable('2025-01-02'))->format('U'), + ], + [ + 'crdate' => (int)(new DateTimeImmutable('2025-01-01'))->format('U'), + ], + ], + ]); + + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute( + [ + 'cutOffDate' => '01-01-2025', + ] + ); + + $records = $this->getAllRecords('tx_cart_domain_model_order_item'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + self::assertSame(0, $records[2]['deleted']); + self::assertSame(0, $records[3]['deleted']); + } + + #[Test] + public function deletesRecordsCreatedBeforeCutOffDate(): void + { + (new PhpDataSet())->import([ + 'tx_cart_domain_model_order_item' => [ + [ + 'crdate' => (int)(new DateTimeImmutable('2024-12-31'))->format('U'), + ], + [ + 'crdate' => (int)(new DateTimeImmutable('2024-10-12'))->format('U'), + ], + ], + ]); + + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute( + [ + 'cutOffDate' => '2025-01-01', + ] + ); + + $commandTester->assertCommandIsSuccessful(); + $records = $this->getAllRecords('tx_cart_domain_model_order_item'); + self::assertSame(1, $records[0]['deleted']); + self::assertSame(1, $records[1]['deleted']); + } + + #[Test] + public function deletesRelatedRecordsCreatedBeforeCutOffDate(): void + { + (new PhpDataSet())->import([ + 'tx_cart_domain_model_order_item' => [ + [ + 'uid' => 10, + 'products' => 2, + 'billing_address' => 1, + 'shipping_address' => 1, + 'payment' => 1, + 'shipping' => 1, + 'tax_class' => 3, + 'crdate' => (int)(new DateTimeImmutable('2024-12-31'))->format('U'), + ], + ], + 'tx_cart_domain_model_order_product' => [ + [ + 'uid' => 1, + 'item' => 10, + ], + [ + 'uid' => 2, + 'item' => 10, + ], + ], + 'tx_cart_domain_model_order_address' => [ + [ + 'uid' => 100, + 'item' => 10, + 'record_type' => '\\' . BillingAddress::class, + ], + [ + 'uid' => 101, + 'item' => 10, + 'record_type' => '\\' . ShippingAddress::class, + ], + ], + 'tx_cart_domain_model_order_payment' => [ + [ + 'uid' => 30, + 'item' => 10, + ], + ], + 'tx_cart_domain_model_order_shipping' => [ + [ + 'uid' => 30, + 'item' => 10, + ], + ], + 'tx_cart_domain_model_order_taxclass' => [ + [ + 'uid' => 30, + 'item' => 10, + ], + [ + 'uid' => 31, + 'item' => 10, + ], + [ + 'uid' => 32, + 'item' => 10, + ], + ], + ]); + + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute( + [ + 'cutOffDate' => '2025-01-01', + ] + ); + + $commandTester->assertCommandIsSuccessful(); + $records = $this->getAllRecords('tx_cart_domain_model_order_item'); + self::assertSame(1, $records[0]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_product'); + self::assertSame(1, $records[0]['deleted']); + self::assertSame(1, $records[1]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_address'); + self::assertSame(1, $records[0]['deleted']); + self::assertSame(1, $records[1]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_payment'); + self::assertSame(1, $records[0]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_shipping'); + self::assertSame(1, $records[0]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_taxclass'); + self::assertSame(1, $records[0]['deleted']); + self::assertSame(1, $records[1]['deleted']); + self::assertSame(1, $records[2]['deleted']); + } + + #[Test] + public function doesNotDeletesNotRelatedRecordsCreatedBeforeCutOffDate(): void + { + (new PhpDataSet())->import([ + 'tx_cart_domain_model_order_item' => [ + [ + 'uid' => 10, + 'products' => 2, + 'billing_address' => 1, + 'shipping_address' => 1, + 'payment' => 1, + 'shipping' => 1, + 'tax_class' => 3, + 'crdate' => (int)(new DateTimeImmutable('2024-12-31'))->format('U'), + ], + ], + 'tx_cart_domain_model_order_product' => [ + [ + 'uid' => 1, + 'item' => 9, + ], + [ + 'uid' => 2, + 'item' => 11, + ], + ], + 'tx_cart_domain_model_order_address' => [ + [ + 'item' => 9, + 'record_type' => '\\' . BillingAddress::class, + ], + [ + 'item' => 9, + 'record_type' => '\\' . ShippingAddress::class, + ], + [ + 'item' => 11, + 'record_type' => '\\' . BillingAddress::class, + ], + [ + 'item' => 11, + 'record_type' => '\\' . ShippingAddress::class, + ], + ], + 'tx_cart_domain_model_order_payment' => [ + [ + 'item' => 9, + ], + [ + 'item' => 11, + ], + ], + 'tx_cart_domain_model_order_shipping' => [ + [ + 'item' => 9, + ], + [ + 'item' => 11, + ], + ], + 'tx_cart_domain_model_order_taxclass' => [ + [ + 'item' => 9, + ], + [ + 'item' => 9, + ], + [ + 'item' => 9, + ], + [ + 'item' => 11, + ], + [ + 'item' => 11, + ], + [ + 'item' => 11, + ], + ], + ]); + + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute( + [ + 'cutOffDate' => '2025-01-01', + ] + ); + + $commandTester->assertCommandIsSuccessful(); + $records = $this->getAllRecords('tx_cart_domain_model_order_item'); + self::assertSame(1, $records[0]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_product'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_address'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + self::assertSame(0, $records[2]['deleted']); + self::assertSame(0, $records[3]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_payment'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_shipping'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + $records = $this->getAllRecords('tx_cart_domain_model_order_taxclass'); + self::assertSame(0, $records[0]['deleted']); + self::assertSame(0, $records[1]['deleted']); + self::assertSame(0, $records[2]['deleted']); + self::assertSame(0, $records[3]['deleted']); + self::assertSame(0, $records[4]['deleted']); + self::assertSame(0, $records[5]['deleted']); + } + + #[Test] + public function noCutOffDateTerminatesTheCommandWithErrorMessage(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Not enough arguments (missing: "cutOffDate").'); + + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute([]); + } + + #[DataProvider('wrongCutOffDateDataProvider')] + #[Test] + public function wrongCutOffDateFormatTerminatesTheCommandWithErrorMessage( + mixed $cutOffDate + ): void { + $commandTester = new CommandTester($this->get(OrderItemCleanupCommand::class)); + $commandTester->execute( + [ + 'cutOffDate' => $cutOffDate, + ] + ); + + self::assertSame(1, $commandTester->getStatusCode()); + self::assertSame( + 'The cutOffDate argument must follow the pattern YYYY-MM-DD.' . "\n", + $commandTester->getDisplay() + ); + } + + public static function wrongCutOffDateDataProvider() + { + yield [ '01-01-2025' ]; + yield [ '01.01.2025' ]; + yield [ '01. Jan. 2025' ]; + yield [ 'first day of month' ]; + yield [ '1767265200' ]; + yield [ 'Thu Jan 01 2026 11:00:00 GMT+0000' ]; + yield [ 1 ]; + } +} diff --git a/Tests/Functional/Fixtures/Import/Sites/default/config.yaml b/Tests/Functional/Fixtures/Import/Sites/default/config.yaml new file mode 100644 index 00000000..d4b97b18 --- /dev/null +++ b/Tests/Functional/Fixtures/Import/Sites/default/config.yaml @@ -0,0 +1,18 @@ +base: 'http://localhost/' +languages: + - + title: 'Deutsch (de-DE)' + enabled: true + base: '/de-DE/' + typo3Language: 'de' + locale: 'de_DE.utf8' + iso-639-1: 'de' + websiteTitle: '' + navigationTitle: 'Deutsch in Deutschland' + hreflang: 'de-DE' + direction: '' + flag: 'de' + languageId: 0 +rootPageId: 1 + +websiteTitle: 'tests' diff --git a/composer.json b/composer.json index f55eada1..6e30a450 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "friendsofphp/php-cs-fixer": "^3.64", "phpstan/phpstan": "^1.12", "ssch/typo3-rector": "^2.9", + "typo3/cms-beuser": "^13.4", "typo3/cms-dashboard": "^13.4", "typo3/cms-form": "^13.4", "typo3/testing-framework": "^8.2" diff --git a/ext_emconf.php b/ext_emconf.php index ae5f0661..ff8456d7 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -4,7 +4,7 @@ 'title' => 'Cart', 'description' => 'Shopping Cart(s) for TYPO3', 'category' => 'plugin', - 'version' => '11.6.0', + 'version' => '11.7.0', 'state' => 'stable', 'author' => 'Daniel Gohlke', 'author_email' => 'ext@extco.de',