feat(inco): migrate to IncoBrand VO, BrandResolver domain service and BrandCode filtering

This commit is contained in:
Jérémy Munsch 2026-08-20 18:16:31 +02:00
parent 2617d41ef3
commit 8f933db9fa
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
38 changed files with 589 additions and 352 deletions

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\IncoSheet;
/**
@ -47,7 +48,7 @@ final class IncoSheetFactory extends Factory
['code' => 1, 'label' => 'Gluten'],
],
'brand' => [
'code' => 'M01',
'code' => 1,
'label' => 'Le Gaulois',
],
'is_seasonal' => false,
@ -68,4 +69,19 @@ final class IncoSheetFactory extends Factory
'compo_id' => (string) fake()->numberBetween(10000, 99999),
];
}
#[\Override]
public function configure(): static
{
return $this->afterCreating(function (IncoSheet $sheet): void {
$raw = is_array($sheet->brand) ? $sheet->brand : [];
$code = isset($raw['code']) && is_numeric($raw['code']) ? (int) $raw['code'] : 1;
$label = isset($raw['label']) && is_string($raw['label']) ? $raw['label'] : (string) ($raw['libelle'] ?? 'Le Gaulois');
ArcadiaBrand::query()->firstOrCreate(
['code' => $code],
['libelle' => $label]
);
});
}
}

View file

@ -31,7 +31,7 @@ http:
description: Filter INCO sheets updated since timestamp (RFC3339)
disabled: true
- name: brands[]
value: M01
value: LGL01
type: query
description: Filter by brand code
disabled: true

View file

@ -12,7 +12,7 @@ http:
type: query
description: Pattern used with SQL-like matching rules
- name: brands[]
value: M01
value: LGL01
type: query
description: Filter by brand code
disabled: true

View file

@ -529,7 +529,8 @@ components:
minItems: 1
items:
type: string
example: ["M01", "M02"]
pattern: '^[A-Z]{3}\d{2}$'
example: ["LGL01", "MCQ01"]
ShortProjection:
name: short
@ -859,10 +860,11 @@ components:
properties:
code:
type: string
example: M01
pattern: '^[A-Z]{3}\d{2}$'
example: "LGL01"
label:
type: string
example: Le Gaulois
example: "Le Gaulois"
Inco:
type: object

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\FindAll;
use Domain\Models\Entities\Brand;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Services\BrandResolver;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
@ -14,6 +14,7 @@ use Webmozart\Assert\Assert;
final readonly class FindAllBrands implements Usecase
{
public function __construct(
private ArcadiaBrandDirectory $arcadiaBrandDirectory,
private BrandResolver $brandResolver,
) {
}
@ -23,12 +24,13 @@ final readonly class FindAllBrands implements Usecase
{
Assert::isInstanceOf($input, Input::class);
$brands = $this->brandResolver->resolveAll();
$arcadiaBrands = $this->arcadiaBrandDirectory->findAll();
$brandItems = [];
$brandItems = array_map(
static fn (Brand $brand): BrandItem => BrandItem::fromDomain($brand),
$brands
);
foreach ($arcadiaBrands as $arcadiaBrand) {
$brand = $this->brandResolver->resolve($arcadiaBrand->id);
$brandItems[] = BrandItem::fromDomain($brand);
}
$total = count($brandItems);
$offset = $input->pagination->from();

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Application\Arcadia\Usecase\IncoSheets\Find;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoWeight;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -34,7 +34,7 @@ final readonly class Output implements UsecaseOutput
public Ingredients $ingredients,
public ?PortionAmount $portions,
public array $allergens,
public Brand $brand,
public IncoBrand $brand,
public bool $isSeasonal,
public ?IncoWeight $weight,
public ?Nutriscore $nutriscore,

View file

@ -6,8 +6,8 @@ namespace Application\Arcadia\Usecase\IncoSheets\FindAll;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoWeight;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -35,7 +35,7 @@ final readonly class PaginatedIncoSheet implements UsecaseOutput
public Ingredients $ingredients,
public ?PortionAmount $portions,
public array $allergens,
public Brand $brand,
public IncoBrand $brand,
public bool $isSeasonal,
public ?IncoWeight $weight,
public ?Nutriscore $nutriscore,

View file

@ -4,26 +4,22 @@ declare(strict_types=1);
namespace Application\Arcadia\Usecase\IncoSheets\Search;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Shared\DDD\Usecase\Input as UsecaseInput;
use Shared\Pagination;
final readonly class Input implements UsecaseInput
{
/** @var array<string> */
/** @var array<BrandCode> */
public array $brands;
public function __construct(
public IncoIdQuery $query,
public bool $short = false,
public ?Pagination $pagination = null,
string ...$brands,
BrandCode ...$brands,
) {
$this->brands = $brands;
foreach ($this->brands as $brand) {
if (trim($brand) === '') {
throw new \InvalidArgumentException('Every item in brands array must be a non empty string.');
}
}
}
}

View file

@ -6,8 +6,8 @@ namespace Application\Arcadia\Usecase\IncoSheets\Search;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoWeight;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -35,7 +35,7 @@ final readonly class PaginatedIncoSheet implements UsecaseOutput
public Ingredients $ingredients,
public ?PortionAmount $portions,
public array $allergens,
public Brand $brand,
public IncoBrand $brand,
public bool $isSeasonal,
public ?IncoWeight $weight,
public ?Nutriscore $nutriscore,

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Domain\Models\Entities\Inco;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoWeight;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -35,7 +35,7 @@ final readonly class IncoSheet implements Entity
public Ingredients $ingredients,
public ?PortionAmount $portions,
public array $allergens,
public Brand $brand,
public IncoBrand $brand,
public bool $isSeasonal,
public ?IncoWeight $weight,
public ?Nutriscore $nutriscore,
@ -70,7 +70,7 @@ final readonly class IncoSheet implements Entity
Ingredients $ingredients,
?PortionAmount $portions,
array $allergens,
Brand $brand,
IncoBrand $brand,
bool $isSeasonal,
?IncoWeight $weight,
?Nutriscore $nutriscore,

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Inco;
final readonly class Brand
{
public function __construct(
public string $code,
public string $label,
) {
if (empty(trim($code))) {
throw new \DomainException('Brand code must not be empty.');
}
if (empty(trim($label))) {
throw new \DomainException('Brand label must not be empty.');
}
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Inco;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
final readonly class IncoBrand
{
public function __construct(
public BrandName $name,
public BrandCode $code,
public ArcadiaId $arcadiaBrandCode,
) {
}
}

View file

@ -4,21 +4,19 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Inco;
use Domain\Models\ValueObjects\Brand\BrandCode;
final readonly class IncoSheetFilter
{
/** @var array<string> */
/** @var array<BrandCode> */
public array $brands;
public function __construct(
public ?\DateTimeInterface $since = null,
string ...$brands,
BrandCode ...$brands,
) {
$this->brands = $brands;
if (array_any($this->brands, static fn (string $brand): bool => trim($brand) === '')) {
throw new \InvalidArgumentException('Every item in brands array must be a non empty string.');
}
if (empty($this->brands) && $since === null) {
throw new \DomainException('IncoSheetFilter requires at least one filtering criterion.');
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Domain\Ports\Directories;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -27,7 +28,7 @@ interface IncoSheetDirectory extends Directory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands
BrandCode ...$brands
): Paginated;
public function find(IncoId $id): ?IncoSheet;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Domain\Ports\Directories;
use Domain\Models\Entities\Inco\IncoShortSheet;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -27,7 +28,7 @@ interface IncoShortSheetDirectory extends Directory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands
BrandCode ...$brands
): Paginated;
public function find(IncoId $id): ?IncoShortSheet;

View file

@ -6,7 +6,6 @@ namespace Domain\Services;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Ports\Repositories\BrandRepository;
@ -27,16 +26,24 @@ final readonly class BrandResolver
}
/**
* Resolve or generate/persist a Brand entity from an ArcadiaBrand.
* Resolve or generate/persist a Brand entity given an Arcadia brand code (ArcadiaId).
*
* @throws BrandNotFoundException
*/
public function resolve(ArcadiaBrand $arcadiaBrand): Brand
public function resolve(ArcadiaId $arcadiaBrandCode): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrand->id);
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrandCode);
if ($existingBrand !== null) {
return $existingBrand;
}
$arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode);
if ($arcadiaBrand === null) {
throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode);
}
do {
$code = $this->brandCodeGenerator->generate($arcadiaBrand->name);
} while ($this->brandRepository->findByCode($code) !== null);
@ -66,43 +73,4 @@ final readonly class BrandResolver
return $brand;
}
/**
* Resolve or generate/persist a Brand entity given an ArcadiaId.
*
* @throws BrandNotFoundException
*/
public function resolveByArcadiaId(ArcadiaId $arcadiaId): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaId);
if ($existingBrand !== null) {
return $existingBrand;
}
$arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaId);
if ($arcadiaBrand === null) {
throw BrandNotFoundException::forArcadiaId($arcadiaId);
}
return $this->resolve($arcadiaBrand);
}
/**
* Resolve all brands known in Arcadia directory.
*
* @return list<Brand>
*/
public function resolveAll(): array
{
$arcadiaBrands = $this->arcadiaBrandDirectory->findAll();
$brands = [];
foreach ($arcadiaBrands as $arcadiaBrand) {
$brands[] = $this->resolve($arcadiaBrand);
}
return $brands;
}
}

View file

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\Additive;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Allergen;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -21,12 +23,21 @@ use Domain\Models\ValueObjects\Inco\Nutriscore;
use Domain\Models\ValueObjects\Inco\PortionAmount;
use Domain\Models\ValueObjects\Inco\StorageCondition;
use Domain\Ports\Directories\IncoSheetDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Services\BrandResolver;
use Infra\Database\Models\IncoSheet as IncoSheetModel;
use Shared\Helpers\DatesHelper;
use Shared\Paginated;
use Shared\Pagination;
final class EloquentIncoSheetDirectory implements IncoSheetDirectory
{
public function __construct(
private BrandResolver $brandResolver,
private BrandRepository $brandRepository,
) {
}
#[\Override]
public function findAll(
Pagination $pagination,
@ -44,7 +55,8 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
}
if (! empty($filter->brands)) {
$query->whereIn('brand->code', $filter->brands);
$resolvedBrands = $this->resolveBrandFilterCodes(...$filter->brands);
$query->whereIn('brand->code', $resolvedBrands);
}
}
@ -73,14 +85,15 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands
BrandCode ...$brands
): Paginated {
$dbQuery = IncoSheetModel::query();
$dbQuery->where('id', 'LIKE', $query->value);
if (! empty($brands)) {
$dbQuery->whereIn('brand->code', $brands);
$resolvedBrands = $this->resolveBrandFilterCodes(...$brands);
$dbQuery->whereIn('brand->code', $resolvedBrands);
}
$total = (int) (clone $dbQuery)->count();
@ -169,10 +182,18 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
{
$raw = is_array($model->ingredients) ? $model->ingredients : [];
/** @var array<int|string, array<string>> $ingredientsGroups */
$ingredientsGroups = $raw;
/** @var array<string, list<string>> $cleanIngredients */
$cleanIngredients = [];
return new Ingredients($ingredientsGroups);
foreach ($raw as $key => $values) {
if (is_string($key) && is_array($values)) {
$cleanIngredients[$key] = array_values(
array_filter($values, static fn ($item): bool => is_string($item))
);
}
}
return new Ingredients($cleanIngredients);
}
private function portionsFrom(IncoSheetModel $model): ?PortionAmount
@ -184,19 +205,22 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
}
return new PortionAmount(
lowPart: $this->getFloat($raw, 'lowPart'),
highPart: $this->getFloat($raw, 'highPart'),
approximative: (bool) ($raw['approximative'] ?? false),
lowPart: $this->getInt($raw, 'lowPart'),
highPart: $this->getInt($raw, 'highPart'),
approximative: $this->getBool($raw, 'approximative'),
);
}
/** @return \Domain\Models\ValueObjects\Inco\Allergen[] */
/**
* @return array<Allergen>
*/
private function allergensFrom(IncoSheetModel $model): array
{
$allergensRaw = is_array($model->allergens) ? $model->allergens : [];
$allergens = [];
$raw = is_array($model->allergens) ? $model->allergens : [];
foreach ($allergensRaw as $a) {
$allergens = [];
foreach ($raw as $a) {
if (is_array($a) && isset($a['code'], $a['label']) && is_numeric($a['code']) && is_string($a['label'])) {
$allergens[] = new Allergen((int) $a['code'], $a['label']);
}
@ -205,13 +229,20 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
return $allergens;
}
private function brandFrom(IncoSheetModel $model): Brand
private function brandFrom(IncoSheetModel $model): IncoBrand
{
$raw = is_array($model->brand) ? $model->brand : [];
$raw = is_array($model->brand) ? $model->brand : [];
$rawCode = isset($raw['code']) && (is_string($raw['code']) || is_numeric($raw['code']))
? (string) $raw['code']
: '1';
return new Brand(
code: $this->getString($raw, 'code', 'M01'),
label: $this->getString($raw, 'label', 'Standard'),
$arcadiaId = new ArcadiaId(is_numeric($rawCode) ? (int) $rawCode : 1);
$brand = $this->brandResolver->resolve($arcadiaId);
return new IncoBrand(
name: $brand->name,
code: $brand->code,
arcadiaBrandCode: $brand->arcadiaId,
);
}
@ -238,9 +269,13 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
return null;
}
return new Nutriscore(
grade: $this->getString($raw, 'grade', 'A'),
);
$grade = $this->getString($raw, 'grade');
if ($grade === '') {
return null;
}
return new Nutriscore($grade);
}
private function storageConditionFrom(IncoSheetModel $model): ?StorageCondition
@ -251,20 +286,27 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
return null;
}
return new StorageCondition(
instructions: $this->getString($raw, 'instructions', ''),
);
$instructions = $this->getString($raw, 'instructions');
if ($instructions === '') {
return null;
}
return new StorageCondition($instructions);
}
/** @return \Domain\Models\ValueObjects\Inco\Additive[] */
/**
* @return array<Additive>
*/
private function additivesFrom(IncoSheetModel $model): array
{
$additivesRaw = is_array($model->additives) ? $model->additives : [];
$additives = [];
$raw = is_array($model->additives) ? $model->additives : [];
foreach ($additivesRaw as $ad) {
if (is_array($ad) && isset($ad['code'], $ad['label']) && is_scalar($ad['code']) && is_scalar($ad['label'])) {
$additives[] = new Additive((string) $ad['code'], (string) $ad['label']);
$additives = [];
foreach ($raw as $a) {
if (is_array($a) && isset($a['code'], $a['label']) && is_string($a['code']) && is_string($a['label'])) {
$additives[] = new Additive($a['code'], $a['label']);
}
}
@ -272,86 +314,97 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory
}
/**
* @param array<mixed> $data
* @return array<int|string>
*/
private function getString(array $data, string $key, string $default = ''): string
private function resolveBrandFilterCodes(BrandCode ...$brandCodes): array
{
$val = $data[$key] ?? null;
$resolved = [];
return is_scalar($val) ? (string) $val : $default;
}
private function toNullableString(mixed $val): ?string
{
return is_scalar($val) ? (string) $val : null;
}
/**
* @param array<mixed> $data
* @return array<string>
*/
private function getArrayOfStrings(array $data, string $key): array
{
$val = $data[$key] ?? null;
if (! is_array($val)) {
return [];
}
$result = [];
foreach ($val as $item) {
if (is_string($item)) {
$result[] = $item;
foreach ($brandCodes as $brandCode) {
$brand = $this->brandRepository->findByCode($brandCode);
if ($brand !== null) {
$resolved[] = $brand->arcadiaId->value;
$resolved[] = (string) $brand->arcadiaId->value;
}
}
return $result;
}
/**
* @param array<mixed> $data
*/
private function getInt(array $data, string $key, int $default = 0): int
{
$val = $data[$key] ?? null;
return is_numeric($val) ? (int) $val : $default;
}
/**
* @param array<mixed> $data
*/
private function getFloat(array $data, string $key, float $default = 0.0): float
{
$val = $data[$key] ?? null;
return is_numeric($val) ? (float) $val : $default;
}
private function toDateTime(\DateTimeInterface | string $value): \DateTimeInterface
{
if ($value instanceof \DateTimeInterface) {
return $value;
if (empty($resolved) && ! empty($brandCodes)) {
return [0];
}
return new \DateTimeImmutable($value);
}
private function toNullableDateTime(\DateTimeInterface | string | null $value): ?\DateTimeInterface
{
if ($value === null) {
return null;
$unique = [];
foreach ($resolved as $item) {
if (! in_array($item, $unique, true)) {
$unique[] = $item;
}
}
return $this->toDateTime($value);
return $unique;
}
private function toSqlColumn(IncoSheetSortColumn $sortBy): string
private function toSqlColumn(IncoSheetSortColumn $column): string
{
return match ($sortBy) {
return match ($column) {
IncoSheetSortColumn::incoSheetId => 'id',
IncoSheetSortColumn::createdAt => 'created_at',
IncoSheetSortColumn::updatedAt => 'updated_at',
};
}
/**
* @param array<array-key, mixed> $data
* @return array<string>
*/
private function getArrayOfStrings(array $data, string $key): array
{
if (! isset($data[$key]) || ! is_array($data[$key])) {
return [];
}
return array_values(
array_filter($data[$key], static fn ($item): bool => is_string($item))
);
}
/**
* @param array<array-key, mixed> $data
*/
private function getString(array $data, string $key, string $default = ''): string
{
return isset($data[$key]) && is_string($data[$key]) ? $data[$key] : $default;
}
/**
* @param array<array-key, mixed> $data
*/
private function getInt(array $data, string $key, int $default = 0): int
{
return isset($data[$key]) && is_numeric($data[$key]) ? (int) $data[$key] : $default;
}
/**
* @param array<array-key, mixed> $data
*/
private function getBool(array $data, string $key, bool $default = false): bool
{
return isset($data[$key]) ? (bool) $data[$key] : $default;
}
private function toNullableString(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? $value : null;
}
private function toNullableDateTime(mixed $value): ?\DateTimeInterface
{
if ($value instanceof \DateTimeInterface) {
return $value;
}
if (is_string($value)) {
return DatesHelper::parseDateTime($value);
}
return null;
}
}

View file

@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Domain\Models\Entities\Inco\IncoShortSheet;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSorter;
use Domain\Ports\Directories\IncoShortSheetDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Infra\Database\Models\IncoSheet as IncoSheetModel;
use Shared\Helpers\DatesHelper;
use Shared\Paginated;
@ -21,6 +23,11 @@ final class EloquentIncoShortSheetDirectory implements IncoShortSheetDirectory
/** @var list<string> */
private const array SHORT_FIELDS = ['id', 'name', 'label', 'created_at', 'updated_at'];
public function __construct(
private BrandRepository $brandRepository,
) {
}
#[\Override]
public function findAll(
Pagination $pagination,
@ -38,7 +45,8 @@ final class EloquentIncoShortSheetDirectory implements IncoShortSheetDirectory
}
if (! empty($filter->brands)) {
$query->whereIn('brand->code', $filter->brands);
$resolvedBrands = $this->resolveBrandFilterCodes(...$filter->brands);
$query->whereIn('brand->code', $resolvedBrands);
}
}
@ -66,14 +74,15 @@ final class EloquentIncoShortSheetDirectory implements IncoShortSheetDirectory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands
BrandCode ...$brands
): Paginated {
$dbQuery = IncoSheetModel::query()->select(self::SHORT_FIELDS);
$dbQuery->where('id', 'LIKE', $query->value);
if (! empty($brands)) {
$dbQuery->whereIn('brand->code', $brands);
$resolvedBrands = $this->resolveBrandFilterCodes(...$brands);
$dbQuery->whereIn('brand->code', $resolvedBrands);
}
$total = (int) (clone $dbQuery)->count();
@ -107,6 +116,35 @@ final class EloquentIncoShortSheetDirectory implements IncoShortSheetDirectory
return $this->toDomain($model);
}
/**
* @return array<int|string>
*/
private function resolveBrandFilterCodes(BrandCode ...$brandCodes): array
{
$resolved = [];
foreach ($brandCodes as $brandCode) {
$brand = $this->brandRepository->findByCode($brandCode);
if ($brand !== null) {
$resolved[] = $brand->arcadiaId->value;
$resolved[] = (string) $brand->arcadiaId->value;
}
}
if (empty($resolved) && ! empty($brandCodes)) {
return [0];
}
$unique = [];
foreach ($resolved as $item) {
if (! in_array($item, $unique, true)) {
$unique[] = $item;
}
}
return $unique;
}
private function toDomain(IncoSheetModel $model): IncoShortSheet
{
$rawId = $model->getAttribute('id');

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -57,8 +58,17 @@ final class InMemoryIncoSheetDirectory implements IncoSheetDirectory
}
}
if (! empty($filter->brands) && ! in_array($sheet->brand->code, $filter->brands, true)) {
return false;
if (! empty($filter->brands)) {
$matched = false;
foreach ($filter->brands as $brandCode) {
if ($sheet->brand->code->value === $brandCode->value) {
$matched = true;
break;
}
}
if (! $matched) {
return false;
}
}
return true;
@ -88,7 +98,7 @@ final class InMemoryIncoSheetDirectory implements IncoSheetDirectory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands,
BrandCode ...$brands,
): Paginated {
$regex = '/^' . str_replace('%', '.*', preg_quote($query->value, '/')) . '$/';
@ -97,8 +107,17 @@ final class InMemoryIncoSheetDirectory implements IncoSheetDirectory
return false;
}
if (! empty($brands) && ! in_array($sheet->brand->code, $brands, true)) {
return false;
if (! empty($brands)) {
$matched = false;
foreach ($brands as $brandCode) {
if ($sheet->brand->code->value === $brandCode->value) {
$matched = true;
break;
}
}
if (! $matched) {
return false;
}
}
return true;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Domain\Models\Entities\Inco\IncoShortSheet;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -84,7 +85,7 @@ final class InMemoryIncoShortSheetDirectory implements IncoShortSheetDirectory
public function search(
IncoIdQuery $query,
Pagination $pagination,
string ...$brands,
BrandCode ...$brands,
): Paginated {
$regex = '/^' . str_replace('%', '.*', preg_quote($query->value, '/')) . '$/';

View file

@ -11,14 +11,24 @@ use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Illuminate\Support\Facades\Cache;
use Infra\Database\Models\Brand as BrandModel;
use Ramsey\Uuid\Uuid;
final class EloquentBrandRepository implements BrandRepository
{
private const int CACHE_TTL_SECONDS = 86400;
#[\Override]
public function find(BrandId $id): ?Brand
{
$key = "brand:id:{$id->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->find($id->value);
@ -27,12 +37,22 @@ final class EloquentBrandRepository implements BrandRepository
return null;
}
return $this->toDomain($model);
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
return $brand;
}
#[\Override]
public function findByCode(BrandCode $code): ?Brand
{
$key = "brand:code:{$code->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->where('uwil_public_id', $code->value)
@ -42,12 +62,22 @@ final class EloquentBrandRepository implements BrandRepository
return null;
}
return $this->toDomain($model);
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
return $brand;
}
#[\Override]
public function findByArcadiaId(ArcadiaId $arcadiaId): ?Brand
{
$key = "brand:arcadia:{$arcadiaId->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->where('arcadia_id', $arcadiaId->value)
@ -57,19 +87,43 @@ final class EloquentBrandRepository implements BrandRepository
return null;
}
return $this->toDomain($model);
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
return $brand;
}
#[\Override]
public function findAll(): array
{
return array_values(
$key = 'brand:all';
$data = Cache::get($key);
if (is_array($data)) {
$brands = [];
foreach ($data as $item) {
$brand = $this->fromCacheData($item);
if ($brand !== null) {
$brands[] = $brand;
}
}
if (! empty($brands) && count($brands) === count($data)) {
return $brands;
}
}
$brands = array_values(
BrandModel::query()
->with('arcadiaBrand')
->get()
->map(fn (BrandModel $model): Brand => $this->toDomain($model))
->all()
);
$cachedList = array_map(fn (Brand $brand): array => $this->toCacheData($brand), $brands);
Cache::put($key, $cachedList, self::CACHE_TTL_SECONDS);
return $brands;
}
#[\Override]
@ -82,6 +136,8 @@ final class EloquentBrandRepository implements BrandRepository
'uwil_public_id' => $entity->code,
]
);
$this->invalidateCache($entity);
}
#[\Override]
@ -94,6 +150,54 @@ final class EloquentBrandRepository implements BrandRepository
if ($deleted === 0) {
throw BrandNotFoundException::forBrand($entity->id());
}
$this->invalidateCache($entity);
}
private function invalidateCache(Brand $entity): void
{
Cache::forget('brand:all');
Cache::forget("brand:id:{$entity->id()->value}");
Cache::forget("brand:code:{$entity->code->value}");
Cache::forget("brand:arcadia:{$entity->arcadiaId->value}");
}
/**
* @return array{id: string, name: string, code: string, arcadiaId: int}
*/
private function toCacheData(Brand $brand): array
{
return [
'id' => (string) $brand->id()->value,
'name' => $brand->name->value,
'code' => $brand->code->value,
'arcadiaId' => $brand->arcadiaId->value,
];
}
private function fromCacheData(mixed $data): ?Brand
{
if (
! is_array($data)
|| ! isset($data['id'], $data['name'], $data['code'], $data['arcadiaId'])
|| ! is_string($data['id'])
|| ! is_string($data['name'])
|| ! is_string($data['code'])
|| ! is_int($data['arcadiaId'])
) {
return null;
}
try {
return Brand::hydrate(
id: new BrandId(Uuid::fromString($data['id'])),
name: new BrandName($data['name']),
code: new BrandCode($data['code']),
arcadiaId: new ArcadiaId($data['arcadiaId']),
);
} catch (\Throwable) {
return null;
}
}
private function toDomain(BrandModel $model): Brand

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Dto\IncoSheets\Parts;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Presentation\Api\V1\Contracts\ApiResponseDto;
final readonly class BrandDto implements ApiResponseDto
@ -15,11 +15,11 @@ final readonly class BrandDto implements ApiResponseDto
) {
}
public static function fromDomain(Brand $brand): self
public static function fromDomain(IncoBrand $brand): self
{
return new self(
code: $brand->code,
label: $brand->label,
code: $brand->code->value,
label: $brand->name->value,
);
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\IncoSheets;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSorter;
@ -47,22 +48,22 @@ final class IncoSheetsIndexRequest extends FormRequest
{
$since = $this->filled('since') ? DatesHelper::parseDateTime($this->string('since')->value()) : null;
$brands = [];
$rawBrands = $this->array('brands');
$brandCodes = [];
$rawBrands = $this->array('brands');
foreach ($rawBrands as $b) {
if (is_string($b) || is_numeric($b)) {
$val = trim((string) $b);
if (is_string($b)) {
$val = trim($b);
if ($val !== '') {
$brands[] = $val;
$brandCodes[] = new BrandCode($val);
}
}
}
if (empty($brands) && $since === null) {
if (empty($brandCodes) && $since === null) {
return null;
}
return new IncoSheetFilter($since, ...$brands);
return new IncoSheetFilter($since, ...$brandCodes);
}
public function sortBy(): IncoSheetSortColumn

View file

@ -4,11 +4,13 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\IncoSheets;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @method \Shared\Pagination pagination(int $defaultPageSize = 25)
* @method \Uri\Rfc3986\Uri toUri()
*/
final class IncoSheetsSearchRequest extends FormRequest
@ -29,16 +31,16 @@ final class IncoSheetsSearchRequest extends FormRequest
return new IncoIdQuery($this->string('incoIdLike')->value());
}
/** @return array<string> */
/** @return array<BrandCode> */
public function brands(): array
{
$raw = $this->array('brands');
$result = [];
foreach ($raw as $b) {
if (is_string($b) || is_numeric($b)) {
$val = trim((string) $b);
if (is_string($b)) {
$val = trim($b);
if ($val !== '') {
$result[] = $val;
$result[] = new BrandCode($val);
}
}
}

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Tests\Feature\Api\V1\IncoSheets;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Services\BrandResolver;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\IncoSheet;
describe('IncoSheets Index Feature API', function (): void {
@ -75,6 +78,42 @@ describe('IncoSheets Index Feature API', function (): void {
['updatedAt', 'Ascending'],
]);
it('returns 200 OK with matching sheets filtered by BrandCode', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
ArcadiaBrand::query()->firstOrCreate(['code' => 504], ['libelle' => 'LE GAULOIS PROFESSIONNEL']);
$brand = app(BrandResolver::class)->resolve(new ArcadiaId(504));
IncoSheet::factory()->create([
'id' => '9999001',
'name' => 'Produit Gaulois Pro',
'brand' => ['code' => 504, 'label' => 'LE GAULOIS PROFESSIONNEL'],
]);
// WHEN
$response = $test->getJson("/api/v1/inco-sheets?brands[]={$brand->code->value}");
// THEN
$response->assertStatus(200);
$response->assertJsonPath('incoSheets.0.id', '9999001')
->assertJsonPath('incoSheets.0.brand.code', $brand->code->value)
->assertJsonPath('incoSheets.0.brand.label', 'LE GAULOIS PROFESSIONNEL');
});
it('returns 422 Unprocessable Entity when brand code format is invalid', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
// WHEN
$response = $test->getJson('/api/v1/inco-sheets?brands[]=invalid');
// THEN
$response->assertStatus(422);
});
it('returns 200 OK with short form inco sheets when short parameter is true', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Tests\Feature\Api\V1\IncoSheets;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Services\BrandResolver;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\IncoSheet;
describe('IncoSheets Search Feature API', function (): void {
@ -11,13 +14,13 @@ describe('IncoSheets Search Feature API', function (): void {
IncoSheet::factory()->create([
'id' => '1000101',
'name' => 'Poulet Rôti 1kg',
'brand' => ['code' => 'M01', 'label' => 'Standard'],
'brand' => ['code' => 1, 'label' => 'Standard'],
]);
IncoSheet::factory()->create([
'id' => '1000102',
'name' => 'Poulet Fermier',
'brand' => ['code' => 'M02', 'label' => 'Bio'],
'brand' => ['code' => 2, 'label' => 'Bio'],
]);
});
@ -51,19 +54,36 @@ describe('IncoSheets Search Feature API', function (): void {
$response->assertJsonCount(1, 'incoSheets');
});
it('returns 200 OK with matching sheets filtered by brand', function (): void {
it('returns 200 OK with matching sheets filtered by BrandCode', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$query = urlencode('%10001%');
ArcadiaBrand::query()->firstOrCreate(['code' => 1], ['libelle' => 'Standard']);
$brand = app(BrandResolver::class)->resolve(new ArcadiaId(1));
// WHEN
$response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&brands[]={$brand->code->value}");
// THEN
$response->assertStatus(200);
$response->assertJsonCount(1, 'incoSheets');
$response->assertJsonPath('incoSheets.0.id', '1000101')
->assertJsonPath('incoSheets.0.brand.code', $brand->code->value);
});
it('returns 422 Unprocessable Entity when brand code format is invalid', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$query = urlencode('%10001%');
// WHEN
$response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&brands[]=M01");
$response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&brands[]=invalid");
// THEN
$response->assertStatus(200);
$response->assertJsonCount(1, 'incoSheets');
$response->assertJsonPath('incoSheets.0.id', '1000101');
$response->assertStatus(422);
});
it('returns 200 OK with short form inco sheets when short parameter is true', function (): void {

View file

@ -56,7 +56,7 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
$brandRepository->add($existingBrand);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(1, 25));
// WHEN
@ -91,7 +91,7 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
$arcadiaDirectory->add($arcadia1, $arcadia2, $arcadia3);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(2, 2));
// WHEN
@ -108,7 +108,7 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
it('returns an empty list when there are no arcadia brands', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(1, 25));
// WHEN

View file

@ -10,8 +10,11 @@ use Application\Arcadia\Usecase\IncoSheets\FindAll\PaginatedIncoSheet;
use Application\Arcadia\Usecase\IncoSheets\FindAll\PaginatedShortIncoSheet;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\Entities\Inco\IncoShortSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSorter;
@ -55,7 +58,7 @@ describe('FindAllIncoSheets DTOs Instantiation & Logic', function (): void {
ingredients: new Ingredients(['main' => ['Viande']]),
portions: null,
allergens: [],
brand: new Brand('M01', 'Le Gaulois'),
brand: new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(1)),
isSeasonal: true,
weight: null,
nutriscore: null,

View file

@ -10,8 +10,11 @@ use Application\Arcadia\Usecase\IncoSheets\FindAll\Output;
use Application\Arcadia\Usecase\IncoSheets\FindAll\PaginatedIncoSheet;
use Application\Arcadia\Usecase\IncoSheets\FindAll\PaginatedShortIncoSheet;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSorter;
@ -53,7 +56,7 @@ describe('FindAllIncoSheets Usecase Unit Tests', function (): void {
ingredients: new Ingredients(['Ingrédients' => ['Viande de dinde (66%)']]),
portions: null,
allergens: [],
brand: new Brand('M01', 'Le Gaulois'),
brand: new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(1)),
isSeasonal: true,
weight: null,
nutriscore: null,

View file

@ -6,8 +6,11 @@ namespace Tests\Unit\Application\Arcadia\Usecase\IncoSheets;
use Application\Arcadia\Usecase\IncoSheets\Find\Input;
use Application\Arcadia\Usecase\IncoSheets\Find\Output;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -43,7 +46,7 @@ describe('FindIncoSheet DTO Instantiation', function (): void {
ingredients: new Ingredients(['main' => ['Viande']]),
portions: null,
allergens: [],
brand: new Brand('M01', 'Le Gaulois'),
brand: new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(1)),
isSeasonal: true,
weight: null,
nutriscore: null,

View file

@ -9,8 +9,11 @@ use Application\Arcadia\Usecase\IncoSheets\Find\FindIncoSheet;
use Application\Arcadia\Usecase\IncoSheets\Find\Input;
use Application\Arcadia\Usecase\IncoSheets\Find\Output;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\Ingredients;
use Infra\Adapters\Directories\InMemoryIncoSheetDirectory;
@ -44,7 +47,7 @@ describe('FindIncoSheet Usecase Unit Tests', function (): void {
ingredients: new Ingredients(['Ingrédients' => ['Viande de dinde (66%)']]),
portions: null,
allergens: [],
brand: new Brand('M01', 'Le Gaulois'),
brand: new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(1)),
isSeasonal: true,
weight: null,
nutriscore: null,
@ -67,7 +70,8 @@ describe('FindIncoSheet Usecase Unit Tests', function (): void {
->and($result->id)->toBe($incoId)
->and($result->name)->toBe('Poulet Rôti 1kg')
->and($result->label)->toBe('Poulet Rôti Classe A')
->and($result->brand->label)->toBe('Le Gaulois')
->and($result->brand->name->value)->toBe('Le Gaulois')
->and($result->brand->code->value)->toBe('LGL01')
->and($result->isSeasonal)->toBeTrue();
});

View file

@ -11,8 +11,11 @@ use Application\Arcadia\Usecase\IncoSheets\Search\PaginatedShortIncoSheet;
use Application\Arcadia\Usecase\IncoSheets\Search\SearchIncoSheets;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\Entities\Inco\IncoShortSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\Ingredients;
@ -52,7 +55,7 @@ describe('SearchIncoSheets Usecase Unit Tests', function (): void {
ingredients: new Ingredients(['Ingrédients' => ['Viande de dinde (66%)']]),
portions: null,
allergens: [],
brand: new Brand('M01', 'Le Gaulois'),
brand: new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(1)),
isSeasonal: true,
weight: null,
nutriscore: null,
@ -69,7 +72,7 @@ describe('SearchIncoSheets Usecase Unit Tests', function (): void {
query: new IncoIdQuery('12%'),
short: false,
pagination: Pagination::ofPage(1, 10),
brands: 'M01',
brands: new BrandCode('LGL01'),
);
// WHEN

View file

@ -5,8 +5,11 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Entities;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\Ingredients;
use Shared\DDD\Model\Entity;
@ -19,7 +22,7 @@ describe('IncoSheet Domain Entity Unit Tests', function (): void {
$id = new IncoId('12345');
$allegations = new Allegations(['Volaille française']);
$ingredients = new Ingredients(['Ingrédients' => ['Viande de dinde (66%)']]);
$brand = new Brand('M01', 'Le Gaulois');
$brand = new IncoBrand(new BrandName('Le Gaulois'), new BrandCode('LGL01'), new ArcadiaId(504));
// WHEN
$sheet = IncoSheet::hydrate(
@ -50,7 +53,9 @@ describe('IncoSheet Domain Entity Unit Tests', function (): void {
expect($sheet)->toBeInstanceOf(Entity::class)
->and($sheet->id())->toBe($id)
->and($sheet->name)->toBe('Poulet Rôti 1kg')
->and($sheet->brand->label)->toBe('Le Gaulois')
->and($sheet->brand->name->value)->toBe('Le Gaulois')
->and($sheet->brand->code->value)->toBe('LGL01')
->and($sheet->brand->arcadiaBrandCode->value)->toBe(504)
->and($sheet->isSeasonal)->toBeTrue();
});
});

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Inco;
use Domain\Models\ValueObjects\Inco\Brand;
covers(Brand::class);
describe('Brand Value Object Unit Tests', function (): void {
it('instantiates valid Brand VO', function (): void {
// GIVEN
$code = 'M01';
$label = 'Le Gaulois';
// WHEN
$brand = new Brand($code, $label);
// THEN
expect($brand->code)->toBe('M01')
->and($brand->label)->toBe('Le Gaulois')
->and($brand->code)->toBe('M01')
->and($brand->label)->toBe('Le Gaulois');
});
it('throws DomainException when code is empty', function (): void {
// GIVEN / WHEN / THEN
expect(fn () => new Brand(' ', 'Le Gaulois'))
->toThrow(\DomainException::class, 'Brand code must not be empty.');
});
it('throws DomainException when label is empty', function (): void {
// GIVEN / WHEN / THEN
expect(fn () => new Brand('M01', ' '))
->toThrow(\DomainException::class, 'Brand label must not be empty.');
});
});

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Inco;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\IncoBrand;
covers(IncoBrand::class);
describe('IncoBrand Value Object Unit Tests', function (): void {
it('instantiates valid IncoBrand VO', function (): void {
// GIVEN
$name = new BrandName('Le Gaulois');
$code = new BrandCode('LGL01');
$arcadiaCode = new ArcadiaId(504);
// WHEN
$brand = new IncoBrand($name, $code, $arcadiaCode);
// THEN
expect($brand->name->value)->toBe('Le Gaulois')
->and($brand->code->value)->toBe('LGL01')
->and($brand->arcadiaBrandCode->value)->toBe(504);
});
});

View file

@ -4,34 +4,30 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Inco;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
covers(IncoSheetFilter::class);
describe('IncoSheetFilter Value Object', function (): void {
it('can be instantiated with brand list filter', function (): void {
it('can be instantiated with BrandCode list filter', function (): void {
// GIVEN
$brands = ['M01', 'M02'];
$code1 = new BrandCode('LGL01');
$code2 = new BrandCode('MCQ01');
// WHEN
$filter = new IncoSheetFilter(null, ...$brands);
$filter = new IncoSheetFilter(null, $code1, $code2);
// THEN
expect($filter->brands)->toBe(['M01', 'M02']);
expect($filter->brands)->toHaveCount(2)
->and($filter->brands[0])->toBe($code1)
->and($filter->brands[1])->toBe($code2);
});
it('throws DomainException when instantiated with all null parameters', function (): void {
// GIVEN, WHEN & THEN
expect(fn () => new IncoSheetFilter())
// THENE
->toThrow(\DomainException::class, 'IncoSheetFilter requires at least one filtering criterion.');
});
it('throws InvalidArgumentException when brands array contains an empty string', function (): void {
// GIVEN
$invalidBrands = ['M01', ''];
// WHEN & THEN
expect(fn () => new IncoSheetFilter(null, ...$invalidBrands))
->toThrow(\InvalidArgumentException::class, 'Every item in brands array must be a non empty string.');
});
});

View file

@ -41,23 +41,22 @@ describe('BrandResolver Unit Tests', function (): void {
);
$brandRepository->add($existing);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolved = $resolver->resolve($arcadiaBrand);
$resolved = $resolver->resolve(new ArcadiaId(1));
// THEN
expect($resolved)->toBe($existing);
});
it('generates, persists and logs a new brand if not in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('generates, persists and logs a new brand if not in repository but in Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
$arcadiaDirectory->add(new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ')));
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolved = $resolver->resolve($arcadiaBrand);
$resolved = $resolver->resolve(new ArcadiaId(2));
// THEN
expect($resolved->arcadiaId->value)->toBe(2)
@ -67,44 +66,12 @@ describe('BrandResolver Unit Tests', function (): void {
expect($brandRepository->findByArcadiaId(new ArcadiaId(2)))->not->toBeNull();
});
it('resolves by ArcadiaId directly', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(3), new BrandName('Loué'));
$arcadiaDirectory->add($arcadiaBrand);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolved = $resolver->resolveByArcadiaId(new ArcadiaId(3));
// THEN
expect($resolved->arcadiaId->value)->toBe(3)
->and($resolved->name->value)->toBe('Loué');
});
it('throws BrandNotFoundException when resolving by unknown ArcadiaId', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('throws BrandNotFoundException when resolving an unknown ArcadiaId', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN & THEN
expect(fn () => $resolver->resolveByArcadiaId(new ArcadiaId(999)))
expect(fn () => $resolver->resolve(new ArcadiaId(999)))
->toThrow(BrandNotFoundException::class, 'Arcadia brand with ID 999 not found.');
});
it('resolves all brands from Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadiaDirectory->add(
new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois')),
new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ')),
);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolvedList = $resolver->resolveAll();
// THEN
expect($resolvedList)->toHaveCount(2)
->and($brandRepository->findAll())->toHaveCount(2);
});
});

View file

@ -5,8 +5,11 @@ declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Directories;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Inco\Allegations;
use Domain\Models\ValueObjects\Inco\Brand;
use Domain\Models\ValueObjects\Inco\IncoBrand;
use Domain\Models\ValueObjects\Inco\IncoId;
use Domain\Models\ValueObjects\Inco\IncoIdQuery;
use Domain\Models\ValueObjects\Inco\IncoSheetFilter;
@ -19,7 +22,7 @@ use Shared\Pagination;
covers(InMemoryIncoSheetDirectory::class);
describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
function createSheet(string $idStr, string $brandCode, string $brandLabel): IncoSheet
function createSheet(string $idStr, string $brandCode, string $brandLabel, int $arcadiaId = 1): IncoSheet
{
return IncoSheet::hydrate(
id: new IncoId($idStr),
@ -34,7 +37,7 @@ describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
ingredients: new Ingredients(['Ingrédients' => ['Test']]),
portions: null,
allergens: [],
brand: new Brand($brandCode, $brandLabel),
brand: new IncoBrand(new BrandName($brandLabel), new BrandCode($brandCode), new ArcadiaId($arcadiaId)),
isSeasonal: true,
weight: null,
nutriscore: null,
@ -49,7 +52,7 @@ describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
it('finds an inco sheet by id', function (): void {
// GIVEN
$directory = new InMemoryIncoSheetDirectory();
$sheet = createSheet('12345', 'M01', 'Le Gaulois');
$sheet = createSheet('12345', 'LGL01', 'Le Gaulois', 1);
$directory->add($sheet);
// WHEN
@ -64,13 +67,13 @@ describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
it('filters, sorts and paginates inco sheets', function (): void {
// GIVEN
$directory = new InMemoryIncoSheetDirectory();
$sheet1 = createSheet('10001', 'M01', 'Le Gaulois');
$sheet2 = createSheet('10002', 'M02', 'Maître CoQ');
$sheet1 = createSheet('10001', 'LGL01', 'Le Gaulois', 1);
$sheet2 = createSheet('10002', 'MCQ01', 'Maître CoQ', 2);
$directory->add($sheet1, $sheet2);
$pagination = Pagination::ofPage(1, 10);
$sorter = new IncoSheetSorter(IncoSheetSortColumn::incoSheetId, \SortDirection::Descending);
$filter = new IncoSheetFilter(null, 'M01');
$filter = new IncoSheetFilter(null, new BrandCode('LGL01'));
// WHEN
$result = $directory->findAll($pagination, $sorter, $filter);
@ -84,16 +87,16 @@ describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
it('searches inco sheets by id query and brands', function (): void {
// GIVEN
$directory = new InMemoryIncoSheetDirectory();
$sheet1 = createSheet('12345', 'M01', 'Le Gaulois');
$sheet2 = createSheet('12999', 'M01', 'Le Gaulois');
$sheet3 = createSheet('54321', 'M02', 'Maître CoQ');
$sheet1 = createSheet('12345', 'LGL01', 'Le Gaulois', 1);
$sheet2 = createSheet('12999', 'LGL01', 'Le Gaulois', 1);
$sheet3 = createSheet('54321', 'MCQ01', 'Maître CoQ', 2);
$directory->add($sheet1, $sheet2, $sheet3);
$query = new IncoIdQuery('12%');
$pagination = Pagination::ofPage(1, 10);
// WHEN
$result = $directory->search($query, $pagination, 'M01');
$result = $directory->search($query, $pagination, new BrandCode('LGL01'));
// THEN
expect($result->getTotal())->toBe(2)
@ -103,7 +106,7 @@ describe('InMemoryIncoSheetDirectory Unit Tests', function (): void {
it('clears all sheets', function (): void {
// GIVEN
$directory = new InMemoryIncoSheetDirectory();
$sheet = createSheet('12345', 'M01', 'Le Gaulois');
$sheet = createSheet('12345', 'LGL01', 'Le Gaulois', 1);
$directory->add($sheet);
// WHEN