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.
*/
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\TimeTracking\TimeTrackingModule;
@@ -15,4 +16,5 @@ return [
CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
AbsenceModule::class,
];
+5
View File
@@ -48,6 +48,11 @@ doctrine:
is_bundle: false
dir: '%kernel.project_dir%/src/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:
auto_mapping: false
+8 -2
View File
@@ -57,11 +57,11 @@ services:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
App\Controller\Absence\AbsenceJustificationUploadController:
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Controller\Absence\AbsenceJustificationDownloadController:
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
@@ -93,4 +93,10 @@ services:
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'
+1 -1
View File
@@ -30,7 +30,7 @@ return [
'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'],
'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'],
],
],
+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;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\MailConfiguration;
use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
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\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
+3 -3
View File
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
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\ProjectManagement\Domain\Entity\Project;
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);
namespace App\Service;
namespace App\Module\Absence\Application\Service;
use App\Entity\AbsenceBalance;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceType;
use App\Module\Core\Domain\Entity\User;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -21,21 +22,24 @@ final readonly class AbsenceBalanceService
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceBalanceRepository $balanceRepository,
private AbsenceBalanceRepositoryInterface $balanceRepository,
) {}
/**
* Reference period string for a request: paid leave follows the employee's
* 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) {
return $date->format('Y');
}
$year = (int) $date->format('Y');
$startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01"
$year = (int) $date->format('Y');
// 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');
$startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1;
@@ -43,7 +47,7 @@ final readonly class AbsenceBalanceService
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);
@@ -85,7 +89,7 @@ final readonly class AbsenceBalanceService
return null;
}
/** @var User $user */
/** @var UserInterface $user */
$user = $request->getUser();
$period = $this->periodFor($user, $request->getType(), $request->getStartDate());
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
@@ -128,7 +132,7 @@ final readonly class AbsenceBalanceService
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
{
/** @var User $user */
/** @var UserInterface $user */
$user = $request->getUser();
$type = $request->getType();
$period = $this->periodFor($user, $type, $request->getStartDate());
@@ -2,16 +2,19 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Enum\AbsenceType;
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\State\AbsenceBalanceProvider;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -35,11 +38,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['absence_balance:read']],
denormalizationContext: ['groups' => ['absence_balance:write']],
)]
#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsenceBalanceRepository::class)]
#[ORM\Table(name: 'absence_balance')]
#[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\GeneratedValue]
#[ORM\Column]
@@ -2,14 +2,17 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Enum\AbsenceType;
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\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -31,11 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['absence_policy:write']],
order: ['type' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsencePolicyRepository::class)]
#[ORM\Table(name: 'absence_policy')]
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
class AbsencePolicy
class AbsencePolicy implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
@@ -10,15 +10,15 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
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\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
use App\State\AbsenceReviewProcessor;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -64,7 +64,7 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['absence_request:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsenceRequestRepository::class)]
#[ORM\Table(name: 'absence_request')]
class AbsenceRequest
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
enum AbsenceStatus: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
enum AbsenceType: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
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);
namespace App\Service;
namespace App\Module\Absence\Domain\Service;
use App\Enum\HalfDay;
use App\Module\Absence\Domain\Enum\HalfDay;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\Absence\Domain\Service;
use DateTimeImmutable;
use DateTimeInterface;
@@ -2,11 +2,12 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
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 Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +19,7 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceBalanceRepositoryInterface $balanceRepository,
private Security $security,
) {}
@@ -26,11 +28,10 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceBalance::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
if (isset($uriVariables['id'])) {
$balance = $repo->find($uriVariables['id']);
$balance = $this->balanceRepository->findById((int) $uriVariables['id']);
if (null === $balance) {
return null;
}
@@ -41,7 +42,8 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
return $balance;
}
$qb = $repo->createQueryBuilder('b')
$qb = $this->entityManager->getRepository(AbsenceBalance::class)
->createQueryBuilder('b')
->orderBy('b.type', 'ASC')
;
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -2,17 +2,17 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -32,8 +32,8 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager,
private Security $security,
private AbsenceDayCalculator $calculator,
private AbsencePolicyRepository $policyRepository,
private AbsenceRequestRepository $requestRepository,
private AbsencePolicyRepositoryInterface $policyRepository,
private AbsenceRequestRepositoryInterface $requestRepository,
private AbsenceBalanceService $balanceService,
) {}
@@ -2,11 +2,12 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
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 Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +19,7 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceRequestRepositoryInterface $requestRepository,
private Security $security,
) {}
@@ -26,12 +28,11 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceRequest::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
// Single item: owner or admin only
if (isset($uriVariables['id'])) {
$request = $repo->find($uriVariables['id']);
$request = $this->requestRepository->findById((int) $uriVariables['id']);
if (null === $request) {
return null;
}
@@ -42,7 +43,8 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
return $request;
}
$qb = $repo->createQueryBuilder('a')
$qb = $this->entityManager->getRepository(AbsenceRequest::class)
->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
;
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\Command;
namespace App\Module\Absence\Infrastructure\Command;
use App\Enum\AbsenceType;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceBalanceRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -37,8 +38,8 @@ use function sprintf;
class AccrueLeaveCommand extends Command
{
public function __construct(
private readonly DoctrineUserRepository $userRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly EntityManagerInterface $entityManager,
) {
@@ -88,7 +89,14 @@ class AccrueLeaveCommand extends Command
$skipped = 0;
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);
$balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period);
@@ -104,7 +112,7 @@ class AccrueLeaveCommand extends Command
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null;
$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);
++$accrued;
$seeded = $isNew && (null !== self::previousPeriod($period) || $user->getInitialLeaveBalance() > 0);
$seeded = $isNew && (null !== self::previousPeriod($period) || $profile->getInitialLeaveBalance() > 0);
$rows[] = [
$user->getUsername(),
$period,
@@ -2,9 +2,9 @@
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceCalendarController extends AbstractController
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
) {}
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
@@ -2,10 +2,9 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\AbsenceRequest;
use Doctrine\ORM\EntityManagerInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -21,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceJustificationDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -30,7 +29,7 @@ class AbsenceJustificationDownloadController extends AbstractController
#[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
$absence = $this->requestRepository->findById($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
@@ -2,9 +2,9 @@
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\SecurityBundle\Security;
@@ -34,6 +34,7 @@ class AbsenceJustificationUploadController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -42,7 +43,7 @@ class AbsenceJustificationUploadController extends AbstractController
#[IsGranted('ROLE_USER')]
public function __invoke(int $id, Request $request): JsonResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
$absence = $this->requestRepository->findById($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -30,8 +30,8 @@ class AbsencePreviewController extends AbstractController
public function __construct(
private readonly Security $security,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
) {}
@@ -2,9 +2,9 @@
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -2,10 +2,11 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsenceBalance;
use App\Enum\AbsenceType;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -13,13 +14,18 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceBalance>
*/
class AbsenceBalanceRepository extends ServiceEntityRepository
class DoctrineAbsenceBalanceRepository extends ServiceEntityRepository implements AbsenceBalanceRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
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
{
return $this->findOneBy([
@@ -2,23 +2,29 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsencePolicy;
use App\Enum\AbsenceType;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsencePolicy>
*/
class AbsencePolicyRepository extends ServiceEntityRepository
class DoctrineAbsencePolicyRepository extends ServiceEntityRepository implements AbsencePolicyRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsencePolicy::class);
}
public function findById(int $id): ?AbsencePolicy
{
return $this->find($id);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy
{
return $this->findOneBy(['type' => $type]);
@@ -2,11 +2,12 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -15,13 +16,18 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceRequest>
*/
class AbsenceRequestRepository extends ServiceEntityRepository
class DoctrineAbsenceRequestRepository extends ServiceEntityRepository implements AbsenceRequestRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
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
* the given date range. Two ranges overlap when start_a <= end_b and
@@ -2,12 +2,12 @@
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\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -21,7 +21,7 @@ class CancelAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
@@ -32,7 +32,7 @@ class CancelAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,18 +2,18 @@
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\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
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 Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -28,9 +28,9 @@ class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly DoctrineUserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
@@ -49,7 +49,7 @@ class CreateAbsenceRequestTool
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));
$typeEnum = AbsenceType::tryFrom($type)
@@ -2,9 +2,9 @@
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 InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -17,7 +17,7 @@ use function sprintf;
class DeleteAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -28,7 +28,7 @@ class DeleteAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -17,7 +17,7 @@ use function sprintf;
class GetAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
) {}
@@ -27,7 +27,7 @@ class GetAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,12 +2,12 @@
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\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -19,8 +19,8 @@ use function sprintf;
class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
@@ -32,7 +32,7 @@ class ListAbsenceBalancesTool
$criteria = [];
if (null !== $userId) {
$user = $this->userRepository->find($userId);
$user = $this->userRepository->findById($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListAbsencePoliciesTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly Security $security,
) {}
@@ -2,13 +2,13 @@
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\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -21,8 +21,8 @@ use function sprintf;
class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
@@ -39,7 +39,7 @@ class ListAbsenceRequestsTool
$user = null;
if (null !== $userId) {
$user = $this->userRepository->find($userId)
$user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
@@ -2,12 +2,12 @@
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\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -24,7 +24,7 @@ class ReviewAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
@@ -39,7 +39,7 @@ class ReviewAbsenceRequestTool
throw new InvalidArgumentException('decision must be "approve" or "reject".');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsenceBalanceTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -33,7 +33,7 @@ class UpdateAbsenceBalanceTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$balance = $this->balanceRepository->find($id);
$balance = $this->balanceRepository->findById($id);
if (null === $balance) {
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsencePolicyTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -36,7 +36,7 @@ class UpdateAbsencePolicyTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$policy = $this->policyRepository->find($id);
$policy = $this->policyRepository->findById($id);
if (null === $policy) {
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\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -63,7 +64,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface
class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface, LeaveProfileInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -9,6 +9,8 @@ use DateTimeInterface;
interface UserRepositoryInterface
{
public function findById(int $id): ?UserInterface;
/**
* @return list<UserInterface>
*/
@@ -21,6 +21,11 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
parent::__construct($registry, User::class);
}
public function findById(int $id): ?UserInterface
{
return $this->find($id);
}
/**
* @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;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Absence\CancelAbsenceRequestTool;
use App\Mcp\Tool\Absence\CreateAbsenceRequestTool;
use App\Mcp\Tool\Absence\ReviewAbsenceRequestTool;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
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\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 InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -93,7 +93,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
self::assertSame(5.0, (float) $data['countedDays']);
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')
;
self::assertNotNull($balance);
@@ -113,7 +113,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
self::assertSame('approved', $data['status']);
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')
;
self::assertSame(0.0, $balance->getPending());
@@ -128,7 +128,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
);
// 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')
;
$balance->setAcquired(2.0);
@@ -158,7 +158,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
$data = json_decode(($this->cancelTool($this->admin))($created['id']), true);
self::assertSame('cancelled', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
$balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertSame(0.0, $balance->getTaken());
@@ -195,7 +195,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
);
self::assertSame('pending', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
$balance = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::Bereavement, '2026')
;
self::assertNull($balance, 'Bereavement must not create or touch any balance.');
@@ -217,8 +217,8 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new CreateAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(DoctrineUserRepository::class),
$c->get(AbsencePolicyRepository::class),
$c->get(AbsenceRequestRepository::class),
$c->get(DoctrineAbsencePolicyRepository::class),
$c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceDayCalculator::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
@@ -231,7 +231,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new ReviewAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
@@ -243,7 +243,7 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
return new CancelAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(DoctrineAbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Enum\HalfDay;
use App\Service\AbsenceDayCalculator;
use App\Service\PublicHolidayProvider;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PublicHolidayProvider;
use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;