feat : modification écran RTT + modification écran frais
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s

This commit is contained in:
2026-03-19 17:10:11 +01:00
parent 3ec1e1f10d
commit 17f871e82d
15 changed files with 468 additions and 67 deletions

View File

@@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\MileageAllowanceRepository;
use App\State\MileageAllowanceAmountReceiptDownloadProvider;
use App\State\MileageAllowanceAmountReceiptUploadProcessor;
use App\State\MileageAllowanceDeleteProcessor;
use App\State\MileageAllowanceReceiptDownloadProvider;
use App\State\MileageAllowanceReceiptUploadProcessor;
@@ -50,6 +52,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
security: "is_granted('ROLE_ADMIN')",
provider: MileageAllowanceReceiptDownloadProvider::class,
),
new Post(
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: MileageAllowanceAmountReceiptUploadProcessor::class,
),
new Get(
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
security: "is_granted('ROLE_ADMIN')",
provider: MileageAllowanceAmountReceiptDownloadProvider::class,
),
],
normalizationContext: [
'groups' => ['mileage_allowance:read', 'employee:read'],
@@ -103,6 +116,14 @@ class MileageAllowance
#[Groups(['mileage_allowance:read'])]
private ?string $receiptName = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $amountReceiptPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $amountReceiptName = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['mileage_allowance:read'])]
private DateTimeImmutable $createdAt;
@@ -201,6 +222,30 @@ class MileageAllowance
return $this;
}
public function getAmountReceiptPath(): ?string
{
return $this->amountReceiptPath;
}
public function setAmountReceiptPath(?string $amountReceiptPath): self
{
$this->amountReceiptPath = $amountReceiptPath;
return $this;
}
public function getAmountReceiptName(): ?string
{
return $this->amountReceiptName;
}
public function setAmountReceiptName(?string $amountReceiptName): self
{
$this->amountReceiptName = $amountReceiptName;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;

View File

@@ -228,6 +228,45 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $result;
}
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
// At least one validated day must exist
$validatedCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('isValid', true)
->getQuery()
->getSingleScalarResult()
;
if (0 === $validatedCount) {
return false;
}
// No non-validated day must exist in the range
$nonValidatedCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('isValid', false)
->getQuery()
->getSingleScalarResult()
;
return 0 === $nonValidatedCount;
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
@@ -36,6 +37,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private RttRecoveryComputationService $rttRecoveryService,
private WorkHourRepository $workHourRepository,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -83,6 +85,16 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
// Exclude the current (incomplete) week: limit to last Sunday
$isoDay = (int) $today->format('N'); // 1=Monday .. 7=Sunday
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
@@ -236,4 +248,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $month >= 6 ? $year + 1 : $year;
}
/**
* If the employee's contract ends within the current week, cap the check range to that end date.
*/
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
}

View File

@@ -13,6 +13,7 @@ use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -32,6 +33,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -142,6 +144,16 @@ class LeaveRecapPrintProvider implements ProviderInterface
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
@@ -165,6 +177,24 @@ class LeaveRecapPrintProvider implements ProviderInterface
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class MileageAllowanceAmountReceiptDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$mileageAllowance = $this->entityManager->find(MileageAllowance::class, $uriVariables['id']);
if (null === $mileageAllowance) {
throw new NotFoundHttpException('Mileage allowance not found.');
}
$receiptPath = $mileageAllowance->getAmountReceiptPath();
if (null === $receiptPath) {
throw new NotFoundHttpException('No amount receipt found for this mileage allowance.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $receiptPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Amount receipt file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$mileageAllowance->getAmountReceiptName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class MileageAllowanceAmountReceiptUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof MileageAllowance) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$month = $data->getMonth();
$year = $month?->format('Y') ?? date('Y');
$monthNumber = $month?->format('m') ?? date('m');
$relativePath = sprintf('mileage-receipts/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$file->move($absoluteDir, $filename);
$data->setAmountReceiptPath($fullRelative);
$data->setAmountReceiptName($originalName);
$this->entityManager->flush();
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -34,6 +34,16 @@ final readonly class MileageAllowanceDeleteProcessor implements ProcessorInterfa
}
}
$amountReceiptPath = $data->getAmountReceiptPath();
if (null !== $amountReceiptPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $amountReceiptPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$this->entityManager->remove($data);
$this->entityManager->flush();