* * @method Disposition|null find($id, $lockMode = null, $lockVersion = null) * @method Disposition|null findOneBy(array $criteria, array $orderBy = null) * @method Disposition[] findAll() * @method Disposition[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class DispositionRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Disposition::class); } public function getUpcomingDispositionsByTeamerQuery(Teamer $teamer): Query { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'job_profile', 'destination') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( $qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->neq('assignment.status', ':assignmentStatus'), $qb->expr()->neq('disposition.status', ':dispositionStatus'), $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->gte('destination.dateTo', ':date') ), $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->gte('assignment.dateTo', ':date') ) ) )) ->setParameter('teamer', $teamer) ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) ->setParameter('dispositionStatus', Disposition::STATUS_CALLED_OFF) ->setParameter('date', new \DateTimeImmutable()) ->getQuery() ; } public function getUpcomingDispositionsByTeamer(Teamer $teamer, int $limit = 5): array { return $this ->getUpcomingDispositionsByTeamerQuery($teamer) ->setMaxResults($limit) ->getResult() ; } public function getRecentDispositionsByTeamerQuery(Teamer $teamer): Query { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'job_profile', 'destination', 'feedback') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') ->where($qb->expr()->andX( $qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->neq('assignment.status', ':assignmentStatus'), $qb->expr()->neq('disposition.status', ':dispositionStatus'), $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->lte('destination.dateTo', ':date') ), $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->lte('assignment.dateTo', ':date') ) ) )) ->setParameter('teamer', $teamer) ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) ->setParameter('dispositionStatus', Disposition::STATUS_CALLED_OFF) ->setParameter('date', new \DateTimeImmutable()) ->getQuery() ; } public function findCurrentDispositionsByAssignment(Assignment $assignment): array { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'teamer', 'user', 'feedback') ->innerJoin('disposition.teamer', 'teamer') ->leftJoin('teamer.user', 'user') ->leftJoin('teamer.feedback', 'feedback') ->where($qb->expr()->eq('disposition.assignment', ':assignment')) ->setParameter('assignment', $assignment) ->getQuery() ->getResult() ; } public function findRecentDispositionsByTeamer(Teamer $teamer, int $limit = 5): array { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'destination', 'feedback') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') ->where($qb->expr()->andX( $qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->neq('assignment.status', ':assignmentStatus'), $qb->expr()->neq('disposition.status', ':dispositionStatus') )) ->setParameter('teamer', $teamer) ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) ->setParameter('dispositionStatus', Disposition::STATUS_CALLED_OFF) ->orderBy('destination.dateFrom', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult() ; } public function findCurrentDispositionsByTeamer(Teamer $teamer): array { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'destination') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( $qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->in('disposition.status', ':status') )) ->setParameter('teamer', $teamer) ->setParameter('status', [ Disposition::STATUS_NEW, Disposition::STATUS_CONFIRMED, ]) ->orderBy('destination.dateFrom', 'ASC') ->getQuery() ->getResult() ; } public function findNewDispositionsByHotelCodes(mixed $hotelCode): array { $hotelCodes = (array) $hotelCode; $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'job_profile', 'destination') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( $qb->expr()->orX( $qb->expr()->in($qb->expr()->substring('destination.hotelCode', 1, 3), ':hotelCodes'), $qb->expr()->in($qb->expr()->substring('destination.hotelCode', -3, 3), ':hotelCodes'), ), $qb->expr()->gte('destination.dateTo', ':dateTo'), $qb->expr()->eq('disposition.status', ':status') )) ->orderBy('destination.dateFrom', 'ASC') ->setParameter('hotelCodes', $hotelCodes) ->setParameter('dateTo', new \DateTimeImmutable()) ->setParameter('status', Disposition::STATUS_CONFIRMED) ->getQuery() ->getResult() ; } public function getDispositionsWithPendingFeedbackQuery(mixed $hotelCode = null, ?int $offsetDays = null): Query { $hotelCodes = (array) $hotelCode; $qb = $this->createQueryBuilder('disposition'); $qb ->select('disposition', 'assignment', 'job_profile', 'destination', 'feedback', 'teamer', 'user') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('disposition.teamer', 'teamer') ->innerJoin('teamer.user', 'user') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') ->where($qb->expr()->andX( $qb->expr()->notIn('assignment.status', ':status'), $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->lte('assignment.dateTo', ':dateTo') ), $qb->expr()->lte('destination.dateTo', ':dateTo') ), $qb->expr()->isNull('feedback') )) ->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ; $dateTo = new \DateTimeImmutable(); if (null !== $offsetDays) { $dateTo = $dateTo->modify('-'.$offsetDays.' days'); } $qb->setParameter('dateTo', $dateTo); if (null !== $hotelCode) { $qb ->andWhere($qb->expr()->orX( $qb->expr()->in($qb->expr()->substring('destination.hotelCode', 1, 3), ':hotelCodes'), $qb->expr()->in($qb->expr()->substring('destination.hotelCode', -3, 3), ':hotelCodes'), )) ->setParameter('hotelCodes', $hotelCodes) ; } return $qb->getQuery(); } public function findDispositionsWithOverdueFeedback(?int $offsetDays = null): array { return $this ->getDispositionsWithPendingFeedbackQuery(null, $offsetDays) ->getResult() ; } public function findDispositionsWithPendingFeedbackByHotelCodes(mixed $hotelCode): array { return $this ->getDispositionsWithPendingFeedbackQuery($hotelCode) ->getResult() ; } /** * @return Disposition[]|array */ public function findEndedDispositions(): array { $qb = $this->createQueryBuilder('disposition'); return $qb ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( $qb->expr()->in('disposition.status', ':status'), $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->lte('assignment.dateTo', ':dateTo') ), $qb->expr()->lte('destination.dateTo', ':dateTo') ) )) ->setParameter('status', [ Disposition::STATUS_NEW, Disposition::STATUS_CHECKING_CONTRACT, Disposition::STATUS_CONFIRMED, ]) ->setParameter('dateTo', new \DateTimeImmutable()) ->getQuery() ->getResult() ; } public function getNew(int $limit = 5): array { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'destination', 'job_profile') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.jobProfile', 'job_profile') ->where($qb->expr()->gt('destination.dateFrom', ':dateFrom')) ->orderBy('disposition.createdAt', 'DESC') ->setParameter('dateFrom', new \DateTimeImmutable()) ->setMaxResults($limit) ->getQuery() ->getResult() ; } /** * Returns feedback statistics per hotel for dispositions with status 'ended' or 'completed'. * * @return array */ public function getFeedbackStatisticsByHotel( ?\DateTimeImmutable $dateFrom = null, ?\DateTimeImmutable $dateTo = null, ): array { $qb = $this->createQueryBuilder('disposition'); $qb ->select( 'destination.hotelCode AS hotelCode', 'destination.hotel AS hotel', 'COUNT(disposition.id) AS totalDispositions', 'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks' ) ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') ->where($qb->expr()->in('disposition.status', ':statuses')) ->setParameter('statuses', [ Disposition::STATUS_ENDED, Disposition::STATUS_PAID, Disposition::STATUS_COMPLETED, ]) ->groupBy('destination.hotelCode', 'destination.hotel') ->orderBy('destination.hotel', 'ASC') ; // Filter by effective date range (assignment date or fallback to destination date) if (null !== $dateFrom) { $qb ->andWhere($qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateFrom'), $qb->expr()->gte('assignment.dateFrom', ':dateFrom') ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateFrom'), $qb->expr()->gte('destination.dateFrom', ':dateFrom') ) )) ->setParameter('dateFrom', $dateFrom) ; } if (null !== $dateTo) { $qb ->andWhere($qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->lte('assignment.dateTo', ':dateTo') ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->lte('destination.dateTo', ':dateTo') ) )) ->setParameter('dateTo', $dateTo) ; } $results = $qb->getQuery()->getResult(); // Calculate missing feedbacks and percentage return array_map(function (array $row): array { $total = (int) $row['totalDispositions']; $provided = (int) $row['providedFeedbacks']; $missing = $total - $provided; $percentage = $total > 0 ? round(($provided / $total) * 100, 1) : 0.0; return [ 'hotelCode' => $row['hotelCode'], 'hotel' => $row['hotel'], 'totalDispositions' => $total, 'providedFeedbacks' => $provided, 'missingFeedbacks' => $missing, 'percentageProvided' => $percentage, ]; }, $results); } /** * Returns feedback statistics grouped by normalized hotel code (base 3-char code). * * @return array */ public function getFeedbackStatisticsByNormalizedHotelCode( ?\DateTimeImmutable $dateFrom = null, ?\DateTimeImmutable $dateTo = null, ): array { $qb = $this->createQueryBuilder('disposition'); // Normalize hotel code: strip SER prefix to get base 3-char code $normalizedHotelCode = "CASE WHEN destination.hotelCode LIKE 'SER%' THEN SUBSTRING(destination.hotelCode, 4, 3) ELSE SUBSTRING(destination.hotelCode, 1, 3) END"; $qb ->select( $normalizedHotelCode.' AS hotelCode', 'COUNT(disposition.id) AS totalDispositions', 'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks' ) ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') ->where($qb->expr()->in('disposition.status', ':statuses')) ->setParameter('statuses', [ Disposition::STATUS_ENDED, Disposition::STATUS_PAID, Disposition::STATUS_COMPLETED, ]) ->groupBy('hotelCode') ->orderBy('hotelCode', 'ASC') ; if (null !== $dateFrom) { $qb ->andWhere($qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateFrom'), $qb->expr()->gte('assignment.dateFrom', ':dateFrom') ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateFrom'), $qb->expr()->gte('destination.dateFrom', ':dateFrom') ) )) ->setParameter('dateFrom', $dateFrom) ; } if (null !== $dateTo) { $qb ->andWhere($qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->lte('assignment.dateTo', ':dateTo') ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->lte('destination.dateTo', ':dateTo') ) )) ->setParameter('dateTo', $dateTo) ; } $results = $qb->getQuery()->getResult(); return array_map(function (array $row): array { $total = (int) $row['totalDispositions']; $provided = (int) $row['providedFeedbacks']; $missing = $total - $provided; $percentage = $total > 0 ? round(($provided / $total) * 100, 1) : 0.0; return [ 'hotelCode' => $row['hotelCode'], 'totalDispositions' => $total, 'providedFeedbacks' => $provided, 'missingFeedbacks' => $missing, 'percentageProvided' => $percentage, ]; }, $results); } /** * Dispositions to remind about a missing contract, on the day the upload period runs out. * * Anchored on the creation of the disposition, which is what the upload period counts * from. The range is a half-open day rather than an equality: disposition.createdAt is a * timestamp, and the cron runs at 01:00, so "=" would only ever match a disposition that * happened to be created at that exact second. * * @param int $deadlineDays %contract_upload_deadline_days% */ public function findDispositionsForContractReminder(int $deadlineDays): array { return $this->getContractReminderQuery($deadlineDays)->getResult(); } public function getContractReminderQuery(int $deadlineDays): Query { $today = new \DateTimeImmutable('today'); $dayStart = $today->modify(sprintf('-%d days', $deadlineDays)); $tomorrow = $today->modify('+1 day'); $qb = $this->createQueryBuilder('disposition'); // A short-notice assignment can end before the normal $deadlineDays window would even // start (DispositionWorkflowGuardSubscriber::guardUploadContract blocks uploads from // assignment.end - 1 day on). For those, remind on the block day instead of the normal // day, which would otherwise fall on or after the block and describe an upload that is // already refused. isContractDue() caps the same way. $normalDay = $qb->expr()->andX( $qb->expr()->gte('disposition.createdAt', ':dayStart'), $qb->expr()->lt('disposition.createdAt', ':dayEnd'), $this->effectiveDateToGreaterThan($qb, ':tomorrow'), ); $shortNoticeDay = $qb->expr()->andX( $this->effectiveDateToEquals($qb, ':tomorrow'), $qb->expr()->gte('disposition.createdAt', ':dayStart'), ); return $qb ->select('disposition', 'assignment', 'destination', 'teamer') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') // restricted to the contract, so an unrelated upload does not suppress the reminder ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->innerJoin('disposition.teamer', 'teamer') ->where($qb->expr()->andX( $qb->expr()->isNull('document'), // deleted teamers are excluded here rather than when sending, so that the // count the caller reports stays truthful $qb->expr()->isNull('teamer.deletedAt'), $qb->expr()->eq('disposition.status', ':dispositionStatus'), $qb->expr()->notIn('assignment.status', ':assignmentStatus'), $qb->expr()->orX($normalDay, $shortNoticeDay), )) ->setParameter('documentType', Upload::TYPE_CONTRACT) ->setParameter('dispositionStatus', Disposition::STATUS_NEW) ->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('dayStart', $dayStart) ->setParameter('dayEnd', $dayStart->modify('+1 day')) // bound as date strings, not DateTimeImmutable: both columns are DATE, and // Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals ->setParameter('tomorrow', $tomorrow->format('Y-m-d')) ->getQuery() ; } /** * Dispositions to remind about a missing invoice, for assignments ending on one given day. * * Both sends - the day after the assignment ends and again on the last day of the upload * period - come through here with a different $endDate, which is what keeps each of them * a one-shot without needing a record of what has already been sent. * * Anchored on the end date rather than Disposition::STATUS_ENDED on purpose: there is no * column recording when a disposition ended, and DispositionStatusService runs after the * reminders in CronCommand, so a status-based rule would always be one run late. */ public function findDispositionsForInvoiceReminder(\DateTimeImmutable $endDate): array { return $this->getInvoiceReminderQuery($endDate)->getResult(); } public function getInvoiceReminderQuery(\DateTimeImmutable $endDate): Query { $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'destination', 'teamer') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->innerJoin('disposition.teamer', 'teamer') ->where($qb->expr()->andX( $qb->expr()->isNull('document'), $qb->expr()->isNull('teamer.deletedAt'), $qb->expr()->notIn('disposition.status', ':dispositionStatus'), $qb->expr()->notIn('assignment.status', ':assignmentStatus'), $this->effectiveDateToEquals($qb, ':endDate'), )) ->setParameter('documentType', Upload::TYPE_INVOICE) ->setParameter('dispositionStatus', [Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED]) ->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) // bound as a date string, not as a DateTimeImmutable: both columns are DATE, and // Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals ->setParameter('endDate', $endDate->format('Y-m-d')) ->getQuery() ; } /** * Compares the assignment's end date against $parameter, falling back to the destination's * when the assignment does not override it. * * The fallback is guarded on both sides - without the isNull() on the second branch an * assignment that moves the end date into the future would still match on the * destination's date. Kept in one place because the per-assignment date override is due to * be removed (docs/remove-assignment-date-override.md), and this then collapses to a plain * comparison on destination.dateTo. */ private function effectiveDateToEquals(QueryBuilder $qb, string $parameter): Orx { return $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->eq('assignment.dateTo', $parameter) ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->eq('destination.dateTo', $parameter) ), ); } /** * Same fallback as effectiveDateToEquals(), but for "later than $parameter". */ private function effectiveDateToGreaterThan(QueryBuilder $qb, string $parameter): Orx { return $qb->expr()->orX( $qb->expr()->andX( $qb->expr()->isNotNull('assignment.dateTo'), $qb->expr()->gt('assignment.dateTo', $parameter) ), $qb->expr()->andX( $qb->expr()->isNull('assignment.dateTo'), $qb->expr()->gt('destination.dateTo', $parameter) ), ); } /** * Dispositions whose contract upload period has run out, for the admin dashboard. * * @param int $deadlineDays %contract_upload_deadline_days%, so this list marks a * disposition overdue on the same day the reminder mail goes out */ public function findOverdueContracts(int $deadlineDays): array { return $this->getOverdueContractsQuery($deadlineDays)->getResult(); } public function getOverdueContractsQuery(int $deadlineDays): Query { // anchored on midnight, like getContractReminderQuery()'s dayStart/dayEnd: a // now()-relative anchor would disagree with the reminder query for part of each day. // +1 day so a disposition is included from the same calendar day its reminder mail // goes out, matching that query's dayStart (== reminderDay), not the day after it. $reminderDay = (new \DateTimeImmutable('today'))->modify(sprintf('-%d days', $deadlineDays)); $dueDate = $reminderDay->modify('+1 day'); $qb = $this->createQueryBuilder('disposition'); return $qb ->select('disposition', 'assignment', 'job_profile', 'destination', 'document', 'teamer', 'user') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('assignment.destination', 'destination') // the join is restricted to the contract, so that an unrelated upload - a // driver's licence, say - does not hide a disposition from this list ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->innerJoin('disposition.teamer', 'teamer') ->innerJoin('teamer.user', 'user') ->where($qb->expr()->andX( $qb->expr()->lt('disposition.createdAt', ':dueDate'), $qb->expr()->eq('disposition.status', ':status'), $qb->expr()->isNull('document'), // kept in sync with getContractReminderQuery(), so the dashboard and the // reminder mail agree on which dispositions are overdue $qb->expr()->isNull('teamer.deletedAt'), $qb->expr()->notIn('assignment.status', ':assignmentStatus'), )) ->setParameter('documentType', Upload::TYPE_CONTRACT) ->setParameter('dueDate', $dueDate) ->setParameter('status', Disposition::STATUS_NEW) ->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->orderBy('disposition.createdAt', 'ASC') ->getQuery() ; } }