From c33c1b6bf06661e9f3f365fb9730e20d3b514e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 26 Aug 2026 16:15:23 +0200 Subject: [PATCH] feat: assignments with disabled formalities addresses #869dnvm8c --- config/email_texts.yaml | 18 +++ migrations/Version20260826153820.php | 30 ++++ src/Config/EmailTextKey.php | 1 + src/Entity/Assignment.php | 20 +++ src/Entity/Disposition.php | 27 +++- .../EmailNotificationSubscriber.php | 13 ++ src/Form/AssignmentType.php | 32 ++++ src/Repository/DispositionRepository.php | 138 +++++++++++++----- src/Security/Voter/DispositionVoter.php | 20 ++- .../Cron/DispositionReminderService.php | 11 +- src/Service/Cron/DispositionStatusService.php | 18 ++- .../administrative/assignment/_form.html.twig | 3 + templates/forms.html.twig | 1 + templates/teamer/disposition/detail.html.twig | 55 ++++--- tests/Entity/DispositionTest.php | 68 ++++++++- .../EmailNotificationSubscriberTest.php | 53 ++++++- tests/Form/AssignmentTypeTest.php | 22 +++ .../Repository/DispositionRepositoryTest.php | 55 +++++++ tests/Security/Voter/DispositionVoterTest.php | 103 +++++++++++++ .../Cron/DispositionStatusServiceTest.php | 99 +++++++++++++ 20 files changed, 720 insertions(+), 67 deletions(-) create mode 100644 migrations/Version20260826153820.php create mode 100644 tests/Security/Voter/DispositionVoterTest.php create mode 100644 tests/Service/Cron/DispositionStatusServiceTest.php diff --git a/config/email_texts.yaml b/config/email_texts.yaml index 52aad82..843376d 100644 --- a/config/email_texts.yaml +++ b/config/email_texts.yaml @@ -33,6 +33,24 @@ texts: Bitte wende dich bei Rückfragen an team@ep-reisen.de. + application_accepted_no_contract: + label: 'Bewerbung angenommen (ohne Vertrag)' + placeholders: ['destination', 'specialAgreements'] + subject: 'Dein Einsatz wurde angenommen' + headline: 'Herzlichen Glückwunsch!' + body: |- + **Dein Einsatz {destination} wurde angenommen!** + + Zusätzliche Absprachen: {specialAgreements} + + Damit ist für dich alles erledigt - für diesen Einsatz brauchen wir weder einen Honorarvertrag noch eine Honorarnote von dir. + + Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen. + + Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊 + + Bitte wende dich bei Rückfragen an team@ep-reisen.de. + application_rejected: label: 'Bewerbung abgelehnt' placeholders: ['destination'] diff --git a/migrations/Version20260826153820.php b/migrations/Version20260826153820.php new file mode 100644 index 0000000..42c3f74 --- /dev/null +++ b/migrations/Version20260826153820.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE assignment ADD skip_formalities TINYINT(1) NOT NULL DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE assignment DROP skip_formalities'); + } +} diff --git a/src/Config/EmailTextKey.php b/src/Config/EmailTextKey.php index 515fcc9..b876dcf 100644 --- a/src/Config/EmailTextKey.php +++ b/src/Config/EmailTextKey.php @@ -11,6 +11,7 @@ namespace App\Config; enum EmailTextKey: string { case APPLICATION_ACCEPTED = 'application_accepted'; + case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract'; case APPLICATION_REJECTED = 'application_rejected'; case ASSIGNMENT_CALLED_OFF = 'assignment_called_off'; case DISPOSITION_CALLED_OFF = 'disposition_called_off'; diff --git a/src/Entity/Assignment.php b/src/Entity/Assignment.php index 3fa672d..1872b84 100644 --- a/src/Entity/Assignment.php +++ b/src/Entity/Assignment.php @@ -57,6 +57,13 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa #[ORM\Column] private ?bool $showAvailableDispositions = false; + /** + * Ends the process for a placed teamer right after the disposition is created: no contract, + * no invoice, no feedback. See Disposition::isSkipFormalities() for the per-placement view. + */ + #[ORM\Column] + private bool $skipFormalities = false; + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] private ?\DateTimeImmutable $dateFrom = null; @@ -125,6 +132,7 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa ->setJobProfile($assignment->getJobProfile()) ->setAvailableDispositions($assignment->getAvailableDispositions()) ->setShowAvailableDispositions($assignment->isShowAvailableDispositions()) + ->setSkipFormalities($assignment->isSkipFormalities()) ->setDateFrom($assignment->getDateFrom()) ->setDateTo($assignment->getDateTo()) ->setPickupDate($assignment->getPickupDate()) @@ -246,6 +254,18 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa return $this; } + public function isSkipFormalities(): bool + { + return $this->skipFormalities; + } + + public function setSkipFormalities(bool $skipFormalities): static + { + $this->skipFormalities = $skipFormalities; + + return $this; + } + public function getDateFrom(): ?\DateTimeImmutable { return $this->dateFrom; diff --git a/src/Entity/Disposition.php b/src/Entity/Disposition.php index 981aae6..6f33a50 100644 --- a/src/Entity/Disposition.php +++ b/src/Entity/Disposition.php @@ -75,7 +75,11 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf public function __construct(Application $application) { $this->uuid = Uuid::v4(); - $this->status = static::STATUS_NEW; + // A skip-formalities assignment has no contract step to wait for, so the placement starts + // out where a signed and checked contract would otherwise have put it. + $this->status = true === $application->getAssignment()?->isSkipFormalities() + ? static::STATUS_CONFIRMED + : static::STATUS_NEW; $this->assignment = $application->getAssignment(); $this->teamer = $application->getTeamer(); $this->pickup = $application->getPickup(); @@ -218,12 +222,25 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf return $this; } + /** + * Whether the owning assignment switches off the contract, invoice and feedback process. + * Kept here so call sites do not have to repeat the null check on the assignment. + */ + public function isSkipFormalities(): bool + { + return true === $this->getAssignment()?->isSkipFormalities(); + } + /** * The period is passed in rather than hardcoded so that this, the reminder mail and the * admin overdue list all work from the same %contract_upload_deadline_days%. */ public function isContractDue(int $deadlineDays): bool { + if (true === $this->isSkipFormalities()) { + return false; + } + if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) { return false; } @@ -251,6 +268,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf public function isContractRejected(): bool { + if (true === $this->isSkipFormalities()) { + return false; + } + if (null === $contractDocument = $this->getDocumentByType(Upload::TYPE_CONTRACT)) { return false; } @@ -267,6 +288,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf */ public function isInvoiceDue(int $deadlineDays): bool { + if (true === $this->isSkipFormalities()) { + return false; + } + if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) { return false; } diff --git a/src/EventListener/EmailNotificationSubscriber.php b/src/EventListener/EmailNotificationSubscriber.php index 176f656..62b83e2 100644 --- a/src/EventListener/EmailNotificationSubscriber.php +++ b/src/EventListener/EmailNotificationSubscriber.php @@ -61,6 +61,19 @@ class EmailNotificationSubscriber implements EventSubscriberInterface $assignment = $disposition->getAssignment(); $destination = $assignment->getDestination(); + // Same notification, minus everything about the contract: the teamer still has to learn + // that they were placed, but on a skip-formalities assignment there is nothing to sign. + if (true === $disposition->isSkipFormalities()) { + $this->mailer->createAndSendText(EmailTextKey::APPLICATION_ACCEPTED_NO_CONTRACT, [ + 'destination' => (string) $destination, + 'specialAgreements' => $disposition->getSpecialAgreements(), + ], [ + 'to' => $teamer->getCommunication()->getEmail(), + ]); + + return; + } + $filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $destination); $contractPdf = $this->contractRenderer->render($disposition); diff --git a/src/Form/AssignmentType.php b/src/Form/AssignmentType.php index bb2a27c..4f7dcb3 100644 --- a/src/Form/AssignmentType.php +++ b/src/Form/AssignmentType.php @@ -11,6 +11,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; @@ -147,6 +148,7 @@ class AssignmentType extends AbstractType $data = $event->getData(); $this->addStatusField($data, $form); + $this->addSkipFormalitiesField($data, $form); $destination = $data->getDestination(); @@ -212,6 +214,36 @@ class AssignmentType extends AbstractType ]); } + /** + * Whether the skip-formalities flag is frozen for a given assignment. + * + * It decides which status a Disposition is created in, so flipping it once placements + * exist would leave them stranded halfway through a process that no longer applies - + * a teamer confirmed without a contract, or one still being chased for a contract on an + * assignment that no longer wants one. + * + * Enforced through the form's `disabled` option, which makes Symfony ignore whatever is + * submitted for the field, so this holds against a hand-crafted POST too. + */ + public static function isSkipFormalitiesLocked(?Assignment $assignment): bool + { + return 0 < ($assignment?->getDispositions()->count() ?? 0); + } + + private function addSkipFormalitiesField(?Assignment $assignment, FormInterface $form): void + { + $isLocked = self::isSkipFormalitiesLocked($assignment); + + $form->add('skipFormalities', CheckboxType::class, [ + 'label' => '"Partymodus": Ohne Vertrags-, Honorarnoten- und Feedbackprozess', + 'required' => false, + 'disabled' => $isLocked, + 'help' => $isLocked + ? 'Nicht mehr änderbar, sobald Teamer:innen eingeteilt sind.' + : 'Teamer:innen bewerben sich und werden eingeteilt; danach ist der Einsatz abgeschlossen.', + ]); + } + private function addPickupField(Destination $destination, FormInterface $form): void { $choices = []; diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index e5614e4..a07c323 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -9,6 +9,7 @@ use App\Entity\Upload; use App\Repository\Filter\SeasonPeriodFilter; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Expr\Comparison; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Orx; use Doctrine\ORM\QueryBuilder; @@ -217,7 +218,8 @@ class DispositionRepository extends ServiceEntityRepository ), $qb->expr()->lte('destination.dateTo', ':dateTo') ), - $qb->expr()->isNull('feedback') + $qb->expr()->isNull('feedback'), + $this->excludesSkipFormalities($qb) )) ->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ; @@ -306,6 +308,47 @@ class DispositionRepository extends ServiceEntityRepository ; } + /** + * The raw per-hotel feedback tally, before the missing/percentage columns are derived. + * + * Split out from getFeedbackStatisticsByHotel() so the rules carried in the DQL can be + * asserted without a database, the same way the reminder queries are. + */ + public function getFeedbackStatisticsByHotelQuery( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): Query { + $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()->andX( + $qb->expr()->in('disposition.status', ':statuses'), + // no feedback is expected here, so counting these would inflate missingFeedbacks + $this->excludesSkipFormalities($qb) + )) + ->setParameter('statuses', [ + Disposition::STATUS_ENDED, + Disposition::STATUS_PAID, + Disposition::STATUS_COMPLETED, + ]) + ->groupBy('destination.hotelCode', 'destination.hotel') + ->orderBy('destination.hotel', 'ASC') + ; + + SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo); + + return $qb->getQuery(); + } + /** * Returns feedback statistics per hotel for dispositions with status 'ended' or 'completed'. * @@ -322,32 +365,11 @@ class DispositionRepository extends ServiceEntityRepository ?\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') + $results = $this + ->getFeedbackStatisticsByHotelQuery($dateFrom, $dateTo) + ->getResult() ; - SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo); - - $results = $qb->getQuery()->getResult(); - // Calculate missing feedbacks and percentage return array_map(function (array $row): array { $total = (int) $row['totalDispositions']; @@ -367,20 +389,15 @@ class DispositionRepository extends ServiceEntityRepository } /** - * Returns feedback statistics grouped by normalized hotel code (base 3-char code). + * The raw tally grouped by normalized hotel code, before the derived columns. * - * @return array + * Kept in step with getFeedbackStatisticsByHotelQuery(): the two back different screens over + * the same numbers, so a rule added to one belongs in the other. */ - public function getFeedbackStatisticsByNormalizedHotelCode( + public function getFeedbackStatisticsByNormalizedHotelCodeQuery( ?\DateTimeImmutable $dateFrom = null, ?\DateTimeImmutable $dateTo = null, - ): array { + ): Query { $qb = $this->createQueryBuilder('disposition'); // Normalize hotel code: strip SER prefix to get base 3-char code @@ -395,7 +412,11 @@ class DispositionRepository extends ServiceEntityRepository ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->leftJoin('disposition.feedback', 'feedback') - ->where($qb->expr()->in('disposition.status', ':statuses')) + ->where($qb->expr()->andX( + $qb->expr()->in('disposition.status', ':statuses'), + // no feedback is expected here, so counting these would inflate missingFeedbacks + $this->excludesSkipFormalities($qb) + )) ->setParameter('statuses', [ Disposition::STATUS_ENDED, Disposition::STATUS_PAID, @@ -407,7 +428,28 @@ class DispositionRepository extends ServiceEntityRepository SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo); - $results = $qb->getQuery()->getResult(); + return $qb->getQuery(); + } + + /** + * Returns feedback statistics grouped by normalized hotel code (base 3-char code). + * + * @return array + */ + public function getFeedbackStatisticsByNormalizedHotelCode( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): array { + $results = $this + ->getFeedbackStatisticsByNormalizedHotelCodeQuery($dateFrom, $dateTo) + ->getResult() + ; return array_map(function (array $row): array { $total = (int) $row['totalDispositions']; @@ -522,6 +564,10 @@ class DispositionRepository extends ServiceEntityRepository $qb->expr()->isNull('teamer.deletedAt'), $qb->expr()->notIn('disposition.status', ':dispositionStatus'), $qb->expr()->notIn('assignment.status', ':assignmentStatus'), + // Cannot be left to disposition.status: this runs before DispositionStatusService + // in CronCommand, so a skip-formalities placement is still `confirmed` here and + // would slip past the status filter on the day after the assignment ends. + $this->excludesSkipFormalities($qb), $this->effectiveDateToEquals($qb, ':endDate'), )) ->setParameter('documentType', Upload::TYPE_INVOICE) @@ -558,6 +604,24 @@ class DispositionRepository extends ServiceEntityRepository ); } + /** + * Restricts a query to assignments that still run the contract, invoice and feedback process. + * + * Four queries carry this rule - the invoice reminder, the pending-feedback list and both + * feedback statistics - and each needs the parameter bound as well as the comparison added, + * so this does both. Kept together because the halves are useless apart: the comparison + * without the bind is an "Invalid parameter number" at execution time, and the bind without + * the comparison is a silently unfiltered result, which is the worse of the two. + * + * Requires the `assignment` alias to be joined by the caller. + */ + private function excludesSkipFormalities(QueryBuilder $qb): Comparison + { + $qb->setParameter('skipFormalities', false); + + return $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'); + } + /** * Same fallback as effectiveDateToEquals(), but for "later than $parameter". */ diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index d076dce..97f7deb 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -50,11 +50,17 @@ class DispositionVoter extends Voter $disposition = $subject; return match ($attribute) { - static::VIEW, static::EDIT, static::CONTRACT, static::INVOICE => $this->security->isGranted('ROLE_ADMINISTRATIVE') + static::VIEW, static::EDIT => $this->security->isGranted('ROLE_ADMINISTRATIVE') || $this->assertTeamerAccess($token, $disposition), + // The documents themselves do not exist on a skip-formalities assignment, so neither + // the blank PDFs nor the upload screens may be reachable for it. + static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities() + && ($this->security->isGranted('ROLE_ADMINISTRATIVE') + || $this->assertTeamerAccess($token, $disposition)), static::DELETE => $this->security->isGranted('ROLE_ADMIN'), - static::FEEDBACK => $this->security->isGranted('ROLE_ADMINISTRATIVE') - || $this->assertHouseManagerAccess($token, $disposition), + static::FEEDBACK => false === $disposition->isSkipFormalities() + && ($this->security->isGranted('ROLE_ADMINISTRATIVE') + || $this->assertHouseManagerAccess($token, $disposition)), static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition), static::ADMIN_DOCUMENT_UPLOAD => $this->assertAdminDocumentUploadAllowed($disposition), static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE') @@ -105,6 +111,10 @@ class DispositionVoter extends Voter return false; } + if (true === $disposition->isSkipFormalities()) { + return false; + } + if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) { return false; } @@ -121,6 +131,10 @@ class DispositionVoter extends Voter return false; } + if (true === $disposition->isSkipFormalities()) { + return false; + } + return Disposition::STATUS_CALLED_OFF !== $disposition->getStatus(); } } diff --git a/src/Service/Cron/DispositionReminderService.php b/src/Service/Cron/DispositionReminderService.php index 6f3368a..9d0159e 100644 --- a/src/Service/Cron/DispositionReminderService.php +++ b/src/Service/Cron/DispositionReminderService.php @@ -38,14 +38,21 @@ class DispositionReminderService ->select('disposition', 'assignment', 'destination') ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') - ->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') + ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->innerJoin('disposition.teamer', 'teamer') ->where($qb->expr()->andX( $qb->expr()->isNull('teamer.deletedAt'), $qb->expr()->notIn('assignment.status', ':statusAssignment'), $qb->expr()->eq('destination.dateFrom', ':dateFrom'), - $qb->expr()->eq('document.status', ':statusContract'), + // A checked contract is what normally marks a placement as settled enough to be + // reminded about. A skip-formalities assignment never produces one, so it has to + // qualify on the flag alone - otherwise its teamers get no trip reminder at all. + $qb->expr()->orX( + $qb->expr()->eq('document.status', ':statusContract'), + $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), + ), )) + ->setParameter('skipFormalities', true) ->setParameter('documentType', Upload::TYPE_CONTRACT) ->setParameter('statusAssignment', [Assignment::STATUS_DELETED, Assignment::STATUS_CALLED_OFF]) ->setParameter('dateFrom', $dateFrom->format('Y-m-d')) diff --git a/src/Service/Cron/DispositionStatusService.php b/src/Service/Cron/DispositionStatusService.php index e59bb4d..fdc6235 100644 --- a/src/Service/Cron/DispositionStatusService.php +++ b/src/Service/Cron/DispositionStatusService.php @@ -26,13 +26,29 @@ class DispositionStatusService return 'No ended dispositions to update'; } + $completed = 0; + foreach ($endedDispositions as $disposition) { + // A skip-formalities placement has no invoice step left to run through, so ending it + // means it is done - anything else would park it in `ended` forever and keep asking + // the teamer for a Honorarnote that is not wanted. + if (true === $disposition->isSkipFormalities()) { + $disposition->setStatus(Disposition::STATUS_COMPLETED); + ++$completed; + + continue; + } + $disposition->setStatus(Disposition::STATUS_ENDED); } $this->entityManager->flush(); - $message = 'Ended '.$count.' dispositions'; + $message = sprintf( + 'Ended %d dispositions (%d of them completed without formalities)', + $count, + $completed + ); $this->logger->info($message); diff --git a/templates/administrative/assignment/_form.html.twig b/templates/administrative/assignment/_form.html.twig index e5bc84d..4f94ab8 100644 --- a/templates/administrative/assignment/_form.html.twig +++ b/templates/administrative/assignment/_form.html.twig @@ -19,6 +19,9 @@ {{ form_row(form.status) }} {{ form_row(form.availableDispositions) }} +
+ {{ form_row(form.skipFormalities) }} +
{{ form_row(form.jobProfile) }} {{ form_row(form.jobProfileInfo) }} diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 7f54cd3..6a05e10 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -90,6 +90,7 @@ {{ form.vars.label }} {{- form_errors(form) -}} + {{- form_help(form) -}}
{% endblock %} diff --git a/templates/teamer/disposition/detail.html.twig b/templates/teamer/disposition/detail.html.twig index dec5754..793071b 100644 --- a/templates/teamer/disposition/detail.html.twig +++ b/templates/teamer/disposition/detail.html.twig @@ -5,7 +5,7 @@ {% block content %} {% set assignment = disposition.assignment %}

- {% if workflow_has_marked_place(disposition, 'ended') %} + {% if workflow_has_marked_place(disposition, 'ended') or workflow_has_marked_place(disposition, 'completed') %} Dein Einsatz ist abgeschlossen {% else %} Dein Einsatz wurde bestätigt! @@ -146,17 +146,27 @@ {% endif %} {% if workflow_has_marked_place(disposition, 'confirmed') %} -

- Danke für den Upload des Honorarvertrags -

- {% set contract = disposition.documentByType('contract') %} - {% if contract %} - + {% if assignment.skipFormalities %} +

+ Dein Einsatz ist bestätigt +

+

+ Es sind keine weiteren Schritte nötig - für diesen Einsatz brauchen wir + weder einen Honorarvertrag noch eine Honorarnote von dir. +

+ {% else %} +

+ Danke für den Upload des Honorarvertrags +

+ {% set contract = disposition.documentByType('contract') %} + {% if contract %} + + {% endif %} {% endif %} {% endif %} @@ -183,12 +193,21 @@ {# Invoice paid #} {% if workflow_has_marked_place(disposition, 'completed') %} -

- Danke für die Honorarnote -

-

- Wir werden sie schnellstmöglich bearbeiten. -

+ {% if assignment.skipFormalities %} +

+ Dein Einsatz ist abgeschlossen +

+

+ Danke, dass du dabei warst! +

+ {% else %} +

+ Danke für die Honorarnote +

+

+ Wir werden sie schnellstmöglich bearbeiten. +

+ {% endif %} {% endif %}
diff --git a/tests/Entity/DispositionTest.php b/tests/Entity/DispositionTest.php index 5ad05b4..faa7d66 100644 --- a/tests/Entity/DispositionTest.php +++ b/tests/Entity/DispositionTest.php @@ -92,17 +92,77 @@ class DispositionTest extends TestCase $this->assertTrue($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS)); } - private function createDisposition(string $createdAt, string $assignmentEndsIn): Disposition + /** + * A skip-formalities assignment has no contract step at all, so a placement on one starts + * where a signed and checked contract would otherwise have put it. + */ + public function testAPlacementOnASkipFormalitiesAssignmentIsConfirmedRightAway(): void { + $disposition = $this->createDisposition( + createdAt: 'today', + assignmentEndsIn: '+30 days', + skipFormalities: true, + ); + + $this->assertSame(Disposition::STATUS_CONFIRMED, $disposition->getStatus()); + $this->assertTrue($disposition->isSkipFormalities()); + } + + public function testAPlacementOnANormalAssignmentStillStartsAsNew(): void + { + $disposition = $this->createDisposition(createdAt: 'today', assignmentEndsIn: '+30 days'); + + $this->assertSame(Disposition::STATUS_NEW, $disposition->getStatus()); + $this->assertFalse($disposition->isSkipFormalities()); + } + + /** + * Both dates are chosen so the unflagged disposition would be due - what is pinned here is + * that the flag wins over the period maths, not that the period happens to be closed. + */ + public function testNoDocumentIsEverDueOnASkipFormalitiesAssignment(): void + { + $disposition = $this->createDisposition( + createdAt: 'today -60 days', + assignmentEndsIn: '-1 day', + skipFormalities: true, + ); + + $this->assertFalse($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS)); + $this->assertFalse($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS)); + } + + public function testARejectedContractIsNotReportedOnASkipFormalitiesAssignment(): void + { + $disposition = $this->createDisposition( + createdAt: 'today -60 days', + assignmentEndsIn: '+30 days', + skipFormalities: true, + ); + $disposition->addDocument( + (new Upload())->setType(Upload::TYPE_CONTRACT)->setStatus(Upload::STATUS_REJECTED) + ); + + $this->assertFalse($disposition->isContractRejected()); + } + + private function createDisposition( + string $createdAt, + string $assignmentEndsIn, + bool $skipFormalities = false, + ): Disposition { $destination = (new Destination()) ->setProduct('Skireise') ->setDateFrom(new \DateTimeImmutable($assignmentEndsIn.' -7 days')) ->setDateTo(new \DateTimeImmutable($assignmentEndsIn)) ; - $disposition = new Disposition( - new Application((new Assignment())->setDestination($destination), new Teamer()) - ); + $assignment = (new Assignment()) + ->setDestination($destination) + ->setSkipFormalities($skipFormalities) + ; + + $disposition = new Disposition(new Application($assignment, new Teamer())); return $disposition->setCreatedAt(new \DateTimeImmutable($createdAt)); } diff --git a/tests/EventListener/EmailNotificationSubscriberTest.php b/tests/EventListener/EmailNotificationSubscriberTest.php index 824e9b5..7679d10 100644 --- a/tests/EventListener/EmailNotificationSubscriberTest.php +++ b/tests/EventListener/EmailNotificationSubscriberTest.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace App\Tests\EventListener; +use App\Config\EmailTextKey; use App\Email\Mailer; use App\Email\MailPlaceholderFactory; use App\Entity\Application; use App\Entity\Assignment; +use App\Entity\Destination; use App\Entity\Disposition; use App\Entity\Embeddable\Communication; use App\Entity\Teamer; use App\Event\ApplicationStatusEvent; use App\Event\AssignmentCalledOffEvent; use App\Event\DispositionCalledOffEvent; +use App\Event\DispositionCreatedEvent; use App\EventListener\EmailNotificationSubscriber; use App\Model\ApplicationStatusDto; use App\Repository\UserRepository; @@ -28,16 +31,18 @@ use PHPUnit\Framework\TestCase; class EmailNotificationSubscriberTest extends TestCase { private Mailer&MockObject $mailer; + private ContractRenderer&MockObject $contractRenderer; private EmailNotificationSubscriber $subscriber; protected function setUp(): void { $this->mailer = $this->createMock(Mailer::class); + $this->contractRenderer = $this->createMock(ContractRenderer::class); $this->subscriber = new EmailNotificationSubscriber( $this->mailer, $this->createMock(UserRepository::class), - $this->createMock(ContractRenderer::class), + $this->contractRenderer, $this->createMock(MailPlaceholderFactory::class), 7, ); @@ -88,6 +93,52 @@ class EmailNotificationSubscriberTest extends TestCase $this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment)); } + /** + * The placement itself still has to be announced - what falls away is only the contract: + * no PDF is rendered, nothing is attached, and the wording comes from a separate text that + * does not ask for a signature or name an upload deadline. + */ + public function testASkipFormalitiesPlacementIsAnnouncedWithoutAContract(): void + { + $disposition = $this->createDisposition($this->createTeamer(), $this->createAssignment(true)); + + $this->contractRenderer->expects($this->never())->method('render'); + $this->mailer + ->expects($this->once()) + ->method('createAndSendText') + ->with( + EmailTextKey::APPLICATION_ACCEPTED_NO_CONTRACT, + $this->anything(), + $this->callback(fn (array $options) => false === isset($options['attachments'])), + ) + ; + + $this->subscriber->onDispositionCreated(new DispositionCreatedEvent($disposition)); + } + + public function testADeletedTeamerIsNotNotifiedAboutASkipFormalitiesPlacement(): void + { + $disposition = $this->createDisposition($this->createDeletedTeamer(), $this->createAssignment(true)); + + $this->mailer->expects($this->never())->method('createAndSendText'); + + $this->subscriber->onDispositionCreated(new DispositionCreatedEvent($disposition)); + } + + private function createAssignment(bool $skipFormalities): Assignment + { + $destination = (new Destination()) + ->setProduct('Skireise') + ->setDateFrom(new \DateTimeImmutable('+30 days')) + ->setDateTo(new \DateTimeImmutable('+37 days')) + ; + + return (new Assignment()) + ->setDestination($destination) + ->setSkipFormalities($skipFormalities) + ; + } + private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition { return new Disposition(new Application($assignment ?? new Assignment(), $teamer)); diff --git a/tests/Form/AssignmentTypeTest.php b/tests/Form/AssignmentTypeTest.php index 247c8d2..fcf083f 100644 --- a/tests/Form/AssignmentTypeTest.php +++ b/tests/Form/AssignmentTypeTest.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace App\Tests\Form; +use App\Entity\Application; use App\Entity\Assignment; +use App\Entity\Disposition; +use App\Entity\Teamer; use App\Form\AssignmentType; use PHPUnit\Framework\TestCase; @@ -63,6 +66,25 @@ class AssignmentTypeTest extends TestCase $this->assertNotContains(Assignment::STATUS_CALLED_OFF, array_values(AssignmentType::statusChoices(null))); } + public function testTheSkipFormalitiesFlagIsOpenOnANewAssignment(): void + { + $this->assertFalse(AssignmentType::isSkipFormalitiesLocked(null)); + $this->assertFalse(AssignmentType::isSkipFormalitiesLocked(new Assignment())); + } + + /** + * The flag decides which status a Disposition is created in, so once placements exist it + * can no longer be answered for them - a teamer confirmed without a contract, or one still + * being chased for a contract the assignment no longer wants. + */ + public function testTheSkipFormalitiesFlagIsFrozenOnceTeamersArePlaced(): void + { + $assignment = new Assignment(); + $assignment->addDisposition(new Disposition(new Application($assignment, new Teamer()))); + + $this->assertTrue(AssignmentType::isSkipFormalitiesLocked($assignment)); + } + /** * @return array */ diff --git a/tests/Repository/DispositionRepositoryTest.php b/tests/Repository/DispositionRepositoryTest.php index 672c7a9..e084931 100644 --- a/tests/Repository/DispositionRepositoryTest.php +++ b/tests/Repository/DispositionRepositoryTest.php @@ -194,6 +194,61 @@ class DispositionRepositoryTest extends KernelTestCase ); } + /** + * A skip-formalities placement is still `confirmed` when this query runs: CronCommand sends + * the invoice reminders before DispositionStatusService promotes it to `completed`, so the + * status filter alone would let it through on the day after the assignment ends. The flag + * has to be part of the DQL. + */ + public function testInvoiceReminderSkipsSkipFormalitiesAssignments(): void + { + $query = $this->invoiceQuery(); + + $this->assertStringContainsString( + 'assignment.skipFormalities = :skipFormalities', + $query->getDQL() + ); + $this->assertFalse($query->getParameter('skipFormalities')->getValue()); + } + + /** + * Feeds the house-manager list, both dashboards' "Überfällige Feedbacks" tiles and the + * reminder mail, so one condition here covers every surface that chases a feedback. + */ + public function testPendingFeedbackSkipsSkipFormalitiesAssignments(): void + { + $query = $this->repository()->getDispositionsWithPendingFeedbackQuery(); + + $this->assertStringContainsString( + 'assignment.skipFormalities = :skipFormalities', + $query->getDQL() + ); + $this->assertFalse($query->getParameter('skipFormalities')->getValue()); + } + + /** + * Otherwise placements nobody is expected to rate would be counted as missingFeedbacks and + * drag every hotel's percentage down. + */ + public function testFeedbackStatisticsDoNotCountSkipFormalitiesPlacementsAsMissing(): void + { + // the by-hotel and normalized-code variants back two different statistics screens and are + // maintained as a pair, so both are pinned - dropping the condition from one only would + // make the two disagree about the same hotel + $queries = [ + $this->repository()->getFeedbackStatisticsByHotelQuery(), + $this->repository()->getFeedbackStatisticsByNormalizedHotelCodeQuery(), + ]; + + foreach ($queries as $query) { + $this->assertStringContainsString( + 'assignment.skipFormalities = :skipFormalities', + $query->getDQL() + ); + $this->assertFalse($query->getParameter('skipFormalities')->getValue()); + } + } + private function contractQuery(int $deadlineDays = 5): Query { return $this->repository()->getContractReminderQuery($deadlineDays); diff --git a/tests/Security/Voter/DispositionVoterTest.php b/tests/Security/Voter/DispositionVoterTest.php new file mode 100644 index 0000000..920787a --- /dev/null +++ b/tests/Security/Voter/DispositionVoterTest.php @@ -0,0 +1,103 @@ +security = $this->createMock(Security::class); + } + + /** + * @dataProvider documentAndFeedbackAttributes + */ + public function testAnAdminIsDeniedOnASkipFormalitiesAssignment(string $attribute): void + { + $this->security->method('isGranted')->willReturn(true); + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->vote($attribute, $this->createDisposition(skipFormalities: true)), + ); + } + + /** + * The same admin on a normal assignment must still get through - the flag is the only thing + * that may take these away. + * + * @dataProvider documentAndFeedbackAttributes + */ + public function testTheSameAdminIsGrantedOnANormalAssignment(string $attribute): void + { + $this->security->method('isGranted')->willReturn(true); + + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $this->vote($attribute, $this->createDisposition(skipFormalities: false)), + ); + } + + /** + * @return array + */ + public static function documentAndFeedbackAttributes(): array + { + return [ + 'contract pdf and upload' => [DispositionVoter::CONTRACT], + 'invoice pdf and upload' => [DispositionVoter::INVOICE], + 'office upload on behalf of the teamer' => [DispositionVoter::CONTRACT_SUPPLEMENTARY], + 'office upload of either document' => [DispositionVoter::ADMIN_DOCUMENT_UPLOAD], + 'providing the feedback' => [DispositionVoter::FEEDBACK], + ]; + } + + /** + * Viewing the placement and calling it off are unrelated to the formalities and have to keep + * working, or an admin could no longer cancel a teamer on such an assignment. + */ + public function testViewingAndCallingOffAreUnaffected(): void + { + $this->security->method('isGranted')->willReturn(true); + $disposition = $this->createDisposition(skipFormalities: true); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->vote(DispositionVoter::VIEW, $disposition)); + $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->vote(DispositionVoter::CALL_OFF, $disposition)); + } + + private function vote(string $attribute, Disposition $disposition): int + { + return (new DispositionVoter($this->security))->vote( + $this->createMock(TokenInterface::class), + $disposition, + [$attribute], + ); + } + + private function createDisposition(bool $skipFormalities): Disposition + { + $assignment = (new Assignment())->setSkipFormalities($skipFormalities); + + return new Disposition(new Application($assignment, new Teamer())); + } +} diff --git a/tests/Service/Cron/DispositionStatusServiceTest.php b/tests/Service/Cron/DispositionStatusServiceTest.php new file mode 100644 index 0000000..f777097 --- /dev/null +++ b/tests/Service/Cron/DispositionStatusServiceTest.php @@ -0,0 +1,99 @@ +repository = $this->createMock(DispositionRepository::class); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->method('getRepository') + ->willReturn($this->repository) + ; + + $this->service = new DispositionStatusService($entityManager, new NullLogger()); + } + + public function testANormalPlacementEnds(): void + { + $disposition = $this->createDisposition(skipFormalities: false); + + $this->givenEndedDispositions($disposition); + $this->service->setEndedStatus(); + + $this->assertSame(Disposition::STATUS_ENDED, $disposition->getStatus()); + } + + public function testASkipFormalitiesPlacementIsCompletedInsteadOfEnded(): void + { + $disposition = $this->createDisposition(skipFormalities: true); + + $this->givenEndedDispositions($disposition); + $this->service->setEndedStatus(); + + $this->assertSame(Disposition::STATUS_COMPLETED, $disposition->getStatus()); + } + + /** + * Both kinds come out of the same query, so the split has to happen per disposition. + */ + public function testAMixedBatchIsSplitPerDisposition(): void + { + $normal = $this->createDisposition(skipFormalities: false); + $skipped = $this->createDisposition(skipFormalities: true); + + $this->givenEndedDispositions($normal, $skipped); + $this->service->setEndedStatus(); + + $this->assertSame(Disposition::STATUS_ENDED, $normal->getStatus()); + $this->assertSame(Disposition::STATUS_COMPLETED, $skipped->getStatus()); + } + + public function testNothingIsFlushedWhenThereIsNothingToEnd(): void + { + $this->givenEndedDispositions(); + + $this->assertSame('No ended dispositions to update', $this->service->setEndedStatus()); + } + + private function givenEndedDispositions(Disposition ...$dispositions): void + { + $this->repository + ->method('findEndedDispositions') + ->willReturn($dispositions) + ; + } + + private function createDisposition(bool $skipFormalities): Disposition + { + $assignment = (new Assignment())->setSkipFormalities($skipFormalities); + + return new Disposition(new Application($assignment, new Teamer())); + } +}