Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #9 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
543 lines
22 KiB
PHP
543 lines
22 KiB
PHP
<?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\Contract\AbsenceReadRepositoryInterface;
|
|
use App\Repository\EmployeeRepository;
|
|
use App\Repository\WorkHourRepository;
|
|
use App\Service\AuditLogger;
|
|
use App\Service\Contracts\EmployeeContractResolver;
|
|
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,
|
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
|
private EmployeeContractResolver $contractResolver,
|
|
private AuditLogger $auditLogger,
|
|
) {}
|
|
|
|
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))
|
|
;
|
|
$absenceByEmployeeId = [];
|
|
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
|
|
$absenceEmployeeId = $absence->getEmployee()?->getId();
|
|
if ($absenceEmployeeId) {
|
|
$absenceByEmployeeId[$absenceEmployeeId] = true;
|
|
}
|
|
}
|
|
|
|
$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));
|
|
}
|
|
|
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
|
if (null === $contract) {
|
|
throw new UnprocessableEntityHttpException(sprintf(
|
|
'Employee %d has no active contract on %s.',
|
|
$employeeId,
|
|
$data->workDate
|
|
));
|
|
}
|
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
|
$isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate);
|
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking, $isDriver);
|
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
|
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
|
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
|
|
|
|
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 (!$isAdmin && $existing?->isSiteValid()) {
|
|
if (!$this->isSameAsExisting($existing, $normalized)) {
|
|
throw new UnprocessableEntityHttpException(sprintf(
|
|
'Employee %d: site validated work hour cannot be modified.',
|
|
$employeeId
|
|
));
|
|
}
|
|
|
|
++$result->processed;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Si aucune donnée n'a changé, on ne touche pas la ligne:
|
|
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
|
|
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
|
|
++$result->processed;
|
|
|
|
continue;
|
|
}
|
|
|
|
$is4hContract = 4 === $contract->getWeeklyHours();
|
|
|
|
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
|
|
|
if ($this->isEntryEmpty($normalized)) {
|
|
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
|
if ($existing) {
|
|
$this->auditLogger->log(
|
|
$employee,
|
|
'delete',
|
|
'work_hour',
|
|
$existing->getId(),
|
|
sprintf('Heures supprimées pour %s le %s', $empName, $data->workDate),
|
|
['old' => $this->snapshotWorkHour($existing)],
|
|
$workDate,
|
|
);
|
|
$this->entityManager->remove($existing);
|
|
++$result->deleted;
|
|
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
|
|
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
|
|
$workHour = new WorkHour()
|
|
->setEmployee($employee)
|
|
->setWorkDate($workDate)
|
|
;
|
|
$this->hydrateWorkHour($workHour, $normalized);
|
|
if ($isSelf) {
|
|
$workHour->setUpdatedAt(new DateTimeImmutable());
|
|
}
|
|
$this->entityManager->persist($workHour);
|
|
$existingByEmployeeId[$employeeId] = $workHour;
|
|
++$result->created;
|
|
}
|
|
|
|
++$result->processed;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($existing) {
|
|
$oldSnapshot = $this->snapshotWorkHour($existing);
|
|
$workHour = $existing;
|
|
++$result->updated;
|
|
} else {
|
|
$oldSnapshot = null;
|
|
// 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);
|
|
if (!$isAdmin) {
|
|
$workHour->setUpdatedAt(new DateTimeImmutable());
|
|
}
|
|
|
|
$newSnapshot = $this->snapshotWorkHour($workHour);
|
|
$action = null !== $oldSnapshot ? 'update' : 'create';
|
|
$changes = null !== $oldSnapshot
|
|
? ['old' => $oldSnapshot, 'new' => $newSnapshot]
|
|
: ['new' => $newSnapshot];
|
|
|
|
$this->auditLogger->log(
|
|
$employee,
|
|
$action,
|
|
'work_hour',
|
|
$workHour->getId(),
|
|
sprintf('Heures %s pour %s le %s', null !== $oldSnapshot ? 'modifiées' : 'créées', $empName, $data->workDate),
|
|
$changes,
|
|
$workDate,
|
|
);
|
|
|
|
++$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,
|
|
* dayHoursMinutes:?int,
|
|
* nightHoursMinutes:?int,
|
|
* workshopHoursMinutes:?int,
|
|
* hasBreakfast:bool,
|
|
* hasLunch:bool,
|
|
* hasDinner:bool,
|
|
* hasOvernight:bool
|
|
* }
|
|
*/
|
|
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking, bool $isDriver): array
|
|
{
|
|
if ($isDriver) {
|
|
return [
|
|
'morningFrom' => null,
|
|
'morningTo' => null,
|
|
'afternoonFrom' => null,
|
|
'afternoonTo' => null,
|
|
'eveningFrom' => null,
|
|
'eveningTo' => null,
|
|
'isPresentMorning' => false,
|
|
'isPresentAfternoon' => false,
|
|
'dayHoursMinutes' => $this->normalizeMinutes($entry['dayHoursMinutes'] ?? null, $employeeId, 'dayHoursMinutes'),
|
|
'nightHoursMinutes' => $this->normalizeMinutes($entry['nightHoursMinutes'] ?? null, $employeeId, 'nightHoursMinutes'),
|
|
'workshopHoursMinutes' => $this->normalizeMinutes($entry['workshopHoursMinutes'] ?? null, $employeeId, 'workshopHoursMinutes'),
|
|
'hasBreakfast' => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'),
|
|
'hasLunch' => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'),
|
|
'hasDinner' => $this->normalizePresence($entry['hasDinner'] ?? false, $employeeId, 'hasDinner'),
|
|
'hasOvernight' => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'),
|
|
];
|
|
}
|
|
|
|
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'),
|
|
'dayHoursMinutes' => null,
|
|
'nightHoursMinutes' => null,
|
|
'workshopHoursMinutes' => null,
|
|
'hasBreakfast' => false,
|
|
'hasLunch' => false,
|
|
'hasDinner' => false,
|
|
'hasOvernight' => false,
|
|
];
|
|
}
|
|
|
|
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'),
|
|
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
|
|
// même si le contrat résolu ce jour est en suivi horaire.
|
|
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
|
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
|
'dayHoursMinutes' => null,
|
|
'nightHoursMinutes' => null,
|
|
'workshopHoursMinutes' => null,
|
|
'hasBreakfast' => false,
|
|
'hasLunch' => false,
|
|
'hasDinner' => false,
|
|
'hasOvernight' => 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 normalizeMinutes(mixed $value, int $employeeId, string $field): ?int
|
|
{
|
|
if (null === $value || '' === $value) {
|
|
return null;
|
|
}
|
|
|
|
if (!is_int($value) && !is_float($value)) {
|
|
throw new UnprocessableEntityHttpException(sprintf(
|
|
'Employee %d: %s must be an integer (minutes).',
|
|
$employeeId,
|
|
$field
|
|
));
|
|
}
|
|
|
|
$minutes = (int) $value;
|
|
if ($minutes < 0) {
|
|
throw new UnprocessableEntityHttpException(sprintf(
|
|
'Employee %d: %s must be >= 0.',
|
|
$employeeId,
|
|
$field
|
|
));
|
|
}
|
|
|
|
return $minutes;
|
|
}
|
|
|
|
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,
|
|
* dayHoursMinutes:?int,
|
|
* nightHoursMinutes:?int,
|
|
* workshopHoursMinutes:?int,
|
|
* hasBreakfast:bool,
|
|
* hasLunch:bool,
|
|
* hasDinner:bool,
|
|
* hasOvernight: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']
|
|
&& (null === $entry['dayHoursMinutes'] || 0 === $entry['dayHoursMinutes'])
|
|
&& (null === $entry['nightHoursMinutes'] || 0 === $entry['nightHoursMinutes'])
|
|
&& (null === $entry['workshopHoursMinutes'] || 0 === $entry['workshopHoursMinutes'])
|
|
&& false === $entry['hasBreakfast']
|
|
&& false === $entry['hasLunch']
|
|
&& false === $entry['hasDinner']
|
|
&& false === $entry['hasOvernight'];
|
|
}
|
|
|
|
/**
|
|
* @param array{
|
|
* morningFrom:?string,
|
|
* morningTo:?string,
|
|
* afternoonFrom:?string,
|
|
* afternoonTo:?string,
|
|
* eveningFrom:?string,
|
|
* eveningTo:?string,
|
|
* isPresentMorning:bool,
|
|
* isPresentAfternoon:bool,
|
|
* dayHoursMinutes:?int,
|
|
* nightHoursMinutes:?int,
|
|
* workshopHoursMinutes:?int,
|
|
* hasBreakfast:bool,
|
|
* hasLunch:bool,
|
|
* hasDinner:bool,
|
|
* hasOvernight: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'])
|
|
->setDayHoursMinutes($entry['dayHoursMinutes'])
|
|
->setNightHoursMinutes($entry['nightHoursMinutes'])
|
|
->setWorkshopHoursMinutes($entry['workshopHoursMinutes'])
|
|
->setHasBreakfast($entry['hasBreakfast'])
|
|
->setHasLunch($entry['hasLunch'])
|
|
->setHasDinner($entry['hasDinner'])
|
|
->setHasOvernight($entry['hasOvernight'])
|
|
// Toute modification invalide la validation chef de site.
|
|
->setIsSiteValid(false)
|
|
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
|
->setIsValid(false)
|
|
;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function snapshotWorkHour(WorkHour $wh): array
|
|
{
|
|
return [
|
|
'morningFrom' => $wh->getMorningFrom(),
|
|
'morningTo' => $wh->getMorningTo(),
|
|
'afternoonFrom' => $wh->getAfternoonFrom(),
|
|
'afternoonTo' => $wh->getAfternoonTo(),
|
|
'eveningFrom' => $wh->getEveningFrom(),
|
|
'eveningTo' => $wh->getEveningTo(),
|
|
'isPresentMorning' => $wh->getIsPresentMorning(),
|
|
'isPresentAfternoon' => $wh->getIsPresentAfternoon(),
|
|
'dayHoursMinutes' => $wh->getDayHoursMinutes(),
|
|
'nightHoursMinutes' => $wh->getNightHoursMinutes(),
|
|
'workshopHoursMinutes' => $wh->getWorkshopHoursMinutes(),
|
|
'hasBreakfast' => $wh->getHasBreakfast(),
|
|
'hasLunch' => $wh->getHasLunch(),
|
|
'hasDinner' => $wh->getHasDinner(),
|
|
'hasOvernight' => $wh->getHasOvernight(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{
|
|
* morningFrom:?string,
|
|
* morningTo:?string,
|
|
* afternoonFrom:?string,
|
|
* afternoonTo:?string,
|
|
* eveningFrom:?string,
|
|
* eveningTo:?string,
|
|
* isPresentMorning:bool,
|
|
* isPresentAfternoon:bool,
|
|
* dayHoursMinutes:?int,
|
|
* nightHoursMinutes:?int,
|
|
* workshopHoursMinutes:?int,
|
|
* hasBreakfast:bool,
|
|
* hasLunch:bool,
|
|
* hasDinner:bool,
|
|
* hasOvernight: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']
|
|
&& $workHour->getDayHoursMinutes() === $entry['dayHoursMinutes']
|
|
&& $workHour->getNightHoursMinutes() === $entry['nightHoursMinutes']
|
|
&& $workHour->getWorkshopHoursMinutes() === $entry['workshopHoursMinutes']
|
|
&& $workHour->getHasBreakfast() === $entry['hasBreakfast']
|
|
&& $workHour->getHasLunch() === $entry['hasLunch']
|
|
&& $workHour->getHasDinner() === $entry['hasDinner']
|
|
&& $workHour->getHasOvernight() === $entry['hasOvernight'];
|
|
}
|
|
}
|