feat: store questionnaire responses per disposition
This commit is contained in:
1 parent
8070fda64d
commit
df6734c939
9 files changed
+521
-1
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user