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

+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'),
];
}
}