feat: store questionnaire responses per disposition

This commit is contained in:
fromme committed 2026-10-27 16:51:56 +01:00
1 parent 8070fda64d
commit df6734c939
9 files changed
+521 -1

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds questionnaire_response: a respondent's answers to one questionnaire from
* config/questionnaires/, for now the teamer questionnaire after a bus-escort disposition.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261027140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add questionnaire_response';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+10 -1
View File
@@ -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')
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Enum\QuestionnaireResponseStatus;
use App\Model\Questionnaire\Definition;
use App\Repository\QuestionnaireResponseRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* One respondent's answers to one questionnaire (config/questionnaires/), a draft until submitted.
*
* Answers are keyed by question key and only mean something together with the definition of
* $questionnaire in $version. $context holds what the subject already knew - for a disposition,
* who, where, when and the escort pickup - so those questions are not asked and the response stays
* readable when the assignment is edited later.
*
* Its subject is a Disposition today. A questionnaire about something else gets a nullable foreign
* key of its own beside $disposition, not a generic subject column.
*
* Not soft-deletable: nothing in the UI deletes a response, and it goes with its disposition.
*/
#[ORM\Entity(repositoryClass: QuestionnaireResponseRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\UniqueConstraint(name: 'questionnaire_disposition', columns: ['questionnaire', 'disposition_id'])]
class QuestionnaireResponse implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64)]
private string $questionnaire;
#[ORM\Column]
private int $version;
#[ORM\Column(length: 16, enumType: QuestionnaireResponseStatus::class)]
private QuestionnaireResponseStatus $status = QuestionnaireResponseStatus::DRAFT;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $submittedAt = null;
/**
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $answers = [];
/**
* @var array<string, mixed>
*/
#[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<string, mixed> $answers
* @param array<string, mixed> $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<string, mixed> $answers
* @param array<string, mixed> $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<string, mixed>
*/
public function getAnswers(): array
{
return $this->answers;
}
/**
* @return array<string, mixed>
*/
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));
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Enum;
enum QuestionnaireResponseStatus: string
{
case DRAFT = 'draft';
case SUBMITTED = 'submitted';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Entwurf',
self::SUBMITTED => 'Abgeschickt',
};
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\Disposition;
use App\Entity\QuestionnaireResponse;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QuestionnaireResponse>
*
* @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]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Service\Questionnaire;
use App\Entity\Disposition;
/**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
* need not ask it: who, in which role, where, when, and where the bus escort started.
*
* Kept as plain scalars (dates as Y-m-d), because it is stored as JSON and must stay readable
* after the assignment, its destination or the teamer change.
*/
final class DispositionContext
{
/**
* @return array{
* teamerName: ?string,
* jobProfileName: ?string,
* jobProfileCategory: ?string,
* destinationProduct: ?string,
* hotel: ?string,
* hotelCode: ?string,
* dateFrom: ?string,
* dateTo: ?string,
* escortPickup: array{busProId: ?int, city: ?string, street: ?string, time: ?string}|null,
* escortPickupDate: ?string,
* }
*/
public static function build(Disposition $disposition): array
{
$assignment = $disposition->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'),
];
}
}
+18
View File
@@ -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));
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\QuestionnaireResponse;
use App\Entity\Teamer;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireResponseStatus;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
use App\Model\Questionnaire\Option;
use App\Model\Questionnaire\Question;
use App\Model\Questionnaire\Section;
use PHPUnit\Framework\TestCase;
class QuestionnaireResponseTest extends TestCase
{
public function testNewResponseIsADraftOfTheCurrentVersion(): void
{
$response = $this->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')),
]),
]);
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Questionnaire;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Service\Questionnaire\DispositionContext;
use PHPUnit\Framework\TestCase;
class DispositionContextTest extends TestCase
{
public function testEscortDispositionIsSnapshotted(): void
{
$context = DispositionContext::build($this->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));
}
}