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:
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Application\Service;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Maintains per-employee leave balances as absence requests move through their
|
||||
* lifecycle: a PENDING request reserves days in `pending`, an APPROVED one
|
||||
* moves them to `taken`, and a cancellation gives them back.
|
||||
*/
|
||||
final readonly class AbsenceBalanceService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
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(UserInterface $user, AbsenceType $type, DateTimeInterface $date): string
|
||||
{
|
||||
if (AbsenceType::PaidLeave !== $type) {
|
||||
return $date->format('Y');
|
||||
}
|
||||
|
||||
$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;
|
||||
|
||||
return sprintf('%d-%d', $startYear, $startYear + 1);
|
||||
}
|
||||
|
||||
public function getOrCreateBalance(UserInterface $user, AbsenceType $type, string $period): AbsenceBalance
|
||||
{
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
|
||||
|
||||
if (null === $balance) {
|
||||
$balance = new AbsenceBalance()
|
||||
->setUser($user)
|
||||
->setType($type)
|
||||
->setPeriod($period)
|
||||
;
|
||||
$this->entityManager->persist($balance);
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
/** Reserve the requested days in the PENDING bucket. */
|
||||
public function reservePending(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending($balance->getPending() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* Days still available to take in the request's balance period
|
||||
* (acquired N-1 + acquiring N − already taken), or null when the type is
|
||||
* not balance-tracked (per-event leaves such as bereavement or marriage).
|
||||
*
|
||||
* Days currently reserved in PENDING are intentionally not subtracted: the
|
||||
* request being reviewed already sits in that pending bucket, and approval
|
||||
* only moves it to TAKEN.
|
||||
*/
|
||||
public function availableForRequest(AbsenceRequest $request): ?float
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserInterface $user */
|
||||
$user = $request->getUser();
|
||||
$period = $this->periodFor($user, $request->getType(), $request->getStartDate());
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
|
||||
|
||||
return $balance?->getAvailable() ?? 0.0;
|
||||
}
|
||||
|
||||
/** Move reserved days from PENDING to TAKEN on approval. */
|
||||
public function applyApproval(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
$balance->setTaken($balance->getTaken() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* Give days back when a request is cancelled or rejected.
|
||||
*
|
||||
* @param bool $wasApproved true if the request had already been approved
|
||||
* (days were in TAKEN), false if still PENDING
|
||||
*/
|
||||
public function release(AbsenceRequest $request, bool $wasApproved): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
|
||||
if ($wasApproved) {
|
||||
$balance->setTaken(max(0.0, $balance->getTaken() - $request->getCountedDays()));
|
||||
} else {
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
}
|
||||
}
|
||||
|
||||
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
|
||||
{
|
||||
/** @var UserInterface $user */
|
||||
$user = $request->getUser();
|
||||
$type = $request->getType();
|
||||
$period = $this->periodFor($user, $type, $request->getStartDate());
|
||||
|
||||
return $this->getOrCreateBalance($user, $type, $period);
|
||||
}
|
||||
|
||||
private function shouldTrack(AbsenceRequest $request): bool
|
||||
{
|
||||
$type = $request->getType();
|
||||
|
||||
return null !== $type && $type->decrementsBalance() && null !== $request->getUser();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Per-employee, per-type leave balance for a given reference period.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceBalanceProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceBalanceProvider::class,
|
||||
),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_balance:read']],
|
||||
denormalizationContext: ['groups' => ['absence_balance:write']],
|
||||
)]
|
||||
#[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 implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private AbsenceType $type;
|
||||
|
||||
/** Reference period, e.g. "2025-2026" for paid leave or "2025" for yearly. */
|
||||
#[ORM\Column(length: 16)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?string $period = null;
|
||||
|
||||
/** Days acquired during the *previous* reference period (Congés N-1): fully available to take. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $acquired = 0.0;
|
||||
|
||||
/** Days being accrued during the *current* reference period (Congés N): "en cours d'acquisition". */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $acquiring = 0.0;
|
||||
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $taken = 0.0;
|
||||
|
||||
/** Sum of days in PENDING requests, for information. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private float $pending = 0.0;
|
||||
|
||||
/** Last month (format YYYY-MM) for which the monthly accrual was applied. */
|
||||
#[ORM\Column(length: 7, nullable: true)]
|
||||
private ?string $lastAccruedMonth = null;
|
||||
|
||||
/** Total entitlement for the period, both finalized (N-1) and in-progress (N). */
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getAcquiredTotal(): float
|
||||
{
|
||||
return $this->acquired + $this->acquiring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the employee can still take: in this organisation the days being
|
||||
* accrued (N) are posable too, so they count towards what is available.
|
||||
*/
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getAvailable(): float
|
||||
{
|
||||
return $this->acquired + $this->acquiring - $this->taken;
|
||||
}
|
||||
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->type->label();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPeriod(): ?string
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
|
||||
public function setPeriod(string $period): static
|
||||
{
|
||||
$this->period = $period;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAcquired(): float
|
||||
{
|
||||
return $this->acquired;
|
||||
}
|
||||
|
||||
public function setAcquired(float $acquired): static
|
||||
{
|
||||
$this->acquired = $acquired;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAcquiring(): float
|
||||
{
|
||||
return $this->acquiring;
|
||||
}
|
||||
|
||||
public function setAcquiring(float $acquiring): static
|
||||
{
|
||||
$this->acquiring = $acquiring;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTaken(): float
|
||||
{
|
||||
return $this->taken;
|
||||
}
|
||||
|
||||
public function setTaken(float $taken): static
|
||||
{
|
||||
$this->taken = $taken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPending(): float
|
||||
{
|
||||
return $this->pending;
|
||||
}
|
||||
|
||||
public function setPending(float $pending): static
|
||||
{
|
||||
$this->pending = $pending;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastAccruedMonth(): ?string
|
||||
{
|
||||
return $this->lastAccruedMonth;
|
||||
}
|
||||
|
||||
public function setLastAccruedMonth(?string $lastAccruedMonth): static
|
||||
{
|
||||
$this->lastAccruedMonth = $lastAccruedMonth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Per-type configuration of absence rules. Overrides the legal defaults and
|
||||
* lets an admin tune days/year, days/event, notice period, etc.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_policy:read']],
|
||||
denormalizationContext: ['groups' => ['absence_policy:write']],
|
||||
order: ['type' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineAbsencePolicyRepository::class)]
|
||||
#[ORM\Table(name: 'absence_policy')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
|
||||
class AbsencePolicy implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_policy:read', 'absence_balance:read', 'absence_request:read'])]
|
||||
private AbsenceType $type;
|
||||
|
||||
/** Yearly entitlement (e.g. 25 for paid leave); null when not relevant. */
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private ?float $daysPerYear = null;
|
||||
|
||||
/** Days granted per event (e.g. 4 for marriage); null when not relevant. */
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private ?float $daysPerEvent = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $justificationRequired = false;
|
||||
|
||||
/** Minimum notice period in days (e.g. 30 for paid leave, 0 for sick leave). */
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private int $noticeDays = 0;
|
||||
|
||||
/** true => "jours ouvrés" (Mon-Fri), false => "jours ouvrables" (Mon-Sat). */
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $countWorkingDaysOnly = true;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $active = true;
|
||||
|
||||
#[Groups(['absence_policy:read'])]
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->type->label();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDaysPerYear(): ?float
|
||||
{
|
||||
return $this->daysPerYear;
|
||||
}
|
||||
|
||||
public function setDaysPerYear(?float $daysPerYear): static
|
||||
{
|
||||
$this->daysPerYear = $daysPerYear;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDaysPerEvent(): ?float
|
||||
{
|
||||
return $this->daysPerEvent;
|
||||
}
|
||||
|
||||
public function setDaysPerEvent(?float $daysPerEvent): static
|
||||
{
|
||||
$this->daysPerEvent = $daysPerEvent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isJustificationRequired(): bool
|
||||
{
|
||||
return $this->justificationRequired;
|
||||
}
|
||||
|
||||
public function setJustificationRequired(bool $justificationRequired): static
|
||||
{
|
||||
$this->justificationRequired = $justificationRequired;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNoticeDays(): int
|
||||
{
|
||||
return $this->noticeDays;
|
||||
}
|
||||
|
||||
public function setNoticeDays(int $noticeDays): static
|
||||
{
|
||||
$this->noticeDays = $noticeDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCountWorkingDaysOnly(): bool
|
||||
{
|
||||
return $this->countWorkingDaysOnly;
|
||||
}
|
||||
|
||||
public function setCountWorkingDaysOnly(bool $countWorkingDaysOnly): static
|
||||
{
|
||||
$this->countWorkingDaysOnly = $countWorkingDaysOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
public function setActive(bool $active): static
|
||||
{
|
||||
$this->active = $active;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
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 DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
processor: AbsenceRequestProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/approve',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: AbsenceReviewProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/reject',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: AbsenceReviewProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/cancel',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
processor: AbsenceCancelProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_request:read']],
|
||||
denormalizationContext: ['groups' => ['absence_request:write']],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineAbsenceRequestRepository::class)]
|
||||
#[ORM\Table(name: 'absence_request')]
|
||||
class AbsenceRequest
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?AbsenceType $type = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?DateTimeImmutable $startDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?DateTimeImmutable $endDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?HalfDay $startHalfDay = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?HalfDay $endHalfDay = null;
|
||||
|
||||
/** Number of deducted days, computed server-side at creation. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private float $countedDays = 0.0;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?string $justificationFileName = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, enumType: AbsenceStatus::class)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private AbsenceStatus $status = AbsenceStatus::Pending;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?string $rejectionReason = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?DateTimeImmutable $reviewedAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?UserInterface $reviewedBy = null;
|
||||
|
||||
#[Groups(['absence_request:read'])]
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->type?->label();
|
||||
}
|
||||
|
||||
#[Groups(['absence_request:read'])]
|
||||
public function getJustificationUrl(): ?string
|
||||
{
|
||||
if (null === $this->justificationFileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/api/absence_requests/'.$this->id.'/justificatif';
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
public function setStartDate(?DateTimeImmutable $startDate): static
|
||||
{
|
||||
$this->startDate = $startDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(?DateTimeImmutable $endDate): static
|
||||
{
|
||||
$this->endDate = $endDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartHalfDay(): ?HalfDay
|
||||
{
|
||||
return $this->startHalfDay;
|
||||
}
|
||||
|
||||
public function setStartHalfDay(?HalfDay $startHalfDay): static
|
||||
{
|
||||
$this->startHalfDay = $startHalfDay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndHalfDay(): ?HalfDay
|
||||
{
|
||||
return $this->endHalfDay;
|
||||
}
|
||||
|
||||
public function setEndHalfDay(?HalfDay $endHalfDay): static
|
||||
{
|
||||
$this->endHalfDay = $endHalfDay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountedDays(): float
|
||||
{
|
||||
return $this->countedDays;
|
||||
}
|
||||
|
||||
public function setCountedDays(float $countedDays): static
|
||||
{
|
||||
$this->countedDays = $countedDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): static
|
||||
{
|
||||
$this->reason = $reason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJustificationFileName(): ?string
|
||||
{
|
||||
return $this->justificationFileName;
|
||||
}
|
||||
|
||||
public function setJustificationFileName(?string $justificationFileName): static
|
||||
{
|
||||
$this->justificationFileName = $justificationFileName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): AbsenceStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(AbsenceStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRejectionReason(): ?string
|
||||
{
|
||||
return $this->rejectionReason;
|
||||
}
|
||||
|
||||
public function setRejectionReason(?string $rejectionReason): static
|
||||
{
|
||||
$this->rejectionReason = $rejectionReason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->reviewedAt;
|
||||
}
|
||||
|
||||
public function setReviewedAt(?DateTimeImmutable $reviewedAt): static
|
||||
{
|
||||
$this->reviewedAt = $reviewedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedBy(): ?UserInterface
|
||||
{
|
||||
return $this->reviewedBy;
|
||||
}
|
||||
|
||||
public function setReviewedBy(?UserInterface $reviewedBy): static
|
||||
{
|
||||
$this->reviewedBy = $reviewedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum AbsenceStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'En attente',
|
||||
self::Approved => 'Approuvée',
|
||||
self::Rejected => 'Refusée',
|
||||
self::Cancelled => 'Annulée',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum AbsenceType: string
|
||||
{
|
||||
case PaidLeave = 'cp';
|
||||
case MarriagePacs = 'mariage_pacs';
|
||||
case Birth = 'naissance';
|
||||
case ParentalLeave = 'conge_parental';
|
||||
case Bereavement = 'deces';
|
||||
case SickLeave = 'maladie';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PaidLeave => 'Congés payés',
|
||||
self::MarriagePacs => 'Mariage / PACS',
|
||||
self::Birth => 'Naissance',
|
||||
self::ParentalLeave => 'Congé parental',
|
||||
self::Bereavement => 'Décès proche',
|
||||
self::SickLeave => 'Arrêt maladie',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether taking this absence decrements a balance.
|
||||
*
|
||||
* Only paid leave (CP) draws down an acquired balance. Family-event leaves
|
||||
* (marriage/PACS, birth, bereavement) are per-event entitlements, parental
|
||||
* leave is a contract suspension, and sick leave is handled by social
|
||||
* security — none of them decrement a balance.
|
||||
*/
|
||||
public function decrementsBalance(): bool
|
||||
{
|
||||
return self::PaidLeave === $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum HalfDay: string
|
||||
{
|
||||
case Morning = 'matin';
|
||||
case Afternoon = 'apres_midi';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Morning => 'Matin',
|
||||
self::Afternoon => 'Après-midi',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Service;
|
||||
|
||||
use App\Module\Absence\Domain\Enum\HalfDay;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Computes the number of days deducted for an absence request, following the
|
||||
* business rules of the spec (§5.1): weekends and public holidays are skipped,
|
||||
* and half-days on the boundaries subtract 0.5 each.
|
||||
*/
|
||||
final readonly class AbsenceDayCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private PublicHolidayProvider $holidayProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param bool $workingDaysOnly true => "jours ouvrés" (Mon-Fri),
|
||||
* false => "jours ouvrables" (Mon-Sat, Sunday excluded)
|
||||
*/
|
||||
public function countWorkingDays(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
?HalfDay $startHalfDay = null,
|
||||
?HalfDay $endHalfDay = null,
|
||||
bool $workingDaysOnly = true,
|
||||
): float {
|
||||
$start = $start->setTime(0, 0);
|
||||
$end = $end->setTime(0, 0);
|
||||
|
||||
if ($end < $start) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$days = 0.0;
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
|
||||
foreach ($period as $day) {
|
||||
if ($this->isCountedDay($day, $workingDaysOnly)) {
|
||||
++$days;
|
||||
}
|
||||
}
|
||||
|
||||
if ($days <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// A half-day only subtracts 0.5 when its boundary day is actually
|
||||
// counted (otherwise a half-day posted on a weekend/holiday would
|
||||
// wrongly under-count the absence).
|
||||
if ($start->getTimestamp() === $end->getTimestamp()) {
|
||||
// Single-day request: both boundaries collapse onto the same day,
|
||||
// so a half-day must subtract 0.5 once, never twice.
|
||||
if ((null !== $startHalfDay || null !== $endHalfDay) && $this->isCountedDay($start, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
} else {
|
||||
if (null !== $startHalfDay && $this->isCountedDay($start, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
if (null !== $endHalfDay && $this->isCountedDay($end, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0.0, $days);
|
||||
}
|
||||
|
||||
private function isCountedDay(DateTimeImmutable $day, bool $workingDaysOnly): bool
|
||||
{
|
||||
$weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun)
|
||||
|
||||
if (7 === $weekday) {
|
||||
return false; // Sunday: never counted
|
||||
}
|
||||
if (6 === $weekday && $workingDaysOnly) {
|
||||
return false; // Saturday: only counted for "jours ouvrables"
|
||||
}
|
||||
|
||||
return !$this->holidayProvider->isHoliday($day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Provides French métropole public holidays.
|
||||
*
|
||||
* Dates are computed in pure PHP: fixed-date holidays are hardcoded and
|
||||
* Easter-based ones are derived from the Computus (Meeus/Jones/Butcher
|
||||
* Gregorian algorithm), so the provider has no runtime dependency and is
|
||||
* fully deterministic. Alsace-Moselle / DOM specifics are out of scope.
|
||||
*/
|
||||
final class PublicHolidayProvider
|
||||
{
|
||||
/** @var array<int, array<string, string>> cache of holidays per year */
|
||||
private array $cache = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string> map of 'Y-m-d' => label, sorted by date
|
||||
*/
|
||||
public function getHolidays(int $year): array
|
||||
{
|
||||
if (isset($this->cache[$year])) {
|
||||
return $this->cache[$year];
|
||||
}
|
||||
|
||||
$easter = $this->easterSunday($year);
|
||||
$easterMonday = $easter->modify('+1 day');
|
||||
$ascension = $easter->modify('+39 days');
|
||||
$whitMonday = $easter->modify('+50 days');
|
||||
|
||||
$holidays = [
|
||||
sprintf('%d-01-01', $year) => 'Jour de l\'an',
|
||||
$easterMonday->format('Y-m-d') => 'Lundi de Pâques',
|
||||
sprintf('%d-05-01', $year) => 'Fête du Travail',
|
||||
sprintf('%d-05-08', $year) => 'Victoire 1945',
|
||||
$ascension->format('Y-m-d') => 'Ascension',
|
||||
$whitMonday->format('Y-m-d') => 'Lundi de Pentecôte',
|
||||
sprintf('%d-07-14', $year) => 'Fête nationale',
|
||||
sprintf('%d-08-15', $year) => 'Assomption',
|
||||
sprintf('%d-11-01', $year) => 'Toussaint',
|
||||
sprintf('%d-11-11', $year) => 'Armistice 1918',
|
||||
sprintf('%d-12-25', $year) => 'Noël',
|
||||
];
|
||||
|
||||
ksort($holidays);
|
||||
|
||||
return $this->cache[$year] = $holidays;
|
||||
}
|
||||
|
||||
public function isHoliday(DateTimeInterface $date): bool
|
||||
{
|
||||
$holidays = $this->getHolidays((int) $date->format('Y'));
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Easter Sunday date for the given year (Gregorian Computus).
|
||||
*/
|
||||
private function easterSunday(int $year): DateTimeImmutable
|
||||
{
|
||||
$a = $year % 19;
|
||||
$b = intdiv($year, 100);
|
||||
$c = $year % 100;
|
||||
$d = intdiv($b, 4);
|
||||
$e = $b % 4;
|
||||
$f = intdiv($b + 8, 25);
|
||||
$g = intdiv($b - $f + 1, 3);
|
||||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||
$i = intdiv($c, 4);
|
||||
$k = $c % 4;
|
||||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||
|
||||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||
|
||||
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<AbsenceBalance>
|
||||
*/
|
||||
final readonly class AbsenceBalanceProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceBalance|array|null
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
if (isset($uriVariables['id'])) {
|
||||
$balance = $this->balanceRepository->findById((int) $uriVariables['id']);
|
||||
if (null === $balance) {
|
||||
return null;
|
||||
}
|
||||
if (!$isAdmin && $balance->getUser() !== $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(AbsenceBalance::class)
|
||||
->createQueryBuilder('b')
|
||||
->orderBy('b.type', 'ASC')
|
||||
;
|
||||
|
||||
if (!$isAdmin) {
|
||||
$qb->andWhere('b.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (isset($filters['type'])) {
|
||||
$qb->andWhere('b.type = :type')->setParameter('type', $filters['type']);
|
||||
}
|
||||
if (isset($filters['period'])) {
|
||||
$qb->andWhere('b.period = :period')->setParameter('period', $filters['period']);
|
||||
}
|
||||
if ($isAdmin && isset($filters['user'])) {
|
||||
$qb->andWhere('b.user = :filterUser')
|
||||
->setParameter('filterUser', self::extractId($filters['user']))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private static function extractId(string $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : (int) basename($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
/**
|
||||
* Cancellation of an absence request. An employee may cancel their own PENDING
|
||||
* request; an admin may additionally cancel an APPROVED one, which credits the
|
||||
* deducted days back to the balance.
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceCancelProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
// Cancellation carries no payload: keep the persisted content intact.
|
||||
$previous = $context['previous_data'] ?? null;
|
||||
if ($previous instanceof AbsenceRequest) {
|
||||
$data->setType($previous->getType());
|
||||
$data->setStartDate($previous->getStartDate());
|
||||
$data->setEndDate($previous->getEndDate());
|
||||
$data->setStartHalfDay($previous->getStartHalfDay());
|
||||
$data->setEndHalfDay($previous->getEndHalfDay());
|
||||
$data->setReason($previous->getReason());
|
||||
$data->setCountedDays($previous->getCountedDays());
|
||||
$data->setStatus($previous->getStatus());
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
$status = $data->getStatus();
|
||||
|
||||
if (AbsenceStatus::Pending === $status) {
|
||||
$this->balanceService->release($data, false);
|
||||
} elseif (AbsenceStatus::Approved === $status) {
|
||||
if (!$isAdmin) {
|
||||
throw new AccessDeniedHttpException('Only an admin can cancel an approved request.');
|
||||
}
|
||||
$this->balanceService->release($data, true);
|
||||
} else {
|
||||
throw new ConflictHttpException('This request can no longer be cancelled.');
|
||||
}
|
||||
|
||||
// An employee may only cancel their own request (admins can cancel any).
|
||||
if (!$isAdmin && $data->getUser() !== $user) {
|
||||
throw new AccessDeniedHttpException('You can only cancel your own requests.');
|
||||
}
|
||||
|
||||
$data->setStatus(AbsenceStatus::Cancelled);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Handles creation of an absence request: computes the deducted days, enforces
|
||||
* the overlap rule, and reserves the days in the employee's pending balance.
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceRequestProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceDayCalculator $calculator,
|
||||
private AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$type = $data->getType();
|
||||
$startDate = $data->getStartDate();
|
||||
$endDate = $data->getEndDate();
|
||||
|
||||
if (null === $type || null === $startDate || null === $endDate) {
|
||||
throw new UnprocessableEntityHttpException('Type, start date and end date are required.');
|
||||
}
|
||||
|
||||
if ($endDate < $startDate) {
|
||||
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($type);
|
||||
if (null === $policy || !$policy->isActive()) {
|
||||
throw new UnprocessableEntityHttpException('This absence type is not available.');
|
||||
}
|
||||
|
||||
// Bereavement has no fixed entitlement: the relationship to the deceased
|
||||
// drives the legal number of days, so the reason is mandatory.
|
||||
if (AbsenceType::Bereavement === $type && '' === trim((string) $data->getReason())) {
|
||||
throw new UnprocessableEntityHttpException('A reason (relationship to the deceased) is required for bereavement leave.');
|
||||
}
|
||||
|
||||
if ($this->requestRepository->hasOverlap($user, $startDate, $endDate)) {
|
||||
throw new ConflictHttpException('This request overlaps an existing absence.');
|
||||
}
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$data->getStartHalfDay(),
|
||||
$data->getEndHalfDay(),
|
||||
$policy->isCountWorkingDaysOnly(),
|
||||
);
|
||||
|
||||
if ($countedDays <= 0.0) {
|
||||
throw new UnprocessableEntityHttpException('The selected range contains no working day.');
|
||||
}
|
||||
|
||||
$data->setUser($user);
|
||||
$data->setCountedDays($countedDays);
|
||||
$data->setStatus(AbsenceStatus::Pending);
|
||||
$data->setRejectionReason(null);
|
||||
$data->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($data);
|
||||
$this->balanceService->reservePending($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceRequestProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest|array|null
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
// Single item: owner or admin only
|
||||
if (isset($uriVariables['id'])) {
|
||||
$request = $this->requestRepository->findById((int) $uriVariables['id']);
|
||||
if (null === $request) {
|
||||
return null;
|
||||
}
|
||||
if (!$isAdmin && $request->getUser() !== $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(AbsenceRequest::class)
|
||||
->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
;
|
||||
|
||||
if (!$isAdmin) {
|
||||
$qb->andWhere('a.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (isset($filters['status'])) {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $filters['status']);
|
||||
}
|
||||
if (isset($filters['type'])) {
|
||||
$qb->andWhere('a.type = :type')->setParameter('type', $filters['type']);
|
||||
}
|
||||
if (isset($filters['year']) && is_numeric($filters['year'])) {
|
||||
$year = (int) $filters['year'];
|
||||
$qb->andWhere('a.startDate <= :yearEnd')
|
||||
->andWhere('a.endDate >= :yearStart')
|
||||
->setParameter('yearStart', sprintf('%d-01-01', $year))
|
||||
->setParameter('yearEnd', sprintf('%d-12-31', $year))
|
||||
;
|
||||
}
|
||||
if ($isAdmin && isset($filters['user'])) {
|
||||
$qb->andWhere('a.user = :filterUser')
|
||||
->setParameter('filterUser', self::extractId($filters['user']))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private static function extractId(string $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : (int) basename($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Admin approval / rejection of a pending absence request. The target status
|
||||
* is derived from the operation URI (.../approve or .../reject).
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceReviewProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
$isApprove = str_contains((string) $operation->getUriTemplate(), 'approve');
|
||||
$newRejectionReason = $data->getRejectionReason();
|
||||
|
||||
// Reviewing must never alter the request content: restore everything
|
||||
// from the persisted state, only status/review fields may change.
|
||||
$previous = $context['previous_data'] ?? null;
|
||||
if ($previous instanceof AbsenceRequest) {
|
||||
$data->setType($previous->getType());
|
||||
$data->setStartDate($previous->getStartDate());
|
||||
$data->setEndDate($previous->getEndDate());
|
||||
$data->setStartHalfDay($previous->getStartHalfDay());
|
||||
$data->setEndHalfDay($previous->getEndHalfDay());
|
||||
$data->setReason($previous->getReason());
|
||||
$data->setCountedDays($previous->getCountedDays());
|
||||
}
|
||||
|
||||
if (AbsenceStatus::Pending !== $data->getStatus()) {
|
||||
throw new ConflictHttpException('Only a pending request can be reviewed.');
|
||||
}
|
||||
|
||||
$admin = $this->security->getUser();
|
||||
assert($admin instanceof UserInterface);
|
||||
|
||||
if ($isApprove) {
|
||||
// Never let an approval push the balance below zero (CP only): the
|
||||
// days being accrued (N) are posable, but not beyond the entitlement.
|
||||
$available = $this->balanceService->availableForRequest($data);
|
||||
if (null !== $available && $data->getCountedDays() > $available + 1e-9) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||
$data->getCountedDays(),
|
||||
$available,
|
||||
));
|
||||
}
|
||||
|
||||
$data->setStatus(AbsenceStatus::Approved);
|
||||
$data->setRejectionReason(null);
|
||||
$this->balanceService->applyApproval($data);
|
||||
} else {
|
||||
if (null === $newRejectionReason || '' === trim($newRejectionReason)) {
|
||||
throw new UnprocessableEntityHttpException('A reason is required when rejecting a request.');
|
||||
}
|
||||
$data->setStatus(AbsenceStatus::Rejected);
|
||||
$data->setRejectionReason($newRejectionReason);
|
||||
$this->balanceService->release($data, false);
|
||||
}
|
||||
|
||||
$data->setReviewedAt(new DateTimeImmutable());
|
||||
$data->setReviewedBy($admin);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Command;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function preg_match;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Monthly paid-leave accrual. For each active employee it credits one twelfth
|
||||
* of their yearly entitlement (prorated by work-time ratio) to the current
|
||||
* reference-period balance. Idempotent per month thanks to lastAccruedMonth,
|
||||
* and it seeds the initial balance when the period balance is first created.
|
||||
*
|
||||
* Intended to run on the 1st of each month (cron). Notifications are out of
|
||||
* scope for now.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:absences:accrue-leave',
|
||||
description: 'Credit the monthly paid-leave accrual to every active employee',
|
||||
)]
|
||||
class AccrueLeaveCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('month', null, InputOption::VALUE_REQUIRED, 'Target month (YYYY-MM), defaults to the current month')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Compute and display without persisting')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$monthOpt = $input->getOption('month');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$firstDay = $monthOpt
|
||||
? new DateTimeImmutable($monthOpt.'-01')
|
||||
: new DateTimeImmutable('first day of this month');
|
||||
} catch (Exception) {
|
||||
$io->error('Invalid --month, expected format YYYY-MM.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$firstDay = $firstDay->setTime(0, 0);
|
||||
$lastDay = $firstDay->modify('last day of this month');
|
||||
$monthKey = $firstDay->format('Y-m');
|
||||
|
||||
$io->title(sprintf('Acquisition CP — %s%s', $monthKey, $dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$employees = $this->userRepository->findActiveEmployees($lastDay);
|
||||
if ([] === $employees) {
|
||||
$io->warning('Aucun salarié actif pour ce mois.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$accrued = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($employees as $user) {
|
||||
// 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);
|
||||
$isNew = null === $balance;
|
||||
|
||||
if ($isNew) {
|
||||
$balance = $this->balanceService->getOrCreateBalance($user, AbsenceType::PaidLeave, $period);
|
||||
// On a new period, the previous period's "en cours d'acquisition" (N)
|
||||
// becomes this period's acquired (N-1). At roll-out (no prior balance)
|
||||
// seed the configured initial balance instead.
|
||||
$previousPeriod = self::previousPeriod($period);
|
||||
$previousBalance = null !== $previousPeriod
|
||||
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
|
||||
: null;
|
||||
$balance->setAcquired(
|
||||
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($monthKey === $balance->getLastAccruedMonth()) {
|
||||
++$skipped;
|
||||
$rows[] = [$user->getUsername(), $period, number_format($balance->getAcquired(), 2), number_format($balance->getAcquiring(), 2), 'déjà fait'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance->setAcquiring($balance->getAcquiring() + $rate);
|
||||
$balance->setLastAccruedMonth($monthKey);
|
||||
++$accrued;
|
||||
|
||||
$seeded = $isNew && (null !== self::previousPeriod($period) || $profile->getInitialLeaveBalance() > 0);
|
||||
$rows[] = [
|
||||
$user->getUsername(),
|
||||
$period,
|
||||
number_format($balance->getAcquired(), 2),
|
||||
number_format($balance->getAcquiring(), 2),
|
||||
sprintf('+%s%s', number_format($rate, 2), $seeded && $balance->getAcquired() > 0 ? ' (N-1 reporté)' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$io->table(['Salarié', 'Période', 'Acquis (N-1)', 'En cours (N)', 'Action'], $rows);
|
||||
$io->success(sprintf('%d crédité(s), %d ignoré(s)%s.', $accrued, $skipped, $dryRun ? ' (dry-run, rien enregistré)' : ''));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Previous reference period for a "YYYY-YYYY" paid-leave period, or null. */
|
||||
private static function previousPeriod(string $period): ?string
|
||||
{
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{4})$/', $period, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%d-%d', (int) $m[1] - 1, (int) $m[2] - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin calendar view: all pending/approved absences overlapping a date range.
|
||||
*/
|
||||
class AbsenceCalendarController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$fromRaw = (string) $request->query->get('from', '');
|
||||
$toRaw = (string) $request->query->get('to', '');
|
||||
|
||||
if ('' === $fromRaw || '' === $toRaw) {
|
||||
throw new UnprocessableEntityHttpException('Query parameters "from" and "to" are required.');
|
||||
}
|
||||
|
||||
$from = new DateTimeImmutable($fromRaw);
|
||||
$to = new DateTimeImmutable($toRaw);
|
||||
|
||||
$absences = $this->requestRepository->findInRange($from, $to);
|
||||
|
||||
return $this->json($absences, context: ['groups' => ['absence_request:read']]);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Streams the justification file of an absence request. Owner or admin only.
|
||||
*/
|
||||
class AbsenceJustificationDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id): BinaryFileResponse
|
||||
{
|
||||
$absence = $this->requestRepository->findById($id);
|
||||
if (null === $absence) {
|
||||
throw new NotFoundHttpException('Absence request not found.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException('You do not have access to this file.');
|
||||
}
|
||||
|
||||
$fileName = $absence->getJustificationFileName();
|
||||
if (null === $fileName) {
|
||||
throw new NotFoundHttpException('No justification file for this request.');
|
||||
}
|
||||
|
||||
$filePath = $this->uploadDir.'/'.$fileName;
|
||||
if (!file_exists($filePath)) {
|
||||
throw new NotFoundHttpException('File not found on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
|
||||
$disposition = (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType)
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response->setContentDisposition($disposition, $fileName);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Uploads a justification file (PDF / image) for an absence request. The owner
|
||||
* or an admin may upload; the server-detected MIME type is validated.
|
||||
*/
|
||||
class AbsenceJustificationUploadController extends AbstractController
|
||||
{
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
private const MIME_TO_EXTENSION = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'application/pdf' => 'pdf',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_upload', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$absence = $this->requestRepository->findById($id);
|
||||
if (null === $absence) {
|
||||
throw new NotFoundHttpException('Absence request not found.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException('You can only attach a file to your own request.');
|
||||
}
|
||||
|
||||
$file = $request->files->get('file');
|
||||
if (null === $file || !$file->isValid()) {
|
||||
throw new BadRequestHttpException('No valid file uploaded.');
|
||||
}
|
||||
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||
throw new BadRequestHttpException('File size exceeds 10 MB limit.');
|
||||
}
|
||||
|
||||
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
|
||||
if (!isset(self::MIME_TO_EXTENSION[$mimeType])) {
|
||||
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed (PDF or image only).', $mimeType));
|
||||
}
|
||||
|
||||
$fileName = Uuid::v4()->toRfc4122().'.'.self::MIME_TO_EXTENSION[$mimeType];
|
||||
|
||||
if (!is_dir($this->uploadDir)) {
|
||||
mkdir($this->uploadDir, 0o775, true);
|
||||
}
|
||||
|
||||
// Remove a previously uploaded file if any
|
||||
$previous = $absence->getJustificationFileName();
|
||||
if (null !== $previous && file_exists($this->uploadDir.'/'.$previous)) {
|
||||
unlink($this->uploadDir.'/'.$previous);
|
||||
}
|
||||
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$absence->setJustificationFileName($fileName);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($absence, context: ['groups' => ['absence_request:read']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Dry-run endpoint for the "new request" form: returns the number of deducted
|
||||
* days and the projected balance without creating anything. Required because
|
||||
* public holidays are computed server-side.
|
||||
*/
|
||||
class AbsencePreviewController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly AbsenceDayCalculator $calculator,
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/preview', name: 'absence_request_preview', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
$type = AbsenceType::tryFrom((string) ($payload['type'] ?? ''));
|
||||
if (null === $type) {
|
||||
throw new UnprocessableEntityHttpException('Unknown absence type.');
|
||||
}
|
||||
|
||||
$startRaw = (string) ($payload['startDate'] ?? '');
|
||||
$endRaw = (string) ($payload['endDate'] ?? '');
|
||||
if ('' === $startRaw || '' === $endRaw) {
|
||||
throw new UnprocessableEntityHttpException('Start date and end date are required.');
|
||||
}
|
||||
|
||||
$start = new DateTimeImmutable($startRaw);
|
||||
$end = new DateTimeImmutable($endRaw);
|
||||
if ($end < $start) {
|
||||
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($type);
|
||||
$workingDaysOnly = $policy?->isCountWorkingDaysOnly() ?? true;
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays(
|
||||
$start,
|
||||
$end,
|
||||
isset($payload['startHalfDay']) ? HalfDay::tryFrom((string) $payload['startHalfDay']) : null,
|
||||
isset($payload['endHalfDay']) ? HalfDay::tryFrom((string) $payload['endHalfDay']) : null,
|
||||
$workingDaysOnly,
|
||||
);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$available = null;
|
||||
$projectedAvailable = null;
|
||||
$period = null;
|
||||
|
||||
if ($type->decrementsBalance()) {
|
||||
$period = $this->balanceService->periodFor($user, $type, $start);
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
|
||||
$available = $balance?->getAvailable() ?? 0.0;
|
||||
$projectedAvailable = $available - $countedDays;
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'countedDays' => $countedDays,
|
||||
'period' => $period,
|
||||
'available' => $available,
|
||||
'projectedAvailable' => $projectedAvailable,
|
||||
'justificationRequired' => $policy?->isJustificationRequired() ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Service\PublicHolidayProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Exposes French public holidays so the front (calendar, date pickers) can
|
||||
* display them — the dates are computed server-side in pure PHP.
|
||||
*/
|
||||
class PublicHolidayController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PublicHolidayProvider $holidayProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/public_holidays', name: 'public_holidays', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$fromRaw = (string) $request->query->get('from', '');
|
||||
$toRaw = (string) $request->query->get('to', '');
|
||||
|
||||
if ('' !== $fromRaw && '' !== $toRaw) {
|
||||
$fromYear = (int) new DateTimeImmutable($fromRaw)->format('Y');
|
||||
$toYear = (int) new DateTimeImmutable($toRaw)->format('Y');
|
||||
} else {
|
||||
$fromYear = $toYear = (int) ($request->query->get('year') ?: date('Y'));
|
||||
}
|
||||
|
||||
$holidays = [];
|
||||
for ($year = $fromYear; $year <= $toYear; ++$year) {
|
||||
$holidays += $this->holidayProvider->getHolidays($year);
|
||||
}
|
||||
|
||||
return $this->json($holidays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Doctrine;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AbsenceBalance>
|
||||
*/
|
||||
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([
|
||||
'user' => $user,
|
||||
'type' => $type,
|
||||
'period' => $period,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Doctrine;
|
||||
|
||||
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 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Doctrine;
|
||||
|
||||
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;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AbsenceRequest>
|
||||
*/
|
||||
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
|
||||
* end_a >= start_b.
|
||||
*/
|
||||
public function hasOverlap(
|
||||
UserInterface $user,
|
||||
DateTimeInterface $startDate,
|
||||
DateTimeInterface $endDate,
|
||||
?int $excludeId = null,
|
||||
): bool {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->andWhere('a.user = :user')
|
||||
->andWhere('a.status IN (:statuses)')
|
||||
->andWhere('a.startDate <= :endDate')
|
||||
->andWhere('a.endDate >= :startDate')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
|
||||
->setParameter('startDate', $startDate->format('Y-m-d'))
|
||||
->setParameter('endDate', $endDate->format('Y-m-d'))
|
||||
;
|
||||
|
||||
if (null !== $excludeId) {
|
||||
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absences (approved or pending) overlapping a date range, all employees —
|
||||
* used by the admin calendar view.
|
||||
*
|
||||
* @return AbsenceRequest[]
|
||||
*/
|
||||
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.status IN (:statuses)')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->andWhere('a.endDate >= :from')
|
||||
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
|
||||
->setParameter('from', $from->format('Y-m-d'))
|
||||
->setParameter('to', $to->format('Y-m-d'))
|
||||
->orderBy('a.startDate', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AbsenceRequest[]
|
||||
*/
|
||||
public function findFiltered(
|
||||
?UserInterface $user = null,
|
||||
?AbsenceStatus $status = null,
|
||||
?AbsenceType $type = null,
|
||||
?DateTimeInterface $from = null,
|
||||
?DateTimeInterface $to = null,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC');
|
||||
|
||||
if (null !== $user) {
|
||||
$qb->andWhere('a.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
if (null !== $status) {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
if (null !== $type) {
|
||||
$qb->andWhere('a.type = :type')->setParameter('type', $type);
|
||||
}
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d'));
|
||||
}
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d'));
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'cancel-absence-request', description: 'Cancel an absence request. A PENDING request releases its reserved days; an APPROVED request can only be cancelled by an admin and credits the taken days back. Already cancelled/rejected requests cannot be cancelled.')]
|
||||
class CancelAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
$status = $request->getStatus();
|
||||
|
||||
if (AbsenceStatus::Pending === $status) {
|
||||
$this->balanceService->release($request, false);
|
||||
} elseif (AbsenceStatus::Approved === $status) {
|
||||
if (!$isAdmin) {
|
||||
throw new AccessDeniedException('Only an admin can cancel an approved request.');
|
||||
}
|
||||
$this->balanceService->release($request, true);
|
||||
} else {
|
||||
throw new InvalidArgumentException('This request can no longer be cancelled.');
|
||||
}
|
||||
|
||||
$request->setStatus(AbsenceStatus::Cancelled);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
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;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-absence-request', description: 'Create an absence request on behalf of an employee (userId). Validates active policy + no overlap, computes deducted working days, sets status=pending and reserves the days in the pending balance. type: cp|mariage_pacs|conge_parental|deces|maladie. Dates YYYY-MM-DD. halfDay (matin|apres_midi) on a boundary subtracts 0.5.')]
|
||||
class CreateAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceDayCalculator $calculator,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $userId,
|
||||
string $type,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?string $startHalfDay = null,
|
||||
?string $endHalfDay = null,
|
||||
?string $reason = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findById($userId)
|
||||
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
|
||||
$typeEnum = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
|
||||
$start = new DateTimeImmutable($startDate);
|
||||
$end = new DateTimeImmutable($endDate);
|
||||
if ($end < $start) {
|
||||
throw new InvalidArgumentException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$startHd = null !== $startHalfDay
|
||||
? (HalfDay::tryFrom($startHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $startHalfDay)))
|
||||
: null;
|
||||
$endHd = null !== $endHalfDay
|
||||
? (HalfDay::tryFrom($endHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $endHalfDay)))
|
||||
: null;
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($typeEnum);
|
||||
if (null === $policy || !$policy->isActive()) {
|
||||
throw new InvalidArgumentException('This absence type is not available.');
|
||||
}
|
||||
|
||||
// Bereavement has no fixed entitlement: the relationship to the deceased
|
||||
// drives the legal number of days, so the reason is mandatory.
|
||||
if (AbsenceType::Bereavement === $typeEnum && '' === trim((string) $reason)) {
|
||||
throw new InvalidArgumentException('A reason (relationship to the deceased) is required for bereavement leave.');
|
||||
}
|
||||
|
||||
if ($this->requestRepository->hasOverlap($user, $start, $end)) {
|
||||
throw new InvalidArgumentException('This request overlaps an existing absence.');
|
||||
}
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays($start, $end, $startHd, $endHd, $policy->isCountWorkingDaysOnly());
|
||||
if ($countedDays <= 0.0) {
|
||||
throw new InvalidArgumentException('The selected range contains no working day.');
|
||||
}
|
||||
|
||||
$request = new AbsenceRequest();
|
||||
$request->setUser($user);
|
||||
$request->setType($typeEnum);
|
||||
$request->setStartDate($start);
|
||||
$request->setEndDate($end);
|
||||
$request->setStartHalfDay($startHd);
|
||||
$request->setEndHalfDay($endHd);
|
||||
$request->setReason($reason);
|
||||
$request->setCountedDays($countedDays);
|
||||
$request->setStatus(AbsenceStatus::Pending);
|
||||
$request->setRejectionReason(null);
|
||||
$request->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($request);
|
||||
$this->balanceService->reservePending($request);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-absence-request', description: 'Permanently delete an absence request (admin). Does not adjust balances — use cancel-absence-request first if the days must be credited back.')]
|
||||
class DeleteAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($request);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('AbsenceRequest %d deleted.', $id)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-absence-request', description: 'Get a single absence request by ID.')]
|
||||
class GetAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
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;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'list-absence-balances', description: 'List leave balances. Optional filters: userId, type (cp, mariage_pacs, conge_parental, deces, maladie), period (e.g. "2025-2026" or "2025").')]
|
||||
class ListAbsenceBalancesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(?int $userId = null, ?string $type = null, ?string $period = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $userId) {
|
||||
$user = $this->userRepository->findById($userId);
|
||||
if (null === $user) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
}
|
||||
$criteria['user'] = $user;
|
||||
}
|
||||
if (null !== $type) {
|
||||
$criteria['type'] = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
}
|
||||
if (null !== $period) {
|
||||
$criteria['period'] = $period;
|
||||
}
|
||||
|
||||
$balances = $this->balanceRepository->findBy($criteria, ['period' => 'DESC']);
|
||||
|
||||
return json_encode(array_map(Serializer::absenceBalance(...), $balances));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-absence-policies', description: 'List all absence policies (one per absence type) with their rules: days/year, days/event, notice period, working-days mode, active flag.')]
|
||||
class ListAbsencePoliciesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$policies = $this->policyRepository->findBy([], ['type' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(Serializer::absencePolicy(...), $policies));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'list-absence-requests', description: 'List absence requests. Optional filters: userId, status (pending, approved, rejected, cancelled), type (cp, mariage_pacs, conge_parental, deces, maladie), from/to (YYYY-MM-DD, overlap window). Without userId returns all employees.')]
|
||||
class ListAbsenceRequestsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $userId = null,
|
||||
?string $status = null,
|
||||
?string $type = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$user = null;
|
||||
if (null !== $userId) {
|
||||
$user = $this->userRepository->findById($userId)
|
||||
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
}
|
||||
|
||||
$statusEnum = null;
|
||||
if (null !== $status) {
|
||||
$statusEnum = AbsenceStatus::tryFrom($status)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown status "%s".', $status));
|
||||
}
|
||||
|
||||
$typeEnum = null;
|
||||
if (null !== $type) {
|
||||
$typeEnum = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
}
|
||||
|
||||
$requests = $this->requestRepository->findFiltered(
|
||||
$user,
|
||||
$statusEnum,
|
||||
$typeEnum,
|
||||
null !== $from ? new DateTimeImmutable($from) : null,
|
||||
null !== $to ? new DateTimeImmutable($to) : null,
|
||||
);
|
||||
|
||||
return json_encode(array_map(Serializer::absenceRequest(...), $requests));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
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;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function assert;
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'review-absence-request', description: 'Approve or reject a PENDING absence request (admin). decision = "approve" or "reject"; rejectionReason is required when rejecting. Approving moves the days from pending to taken; rejecting releases the reserved days.')]
|
||||
class ReviewAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, string $decision, ?string $rejectionReason = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
if (!in_array($decision, ['approve', 'reject'], true)) {
|
||||
throw new InvalidArgumentException('decision must be "approve" or "reject".');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (AbsenceStatus::Pending !== $request->getStatus()) {
|
||||
throw new InvalidArgumentException('Only a pending request can be reviewed.');
|
||||
}
|
||||
|
||||
$admin = $this->security->getUser();
|
||||
assert($admin instanceof UserInterface);
|
||||
|
||||
if ('approve' === $decision) {
|
||||
// Never let an approval push the balance below zero (CP only).
|
||||
$available = $this->balanceService->availableForRequest($request);
|
||||
if (null !== $available && $request->getCountedDays() > $available + 1e-9) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||
$request->getCountedDays(),
|
||||
$available,
|
||||
));
|
||||
}
|
||||
|
||||
$request->setStatus(AbsenceStatus::Approved);
|
||||
$request->setRejectionReason(null);
|
||||
$this->balanceService->applyApproval($request);
|
||||
} else {
|
||||
if (null === $rejectionReason || '' === trim($rejectionReason)) {
|
||||
throw new InvalidArgumentException('A reason is required when rejecting a request.');
|
||||
}
|
||||
$request->setStatus(AbsenceStatus::Rejected);
|
||||
$request->setRejectionReason($rejectionReason);
|
||||
$this->balanceService->release($request, false);
|
||||
}
|
||||
|
||||
$request->setReviewedAt(new DateTimeImmutable());
|
||||
$request->setReviewedBy($admin);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-absence-balance', description: 'Manually adjust a leave balance (admin regularisation). Only provided buckets change: acquired (N-1), acquiring (N), taken.')]
|
||||
class UpdateAbsenceBalanceTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?float $acquired = null,
|
||||
?float $acquiring = null,
|
||||
?float $taken = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$balance = $this->balanceRepository->findById($id);
|
||||
if (null === $balance) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $acquired) {
|
||||
$balance->setAcquired($acquired);
|
||||
}
|
||||
if (null !== $acquiring) {
|
||||
$balance->setAcquiring($acquiring);
|
||||
}
|
||||
if (null !== $taken) {
|
||||
$balance->setTaken($taken);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceBalance($balance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-absence-policy', description: 'Update an absence policy (admin). Only provided fields change.')]
|
||||
class UpdateAbsencePolicyTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?float $daysPerYear = null,
|
||||
?float $daysPerEvent = null,
|
||||
?bool $justificationRequired = null,
|
||||
?int $noticeDays = null,
|
||||
?bool $countWorkingDaysOnly = null,
|
||||
?bool $active = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findById($id);
|
||||
if (null === $policy) {
|
||||
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $daysPerYear) {
|
||||
$policy->setDaysPerYear($daysPerYear);
|
||||
}
|
||||
if (null !== $daysPerEvent) {
|
||||
$policy->setDaysPerEvent($daysPerEvent);
|
||||
}
|
||||
if (null !== $justificationRequired) {
|
||||
$policy->setJustificationRequired($justificationRequired);
|
||||
}
|
||||
if (null !== $noticeDays) {
|
||||
$policy->setNoticeDays($noticeDays);
|
||||
}
|
||||
if (null !== $countWorkingDaysOnly) {
|
||||
$policy->setCountWorkingDaysOnly($countWorkingDaysOnly);
|
||||
}
|
||||
if (null !== $active) {
|
||||
$policy->setActive($active);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absencePolicy($policy));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user