| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #322 | Page horaire | ## Description de la PR [#322] Page horaire ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #4 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #4.
This commit is contained in:
22
src/ApiResource/ScopedEmployee.php
Normal file
22
src/ApiResource/ScopedEmployee.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\State\ScopedEmployeeProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/employees/scoped',
|
||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: ScopedEmployeeProvider::class,
|
||||
paginationEnabled: false
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class ScopedEmployee {}
|
||||
39
src/ApiResource/WorkHourBulkUpsert.php
Normal file
39
src/ApiResource/WorkHourBulkUpsert.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\WorkHourBulkUpsertProcessor;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/work-hours/bulk-upsert',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
output: WorkHourBulkUpsertResult::class,
|
||||
processor: WorkHourBulkUpsertProcessor::class
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class WorkHourBulkUpsert
|
||||
{
|
||||
public string $workDate = '';
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* employeeId:int,
|
||||
* morningFrom?:?string,
|
||||
* morningTo?:?string,
|
||||
* afternoonFrom?:?string,
|
||||
* afternoonTo?:?string,
|
||||
* eveningFrom?:?string,
|
||||
* eveningTo?:?string,
|
||||
* isPresentMorning?:bool,
|
||||
* isPresentAfternoon?:bool
|
||||
* }>
|
||||
*/
|
||||
public array $entries = [];
|
||||
}
|
||||
13
src/ApiResource/WorkHourBulkUpsertResult.php
Normal file
13
src/ApiResource/WorkHourBulkUpsertResult.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
final class WorkHourBulkUpsertResult
|
||||
{
|
||||
public int $processed = 0;
|
||||
public int $created = 0;
|
||||
public int $updated = 0;
|
||||
public int $deleted = 0;
|
||||
}
|
||||
37
src/ApiResource/WorkHourDayContext.php
Normal file
37
src/ApiResource/WorkHourDayContext.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\WorkHourDayContextProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/work-hours/day-context',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: WorkHourDayContextProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class WorkHourDayContext
|
||||
{
|
||||
public string $workDate = '';
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* employeeId:int,
|
||||
* absenceLabel:?string,
|
||||
* absenceHalf:?string,
|
||||
* absentMorning:bool,
|
||||
* absentAfternoon:bool,
|
||||
* creditedMinutes:int,
|
||||
* creditedPresenceUnits:float
|
||||
* }>
|
||||
*/
|
||||
public array $rows = [];
|
||||
}
|
||||
32
src/ApiResource/WorkHourWeeklySummary.php
Normal file
32
src/ApiResource/WorkHourWeeklySummary.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||
use App\State\WorkHourWeeklySummaryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/work-hours/weekly-summary',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: WorkHourWeeklySummaryProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class WorkHourWeeklySummary
|
||||
{
|
||||
public string $weekStart = '';
|
||||
public string $weekEnd = '';
|
||||
|
||||
/** @var list<string> */
|
||||
public array $days = [];
|
||||
|
||||
/** @var list<WeeklySummaryRow> */
|
||||
public array $rows = [];
|
||||
}
|
||||
50
src/Doctrine/AbsenceCollectionExtension.php
Normal file
50
src/Doctrine/AbsenceCollectionExtension.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\User;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class AbsenceCollectionExtension implements QueryCollectionExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
if (Absence::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
$queryBuilder->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$employeeAlias = 'absence_employee_scope';
|
||||
|
||||
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
|
||||
->addSelect($employeeAlias)
|
||||
;
|
||||
|
||||
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'absence_scope', $user);
|
||||
}
|
||||
}
|
||||
53
src/Doctrine/WorkHourCollectionExtension.php
Normal file
53
src/Doctrine/WorkHourCollectionExtension.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class WorkHourCollectionExtension implements QueryCollectionExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
// N'applique le filtrage qu'à la ressource WorkHour.
|
||||
if (WorkHour::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
// Pas d'utilisateur => aucune ligne renvoyée.
|
||||
$queryBuilder->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$employeeAlias = 'employee_scope';
|
||||
|
||||
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
|
||||
->addSelect($employeeAlias)
|
||||
;
|
||||
|
||||
// Filtrage SQL par scope (admin/self/site) avant retour API.
|
||||
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'work_hour_scope', $user);
|
||||
}
|
||||
}
|
||||
84
src/Dto/WorkHours/DayContextRow.php
Normal file
84
src/Dto/WorkHours/DayContextRow.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class DayContextRow
|
||||
{
|
||||
public function __construct(
|
||||
public int $employeeId,
|
||||
public ?string $absenceLabel = null,
|
||||
public ?string $absenceHalf = null,
|
||||
public bool $absentMorning = false,
|
||||
public bool $absentAfternoon = false,
|
||||
public int $creditedMinutes = 0,
|
||||
public float $creditedPresenceUnits = 0.0,
|
||||
) {}
|
||||
|
||||
public function addAbsence(
|
||||
?string $label,
|
||||
bool $morning,
|
||||
bool $afternoon,
|
||||
int $creditedMinutes,
|
||||
float $creditedPresenceUnits
|
||||
): void {
|
||||
// Fusionne plusieurs absences du même jour sur la ligne salarié.
|
||||
$this->absentMorning = $this->absentMorning || $morning;
|
||||
$this->absentAfternoon = $this->absentAfternoon || $afternoon;
|
||||
|
||||
// Garde un libellé lisible: unique si possible, sinon "Absences multiples".
|
||||
if (null === $this->absenceLabel) {
|
||||
$this->absenceLabel = $label;
|
||||
} elseif ($label !== $this->absenceLabel) {
|
||||
$this->absenceLabel = 'Absences multiples';
|
||||
}
|
||||
|
||||
// AM/PM seulement pour les demi-journées, null pour journée complète.
|
||||
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
|
||||
// Cumule les minutes créditées par les absences "comptées comme travaillées".
|
||||
$this->creditedMinutes += $creditedMinutes;
|
||||
// Cumule les unités de présence créditées (0.5 par demi-journée).
|
||||
$this->creditedPresenceUnits += $creditedPresenceUnits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* employeeId:int,
|
||||
* absenceLabel:?string,
|
||||
* absenceHalf:?string,
|
||||
* absentMorning:bool,
|
||||
* absentAfternoon:bool,
|
||||
* creditedMinutes:int,
|
||||
* creditedPresenceUnits:float
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'employeeId' => $this->employeeId,
|
||||
'absenceLabel' => $this->absenceLabel,
|
||||
'absenceHalf' => $this->absenceHalf,
|
||||
'absentMorning' => $this->absentMorning,
|
||||
'absentAfternoon' => $this->absentAfternoon,
|
||||
'creditedMinutes' => $this->creditedMinutes,
|
||||
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveHalfLabel(bool $morning, bool $afternoon): ?string
|
||||
{
|
||||
// Matin + après-midi => journée complète, pas de libellé AM/PM.
|
||||
if ($morning && $afternoon) {
|
||||
return null;
|
||||
}
|
||||
if ($morning) {
|
||||
return 'AM';
|
||||
}
|
||||
if ($afternoon) {
|
||||
return 'PM';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class WeeklyDaySummary
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public int $dayMinutes,
|
||||
public int $nightMinutes,
|
||||
public int $totalMinutes,
|
||||
public ?float $present = null,
|
||||
public bool $hasAbsence = false,
|
||||
public ?string $absenceLabel = null,
|
||||
public ?string $absenceColor = null,
|
||||
) {}
|
||||
}
|
||||
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class WeeklySummaryRow
|
||||
{
|
||||
/**
|
||||
* @param list<WeeklyDaySummary> $daily
|
||||
*/
|
||||
public function __construct(
|
||||
public int $employeeId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $siteName,
|
||||
public ?string $contractName,
|
||||
public ?string $contractType,
|
||||
public ?string $trackingMode,
|
||||
public array $daily,
|
||||
public int $weeklyDayMinutes,
|
||||
public int $weeklyNightMinutes,
|
||||
public int $weeklyTotalMinutes,
|
||||
public float $weeklyPresenceCount,
|
||||
public int $weeklyOvertimeTotalMinutes,
|
||||
public int $weeklyOvertime25Minutes,
|
||||
public int $weeklyOvertime50Minutes,
|
||||
public int $weeklyRecoveryMinutes,
|
||||
) {}
|
||||
}
|
||||
26
src/Dto/WorkHours/WorkMetrics.php
Normal file
26
src/Dto/WorkHours/WorkMetrics.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class WorkMetrics
|
||||
{
|
||||
public function __construct(
|
||||
public int $dayMinutes = 0,
|
||||
public int $nightMinutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
) {}
|
||||
|
||||
public function addCreditedMinutes(int $creditedMinutes): void
|
||||
{
|
||||
// Ignore les valeurs nulles ou négatives pour ne pas biaiser les totaux.
|
||||
if ($creditedMinutes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Le crédit absence alimente les heures de jour et le total.
|
||||
$this->dayMinutes += $creditedMinutes;
|
||||
$this->totalMinutes += $creditedMinutes;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,39 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\State\AbsenceWriteProcessor;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ABSENCE_VIEW', object)"
|
||||
),
|
||||
new Post(
|
||||
securityPostDenormalize: "is_granted('ABSENCE_EDIT', object)",
|
||||
processor: AbsenceWriteProcessor::class
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('ABSENCE_EDIT', object)",
|
||||
processor: AbsenceWriteProcessor::class
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('ABSENCE_EDIT', object)",
|
||||
processor: AbsenceWriteProcessor::class
|
||||
),
|
||||
],
|
||||
normalizationContext: [
|
||||
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
|
||||
'datetime_format' => 'Y-m-d',
|
||||
@@ -22,11 +50,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
'datetime_format' => 'Y-m-d',
|
||||
],
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
)]
|
||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||
#[ORM\Table(name: 'absences')]
|
||||
class Absence
|
||||
{
|
||||
@@ -52,17 +79,17 @@ class Absence
|
||||
#[Groups(['absence:read'])]
|
||||
private DateTimeInterface $startDate;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'AM'])]
|
||||
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
|
||||
#[Groups(['absence:read'])]
|
||||
private string $startHalf = 'AM';
|
||||
private HalfDay $startHalf = HalfDay::AM;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
#[Groups(['absence:read'])]
|
||||
private DateTimeInterface $endDate;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'PM'])]
|
||||
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
|
||||
#[Groups(['absence:read'])]
|
||||
private string $endHalf = 'PM';
|
||||
private HalfDay $endHalf = HalfDay::PM;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['absence:read'])]
|
||||
@@ -121,24 +148,24 @@ class Absence
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartHalf(): string
|
||||
public function getStartHalf(): HalfDay
|
||||
{
|
||||
return $this->startHalf;
|
||||
}
|
||||
|
||||
public function setStartHalf(string $startHalf): self
|
||||
public function setStartHalf(HalfDay $startHalf): self
|
||||
{
|
||||
$this->startHalf = $startHalf;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndHalf(): string
|
||||
public function getEndHalf(): HalfDay
|
||||
{
|
||||
return $this->endHalf;
|
||||
}
|
||||
|
||||
public function setEndHalf(string $endHalf): self
|
||||
public function setEndHalf(HalfDay $endHalf): self
|
||||
{
|
||||
$this->endHalf = $endHalf;
|
||||
|
||||
|
||||
@@ -5,13 +5,34 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_type:read']],
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'absence_types')]
|
||||
@@ -35,6 +56,10 @@ class AbsenceType
|
||||
#[Groups(['absence:read', 'absence_type:read'])]
|
||||
private string $color = '';
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['absence:read', 'absence_type:read'])]
|
||||
private bool $countAsWorkedHours = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -75,4 +100,21 @@ class AbsenceType
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCountAsWorkedHours(): bool
|
||||
{
|
||||
return $this->countAsWorkedHours;
|
||||
}
|
||||
|
||||
public function getCountAsWorkedHours(): bool
|
||||
{
|
||||
return $this->countAsWorkedHours;
|
||||
}
|
||||
|
||||
public function setCountAsWorkedHours(bool $countAsWorkedHours): self
|
||||
{
|
||||
$this->countAsWorkedHours = $countAsWorkedHours;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
122
src/Entity/Contract.php
Normal file
122
src/Entity/Contract.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['contract:read']],
|
||||
denormalizationContext: ['groups' => ['contract:write']],
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'contracts')]
|
||||
class Contract
|
||||
{
|
||||
public const string TRACKING_TIME = TrackingMode::TIME->value;
|
||||
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['contract:read', 'employee:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||
private string $trackingMode = self::TRACKING_TIME;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||
private ?int $weeklyHours = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
#[Groups(['contract:read', 'contract:write'])]
|
||||
private bool $isActive = true;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTrackingMode(): string
|
||||
{
|
||||
return $this->trackingMode;
|
||||
}
|
||||
|
||||
public function getTrackingModeEnum(): TrackingMode
|
||||
{
|
||||
return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
|
||||
}
|
||||
|
||||
public function setTrackingMode(string|TrackingMode $trackingMode): self
|
||||
{
|
||||
$value = $trackingMode instanceof TrackingMode ? $trackingMode->value : $trackingMode;
|
||||
if (null === TrackingMode::tryFrom($value)) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid tracking mode "%s".', $value));
|
||||
}
|
||||
|
||||
$this->trackingMode = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['contract:read', 'employee:read'])]
|
||||
public function getType(): ContractType
|
||||
{
|
||||
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
|
||||
}
|
||||
|
||||
public function getWeeklyHours(): ?int
|
||||
{
|
||||
return $this->weeklyHours;
|
||||
}
|
||||
|
||||
public function setWeeklyHours(?int $weeklyHours): self
|
||||
{
|
||||
$this->weeklyHours = $weeklyHours;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
|
||||
public function getIsActive(): bool
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
|
||||
public function setIsActive(bool $isActive): self
|
||||
{
|
||||
$this->isActive = $isActive;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -15,7 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||
#[ORM\Table(name: 'employees')]
|
||||
class Employee
|
||||
{
|
||||
@@ -33,12 +35,18 @@ class Employee
|
||||
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
||||
private string $lastName = '';
|
||||
|
||||
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
#[Groups(['employee:read', 'employee:write'])]
|
||||
private ?Site $site = null;
|
||||
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[Groups(['employee:read', 'employee:write'])]
|
||||
private ?Contract $contract = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||
#[Groups(['employee:read', 'employee:write'])]
|
||||
private int $displayOrder = 0;
|
||||
@@ -92,6 +100,18 @@ class Employee
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContract(): ?Contract
|
||||
{
|
||||
return $this->contract;
|
||||
}
|
||||
|
||||
public function setContract(?Contract $contract): self
|
||||
{
|
||||
$this->contract = $contract;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
248
src/Entity/WorkHour.php
Normal file
248
src/Entity/WorkHour.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||
security: "is_granted('WORK_HOUR_VIEW', object)"
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||
denormalizationContext: ['groups' => ['work_hour:validate']],
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: WorkHourRepository::class)]
|
||||
#[ORM\Table(name: 'work_hours')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
|
||||
class WorkHour
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?Employee $employee = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private DateTimeInterface $workDate;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $morningFrom = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $morningTo = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $afternoonFrom = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $afternoonTo = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $eveningFrom = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private ?string $eveningTo = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private bool $isPresentMorning = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['work_hour:read'])]
|
||||
private bool $isPresentAfternoon = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||
private bool $isValid = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmployee(): ?Employee
|
||||
{
|
||||
return $this->employee;
|
||||
}
|
||||
|
||||
public function setEmployee(?Employee $employee): self
|
||||
{
|
||||
$this->employee = $employee;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkDate(): DateTimeInterface
|
||||
{
|
||||
return $this->workDate;
|
||||
}
|
||||
|
||||
public function setWorkDate(DateTimeInterface $workDate): self
|
||||
{
|
||||
$this->workDate = $workDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMorningFrom(): ?string
|
||||
{
|
||||
return $this->morningFrom;
|
||||
}
|
||||
|
||||
public function setMorningFrom(?string $morningFrom): self
|
||||
{
|
||||
$this->morningFrom = $morningFrom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMorningTo(): ?string
|
||||
{
|
||||
return $this->morningTo;
|
||||
}
|
||||
|
||||
public function setMorningTo(?string $morningTo): self
|
||||
{
|
||||
$this->morningTo = $morningTo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAfternoonFrom(): ?string
|
||||
{
|
||||
return $this->afternoonFrom;
|
||||
}
|
||||
|
||||
public function setAfternoonFrom(?string $afternoonFrom): self
|
||||
{
|
||||
$this->afternoonFrom = $afternoonFrom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAfternoonTo(): ?string
|
||||
{
|
||||
return $this->afternoonTo;
|
||||
}
|
||||
|
||||
public function setAfternoonTo(?string $afternoonTo): self
|
||||
{
|
||||
$this->afternoonTo = $afternoonTo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEveningFrom(): ?string
|
||||
{
|
||||
return $this->eveningFrom;
|
||||
}
|
||||
|
||||
public function setEveningFrom(?string $eveningFrom): self
|
||||
{
|
||||
$this->eveningFrom = $eveningFrom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEveningTo(): ?string
|
||||
{
|
||||
return $this->eveningTo;
|
||||
}
|
||||
|
||||
public function setEveningTo(?string $eveningTo): self
|
||||
{
|
||||
$this->eveningTo = $eveningTo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPresentMorning(): bool
|
||||
{
|
||||
return $this->isPresentMorning;
|
||||
}
|
||||
|
||||
public function getIsPresentMorning(): bool
|
||||
{
|
||||
return $this->isPresentMorning;
|
||||
}
|
||||
|
||||
public function setIsPresentMorning(bool $isPresentMorning): self
|
||||
{
|
||||
$this->isPresentMorning = $isPresentMorning;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPresentAfternoon(): bool
|
||||
{
|
||||
return $this->isPresentAfternoon;
|
||||
}
|
||||
|
||||
public function getIsPresentAfternoon(): bool
|
||||
{
|
||||
return $this->isPresentAfternoon;
|
||||
}
|
||||
|
||||
public function setIsPresentAfternoon(bool $isPresentAfternoon): self
|
||||
{
|
||||
$this->isPresentAfternoon = $isPresentAfternoon;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->isValid;
|
||||
}
|
||||
|
||||
public function getIsValid(): bool
|
||||
{
|
||||
return $this->isValid;
|
||||
}
|
||||
|
||||
public function setIsValid(bool $isValid): self
|
||||
{
|
||||
$this->isValid = $isValid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
47
src/Enum/ContractType.php
Normal file
47
src/Enum/ContractType.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum ContractType: string
|
||||
{
|
||||
case FORFAIT = 'FORFAIT';
|
||||
case H35 = '35H';
|
||||
case H39 = '39H';
|
||||
case INTERIM = 'INTERIM';
|
||||
case CUSTOM = 'CUSTOM';
|
||||
|
||||
public static function resolve(?string $name, ?string $trackingMode, ?int $weeklyHours): self
|
||||
{
|
||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||
return self::FORFAIT;
|
||||
}
|
||||
|
||||
$normalizedName = self::normalize($name);
|
||||
if ('interim' === $normalizedName) {
|
||||
return self::INTERIM;
|
||||
}
|
||||
|
||||
if (35 === $weeklyHours) {
|
||||
return self::H35;
|
||||
}
|
||||
|
||||
if (39 === $weeklyHours) {
|
||||
return self::H39;
|
||||
}
|
||||
|
||||
return self::CUSTOM;
|
||||
}
|
||||
|
||||
private static function normalize(?string $value): string
|
||||
{
|
||||
if (null === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalized = mb_strtolower(trim($value));
|
||||
|
||||
return str_replace('é', 'e', $normalized);
|
||||
}
|
||||
}
|
||||
11
src/Enum/HalfDay.php
Normal file
11
src/Enum/HalfDay.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum HalfDay: string
|
||||
{
|
||||
case AM = 'AM';
|
||||
case PM = 'PM';
|
||||
}
|
||||
11
src/Enum/TrackingMode.php
Normal file
11
src/Enum/TrackingMode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum TrackingMode: string
|
||||
{
|
||||
case TIME = 'TIME';
|
||||
case PRESENCE = 'PRESENCE';
|
||||
}
|
||||
102
src/Repository/AbsenceRepository.php
Normal file
102
src/Repository/AbsenceRepository.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Absence>
|
||||
*/
|
||||
final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Absence::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||
{
|
||||
if ([] === $employees) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->leftJoin('a.employee', 'e')
|
||||
->leftJoin('a.type', 't')
|
||||
->addSelect('e', 't')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->andWhere('a.endDate >= :from')
|
||||
->andWhere('a.employee IN (:employees)')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('employees', $employees)
|
||||
;
|
||||
|
||||
// @var list<Absence> $absences
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
|
||||
{
|
||||
if ([] === $employees) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->leftJoin('a.employee', 'e')
|
||||
->leftJoin('a.type', 't')
|
||||
->addSelect('e', 't')
|
||||
->andWhere('a.startDate <= :date')
|
||||
->andWhere('a.endDate >= :date')
|
||||
->andWhere('a.employee IN (:employees)')
|
||||
->setParameter('date', $date)
|
||||
->setParameter('employees', $employees)
|
||||
;
|
||||
|
||||
// @var list<Absence> $absences
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
|
||||
{
|
||||
$fromDate = DateTimeImmutable::createFromInterface($from);
|
||||
$toDate = DateTimeImmutable::createFromInterface($to);
|
||||
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->leftJoin('a.employee', 'e')
|
||||
->leftJoin('a.type', 't')
|
||||
->addSelect('e', 't')
|
||||
->andWhere('a.employee = :employee')
|
||||
->andWhere('a.startDate >= :from')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('from', $fromDate)
|
||||
->setParameter('to', $toDate)
|
||||
->orderBy('a.startDate', 'ASC')
|
||||
;
|
||||
|
||||
// @var list<Absence> $absences
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
32
src/Repository/Contract/AbsenceReadRepositoryInterface.php
Normal file
32
src/Repository/Contract/AbsenceReadRepositoryInterface.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
interface AbsenceReadRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
|
||||
|
||||
/**
|
||||
* @return list<Absence>
|
||||
*/
|
||||
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
|
||||
interface EmployeeScopedRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return list<Employee>
|
||||
*/
|
||||
public function findScoped(User $user): array;
|
||||
}
|
||||
24
src/Repository/Contract/WorkHourReadRepositoryInterface.php
Normal file
24
src/Repository/Contract/WorkHourReadRepositoryInterface.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
interface WorkHourReadRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<WorkHour>
|
||||
*/
|
||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
}
|
||||
104
src/Repository/EmployeeRepository.php
Normal file
104
src/Repository/EmployeeRepository.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Employee>
|
||||
*/
|
||||
final class EmployeeRepository extends ServiceEntityRepository implements EmployeeScopedRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
ManagerRegistry $registry,
|
||||
private readonly EmployeeScopeService $employeeScopeService,
|
||||
) {
|
||||
parent::__construct($registry, Employee::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $employeeIds
|
||||
*
|
||||
* @return array<int, Employee>
|
||||
*/
|
||||
public function findAccessibleByIds(array $employeeIds, User $user): array
|
||||
{
|
||||
if ([] === $employeeIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('e')
|
||||
->andWhere('e.id IN (:ids)')
|
||||
->setParameter('ids', $employeeIds)
|
||||
;
|
||||
|
||||
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_repo_scope', $user);
|
||||
|
||||
/** @var list<Employee> $employees */
|
||||
$employees = $qb->getQuery()->getResult();
|
||||
|
||||
$byId = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if ($employeeId) {
|
||||
$byId[$employeeId] = $employee;
|
||||
}
|
||||
}
|
||||
|
||||
return $byId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Employee>
|
||||
*/
|
||||
public function findScoped(User $user): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('e')
|
||||
->leftJoin('e.site', 's')
|
||||
->addSelect('s')
|
||||
->orderBy('s.name', 'ASC')
|
||||
->addOrderBy('e.displayOrder', 'ASC')
|
||||
->addOrderBy('e.lastName', 'ASC')
|
||||
->addOrderBy('e.firstName', 'ASC')
|
||||
;
|
||||
|
||||
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
|
||||
|
||||
// @var list<Employee> $employees
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $siteIds
|
||||
*
|
||||
* @return list<Employee>
|
||||
*/
|
||||
public function findForPrintBySiteIds(array $siteIds): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('e')
|
||||
->leftJoin('e.site', 's')
|
||||
->addSelect('s')
|
||||
->orderBy('s.displayOrder', 'ASC')
|
||||
->addOrderBy('s.name', 'ASC')
|
||||
->addOrderBy('e.displayOrder', 'ASC')
|
||||
->addOrderBy('e.lastName', 'ASC')
|
||||
->addOrderBy('e.firstName', 'ASC')
|
||||
;
|
||||
|
||||
if ([] !== $siteIds) {
|
||||
$qb->andWhere('s.id IN (:siteIds)')
|
||||
->setParameter('siteIds', $siteIds)
|
||||
;
|
||||
}
|
||||
|
||||
// @var list<Employee> $employees
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
120
src/Repository/WorkHourRepository.php
Normal file
120
src/Repository/WorkHourRepository.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<WorkHour>
|
||||
*/
|
||||
final class WorkHourRepository extends ServiceEntityRepository implements WorkHourReadRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WorkHour::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return array<int, WorkHour>
|
||||
*/
|
||||
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array
|
||||
{
|
||||
if ([] === $employees) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('w')
|
||||
->leftJoin('w.employee', 'e')
|
||||
->addSelect('e')
|
||||
->andWhere('w.workDate = :workDate')
|
||||
->andWhere('w.employee IN (:employees)')
|
||||
->setParameter('workDate', $workDate)
|
||||
->setParameter('employees', $employees)
|
||||
;
|
||||
|
||||
/** @var list<WorkHour> $workHours */
|
||||
$workHours = $qb->getQuery()->getResult();
|
||||
|
||||
$byEmployeeId = [];
|
||||
foreach ($workHours as $workHour) {
|
||||
$employeeId = $workHour->getEmployee()?->getId();
|
||||
if ($employeeId) {
|
||||
$byEmployeeId[$employeeId] = $workHour;
|
||||
}
|
||||
}
|
||||
|
||||
return $byEmployeeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<WorkHour>
|
||||
*/
|
||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||
{
|
||||
if ([] === $employees) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('w')
|
||||
->leftJoin('w.employee', 'e')
|
||||
->addSelect('e')
|
||||
->andWhere('w.workDate >= :from')
|
||||
->andWhere('w.workDate <= :to')
|
||||
->andWhere('w.employee IN (:employees)')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('employees', $employees)
|
||||
;
|
||||
|
||||
// @var list<WorkHour> $workHours
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
|
||||
{
|
||||
$fromDate = DateTimeImmutable::createFromInterface($from);
|
||||
$toDate = DateTimeImmutable::createFromInterface($to);
|
||||
|
||||
$qb = $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', $fromDate)
|
||||
->setParameter('to', $toDate)
|
||||
->setParameter('isValid', true)
|
||||
;
|
||||
|
||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||
}
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
||||
{
|
||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||
|
||||
$qb = $this->createQueryBuilder('w')
|
||||
->andWhere('w.employee = :employee')
|
||||
->andWhere('w.workDate = :workDate')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('workDate', $workDate)
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
/** @var null|WorkHour $workHour */
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
99
src/Security/EmployeeScopeService.php
Normal file
99
src/Security/EmployeeScopeService.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class EmployeeScopeService
|
||||
{
|
||||
public const string SITE_ACCESS_ROLE = 'SITE_ACCESS';
|
||||
|
||||
/**
|
||||
* Règle métier centrale d'accès à un employé.
|
||||
* - Admin : accès global
|
||||
* - Self : uniquement son employé lié
|
||||
* - Site : uniquement les employés des sites autorisés.
|
||||
*/
|
||||
public function canAccessEmployee(User $user, Employee $employee): bool
|
||||
{
|
||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
|
||||
return $user->getEmployee()?->getId() === $employee->getId();
|
||||
}
|
||||
|
||||
$employeeSiteId = $employee->getSite()?->getId();
|
||||
if (!$employeeSiteId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($employeeSiteId, $this->getAllowedSiteIds($user), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des sites accessibles via user_site_roles.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getAllowedSiteIds(User $user): array
|
||||
{
|
||||
$siteIds = [];
|
||||
|
||||
foreach ($user->getSiteRoles() as $siteRole) {
|
||||
if (self::SITE_ACCESS_ROLE !== $siteRole->getRole()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$siteId = $siteRole->getSite()?->getId();
|
||||
if ($siteId) {
|
||||
$siteIds[] = $siteId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($siteIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le scope directement sur un QueryBuilder Doctrine.
|
||||
* Cette méthode est utilisée pour filtrer les collections SQL
|
||||
* avant sérialisation (plus sûr et plus performant).
|
||||
*/
|
||||
public function applyEmployeeScope(QueryBuilder $qb, string $employeeAlias, string $paramPrefix, User $user): void
|
||||
{
|
||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
|
||||
$employeeId = $user->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
$qb->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere(sprintf('%s.id = :%s_employee_id', $employeeAlias, $paramPrefix))
|
||||
->setParameter(sprintf('%s_employee_id', $paramPrefix), $employeeId)
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$siteIds = $this->getAllowedSiteIds($user);
|
||||
if ([] === $siteIds) {
|
||||
$qb->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere(sprintf('%s.site IN (:%s_site_ids)', $employeeAlias, $paramPrefix))
|
||||
->setParameter(sprintf('%s_site_ids', $paramPrefix), $siteIds)
|
||||
;
|
||||
}
|
||||
}
|
||||
48
src/Security/Voter/AbsenceVoter.php
Normal file
48
src/Security/Voter/AbsenceVoter.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\User;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
final class AbsenceVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'ABSENCE_VIEW';
|
||||
public const string EDIT = 'ABSENCE_EDIT';
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EmployeeScopeService $employeeScopeService,
|
||||
) {}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof Absence;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$subject instanceof Absence) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$employee = $subject->getEmployee();
|
||||
if (null === $employee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->employeeScopeService->canAccessEmployee($user, $employee);
|
||||
}
|
||||
}
|
||||
50
src/Security/Voter/WorkHourVoter.php
Normal file
50
src/Security/Voter/WorkHourVoter.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
class WorkHourVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'WORK_HOUR_VIEW';
|
||||
public const string EDIT = 'WORK_HOUR_EDIT';
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EmployeeScopeService $employeeScopeService,
|
||||
) {}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof WorkHour;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
// On ne traite que des utilisateurs applicatifs authentifiés.
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$subject instanceof WorkHour) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$employee = $subject->getEmployee();
|
||||
if (null === $employee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Délégation de la règle au service de scope unique (évite la duplication).
|
||||
return $this->employeeScopeService->canAccessEmployee($user, $employee);
|
||||
}
|
||||
}
|
||||
50
src/Service/WorkHours/AbsenceSegmentsResolver.php
Normal file
50
src/Service/WorkHours/AbsenceSegmentsResolver.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Enum\HalfDay;
|
||||
|
||||
final class AbsenceSegmentsResolver
|
||||
{
|
||||
/**
|
||||
* @return array{bool, bool}
|
||||
*/
|
||||
public function resolveForDate(Absence $absence, string $dateYmd): array
|
||||
{
|
||||
$startDate = $absence->getStartDate()->format('Y-m-d');
|
||||
$endDate = $absence->getEndDate()->format('Y-m-d');
|
||||
$startHalf = $absence->getStartHalf();
|
||||
$endHalf = $absence->getEndHalf();
|
||||
|
||||
// Cas d'une absence sur une seule date: on déduit matin/après-midi depuis les bornes.
|
||||
if ($startDate === $endDate) {
|
||||
if (HalfDay::AM === $startHalf && HalfDay::AM === $endHalf) {
|
||||
// Uniquement le matin absent.
|
||||
return [true, false];
|
||||
}
|
||||
if (HalfDay::PM === $startHalf && HalfDay::PM === $endHalf) {
|
||||
// Uniquement l'après-midi absent.
|
||||
return [false, true];
|
||||
}
|
||||
|
||||
// Sinon, on considère la journée complète absente.
|
||||
return [true, true];
|
||||
}
|
||||
|
||||
// Premier jour d'une absence multi-jours qui commence l'après-midi.
|
||||
if ($dateYmd === $startDate && HalfDay::PM === $startHalf) {
|
||||
return [false, true];
|
||||
}
|
||||
|
||||
// Dernier jour d'une absence multi-jours qui se termine le matin.
|
||||
if ($dateYmd === $endDate && HalfDay::AM === $endHalf) {
|
||||
return [true, false];
|
||||
}
|
||||
|
||||
// Les jours intermédiaires sont entièrement absents.
|
||||
return [true, true];
|
||||
}
|
||||
}
|
||||
91
src/Service/WorkHours/WorkedHoursCreditPolicy.php
Normal file
91
src/Service/WorkHours/WorkedHoursCreditPolicy.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Enum\TrackingMode;
|
||||
use DateMalformedStringException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class WorkedHoursCreditPolicy
|
||||
{
|
||||
/**
|
||||
* @throws DateMalformedStringException
|
||||
*/
|
||||
public function computeCreditedMinutes(Absence $absence, string $dateYmd, bool $absentMorning, bool $absentAfternoon): int
|
||||
{
|
||||
$type = $absence->getType();
|
||||
// Certaines absences ne doivent jamais générer d'heures créditées.
|
||||
if (!$type?->getCountAsWorkedHours()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$employee = $absence->getEmployee();
|
||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
||||
if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$weekday = (int) new DateTimeImmutable($dateYmd)->format('N');
|
||||
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
||||
$dayMinutes = $this->resolveContractDayMinutes($employee->getContract()?->getWeeklyHours(), $weekday);
|
||||
if ($dayMinutes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Crédit en demi-journées: matin = 0.5, après-midi = 0.5.
|
||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
||||
|
||||
return (int) round(($dayMinutes / 2) * $halfUnits);
|
||||
}
|
||||
|
||||
public function computeCreditedPresenceUnits(Absence $absence, bool $absentMorning, bool $absentAfternoon): float
|
||||
{
|
||||
$type = $absence->getType();
|
||||
if (!$type?->getCountAsWorkedHours()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$employee = $absence->getEmployee();
|
||||
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
||||
|
||||
return $halfUnits * 0.5;
|
||||
}
|
||||
|
||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||
{
|
||||
// Week-end non travaillé dans cette politique.
|
||||
if ($isoWeekDay >= 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Règle fixe: 35h => 7h/jour.
|
||||
if (35 === $weeklyHours) {
|
||||
return 7 * 60;
|
||||
}
|
||||
|
||||
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
|
||||
if (39 === $weeklyHours) {
|
||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||
}
|
||||
|
||||
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
|
||||
if (4 === $weeklyHours) {
|
||||
return 2 * 60;
|
||||
}
|
||||
|
||||
// Contrat non renseigné/invalide: aucun crédit.
|
||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fallback générique: répartition homogène sur 5 jours ouvrés.
|
||||
return (int) round(($weeklyHours * 60) / 5);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
@@ -27,7 +27,8 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private readonly RequestStack $requestStack,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
@@ -109,50 +110,12 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
|
||||
private function loadEmployees(array $siteIds): array
|
||||
{
|
||||
$qb = $this->entityManager
|
||||
->getRepository(Employee::class)
|
||||
->createQueryBuilder('e')
|
||||
->leftJoin('e.site', 's')
|
||||
->addSelect('s')
|
||||
->orderBy('s.displayOrder', 'ASC')
|
||||
->addOrderBy('s.name', 'ASC')
|
||||
->addOrderBy('e.displayOrder', 'ASC')
|
||||
->addOrderBy('e.lastName', 'ASC')
|
||||
->addOrderBy('e.firstName', 'ASC')
|
||||
;
|
||||
|
||||
if ([] !== $siteIds) {
|
||||
$qb->andWhere('s.id IN (:siteIds)')
|
||||
->setParameter('siteIds', $siteIds)
|
||||
;
|
||||
}
|
||||
|
||||
// @var list<Employee> $result
|
||||
return $qb->getQuery()->getResult();
|
||||
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
|
||||
}
|
||||
|
||||
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||
{
|
||||
if ([] === $employees) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->entityManager
|
||||
->getRepository(Absence::class)
|
||||
->createQueryBuilder('a')
|
||||
->leftJoin('a.employee', 'e')
|
||||
->leftJoin('a.type', 't')
|
||||
->addSelect('e', 't')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->andWhere('a.endDate >= :from')
|
||||
->andWhere('a.employee IN (:employees)')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('employees', $employees)
|
||||
;
|
||||
|
||||
// @var list<Absence> $result
|
||||
return $qb->getQuery()->getResult();
|
||||
return $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||
}
|
||||
|
||||
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
@@ -202,13 +165,13 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
|
||||
if ($isSameDay) {
|
||||
if ($startHalf === $endHalf) {
|
||||
$halfLabel = $startHalf;
|
||||
$halfLabel = $startHalf->value;
|
||||
}
|
||||
} else {
|
||||
if ($isStartDay && 'PM' === $startHalf) {
|
||||
if ($isStartDay && HalfDay::PM === $startHalf) {
|
||||
$halfLabel = 'PM';
|
||||
}
|
||||
if ($isEndDay && 'AM' === $endHalf) {
|
||||
if ($isEndDay && HalfDay::AM === $endHalf) {
|
||||
$halfLabel = 'AM';
|
||||
}
|
||||
}
|
||||
|
||||
223
src/State/AbsenceWriteProcessor.php
Normal file
223
src/State/AbsenceWriteProcessor.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Absence) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$employee = $data->getEmployee();
|
||||
if (null === $employee) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
|
||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$segments = $this->expandAbsenceRange($data);
|
||||
if ([] === $segments) {
|
||||
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
|
||||
}
|
||||
|
||||
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
|
||||
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
|
||||
|
||||
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
|
||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
||||
}
|
||||
|
||||
$existing = $this->absenceRepository->findByEmployeeAndDateRange($employee, $from, $to);
|
||||
foreach ($existing as $existingAbsence) {
|
||||
if ($existingAbsence->getId() === $data->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ConflictHttpException('Cette période chevauche déjà une absence existante.');
|
||||
}
|
||||
|
||||
$first = array_shift($segments);
|
||||
if (null === $first) {
|
||||
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
|
||||
}
|
||||
|
||||
$data
|
||||
->setStartDate($this->toMutableDate($first['date']))
|
||||
->setEndDate($this->toMutableDate($first['date']))
|
||||
->setStartHalf($first['startHalf'])
|
||||
->setEndHalf($first['endHalf'])
|
||||
;
|
||||
$this->clearWorkHoursForSegment($employee, $first);
|
||||
$this->entityManager->persist($data);
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
$absence = new Absence()
|
||||
->setEmployee($employee)
|
||||
->setType($data->getType())
|
||||
->setComment($data->getComment())
|
||||
->setStartDate($this->toMutableDate($segment['date']))
|
||||
->setEndDate($this->toMutableDate($segment['date']))
|
||||
->setStartHalf($segment['startHalf'])
|
||||
->setEndHalf($segment['endHalf'])
|
||||
;
|
||||
|
||||
$this->clearWorkHoursForSegment($employee, $segment);
|
||||
$this->entityManager->persist($absence);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay}>
|
||||
*/
|
||||
private function expandAbsenceRange(Absence $absence): array
|
||||
{
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
|
||||
|
||||
if ($start > $end) {
|
||||
throw new UnprocessableEntityHttpException('La date de fin ne peut pas être avant la date de début.');
|
||||
}
|
||||
|
||||
if (
|
||||
$start->format('Y-m-d') === $end->format('Y-m-d')
|
||||
&& HalfDay::PM === $absence->getStartHalf()
|
||||
&& HalfDay::AM === $absence->getEndHalf()
|
||||
) {
|
||||
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
|
||||
}
|
||||
|
||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
|
||||
$segments = [];
|
||||
foreach ($days as $day) {
|
||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
||||
$isSame = $isFirst && $isLast;
|
||||
|
||||
if ($isSame) {
|
||||
$segments[] = [
|
||||
'date' => $day,
|
||||
'startHalf' => $absence->getStartHalf(),
|
||||
'endHalf' => $absence->getEndHalf(),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isFirst && HalfDay::PM === $absence->getStartHalf()) {
|
||||
$segments[] = [
|
||||
'date' => $day,
|
||||
'startHalf' => HalfDay::PM,
|
||||
'endHalf' => HalfDay::PM,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isLast && HalfDay::AM === $absence->getEndHalf()) {
|
||||
$segments[] = [
|
||||
'date' => $day,
|
||||
'startHalf' => HalfDay::AM,
|
||||
'endHalf' => HalfDay::AM,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = [
|
||||
'date' => $day,
|
||||
'startHalf' => HalfDay::AM,
|
||||
'endHalf' => HalfDay::PM,
|
||||
];
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
private function toMutableDate(DateTimeImmutable $date): DateTime
|
||||
{
|
||||
return DateTime::createFromImmutable($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
||||
*/
|
||||
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
|
||||
{
|
||||
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
|
||||
if (null === $workHour) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Demi-journée matin: on efface uniquement la plage du matin.
|
||||
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
|
||||
$workHour
|
||||
->setMorningFrom(null)
|
||||
->setMorningTo(null)
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Demi-journée après-midi: on efface après-midi + soirée.
|
||||
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
|
||||
$workHour
|
||||
->setAfternoonFrom(null)
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Journée complète: on efface toutes les plages horaires.
|
||||
$workHour
|
||||
->setMorningFrom(null)
|
||||
->setMorningTo(null)
|
||||
->setAfternoonFrom(null)
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
;
|
||||
}
|
||||
}
|
||||
29
src/State/ScopedEmployeeProvider.php
Normal file
29
src/State/ScopedEmployeeProvider.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class ScopedEmployeeProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->employeeRepository->findScoped($user);
|
||||
}
|
||||
}
|
||||
312
src/State/WorkHourBulkUpsertProcessor.php
Normal file
312
src/State/WorkHourBulkUpsertProcessor.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\WorkHourBulkUpsert;
|
||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
mixed $data,
|
||||
Operation $operation,
|
||||
array $uriVariables = [],
|
||||
array $context = []
|
||||
): WorkHourBulkUpsertResult {
|
||||
// Endpoint dédié au bulk: on refuse tout autre payload.
|
||||
if (!$data instanceof WorkHourBulkUpsert) {
|
||||
throw new BadRequestHttpException('Invalid payload.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
|
||||
if (!$workDate || $workDate->format('Y-m-d') !== $data->workDate) {
|
||||
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
|
||||
}
|
||||
|
||||
if ([] === $data->entries) {
|
||||
throw new UnprocessableEntityHttpException('entries must contain at least one employee.');
|
||||
}
|
||||
|
||||
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
|
||||
$employeeIds = $this->extractEmployeeIds($data->entries);
|
||||
$employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);
|
||||
|
||||
if (count($employeesById) !== count($employeeIds)) {
|
||||
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
|
||||
}
|
||||
|
||||
$existingByEmployeeId = $this->workHourRepository
|
||||
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
|
||||
;
|
||||
|
||||
$result = new WorkHourBulkUpsertResult();
|
||||
|
||||
foreach ($data->entries as $entry) {
|
||||
$employeeId = (int) $entry['employeeId'];
|
||||
$employee = $employeesById[$employeeId] ?? null;
|
||||
if (!$employee) {
|
||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||
}
|
||||
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
|
||||
if ($existing?->isValid()) {
|
||||
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Employee %d: validated work hour cannot be modified.',
|
||||
$employeeId
|
||||
));
|
||||
}
|
||||
|
||||
++$result->processed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isEntryEmpty($normalized)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
$this->entityManager->remove($existing);
|
||||
++$result->deleted;
|
||||
}
|
||||
|
||||
++$result->processed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$workHour = $existing;
|
||||
++$result->updated;
|
||||
} else {
|
||||
// Upsert: création si aucune ligne n'existe pour (employé, date).
|
||||
$workHour = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
->setWorkDate($workDate)
|
||||
;
|
||||
$this->entityManager->persist($workHour);
|
||||
++$result->created;
|
||||
}
|
||||
|
||||
$this->hydrateWorkHour($workHour, $normalized);
|
||||
++$result->processed;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $entries
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function extractEmployeeIds(array $entries): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($entries as $index => $entry) {
|
||||
if (!is_array($entry) || !array_key_exists('employeeId', $entry)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId is required.', $index));
|
||||
}
|
||||
|
||||
$employeeId = (int) $entry['employeeId'];
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId must be a positive integer.', $index));
|
||||
}
|
||||
|
||||
if (isset($ids[$employeeId])) {
|
||||
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in the same bulk payload.', $employeeId));
|
||||
}
|
||||
|
||||
$ids[$employeeId] = $employeeId;
|
||||
}
|
||||
|
||||
return array_values($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*
|
||||
* @return array{
|
||||
* morningFrom:?string,
|
||||
* morningTo:?string,
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* }
|
||||
*/
|
||||
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
|
||||
{
|
||||
if ($isPresenceTracking) {
|
||||
return [
|
||||
'morningFrom' => null,
|
||||
'morningTo' => null,
|
||||
'afternoonFrom' => null,
|
||||
'afternoonTo' => null,
|
||||
'eveningFrom' => null,
|
||||
'eveningTo' => null,
|
||||
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
||||
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
||||
'isPresentMorning' => false,
|
||||
'isPresentAfternoon' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
|
||||
{
|
||||
if (null === $value || '' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Employee %d: %s must be a string in HH:MM format.',
|
||||
$employeeId,
|
||||
$field
|
||||
));
|
||||
}
|
||||
|
||||
$time = trim($value);
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Employee %d: %s must use HH:MM format.',
|
||||
$employeeId,
|
||||
$field
|
||||
));
|
||||
}
|
||||
|
||||
return $time;
|
||||
}
|
||||
|
||||
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
|
||||
{
|
||||
if (!is_bool($value)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Employee %d: %s must be a boolean.',
|
||||
$employeeId,
|
||||
$field
|
||||
));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* morningFrom:?string,
|
||||
* morningTo:?string,
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* } $entry
|
||||
*/
|
||||
private function isEntryEmpty(array $entry): bool
|
||||
{
|
||||
return null === $entry['morningFrom']
|
||||
&& null === $entry['morningTo']
|
||||
&& null === $entry['afternoonFrom']
|
||||
&& null === $entry['afternoonTo']
|
||||
&& null === $entry['eveningFrom']
|
||||
&& null === $entry['eveningTo']
|
||||
&& false === $entry['isPresentMorning']
|
||||
&& false === $entry['isPresentAfternoon'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* morningFrom:?string,
|
||||
* morningTo:?string,
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* } $entry
|
||||
*/
|
||||
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
||||
{
|
||||
$workHour
|
||||
->setMorningFrom($entry['morningFrom'])
|
||||
->setMorningTo($entry['morningTo'])
|
||||
->setAfternoonFrom($entry['afternoonFrom'])
|
||||
->setAfternoonTo($entry['afternoonTo'])
|
||||
->setEveningFrom($entry['eveningFrom'])
|
||||
->setEveningTo($entry['eveningTo'])
|
||||
->setIsPresentMorning($entry['isPresentMorning'])
|
||||
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
||||
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
||||
->setIsValid(false)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* morningFrom:?string,
|
||||
* morningTo:?string,
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* } $entry
|
||||
*/
|
||||
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
|
||||
{
|
||||
return $workHour->getMorningFrom() === $entry['morningFrom']
|
||||
&& $workHour->getMorningTo() === $entry['morningTo']
|
||||
&& $workHour->getAfternoonFrom() === $entry['afternoonFrom']
|
||||
&& $workHour->getAfternoonTo() === $entry['afternoonTo']
|
||||
&& $workHour->getEveningFrom() === $entry['eveningFrom']
|
||||
&& $workHour->getEveningTo() === $entry['eveningTo']
|
||||
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
|
||||
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
|
||||
}
|
||||
}
|
||||
110
src/State/WorkHourDayContextProvider.php
Normal file
110
src/State/WorkHourDayContextProvider.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\WorkHourDayContext;
|
||||
use App\Dto\WorkHours\DayContextRow;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class WorkHourDayContextProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
// Endpoint protégé: on exige un utilisateur authentifié.
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$workDate = $this->resolveWorkDate();
|
||||
$employees = $this->employeeRepository->findScoped($user);
|
||||
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
|
||||
|
||||
$rowsByEmployeeId = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// On initialise toutes les lignes, même sans absence ce jour-là.
|
||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
|
||||
}
|
||||
|
||||
$dateKey = $workDate->format('Y-m-d');
|
||||
foreach ($absences as $absence) {
|
||||
$employeeId = $absence->getEmployee()?->getId();
|
||||
// Ignore les absences orphelines ou hors scope utilisateur.
|
||||
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $dateKey);
|
||||
// Pas de segment absent sur ce jour: rien à injecter dans la ligne.
|
||||
if (!$absentMorning && !$absentAfternoon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
||||
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
||||
$rowsByEmployeeId[$employeeId]->addAbsence(
|
||||
label: $absence->getType()?->getLabel(),
|
||||
morning: $absentMorning,
|
||||
afternoon: $absentAfternoon,
|
||||
creditedMinutes: $creditedMinutes,
|
||||
creditedPresenceUnits: $creditedPresenceUnits
|
||||
);
|
||||
}
|
||||
|
||||
$response = new WorkHourDayContext();
|
||||
$response->workDate = $dateKey;
|
||||
$response->rows = array_map(
|
||||
static fn (DayContextRow $row): array => $row->toArray(),
|
||||
array_values($rowsByEmployeeId)
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function resolveWorkDate(): DateTimeImmutable
|
||||
{
|
||||
$query = $this->requestStack->getCurrentRequest()?->query;
|
||||
$raw = (string) ($query?->get('workDate') ?? '');
|
||||
|
||||
// Sans paramètre, on cible la date du jour.
|
||||
if ('' === $raw) {
|
||||
return new DateTimeImmutable('today');
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
||||
// Validation stricte du format pour éviter les ambiguïtés de parsing.
|
||||
if (!$date || $date->format('Y-m-d') !== $raw) {
|
||||
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
389
src/State/WorkHourWeeklySummaryProvider.php
Normal file
389
src/State/WorkHourWeeklySummaryProvider.php
Normal file
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\WorkHourWeeklySummary;
|
||||
use App\Dto\WorkHours\WeeklyDaySummary;
|
||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$anchorDate = $this->resolveAnchorDate();
|
||||
[$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate);
|
||||
|
||||
$employees = $this->employeeRepository->findScoped($user);
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
||||
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
||||
|
||||
$summary = new WorkHourWeeklySummary();
|
||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||
$summary->days = $days;
|
||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function resolveAnchorDate(): DateTimeImmutable
|
||||
{
|
||||
$query = $this->requestStack->getCurrentRequest()?->query;
|
||||
$raw = (string) ($query?->get('weekStart') ?? '');
|
||||
|
||||
// Sans paramètre, on ancre la semaine sur aujourd'hui.
|
||||
if ('' === $raw) {
|
||||
return new DateTimeImmutable('today');
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
||||
// Validation stricte du format attendu.
|
||||
if (!$date || $date->format('Y-m-d') !== $raw) {
|
||||
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable, list<string>}
|
||||
*/
|
||||
private function resolveWeek(DateTimeImmutable $anchorDate): array
|
||||
{
|
||||
// Convention ISO: semaine de lundi (1) à dimanche (7).
|
||||
$dayOfWeek = (int) $anchorDate->format('N');
|
||||
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
|
||||
$weekEnd = $weekStart->modify('+6 days');
|
||||
|
||||
$days = [];
|
||||
for ($i = 0; $i < 7; ++$i) {
|
||||
$days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d');
|
||||
}
|
||||
|
||||
return [$weekStart, $weekEnd, $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
* @param list<WorkHour> $workHours
|
||||
* @param list<Absence> $absences
|
||||
* @param list<string> $days
|
||||
*
|
||||
* @return list<WeeklySummaryRow>
|
||||
*/
|
||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
||||
{
|
||||
$metricsByEmployeeDate = [];
|
||||
foreach ($workHours as $workHour) {
|
||||
$employeeId = $workHour->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
|
||||
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
|
||||
$metricsByEmployeeDate[$employeeId][$dateKey] = [
|
||||
'metrics' => $this->computeMetrics($workHour),
|
||||
'isPresentMorning' => $workHour->getIsPresentMorning(),
|
||||
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
|
||||
];
|
||||
}
|
||||
|
||||
$creditedByEmployeeDate = [];
|
||||
$creditedPresenceByEmployeeDate = [];
|
||||
$absenceByEmployeeDate = [];
|
||||
$absenceLabelByEmployeeDate = [];
|
||||
$absenceColorByEmployeeDate = [];
|
||||
foreach ($absences as $absence) {
|
||||
$employeeId = $absence->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = $absence->getStartDate()->format('Y-m-d');
|
||||
$end = $absence->getEndDate()->format('Y-m-d');
|
||||
foreach ($days as $date) {
|
||||
// On ne crédite que les dates couvertes par l'intervalle d'absence.
|
||||
if ($date < $start || $date > $end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||
if ($absentMorning || $absentAfternoon) {
|
||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||
}
|
||||
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
|
||||
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
|
||||
}
|
||||
}
|
||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$weeklyDayMinutes = 0;
|
||||
$weeklyNightMinutes = 0;
|
||||
$weeklyTotalMinutes = 0;
|
||||
$weeklyPresenceCount = 0.0;
|
||||
$daily = [];
|
||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
|
||||
foreach ($days as $date) {
|
||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
||||
$metrics->addCreditedMinutes($creditedMinutes);
|
||||
$present = null;
|
||||
if ($isPresenceTracking) {
|
||||
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
||||
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
||||
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
||||
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
||||
}
|
||||
|
||||
$weeklyDayMinutes += $metrics->dayMinutes;
|
||||
$weeklyNightMinutes += $metrics->nightMinutes;
|
||||
$weeklyTotalMinutes += $metrics->totalMinutes;
|
||||
if (null !== $present) {
|
||||
$weeklyPresenceCount += $present;
|
||||
}
|
||||
|
||||
$daily[] = new WeeklyDaySummary(
|
||||
date: $date,
|
||||
dayMinutes: $metrics->dayMinutes,
|
||||
nightMinutes: $metrics->nightMinutes,
|
||||
totalMinutes: $metrics->totalMinutes,
|
||||
present: $present,
|
||||
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
||||
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
||||
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
||||
? 0
|
||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||
|
||||
$rows[] = new WeeklySummaryRow(
|
||||
employeeId: $employeeId,
|
||||
firstName: $employee->getFirstName(),
|
||||
lastName: $employee->getLastName(),
|
||||
siteName: $employee->getSite()?->getName(),
|
||||
contractName: $employee->getContract()?->getName(),
|
||||
contractType: $employee->getContract()?->getType()->value,
|
||||
trackingMode: $employee->getContract()?->getTrackingMode(),
|
||||
daily: $daily,
|
||||
weeklyDayMinutes: $weeklyDayMinutes,
|
||||
weeklyNightMinutes: $weeklyNightMinutes,
|
||||
weeklyTotalMinutes: $weeklyTotalMinutes,
|
||||
weeklyPresenceCount: $weeklyPresenceCount,
|
||||
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
||||
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
||||
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
||||
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||
{
|
||||
$ranges = [
|
||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
nightMinutes: $nightMinutes,
|
||||
totalMinutes: $totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{int, int}
|
||||
*/
|
||||
private function resolveInterval(?string $from, ?string $to): ?array
|
||||
{
|
||||
$fromMinutes = $this->toMinutes($from);
|
||||
$toMinutes = $this->toMinutes($to);
|
||||
if (null === $fromMinutes || null === $toMinutes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si fin <= début, on considère un passage à minuit.
|
||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||
|
||||
return [$fromMinutes, $end];
|
||||
}
|
||||
|
||||
private function toMinutes(?string $time): ?int
|
||||
{
|
||||
if (null === $time || '' === $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
private function intervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
||||
{
|
||||
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
|
||||
$referenceHours = max(35, $contractWeeklyHours);
|
||||
|
||||
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
|
||||
}
|
||||
|
||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
||||
{
|
||||
// Règle métier:
|
||||
// - contrats <= 35h: 25% entre 35h et 43h
|
||||
// - contrats >= 39h: 25% entre 39h et 43h
|
||||
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
|
||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
|
||||
|
||||
return (int) round($trancheMinutes * 0.25);
|
||||
}
|
||||
|
||||
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
|
||||
{
|
||||
// Bonus 50% appliqué au-delà de 43h.
|
||||
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
|
||||
|
||||
return (int) round($trancheMinutes * 0.5);
|
||||
}
|
||||
|
||||
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
||||
{
|
||||
$contract = $employee->getContract();
|
||||
$type = ContractType::resolve(
|
||||
$contract?->getName(),
|
||||
$contract?->getTrackingMode(),
|
||||
$contract?->getWeeklyHours()
|
||||
);
|
||||
|
||||
return ContractType::INTERIM === $type;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user