fix: offer the questionnaire after every placement, the bus part to escorts

This commit is contained in:
fromme committed 2026-10-27 17:12:24 +01:00
1 parent 9fedc5b5d3
commit 2f4f9800dd
30 files changed
+311 -77

No files matched your search

@@ -1,21 +1,24 @@
# Questionnaire a teamer fills in after an assignment with bus escort. # Questionnaire a teamer fills in after a placement (Disposition, not SeasonAssignment).
# #
# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question # Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question
# spec, with every decision taken, is in .ddev/plans/questionnaires/. Questions whose answer the # spec, with every decision taken, is in .ddev/plans/questionnaires/. Every teamer gets the
# disposition already knows (name, job profile, house, dates, destination, escort pickup) are not # sections up to "Sonstiges"; the sections marked `only_for: bus_escort` are added when the
# asked: they are snapshotted into the response's context instead. # assignment had a bus escort. Questions whose answer the disposition already knows (name, job
# profile, house, dates, destination, escort pickup) are not asked: they are snapshotted into the
# response's context instead.
# #
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it. # Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
# #
# Labels, help texts, section titles and option labels may be edited in place. Renaming or # Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to # removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_escort.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. # teamer_assignment.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
# #
# Section fields: key, title, only_for (a App\Enum\QuestionnaireFlag value), questions.
# Question fields: key, label, type (text | textarea | choice | multi | date | rating), # Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio), # required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only). # options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_escort key: teamer_assignment
version: 1 version: 1
title: "Reiseleiter/-in Feedback" title: "Reiseleiter/-in Feedback"
description: |- description: |-
@@ -135,6 +138,7 @@ sections:
- key: bus_trip - key: bus_trip
title: "Busfahrt" title: "Busfahrt"
only_for: bus_escort
questions: questions:
- key: direction - key: direction
label: "Hin- oder Rückfahrt" label: "Hin- oder Rückfahrt"
@@ -163,6 +167,7 @@ sections:
- key: bus_team - key: bus_team
title: "Busteam" title: "Busteam"
only_for: bus_escort
questions: questions:
- key: driver_names - key: driver_names
label: "Busfahrer-Namen:" label: "Busfahrer-Namen:"
@@ -171,6 +176,7 @@ sections:
- key: bus_first_pickup - key: bus_first_pickup
title: "1. Abfahrtsort" title: "1. Abfahrtsort"
only_for: bus_escort
questions: questions:
- key: on_time_at_first_pickup - key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?" label: "Bus pünktlich am 1. Abfahrtsort?"
@@ -188,6 +194,7 @@ sections:
- key: bus_rating - key: bus_rating
title: "Bewertung Bus & Fahrer" title: "Bewertung Bus & Fahrer"
only_for: bus_escort
questions: questions:
- key: overall_impression - key: overall_impression
label: "Gesamteindruck des Busses" label: "Gesamteindruck des Busses"
@@ -268,6 +275,7 @@ sections:
- key: bus_pickups - key: bus_pickups
title: "Ladestellen" title: "Ladestellen"
only_for: bus_escort
questions: questions:
- key: drivers_knew_pickups - key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?" label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
@@ -285,6 +293,7 @@ sections:
- key: bus_destination - key: bus_destination
title: "Zielkenntnis" title: "Zielkenntnis"
only_for: bus_escort
questions: questions:
- key: drivers_knew_destination - key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?" label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
@@ -296,6 +305,7 @@ sections:
- key: bus_equipment - key: bus_equipment
title: "Bus, Technik & Allgemeines" title: "Bus, Technik & Allgemeines"
only_for: bus_escort
questions: questions:
- key: video - key: video
label: "Video/DVD-Gerät" label: "Video/DVD-Gerät"
@@ -345,6 +355,7 @@ sections:
- key: bus_damage - key: bus_damage
title: "Schäden" title: "Schäden"
only_for: bus_escort
questions: questions:
- key: bus_damage - key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?" label: "Entstanden während der Fahrt Schäden am Bus?"
@@ -363,6 +374,7 @@ sections:
- key: bus_remarks - key: bus_remarks
title: "Anmerkungen zur Busfahrt" title: "Anmerkungen zur Busfahrt"
only_for: bus_escort
questions: questions:
- key: bus_remarks - key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:" label: "Das ging gar nicht / ist besonders hervorzuheben:"
+3 -3
View File
@@ -90,9 +90,9 @@ parameters:
season_start: '11-01' season_start: '11-01'
season_end: '04-01' season_end: '04-01'
# The teamer questionnaire after a bus escort (config/questionnaires/teamer_escort.yaml) is # The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is
# offered for assignments ending on or after this day (Y-m-d). Set it to the deployment day: # offered for placements ending on or after this day (Y-m-d). Set it to the deployment day:
# without a cutoff every past escort would show up as pending on the teamers' dashboards. # without a cutoff every past placement would show up as pending on the teamers' dashboards.
# null offers it to nobody. # null offers it to nobody.
questionnaire_go_live: ~ questionnaire_go_live: ~
+1 -1
View File
@@ -9,7 +9,7 @@ use Doctrine\Migrations\AbstractMigration;
/** /**
* Adds questionnaire_response: a respondent's answers to one questionnaire from * Adds questionnaire_response: a respondent's answers to one questionnaire from
* config/questionnaires/, for now the teamer questionnaire after a bus-escort disposition. * config/questionnaires/, for now the teamer questionnaire after a placement.
* *
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the * Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database. * pre-existing drift of the dev database.
+31 -5
View File
@@ -4,6 +4,7 @@ namespace App\Config;
use App\Enum\ChoiceWidget; use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator; use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType; use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition; use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition; use App\Model\Questionnaire\Definition;
@@ -26,12 +27,12 @@ use Symfony\Component\Yaml\Yaml;
class QuestionnaireCatalog class QuestionnaireCatalog
{ {
/** /**
* The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE. * The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
*/ */
public const string TEAMER_ESCORT = 'teamer_escort'; public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'questions']; private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label']; private const array OPTION_FIELDS = ['key', 'label'];
@@ -53,7 +54,7 @@ class QuestionnaireCatalog
/** /**
* The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is * The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is
* offered for assignments ending on or after it, and to nobody while it is null. * offered for placements ending on or after it, and to nobody while it is null.
*/ */
public static function parseGoLive(?string $goLive): ?\DateTimeImmutable public static function parseGoLive(?string $goLive): ?\DateTimeImmutable
{ {
@@ -129,6 +130,8 @@ class QuestionnaireCatalog
$sectionKeys = []; $sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */ /** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = []; $questions = [];
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
$questionFlags = [];
foreach ($data['sections'] as $index => $sectionData) { foreach ($data['sections'] as $index => $sectionData) {
$context = sprintf('%s: section #%d', $file, $index + 1); $context = sprintf('%s: section #%d', $file, $index + 1);
@@ -146,15 +149,38 @@ class QuestionnaireCatalog
throw $this->error($context, 'questions must be a non-empty list'); throw $this->error($context, 'questions must be a non-empty list');
} }
$onlyFor = null;
if (array_key_exists('only_for', $sectionData)) {
$onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for'])
?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for']));
}
$sectionQuestions = []; $sectionQuestions = [];
foreach ($sectionData['questions'] as $questionData) { foreach ($sectionData['questions'] as $questionData) {
$question = $this->buildQuestion($questionData, $questions, $context); $question = $this->buildQuestion($questionData, $questions, $context);
// A question that may be asked without the flag cannot depend on one that is only
// asked with it: it would stay hidden for everyone without the flag.
if (null !== $question->showIf
&& null !== ($controllingFlag = $questionFlags[$question->showIf->question])
&& $controllingFlag !== $onlyFor
) {
throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value));
}
$questions[$question->key] = $question; $questions[$question->key] = $question;
$questionFlags[$question->key] = $onlyFor;
$sectionQuestions[] = $question; $sectionQuestions[] = $question;
} }
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions); $sections[] = new Section(
$key,
$this->optionalString($sectionData['title'] ?? null, 'title', $context),
$sectionQuestions,
$onlyFor,
);
} }
return new Definition( return new Definition(
@@ -33,7 +33,7 @@ class IndexController extends AbstractController
$filterDto = $this->filterHandler->getFilterSettings(); $filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate( $pagination = $this->paginator->paginate(
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filterDto), $this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
$request->query->getInt('limit', 50), $request->query->getInt('limit', 50),
[ [
@@ -43,7 +43,7 @@ class IndexController extends AbstractController
); );
return $this->render('administrative/questionnaire/index.html.twig', [ return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT), 'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
'pagination' => $pagination, 'pagination' => $pagination,
'filterDto' => $filterDto, 'filterDto' => $filterDto,
]); ]);
@@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog; use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait; use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment; use App\Entity\Assignment;
use App\Enum\QuestionnaireFlag;
use App\Form\QuestionnaireType; use App\Form\QuestionnaireType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -14,9 +15,10 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without * The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after
* bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the * the trip, placed or not. It shows the assignment's own variant, with or without the bus
* answers as they would be stored, "Entwurf speichern" just keeps them in the form. * sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers
* as they would be stored, "Entwurf speichern" just keeps them in the form.
*/ */
class TestController extends AbstractController class TestController extends AbstractController
{ {
@@ -34,7 +36,13 @@ class TestController extends AbstractController
Assignment $assignment, Assignment $assignment,
Request $request, Request $request,
): Response { ): Response {
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); $busEscort = $request->query->has('bus_escort')
? $request->query->getBoolean('bus_escort')
: $assignment->hasBusEscort();
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : [])
;
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]); $form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -56,6 +64,7 @@ class TestController extends AbstractController
'form' => $form, 'form' => $form,
'submitted' => $submitted, 'submitted' => $submitted,
'draft' => $draft, 'draft' => $draft,
'busEscort' => $busEscort,
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]), 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]),
]); ]);
} }
@@ -30,7 +30,7 @@ class DetailController extends AbstractController
#[MapEntity(mapping: ['uuid' => 'uuid'])] #[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition, Disposition $disposition,
): Response { ): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true !== $response?->isSubmitted()) { if (true !== $response?->isSubmitted()) {
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
@@ -18,8 +18,9 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* The teamer's questionnaire after a bus escort, saved as a draft until it is submitted. A * The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
* submitted one is final and only shown read-only (DetailController). * saved as a draft until it is submitted. A submitted one is final and only shown read-only
* (DetailController).
*/ */
class EditController extends AbstractController class EditController extends AbstractController
{ {
@@ -39,13 +40,16 @@ class EditController extends AbstractController
Disposition $disposition, Disposition $disposition,
Request $request, Request $request,
): Response { ): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true === $response?->isSubmitted()) { if (true === $response?->isSubmitted()) {
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
} }
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); $definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags(DispositionContext::flags($disposition->getAssignment()))
;
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [ $form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
'definition' => $definition, 'definition' => $definition,
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enum;
/**
* Something a questionnaire's subject may or may not have, deciding whether a section marked
* `only_for` it is part of the questionnaire at all. Unlike show_if, it is not answered by the
* respondent but known beforehand - for a disposition, by DispositionContext::flags().
*/
enum QuestionnaireFlag: string
{
case BUS_ESCORT = 'bus_escort';
}
+2 -2
View File
@@ -112,10 +112,10 @@ class AdminMenuBuilder extends AbstractMenuBuilder
$menu->addChild('Fragebögen', [ $menu->addChild('Fragebögen', [
'route' => 'app_administrative_questionnaire_index', 'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [ 'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen', 'title' => 'Fragebögen nach Einsätzen',
], ],
'extras' => [ 'extras' => [
'icon' => 'bus', 'icon' => 'document',
'routes' => [ 'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'], ['pattern' => '/^app_administrative_questionnaire_/'],
], ],
+2 -2
View File
@@ -121,10 +121,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
$menu->addChild('Fragebögen', [ $menu->addChild('Fragebögen', [
'route' => 'app_administrative_questionnaire_index', 'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [ 'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen', 'title' => 'Fragebögen nach Einsätzen',
], ],
'extras' => [ 'extras' => [
'icon' => 'bus', 'icon' => 'document',
'routes' => [ 'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'], ['pattern' => '/^app_administrative_questionnaire_/'],
], ],
+22
View File
@@ -2,6 +2,8 @@
namespace App\Model\Questionnaire; namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
/** /**
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and * One version of a questionnaire as read from config/questionnaires/. Responses store its key and
* version, and their answers are keyed by Question::$key. * version, and their answers are keyed by Question::$key.
@@ -20,6 +22,26 @@ readonly class Definition
) { ) {
} }
/**
* The questionnaire as one subject gets it: without the sections marked `only_for` a flag the
* subject does not have. Key and version stay, so answers to it are answers to this definition.
*
* @param list<QuestionnaireFlag> $flags
*/
public function forFlags(array $flags): static
{
return new static(
$this->key,
$this->version,
$this->title,
$this->description,
array_values(array_filter(
$this->sections,
static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true),
)),
);
}
/** /**
* @return array<string, Question> in the order they are asked, keyed by question key * @return array<string, Question> in the order they are asked, keyed by question key
*/ */
+5 -1
View File
@@ -2,15 +2,19 @@
namespace App\Model\Questionnaire; namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
readonly class Section readonly class Section
{ {
/** /**
* @param list<Question> $questions * @param list<Question> $questions
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
*/ */
public function __construct( public function __construct(
public string $key, public string $key,
public ?string $title, public ?string $title,
public array $questions, public array $questions,
public ?QuestionnaireFlag $onlyFor = null,
) { ) {
} }
} }
+4 -6
View File
@@ -77,10 +77,10 @@ class DispositionRepository extends ServiceEntityRepository
} }
/** /**
* The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is * The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is
* still to be submitted: not started, or saved as a draft. The rules are those of * still to be submitted: not started, or saved as a draft. The rules are those of
* DispositionVoter::QUESTIONNAIRE - bus escort, over (today included), on or after go-live, * DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
* neither called off nor skip-formalities - so every row shown on the dashboard can be opened. * off nor skip-formalities - so every row shown on the dashboard can be opened.
* *
* The response is joined as an arbitrary entity because the association is one-way; the * The response is joined as an arbitrary entity because the association is one-way; the
* questionnaire key has to be part of the JOIN condition, or a response to another * questionnaire key has to be part of the JOIN condition, or a response to another
@@ -107,8 +107,6 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'), $qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'), $qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
$qb->expr()->isNotNull('assignment.pickup'),
$qb->expr()->neq('assignment.pickup', 0),
$qb->expr()->gte('destination.dateTo', ':goLive'), $qb->expr()->gte('destination.dateTo', ':goLive'),
$qb->expr()->lte('destination.dateTo', ':today'), $qb->expr()->lte('destination.dateTo', ':today'),
$qb->expr()->orX( $qb->expr()->orX(
@@ -118,7 +116,7 @@ class DispositionRepository extends ServiceEntityRepository
)) ))
->orderBy('destination.dateTo', \SortDirection::Descending) ->orderBy('destination.dateTo', \SortDirection::Descending)
->setParameter('teamer', $teamer) ->setParameter('teamer', $teamer)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT) ->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false) ->setParameter('skipFormalities', false)
+6 -5
View File
@@ -118,8 +118,10 @@ class DispositionVoter extends Voter
/** /**
* Whether the teamer may fill in, and later read, the questionnaire after this placement * Whether the teamer may fill in, and later read, the questionnaire after this placement
* (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the * (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
* submitted responses through ROLE_ADMINISTRATIVE, not through this attribute. * escort gets the bus sections as well, which is the form's business, not this voter's. Only
* the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE,
* not through this attribute.
* *
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a * "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
* run late, and it moves on to checking_invoice and completed while the date stays true. An * run late, and it moves on to checking_invoice and completed while the date stays true. An
@@ -137,12 +139,11 @@ class DispositionVoter extends Voter
} }
if (true === $disposition->isSkipFormalities() if (true === $disposition->isSkipFormalities()
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus() || Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|| true !== $disposition->getAssignment()?->hasBusEscort()) {
return false; return false;
} }
$dateTo = $disposition->getAssignment()->getDestination()?->getDateTo(); $dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
return null !== $dateTo return null !== $dateTo
&& $dateTo >= $this->questionnaireGoLive && $dateTo >= $this->questionnaireGoLive
@@ -2,7 +2,9 @@
namespace App\Service\Questionnaire; namespace App\Service\Questionnaire;
use App\Entity\Assignment;
use App\Entity\Disposition; use App\Entity\Disposition;
use App\Enum\QuestionnaireFlag;
/** /**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire * What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
@@ -13,6 +15,17 @@ use App\Entity\Disposition;
*/ */
final class DispositionContext final class DispositionContext
{ {
/**
* Which `only_for` sections of the questionnaire apply to a placement on this assignment.
* Takes the assignment so the office's test mode can ask before anyone is placed.
*
* @return list<QuestionnaireFlag>
*/
public static function flags(Assignment $assignment): array
{
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
}
/** /**
* @return array{ * @return array{
* teamerName: ?string, * teamerName: ?string,
@@ -262,7 +262,7 @@
</a> </a>
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': return_url() }) }}" <a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
class="btn btn--secondary" class="btn btn--secondary"
title="Den Fragebogen nach einer Busbegleitung für diesen Einsatz ausprobieren, ohne etwas zu speichern"> title="Den Fragebogen für diesen Einsatz ausprobieren, ohne etwas zu speichern">
Fragebogen testen Fragebogen testen
</a> </a>
</div> </div>
@@ -10,12 +10,20 @@
'type': 'warning', 'type': 'warning',
'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.' 'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.'
} %} } %}
{% if not assignment.hasBusEscort %} {% set otherVariant = path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'bus_escort': busEscort ? 0 : 1, 'r': app.request.query.get('r') }) %}
{% include '_partials/_alert.html.twig' with { {% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
'type': 'info', {% block message %}
'message': 'Dieser Einsatz hat keine Busbegleitung. Teamer:innen bekommen den Fragebogen hier nicht.' {% if busEscort %}
} %} Mit den Fragen zur Busfahrt, wie bei einer Busbegleitung.
{% endif %} {% else %}
Ohne die Fragen zur Busfahrt, wie bei einem Einsatz ohne Busbegleitung.
{% endif %}
{% if busEscort != assignment.hasBusEscort %}
Dieser Einsatz hat {{ assignment.hasBusEscort ? 'eine' : 'keine' }} Busbegleitung.
{% endif %}
<a href="{{ otherVariant }}" class="underline">{{ busEscort ? 'Ohne' : 'Mit' }} Busfahrt testen</a>
{% endblock %}
{% endembed %}
<div class="grid lg:grid-cols-3 gap-8 items-start"> <div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2"> <div class="lg:col-span-2">
{% if submitted is not null %} {% if submitted is not null %}
@@ -219,10 +219,10 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{# Questionnaire after a bus escort; the edit page forwards to the read-only view once submitted #} {# Questionnaire after the placement; the edit page forwards to the read-only view once submitted #}
{% if is_granted('QUESTIONNAIRE', disposition) %} {% if is_granted('QUESTIONNAIRE', disposition) %}
<h3 class="text-xl font-bold pb-4"> <h3 class="text-xl font-bold pb-4">
Fragebogen zur Busbegleitung Fragebogen zu deinem Einsatz
</h3> </h3>
<p class="pb-4"> <p class="pb-4">
Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden. Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden.
+1 -1
View File
@@ -18,7 +18,7 @@
{% if pendingQuestionnaires is not empty %} {% if pendingQuestionnaires is not empty %}
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %} {% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
{% block message %} {% block message %}
<p>Bitte fülle den Fragebogen zu deiner Busbegleitung aus:</p> <p>Bitte fülle den Fragebogen zu deinem Einsatz aus:</p>
<ul class="pt-1"> <ul class="pt-1">
{% for row in pendingQuestionnaires %} {% for row in pendingQuestionnaires %}
{% set destination = row.disposition.assignment.destination %} {% set destination = row.disposition.assignment.destination %}
+65 -6
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Config;
use App\Config\QuestionnaireCatalog; use App\Config\QuestionnaireCatalog;
use App\Enum\ChoiceWidget; use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator; use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType; use App\Enum\QuestionType;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -50,18 +51,18 @@ class QuestionnaireCatalogTest extends TestCase
* What the disposition already knows was decided not to be asked; it is snapshotted into the * What the disposition already knows was decided not to be asked; it is snapshotted into the
* response instead. These are the Google forms' keys for those questions. * response instead. These are the Google forms' keys for those questions.
*/ */
public function testTeamerEscortDoesNotAskWhatTheDispositionKnows(): void public function testTeamerAssignmentDoesNotAskWhatTheDispositionKnows(): void
{ {
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort'); $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
foreach (['name', 'job_profile', 'house', 'assignment_start', 'travel_date', 'destination', 'guide_name', 'first_pickup_location'] as $key) { foreach (['name', 'job_profile', 'house', 'assignment_start', 'travel_date', 'destination', 'guide_name', 'first_pickup_location'] as $key) {
$this->assertNull($definition->getQuestion($key), $key); $this->assertNull($definition->getQuestion($key), $key);
} }
} }
public function testTeamerEscortLowRatingReasonIsRequiredOnlyForLowRatings(): void public function testTeamerAssignmentLowRatingReasonIsRequiredOnlyForLowRatings(): void
{ {
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort')->getQuestion('office_briefing_low_reason'); $question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment')->getQuestion('office_briefing_low_reason');
$this->assertTrue($question->required); $this->assertTrue($question->required);
$this->assertSame('office_briefing_rating', $question->showIf->question); $this->assertSame('office_briefing_rating', $question->showIf->question);
@@ -73,9 +74,9 @@ class QuestionnaireCatalogTest extends TestCase
* Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig" * Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig"
* together; it was split into one single choice per topic. * together; it was split into one single choice per topic.
*/ */
public function testTeamerEscortEquipmentIsOneSingleChoicePerTopic(): void public function testTeamerAssignmentEquipmentIsOneSingleChoicePerTopic(): void
{ {
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort'); $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) { foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
$question = $definition->getQuestion($key); $question = $definition->getQuestion($key);
@@ -84,6 +85,28 @@ class QuestionnaireCatalogTest extends TestCase
} }
} }
/**
* Every teamer gets the questionnaire; only a bus escort gets the bus part, and nobody gets a
* bus section without it.
*/
public function testTeamerAssignmentBusPartIsOnlyForBusEscorts(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
foreach ($definition->sections as $section) {
$this->assertSame(
str_starts_with($section->key, 'bus_') ? QuestionnaireFlag::BUS_ESCORT : null,
$section->onlyFor,
$section->key,
);
}
$withoutBus = $definition->forFlags([]);
$this->assertNull($withoutBus->getQuestion('bus_company'));
$this->assertNotNull($withoutBus->getQuestion('office_briefing_rating'));
$this->assertNotNull($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestion('bus_company'));
}
public function testChoiceDefaultsToRadio(): void public function testChoiceDefaultsToRadio(): void
{ {
$this->write('sample', $this->sample()); $this->write('sample', $this->sample());
@@ -181,12 +204,41 @@ class QuestionnaireCatalogTest extends TestCase
static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]), static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]),
'lte needs a rating from 1 to 5', 'lte needs a rating from 1 to 5',
]; ];
yield 'unknown only_for' => [
static fn (array $d): array => self::withSection($d, ['only_for' => 'ferry']),
'unknown only_for "ferry"',
];
yield 'key does not match the file name' => [ yield 'key does not match the file name' => [
static fn (array $d): array => ['key' => 'other'] + $d, static fn (array $d): array => ['key' => 'other'] + $d,
'does not match the file name', 'does not match the file name',
]; ];
} }
/**
* A question everyone gets cannot depend on a bus-only question: without the bus part it
* would never be shown.
*/
public function testQuestionCannotDependOnAQuestionOnlyAskedWithAFlag(): void
{
$definition = $this->sample();
$definition['sections'][0]['only_for'] = 'bus_escort';
$definition['sections'][] = [
'key' => 'later',
'questions' => [[
'key' => 'other_reason',
'label' => 'Warum?',
'type' => 'text',
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
]],
];
$this->write('sample', $definition);
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('"answer" is only asked for bus_escort');
new QuestionnaireCatalog($this->dir)->get('sample');
}
public function testArchivedFileWithTheWrongVersionIsRejected(): void public function testArchivedFileWithTheWrongVersionIsRejected(): void
{ {
$this->write('sample', ['version' => 3] + $this->sample()); $this->write('sample', ['version' => 3] + $this->sample());
@@ -238,6 +290,13 @@ class QuestionnaireCatalogTest extends TestCase
return $definition; return $definition;
} }
private static function withSection(array $definition, array $fields): array
{
$definition['sections'][0] = $fields + $definition['sections'][0];
return $definition;
}
private function write(string $name, array $definition): void private function write(string $name, array $definition): void
{ {
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10)); file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
@@ -71,7 +71,9 @@ class TestControllerTest extends WebTestCase
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$this->assertStringContainsString('Testmodus', $crawler->text()); $this->assertStringContainsString('Testmodus', $crawler->text());
$this->assertStringContainsString('keine Busbegleitung', $crawler->text()); // the assignment has no escort, so the questionnaire comes without the bus part
$this->assertStringContainsString('Ohne die Fragen zur Busfahrt', $crawler->text());
$this->assertCount(0, $crawler->filter('[data-question-key="bus_company"]'));
$this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]')); $this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]'));
} }
@@ -102,7 +104,7 @@ class TestControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN')); $this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$form = $this->open()->selectButton('Absenden')->form(); $form = $this->open()->selectButton('Absenden')->form();
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ESCORT); $definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)->forFlags([]);
foreach ($definition->sections as $section) { foreach ($definition->sections as $section) {
foreach ($section->questions as $question) { foreach ($section->questions as $question) {
@@ -121,10 +123,19 @@ class TestControllerTest extends WebTestCase
$crawler = $this->client->submit($form); $crawler = $this->client->submit($form);
$this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text()); $this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text());
$this->assertStringContainsString('Boonk Reisen', $crawler->text()); $this->assertStringContainsString('Sehr gut – Probleme wurden schnell', $crawler->text());
$this->assertNothingSaved(); $this->assertNothingSaved();
} }
public function testTheBusPartCanBeTriedOnAnAssignmentWithoutEscort(): void
{
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$crawler = $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s?bus_escort=1', $this->assignment->getUuid()));
$this->assertStringContainsString('Mit den Fragen zur Busfahrt', $crawler->text());
$this->assertCount(1, $crawler->filter('[data-question-key="bus_company"]'));
}
/** /**
* The app answers a teamer in the office area with a redirect to their dashboard, not a 403. * The app answers a teamer in the office area with a redirect to their dashboard, not a 403.
*/ */
+27 -11
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView; use Symfony\Component\Form\FormView;
/** /**
* Runs against the shipped teamer_escort definition, so a definition that parses but cannot be * Runs against the shipped teamer_assignment definition, so a definition that parses but cannot be
* turned into a working form fails here. * turned into a working form fails here.
* *
* Every submit goes through Form::submit() with clearMissing, as a browser request does: an * Every submit goes through Form::submit() with clearMissing, as a browser request does: an
@@ -28,7 +28,7 @@ class QuestionnaireTypeTest extends KernelTestCase
{ {
public function testEmptyDraftIsValid(): void public function testEmptyDraftIsValid(): void
{ {
$form = $this->createForm($this->teamerEscort()); $form = $this->createForm($this->teamerAssignment());
$form->submit([QuestionnaireType::DRAFT => '']); $form->submit([QuestionnaireType::DRAFT => '']);
$this->assertTrue($form->isValid()); $this->assertTrue($form->isValid());
@@ -40,7 +40,7 @@ class QuestionnaireTypeTest extends KernelTestCase
*/ */
public function testFormSentWithoutAButtonIsADraft(): void public function testFormSentWithoutAButtonIsADraft(): void
{ {
$form = $this->createForm($this->teamerEscort()); $form = $this->createForm($this->teamerAssignment());
$form->submit([]); $form->submit([]);
$this->assertTrue($form->isValid()); $this->assertTrue($form->isValid());
@@ -49,7 +49,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testCompleteSubmitIsValidAndFlat(): void public function testCompleteSubmitIsValidAndFlat(): void
{ {
$definition = $this->teamerEscort(); $definition = $this->teamerAssignment();
$form = $this->createForm($definition); $form = $this->createForm($definition);
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']); $form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
@@ -65,7 +65,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void
{ {
$definition = $this->teamerEscort(); $definition = $this->teamerAssignment();
$form = $this->createForm($definition); $form = $this->createForm($definition);
$form->submit([QuestionnaireType::SUBMIT => '']); $form->submit([QuestionnaireType::SUBMIT => '']);
@@ -79,9 +79,25 @@ class QuestionnaireTypeTest extends KernelTestCase
$this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors()); $this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors());
} }
/**
* A placement without bus escort gets the questionnaire without the bus sections, and can
* submit it without them.
*/
public function testWithoutBusEscortTheBusPartIsNeitherShownNorDemanded(): void
{
$definition = $this->teamerAssignment()->forFlags([]);
$form = $this->createForm($definition);
$this->assertFalse($form->has('bus_trip'));
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
}
public function testLowRatingDemandsAReason(): void public function testLowRatingDemandsAReason(): void
{ {
$definition = $this->teamerEscort(); $definition = $this->teamerAssignment();
$data = $this->complete($definition); $data = $this->complete($definition);
$data['organisation']['office_briefing_rating'] = '3'; $data['organisation']['office_briefing_rating'] = '3';
@@ -95,7 +111,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testOtherBusCompanyDemandsItsName(): void public function testOtherBusCompanyDemandsItsName(): void
{ {
$definition = $this->teamerEscort(); $definition = $this->teamerAssignment();
$data = $this->complete($definition); $data = $this->complete($definition);
$data['bus_trip']['bus_company'] = 'other'; $data['bus_trip']['bus_company'] = 'other';
@@ -111,7 +127,7 @@ class QuestionnaireTypeTest extends KernelTestCase
*/ */
public function testNoFieldIsHtmlRequired(): void public function testNoFieldIsHtmlRequired(): void
{ {
$view = $this->createForm($this->teamerEscort())->createView(); $view = $this->createForm($this->teamerAssignment())->createView();
foreach ($view as $section) { foreach ($view as $section) {
foreach ($section as $field) { foreach ($section as $field) {
@@ -122,7 +138,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void
{ {
$view = $this->createForm($this->teamerEscort())->createView(); $view = $this->createForm($this->teamerAssignment())->createView();
$row = $this->view($view, 'organisation', 'office_briefing_low_reason'); $row = $this->view($view, 'organisation', 'office_briefing_low_reason');
$this->assertSame( $this->assertSame(
@@ -159,9 +175,9 @@ class QuestionnaireTypeTest extends KernelTestCase
$this->assertSame('2026-01-10', $form->getData()['travel_date']); $this->assertSame('2026-01-10', $form->getData()['travel_date']);
} }
private function teamerEscort(): Definition private function teamerAssignment(): Definition
{ {
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_escort'); return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_assignment');
} }
/** /**
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Model\Questionnaire; namespace App\Tests\Model\Questionnaire;
use App\Enum\ConditionOperator; use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType; use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition; use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition; use App\Model\Questionnaire\Definition;
@@ -44,6 +45,19 @@ class DefinitionTest extends TestCase
$this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected'])); $this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected']));
} }
public function testForFlagsDropsTheSectionsOfFlagsTheSubjectLacks(): void
{
$definition = new Definition('sample', 2, 'Beispiel', null, [
new Section('everyone', null, [new Question('rating', 'Wie war es?', QuestionType::RATING)]),
new Section('bus', null, [new Question('bus', 'Wie war der Bus?', QuestionType::RATING)], QuestionnaireFlag::BUS_ESCORT),
]);
$this->assertSame(['rating'], array_keys($definition->forFlags([])->getQuestions()));
$this->assertSame(['rating', 'bus'], array_keys($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestions()));
// still the same questionnaire, so answers to it are answers to this definition
$this->assertSame(2, $definition->forFlags([])->version);
}
public function testWithoutHiddenAnswersDropsHiddenEmptyAndUnknownAnswers(): void public function testWithoutHiddenAnswersDropsHiddenEmptyAndUnknownAnswers(): void
{ {
$answers = [ $answers = [
@@ -83,6 +83,14 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
} }
public function testAPlacementWithoutBusEscortIsPendingToo(): void
{
$disposition = $this->escort();
$disposition->getAssignment()->setPickup(0);
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
}
public function testAnAssignmentEndingTodayIsPending(): void public function testAnAssignmentEndingTodayIsPending(): void
{ {
$this->escort(dateTo: 'today'); $this->escort(dateTo: 'today');
@@ -108,7 +116,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
public static function notPending(): array public static function notPending(): array
{ {
return [ return [
'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)],
'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
@@ -50,7 +50,7 @@ trait QuestionnaireFixturesTrait
return $disposition; return $disposition;
} }
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ESCORT): QuestionnaireResponse private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ASSIGNMENT): QuestionnaireResponse
{ {
$definition = new Definition($questionnaire, 1, 'Beispiel', null, []); $definition = new Definition($questionnaire, 1, 'Beispiel', null, []);
$response = QuestionnaireResponse::forDisposition($definition, $disposition); $response = QuestionnaireResponse::forDisposition($definition, $disposition);
@@ -94,6 +94,6 @@ class QuestionnaireResponseRepositoryTest extends KernelTestCase
{ {
$this->entityManager->flush(); $this->entityManager->flush();
return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filter)->getResult(); return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filter)->getResult();
} }
} }
+11 -1
View File
@@ -179,6 +179,17 @@ class DispositionVoterTest extends TestCase
$this->assertQuestionnaire(true, $this->escortDisposition()); $this->assertQuestionnaire(true, $this->escortDisposition());
} }
/**
* Every placement gets the questionnaire; the escort only adds the bus sections.
*/
public function testTheTeamerMayFillInTheQuestionnaireWithoutAnEscortToo(): void
{
$disposition = $this->escortDisposition();
$disposition->getAssignment()->setPickup(0);
$this->assertQuestionnaire(true, $disposition);
}
/** /**
* Ending today already counts as over, as in DispositionRepository's feedback queries. * Ending today already counts as over, as in DispositionRepository's feedback queries.
*/ */
@@ -205,7 +216,6 @@ class DispositionVoterTest extends TestCase
public static function questionnaireDenials(): array public static function questionnaireDenials(): array
{ {
return [ return [
'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)],
'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
@@ -10,6 +10,7 @@ use App\Entity\Destination;
use App\Entity\Disposition; use App\Entity\Disposition;
use App\Entity\JobProfile; use App\Entity\JobProfile;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Enum\QuestionnaireFlag;
use App\Service\Questionnaire\DispositionContext; use App\Service\Questionnaire\DispositionContext;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -42,6 +43,12 @@ class DispositionContextTest extends TestCase
$this->assertNull($context['escortPickupDate']); $this->assertNull($context['escortPickupDate']);
} }
public function testOnlyABusEscortGetsTheBusSections(): void
{
$this->assertSame([QuestionnaireFlag::BUS_ESCORT], DispositionContext::flags($this->disposition(pickup: 4711)->getAssignment()));
$this->assertSame([], DispositionContext::flags($this->disposition(pickup: 0)->getAssignment()));
}
private function disposition(int $pickup): Disposition private function disposition(int $pickup): Disposition
{ {
$destination = (new Destination()) $destination = (new Destination())
+1 -1
View File
@@ -96,6 +96,6 @@ class QuestionnaireTemplateTest extends KernelTestCase
private function definition(): Definition private function definition(): Definition
{ {
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ESCORT); return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
} }
} }