From df6734c9390ed9edb006f839e77e1024612d1dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 16:51:56 +0100 Subject: [PATCH] feat: store questionnaire responses per disposition --- migrations/Version20261027140000.php | 35 ++++ src/Entity/Assignment.php | 11 +- src/Entity/QuestionnaireResponse.php | 189 ++++++++++++++++++ src/Enum/QuestionnaireResponseStatus.php | 17 ++ .../QuestionnaireResponseRepository.php | 29 +++ .../Questionnaire/DispositionContext.php | 56 ++++++ tests/Entity/AssignmentTest.php | 18 ++ tests/Entity/QuestionnaireResponseTest.php | 100 +++++++++ .../Questionnaire/DispositionContextTest.php | 67 +++++++ 9 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20261027140000.php create mode 100644 src/Entity/QuestionnaireResponse.php create mode 100644 src/Enum/QuestionnaireResponseStatus.php create mode 100644 src/Repository/QuestionnaireResponseRepository.php create mode 100644 src/Service/Questionnaire/DispositionContext.php create mode 100644 tests/Entity/QuestionnaireResponseTest.php create mode 100644 tests/Service/Questionnaire/DispositionContextTest.php diff --git a/migrations/Version20261027140000.php b/migrations/Version20261027140000.php new file mode 100644 index 0000000..498e151 --- /dev/null +++ b/migrations/Version20261027140000.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE questionnaire_response (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, questionnaire VARCHAR(64) NOT NULL, version INT NOT NULL, status VARCHAR(16) NOT NULL, submitted_at DATETIME DEFAULT NULL, answers JSON NOT NULL, context JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, disposition_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A0400276D17F50A6 (uuid), UNIQUE INDEX questionnaire_disposition (questionnaire, disposition_id), INDEX IDX_A0400276287B65ED (disposition_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE questionnaire_response ADD CONSTRAINT FK_A0400276287B65ED FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE questionnaire_response DROP FOREIGN KEY FK_A0400276287B65ED'); + $this->addSql('DROP TABLE questionnaire_response'); + } +} diff --git a/src/Entity/Assignment.php b/src/Entity/Assignment.php index f5e7325..3872193 100644 --- a/src/Entity/Assignment.php +++ b/src/Entity/Assignment.php @@ -256,13 +256,22 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa return $this; } + /** + * Whether the teamer escorts the bus: the "Busbegleitung ab" pickup is set. 0 is stored for + * "Keine Busbegleitung" as well as null, so this is not a plain null check. + */ + public function hasBusEscort(): bool + { + return 0 !== (int) $this->pickup; + } + public function getEffectivePickupDate(): ?\DateTimeImmutable { if (null !== $this->pickupDate) { return $this->pickupDate; } - if (0 !== (int) $this->pickup && null !== $destination = $this->getDestination()) { + if ($this->hasBusEscort() && null !== $destination = $this->getDestination()) { return $destination ->getDateFrom() ->modify('-1 day') diff --git a/src/Entity/QuestionnaireResponse.php b/src/Entity/QuestionnaireResponse.php new file mode 100644 index 0000000..93454bb --- /dev/null +++ b/src/Entity/QuestionnaireResponse.php @@ -0,0 +1,189 @@ + + */ + #[ORM\Column(type: Types::JSON)] + private array $answers = []; + + /** + * @var array + */ + #[ORM\Column(type: Types::JSON)] + private array $context = []; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(onDelete: 'CASCADE')] + private ?Disposition $disposition = null; + + private function __construct(Definition $definition) + { + $this->uuid = Uuid::v4(); + $this->questionnaire = $definition->key; + $this->version = $definition->version; + } + + public static function forDisposition(Definition $definition, Disposition $disposition): static + { + $instance = new static($definition); + $instance->disposition = $disposition; + + return $instance; + } + + /** + * Keeps every answer, including those to questions that are hidden right now: switching an + * answer back restores the follow-up already written. + * + * @param array $answers + * @param array $context + */ + public function saveDraft(array $answers, array $context): static + { + $this->assertDraft(); + + $this->answers = $answers; + $this->context = $context; + + return $this; + } + + /** + * Final. Keeps only the answers to questions that are shown, and records the definition version + * they were given under - the draft may have been started under an older one. + * + * @param array $answers + * @param array $context + */ + public function submit(Definition $definition, array $answers, array $context, \DateTimeImmutable $now): static + { + $this->assertDraft(); + + if ($definition->key !== $this->questionnaire) { + throw new \LogicException(sprintf('Response to "%s" cannot be submitted with "%s"', $this->questionnaire, $definition->key)); + } + + $this->answers = $definition->withoutHiddenAnswers($answers); + $this->context = $context; + $this->version = $definition->version; + $this->status = QuestionnaireResponseStatus::SUBMITTED; + $this->submittedAt = $now; + + return $this; + } + + public function isSubmitted(): bool + { + return QuestionnaireResponseStatus::SUBMITTED === $this->status; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getQuestionnaire(): string + { + return $this->questionnaire; + } + + public function getVersion(): int + { + return $this->version; + } + + public function getStatus(): QuestionnaireResponseStatus + { + return $this->status; + } + + public function getSubmittedAt(): ?\DateTimeImmutable + { + return $this->submittedAt; + } + + /** + * @return array + */ + public function getAnswers(): array + { + return $this->answers; + } + + /** + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + public function getDisposition(): ?Disposition + { + return $this->disposition; + } + + private function assertDraft(): void + { + if ($this->isSubmitted()) { + throw new \LogicException(sprintf('Questionnaire response %s is already submitted', $this->uuid)); + } + } +} diff --git a/src/Enum/QuestionnaireResponseStatus.php b/src/Enum/QuestionnaireResponseStatus.php new file mode 100644 index 0000000..6a2a861 --- /dev/null +++ b/src/Enum/QuestionnaireResponseStatus.php @@ -0,0 +1,17 @@ + 'Entwurf', + self::SUBMITTED => 'Abgeschickt', + }; + } +} diff --git a/src/Repository/QuestionnaireResponseRepository.php b/src/Repository/QuestionnaireResponseRepository.php new file mode 100644 index 0000000..8e027cd --- /dev/null +++ b/src/Repository/QuestionnaireResponseRepository.php @@ -0,0 +1,29 @@ + + * + * @method QuestionnaireResponse|null find($id, $lockMode = null, $lockVersion = null) + * @method QuestionnaireResponse|null findOneBy(array $criteria, array $orderBy = null) + * @method QuestionnaireResponse[] findAll() + * @method QuestionnaireResponse[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class QuestionnaireResponseRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QuestionnaireResponse::class); + } + + public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse + { + return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]); + } +} diff --git a/src/Service/Questionnaire/DispositionContext.php b/src/Service/Questionnaire/DispositionContext.php new file mode 100644 index 0000000..51584b0 --- /dev/null +++ b/src/Service/Questionnaire/DispositionContext.php @@ -0,0 +1,56 @@ +getAssignment(); + $destination = $assignment?->getDestination(); + $jobProfile = $assignment?->getJobProfile(); + $pickup = $assignment?->hasBusEscort() ? $destination?->getPickup($assignment->getPickup()) : null; + + return [ + 'teamerName' => $disposition->getTeamer()?->getFullName(), + 'jobProfileName' => $jobProfile?->getName(), + 'jobProfileCategory' => $jobProfile?->getCategory(), + 'destinationProduct' => $destination?->getProduct(), + 'hotel' => $destination?->getHotel(), + 'hotelCode' => $destination?->getHotelCode(), + 'dateFrom' => $destination?->getDateFrom()?->format('Y-m-d'), + 'dateTo' => $destination?->getDateTo()?->format('Y-m-d'), + 'escortPickup' => null === $pickup ? null : [ + // Pickup::fromArray() fills id, not busProId; the assignment holds the same value + 'busProId' => $assignment->getPickup(), + 'city' => $pickup->getCity(), + 'street' => $pickup->getStreet(), + 'time' => $pickup->getTime(), + ], + 'escortPickupDate' => $assignment?->getEffectivePickupDate()?->format('Y-m-d'), + ]; + } +} diff --git a/tests/Entity/AssignmentTest.php b/tests/Entity/AssignmentTest.php index df83b49..d021887 100644 --- a/tests/Entity/AssignmentTest.php +++ b/tests/Entity/AssignmentTest.php @@ -8,6 +8,7 @@ use App\Entity\Application; use App\Entity\Assignment; use App\Entity\Disposition; use App\Entity\Teamer; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -65,6 +66,23 @@ class AssignmentTest extends TestCase $this->assertSame($disposition, $assignment->getActiveDispositionByTeamer($teamer)); } + /** + * The "Busbegleitung ab" field stores 0 for "Keine Busbegleitung" as well as null. + */ + #[DataProvider('providePickups')] + public function testHasBusEscort(int|string|null $pickup, bool $expected): void + { + $this->assertSame($expected, (new Assignment())->setPickup($pickup)->hasBusEscort()); + } + + public static function providePickups(): iterable + { + yield 'none' => [null, false]; + yield 'zero' => [0, false]; + yield 'empty form value' => ['', false]; + yield 'pickup' => [4711, true]; + } + private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition { $disposition = new Disposition(new Application($assignment, $teamer)); diff --git a/tests/Entity/QuestionnaireResponseTest.php b/tests/Entity/QuestionnaireResponseTest.php new file mode 100644 index 0000000..bc7cc76 --- /dev/null +++ b/tests/Entity/QuestionnaireResponseTest.php @@ -0,0 +1,100 @@ +response(); + + $this->assertSame('sample', $response->getQuestionnaire()); + $this->assertSame(1, $response->getVersion()); + $this->assertSame(QuestionnaireResponseStatus::DRAFT, $response->getStatus()); + $this->assertNull($response->getSubmittedAt()); + } + + /** + * The respondent switched "damage" back to "no" after writing a description; switching it to + * "yes" again must bring the description back. + */ + public function testDraftKeepsAnswersToHiddenQuestions(): void + { + $response = $this->response()->saveDraft(['damage' => 'no', 'details' => 'Spiegel ab'], ['teamerName' => 'Kim']); + + $this->assertSame(['damage' => 'no', 'details' => 'Spiegel ab'], $response->getAnswers()); + $this->assertSame(['teamerName' => 'Kim'], $response->getContext()); + $this->assertFalse($response->isSubmitted()); + } + + public function testSubmitDropsAnswersToHiddenQuestions(): void + { + $now = new \DateTimeImmutable('2026-03-01 12:00'); + $response = $this->response()->submit($this->definition(), ['damage' => 'no', 'details' => 'Spiegel ab'], [], $now); + + $this->assertSame(['damage' => 'no'], $response->getAnswers()); + $this->assertTrue($response->isSubmitted()); + $this->assertSame($now, $response->getSubmittedAt()); + } + + /** + * A draft begun before the definition was bumped is submitted, and therefore rendered, under + * the version it was completed in. + */ + public function testSubmitRecordsTheVersionAnsweredUnder(): void + { + $response = $this->response()->submit($this->definition(version: 2), ['damage' => 'no'], [], new \DateTimeImmutable()); + + $this->assertSame(2, $response->getVersion()); + } + + public function testSubmittedResponseCannotBeChanged(): void + { + $response = $this->response()->submit($this->definition(), ['damage' => 'no'], [], new \DateTimeImmutable()); + + $this->expectException(\LogicException::class); + + $response->saveDraft(['damage' => 'yes'], []); + } + + public function testResponseCannotBeSubmittedWithAnotherQuestionnaire(): void + { + $this->expectException(\LogicException::class); + + $this->response()->submit($this->definition(key: 'other'), [], [], new \DateTimeImmutable()); + } + + private function response(): QuestionnaireResponse + { + $disposition = new Disposition(new Application(new Assignment(), new Teamer())); + + return QuestionnaireResponse::forDisposition($this->definition(), $disposition); + } + + private function definition(string $key = 'sample', int $version = 1): Definition + { + return new Definition($key, $version, 'Beispiel', null, [ + new Section('main', null, [ + new Question('damage', 'Schäden?', QuestionType::CHOICE, options: [new Option('yes', 'Ja'), new Option('no', 'Nein')]), + new Question('details', 'Was ist passiert?', QuestionType::TEXTAREA, showIf: new Condition('damage', ConditionOperator::EQUALS, 'yes')), + ]), + ]); + } +} diff --git a/tests/Service/Questionnaire/DispositionContextTest.php b/tests/Service/Questionnaire/DispositionContextTest.php new file mode 100644 index 0000000..c7712b8 --- /dev/null +++ b/tests/Service/Questionnaire/DispositionContextTest.php @@ -0,0 +1,67 @@ +disposition(pickup: 4711)); + + $this->assertSame([ + 'teamerName' => 'Kim Muster', + 'jobProfileName' => 'Reiseleitung', + 'jobProfileCategory' => JobProfile::CATEGORY_GUIDE, + 'destinationProduct' => 'Davos Klosters - Schwendi', + 'hotel' => 'Schwendi', + 'hotelCode' => 'DKI', + 'dateFrom' => '2026-01-10', + 'dateTo' => '2026-01-17', + 'escortPickup' => ['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30'], + // the escort leaves the evening before arrival + 'escortPickupDate' => '2026-01-09', + ], $context); + } + + public function testDispositionWithoutEscortHasNoPickup(): void + { + $context = DispositionContext::build($this->disposition(pickup: 0)); + + $this->assertNull($context['escortPickup']); + $this->assertNull($context['escortPickupDate']); + } + + private function disposition(int $pickup): Disposition + { + $destination = (new Destination()) + ->setProduct('Davos Klosters - Schwendi') + ->setHotel('Schwendi') + ->setHotelCode('DKI') + ->setDateFrom(new \DateTimeImmutable('2026-01-10')) + ->setDateTo(new \DateTimeImmutable('2026-01-17')) + ->setPickups([ + ['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30'], + ['busProId' => 4712, 'city' => 'Ulm', 'street' => 'ZOB', 'time' => '22:45'], + ]) + ; + $assignment = (new Assignment()) + ->setDestination($destination) + ->setJobProfile((new JobProfile())->setName('Reiseleitung')->setCategory(JobProfile::CATEGORY_GUIDE)) + ->setPickup($pickup) + ; + $teamer = (new Teamer())->setFirstName('Kim')->setLastName('Muster'); + + return new Disposition(new Application($assignment, $teamer)); + } +}