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

+31 -5
View File
@@ -4,6 +4,7 @@ namespace App\Config;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
@@ -26,12 +27,12 @@ use Symfony\Component\Yaml\Yaml;
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 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 OPTION_FIELDS = ['key', 'label'];
@@ -53,7 +54,7 @@ class QuestionnaireCatalog
/**
* 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
{
@@ -129,6 +130,8 @@ class QuestionnaireCatalog
$sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = [];
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
$questionFlags = [];
foreach ($data['sections'] as $index => $sectionData) {
$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');
}
$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 = [];
foreach ($sectionData['questions'] as $questionData) {
$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;
$questionFlags[$question->key] = $onlyFor;
$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(
@@ -33,7 +33,7 @@ class IndexController extends AbstractController
$filterDto = $this->filterHandler->getFilterSettings();
$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('limit', 50),
[
@@ -43,7 +43,7 @@ class IndexController extends AbstractController
);
return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT),
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
@@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment;
use App\Enum\QuestionnaireFlag;
use App\Form\QuestionnaireType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -14,9 +15,10 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without
* bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the
* answers as they would be stored, "Entwurf speichern" just keeps them in the form.
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after
* the trip, placed or not. It shows the assignment's own variant, with or without the bus
* 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
{
@@ -34,7 +36,13 @@ class TestController extends AbstractController
Assignment $assignment,
Request $request,
): 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->handleRequest($request);
@@ -56,6 +64,7 @@ class TestController extends AbstractController
'form' => $form,
'submitted' => $submitted,
'draft' => $draft,
'busEscort' => $busEscort,
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]),
]);
}
@@ -30,7 +30,7 @@ class DetailController extends AbstractController
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true !== $response?->isSubmitted()) {
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;
/**
* The teamer's questionnaire after a bus escort, saved as a draft until it is submitted. A
* submitted one is final and only shown read-only (DetailController).
* The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
* saved as a draft until it is submitted. A submitted one is final and only shown read-only
* (DetailController).
*/
class EditController extends AbstractController
{
@@ -39,13 +40,16 @@ class EditController extends AbstractController
Disposition $disposition,
Request $request,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true === $response?->isSubmitted()) {
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() ?? [], [
'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', [
'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen',
'title' => 'Fragebögen nach Einsätzen',
],
'extras' => [
'icon' => 'bus',
'icon' => 'document',
'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'],
],
+2 -2
View File
@@ -121,10 +121,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
$menu->addChild('Fragebögen', [
'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen',
'title' => 'Fragebögen nach Einsätzen',
],
'extras' => [
'icon' => 'bus',
'icon' => 'document',
'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'],
],
+22
View File
@@ -2,6 +2,8 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
/**
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
* 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
*/
+5 -1
View File
@@ -2,15 +2,19 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
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 string $key,
public ?string $title,
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
* DispositionVoter::QUESTIONNAIRE - bus escort, over (today included), on or after go-live,
* neither called off nor skip-formalities - so every row shown on the dashboard can be opened.
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
* 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
* 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()->notIn('assignment.status', ':assignmentStatuses'),
$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()->lte('destination.dateTo', ':today'),
$qb->expr()->orX(
@@ -118,7 +116,7 @@ class DispositionRepository extends ServiceEntityRepository
))
->orderBy('destination.dateTo', \SortDirection::Descending)
->setParameter('teamer', $teamer)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->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
* (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the
* submitted responses through ROLE_ADMINISTRATIVE, not through this attribute.
* (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
* 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
* 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()
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()
|| true !== $disposition->getAssignment()?->hasBusEscort()) {
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
return false;
}
$dateTo = $disposition->getAssignment()->getDestination()?->getDateTo();
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
return null !== $dateTo
&& $dateTo >= $this->questionnaireGoLive
@@ -2,7 +2,9 @@
namespace App\Service\Questionnaire;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\QuestionnaireFlag;
/**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
@@ -13,6 +15,17 @@ use App\Entity\Disposition;
*/
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{
* teamerName: ?string,