Merge branch 'remove-coupon-quantity' into 'coupon'

refactor: ♻️ compute quantity from stock movements

See merge request ldc-web/projets/mesbr!65
This commit is contained in:
Corentin HUET 2026-10-14 11:02:47 +00:00
commit eb09b6c0e7
16 changed files with 59 additions and 142 deletions

View file

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Application\UseCases\Coupon\Create;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
@ -36,9 +34,6 @@ final readonly class CreateCoupon implements UseCase, Transactional
throw new \DomainException(sprintf('Coupon code "%s" already exists.', $input->code->value));
}
$couponMovement = new CouponMovement($input->quantity->value);
$couponMovements = new CouponMovementArray([$couponMovement]);
$coupon = Coupon::create(
code: $input->code,
name: $input->name,
@ -51,8 +46,7 @@ final readonly class CreateCoupon implements UseCase, Transactional
brandCode: $input->brandCode,
createdBy: $input->createdBy,
products: $input->products,
movements: $couponMovements,
quantity: $input->quantity,
movements: $input->movements,
availabilityCalendar: $input->availabilityCalendar,
);
@ -72,7 +66,6 @@ final readonly class CreateCoupon implements UseCase, Transactional
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
quantity: $coupon->quantity,
createdAt: $coupon->createdAt(),
availabilityCalendar: $coupon->availabilityCalendar,
);

View file

@ -10,10 +10,10 @@ use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
@ -33,7 +33,7 @@ final readonly class Input implements UseCaseInput
public BrandCode $brandCode,
public ActorId $createdBy,
public CouponProductArray $products,
public CouponQuantity $quantity,
public CouponMovementArray $movements,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {
}

View file

@ -15,7 +15,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
@ -37,7 +36,6 @@ final readonly class Output implements UseCaseOutput
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public CouponQuantity $quantity,
public \DateTimeInterface $createdAt,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {

View file

@ -55,7 +55,7 @@ final readonly class FindCoupon implements UseCase
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
quantity: $coupon->quantity,
quantity: $coupon->quantity(),
campaigns: $campaigns,
createdAt: $coupon->createdAt(),
updatedAt: $coupon->updatedAt(),

View file

@ -15,7 +15,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
@ -40,7 +39,7 @@ final readonly class CouponFound implements Output
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public CouponQuantity $quantity,
public int $quantity,
public array $campaigns,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,

View file

@ -14,7 +14,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
@ -35,7 +34,6 @@ final readonly class CouponUpdated implements Output
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public CouponQuantity $quantity,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
) {

View file

@ -66,7 +66,6 @@ final readonly class UpdateCoupon implements UseCase, Transactional
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
quantity: $coupon->quantity,
createdAt: $coupon->createdAt(),
updatedAt: $coupon->updatedAt(),
);

View file

@ -16,7 +16,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
@ -43,7 +42,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public CouponQuantity $quantity,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
public ?\DateTimeInterface $updatedAt = null,
) {
@ -69,6 +67,11 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
return $this->updatedAt;
}
public function quantity(): int
{
return $this->movements->quantity();
}
public static function create(
CouponCode $code,
CouponName $name,
@ -82,7 +85,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
ActorId $createdBy,
CouponProductArray $products,
CouponMovementArray $movements,
CouponQuantity $quantity,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?\DateTimeInterface $updatedAt = null,
): Coupon {
@ -100,7 +102,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
updatedAt: $updatedAt,
);
@ -120,7 +121,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
ActorId $createdBy,
CouponProductArray $products,
CouponMovementArray $movements,
CouponQuantity $quantity,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?\DateTimeInterface $updatedAt = null,
): self {
@ -138,7 +138,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
updatedAt: $updatedAt,
);
@ -160,7 +159,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
createdBy: $this->createdBy,
products: $this->products,
movements: $this->movements,
quantity: $this->quantity,
availabilityCalendar: $this->availabilityCalendar,
updatedAt: DatesHelper::now(),
);
@ -182,7 +180,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
createdBy: $this->createdBy,
products: $this->products,
movements: $this->movements,
quantity: $this->quantity,
availabilityCalendar: $this->availabilityCalendar,
updatedAt: DatesHelper::now(),
);

View file

@ -20,4 +20,9 @@ final readonly class CouponMovementArray
}
}
}
public function quantity(): int
{
return array_sum(array_column($this->value, 'quantity'));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponQuantity
{
public function __construct(
public int $value,
) {
if ($this->value <= 0) {
throw new \DomainException('CouponQuantity quantity must be positive.');
}
}
}

View file

@ -22,7 +22,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
@ -64,7 +63,9 @@ describe('create coupon use case', function (): void {
'brandCode' => new BrandCode('TST89'),
'createdBy' => ActorId::generate(),
'products' => new CouponProductArray(),
'quantity' => new CouponQuantity(1000),
'movements' => new CouponMovementArray([
new CouponMovement(100),
]),
'availabilityCalendar' => null,
], $overrides));
@ -98,8 +99,7 @@ describe('create coupon use case', function (): void {
->and($result->brandCode)->toEqual($input->brandCode)
->and($result->createdBy)->toEqual($input->createdBy)
->and($result->products)->toEqual($input->products)
->and($result->movements)->toBeInstanceOf(CouponMovementArray::class)
->and($result->quantity)->toEqual($input->quantity)
->and($result->movements)->toEqual($input->movements)
->and($result->availabilityCalendar)->toBeNull()
->and($result->createdAt)->toBeInstanceOf(\DateTimeInterface::class);
});
@ -144,31 +144,6 @@ describe('create coupon use case', function (): void {
expect($action)->toThrow(\DomainException::class, 'Coupon code "COU01" already exists.');
});
it('initialises the stock with a first movement equal to the initial quantity', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->once()->andReturn(false);
$couponRepository->shouldReceive('save')->once()->with(\Mockery::on(
fn (Coupon $saved): bool => count($saved->movements->value) === 1
&& $saved->movements->value[0] instanceof CouponMovement
&& $saved->movements->value[0]->quantity === $input->quantity->value,
));
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result->movements)->toBeInstanceOf(CouponMovementArray::class)
->and($result->movements->value)->toHaveCount(1);
});
it('creates a coupon with an availability calendar', function () use ($makeInput): void {
// GIVEN
$calendar = new CouponAvailabilityCalendar([

View file

@ -28,7 +28,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
@ -95,7 +94,6 @@ describe('find coupon use case', function (): void {
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
quantity: new CouponQuantity(1000),
);
$campaign = Campaign::create(
code: new CampaignCode('CMP01'),
@ -140,7 +138,7 @@ describe('find coupon use case', function (): void {
->and($result->createdBy)->toBe($coupon->createdBy)
->and($result->products)->toBe($coupon->products)
->and($result->movements)->toBe($coupon->movements)
->and($result->quantity)->toBe($coupon->quantity)
->and($result->quantity)->toBe($coupon->quantity())
->and($result->campaigns)->toBe([$campaign])
->and($result->createdAt)->toBeInstanceOf(\DateTimeInterface::class)
->and($result->updatedAt)->toBeNull();

View file

@ -23,7 +23,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Domain\Models\ValueObjects\Coupon\CouponSortField;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
@ -72,7 +71,6 @@ describe('list coupons use case', function (): void {
createdBy: ActorId::generate(),
products: new CouponProductArray([new IncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
quantity: new CouponQuantity(100),
availabilityCalendar: new CouponAvailabilityCalendar([
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
]),
@ -104,7 +102,6 @@ describe('list coupons use case', function (): void {
createdBy: ActorId::generate(),
products: new CouponProductArray([new IncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
quantity: new CouponQuantity(100),
);
$pagination = Pagination::firstPage();
$brandCode = new BrandCode('AAA01');

View file

@ -22,7 +22,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
@ -79,7 +78,6 @@ describe('update coupon use case', function (): void {
movements: new CouponMovementArray([
new CouponMovement(1000),
]),
quantity: new CouponQuantity(1000),
);
it('updates the picture and keeps the other fields', function () use ($makeCoupon): void {
@ -101,7 +99,6 @@ describe('update coupon use case', function (): void {
&& $saved->name === $coupon->name
&& $saved->amount === $coupon->amount
&& $saved->diffusion === $coupon->diffusion
&& $saved->quantity === $coupon->quantity
&& $saved->updatedAt instanceof \DateTimeInterface,
));

View file

@ -20,7 +20,6 @@ use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
@ -65,7 +64,6 @@ describe('coupon', function (): void {
$createdBy = ActorId::generate();
$products = new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]);
$movements = new CouponMovementArray([new CouponMovement(100)]);
$quantity = new CouponQuantity(100);
$availabilityCalendar = new CouponAvailabilityCalendar([
new Period(
new DateTime('2026-08-25'),
@ -89,7 +87,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -110,7 +107,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
@ -146,7 +142,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
): void {
// GIVEN
$id = CouponId::generate();
@ -166,7 +161,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
);
// THEN
@ -186,7 +180,6 @@ describe('coupon', function (): void {
$brandCode,
$createdBy,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -207,7 +200,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: new CouponProductArray(),
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
@ -228,7 +220,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -249,7 +240,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
))->toThrow(\DomainException::class, 'Value of CouponValidityInformationArray must be CouponValidityInformation instances.');
});
@ -266,7 +256,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -287,7 +276,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
))->toThrow(\DomainException::class, 'Value of CouponLegalInformationArray must be CouponLegalInformation instances.');
});
@ -305,7 +293,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -327,7 +314,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
updatedAt: $updatedAt,
);
@ -350,7 +336,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN / WHEN
@ -367,7 +352,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
@ -385,7 +369,6 @@ describe('coupon', function (): void {
->and($coupon->createdBy)->toBe($createdBy)
->and($coupon->products)->toBe($products)
->and($coupon->movements)->toBe($movements)
->and($coupon->quantity)->toBe($quantity)
->and($coupon->availabilityCalendar)->toBe($availabilityCalendar)
->and($coupon->updatedAt)->toBeNull()
->and($coupon->updatedAt())->toBeNull();
@ -404,7 +387,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN / WHEN
@ -421,7 +403,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
$secondCoupon = Coupon::create(
@ -437,7 +418,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
@ -458,7 +438,6 @@ describe('coupon', function (): void {
$createdBy,
$products,
$movements,
$quantity,
$availabilityCalendar,
): void {
// GIVEN
@ -478,7 +457,6 @@ describe('coupon', function (): void {
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
);
@ -488,4 +466,43 @@ describe('coupon', function (): void {
// THEN
expect($result->format('Y-m-d H:i:s.v'))->toBe($expected->format('Y-m-d H:i:s.v'));
});
it('computes the quantity from its movements', function () use (
$code,
$name,
$amount,
$picture,
$diffusion,
$validityInformations,
$legalInformations,
$processingCode,
$brandCode,
$createdBy,
$products,
): void {
// GIVEN
$coupon = Coupon::create(
code: $code,
name: $name,
amount: $amount,
picture: $picture,
diffusion: $diffusion,
validityInformations: $validityInformations,
legalInformations: $legalInformations,
processingCode: $processingCode,
brandCode: $brandCode,
createdBy: $createdBy,
products: $products,
movements: new CouponMovementArray([
new CouponMovement(1000),
new CouponMovement(-200),
]),
);
// WHEN
$quantity = $coupon->quantity();
// THEN
expect($quantity)->toBe(800);
});
});

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
covers(CouponQuantity::class);
describe('coupon quantity', function (): void {
$quantity = 10;
$zeroQuantity = 0;
$negativeQuantity = -1;
it('creates a valid coupon quantity', function () use ($quantity): void {
// GIVEN / WHEN
$couponQuantity = new CouponQuantity($quantity);
// THEN
expect($couponQuantity)->toBeInstanceOf(CouponQuantity::class)
->and($couponQuantity->value)->toBe($quantity);
});
it('rejects a quantity of zero', function () use ($zeroQuantity): void {
// WHEN
$action = fn (): CouponQuantity => new CouponQuantity($zeroQuantity);
// THEN
expect($action)->toThrow(\DomainException::class, 'CouponQuantity quantity must be positive.');
});
it('rejects a negative quantity', function () use ($negativeQuantity): void {
// WHEN
$action = fn (): CouponQuantity => new CouponQuantity($negativeQuantity);
// THEN
expect($action)->toThrow(\DomainException::class, 'CouponQuantity quantity must be positive.');
});
});