Files
SIRH/docs/superpowers/plans/2026-03-12-contract-suspension.md
tristan 38f09914cb
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
feat : ajout des suspensions et des jours de présence
2026-03-12 16:46:06 +01:00

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
  1. Open an employee detail page
  2. Click "Modifier" button — drawer should open with 2 tabs
  3. Click "Suspendre" tab — should show "+ Ajouter une suspension" button
  4. Click it, fill in dates, submit — suspension should be created
  5. Reopen drawer — suspension should be pre-filled
  6. 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.