48 KiB
Contract Suspension Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Allow suspending employee contracts, with multiple suspensions per contract period. Suspensions reduce accruing leave days (CDI/CDD non forfait) and acquired days (forfait 218) prorata.
Architecture: New ContractSuspension entity (OneToMany from EmployeeContractPeriod). Dedicated API endpoint for CRUD. Leave calculation modified in both EmployeeLeaveSummaryProvider (live display) and LeaveBalanceComputationService (rollover). Frontend drawer gets tabbed UI with Clôturer/Suspendre.
Tech Stack: Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript (frontend)
Spec: docs/superpowers/specs/2026-03-12-contract-suspension-design.md
No commits, no pushes — user handles git.
File Structure
| File | Action | Responsibility |
|---|---|---|
src/Entity/ContractSuspension.php |
Create | New entity with startDate, endDate, comment |
src/Repository/ContractSuspensionRepository.php |
Create | Repository for ContractSuspension |
src/Entity/EmployeeContractPeriod.php |
Modify | Add OneToMany relation to suspensions |
migrations/Version20260312140000.php |
Create | Create contract_suspensions table |
src/State/ContractSuspensionWriteProcessor.php |
Create | Custom processor: validation + persistence |
tests/State/ContractSuspensionWriteProcessorTest.php |
Create | Tests for suspension processor |
src/Dto/Employees/ContractHistoryItem.php |
Modify | Add periodId + suspensions array |
src/Entity/Employee.php |
Modify | Add getContractPeriods(), getCurrentSuspensions() |
src/Service/Leave/SuspensionDaysCalculator.php |
Create | Extract suspension day counting logic (shared) |
tests/Service/Leave/SuspensionDaysCalculatorTest.php |
Create | Tests for suspension calculator |
src/State/EmployeeLeaveSummaryProvider.php |
Modify | Use SuspensionDaysCalculator in accrual + forfait |
src/Service/Leave/LeaveBalanceComputationService.php |
Modify | Use SuspensionDaysCalculator in accrual + forfait |
tests/State/EmployeeLeaveSummaryProviderTest.php |
Modify | Test suspension impact on leave |
frontend/services/dto/employee.ts |
Modify | Add ContractSuspension type and fields |
frontend/services/contractSuspensions.ts |
Create | API service for suspension CRUD |
frontend/components/employees/ContractTab.vue |
Modify | Tabbed drawer (Clôturer/Suspendre) |
frontend/composables/useEmployeeDetailPage.ts |
Modify | Suspension form state + submit logic |
Chunk 1: Entity, Migration, Repository
Task 1: ContractSuspension Entity
Files:
-
Create:
src/Entity/ContractSuspension.php -
Create:
src/Repository/ContractSuspensionRepository.php -
Step 1: Create the repository
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ContractSuspension;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContractSuspension>
*/
class ContractSuspensionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContractSuspension::class);
}
}
- Step 2: Create the entity
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ContractSuspensionRepository;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ApiResource(
operations: [
new GetCollection(),
new Post(processor: ContractSuspensionWriteProcessor::class),
new Patch(processor: ContractSuspensionWriteProcessor::class),
],
normalizationContext: ['groups' => ['suspension:read']],
denormalizationContext: ['groups' => ['suspension:write']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
)]
#[ORM\Entity(repositoryClass: ContractSuspensionRepository::class)]
#[ORM\Table(name: 'contract_suspensions')]
#[ORM\Index(columns: ['contract_period_id', 'start_date'], name: 'idx_suspension_period_start')]
class ContractSuspension
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['suspension:read', 'employee:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EmployeeContractPeriod::class, inversedBy: 'suspensions')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['suspension:write'])]
private ?EmployeeContractPeriod $contractPeriod = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private DateTimeImmutable $startDate;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
}
public function getId(): ?int
{
return $this->id;
}
public function getContractPeriod(): ?EmployeeContractPeriod
{
return $this->contractPeriod;
}
public function setContractPeriod(?EmployeeContractPeriod $contractPeriod): self
{
$this->contractPeriod = $contractPeriod;
return $this;
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
- Step 3: Verify entity is syntactically correct
Run: docker exec php-sirh-fpm php -l src/Entity/ContractSuspension.php
Expected: No syntax errors
Task 2: Add OneToMany relation on EmployeeContractPeriod
Files:
-
Modify:
src/Entity/EmployeeContractPeriod.php -
Step 1: Add the suspensions collection
Add import at top of file (after existing use statements):
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
Add property after $comment (after line 44):
/**
* @var Collection<int, ContractSuspension>
*/
#[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])]
private Collection $suspensions;
In the constructor (line 49-53), add after $this->startDate = ...:
$this->suspensions = new ArrayCollection();
Add getter after setComment() method (after line 153):
/**
* @return Collection<int, ContractSuspension>
*/
public function getSuspensions(): Collection
{
return $this->suspensions;
}
Task 3: Migration
Files:
-
Create:
migrations/Version20260312140000.php -
Step 1: Create the migration file
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260312140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create contract_suspensions table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contract_suspensions (
id SERIAL PRIMARY KEY,
contract_period_id INT NOT NULL REFERENCES employee_contract_periods(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE DEFAULT NULL,
comment TEXT DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)');
$this->addSql('CREATE INDEX idx_suspension_period_start ON contract_suspensions (contract_period_id, start_date)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE contract_suspensions');
}
}
- Step 2: Run migration
Run: docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
Expected: Migration applied successfully
- Step 3: Verify schema is in sync
Run: docker exec php-sirh-fpm php bin/console doctrine:schema:validate
Expected: OK (or only pre-existing warnings)
Task 4: Expose suspensions in Employee read API
Files:
-
Modify:
src/Dto/Employees/ContractHistoryItem.php -
Modify:
src/Entity/Employee.php -
Step 1: Add periodId and suspensions to ContractHistoryItem DTO
In src/Dto/Employees/ContractHistoryItem.php, add two new constructor parameters after $comment (line 25):
#[Groups(['employee:read'])]
public ?string $comment = null,
#[Groups(['employee:read'])]
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
- Step 2: Update Employee::getContractHistory() to include suspensions
In src/Entity/Employee.php, find the getContractHistory() method (around line 253). In the array_map callback, update the ContractHistoryItem construction to pass suspensions:
Replace the current return new ContractHistoryItem(...) block with:
$suspensionData = array_map(
static fn (ContractSuspension $s): array => [
'id' => $s->getId(),
'startDate' => $s->getStartDate()->format('Y-m-d'),
'endDate' => $s->getEndDate()?->format('Y-m-d'),
'comment' => $s->getComment(),
],
$period->getSuspensions()->toArray()
);
return new ContractHistoryItem(
contractId: $contract?->getId(),
contractName: $contract?->getName(),
weeklyHours: $contract?->getWeeklyHours(),
contractNature: $period->getContractNatureEnum()->value,
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
);
Add use App\Entity\ContractSuspension; at the top of Employee.php if not already present.
- Step 3: Add getContractPeriods() getter to Employee
In src/Entity/Employee.php, add a public getter for the $contractPeriods collection (needed by leave calculation to access suspensions):
/**
* @return Collection<int, EmployeeContractPeriod>
*/
public function getContractPeriods(): Collection
{
return $this->contractPeriods;
}
- Step 4: Add getCurrentSuspensions() to Employee
In src/Entity/Employee.php, add a new method after getCurrentContractEndDate() (around line 247):
/**
* @return list<array{id: int|null, startDate: string, endDate: string|null, comment: string|null}>
*/
#[Groups(['employee:read'])]
public function getCurrentSuspensions(): array
{
$currentPeriod = $this->resolveCurrentContractPeriod();
if (null === $currentPeriod) {
return [];
}
return array_values(array_map(
static fn (ContractSuspension $s): array => [
'id' => $s->getId(),
'startDate' => $s->getStartDate()->format('Y-m-d'),
'endDate' => $s->getEndDate()?->format('Y-m-d'),
'comment' => $s->getComment(),
],
$currentPeriod->getSuspensions()->toArray()
));
}
- Step 5: Verify no syntax errors
Run: docker exec php-sirh-fpm php -l src/Entity/Employee.php && docker exec php-sirh-fpm php -l src/Dto/Employees/ContractHistoryItem.php
Expected: No syntax errors
Chunk 2: Suspension API Processor
Task 5: ContractSuspensionWriteProcessor
Files:
-
Create:
src/State/ContractSuspensionWriteProcessor.php -
Create:
tests/State/ContractSuspensionWriteProcessorTest.php -
Step 1: Write the test — valid suspension creation
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class ContractSuspensionWriteProcessorTest extends TestCase
{
public function testPersistsValidSuspension(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-03-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-04-30'));
$suspension->setComment('Congé sans solde');
$persistProcessor = $this->createMock(ProcessorInterface::class);
$persistProcessor->expects(self::once())->method('process')->willReturn($suspension);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$result = $processor->process($suspension, new Post());
self::assertSame($suspension, $result);
}
public function testRejectsEndDateBeforeStartDate(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-05-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-03-01'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsStartDateBeforePeriodStart(): void
{
$period = $this->buildPeriodWithId(1, '2026-06-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-01-01'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsOverlappingSuspension(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$existing = new ContractSuspension();
$existing->setContractPeriod($period);
$existing->setStartDate(new DateTimeImmutable('2026-03-01'));
$existing->setEndDate(new DateTimeImmutable('2026-04-30'));
$period->getSuspensions()->add($existing);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-04-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-05-31'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsClosedPeriod(): void
{
$period = $this->buildPeriodWithId(1, '2025-01-01', '2025-12-31');
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2025-06-01'));
$suspension->setEndDate(new DateTimeImmutable('2025-07-31'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
private function buildPeriodWithId(int $id, string $start, ?string $end): EmployeeContractPeriod
{
$period = new EmployeeContractPeriod();
$period->setStartDate(new DateTimeImmutable($start));
if (null !== $end) {
$period->setEndDate(new DateTimeImmutable($end));
}
$period->setContractNature(ContractNature::CDI);
$ref = new ReflectionProperty($period, 'id');
$ref->setValue($period, $id);
return $period;
}
}
- Step 2: Run tests to verify they fail
Run: docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php
Expected: FAIL — class ContractSuspensionWriteProcessor not found
- Step 3: Implement the processor
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class ContractSuspensionWriteProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private EntityManagerInterface $entityManager,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): mixed {
if (!$data instanceof ContractSuspension) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$period = $data->getContractPeriod();
if (!$period instanceof EmployeeContractPeriod) {
throw new UnprocessableEntityHttpException('contractPeriod is required.');
}
$this->validate($data, $period);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
$startDate = $suspension->getStartDate();
$endDate = $suspension->getEndDate();
$periodEnd = $period->getEndDate();
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < new DateTimeImmutable('today')) {
throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.');
}
if ($endDate instanceof DateTimeImmutable && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.');
}
if ($startDate < $period->getStartDate()) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.');
}
$periodEnd = $period->getEndDate();
if ($periodEnd instanceof DateTimeImmutable) {
if ($startDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.');
}
if ($endDate instanceof DateTimeImmutable && $endDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas se terminer après la fin du contrat.');
}
}
$this->validateNoOverlap($suspension, $period);
}
private function validateNoOverlap(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
$start = $suspension->getStartDate();
$end = $suspension->getEndDate();
foreach ($period->getSuspensions() as $existing) {
if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) {
continue;
}
$existingStart = $existing->getStartDate();
$existingEnd = $existing->getEndDate();
if (null === $end && null === $existingEnd) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
if (null === $end) {
if ($start <= $existingEnd) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
continue;
}
if (null === $existingEnd) {
if ($existingStart <= $end) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
continue;
}
if ($start <= $existingEnd && $end >= $existingStart) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
}
}
}
- Step 4: Run tests to verify they pass
Run: docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php
Expected: All 5 tests PASS
Chunk 3: Leave Calculation — SuspensionDaysCalculator
Task 6: SuspensionDaysCalculator (shared logic)
Both EmployeeLeaveSummaryProvider and LeaveBalanceComputationService need the same logic to subtract suspended days from a month. Extract this into a small dedicated service.
Files:
-
Create:
src/Service/Leave/SuspensionDaysCalculator.php -
Create:
tests/Service/Leave/SuspensionDaysCalculatorTest.php -
Step 1: Write the test
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Service\Leave\SuspensionDaysCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SuspensionDaysCalculatorTest extends TestCase
{
public function testNoSuspensionsReturnsZero(): void
{
$calc = new SuspensionDaysCalculator();
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[]
);
self::assertSame(0, $result);
}
public function testFullMonthSuspension(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-01', '2026-03-31');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(31, $result);
}
public function testPartialMonthSuspension(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-10', '2026-03-20');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(11, $result);
}
public function testSuspensionSpanningMultipleMonths(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-02-15', '2026-04-10');
// March fully covered
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(31, $result);
}
public function testSuspensionWithoutEndDate(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-15', null);
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(17, $result);
}
public function testMultipleSuspensionsInSameMonth(): void
{
$calc = new SuspensionDaysCalculator();
$s1 = $this->buildSuspension('2026-03-01', '2026-03-10');
$s2 = $this->buildSuspension('2026-03-20', '2026-03-25');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$s1, $s2]
);
self::assertSame(16, $result);
}
public function testSuspensionOutsideMonthReturnsZero(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-01-01', '2026-01-31');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(0, $result);
}
public function testCountSuspendedBusinessDays(): void
{
$calc = new SuspensionDaysCalculator();
// March 2-6, 2026 = Mon-Fri = 5 business days
$suspension = $this->buildSuspension('2026-03-02', '2026-03-06');
$result = $calc->countSuspendedBusinessDays(
new DateTimeImmutable('2026-01-01'),
new DateTimeImmutable('2026-12-31'),
[$suspension],
[]
);
self::assertSame(5, $result);
}
private function buildSuspension(string $start, ?string $end): ContractSuspension
{
$s = new ContractSuspension();
$s->setStartDate(new DateTimeImmutable($start));
if (null !== $end) {
$s->setEndDate(new DateTimeImmutable($end));
}
return $s;
}
}
- Step 2: Run tests to verify they fail
Run: docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php
Expected: FAIL — class not found
- Step 3: Implement SuspensionDaysCalculator
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\ContractSuspension;
use DateTimeImmutable;
final class SuspensionDaysCalculator
{
/**
* Count calendar days suspended within a month window [monthStart, monthEnd].
*
* @param list<ContractSuspension> $suspensions
*/
public function countSuspendedDaysInMonth(
DateTimeImmutable $monthStart,
DateTimeImmutable $monthEnd,
array $suspensions
): int {
$total = 0;
foreach ($suspensions as $suspension) {
$sStart = $suspension->getStartDate();
$sEnd = $suspension->getEndDate() ?? $monthEnd;
$overlapStart = $sStart > $monthStart ? $sStart : $monthStart;
$overlapEnd = $sEnd < $monthEnd ? $sEnd : $monthEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
$total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1;
}
return $total;
}
/**
* Count business days (Mon-Fri, excl. public holidays) suspended within a period.
*
* @param list<ContractSuspension> $suspensions
* @param array<string, string> $publicHolidays map of Y-m-d => label
*/
public function countSuspendedBusinessDays(
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
array $suspensions,
array $publicHolidays
): int {
$total = 0;
foreach ($suspensions as $suspension) {
$sStart = $suspension->getStartDate();
$sEnd = $suspension->getEndDate() ?? $periodEnd;
$overlapStart = $sStart > $periodStart ? $sStart : $periodStart;
$overlapEnd = $sEnd < $periodEnd ? $sEnd : $periodEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
for ($cursor = $overlapStart; $cursor <= $overlapEnd; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
$dayKey = $cursor->format('Y-m-d');
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
++$total;
}
}
}
return $total;
}
}
- Step 4: Run tests to verify they pass
Run: docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php
Expected: All 8 tests PASS
Chunk 4: Integrate suspension into leave calculation
Task 7: Modify EmployeeLeaveSummaryProvider
Files:
-
Modify:
src/State/EmployeeLeaveSummaryProvider.php -
Step 1: Inject SuspensionDaysCalculator
Add use App\Service\Leave\SuspensionDaysCalculator; to imports.
Add to constructor: private SuspensionDaysCalculator $suspensionDaysCalculator
- Step 2: Modify computeAccruedDaysFromStart signature
Change method signature at line 333 to add suspensions parameter:
private function computeAccruedDaysFromStart(
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
- Step 3: Add suspension subtraction in the month loop
Inside the while loop (after line 358 $coveredDays = ...), add:
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
- Step 4: Resolve suspensions and pass to computeAccruedDaysFromStart calls
Find where computeAccruedDaysFromStart is called (around lines 173-187). Before those calls, resolve the suspensions for the current period:
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
Then pass $suspensions as the 5th argument to both calls:
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 0.0;
- Step 5: Modify the forfait branch to subtract suspended business days
In the forfait branch (around line 225-234), replace $acquiredDays = $carryDays + $leavePolicy['acquiredDays']; (line 227) with:
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
$suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to);
if ([] !== $suspensions) {
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays);
$totalBusinessDays = $this->countBusinessDays($from, $to);
if ($totalBusinessDays > 0) {
$effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays;
$acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
}
}
- Step 6: Add resolveSuspensionsForPeriod helper method
Add at the end of the class:
/**
* @return list<ContractSuspension>
*/
private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$suspensions = [];
foreach ($employee->getContractPeriods() as $period) {
$periodStart = $period->getStartDate();
$periodEnd = $period->getEndDate();
if ($periodStart > $to) {
continue;
}
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
continue;
}
foreach ($period->getSuspensions() as $suspension) {
$suspensions[] = $suspension;
}
}
return $suspensions;
}
Note: Employee::getContractPeriods() was already added in Task 4 Step 3.
Add use App\Entity\ContractSuspension; import in EmployeeLeaveSummaryProvider.php.
- Step 7: Run existing leave tests
Run: docker exec php-sirh-fpm php bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php
Expected: All existing tests still pass (no regression — employees without suspensions should behave identically)
Task 8: Modify LeaveBalanceComputationService
Files:
-
Modify:
src/Service/Leave/LeaveBalanceComputationService.php -
Step 1: Inject SuspensionDaysCalculator
Add use App\Entity\ContractSuspension; and use App\Service\Leave\SuspensionDaysCalculator; to imports.
Add to constructor: private SuspensionDaysCalculator $suspensionDaysCalculator
- Step 2: Modify computeAccruedDays signature
Change method signature at line 261 to add suspensions:
private function computeAccruedDays(
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
- Step 3: Add suspension subtraction in the month loop
Inside the while loop (after line 282 $coveredDays = ...), add:
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
- Step 4: Resolve suspensions in computeDynamicClosingForYear and pass to calls
In computeDynamicClosingForYear(), before the calls to computeAccruedDays (around lines 79-90), add:
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
Pass $suspensions as 5th argument to both computeAccruedDays calls:
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
);
- Step 5: Modify the forfait branch
In the forfait section (around line 69-76), after $acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;, replace with:
$totalBusinessDays = $this->countBusinessDays($from, $to);
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
$suspendedBusinessDays = 0;
if ([] !== $suspensions) {
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays);
}
$effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays;
$acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
- Step 6: Add resolveSuspensionsForEmployeePeriod helper
/**
* @return list<ContractSuspension>
*/
private function resolveSuspensionsForEmployeePeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$suspensions = [];
foreach ($employee->getContractPeriods() as $period) {
$periodStart = $period->getStartDate();
$periodEnd = $period->getEndDate();
if ($periodStart > $to) {
continue;
}
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
continue;
}
foreach ($period->getSuspensions() as $suspension) {
$suspensions[] = $suspension;
}
}
return $suspensions;
}
- Step 7: Run all tests
Run: docker exec php-sirh-fpm php bin/phpunit
Expected: All tests pass
Chunk 5: Frontend — DTO, Service, Drawer UI
Task 9: Frontend types and API service
Files:
-
Modify:
frontend/services/dto/employee.ts -
Create:
frontend/services/contractSuspensions.ts -
Step 1: Add ContractSuspension type and update Employee DTO
In frontend/services/dto/employee.ts, add the type and update Employee:
export type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
Add to Employee type:
currentSuspensions?: ContractSuspension[]
Add to ContractHistoryItem type:
suspensions?: ContractSuspension[]
- Step 2: Create suspension API service
import type { ContractSuspension } from './dto/employee'
export const createSuspension = async (payload: {
contractPeriodId: number
startDate: string
endDate?: string | null
comment?: string | null
}) => {
const api = useApi()
return api.post<ContractSuspension>('/contract_suspensions', {
contractPeriod: `/api/employee_contract_periods/${payload.contractPeriodId}`,
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'success.suspension.create',
toastErrorKey: 'errors.suspension.create'
})
}
export const updateSuspension = async (
id: number,
payload: {
startDate: string
endDate?: string | null
comment?: string | null
}
) => {
const api = useApi()
return api.patch<ContractSuspension>(`/contract_suspensions/${id}`, {
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'success.suspension.update',
toastErrorKey: 'errors.suspension.update'
})
}
Task 10: Modify ContractTab.vue — tabbed drawer
Files:
-
Modify:
frontend/components/employees/ContractTab.vue -
Modify:
frontend/composables/useEmployeeDetailPage.ts -
Step 1: Add suspension props to ContractTab.vue
In the defineProps section of ContractTab.vue, add new props using the TypeScript generics syntax (matching the existing codebase pattern):
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
Add to the existing defineProps (which uses TypeScript generics):
// Add these to the existing defineProps<{ ... }>()
suspensionForms: SuspensionForm[]
isSuspensionSubmitting: boolean
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
- Step 2: Change button label from "Clôturer" to "Modifier"
In the template, find the "Clôturer" button (around lines 28-35). Change the button text:
From: Clôturer
To: Modifier
- Step 3: Change drawer title and add tabs
Replace the drawer title from "Clôturer le contrat" to "Modifier le contrat".
Inside the AppDrawer, wrap the existing close-contract form content in a tab structure. Add at the start of the drawer content:
<div class="mb-4 flex border-b border-neutral-200">
<button
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
@click="drawerTab = 'close'"
>
Clôturer
</button>
<button
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
@click="drawerTab = 'suspend'"
>
Suspendre
</button>
</div>
Add a ref in the script:
const drawerTab = ref<'close' | 'suspend'>('close')
Wrap the existing close-contract form content in <div v-if="drawerTab === 'close'">...</div>.
- Step 4: Add Suspendre tab content
After the close tab div, add:
<div v-if="drawerTab === 'suspend'" class="space-y-6">
<div
v-for="(form, index) in suspensionForms"
:key="form.id ?? `new-${index}`"
class="space-y-4 rounded-lg border border-neutral-200 p-4"
>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de début <span class="text-red-600">*</span>
</label>
<input
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de fin
</label>
<input
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Commentaire
</label>
<textarea
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="button"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!form.startDate || isSuspensionSubmitting"
@click="onSubmitSuspension(index)"
>
{{ form.id ? 'Modifier' : '+ Ajouter' }}
</button>
</div>
<button
type="button"
class="w-full rounded-md border-2 border-dashed border-neutral-300 px-4 py-3 text-base font-semibold text-neutral-500 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
</button>
</div>
Task 11: Suspension form logic in composable
Files:
-
Modify:
frontend/composables/useEmployeeDetailPage.ts -
Step 1: Add suspension imports and state
Add imports:
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
Add reactive state alongside the existing contract form state:
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
- Step 2: Add hydration logic
Add a function to populate suspension forms from current employee data:
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
Call hydrateSuspensionForms() inside openCloseContractDrawer() (which becomes openModifyContractDrawer()).
- Step 3: Add submit function
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await loadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
- Step 4: Add addSuspensionForm function
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
- Step 5: Resolve currentActiveContractPeriodId
Add a computed that extracts the period ID from the already-existing currentActiveContractPeriod computed (the periodId field was already added to ContractHistoryItem in Task 4 and Task 9):
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
Where currentActiveContractPeriod already exists in the composable (around line 80).
- Step 6: Export new values from composable
Add to the return object:
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
- Step 7: Pass new props from [id].vue to ContractTab
In frontend/pages/employees/[id].vue, destructure the new values from the composable and pass them as props to <EmployeesContractTab>:
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
- Step 8: Verify in browser
- Open an employee detail page
- Click "Modifier" button — drawer should open with 2 tabs
- Click "Suspendre" tab — should show "+ Ajouter une suspension" button
- Click it, fill in dates, submit — suspension should be created
- Reopen drawer — suspension should be pre-filled
- Check leave tab — accruing days should be reduced
Chunk 6: Add i18n toast keys
Task 12: Add toast translation keys
Files:
-
Find and modify the i18n file(s) for success/error messages
-
Step 1: Locate i18n files
Search for existing toast keys like success.employee.create to find the translation file.
- Step 2: Add suspension keys
Add:
success.suspension.create: "Suspension créée"
success.suspension.update: "Suspension modifiée"
errors.suspension.create: "Erreur lors de la création de la suspension"
errors.suspension.update: "Erreur lors de la modification de la suspension"
Note: If the project uses the toast key as a fallback label when no translation file is found, adding these keys to the translation file is optional — the key string itself will be displayed. Check the useApi composable behavior to confirm.