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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user