Step 00 — Squelette + intégration naïve
3 Bounded Contexts (Sales, Invoicing, LegacyFulfillment) avec : - Domaines complets (agrégats, VOs, événements, invariants) - Couche application (commands, queries, ports) - Infrastructure in-memory (repos, gateway fake) - Controllers HTTP Symfony - Couplage naïf synchrone entre BC via NaiveSalesEventPublisher - 20 tests unitaires et d'intégration passants
This commit is contained in:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
APP_ENV=dev
|
||||||
|
APP_DEBUG=1
|
||||||
|
APP_SECRET=minishop-boilerplate-change-me
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/vendor/
|
||||||
|
/apps/symfony/var/
|
||||||
|
/.env.local
|
||||||
|
/.phpunit.cache/
|
||||||
|
/.phpunit.result.cache
|
||||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# DDDBoilerplate - MiniShop
|
||||||
|
|
||||||
|
Boilerplate DDD minimaliste pour tutoriels Blue Book, pense pour PHP/Symfony et transposable.
|
||||||
|
|
||||||
|
Document de reference:
|
||||||
|
|
||||||
|
- [docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md](docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md)
|
||||||
|
|
||||||
|
Patterns cibles:
|
||||||
|
|
||||||
|
- OHS
|
||||||
|
- ACL
|
||||||
|
- Conformist
|
||||||
|
- Published Language
|
||||||
|
|
||||||
|
Progression recommandee via tags Git:
|
||||||
|
|
||||||
|
- `step/00-naive`
|
||||||
|
- `step/01-published-language`
|
||||||
|
- `step/02-conformist`
|
||||||
|
- `step/03-acl`
|
||||||
|
- `step/04-ohs`
|
||||||
|
- `step/05-hardening`
|
||||||
11
apps/symfony/config/packages/framework.yaml
Normal file
11
apps/symfony/config/packages/framework.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
test: ~
|
||||||
|
http_method_override: false
|
||||||
|
handle_all_throwables: true
|
||||||
|
php_errors:
|
||||||
|
log: true
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
test: true
|
||||||
5
apps/symfony/config/routes.yaml
Normal file
5
apps/symfony/config/routes.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
controllers:
|
||||||
|
resource:
|
||||||
|
path: ../../../src/
|
||||||
|
namespace: MiniShop
|
||||||
|
type: attribute
|
||||||
62
apps/symfony/config/services.yaml
Normal file
62
apps/symfony/config/services.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
# --- Shared ---
|
||||||
|
MiniShop\Shared\:
|
||||||
|
resource: '%kernel.project_dir%/src/Shared/'
|
||||||
|
|
||||||
|
MiniShop\Shared\Technical\Clock:
|
||||||
|
alias: MiniShop\Shared\Technical\SystemClock
|
||||||
|
|
||||||
|
# --- Sales ---
|
||||||
|
MiniShop\Sales\Application\:
|
||||||
|
resource: '%kernel.project_dir%/src/Sales/Application/'
|
||||||
|
|
||||||
|
MiniShop\Sales\Infrastructure\:
|
||||||
|
resource: '%kernel.project_dir%/src/Sales/Infrastructure/'
|
||||||
|
|
||||||
|
MiniShop\Sales\Interfaces\:
|
||||||
|
resource: '%kernel.project_dir%/src/Sales/Interfaces/'
|
||||||
|
tags: ['controller.service_arguments']
|
||||||
|
|
||||||
|
MiniShop\Sales\Application\Port\OrderRepository:
|
||||||
|
alias: MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository
|
||||||
|
|
||||||
|
MiniShop\Sales\Application\Port\SalesEventPublisher:
|
||||||
|
alias: MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher
|
||||||
|
|
||||||
|
# --- Invoicing ---
|
||||||
|
MiniShop\Invoicing\Application\:
|
||||||
|
resource: '%kernel.project_dir%/src/Invoicing/Application/'
|
||||||
|
|
||||||
|
MiniShop\Invoicing\Infrastructure\:
|
||||||
|
resource: '%kernel.project_dir%/src/Invoicing/Infrastructure/'
|
||||||
|
|
||||||
|
MiniShop\Invoicing\Interfaces\:
|
||||||
|
resource: '%kernel.project_dir%/src/Invoicing/Interfaces/'
|
||||||
|
tags: ['controller.service_arguments']
|
||||||
|
|
||||||
|
MiniShop\Invoicing\Application\Port\InvoiceRepository:
|
||||||
|
alias: MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository
|
||||||
|
|
||||||
|
MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator:
|
||||||
|
alias: MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator
|
||||||
|
|
||||||
|
# --- LegacyFulfillment ---
|
||||||
|
MiniShop\LegacyFulfillment\Application\:
|
||||||
|
resource: '%kernel.project_dir%/src/LegacyFulfillment/Application/'
|
||||||
|
|
||||||
|
MiniShop\LegacyFulfillment\Infrastructure\:
|
||||||
|
resource: '%kernel.project_dir%/src/LegacyFulfillment/Infrastructure/'
|
||||||
|
|
||||||
|
MiniShop\LegacyFulfillment\Interfaces\:
|
||||||
|
resource: '%kernel.project_dir%/src/LegacyFulfillment/Interfaces/'
|
||||||
|
tags: ['controller.service_arguments']
|
||||||
|
|
||||||
|
MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository:
|
||||||
|
alias: MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository
|
||||||
|
|
||||||
|
MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway:
|
||||||
|
alias: MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway
|
||||||
17
apps/symfony/public/index.php
Normal file
17
apps/symfony/public/index.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 3) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__, 3) . '/.env');
|
||||||
|
|
||||||
|
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true));
|
||||||
|
$request = Request::createFromGlobals();
|
||||||
|
$response = $kernel->handle($request);
|
||||||
|
$response->send();
|
||||||
|
$kernel->terminate($request, $response);
|
||||||
28
apps/symfony/src/Kernel.php
Normal file
28
apps/symfony/src/Kernel.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
final class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
|
||||||
|
public function getConfigDir(): string
|
||||||
|
{
|
||||||
|
return $this->getProjectDir() . '/apps/symfony/config';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCacheDir(): string
|
||||||
|
{
|
||||||
|
return $this->getProjectDir() . '/apps/symfony/var/cache/' . $this->environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLogDir(): string
|
||||||
|
{
|
||||||
|
return $this->getProjectDir() . '/apps/symfony/var/log';
|
||||||
|
}
|
||||||
|
}
|
||||||
21
bin/console
Executable file
21
bin/console
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
|
||||||
|
|
||||||
|
$input = new ArgvInput();
|
||||||
|
$env = $input->getParameterOption(['--env', '-e'], $_SERVER['APP_ENV'] ?? 'dev', true);
|
||||||
|
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? ($env !== 'prod'));
|
||||||
|
|
||||||
|
$kernel = new Kernel($env, $debug);
|
||||||
|
$application = new Application($kernel);
|
||||||
|
$application->run($input);
|
||||||
34
composer.json
Normal file
34
composer.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "roukmoute/ddd-boilerplate",
|
||||||
|
"description": "MiniShop - DDD Blue Book Boilerplate",
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.5",
|
||||||
|
"symfony/framework-bundle": "^7.2",
|
||||||
|
"symfony/console": "^7.2",
|
||||||
|
"symfony/dotenv": "^7.2",
|
||||||
|
"symfony/yaml": "^7.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.5"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"MiniShop\\Sales\\": "src/Sales/",
|
||||||
|
"MiniShop\\Invoicing\\": "src/Invoicing/",
|
||||||
|
"MiniShop\\LegacyFulfillment\\": "src/LegacyFulfillment/",
|
||||||
|
"MiniShop\\Shared\\": "src/Shared/",
|
||||||
|
"MiniShop\\Contracts\\": "contracts/",
|
||||||
|
"App\\": "apps/symfony/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"MiniShop\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true
|
||||||
|
}
|
||||||
|
}
|
||||||
4220
composer.lock
generated
Normal file
4220
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
404
docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md
Normal file
404
docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
# MiniShop DDD Blue Book Boilerplate
|
||||||
|
|
||||||
|
Document de reference pour un boilerplate DDD minimaliste, pense pour PHP/Symfony, mais transposable.
|
||||||
|
|
||||||
|
## 1. But du boilerplate
|
||||||
|
|
||||||
|
Construire une base unique, reutilisable pour des tutoriels progressifs sur les patterns du Blue Book:
|
||||||
|
|
||||||
|
- `OHS` (Open Host Service)
|
||||||
|
- `ACL` (Anti-Corruption Layer)
|
||||||
|
- `Conformist`
|
||||||
|
- `Published Language`
|
||||||
|
|
||||||
|
Contraintes voulues:
|
||||||
|
|
||||||
|
- un seul repo
|
||||||
|
- `2 a 3 Bounded Contexts` maximum
|
||||||
|
- interfaces explicites entre contextes
|
||||||
|
- integration volontairement naive au debut
|
||||||
|
- evolution pas a pas, concept par concept
|
||||||
|
|
||||||
|
## 2. Positionnement et principes
|
||||||
|
|
||||||
|
Ce boilerplate privilegie la purete tactique/strategique du DDD, pas la sophistication technique.
|
||||||
|
|
||||||
|
Principes directeurs:
|
||||||
|
|
||||||
|
1. Un `Bounded Context` = un modele autonome + un langage propre.
|
||||||
|
2. Aucune entite/VO partagee entre BC par defaut.
|
||||||
|
3. Les relations entre BC sont explicites via une `Context Map`.
|
||||||
|
4. Le code inter-BC passe par des contrats (DTO, messages, API), jamais par les modeles internes.
|
||||||
|
5. Tout pattern (Conformist, ACL, OHS, Published Language) est rendu visible dans l'arborescence et dans les tests.
|
||||||
|
6. Les choix techniques Symfony/Doctrine restent remplaçables.
|
||||||
|
|
||||||
|
## 3. Domaine MiniShop (scope minimal)
|
||||||
|
|
||||||
|
### 3.1 Bounded Contexts retenus
|
||||||
|
|
||||||
|
- `Sales` (coeur metier): prise de commande
|
||||||
|
- `Invoicing`: emission de facture
|
||||||
|
- `LegacyFulfillment`: expedition via systeme legacy volontairement "sale"
|
||||||
|
|
||||||
|
`Shipping` peut remplacer `LegacyFulfillment` dans une variante "moderne", mais la version pedagogique recommande `LegacyFulfillment` pour illustrer `ACL`.
|
||||||
|
|
||||||
|
### 3.2 Scenario fil rouge
|
||||||
|
|
||||||
|
1. Un client passe une commande dans `Sales`.
|
||||||
|
2. `Sales` notifie les autres BC qu'une commande est confirmee.
|
||||||
|
3. `Invoicing` produit une facture.
|
||||||
|
4. `LegacyFulfillment` cree une demande d'expedition dans un format legacy.
|
||||||
|
|
||||||
|
## 4. Ubiquitous Language par BC
|
||||||
|
|
||||||
|
### 4.1 Sales
|
||||||
|
|
||||||
|
- Entites/Aggregats: `Order`
|
||||||
|
- Value Objects: `OrderId`, `CustomerId`, `Money`, `OrderLine`
|
||||||
|
- Evenements de domaine: `OrderPlaced`, `OrderConfirmed`
|
||||||
|
- Invariants:
|
||||||
|
- une commande a au moins une ligne
|
||||||
|
- total > 0
|
||||||
|
- confirmation impossible si commande vide
|
||||||
|
|
||||||
|
### 4.2 Invoicing
|
||||||
|
|
||||||
|
- Entites/Aggregats: `Invoice`
|
||||||
|
- Value Objects: `InvoiceId`, `BillingParty`, `InvoiceLine`, `TaxRate`
|
||||||
|
- Evenements de domaine: `InvoiceIssued`
|
||||||
|
- Invariants:
|
||||||
|
- une facture refere une commande externe
|
||||||
|
- montant facture coherent avec ses lignes
|
||||||
|
|
||||||
|
### 4.3 LegacyFulfillment
|
||||||
|
|
||||||
|
- Entites/Aggregats: `ShipmentRequest`
|
||||||
|
- Value Objects: `LegacyOrderRef`, `ShippingAddress`, `ParcelSpec`
|
||||||
|
- Evenements de domaine: `ShipmentRequested`
|
||||||
|
- Contraintes:
|
||||||
|
- format legacy impose (champs abrégés, codifications date/status)
|
||||||
|
- mapping explicite depuis le langage publie
|
||||||
|
|
||||||
|
## 5. Context Map cible (pedagogique)
|
||||||
|
|
||||||
|
Etat initial (naif):
|
||||||
|
|
||||||
|
- `Sales` appelle directement des services applicatifs externes
|
||||||
|
- schemas ad hoc, non versionnes
|
||||||
|
- couplage fort et fragile
|
||||||
|
|
||||||
|
Etat cible (apres tutoriels):
|
||||||
|
|
||||||
|
- `Sales` expose un `OHS` pour consultation/commande (option REST ou message)
|
||||||
|
- `Sales` publie un `Published Language` versionne (`sales.v1`)
|
||||||
|
- `Invoicing` suit `Conformist` sur ce langage publie
|
||||||
|
- `LegacyFulfillment` introduit un `ACL` entre langage publie et modele legacy
|
||||||
|
|
||||||
|
Representation synthese:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sales (Upstream)
|
||||||
|
├── Published Language (sales.v1.*)
|
||||||
|
├── OHS (query/command API)
|
||||||
|
├──> Invoicing (Conformist)
|
||||||
|
└──> LegacyFulfillment (ACL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Architecture de code recommandee
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── apps/
|
||||||
|
│ └── symfony/
|
||||||
|
│ ├── config/
|
||||||
|
│ └── public/
|
||||||
|
├── src/
|
||||||
|
│ ├── Sales/
|
||||||
|
│ │ ├── Domain/
|
||||||
|
│ │ ├── Application/
|
||||||
|
│ │ ├── Infrastructure/
|
||||||
|
│ │ └── Interfaces/
|
||||||
|
│ ├── Invoicing/
|
||||||
|
│ │ ├── Domain/
|
||||||
|
│ │ ├── Application/
|
||||||
|
│ │ ├── Infrastructure/
|
||||||
|
│ │ └── Interfaces/
|
||||||
|
│ ├── LegacyFulfillment/
|
||||||
|
│ │ ├── Domain/
|
||||||
|
│ │ ├── Application/
|
||||||
|
│ │ ├── Infrastructure/
|
||||||
|
│ │ └── Interfaces/
|
||||||
|
│ └── Shared/
|
||||||
|
│ └── Technical/ # utilitaires purement techniques (Clock, UUID)
|
||||||
|
├── contracts/
|
||||||
|
│ └── sales/
|
||||||
|
│ └── v1/
|
||||||
|
│ ├── Event/
|
||||||
|
│ └── Api/
|
||||||
|
├── tests/
|
||||||
|
│ ├── Unit/
|
||||||
|
│ ├── Integration/
|
||||||
|
│ └── Contract/
|
||||||
|
└── docs/
|
||||||
|
├── MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md
|
||||||
|
└── tutorials/
|
||||||
|
```
|
||||||
|
|
||||||
|
Regle stricte:
|
||||||
|
|
||||||
|
- `src/Shared` ne contient aucun concept metier transverse.
|
||||||
|
- tout objet dans `contracts/` est stable, versionnable et "publique" entre BC.
|
||||||
|
|
||||||
|
## 7. Convention de couches par BC
|
||||||
|
|
||||||
|
- `Domain`:
|
||||||
|
- modeles metier, invariants, services de domaine, evenements de domaine
|
||||||
|
- aucune dependance framework
|
||||||
|
- `Application`:
|
||||||
|
- use cases (commands/queries), orchestration, ports (interfaces)
|
||||||
|
- `Infrastructure`:
|
||||||
|
- persistance, bus, clients HTTP, adaptateurs externes
|
||||||
|
- `Interfaces`:
|
||||||
|
- HTTP CLI Messenger, serializers, controleurs
|
||||||
|
|
||||||
|
## 8. Contrats inter-BC (Published Language)
|
||||||
|
|
||||||
|
Le contrat publie minimal recommande:
|
||||||
|
|
||||||
|
- `contracts/sales/v1/Event/OrderPlaced.php`
|
||||||
|
- `contracts/sales/v1/Event/OrderConfirmed.php`
|
||||||
|
- `contracts/sales/v1/Api/OrderView.php`
|
||||||
|
|
||||||
|
Regles:
|
||||||
|
|
||||||
|
1. Contrats immutables.
|
||||||
|
2. Version explicite (`v1`, puis `v2`).
|
||||||
|
3. Compatibilite retro geree par ajout, pas mutation destructive.
|
||||||
|
4. Tests de contrat obligatoires (`tests/Contract`).
|
||||||
|
|
||||||
|
## 9. Evolution tutorielle (roadmap)
|
||||||
|
|
||||||
|
### Episode 00 - Squelette + integration naive
|
||||||
|
|
||||||
|
- BC en place
|
||||||
|
- workflow `PlaceOrder -> Invoice -> LegacyShipment`
|
||||||
|
- appels synchrones directs inter-BC
|
||||||
|
- aucun Published Language officiel
|
||||||
|
|
||||||
|
Tag Git: `step/00-naive`
|
||||||
|
|
||||||
|
### Episode 01 - Published Language
|
||||||
|
|
||||||
|
- extraction des DTO publics dans `contracts/sales/v1`
|
||||||
|
- premiere version de messages d'integration
|
||||||
|
- tests de schema/serialisation
|
||||||
|
|
||||||
|
Tag Git: `step/01-published-language`
|
||||||
|
|
||||||
|
### Episode 02 - Conformist (Invoicing)
|
||||||
|
|
||||||
|
- `Invoicing` consomme tel quel `sales.v1`
|
||||||
|
- documentation explicite de la dependance upstream
|
||||||
|
- tests de non-regression de compatibilite
|
||||||
|
|
||||||
|
Tag Git: `step/02-conformist`
|
||||||
|
|
||||||
|
### Episode 03 - ACL (LegacyFulfillment)
|
||||||
|
|
||||||
|
- creation d'une couche `AntiCorruption` cote `LegacyFulfillment`
|
||||||
|
- mapping `sales.v1 -> legacy command model`
|
||||||
|
- isolation des bizarreries legacy dans l'ACL
|
||||||
|
|
||||||
|
Tag Git: `step/03-acl`
|
||||||
|
|
||||||
|
### Episode 04 - OHS (Sales)
|
||||||
|
|
||||||
|
- exposition d'un service hote explicite (`/api/sales/v1/...` ou endpoint message)
|
||||||
|
- documentation des points d'entree supportes
|
||||||
|
- modeles internes toujours non exposes
|
||||||
|
|
||||||
|
Tag Git: `step/04-ohs`
|
||||||
|
|
||||||
|
### Episode 05 - Hardening
|
||||||
|
|
||||||
|
- outbox/event relay (optionnel selon niveau tuto)
|
||||||
|
- idempotence des consommateurs
|
||||||
|
- monitoring minimal (logs de correlation)
|
||||||
|
|
||||||
|
Tag Git: `step/05-hardening`
|
||||||
|
|
||||||
|
## 10. Definition of Done par episode
|
||||||
|
|
||||||
|
Chaque episode doit fournir:
|
||||||
|
|
||||||
|
1. code compilable et testable
|
||||||
|
2. diagramme context map a jour (`docs/tutorials/`)
|
||||||
|
3. note "ce qui change" + "pourquoi Blue Book"
|
||||||
|
4. tag git pour reset rapide
|
||||||
|
|
||||||
|
## 11. Strategie de reset pour les tutos
|
||||||
|
|
||||||
|
Approche recommandee:
|
||||||
|
|
||||||
|
- une branche `main` stable (etat final ou etat de base choisi)
|
||||||
|
- des tags immuables `step/xx-*`
|
||||||
|
- une branche de travail recreee a chaque tuto depuis le tag
|
||||||
|
|
||||||
|
Exemple:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --tags
|
||||||
|
git switch -C play/episode-03 step/03-acl
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Regles de purete Blue Book (checklist)
|
||||||
|
|
||||||
|
- Le modele de domaine de chaque BC est independant des autres BC.
|
||||||
|
- La relation upstream/downstream est documentee.
|
||||||
|
- Les dependances de code suivent la context map, pas l'inverse.
|
||||||
|
- Les traductions de langage sont localisees (ACL), jamais diffuses partout.
|
||||||
|
- Les contrats publies sont versionnes et testes.
|
||||||
|
- Les cas d'usage ne contournent pas les invariants d'agregat.
|
||||||
|
|
||||||
|
## 13. Gabarits minimaux utiles (pseudo-code)
|
||||||
|
|
||||||
|
### 13.1 Port de publication d'evenement (Application)
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface SalesEventPublisher
|
||||||
|
{
|
||||||
|
public function publishOrderConfirmed(OrderConfirmedMessage $message): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 ACL vers legacy (Infrastructure)
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class LegacyShipmentAcl
|
||||||
|
{
|
||||||
|
public function fromSalesOrderConfirmed(OrderConfirmedMessage $message): LegacyCreateShipmentCommand
|
||||||
|
{
|
||||||
|
// Mapping explicite du langage publie vers le modele legacy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.3 Use case de Sales (Application)
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class ConfirmOrderHandler
|
||||||
|
{
|
||||||
|
public function __invoke(ConfirmOrderCommand $command): void
|
||||||
|
{
|
||||||
|
// Charge l'agregat, applique l'invariant, persiste, publie l'evenement d'integration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Decoupage recommande des tests
|
||||||
|
|
||||||
|
- `tests/Unit/Sales/Domain/*`: invariants d'agregat
|
||||||
|
- `tests/Unit/Invoicing/Domain/*`: regles de facturation
|
||||||
|
- `tests/Unit/LegacyFulfillment/Infrastructure/AntiCorruption/*`: mapping ACL
|
||||||
|
- `tests/Contract/Sales/v1/*`: stabilite du langage publie
|
||||||
|
- `tests/Integration/*`: wiring Symfony, bus, persistence
|
||||||
|
|
||||||
|
## 15. Ce que ce boilerplate n'essaie pas de faire
|
||||||
|
|
||||||
|
- couvrir tous les patterns strategiques DDD d'un coup
|
||||||
|
- fournir une architecture microservices complete
|
||||||
|
- optimiser prematurement la performance
|
||||||
|
|
||||||
|
La priorite est la clarte pedagogique, la reproductibilite et la fidelite Blue Book.
|
||||||
|
|
||||||
|
## 16. Specification minimale "codegen-ready"
|
||||||
|
|
||||||
|
### 16.1 Sales - cas d'usage
|
||||||
|
|
||||||
|
Commandes:
|
||||||
|
|
||||||
|
- `PlaceOrder`
|
||||||
|
- `ConfirmOrder`
|
||||||
|
- `CancelOrder`
|
||||||
|
|
||||||
|
Queries:
|
||||||
|
|
||||||
|
- `GetOrderById`
|
||||||
|
- `ListOrdersByCustomer`
|
||||||
|
|
||||||
|
Ports sortants:
|
||||||
|
|
||||||
|
- `OrderRepository`
|
||||||
|
- `SalesEventPublisher`
|
||||||
|
- `Clock`
|
||||||
|
- `TransactionManager`
|
||||||
|
|
||||||
|
Evenements d'integration publies:
|
||||||
|
|
||||||
|
- `sales.v1.OrderPlaced`
|
||||||
|
- `sales.v1.OrderConfirmed`
|
||||||
|
- `sales.v1.OrderCancelled`
|
||||||
|
|
||||||
|
### 16.2 Invoicing - cas d'usage
|
||||||
|
|
||||||
|
Commandes:
|
||||||
|
|
||||||
|
- `IssueInvoiceForExternalOrder`
|
||||||
|
- `MarkInvoiceAsSent`
|
||||||
|
|
||||||
|
Queries:
|
||||||
|
|
||||||
|
- `GetInvoiceByOrderRef`
|
||||||
|
|
||||||
|
Ports sortants:
|
||||||
|
|
||||||
|
- `InvoiceRepository`
|
||||||
|
- `InvoiceNumberGenerator`
|
||||||
|
|
||||||
|
Evenements d'integration consommes:
|
||||||
|
|
||||||
|
- `sales.v1.OrderConfirmed` (Conformist)
|
||||||
|
|
||||||
|
### 16.3 LegacyFulfillment - cas d'usage
|
||||||
|
|
||||||
|
Commandes:
|
||||||
|
|
||||||
|
- `RequestShipmentFromSalesOrder`
|
||||||
|
- `MarkShipmentDispatched`
|
||||||
|
|
||||||
|
Queries:
|
||||||
|
|
||||||
|
- `GetShipmentByExternalOrderRef`
|
||||||
|
|
||||||
|
Ports sortants:
|
||||||
|
|
||||||
|
- `ShipmentRequestRepository`
|
||||||
|
- `LegacyFulfillmentGateway`
|
||||||
|
|
||||||
|
Evenements d'integration consommes:
|
||||||
|
|
||||||
|
- `sales.v1.OrderConfirmed` (via ACL)
|
||||||
|
|
||||||
|
## 17. Matrice d'integration inter-BC
|
||||||
|
|
||||||
|
| Producteur | Contrat | Consommateur | Pattern | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Sales | `sales.v1.OrderPlaced` | (optionnel) Invoicing | Published Language | Peut etre ignore en phase initiale |
|
||||||
|
| Sales | `sales.v1.OrderConfirmed` | Invoicing | Conformist | Invoicing s'aligne sur le schema upstream |
|
||||||
|
| Sales | `sales.v1.OrderConfirmed` | LegacyFulfillment | ACL | Traduction vers format legacy |
|
||||||
|
| Sales | `sales.v1 API` | clients externes | OHS | API stable et versionnee |
|
||||||
|
|
||||||
|
## 18. Plan d'implementation concret (ordre recommande)
|
||||||
|
|
||||||
|
1. Implementer le domaine `Sales` (aggregate `Order`, invariants, repository in-memory).
|
||||||
|
2. Ajouter use case `PlaceOrder`, puis `ConfirmOrder`.
|
||||||
|
3. Brancher `Invoicing` en appel direct naif (episode 00).
|
||||||
|
4. Introduire `contracts/sales/v1` et migrer les echanges (episode 01).
|
||||||
|
5. Formaliser `Invoicing` en Conformist avec tests de contrat (episode 02).
|
||||||
|
6. Introduire `LegacyFulfillment` ACL et isoler tout mapping legacy (episode 03).
|
||||||
|
7. Exposer `Sales` via OHS versionne (episode 04).
|
||||||
|
8. Ajouter robustesse (idempotence, outbox optionnelle, correlation-id) (episode 05).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ce document sert de "constitution" du repo MiniShop. Toute evolution de structure doit justifier son impact sur la context map et sur les patterns pedagogiques cibles.
|
||||||
34
phpunit.xml.dist
Normal file
34
phpunit.xml.dist
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1"/>
|
||||||
|
<ini name="error_reporting" value="-1"/>
|
||||||
|
<server name="APP_ENV" value="test" force="true"/>
|
||||||
|
<server name="APP_DEBUG" value="1" force="true"/>
|
||||||
|
<server name="APP_SECRET" value="test-secret"/>
|
||||||
|
<server name="KERNEL_CLASS" value="App\Kernel"/>
|
||||||
|
</php>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Integration">
|
||||||
|
<directory>tests/Integration</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Contract">
|
||||||
|
<directory>tests/Contract</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
<directory>contracts</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Command;
|
||||||
|
|
||||||
|
final readonly class IssueInvoiceForExternalOrder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{description: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $externalOrderId,
|
||||||
|
public string $customerName,
|
||||||
|
public string $customerAddress,
|
||||||
|
public array $lines,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\BillingParty;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\TaxRate;
|
||||||
|
use MiniShop\Shared\Technical\Clock;
|
||||||
|
use MiniShop\Shared\Technical\UuidGenerator;
|
||||||
|
|
||||||
|
final readonly class IssueInvoiceForExternalOrderHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InvoiceRepository $invoiceRepository,
|
||||||
|
private InvoiceNumberGenerator $invoiceNumberGenerator,
|
||||||
|
private Clock $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(IssueInvoiceForExternalOrder $command): void
|
||||||
|
{
|
||||||
|
$lines = array_map(
|
||||||
|
static fn (array $line): InvoiceLine => new InvoiceLine(
|
||||||
|
$line['description'],
|
||||||
|
$line['quantity'],
|
||||||
|
$line['unitPriceInCents'],
|
||||||
|
$line['currency'],
|
||||||
|
TaxRate::standard(),
|
||||||
|
),
|
||||||
|
$command->lines,
|
||||||
|
);
|
||||||
|
|
||||||
|
$invoice = Invoice::issueForExternalOrder(
|
||||||
|
InvoiceId::fromString(UuidGenerator::generate()),
|
||||||
|
$this->invoiceNumberGenerator->next(),
|
||||||
|
$command->externalOrderId,
|
||||||
|
new BillingParty($command->customerName, $command->customerAddress),
|
||||||
|
$lines,
|
||||||
|
$this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invoiceRepository->save($invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Invoicing/Application/Command/MarkInvoiceAsSent.php
Normal file
12
src/Invoicing/Application/Command/MarkInvoiceAsSent.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Command;
|
||||||
|
|
||||||
|
final readonly class MarkInvoiceAsSent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $invoiceId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
|
||||||
|
final readonly class MarkInvoiceAsSentHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InvoiceRepository $invoiceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(MarkInvoiceAsSent $command): void
|
||||||
|
{
|
||||||
|
$invoice = $this->invoiceRepository->get(InvoiceId::fromString($command->invoiceId));
|
||||||
|
$invoice->markAsSent();
|
||||||
|
$this->invoiceRepository->save($invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Invoicing/Application/Port/InvoiceNumberGenerator.php
Normal file
10
src/Invoicing/Application/Port/InvoiceNumberGenerator.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Port;
|
||||||
|
|
||||||
|
interface InvoiceNumberGenerator
|
||||||
|
{
|
||||||
|
public function next(): string;
|
||||||
|
}
|
||||||
17
src/Invoicing/Application/Port/InvoiceRepository.php
Normal file
17
src/Invoicing/Application/Port/InvoiceRepository.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Port;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
|
||||||
|
interface InvoiceRepository
|
||||||
|
{
|
||||||
|
public function save(Invoice $invoice): void;
|
||||||
|
|
||||||
|
public function get(InvoiceId $id): Invoice;
|
||||||
|
|
||||||
|
public function findByExternalOrderId(string $externalOrderId): ?Invoice;
|
||||||
|
}
|
||||||
12
src/Invoicing/Application/Query/GetInvoiceByOrderRef.php
Normal file
12
src/Invoicing/Application/Query/GetInvoiceByOrderRef.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Query;
|
||||||
|
|
||||||
|
final readonly class GetInvoiceByOrderRef
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $externalOrderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Application\Query;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||||
|
|
||||||
|
final readonly class GetInvoiceByOrderRefHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InvoiceRepository $invoiceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(GetInvoiceByOrderRef $query): ?Invoice
|
||||||
|
{
|
||||||
|
return $this->invoiceRepository->findByExternalOrderId($query->externalOrderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Invoicing/Domain/Event/InvoiceIssued.php
Normal file
18
src/Invoicing/Domain/Event/InvoiceIssued.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Event;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
|
||||||
|
final readonly class InvoiceIssued
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public InvoiceId $invoiceId,
|
||||||
|
public string $invoiceNumber,
|
||||||
|
public string $externalOrderId,
|
||||||
|
public int $totalInclTaxInCents,
|
||||||
|
public \DateTimeImmutable $issuedAt,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
13
src/Invoicing/Domain/Model/BillingParty.php
Normal file
13
src/Invoicing/Domain/Model/BillingParty.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class BillingParty
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $address,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
111
src/Invoicing/Domain/Model/Invoice.php
Normal file
111
src/Invoicing/Domain/Model/Invoice.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
|
||||||
|
|
||||||
|
final class Invoice
|
||||||
|
{
|
||||||
|
private InvoiceStatus $status;
|
||||||
|
/** @var list<object> */
|
||||||
|
private array $domainEvents = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<InvoiceLine> $lines
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
public readonly InvoiceId $id,
|
||||||
|
public readonly string $invoiceNumber,
|
||||||
|
public readonly string $externalOrderId,
|
||||||
|
public readonly BillingParty $billingParty,
|
||||||
|
private readonly array $lines,
|
||||||
|
private readonly \DateTimeImmutable $issuedAt,
|
||||||
|
) {
|
||||||
|
$this->status = InvoiceStatus::Issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<InvoiceLine> $lines
|
||||||
|
*/
|
||||||
|
public static function issueForExternalOrder(
|
||||||
|
InvoiceId $id,
|
||||||
|
string $invoiceNumber,
|
||||||
|
string $externalOrderId,
|
||||||
|
BillingParty $billingParty,
|
||||||
|
array $lines,
|
||||||
|
\DateTimeImmutable $issuedAt,
|
||||||
|
): self {
|
||||||
|
if ($lines === []) {
|
||||||
|
throw new \DomainException('An invoice must have at least one line.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = new self($id, $invoiceNumber, $externalOrderId, $billingParty, $lines, $issuedAt);
|
||||||
|
|
||||||
|
$invoice->recordEvent(new InvoiceIssued(
|
||||||
|
$id,
|
||||||
|
$invoiceNumber,
|
||||||
|
$externalOrderId,
|
||||||
|
$invoice->totalInclTax(),
|
||||||
|
$issuedAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsSent(): void
|
||||||
|
{
|
||||||
|
if ($this->status === InvoiceStatus::Sent) {
|
||||||
|
throw new \DomainException('Invoice is already sent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = InvoiceStatus::Sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalExclTax(): int
|
||||||
|
{
|
||||||
|
return array_sum(array_map(
|
||||||
|
static fn (InvoiceLine $line): int => $line->lineTotalExclTax(),
|
||||||
|
$this->lines,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalInclTax(): int
|
||||||
|
{
|
||||||
|
return array_sum(array_map(
|
||||||
|
static fn (InvoiceLine $line): int => $line->lineTotalInclTax(),
|
||||||
|
$this->lines,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(): InvoiceStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<InvoiceLine> */
|
||||||
|
public function lines(): array
|
||||||
|
{
|
||||||
|
return $this->lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issuedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> */
|
||||||
|
public function releaseEvents(): array
|
||||||
|
{
|
||||||
|
$events = $this->domainEvents;
|
||||||
|
$this->domainEvents = [];
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordEvent(object $event): void
|
||||||
|
{
|
||||||
|
$this->domainEvents[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Invoicing/Domain/Model/InvoiceId.php
Normal file
25
src/Invoicing/Domain/Model/InvoiceId.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class InvoiceId
|
||||||
|
{
|
||||||
|
private function __construct(public string $value) {}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Invoicing/Domain/Model/InvoiceLine.php
Normal file
30
src/Invoicing/Domain/Model/InvoiceLine.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class InvoiceLine
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $description,
|
||||||
|
public int $quantity,
|
||||||
|
public int $unitPriceInCents,
|
||||||
|
public string $currency,
|
||||||
|
public TaxRate $taxRate,
|
||||||
|
) {
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Quantity must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineTotalExclTax(): int
|
||||||
|
{
|
||||||
|
return $this->quantity * $this->unitPriceInCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineTotalInclTax(): int
|
||||||
|
{
|
||||||
|
return (int) round($this->lineTotalExclTax() * (1 + $this->taxRate->rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Invoicing/Domain/Model/InvoiceStatus.php
Normal file
11
src/Invoicing/Domain/Model/InvoiceStatus.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
enum InvoiceStatus: string
|
||||||
|
{
|
||||||
|
case Issued = 'issued';
|
||||||
|
case Sent = 'sent';
|
||||||
|
}
|
||||||
27
src/Invoicing/Domain/Model/TaxRate.php
Normal file
27
src/Invoicing/Domain/Model/TaxRate.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class TaxRate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public float $rate,
|
||||||
|
public string $label,
|
||||||
|
) {
|
||||||
|
if ($rate < 0 || $rate > 1) {
|
||||||
|
throw new \InvalidArgumentException('Tax rate must be between 0 and 1.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function standard(): self
|
||||||
|
{
|
||||||
|
return new self(0.20, 'TVA 20%');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function zero(): self
|
||||||
|
{
|
||||||
|
return new self(0.0, 'Exonere');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Infrastructure\Persistence;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
|
||||||
|
final class InMemoryInvoiceRepository implements InvoiceRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, Invoice> */
|
||||||
|
private array $invoices = [];
|
||||||
|
|
||||||
|
public function save(Invoice $invoice): void
|
||||||
|
{
|
||||||
|
$this->invoices[$invoice->id->toString()] = $invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(InvoiceId $id): Invoice
|
||||||
|
{
|
||||||
|
return $this->invoices[$id->toString()]
|
||||||
|
?? throw new \RuntimeException(sprintf('Invoice "%s" not found.', $id->toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByExternalOrderId(string $externalOrderId): ?Invoice
|
||||||
|
{
|
||||||
|
foreach ($this->invoices as $invoice) {
|
||||||
|
if ($invoice->externalOrderId === $externalOrderId) {
|
||||||
|
return $invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Infrastructure;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
|
||||||
|
|
||||||
|
final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator
|
||||||
|
{
|
||||||
|
private int $counter = 0;
|
||||||
|
|
||||||
|
public function next(): string
|
||||||
|
{
|
||||||
|
return sprintf('INV-%06d', ++$this->counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Invoicing/Interfaces/Http/GetInvoiceController.php
Normal file
52
src/Invoicing/Interfaces/Http/GetInvoiceController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Invoicing\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRef;
|
||||||
|
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRefHandler;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/invoicing/orders/{externalOrderId}/invoice', name: 'invoicing_get_invoice', methods: ['GET'])]
|
||||||
|
final readonly class GetInvoiceController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetInvoiceByOrderRefHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $externalOrderId): JsonResponse
|
||||||
|
{
|
||||||
|
$invoice = ($this->handler)(new GetInvoiceByOrderRef($externalOrderId));
|
||||||
|
|
||||||
|
if ($invoice === null) {
|
||||||
|
return new JsonResponse(['error' => 'Invoice not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'invoiceId' => $invoice->id->toString(),
|
||||||
|
'invoiceNumber' => $invoice->invoiceNumber,
|
||||||
|
'externalOrderId' => $invoice->externalOrderId,
|
||||||
|
'billingParty' => [
|
||||||
|
'name' => $invoice->billingParty->name,
|
||||||
|
'address' => $invoice->billingParty->address,
|
||||||
|
],
|
||||||
|
'status' => $invoice->status()->value,
|
||||||
|
'totalExclTax' => $invoice->totalExclTax(),
|
||||||
|
'totalInclTax' => $invoice->totalInclTax(),
|
||||||
|
'lines' => array_map(
|
||||||
|
static fn (InvoiceLine $line): array => [
|
||||||
|
'description' => $line->description,
|
||||||
|
'quantity' => $line->quantity,
|
||||||
|
'unitPriceInCents' => $line->unitPriceInCents,
|
||||||
|
'lineTotalExclTax' => $line->lineTotalExclTax(),
|
||||||
|
'lineTotalInclTax' => $line->lineTotalInclTax(),
|
||||||
|
],
|
||||||
|
$invoice->lines(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Command;
|
||||||
|
|
||||||
|
final readonly class MarkShipmentDispatched
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $externalOrderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
|
||||||
|
final readonly class MarkShipmentDispatchedHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ShipmentRequestRepository $shipmentRequestRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(MarkShipmentDispatched $command): void
|
||||||
|
{
|
||||||
|
$ref = LegacyOrderRef::fromExternalId($command->externalOrderId);
|
||||||
|
$request = $this->shipmentRequestRepository->findByOrderRef($ref)
|
||||||
|
?? throw new \RuntimeException(sprintf('Shipment for order "%s" not found.', $command->externalOrderId));
|
||||||
|
|
||||||
|
$request->markDispatched();
|
||||||
|
$this->shipmentRequestRepository->save($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Command;
|
||||||
|
|
||||||
|
final readonly class RequestShipmentFromSalesOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $externalOrderId,
|
||||||
|
public string $recipientName,
|
||||||
|
public string $street,
|
||||||
|
public string $city,
|
||||||
|
public string $postalCode,
|
||||||
|
public string $country,
|
||||||
|
public int $totalWeightInGrams,
|
||||||
|
public string $description,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
|
||||||
|
use MiniShop\Shared\Technical\Clock;
|
||||||
|
|
||||||
|
final readonly class RequestShipmentFromSalesOrderHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ShipmentRequestRepository $shipmentRequestRepository,
|
||||||
|
private LegacyFulfillmentGateway $gateway,
|
||||||
|
private Clock $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(RequestShipmentFromSalesOrder $command): void
|
||||||
|
{
|
||||||
|
$request = ShipmentRequest::fromSalesOrder(
|
||||||
|
LegacyOrderRef::fromExternalId($command->externalOrderId),
|
||||||
|
new ShippingAddress(
|
||||||
|
$command->recipientName,
|
||||||
|
$command->street,
|
||||||
|
$command->city,
|
||||||
|
$command->postalCode,
|
||||||
|
$command->country,
|
||||||
|
),
|
||||||
|
new ParcelSpec($command->totalWeightInGrams, $command->description),
|
||||||
|
$this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->shipmentRequestRepository->save($request);
|
||||||
|
$this->gateway->sendShipmentRequest($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Port;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
|
||||||
|
interface LegacyFulfillmentGateway
|
||||||
|
{
|
||||||
|
public function sendShipmentRequest(ShipmentRequest $request): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Port;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
|
||||||
|
interface ShipmentRequestRepository
|
||||||
|
{
|
||||||
|
public function save(ShipmentRequest $request): void;
|
||||||
|
|
||||||
|
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Query;
|
||||||
|
|
||||||
|
final readonly class GetShipmentByExternalOrderRef
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $externalOrderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Application\Query;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
|
||||||
|
final readonly class GetShipmentByExternalOrderRefHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ShipmentRequestRepository $shipmentRequestRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(GetShipmentByExternalOrderRef $query): ?ShipmentRequest
|
||||||
|
{
|
||||||
|
return $this->shipmentRequestRepository->findByOrderRef(
|
||||||
|
LegacyOrderRef::fromExternalId($query->externalOrderId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/LegacyFulfillment/Domain/Event/ShipmentRequested.php
Normal file
15
src/LegacyFulfillment/Domain/Event/ShipmentRequested.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Event;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
|
||||||
|
final readonly class ShipmentRequested
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public LegacyOrderRef $orderRef,
|
||||||
|
public \DateTimeImmutable $requestedAt,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
25
src/LegacyFulfillment/Domain/Model/LegacyOrderRef.php
Normal file
25
src/LegacyFulfillment/Domain/Model/LegacyOrderRef.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class LegacyOrderRef
|
||||||
|
{
|
||||||
|
private function __construct(public string $value) {}
|
||||||
|
|
||||||
|
public static function fromExternalId(string $externalId): self
|
||||||
|
{
|
||||||
|
return new self($externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/LegacyFulfillment/Domain/Model/ParcelSpec.php
Normal file
17
src/LegacyFulfillment/Domain/Model/ParcelSpec.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class ParcelSpec
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $weightInGrams,
|
||||||
|
public string $description,
|
||||||
|
) {
|
||||||
|
if ($weightInGrams <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Weight must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/LegacyFulfillment/Domain/Model/ShipmentRequest.php
Normal file
69
src/LegacyFulfillment/Domain/Model/ShipmentRequest.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Model;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
|
||||||
|
|
||||||
|
final class ShipmentRequest
|
||||||
|
{
|
||||||
|
private ShipmentStatus $status;
|
||||||
|
/** @var list<object> */
|
||||||
|
private array $domainEvents = [];
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public readonly LegacyOrderRef $orderRef,
|
||||||
|
public readonly ShippingAddress $address,
|
||||||
|
public readonly ParcelSpec $parcel,
|
||||||
|
private readonly \DateTimeImmutable $requestedAt,
|
||||||
|
) {
|
||||||
|
$this->status = ShipmentStatus::Requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromSalesOrder(
|
||||||
|
LegacyOrderRef $orderRef,
|
||||||
|
ShippingAddress $address,
|
||||||
|
ParcelSpec $parcel,
|
||||||
|
\DateTimeImmutable $requestedAt,
|
||||||
|
): self {
|
||||||
|
$request = new self($orderRef, $address, $parcel, $requestedAt);
|
||||||
|
|
||||||
|
$request->recordEvent(new ShipmentRequested($orderRef, $requestedAt));
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markDispatched(): void
|
||||||
|
{
|
||||||
|
if ($this->status === ShipmentStatus::Dispatched) {
|
||||||
|
throw new \DomainException('Shipment already dispatched.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = ShipmentStatus::Dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(): ShipmentStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->requestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> */
|
||||||
|
public function releaseEvents(): array
|
||||||
|
{
|
||||||
|
$events = $this->domainEvents;
|
||||||
|
$this->domainEvents = [];
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordEvent(object $event): void
|
||||||
|
{
|
||||||
|
$this->domainEvents[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/LegacyFulfillment/Domain/Model/ShipmentStatus.php
Normal file
11
src/LegacyFulfillment/Domain/Model/ShipmentStatus.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Model;
|
||||||
|
|
||||||
|
enum ShipmentStatus: string
|
||||||
|
{
|
||||||
|
case Requested = 'requested';
|
||||||
|
case Dispatched = 'dispatched';
|
||||||
|
}
|
||||||
16
src/LegacyFulfillment/Domain/Model/ShippingAddress.php
Normal file
16
src/LegacyFulfillment/Domain/Model/ShippingAddress.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class ShippingAddress
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $recipientName,
|
||||||
|
public string $street,
|
||||||
|
public string $city,
|
||||||
|
public string $postalCode,
|
||||||
|
public string $country,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Infrastructure\Gateway;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
|
||||||
|
final class FakeLegacyFulfillmentGateway implements LegacyFulfillmentGateway
|
||||||
|
{
|
||||||
|
/** @var list<ShipmentRequest> */
|
||||||
|
private array $sentRequests = [];
|
||||||
|
|
||||||
|
public function sendShipmentRequest(ShipmentRequest $request): void
|
||||||
|
{
|
||||||
|
$this->sentRequests[] = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<ShipmentRequest> */
|
||||||
|
public function sentRequests(): array
|
||||||
|
{
|
||||||
|
return $this->sentRequests;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Infrastructure\Persistence;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
|
||||||
|
final class InMemoryShipmentRequestRepository implements ShipmentRequestRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, ShipmentRequest> */
|
||||||
|
private array $requests = [];
|
||||||
|
|
||||||
|
public function save(ShipmentRequest $request): void
|
||||||
|
{
|
||||||
|
$this->requests[$request->orderRef->toString()] = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest
|
||||||
|
{
|
||||||
|
return $this->requests[$orderRef->toString()] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\LegacyFulfillment\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRefHandler;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/fulfillment/orders/{externalOrderId}/shipment', name: 'fulfillment_get_shipment', methods: ['GET'])]
|
||||||
|
final readonly class GetShipmentController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetShipmentByExternalOrderRefHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $externalOrderId): JsonResponse
|
||||||
|
{
|
||||||
|
$shipment = ($this->handler)(new GetShipmentByExternalOrderRef($externalOrderId));
|
||||||
|
|
||||||
|
if ($shipment === null) {
|
||||||
|
return new JsonResponse(['error' => 'Shipment not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'orderRef' => $shipment->orderRef->toString(),
|
||||||
|
'status' => $shipment->status()->value,
|
||||||
|
'address' => [
|
||||||
|
'recipientName' => $shipment->address->recipientName,
|
||||||
|
'street' => $shipment->address->street,
|
||||||
|
'city' => $shipment->address->city,
|
||||||
|
'postalCode' => $shipment->address->postalCode,
|
||||||
|
'country' => $shipment->address->country,
|
||||||
|
],
|
||||||
|
'parcel' => [
|
||||||
|
'weightInGrams' => $shipment->parcel->weightInGrams,
|
||||||
|
'description' => $shipment->parcel->description,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Sales/Application/Command/CancelOrder.php
Normal file
12
src/Sales/Application/Command/CancelOrder.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
final readonly class CancelOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $orderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
31
src/Sales/Application/Command/CancelOrderHandler.php
Normal file
31
src/Sales/Application/Command/CancelOrderHandler.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Application\Port\SalesEventPublisher;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final readonly class CancelOrderHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
private SalesEventPublisher $publisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(CancelOrder $command): void
|
||||||
|
{
|
||||||
|
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
|
||||||
|
$order->cancel();
|
||||||
|
$this->orderRepository->save($order);
|
||||||
|
|
||||||
|
foreach ($order->releaseEvents() as $event) {
|
||||||
|
if ($event instanceof OrderCancelled) {
|
||||||
|
$this->publisher->publishOrderCancelled($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Sales/Application/Command/ConfirmOrder.php
Normal file
12
src/Sales/Application/Command/ConfirmOrder.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
final readonly class ConfirmOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $orderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
31
src/Sales/Application/Command/ConfirmOrderHandler.php
Normal file
31
src/Sales/Application/Command/ConfirmOrderHandler.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Application\Port\SalesEventPublisher;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final readonly class ConfirmOrderHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
private SalesEventPublisher $publisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ConfirmOrder $command): void
|
||||||
|
{
|
||||||
|
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
|
||||||
|
$order->confirm();
|
||||||
|
$this->orderRepository->save($order);
|
||||||
|
|
||||||
|
foreach ($order->releaseEvents() as $event) {
|
||||||
|
if ($event instanceof OrderConfirmed) {
|
||||||
|
$this->publisher->publishOrderConfirmed($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Sales/Application/Command/PlaceOrder.php
Normal file
17
src/Sales/Application/Command/PlaceOrder.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
final readonly class PlaceOrder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $orderId,
|
||||||
|
public string $customerId,
|
||||||
|
public array $lines,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
51
src/Sales/Application/Command/PlaceOrderHandler.php
Normal file
51
src/Sales/Application/Command/PlaceOrderHandler.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Command;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Application\Port\SalesEventPublisher;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Money;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||||
|
use MiniShop\Shared\Technical\Clock;
|
||||||
|
|
||||||
|
final readonly class PlaceOrderHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
private SalesEventPublisher $publisher,
|
||||||
|
private Clock $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(PlaceOrder $command): void
|
||||||
|
{
|
||||||
|
$lines = array_map(
|
||||||
|
static fn (array $line): OrderLine => new OrderLine(
|
||||||
|
$line['productName'],
|
||||||
|
$line['quantity'],
|
||||||
|
new Money($line['unitPriceInCents'], $line['currency']),
|
||||||
|
),
|
||||||
|
$command->lines,
|
||||||
|
);
|
||||||
|
|
||||||
|
$order = Order::place(
|
||||||
|
OrderId::fromString($command->orderId),
|
||||||
|
CustomerId::fromString($command->customerId),
|
||||||
|
$lines,
|
||||||
|
$this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->orderRepository->save($order);
|
||||||
|
|
||||||
|
foreach ($order->releaseEvents() as $event) {
|
||||||
|
if ($event instanceof OrderPlaced) {
|
||||||
|
$this->publisher->publishOrderPlaced($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Sales/Application/Port/OrderRepository.php
Normal file
19
src/Sales/Application/Port/OrderRepository.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Port;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
interface OrderRepository
|
||||||
|
{
|
||||||
|
public function save(Order $order): void;
|
||||||
|
|
||||||
|
public function get(OrderId $id): Order;
|
||||||
|
|
||||||
|
/** @return list<Order> */
|
||||||
|
public function findByCustomer(CustomerId $customerId): array;
|
||||||
|
}
|
||||||
18
src/Sales/Application/Port/SalesEventPublisher.php
Normal file
18
src/Sales/Application/Port/SalesEventPublisher.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Port;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||||
|
|
||||||
|
interface SalesEventPublisher
|
||||||
|
{
|
||||||
|
public function publishOrderPlaced(OrderPlaced $event): void;
|
||||||
|
|
||||||
|
public function publishOrderConfirmed(OrderConfirmed $event): void;
|
||||||
|
|
||||||
|
public function publishOrderCancelled(OrderCancelled $event): void;
|
||||||
|
}
|
||||||
12
src/Sales/Application/Query/GetOrderById.php
Normal file
12
src/Sales/Application/Query/GetOrderById.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Query;
|
||||||
|
|
||||||
|
final readonly class GetOrderById
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $orderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
21
src/Sales/Application/Query/GetOrderByIdHandler.php
Normal file
21
src/Sales/Application/Query/GetOrderByIdHandler.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Query;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final readonly class GetOrderByIdHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(GetOrderById $query): Order
|
||||||
|
{
|
||||||
|
return $this->orderRepository->get(OrderId::fromString($query->orderId));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Sales/Application/Query/ListOrdersByCustomer.php
Normal file
12
src/Sales/Application/Query/ListOrdersByCustomer.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Query;
|
||||||
|
|
||||||
|
final readonly class ListOrdersByCustomer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $customerId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
22
src/Sales/Application/Query/ListOrdersByCustomerHandler.php
Normal file
22
src/Sales/Application/Query/ListOrdersByCustomerHandler.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Application\Query;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
|
||||||
|
final readonly class ListOrdersByCustomerHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return list<Order> */
|
||||||
|
public function __invoke(ListOrdersByCustomer $query): array
|
||||||
|
{
|
||||||
|
return $this->orderRepository->findByCustomer(CustomerId::fromString($query->customerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Sales/Domain/Event/OrderCancelled.php
Normal file
14
src/Sales/Domain/Event/OrderCancelled.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Event;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final readonly class OrderCancelled
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public OrderId $orderId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
23
src/Sales/Domain/Event/OrderConfirmed.php
Normal file
23
src/Sales/Domain/Event/OrderConfirmed.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Event;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Money;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||||
|
|
||||||
|
final readonly class OrderConfirmed
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<OrderLine> $lines
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public OrderId $orderId,
|
||||||
|
public CustomerId $customerId,
|
||||||
|
public Money $total,
|
||||||
|
public array $lines,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
src/Sales/Domain/Event/OrderPlaced.php
Normal file
19
src/Sales/Domain/Event/OrderPlaced.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Event;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Money;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final readonly class OrderPlaced
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public OrderId $orderId,
|
||||||
|
public CustomerId $customerId,
|
||||||
|
public Money $total,
|
||||||
|
public \DateTimeImmutable $placedAt,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
18
src/Sales/Domain/Exception/EmptyOrderException.php
Normal file
18
src/Sales/Domain/Exception/EmptyOrderException.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Exception;
|
||||||
|
|
||||||
|
final class EmptyOrderException extends \DomainException
|
||||||
|
{
|
||||||
|
public static function noLines(): self
|
||||||
|
{
|
||||||
|
return new self('An order must have at least one line.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cannotConfirmEmpty(): self
|
||||||
|
{
|
||||||
|
return new self('Cannot confirm an order with no lines.');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Sales/Domain/Exception/InvalidOrderStateException.php
Normal file
20
src/Sales/Domain/Exception/InvalidOrderStateException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Exception;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderStatus;
|
||||||
|
|
||||||
|
final class InvalidOrderStateException extends \DomainException
|
||||||
|
{
|
||||||
|
public static function cannotConfirm(OrderStatus $current): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Cannot confirm order in "%s" state.', $current->value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cannotCancel(OrderStatus $current): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Cannot cancel order in "%s" state.', $current->value));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Sales/Domain/Model/CustomerId.php
Normal file
25
src/Sales/Domain/Model/CustomerId.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class CustomerId
|
||||||
|
{
|
||||||
|
private function __construct(public string $value) {}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Sales/Domain/Model/Money.php
Normal file
39
src/Sales/Domain/Model/Money.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class Money
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $amount,
|
||||||
|
public string $currency,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function zero(string $currency): self
|
||||||
|
{
|
||||||
|
return new self(0, $currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(self $other): self
|
||||||
|
{
|
||||||
|
if ($this->currency !== $other->currency) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('Cannot add %s to %s.', $other->currency, $this->currency),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($this->amount + $other->amount, $this->currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPositive(): bool
|
||||||
|
{
|
||||||
|
return $this->amount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->amount === $other->amount && $this->currency === $other->currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Sales/Domain/Model/Order.php
Normal file
115
src/Sales/Domain/Model/Order.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||||
|
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
|
||||||
|
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
|
||||||
|
|
||||||
|
final class Order
|
||||||
|
{
|
||||||
|
private OrderStatus $status;
|
||||||
|
/** @var list<OrderLine> */
|
||||||
|
private array $lines;
|
||||||
|
/** @var list<object> */
|
||||||
|
private array $domainEvents = [];
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public readonly OrderId $id,
|
||||||
|
public readonly CustomerId $customerId,
|
||||||
|
private readonly \DateTimeImmutable $placedAt,
|
||||||
|
) {
|
||||||
|
$this->status = OrderStatus::Draft;
|
||||||
|
$this->lines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<OrderLine> $lines
|
||||||
|
*/
|
||||||
|
public static function place(
|
||||||
|
OrderId $id,
|
||||||
|
CustomerId $customerId,
|
||||||
|
array $lines,
|
||||||
|
\DateTimeImmutable $placedAt,
|
||||||
|
): self {
|
||||||
|
if ($lines === []) {
|
||||||
|
throw EmptyOrderException::noLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = new self($id, $customerId, $placedAt);
|
||||||
|
$order->lines = $lines;
|
||||||
|
$order->status = OrderStatus::Placed;
|
||||||
|
|
||||||
|
$order->recordEvent(new OrderPlaced($id, $customerId, $order->total(), $placedAt));
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirm(): void
|
||||||
|
{
|
||||||
|
if ($this->status !== OrderStatus::Placed) {
|
||||||
|
throw InvalidOrderStateException::cannotConfirm($this->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = OrderStatus::Confirmed;
|
||||||
|
$this->recordEvent(new OrderConfirmed(
|
||||||
|
$this->id,
|
||||||
|
$this->customerId,
|
||||||
|
$this->total(),
|
||||||
|
$this->lines,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
if ($this->status !== OrderStatus::Placed) {
|
||||||
|
throw InvalidOrderStateException::cannotCancel($this->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = OrderStatus::Cancelled;
|
||||||
|
$this->recordEvent(new OrderCancelled($this->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function total(): Money
|
||||||
|
{
|
||||||
|
return array_reduce(
|
||||||
|
$this->lines,
|
||||||
|
static fn (Money $carry, OrderLine $line): Money => $carry->add($line->lineTotal()),
|
||||||
|
Money::zero($this->lines[0]->unitPrice->currency),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(): OrderStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<OrderLine> */
|
||||||
|
public function lines(): array
|
||||||
|
{
|
||||||
|
return $this->lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->placedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> */
|
||||||
|
public function releaseEvents(): array
|
||||||
|
{
|
||||||
|
$events = $this->domainEvents;
|
||||||
|
$this->domainEvents = [];
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordEvent(object $event): void
|
||||||
|
{
|
||||||
|
$this->domainEvents[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Sales/Domain/Model/OrderId.php
Normal file
25
src/Sales/Domain/Model/OrderId.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class OrderId
|
||||||
|
{
|
||||||
|
private function __construct(public string $value) {}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Sales/Domain/Model/OrderLine.php
Normal file
26
src/Sales/Domain/Model/OrderLine.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
final readonly class OrderLine
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $productName,
|
||||||
|
public int $quantity,
|
||||||
|
public Money $unitPrice,
|
||||||
|
) {
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Quantity must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineTotal(): Money
|
||||||
|
{
|
||||||
|
return new Money(
|
||||||
|
$this->unitPrice->amount * $this->quantity,
|
||||||
|
$this->unitPrice->currency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Sales/Domain/Model/OrderStatus.php
Normal file
13
src/Sales/Domain/Model/OrderStatus.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Domain\Model;
|
||||||
|
|
||||||
|
enum OrderStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Placed = 'placed';
|
||||||
|
case Confirmed = 'confirmed';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Infrastructure\Messaging;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrder;
|
||||||
|
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrder;
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
|
||||||
|
use MiniShop\Sales\Application\Port\SalesEventPublisher;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couplage naif : Sales appelle directement les handlers des autres BC.
|
||||||
|
* Ce couplage sera supprime dans les episodes suivants (Published Language, Conformist, ACL).
|
||||||
|
*/
|
||||||
|
final readonly class NaiveSalesEventPublisher implements SalesEventPublisher
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private IssueInvoiceForExternalOrderHandler $issueInvoiceHandler,
|
||||||
|
private RequestShipmentFromSalesOrderHandler $requestShipmentHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function publishOrderPlaced(OrderPlaced $event): void
|
||||||
|
{
|
||||||
|
// Pas d'action downstream sur OrderPlaced dans le scenario naif.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publishOrderConfirmed(OrderConfirmed $event): void
|
||||||
|
{
|
||||||
|
// Appel direct vers Invoicing — couplage naif
|
||||||
|
($this->issueInvoiceHandler)(new IssueInvoiceForExternalOrder(
|
||||||
|
externalOrderId: $event->orderId->toString(),
|
||||||
|
customerName: 'Customer ' . $event->customerId->toString(),
|
||||||
|
customerAddress: 'N/A',
|
||||||
|
lines: array_map(
|
||||||
|
static fn (OrderLine $line): array => [
|
||||||
|
'description' => $line->productName,
|
||||||
|
'quantity' => $line->quantity,
|
||||||
|
'unitPriceInCents' => $line->unitPrice->amount,
|
||||||
|
'currency' => $line->unitPrice->currency,
|
||||||
|
],
|
||||||
|
$event->lines,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Appel direct vers LegacyFulfillment — couplage naif
|
||||||
|
($this->requestShipmentHandler)(new RequestShipmentFromSalesOrder(
|
||||||
|
externalOrderId: $event->orderId->toString(),
|
||||||
|
recipientName: 'Customer ' . $event->customerId->toString(),
|
||||||
|
street: '1 Rue du Commerce',
|
||||||
|
city: 'Paris',
|
||||||
|
postalCode: '75001',
|
||||||
|
country: 'FR',
|
||||||
|
totalWeightInGrams: 1000,
|
||||||
|
description: sprintf('Order %s', $event->orderId->toString()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publishOrderCancelled(OrderCancelled $event): void
|
||||||
|
{
|
||||||
|
// Pas d'action downstream sur OrderCancelled dans le scenario naif.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Infrastructure\Persistence;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Port\OrderRepository;
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
|
||||||
|
final class InMemoryOrderRepository implements OrderRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, Order> */
|
||||||
|
private array $orders = [];
|
||||||
|
|
||||||
|
public function save(Order $order): void
|
||||||
|
{
|
||||||
|
$this->orders[$order->id->toString()] = $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(OrderId $id): Order
|
||||||
|
{
|
||||||
|
return $this->orders[$id->toString()]
|
||||||
|
?? throw new \RuntimeException(sprintf('Order "%s" not found.', $id->toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Order> */
|
||||||
|
public function findByCustomer(CustomerId $customerId): array
|
||||||
|
{
|
||||||
|
return array_values(
|
||||||
|
array_filter(
|
||||||
|
$this->orders,
|
||||||
|
static fn (Order $order): bool => $order->customerId->equals($customerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Sales/Interfaces/Http/CancelOrderController.php
Normal file
26
src/Sales/Interfaces/Http/CancelOrderController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Command\CancelOrder;
|
||||||
|
use MiniShop\Sales\Application\Command\CancelOrderHandler;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/sales/orders/{orderId}/cancel', name: 'sales_cancel_order', methods: ['POST'])]
|
||||||
|
final readonly class CancelOrderController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CancelOrderHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $orderId): JsonResponse
|
||||||
|
{
|
||||||
|
($this->handler)(new CancelOrder(orderId: $orderId));
|
||||||
|
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Sales/Interfaces/Http/ConfirmOrderController.php
Normal file
26
src/Sales/Interfaces/Http/ConfirmOrderController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Command\ConfirmOrder;
|
||||||
|
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/sales/orders/{orderId}/confirm', name: 'sales_confirm_order', methods: ['POST'])]
|
||||||
|
final readonly class ConfirmOrderController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConfirmOrderHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $orderId): JsonResponse
|
||||||
|
{
|
||||||
|
($this->handler)(new ConfirmOrder(orderId: $orderId));
|
||||||
|
|
||||||
|
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Sales/Interfaces/Http/GetOrderController.php
Normal file
44
src/Sales/Interfaces/Http/GetOrderController.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Query\GetOrderById;
|
||||||
|
use MiniShop\Sales\Application\Query\GetOrderByIdHandler;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/sales/orders/{orderId}', name: 'sales_get_order', methods: ['GET'])]
|
||||||
|
final readonly class GetOrderController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetOrderByIdHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $orderId): JsonResponse
|
||||||
|
{
|
||||||
|
$order = ($this->handler)(new GetOrderById($orderId));
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'orderId' => $order->id->toString(),
|
||||||
|
'customerId' => $order->customerId->toString(),
|
||||||
|
'status' => $order->status()->value,
|
||||||
|
'total' => [
|
||||||
|
'amount' => $order->total()->amount,
|
||||||
|
'currency' => $order->total()->currency,
|
||||||
|
],
|
||||||
|
'lines' => array_map(
|
||||||
|
static fn (OrderLine $line): array => [
|
||||||
|
'productName' => $line->productName,
|
||||||
|
'quantity' => $line->quantity,
|
||||||
|
'unitPrice' => $line->unitPrice->amount,
|
||||||
|
'currency' => $line->unitPrice->currency,
|
||||||
|
'lineTotal' => $line->lineTotal()->amount,
|
||||||
|
],
|
||||||
|
$order->lines(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Sales/Interfaces/Http/ListCustomerOrdersController.php
Normal file
36
src/Sales/Interfaces/Http/ListCustomerOrdersController.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Query\ListOrdersByCustomer;
|
||||||
|
use MiniShop\Sales\Application\Query\ListOrdersByCustomerHandler;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/sales/customers/{customerId}/orders', name: 'sales_list_customer_orders', methods: ['GET'])]
|
||||||
|
final readonly class ListCustomerOrdersController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ListOrdersByCustomerHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $customerId): JsonResponse
|
||||||
|
{
|
||||||
|
$orders = ($this->handler)(new ListOrdersByCustomer($customerId));
|
||||||
|
|
||||||
|
return new JsonResponse(array_map(
|
||||||
|
static fn (Order $order): array => [
|
||||||
|
'orderId' => $order->id->toString(),
|
||||||
|
'status' => $order->status()->value,
|
||||||
|
'total' => [
|
||||||
|
'amount' => $order->total()->amount,
|
||||||
|
'currency' => $order->total()->currency,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$orders,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Sales/Interfaces/Http/PlaceOrderController.php
Normal file
37
src/Sales/Interfaces/Http/PlaceOrderController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Sales\Interfaces\Http;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Application\Command\PlaceOrder;
|
||||||
|
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
|
||||||
|
use MiniShop\Shared\Technical\UuidGenerator;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/sales/orders', name: 'sales_place_order', methods: ['POST'])]
|
||||||
|
final readonly class PlaceOrderController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PlaceOrderHandler $handler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var array{customerId: string, lines: list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}>} $data */
|
||||||
|
$data = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$orderId = UuidGenerator::generate();
|
||||||
|
|
||||||
|
($this->handler)(new PlaceOrder(
|
||||||
|
orderId: $orderId,
|
||||||
|
customerId: $data['customerId'],
|
||||||
|
lines: $data['lines'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return new JsonResponse(['orderId' => $orderId], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Shared/Technical/Clock.php
Normal file
10
src/Shared/Technical/Clock.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Shared\Technical;
|
||||||
|
|
||||||
|
interface Clock
|
||||||
|
{
|
||||||
|
public function now(): \DateTimeImmutable;
|
||||||
|
}
|
||||||
13
src/Shared/Technical/SystemClock.php
Normal file
13
src/Shared/Technical/SystemClock.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Shared\Technical;
|
||||||
|
|
||||||
|
final class SystemClock implements Clock
|
||||||
|
{
|
||||||
|
public function now(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Shared/Technical/UuidGenerator.php
Normal file
17
src/Shared/Technical/UuidGenerator.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Shared\Technical;
|
||||||
|
|
||||||
|
final class UuidGenerator
|
||||||
|
{
|
||||||
|
public static function generate(): string
|
||||||
|
{
|
||||||
|
$bytes = random_bytes(16);
|
||||||
|
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||||
|
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||||
|
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
0
tests/Contract/.gitkeep
Normal file
0
tests/Contract/.gitkeep
Normal file
88
tests/Integration/NaiveWorkflowTest.php
Normal file
88
tests/Integration/NaiveWorkflowTest.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Tests\Integration;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
|
||||||
|
use MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository;
|
||||||
|
use MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator;
|
||||||
|
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
|
||||||
|
use MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway;
|
||||||
|
use MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository;
|
||||||
|
use MiniShop\Sales\Application\Command\ConfirmOrder;
|
||||||
|
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
|
||||||
|
use MiniShop\Sales\Application\Command\PlaceOrder;
|
||||||
|
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderStatus;
|
||||||
|
use MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher;
|
||||||
|
use MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository;
|
||||||
|
use MiniShop\Shared\Technical\SystemClock;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test d'integration naif : PlaceOrder -> ConfirmOrder -> Invoice + Shipment.
|
||||||
|
* Illustre le couplage direct entre BC (episode 00).
|
||||||
|
*/
|
||||||
|
final class NaiveWorkflowTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_full_naive_workflow(): void
|
||||||
|
{
|
||||||
|
// --- Setup : cablage naif synchrone ---
|
||||||
|
$clock = new SystemClock();
|
||||||
|
$orderRepo = new InMemoryOrderRepository();
|
||||||
|
$invoiceRepo = new InMemoryInvoiceRepository();
|
||||||
|
$shipmentRepo = new InMemoryShipmentRequestRepository();
|
||||||
|
$gateway = new FakeLegacyFulfillmentGateway();
|
||||||
|
|
||||||
|
$issueInvoiceHandler = new IssueInvoiceForExternalOrderHandler(
|
||||||
|
$invoiceRepo,
|
||||||
|
new SequentialInvoiceNumberGenerator(),
|
||||||
|
$clock,
|
||||||
|
);
|
||||||
|
$requestShipmentHandler = new RequestShipmentFromSalesOrderHandler(
|
||||||
|
$shipmentRepo,
|
||||||
|
$gateway,
|
||||||
|
$clock,
|
||||||
|
);
|
||||||
|
$publisher = new NaiveSalesEventPublisher($issueInvoiceHandler, $requestShipmentHandler);
|
||||||
|
|
||||||
|
$placeOrderHandler = new PlaceOrderHandler($orderRepo, $publisher, $clock);
|
||||||
|
$confirmOrderHandler = new ConfirmOrderHandler($orderRepo, $publisher);
|
||||||
|
|
||||||
|
$orderId = 'test-order-001';
|
||||||
|
|
||||||
|
// --- Act : passer et confirmer une commande ---
|
||||||
|
($placeOrderHandler)(new PlaceOrder(
|
||||||
|
orderId: $orderId,
|
||||||
|
customerId: 'cust-001',
|
||||||
|
lines: [
|
||||||
|
['productName' => 'Widget', 'quantity' => 2, 'unitPriceInCents' => 1500, 'currency' => 'EUR'],
|
||||||
|
['productName' => 'Gadget', 'quantity' => 1, 'unitPriceInCents' => 2500, 'currency' => 'EUR'],
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
($confirmOrderHandler)(new ConfirmOrder(orderId: $orderId));
|
||||||
|
|
||||||
|
// --- Assert : ordre confirme ---
|
||||||
|
$order = $orderRepo->get(OrderId::fromString($orderId));
|
||||||
|
self::assertSame(OrderStatus::Confirmed, $order->status());
|
||||||
|
|
||||||
|
// --- Assert : facture creee ---
|
||||||
|
$invoice = $invoiceRepo->findByExternalOrderId($orderId);
|
||||||
|
self::assertNotNull($invoice, 'An invoice should have been created for the confirmed order.');
|
||||||
|
self::assertSame($orderId, $invoice->externalOrderId);
|
||||||
|
self::assertCount(2, $invoice->lines());
|
||||||
|
|
||||||
|
// --- Assert : expedition demandee ---
|
||||||
|
$shipment = $shipmentRepo->findByOrderRef(LegacyOrderRef::fromExternalId($orderId));
|
||||||
|
self::assertNotNull($shipment, 'A shipment request should have been created.');
|
||||||
|
self::assertSame(ShipmentStatus::Requested, $shipment->status());
|
||||||
|
|
||||||
|
// --- Assert : gateway legacy appele ---
|
||||||
|
self::assertCount(1, $gateway->sentRequests());
|
||||||
|
}
|
||||||
|
}
|
||||||
97
tests/Unit/Invoicing/Domain/InvoiceTest.php
Normal file
97
tests/Unit/Invoicing/Domain/InvoiceTest.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Tests\Unit\Invoicing\Domain;
|
||||||
|
|
||||||
|
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\BillingParty;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\InvoiceStatus;
|
||||||
|
use MiniShop\Invoicing\Domain\Model\TaxRate;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class InvoiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_issue_invoice_for_external_order(): void
|
||||||
|
{
|
||||||
|
$invoice = $this->issueInvoice();
|
||||||
|
|
||||||
|
self::assertSame(InvoiceStatus::Issued, $invoice->status());
|
||||||
|
self::assertSame('ext-order-1', $invoice->externalOrderId);
|
||||||
|
self::assertCount(1, $invoice->lines());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_issue_invoice_records_event(): void
|
||||||
|
{
|
||||||
|
$invoice = $this->issueInvoice();
|
||||||
|
|
||||||
|
$events = $invoice->releaseEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(InvoiceIssued::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invoice_without_lines_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\DomainException::class);
|
||||||
|
|
||||||
|
Invoice::issueForExternalOrder(
|
||||||
|
InvoiceId::fromString('inv-1'),
|
||||||
|
'INV-000001',
|
||||||
|
'ext-order-1',
|
||||||
|
new BillingParty('Acme', '1 Rue Test'),
|
||||||
|
[],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_coherent_with_lines(): void
|
||||||
|
{
|
||||||
|
$invoice = Invoice::issueForExternalOrder(
|
||||||
|
InvoiceId::fromString('inv-1'),
|
||||||
|
'INV-000001',
|
||||||
|
'ext-order-1',
|
||||||
|
new BillingParty('Acme', '1 Rue Test'),
|
||||||
|
[
|
||||||
|
new InvoiceLine('Widget', 2, 1000, 'EUR', TaxRate::standard()),
|
||||||
|
new InvoiceLine('Gadget', 1, 2500, 'EUR', TaxRate::standard()),
|
||||||
|
],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(4500, $invoice->totalExclTax());
|
||||||
|
self::assertSame(5400, $invoice->totalInclTax());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mark_as_sent(): void
|
||||||
|
{
|
||||||
|
$invoice = $this->issueInvoice();
|
||||||
|
|
||||||
|
$invoice->markAsSent();
|
||||||
|
|
||||||
|
self::assertSame(InvoiceStatus::Sent, $invoice->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mark_as_sent_twice_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\DomainException::class);
|
||||||
|
|
||||||
|
$invoice = $this->issueInvoice();
|
||||||
|
$invoice->markAsSent();
|
||||||
|
$invoice->markAsSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueInvoice(): Invoice
|
||||||
|
{
|
||||||
|
return Invoice::issueForExternalOrder(
|
||||||
|
InvoiceId::fromString('inv-1'),
|
||||||
|
'INV-000001',
|
||||||
|
'ext-order-1',
|
||||||
|
new BillingParty('Acme', '1 Rue Test'),
|
||||||
|
[new InvoiceLine('Widget', 2, 1500, 'EUR', TaxRate::standard())],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Unit/LegacyFulfillment/Domain/ShipmentRequestTest.php
Normal file
68
tests/Unit/LegacyFulfillment/Domain/ShipmentRequestTest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Tests\Unit\LegacyFulfillment\Domain;
|
||||||
|
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
|
||||||
|
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ShipmentRequestTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_create_from_sales_order(): void
|
||||||
|
{
|
||||||
|
$request = $this->createRequest();
|
||||||
|
|
||||||
|
self::assertSame(ShipmentStatus::Requested, $request->status());
|
||||||
|
self::assertSame('order-1', $request->orderRef->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_records_event(): void
|
||||||
|
{
|
||||||
|
$request = $this->createRequest();
|
||||||
|
|
||||||
|
$events = $request->releaseEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(ShipmentRequested::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mark_dispatched(): void
|
||||||
|
{
|
||||||
|
$request = $this->createRequest();
|
||||||
|
|
||||||
|
$request->markDispatched();
|
||||||
|
|
||||||
|
self::assertSame(ShipmentStatus::Dispatched, $request->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mark_dispatched_twice_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\DomainException::class);
|
||||||
|
|
||||||
|
$request = $this->createRequest();
|
||||||
|
$request->markDispatched();
|
||||||
|
$request->markDispatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parcel_with_zero_weight_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new ParcelSpec(0, 'Widget');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRequest(): ShipmentRequest
|
||||||
|
{
|
||||||
|
return ShipmentRequest::fromSalesOrder(
|
||||||
|
LegacyOrderRef::fromExternalId('order-1'),
|
||||||
|
new ShippingAddress('John Doe', '1 Rue du Commerce', 'Paris', '75001', 'FR'),
|
||||||
|
new ParcelSpec(1000, 'Order order-1'),
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tests/Unit/Sales/Domain/OrderTest.php
Normal file
122
tests/Unit/Sales/Domain/OrderTest.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MiniShop\Tests\Unit\Sales\Domain;
|
||||||
|
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||||
|
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||||
|
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
|
||||||
|
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
|
||||||
|
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||||
|
use MiniShop\Sales\Domain\Model\Money;
|
||||||
|
use MiniShop\Sales\Domain\Model\Order;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderId;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||||
|
use MiniShop\Sales\Domain\Model\OrderStatus;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class OrderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_place_order_with_lines(): void
|
||||||
|
{
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
|
||||||
|
self::assertSame(OrderStatus::Placed, $order->status());
|
||||||
|
self::assertCount(1, $order->lines());
|
||||||
|
self::assertTrue($order->total()->isPositive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_place_order_records_order_placed_event(): void
|
||||||
|
{
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
|
||||||
|
$events = $order->releaseEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(OrderPlaced::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_place_order_without_lines_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(EmptyOrderException::class);
|
||||||
|
|
||||||
|
Order::place(
|
||||||
|
OrderId::fromString('order-1'),
|
||||||
|
CustomerId::fromString('cust-1'),
|
||||||
|
[],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_confirm_placed_order(): void
|
||||||
|
{
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
$order->releaseEvents();
|
||||||
|
|
||||||
|
$order->confirm();
|
||||||
|
|
||||||
|
self::assertSame(OrderStatus::Confirmed, $order->status());
|
||||||
|
$events = $order->releaseEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(OrderConfirmed::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_confirm_draft_order_throws(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidOrderStateException::class);
|
||||||
|
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
$order->confirm();
|
||||||
|
$order->releaseEvents();
|
||||||
|
|
||||||
|
$order->confirm(); // already confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cancel_placed_order(): void
|
||||||
|
{
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
$order->releaseEvents();
|
||||||
|
|
||||||
|
$order->cancel();
|
||||||
|
|
||||||
|
self::assertSame(OrderStatus::Cancelled, $order->status());
|
||||||
|
$events = $order->releaseEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(OrderCancelled::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_cancel_confirmed_order(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidOrderStateException::class);
|
||||||
|
|
||||||
|
$order = $this->placeOrder();
|
||||||
|
$order->confirm();
|
||||||
|
$order->cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_is_sum_of_line_totals(): void
|
||||||
|
{
|
||||||
|
$order = Order::place(
|
||||||
|
OrderId::fromString('order-1'),
|
||||||
|
CustomerId::fromString('cust-1'),
|
||||||
|
[
|
||||||
|
new OrderLine('Widget', 2, new Money(1000, 'EUR')),
|
||||||
|
new OrderLine('Gadget', 1, new Money(2500, 'EUR')),
|
||||||
|
],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($order->total()->equals(new Money(4500, 'EUR')));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function placeOrder(): Order
|
||||||
|
{
|
||||||
|
return Order::place(
|
||||||
|
OrderId::fromString('order-1'),
|
||||||
|
CustomerId::fromString('cust-1'),
|
||||||
|
[new OrderLine('Widget', 2, new Money(1500, 'EUR'))],
|
||||||
|
new \DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user