From dcc81ec9bb0ba20ee414b626a3d8f408e9095a4a Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 4 Mar 2026 00:31:08 +0100 Subject: [PATCH] =?UTF-8?q?Step=2002=20=E2=80=94=20Conformist=20(Invoicing?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout Symfony Messenger comme bus d'intégration - MessengerSalesEventPublisher remplace le NaivePublisher pour dispatch via Messenger - WhenOrderConfirmed (Invoicing) : consumer Conformist qui consomme sales.v1.OrderConfirmed tel quel - Tests de compatibilité Conformist et intégration - Configuration Messenger avec transport sync --- apps/symfony/config/packages/messenger.yaml | 11 + apps/symfony/config/services.yaml | 2 +- composer.json | 3 +- composer.lock | 221 +++++++++++++++++- .../Messaging/WhenOrderConfirmed.php | 40 ++++ .../MessengerSalesEventPublisher.php | 64 +++++ .../Sales/v1/ConformistCompatibilityTest.php | 68 ++++++ tests/Integration/InvoicingConformistTest.php | 48 ++++ 8 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 apps/symfony/config/packages/messenger.yaml create mode 100644 src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php create mode 100644 src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php create mode 100644 tests/Contract/Sales/v1/ConformistCompatibilityTest.php create mode 100644 tests/Integration/InvoicingConformistTest.php diff --git a/apps/symfony/config/packages/messenger.yaml b/apps/symfony/config/packages/messenger.yaml new file mode 100644 index 0000000..f5d938c --- /dev/null +++ b/apps/symfony/config/packages/messenger.yaml @@ -0,0 +1,11 @@ +framework: + messenger: + default_bus: messenger.bus.default + buses: + messenger.bus.default: ~ + transports: + sync: 'sync://' + routing: + 'MiniShop\Contracts\Sales\V1\Event\OrderConfirmed': sync + 'MiniShop\Contracts\Sales\V1\Event\OrderPlaced': sync + 'MiniShop\Contracts\Sales\V1\Event\OrderCancelled': sync diff --git a/apps/symfony/config/services.yaml b/apps/symfony/config/services.yaml index a9c9a58..f8e1b14 100644 --- a/apps/symfony/config/services.yaml +++ b/apps/symfony/config/services.yaml @@ -25,7 +25,7 @@ services: alias: MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository MiniShop\Sales\Application\Port\SalesEventPublisher: - alias: MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher + alias: MiniShop\Sales\Infrastructure\Messaging\MessengerSalesEventPublisher # --- Invoicing --- MiniShop\Invoicing\Application\: diff --git a/composer.json b/composer.json index e6c0ca3..7160628 100644 --- a/composer.json +++ b/composer.json @@ -5,9 +5,10 @@ "license": "MIT", "require": { "php": ">=8.5", - "symfony/framework-bundle": "^7.2", "symfony/console": "^7.2", "symfony/dotenv": "^7.2", + "symfony/framework-bundle": "^7.2", + "symfony/messenger": "^7.2", "symfony/yaml": "^7.2" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 94b1433..f2cf449 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf876267ce49272867204aaa70702ed4", + "content-hash": "1ad6323891b4f76eb1b6f55ff6741b32", "packages": [ { "name": "psr/cache", @@ -55,6 +55,54 @@ }, "time": "2021-02-03T23:26:27+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -384,6 +432,83 @@ ], "time": "2025-03-13T15:25:07+00:00" }, + { + "name": "symfony/clock", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:46:48+00:00" + }, { "name": "symfony/config", "version": "v8.0.6", @@ -1508,6 +1633,100 @@ ], "time": "2026-02-26T08:36:42+00:00" }, + { + "name": "symfony/messenger", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "9da6166eb98937d903ed3685b317b426c82d3496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/9da6166eb98937d903ed3685b317b426c82d3496", + "reference": "9da6166eb98937d903ed3685b317b426c82d3496", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:50:00+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", diff --git a/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php b/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php new file mode 100644 index 0000000..6fd1ab5 --- /dev/null +++ b/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php @@ -0,0 +1,40 @@ +handler)(new IssueInvoiceForExternalOrder( + externalOrderId: $message->orderId, + customerName: 'Customer ' . $message->customerId, + customerAddress: 'N/A', + lines: array_map( + static fn (array $line): array => [ + 'description' => $line['productName'], + 'quantity' => $line['quantity'], + 'unitPriceInCents' => $line['unitPriceInCents'], + 'currency' => $line['currency'], + ], + $message->lines, + ), + )); + } +} diff --git a/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php b/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php new file mode 100644 index 0000000..df38de2 --- /dev/null +++ b/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php @@ -0,0 +1,64 @@ +messageBus->dispatch(new OrderPlacedContract( + orderId: $event->orderId->toString(), + customerId: $event->customerId->toString(), + totalInCents: $event->total->amount, + currency: $event->total->currency, + placedAt: $event->placedAt->format(\DateTimeInterface::ATOM), + lines: [], + )); + } + + public function publishOrderConfirmed(OrderConfirmed $event): void + { + $this->messageBus->dispatch(new OrderConfirmedContract( + orderId: $event->orderId->toString(), + customerId: $event->customerId->toString(), + totalInCents: $event->total->amount, + currency: $event->total->currency, + lines: array_map( + static fn (OrderLine $line): array => [ + 'productName' => $line->productName, + 'quantity' => $line->quantity, + 'unitPriceInCents' => $line->unitPrice->amount, + 'currency' => $line->unitPrice->currency, + ], + $event->lines, + ), + )); + } + + public function publishOrderCancelled(OrderCancelled $event): void + { + $this->messageBus->dispatch(new OrderCancelledContract( + orderId: $event->orderId->toString(), + )); + } +} diff --git a/tests/Contract/Sales/v1/ConformistCompatibilityTest.php b/tests/Contract/Sales/v1/ConformistCompatibilityTest.php new file mode 100644 index 0000000..79306bc --- /dev/null +++ b/tests/Contract/Sales/v1/ConformistCompatibilityTest.php @@ -0,0 +1,68 @@ + 'Widget', 'quantity' => 2, 'unitPriceInCents' => 1500, 'currency' => 'EUR'], + ], + ); + + $consumer($message); + + $invoice = $invoiceRepo->findByExternalOrderId('order-conformist-001'); + self::assertNotNull($invoice, 'Invoicing (Conformist) must be able to consume sales.v1.OrderConfirmed.'); + self::assertSame('order-conformist-001', $invoice->externalOrderId); + self::assertCount(1, $invoice->lines()); + } + + public function test_schema_backward_compatible_with_extra_fields(): void + { + // Simule un schema upstream avec un champ supplementaire (retro-compatible) + $json = '{"orderId":"order-002","customerId":"cust-002","totalInCents":5000,"currency":"EUR","lines":[{"productName":"Gadget","quantity":1,"unitPriceInCents":5000,"currency":"EUR"}],"newField":"ignored"}'; + $decoded = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + + $message = new OrderConfirmed( + orderId: $decoded['orderId'], + customerId: $decoded['customerId'], + totalInCents: $decoded['totalInCents'], + currency: $decoded['currency'], + lines: $decoded['lines'], + ); + + self::assertSame('order-002', $message->orderId); + self::assertSame(5000, $message->totalInCents); + } +} diff --git a/tests/Integration/InvoicingConformistTest.php b/tests/Integration/InvoicingConformistTest.php new file mode 100644 index 0000000..e0a6dc3 --- /dev/null +++ b/tests/Integration/InvoicingConformistTest.php @@ -0,0 +1,48 @@ + 'Widget', 'quantity' => 2, 'unitPriceInCents' => 1500, 'currency' => 'EUR'], + ['productName' => 'Gadget', 'quantity' => 1, 'unitPriceInCents' => 1500, 'currency' => 'EUR'], + ], + )); + + $invoice = $invoiceRepo->findByExternalOrderId('order-int-001'); + self::assertNotNull($invoice); + self::assertCount(2, $invoice->lines()); + self::assertSame('INV-000001', $invoice->invoiceNumber); + } +}