feat(absence) : migrate Absence domain into module (back)

LST-66 (2.3) backend. Behaviour-preserving move of the absences domain into
src/Module/Absence/. API operations, securities, routes and the 10 MCP tool
names are unchanged.

- 3 entities + 3 enums moved to Domain/{Entity,Enum}; user relations stay on
  UserInterface. 3 repositories split into Domain/Repository interfaces +
  Doctrine impls (bound in services.yaml); find() kept off interfaces
  (findById instead).
- Pure services (AbsenceDayCalculator, PublicHolidayProvider) -> Domain/Service;
  AbsenceBalanceService -> Application/Service; State (5), controllers (5),
  10 MCP tools and AccrueLeaveCommand -> Infrastructure/.
- New LeaveProfileInterface contract (Shared) exposes the HR getters used by
  AbsenceBalanceService/AccrueLeaveCommand; User implements it -> Absence no
  longer imports the concrete Core User. MCP tools/command inject
  UserRepositoryInterface (findById) instead of the concrete repository.
- Timestampable/Blamable added to AbsenceBalance and AbsencePolicy (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT). AbsenceRequest untouched (already has createdAt/reviewedAt).
- AbsenceModule registered (id absence, 4 RBAC perms, not re-wired); doctrine
  mapping added; team-absences sidebar item gated by the module.

161 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 18:32:02 +02:00
parent 7446b7dca9
commit 306cfd34cd
51 changed files with 514 additions and 209 deletions
+2
View File
@@ -7,6 +7,7 @@ declare(strict_types=1);
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules. * Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/ */
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\ProjectManagement\ProjectManagementModule; use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\TimeTracking\TimeTrackingModule; use App\Module\TimeTracking\TimeTrackingModule;
@@ -15,4 +16,5 @@ return [
CoreModule::class, CoreModule::class,
TimeTrackingModule::class, TimeTrackingModule::class,
ProjectManagementModule::class, ProjectManagementModule::class,
AbsenceModule::class,
]; ];
+5
View File
@@ -48,6 +48,11 @@ doctrine:
is_bundle: false is_bundle: false
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
prefix: 'App\Module\ProjectManagement\Domain\Entity' prefix: 'App\Module\ProjectManagement\Domain\Entity'
Absence:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
prefix: 'App\Module\Absence\Domain\Entity'
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
+8 -2
View File
@@ -57,11 +57,11 @@ services:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Controller\Absence\AbsenceJustificationUploadController: App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Controller\Absence\AbsenceJustificationDownloadController: App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
@@ -93,4 +93,10 @@ services:
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository' App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier' App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+1 -1
View File
@@ -30,7 +30,7 @@ return [
'icon' => 'mdi:cog-outline', 'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'], 'roles' => ['ROLE_ADMIN'],
'items' => [ 'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
], ],
], ],
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Absence module: add Timestampable/Blamable columns to absence_balance and
* absence_policy.
*
* AbsenceBalance and AbsencePolicy adopt TimestampableBlamableTrait.
* AbsenceRequest is intentionally untouched (it already carries createdAt /
* reviewedAt). This migration is purely additive — nullable columns + nullable
* FK to "user" with ON DELETE SET NULL. No DROP/ALTER on existing data. Columns
* are lowercase snake_case. Hand-written to guarantee zero destructive
* instruction.
*/
final class Version20260620170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Absence: add timestampable/blamable columns to absence_balance and absence_policy (additive)';
}
public function up(Schema $schema): void
{
// absence_balance
$this->addSql('ALTER TABLE absence_balance ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE absence_balance ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE absence_balance ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE absence_balance ADD updated_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE absence_balance ADD CONSTRAINT FK_65723A76DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE absence_balance ADD CONSTRAINT FK_65723A7616FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_65723A76DE12AB56 ON absence_balance (created_by)');
$this->addSql('CREATE INDEX IDX_65723A7616FE72E1 ON absence_balance (updated_by)');
$this->addSql("COMMENT ON COLUMN absence_balance.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN absence_balance.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN absence_balance.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN absence_balance.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
// absence_policy
$this->addSql('ALTER TABLE absence_policy ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE absence_policy ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE absence_policy ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE absence_policy ADD updated_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE absence_policy ADD CONSTRAINT FK_7A780B65DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE absence_policy ADD CONSTRAINT FK_7A780B6516FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_7A780B65DE12AB56 ON absence_policy (created_by)');
$this->addSql('CREATE INDEX IDX_7A780B6516FE72E1 ON absence_policy (updated_by)');
$this->addSql("COMMENT ON COLUMN absence_policy.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN absence_policy.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN absence_policy.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN absence_policy.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
}
public function down(Schema $schema): void
{
// absence_balance
$this->addSql('ALTER TABLE absence_balance DROP CONSTRAINT FK_65723A76DE12AB56');
$this->addSql('ALTER TABLE absence_balance DROP CONSTRAINT FK_65723A7616FE72E1');
$this->addSql('DROP INDEX IDX_65723A76DE12AB56');
$this->addSql('DROP INDEX IDX_65723A7616FE72E1');
$this->addSql('ALTER TABLE absence_balance DROP created_at');
$this->addSql('ALTER TABLE absence_balance DROP updated_at');
$this->addSql('ALTER TABLE absence_balance DROP created_by');
$this->addSql('ALTER TABLE absence_balance DROP updated_by');
// absence_policy
$this->addSql('ALTER TABLE absence_policy DROP CONSTRAINT FK_7A780B65DE12AB56');
$this->addSql('ALTER TABLE absence_policy DROP CONSTRAINT FK_7A780B6516FE72E1');
$this->addSql('DROP INDEX IDX_7A780B65DE12AB56');
$this->addSql('DROP INDEX IDX_7A780B6516FE72E1');
$this->addSql('ALTER TABLE absence_policy DROP created_at');
$this->addSql('ALTER TABLE absence_policy DROP updated_at');
$this->addSql('ALTER TABLE absence_policy DROP created_by');
$this->addSql('ALTER TABLE absence_policy DROP updated_by');
}
}
+5 -5
View File
@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\DataFixtures; namespace App\DataFixtures;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client; use App\Entity\Client;
use App\Entity\MailConfiguration; use App\Entity\MailConfiguration;
use App\Entity\ZimbraConfiguration; use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\ContractType; use App\Enum\ContractType;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Core\Application\Rbac\RbacSeeder; use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Project;
+3 -3
View File
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Mcp\Tool; namespace App\Mcp\Tool;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client; use App\Entity\Client;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task; use App\Module\ProjectManagement\Domain\Entity\Task;
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence;
use App\Shared\Domain\Module\ModuleInterface;
final class AbsenceModule implements ModuleInterface
{
public static function id(): string
{
return 'absence';
}
public static function label(): string
{
return 'Absences';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Absence.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'absence.requests.view', 'label' => 'Voir les demandes d\'absence'],
['code' => 'absence.requests.manage', 'label' => 'Gérer les demandes d\'absence'],
['code' => 'absence.policies.manage', 'label' => 'Gérer les règles d\'absence'],
['code' => 'absence.balances.manage', 'label' => 'Gérer les soldes d\'absence'],
];
}
}
@@ -2,13 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service; namespace App\Module\Absence\Application\Service;
use App\Entity\AbsenceBalance; use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Entity\AbsenceRequest; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Core\Domain\Entity\User; use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Repository\AbsenceBalanceRepository; use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -21,21 +22,24 @@ final readonly class AbsenceBalanceService
{ {
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private AbsenceBalanceRepository $balanceRepository, private AbsenceBalanceRepositoryInterface $balanceRepository,
) {} ) {}
/** /**
* Reference period string for a request: paid leave follows the employee's * Reference period string for a request: paid leave follows the employee's
* reference period (e.g. "2025-2026"), other types are tracked yearly. * reference period (e.g. "2025-2026"), other types are tracked yearly.
*/ */
public function periodFor(User $user, AbsenceType $type, DateTimeInterface $date): string public function periodFor(UserInterface $user, AbsenceType $type, DateTimeInterface $date): string
{ {
if (AbsenceType::PaidLeave !== $type) { if (AbsenceType::PaidLeave !== $type) {
return $date->format('Y'); return $date->format('Y');
} }
$year = (int) $date->format('Y'); $year = (int) $date->format('Y');
$startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01" // The reference-period start (e.g. "06-01") is an HR profile field,
// accessed through the LeaveProfileInterface contract to keep the
// Absence module decoupled from the concrete Core User entity.
$startMonthDay = $user instanceof LeaveProfileInterface ? $user->getReferencePeriodStart() : '01-01';
$currentMonthDay = $date->format('m-d'); $currentMonthDay = $date->format('m-d');
$startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1; $startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1;
@@ -43,7 +47,7 @@ final readonly class AbsenceBalanceService
return sprintf('%d-%d', $startYear, $startYear + 1); return sprintf('%d-%d', $startYear, $startYear + 1);
} }
public function getOrCreateBalance(User $user, AbsenceType $type, string $period): AbsenceBalance public function getOrCreateBalance(UserInterface $user, AbsenceType $type, string $period): AbsenceBalance
{ {
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period); $balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
@@ -85,7 +89,7 @@ final readonly class AbsenceBalanceService
return null; return null;
} }
/** @var User $user */ /** @var UserInterface $user */
$user = $request->getUser(); $user = $request->getUser();
$period = $this->periodFor($user, $request->getType(), $request->getStartDate()); $period = $this->periodFor($user, $request->getType(), $request->getStartDate());
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period); $balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
@@ -128,7 +132,7 @@ final readonly class AbsenceBalanceService
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
{ {
/** @var User $user */ /** @var UserInterface $user */
$user = $request->getUser(); $user = $request->getUser();
$type = $request->getType(); $type = $request->getType();
$period = $this->periodFor($user, $type, $request->getStartDate()); $period = $this->periodFor($user, $type, $request->getStartDate());
@@ -2,16 +2,19 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Entity; namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository; use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceBalanceProvider;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceBalanceProvider; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -35,11 +38,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['absence_balance:read']], normalizationContext: ['groups' => ['absence_balance:read']],
denormalizationContext: ['groups' => ['absence_balance:write']], denormalizationContext: ['groups' => ['absence_balance:write']],
)] )]
#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)] #[ORM\Entity(repositoryClass: DoctrineAbsenceBalanceRepository::class)]
#[ORM\Table(name: 'absence_balance')] #[ORM\Table(name: 'absence_balance')]
#[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])] #[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])]
class AbsenceBalance class AbsenceBalance implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -2,14 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Entity; namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -31,11 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['absence_policy:write']], denormalizationContext: ['groups' => ['absence_policy:write']],
order: ['type' => 'ASC'], order: ['type' => 'ASC'],
)] )]
#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)] #[ORM\Entity(repositoryClass: DoctrineAbsencePolicyRepository::class)]
#[ORM\Table(name: 'absence_policy')] #[ORM\Table(name: 'absence_policy')]
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])] #[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
class AbsencePolicy class AbsencePolicy implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Entity; namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
@@ -10,15 +10,15 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Enum\AbsenceStatus; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Enum\HalfDay; use App\Module\Absence\Domain\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceCancelProcessor;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceRequestProcessor;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceRequestProvider;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceReviewProcessor;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
use App\State\AbsenceReviewProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -64,7 +64,7 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['absence_request:write']], denormalizationContext: ['groups' => ['absence_request:write']],
order: ['createdAt' => 'DESC'], order: ['createdAt' => 'DESC'],
)] )]
#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)] #[ORM\Entity(repositoryClass: DoctrineAbsenceRequestRepository::class)]
#[ORM\Table(name: 'absence_request')] #[ORM\Table(name: 'absence_request')]
class AbsenceRequest class AbsenceRequest
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Enum; namespace App\Module\Absence\Domain\Enum;
enum AbsenceStatus: string enum AbsenceStatus: string
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Enum; namespace App\Module\Absence\Domain\Enum;
enum AbsenceType: string enum AbsenceType: string
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Enum; namespace App\Module\Absence\Domain\Enum;
enum HalfDay: string enum HalfDay: string
{ {
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
interface AbsenceBalanceRepositoryInterface
{
public function findById(int $id): ?AbsenceBalance;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return AbsenceBalance[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
interface AbsencePolicyRepositoryInterface
{
public function findById(int $id): ?AbsencePolicy;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return AbsencePolicy[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneByType(AbsenceType $type): ?AbsencePolicy;
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
interface AbsenceRequestRepositoryInterface
{
public function findById(int $id): ?AbsenceRequest;
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range.
*/
public function hasOverlap(
UserInterface $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
): bool;
/**
* Absences (approved or pending) overlapping a date range, all employees.
*
* @return AbsenceRequest[]
*/
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array;
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?UserInterface $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
?DateTimeInterface $to = null,
): array;
}
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service; namespace App\Module\Absence\Domain\Service;
use App\Enum\HalfDay; use App\Module\Absence\Domain\Enum\HalfDay;
use DateInterval; use DateInterval;
use DatePeriod; use DatePeriod;
use DateTimeImmutable; use DateTimeImmutable;
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service; namespace App\Module\Absence\Domain\Service;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
@@ -2,11 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceBalance; use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +19,7 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private AbsenceBalanceRepositoryInterface $balanceRepository,
private Security $security, private Security $security,
) {} ) {}
@@ -26,11 +28,10 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
$user = $this->security->getUser(); $user = $this->security->getUser();
assert($user instanceof UserInterface); assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceBalance::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN'); $isAdmin = $this->security->isGranted('ROLE_ADMIN');
if (isset($uriVariables['id'])) { if (isset($uriVariables['id'])) {
$balance = $repo->find($uriVariables['id']); $balance = $this->balanceRepository->findById((int) $uriVariables['id']);
if (null === $balance) { if (null === $balance) {
return null; return null;
} }
@@ -41,7 +42,8 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
return $balance; return $balance;
} }
$qb = $repo->createQueryBuilder('b') $qb = $this->entityManager->getRepository(AbsenceBalance::class)
->createQueryBuilder('b')
->orderBy('b.type', 'ASC') ->orderBy('b.type', 'ASC')
; ;
@@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Enum\AbsenceStatus; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -2,17 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Enum\AbsenceStatus; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Service\AbsenceDayCalculator; use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -32,8 +32,8 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Security $security, private Security $security,
private AbsenceDayCalculator $calculator, private AbsenceDayCalculator $calculator,
private AbsencePolicyRepository $policyRepository, private AbsencePolicyRepositoryInterface $policyRepository,
private AbsenceRequestRepository $requestRepository, private AbsenceRequestRepositoryInterface $requestRepository,
private AbsenceBalanceService $balanceService, private AbsenceBalanceService $balanceService,
) {} ) {}
@@ -2,11 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceRequest; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +19,7 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private AbsenceRequestRepositoryInterface $requestRepository,
private Security $security, private Security $security,
) {} ) {}
@@ -26,12 +28,11 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
$user = $this->security->getUser(); $user = $this->security->getUser();
assert($user instanceof UserInterface); assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceRequest::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN'); $isAdmin = $this->security->isGranted('ROLE_ADMIN');
// Single item: owner or admin only // Single item: owner or admin only
if (isset($uriVariables['id'])) { if (isset($uriVariables['id'])) {
$request = $repo->find($uriVariables['id']); $request = $this->requestRepository->findById((int) $uriVariables['id']);
if (null === $request) { if (null === $request) {
return null; return null;
} }
@@ -42,7 +43,8 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
return $request; return $request;
} }
$qb = $repo->createQueryBuilder('a') $qb = $this->entityManager->getRepository(AbsenceRequest::class)
->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC') ->orderBy('a.createdAt', 'DESC')
; ;
@@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Enum\AbsenceStatus; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -2,12 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Command; namespace App\Module\Absence\Infrastructure\Command;
use App\Enum\AbsenceType; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository; use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Service\AbsenceBalanceService; use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
@@ -37,8 +38,8 @@ use function sprintf;
class AccrueLeaveCommand extends Command class AccrueLeaveCommand extends Command
{ {
public function __construct( public function __construct(
private readonly DoctrineUserRepository $userRepository, private readonly UserRepositoryInterface $userRepository,
private readonly AbsenceBalanceRepository $balanceRepository, private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService, private readonly AbsenceBalanceService $balanceService,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
) { ) {
@@ -88,7 +89,14 @@ class AccrueLeaveCommand extends Command
$skipped = 0; $skipped = 0;
foreach ($employees as $user) { foreach ($employees as $user) {
$rate = ($user->getAnnualLeaveDays() / 12) * $user->getWorkTimeRatio(); // RH leave profile fields are read through the contract to keep the
// Absence module decoupled from the concrete Core User entity.
$profile = $user instanceof LeaveProfileInterface ? $user : null;
if (null === $profile) {
continue;
}
$rate = ($profile->getAnnualLeaveDays() / 12) * $profile->getWorkTimeRatio();
$period = $this->balanceService->periodFor($user, AbsenceType::PaidLeave, $firstDay); $period = $this->balanceService->periodFor($user, AbsenceType::PaidLeave, $firstDay);
$balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period); $balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period);
@@ -104,7 +112,7 @@ class AccrueLeaveCommand extends Command
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod) ? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null; : null;
$balance->setAcquired( $balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $user->getInitialLeaveBalance(), null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
); );
} }
@@ -119,7 +127,7 @@ class AccrueLeaveCommand extends Command
$balance->setLastAccruedMonth($monthKey); $balance->setLastAccruedMonth($monthKey);
++$accrued; ++$accrued;
$seeded = $isNew && (null !== self::previousPeriod($period) || $user->getInitialLeaveBalance() > 0); $seeded = $isNew && (null !== self::previousPeriod($period) || $profile->getInitialLeaveBalance() > 0);
$rows[] = [ $rows[] = [
$user->getUsername(), $user->getUsername(),
$period, $period,
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Absence; namespace App\Module\Absence\Infrastructure\Controller;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceCalendarController extends AbstractController class AbsenceCalendarController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
) {} ) {}
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)] #[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
@@ -2,10 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Absence; namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\AbsenceRequest; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -21,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceJustificationDownloadController extends AbstractController class AbsenceJustificationDownloadController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security, private readonly Security $security,
private readonly string $uploadDir, private readonly string $uploadDir,
) {} ) {}
@@ -30,7 +29,7 @@ class AbsenceJustificationDownloadController extends AbstractController
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse public function __invoke(int $id): BinaryFileResponse
{ {
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id); $absence = $this->requestRepository->findById($id);
if (null === $absence) { if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.'); throw new NotFoundHttpException('Absence request not found.');
} }
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Absence; namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\AbsenceRequest; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -34,6 +34,7 @@ class AbsenceJustificationUploadController extends AbstractController
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security, private readonly Security $security,
private readonly string $uploadDir, private readonly string $uploadDir,
) {} ) {}
@@ -42,7 +43,7 @@ class AbsenceJustificationUploadController extends AbstractController
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function __invoke(int $id, Request $request): JsonResponse public function __invoke(int $id, Request $request): JsonResponse
{ {
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id); $absence = $this->requestRepository->findById($id);
if (null === $absence) { if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.'); throw new NotFoundHttpException('Absence request not found.');
} }
@@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Absence; namespace App\Module\Absence\Infrastructure\Controller;
use App\Enum\AbsenceType; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Enum\HalfDay; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository; use App\Module\Absence\Domain\Enum\HalfDay;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Service\AbsenceDayCalculator; use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -30,8 +30,8 @@ class AbsencePreviewController extends AbstractController
public function __construct( public function __construct(
private readonly Security $security, private readonly Security $security,
private readonly AbsenceDayCalculator $calculator, private readonly AbsenceDayCalculator $calculator,
private readonly AbsencePolicyRepository $policyRepository, private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceBalanceRepository $balanceRepository, private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService, private readonly AbsenceBalanceService $balanceService,
) {} ) {}
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Absence; namespace App\Module\Absence\Infrastructure\Controller;
use App\Service\PublicHolidayProvider; use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -2,10 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Repository; namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsenceBalance; use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -13,13 +14,18 @@ use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<AbsenceBalance> * @extends ServiceEntityRepository<AbsenceBalance>
*/ */
class AbsenceBalanceRepository extends ServiceEntityRepository class DoctrineAbsenceBalanceRepository extends ServiceEntityRepository implements AbsenceBalanceRepositoryInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, AbsenceBalance::class); parent::__construct($registry, AbsenceBalance::class);
} }
public function findById(int $id): ?AbsenceBalance
{
return $this->find($id);
}
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance
{ {
return $this->findOneBy([ return $this->findOneBy([
@@ -2,23 +2,29 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Repository; namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsencePolicy; use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<AbsencePolicy> * @extends ServiceEntityRepository<AbsencePolicy>
*/ */
class AbsencePolicyRepository extends ServiceEntityRepository class DoctrineAbsencePolicyRepository extends ServiceEntityRepository implements AbsencePolicyRepositoryInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, AbsencePolicy::class); parent::__construct($registry, AbsencePolicy::class);
} }
public function findById(int $id): ?AbsencePolicy
{
return $this->find($id);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy public function findOneByType(AbsenceType $type): ?AbsencePolicy
{ {
return $this->findOneBy(['type' => $type]); return $this->findOneBy(['type' => $type]);
@@ -2,11 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Repository; namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsenceRequest; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -15,13 +16,18 @@ use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<AbsenceRequest> * @extends ServiceEntityRepository<AbsenceRequest>
*/ */
class AbsenceRequestRepository extends ServiceEntityRepository class DoctrineAbsenceRequestRepository extends ServiceEntityRepository implements AbsenceRequestRepositoryInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, AbsenceRequest::class); parent::__construct($registry, AbsenceRequest::class);
} }
public function findById(int $id): ?AbsenceRequest
{
return $this->find($id);
}
/** /**
* Whether the user already has a PENDING or APPROVED absence that overlaps * Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range. Two ranges overlap when start_a <= end_b and * the given date range. Two ranges overlap when start_a <= end_b and
@@ -2,12 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -21,7 +21,7 @@ class CancelAbsenceRequestTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService, private readonly AbsenceBalanceService $balanceService,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -32,7 +32,7 @@ class CancelAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
} }
$request = $this->requestRepository->find($id); $request = $this->requestRepository->findById($id);
if (null === $request) { if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
} }
@@ -2,18 +2,18 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Service\AbsenceDayCalculator; use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
@@ -28,9 +28,9 @@ class CreateAbsenceRequestTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly DoctrineUserRepository $userRepository, private readonly UserRepositoryInterface $userRepository,
private readonly AbsencePolicyRepository $policyRepository, private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceDayCalculator $calculator, private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService, private readonly AbsenceBalanceService $balanceService,
private readonly Security $security, private readonly Security $security,
@@ -49,7 +49,7 @@ class CreateAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
} }
$user = $this->userRepository->find($userId) $user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); ?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
$typeEnum = AbsenceType::tryFrom($type) $typeEnum = AbsenceType::tryFrom($type)
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -17,7 +17,7 @@ use function sprintf;
class DeleteAbsenceRequestTool class DeleteAbsenceRequestTool
{ {
public function __construct( public function __construct(
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -28,7 +28,7 @@ class DeleteAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
} }
$request = $this->requestRepository->find($id); $request = $this->requestRepository->findById($id);
if (null === $request) { if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
} }
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -17,7 +17,7 @@ use function sprintf;
class GetAbsenceRequestTool class GetAbsenceRequestTool
{ {
public function __construct( public function __construct(
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -27,7 +27,7 @@ class GetAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
} }
$request = $this->requestRepository->find($id); $request = $this->requestRepository->findById($id);
if (null === $request) { if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
} }
@@ -2,12 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository; use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -19,8 +19,8 @@ use function sprintf;
class ListAbsenceBalancesTool class ListAbsenceBalancesTool
{ {
public function __construct( public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository, private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly DoctrineUserRepository $userRepository, private readonly UserRepositoryInterface $userRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -32,7 +32,7 @@ class ListAbsenceBalancesTool
$criteria = []; $criteria = [];
if (null !== $userId) { if (null !== $userId) {
$user = $this->userRepository->find($userId); $user = $this->userRepository->findById($userId);
if (null === $user) { if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
} }
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AccessDeniedException;
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListAbsencePoliciesTool class ListAbsencePoliciesTool
{ {
public function __construct( public function __construct(
private readonly AbsencePolicyRepository $policyRepository, private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use DateTimeImmutable; use DateTimeImmutable;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -21,8 +21,8 @@ use function sprintf;
class ListAbsenceRequestsTool class ListAbsenceRequestsTool
{ {
public function __construct( public function __construct(
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly DoctrineUserRepository $userRepository, private readonly UserRepositoryInterface $userRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -39,7 +39,7 @@ class ListAbsenceRequestsTool
$user = null; $user = null;
if (null !== $userId) { if (null !== $userId) {
$user = $this->userRepository->find($userId) $user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); ?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
} }
@@ -2,12 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Service\AbsenceBalanceService; use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -24,7 +24,7 @@ class ReviewAbsenceRequestTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository, private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService, private readonly AbsenceBalanceService $balanceService,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -39,7 +39,7 @@ class ReviewAbsenceRequestTool
throw new InvalidArgumentException('decision must be "approve" or "reject".'); throw new InvalidArgumentException('decision must be "approve" or "reject".');
} }
$request = $this->requestRepository->find($id); $request = $this->requestRepository->findById($id);
if (null === $request) { if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
} }
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository; use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsenceBalanceTool class UpdateAbsenceBalanceTool
{ {
public function __construct( public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository, private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -33,7 +33,7 @@ class UpdateAbsenceBalanceTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
} }
$balance = $this->balanceRepository->find($id); $balance = $this->balanceRepository->findById($id);
if (null === $balance) { if (null === $balance) {
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
} }
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Mcp\Tool\Absence; namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository; use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsencePolicyTool class UpdateAbsencePolicyTool
{ {
public function __construct( public function __construct(
private readonly AbsencePolicyRepository $policyRepository, private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -36,7 +36,7 @@ class UpdateAbsencePolicyTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
} }
$policy = $this->policyRepository->find($id); $policy = $this->policyRepository->findById($id);
if (null === $policy) { if (null === $policy) {
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
} }
+2 -1
View File
@@ -18,6 +18,7 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore; use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -63,7 +64,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')] #[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface, LeaveProfileInterface
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -9,6 +9,8 @@ use DateTimeInterface;
interface UserRepositoryInterface interface UserRepositoryInterface
{ {
public function findById(int $id): ?UserInterface;
/** /**
* @return list<UserInterface> * @return list<UserInterface>
*/ */
@@ -21,6 +21,11 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
parent::__construct($registry, User::class); parent::__construct($registry, User::class);
} }
public function findById(int $id): ?UserInterface
{
return $this->find($id);
}
/** /**
* @return list<UserInterface> * @return list<UserInterface>
*/ */
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* HR leave profile of an employee, consumed by the Absence module without
* coupling it to the concrete Core User entity.
*
* Exposes only the RH getters actually used by AbsenceBalanceService and
* AccrueLeaveCommand. This is a service-level typing contract, not a Doctrine
* relation (no resolve_target_entities entry).
*/
interface LeaveProfileInterface
{
public function getWorkTimeRatio(): float;
public function getAnnualLeaveDays(): float;
public function getReferencePeriodStart(): string;
public function getInitialLeaveBalance(): float;
}
@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Tests\Functional\Mcp; namespace App\Tests\Functional\Mcp;
use App\Entity\AbsenceBalance; use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Entity\AbsencePolicy; use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Enum\AbsenceType; use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Mcp\Tool\Absence\CancelAbsenceRequestTool; use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Mcp\Tool\Absence\CreateAbsenceRequestTool; use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Mcp\Tool\Absence\ReviewAbsenceRequestTool; use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository;
use App\Module\Absence\Infrastructure\Mcp\Tool\CancelAbsenceRequestTool;
use App\Module\Absence\Infrastructure\Mcp\Tool\CreateAbsenceRequestTool;
use App\Module\Absence\Infrastructure\Mcp\Tool\ReviewAbsenceRequestTool;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -93,7 +93,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
self::assertSame(5.0, (float) $data['countedDays']); self::assertSame(5.0, (float) $data['countedDays']);
self::assertSame($this->employee->getId(), $data['user']['id']); self::assertSame($this->employee->getId(), $data['user']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class) $balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027') ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
; ;
self::assertNotNull($balance); self::assertNotNull($balance);
@@ -113,7 +113,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
self::assertSame('approved', $data['status']); self::assertSame('approved', $data['status']);
self::assertSame($this->admin->getId(), $data['reviewedBy']['id']); self::assertSame($this->admin->getId(), $data['reviewedBy']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class) $balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027') ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
; ;
self::assertSame(0.0, $balance->getPending()); self::assertSame(0.0, $balance->getPending());
@@ -128,7 +128,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
); );
// Shrink the entitlement below the 5 requested days. // Shrink the entitlement below the 5 requested days.
$balance = self::getContainer()->get(AbsenceBalanceRepository::class) $balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027') ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
; ;
$balance->setAcquired(2.0); $balance->setAcquired(2.0);
@@ -158,7 +158,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
$data = json_decode(($this->cancelTool($this->admin))($created['id']), true); $data = json_decode(($this->cancelTool($this->admin))($created['id']), true);
self::assertSame('cancelled', $data['status']); self::assertSame('cancelled', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class) $balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027') ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
; ;
self::assertSame(0.0, $balance->getTaken()); self::assertSame(0.0, $balance->getTaken());
@@ -195,7 +195,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
); );
self::assertSame('pending', $data['status']); self::assertSame('pending', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class) $balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::Bereavement, '2026') ->findOneForPeriod($this->employee, AbsenceType::Bereavement, '2026')
; ;
self::assertNull($balance, 'Bereavement must not create or touch any balance.'); self::assertNull($balance, 'Bereavement must not create or touch any balance.');
@@ -217,8 +217,8 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new CreateAbsenceRequestTool( return new CreateAbsenceRequestTool(
$c->get(EntityManagerInterface::class), $c->get(EntityManagerInterface::class),
$c->get(DoctrineUserRepository::class), $c->get(DoctrineUserRepository::class),
$c->get(AbsencePolicyRepository::class), $c->get(DoctrineAbsencePolicyRepository::class),
$c->get(AbsenceRequestRepository::class), $c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceDayCalculator::class), $c->get(AbsenceDayCalculator::class),
$c->get(AbsenceBalanceService::class), $c->get(AbsenceBalanceService::class),
$this->securityFor($actor), $this->securityFor($actor),
@@ -231,7 +231,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new ReviewAbsenceRequestTool( return new ReviewAbsenceRequestTool(
$c->get(EntityManagerInterface::class), $c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class), $c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class), $c->get(AbsenceBalanceService::class),
$this->securityFor($actor), $this->securityFor($actor),
); );
@@ -243,7 +243,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new CancelAbsenceRequestTool( return new CancelAbsenceRequestTool(
$c->get(EntityManagerInterface::class), $c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class), $c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class), $c->get(AbsenceBalanceService::class),
$this->securityFor($actor), $this->securityFor($actor),
); );
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service; namespace App\Tests\Unit\Service;
use App\Enum\HalfDay; use App\Module\Absence\Domain\Enum\HalfDay;
use App\Service\AbsenceDayCalculator; use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Service\PublicHolidayProvider; use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service; namespace App\Tests\Unit\Service;
use App\Service\PublicHolidayProvider; use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;