feat(employees): implement filtering and sorting for employee retrieval

Added Filter and Sorter classes to handle employee filtering and sorting criteria. Updated FindAllEmployees use case to utilize these new classes for improved employee data retrieval.
This commit is contained in:
Jérémy Munsch 2026-08-14 09:29:41 +02:00
parent 86e6de84ef
commit 53b0dd9f1e
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
12 changed files with 259 additions and 43 deletions

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\Employees\FindAll;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
final readonly class Filter
{
/** @var array<SiteId> */
public array $sites;
public function __construct(
public ?EstablishmentCode $establishment = null,
public ?ServiceId $service = null,
public ?\DateTimeInterface $since = null,
SiteId ...$sites,
) {
$this->sites = $sites;
if ($establishment === null && empty($this->sites) && $service === null && $since === null) {
throw new \DomainException('Filter requires at least one filtering criterion.');
}
}
}

View file

@ -6,6 +6,9 @@ namespace Application\Usecase\Employees\FindAll;
use Domain\Models\Entities\Employee;
use Domain\Ports\Directories\EmployeeDirectory;
use Domain\Models\ValueObjects\Employees\EmployeeFilter as DirectoryEmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn as DirectoryEmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter as DirectoryEmployeeSorter;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Shared\Paginated;
@ -24,10 +27,24 @@ final readonly class FindAllEmployees implements Usecase
{
Assert::isInstanceOf($input, Input::class);
$filter = $input->filter === null
? null
: new DirectoryEmployeeFilter(
establishment: $input->filter->establishment,
sites: $input->filter->sites,
service: $input->filter->service,
since: $input->filter->since,
);
$sorter = new DirectoryEmployeeSorter(
sortBy: DirectoryEmployeeSortColumn::from($input->sorter->sortBy->value),
direction: $input->sorter->direction,
);
$page = $this->directory->findAll(
$input->pagination,
$input->sorter,
$input->filter
$sorter,
$filter
);
return $this->toOutput($page);

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Application\Usecase\Employees\FindAll;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Shared\DDD\Usecase\Input as UsecaseInput;
use Shared\Pagination;
@ -13,8 +11,8 @@ final readonly class Input implements UsecaseInput
{
public function __construct(
public Pagination $pagination,
public EmployeeSorter $sorter,
public ?EmployeeFilter $filter = null,
public Sorter $sorter,
public ?Filter $filter = null,
) {
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\Employees\FindAll;
enum SortColumn: string
{
case arcadiaId = 'arcadiaId';
case startDate = 'startDate';
case endDate = 'endDate';
case plannedEndDate = 'plannedEndDate';
case createdAt = 'createdAt';
case updatedAt = 'updatedAt';
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\Employees\FindAll;
final readonly class Sorter
{
public function __construct(
public SortColumn $sortBy,
public \SortDirection $direction,
) {
}
}

View file

@ -7,24 +7,23 @@ namespace Domain\Models\ValueObjects\Employees;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Webmozart\Assert\Assert;
final readonly class EmployeeFilter
{
/** @param array<SiteId> $sites */
/**
* @param array<SiteId> $sites
*/
public function __construct(
public ?EstablishmentCode $establishment = null,
public array $sites = [],
public ?ServiceId $service = null,
public ?\DateTimeInterface $since = null,
) {
foreach ($this->sites as $site) {
if (! $site instanceof SiteId) {
throw new \InvalidArgumentException('Every item in sites array must be an instance of SiteId.');
}
}
if ($establishment === null && empty($this->sites) && $service === null && $since === null) {
if ($establishment === null && empty($sites) && $service === null && $since === null) {
throw new \DomainException('EmployeeFilter requires at least one filtering criterion.');
}
Assert::allIsInstanceOf($sites, SiteId::class, 'Every item in sites array must be an instance of SiteId.');
}
}

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Domain\Ports\Directories;
use Domain\Models\Entities\Employee;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Shared\DDD\Directory;
use Shared\Paginated;
use Shared\Pagination;

View file

@ -5,14 +5,14 @@ declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Domain\Models\Entities\Employee;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Domain\Ports\Directories\EmployeeDirectory;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Infra\Database\Models\Employee as EmployeeModel;
use Shared\Paginated;
use Shared\Pagination;

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\Employees;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Application\Usecase\Employees\FindAll\Filter;
use Application\Usecase\Employees\FindAll\SortColumn;
use Application\Usecase\Employees\FindAll\Sorter;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
@ -26,8 +26,8 @@ final class EmployeesIndexRequest extends FormRequest
'page' => ['sometimes', 'integer', 'min:1'],
'pageSize' => ['sometimes', 'integer', 'min:1', 'max:' . Pagination::MAX_PAGE_SIZE],
'sortBy' => ['sometimes', Rule::in(array_map(
static fn (EmployeeSortColumn $case): string => $case->name,
EmployeeSortColumn::cases()
static fn (SortColumn $case): string => $case->name,
SortColumn::cases()
))],
'sortDirection' => ['sometimes', Rule::in(['Ascending', 'Descending'])],
'since' => ['sometimes', 'date'],
@ -59,12 +59,12 @@ final class EmployeesIndexRequest extends FormRequest
);
}
public function sorter(): EmployeeSorter
public function sorter(): Sorter
{
return new EmployeeSorter($this->sortBy(), $this->direction());
return new Sorter($this->sortBy(), $this->direction());
}
public function filter(): ?EmployeeFilter
public function filter(): ?Filter
{
$since = $this->filled('since') ? DatesHelper::parseDateTime($this->string('since')->value()) : null;
$establishment = $this->filled('establishment') ? new EstablishmentCode($this->string('establishment')->value()) : null;
@ -88,25 +88,20 @@ final class EmployeesIndexRequest extends FormRequest
return null;
}
return new EmployeeFilter(
establishment: $establishment,
sites: $sites,
service: $service,
since: $since,
);
return new Filter($establishment, $service, $since, ...$sites);
}
public function sortBy(): EmployeeSortColumn
public function sortBy(): SortColumn
{
$value = $this->string('sortBy', EmployeeSortColumn::arcadiaId->name)->value();
$value = $this->string('sortBy', SortColumn::arcadiaId->name)->value();
return match ($value) {
'startDate' => EmployeeSortColumn::startDate,
'endDate' => EmployeeSortColumn::endDate,
'plannedEndDate' => EmployeeSortColumn::plannedEndDate,
'createdAt' => EmployeeSortColumn::createdAt,
'updatedAt' => EmployeeSortColumn::updatedAt,
default => EmployeeSortColumn::arcadiaId,
'startDate' => SortColumn::startDate,
'endDate' => SortColumn::endDate,
'plannedEndDate' => SortColumn::plannedEndDate,
'createdAt' => SortColumn::createdAt,
'updatedAt' => SortColumn::updatedAt,
default => SortColumn::arcadiaId,
};
}

View file

@ -5,13 +5,13 @@ declare(strict_types=1);
namespace Tests\Integration\Infra\Adapters\Directories;
use Domain\Models\Entities\Employee;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Domain\Models\ValueObjects\Employees\EmployeeFilter;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Adapters\Directories\EloquentEmployeeDirectory;
use Infra\Database\Models\Employee as EmployeeModel;

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Usecase\Employees;
use Application\Usecase\Employees\FindAll\FindAllEmployees;
use Application\Usecase\Employees\FindAll\Input;
use Application\Usecase\Employees\FindAll\Output;
use Application\Usecase\Employees\FindAll\SortColumn;
use Application\Usecase\Employees\FindAll\Sorter;
use Domain\Models\Entities\Employee;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Domain\Ports\Directories\EmployeeDirectory;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn as DirectoryEmployeeSortColumn;
use Domain\Models\ValueObjects\Employees\EmployeeSorter as DirectoryEmployeeSorter;
use Shared\Paginated;
use Shared\Pagination;
covers(FindAllEmployees::class);
describe('FindAllEmployees Usecase Unit Tests', function (): void {
it('executes successfully and returns paginated output DTO', function (): void {
// GIVEN
$pagination = Pagination::ofPage(1, 10);
$sorter = new Sorter(
SortColumn::arcadiaId,
\SortDirection::Ascending
);
$input = new Input($pagination, $sorter);
$employeeDirectory = \Mockery::mock(EmployeeDirectory::class);
$usecase = new FindAllEmployees($employeeDirectory);
$employeeId = new ArcadiaEmployeeId('01155902');
$employee = new Employee(
publicEmployeeNumber: $employeeId,
lastName: 'DOE',
firstName: 'John',
startDate: new \DateTimeImmutable('2024-01-01'),
endDate: null,
plannedEndDate: null,
establishment: new EstablishmentCode('EST01'),
site: new SiteId('LDCSER'),
service: new ServiceId('097'),
createdAt: new \DateTimeImmutable('2024-01-01'),
updatedAt: null,
);
$paginatedResult = new Paginated($pagination, [$employee], 1);
$employeeDirectory->shouldReceive('findAll')
->once()
->withArgs(function (Pagination $actualPagination, DirectoryEmployeeSorter $actualSorter, mixed $actualFilter) use ($pagination): bool {
return $actualPagination === $pagination
&& $actualSorter->sortBy === DirectoryEmployeeSortColumn::arcadiaId
&& $actualSorter->direction === \SortDirection::Ascending
&& $actualFilter === null;
})
->andReturn($paginatedResult);
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->total)->toBe(1)
->and($result->page)->toBe(1)
->and($result->perPage)->toBe(10)
->and($result->employees)->toHaveCount(1)
->and($result->employees[0]->firstName)->toBe('John');
});
});

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Usecase\Employees;
use Application\Exceptions\NotFoundException;
use Application\Usecase\Employees\Find\FindEmployee;
use Application\Usecase\Employees\Find\Input;
use Application\Usecase\Employees\Find\Output;
use Domain\Models\Entities\Employee;
use Domain\Models\ValueObjects\Entities\ArcadiaEmployeeId;
use Domain\Models\ValueObjects\EstablishmentCode;
use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Domain\Ports\Directories\EmployeeDirectory;
covers(FindEmployee::class);
describe('FindEmployee Usecase Unit Tests', function (): void {
it('executes successfully and returns Output when employee exists', function (): void {
// GIVEN
$employeeId = new ArcadiaEmployeeId('01155902');
$input = new Input($employeeId);
$employeeDirectory = \Mockery::mock(EmployeeDirectory::class);
$usecase = new FindEmployee($employeeDirectory);
$employee = new Employee(
publicEmployeeNumber: $employeeId,
lastName: 'DOE',
firstName: 'John',
startDate: new \DateTimeImmutable('2024-01-01'),
endDate: null,
plannedEndDate: null,
establishment: new EstablishmentCode('EST01'),
site: new SiteId('LDCSER'),
service: new ServiceId('097'),
createdAt: new \DateTimeImmutable('2024-01-01'),
updatedAt: null,
);
$employeeDirectory->shouldReceive('find')
->once()
->with($employeeId)
->andReturn($employee);
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->id)->toBe($employeeId)
->and($result->firstName)->toBe('John')
->and($result->lastName)->toBe('DOE')
->and($result->establishment)->toBe('EST01')
->and($result->site)->toBe('LDCSER')
->and($result->service)->toBe('097');
});
it('throws NotFoundException when employee does not exist', function (): void {
// GIVEN
$employeeId = new ArcadiaEmployeeId('01155902');
$input = new Input($employeeId);
$employeeDirectory = \Mockery::mock(EmployeeDirectory::class);
$usecase = new FindEmployee($employeeDirectory);
$employeeDirectory->shouldReceive('find')
->once()
->with($employeeId)
->andReturnNull();
// WHEN & THEN
expect(fn () => $usecase->execute($input))
->toThrow(NotFoundException::class, "Employee '01155902' not found.");
});
});