feat: split nights off an accommodation booking as separate periods
addresses #869f6z36v
This commit is contained in:
1 parent
972839286c
commit
7001bc0cf2
35 files changed
+2221
-25
No files matched your search
@@ -10,10 +10,13 @@ use App\Entity\TimestampableEntity;
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Enum\Groups\AdditionalServiceType;
|
||||
use App\Exception\ImplausibleBookingPriceException;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -195,9 +198,21 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?User $managedBy = null;
|
||||
|
||||
/**
|
||||
* Nights split off the start or the end of the stay and quoted as free-form lines, at most
|
||||
* one on each side. dateFrom and dateTo keep spanning the whole stay; the booking's own
|
||||
* parameters price only the main period left between the leading and the trailing period.
|
||||
*
|
||||
* @var Collection<int, AccommodationBookingPeriod>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AccommodationBookingPeriod::class, mappedBy: 'booking', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
|
||||
private Collection $periods;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->periods = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +226,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
* The price snapshot, the service snapshots and all four discounts are deliberately carried
|
||||
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
|
||||
* the same numbers as the record it came from rather than a re-quote at today's catalog
|
||||
* prices; saving it through the edit form recomputes it.
|
||||
* prices; saving it through the edit form recomputes it. The split-off periods belong to
|
||||
* that quote and are copied along with it.
|
||||
*/
|
||||
public function __clone()
|
||||
{
|
||||
$periods = $this->periods->toArray();
|
||||
$this->periods = new ArrayCollection();
|
||||
foreach ($periods as $period) {
|
||||
$this->addPeriod(clone $period);
|
||||
}
|
||||
|
||||
$this->id = null;
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->status = AccommodationBookingStatus::Draft;
|
||||
@@ -302,6 +324,174 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* At most one period on each side, attached to its edge of the stay, and at least one night
|
||||
* left for the main period in between. A change to the stay's dates that no longer fits the
|
||||
* periods is reported on that date rather than silently moving the periods along.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validatePeriods(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null === $this->dateFrom || null === $this->dateTo || $this->periods->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (AccommodationBookingPeriodPosition::cases() as $position) {
|
||||
if (count($this->periodsAt($position)) > 1) {
|
||||
$context->buildViolation('Es kann nur einen Zeitraum am Anfang und einen am Ende geben.')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$leading = $this->getLeadingPeriod();
|
||||
if (null !== $leading && (!self::sameDay($leading->getDateFrom(), $this->dateFrom) || $leading->getNights() < 1)) {
|
||||
$context->buildViolation('Der Zeitraum am Anfang passt nicht mehr zur Anreise — bitte Anreise oder Zeitraum prüfen.')
|
||||
->atPath('dateFrom')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$trailing = $this->getTrailingPeriod();
|
||||
if (null !== $trailing && (!self::sameDay($trailing->getDateTo(), $this->dateTo) || $trailing->getNights() < 1)) {
|
||||
$context->buildViolation('Der Zeitraum am Ende passt nicht mehr zur Abreise — bitte Abreise oder Zeitraum prüfen.')
|
||||
->atPath('dateTo')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->getMainNights() < 1) {
|
||||
$context->buildViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.')
|
||||
->atPath('dateTo')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return Collection<int, AccommodationBookingPeriod> */
|
||||
public function getPeriods(): Collection
|
||||
{
|
||||
return $this->periods;
|
||||
}
|
||||
|
||||
public function addPeriod(AccommodationBookingPeriod $period): self
|
||||
{
|
||||
if (!$this->periods->contains($period)) {
|
||||
$this->periods->add($period);
|
||||
$period->setBooking($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function canSplitOffPeriod(AccommodationBookingPeriodPosition $position): bool
|
||||
{
|
||||
return [] === $this->periodsAt($position);
|
||||
}
|
||||
|
||||
/** Splits $nights off the main period, at the edge of the stay that $position names. */
|
||||
public function splitOffPeriod(AccommodationBookingPeriodPosition $position, int $nights): AccommodationBookingPeriod
|
||||
{
|
||||
if (null === $this->dateFrom || null === $this->dateTo) {
|
||||
throw new \LogicException('A period can only be split off a booking with dates.');
|
||||
}
|
||||
|
||||
if (!$this->canSplitOffPeriod($position)) {
|
||||
throw new \LogicException(sprintf('The booking already has a %s period.', $position->value));
|
||||
}
|
||||
|
||||
$period = new AccommodationBookingPeriod($position, $this->dateFrom, $this->dateTo);
|
||||
$this->addPeriod($period);
|
||||
$this->resizePeriod($period, $nights);
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
/** Gives $period a new length, keeping it at its edge of the stay; the main period absorbs the difference. */
|
||||
public function resizePeriod(AccommodationBookingPeriod $period, int $nights): self
|
||||
{
|
||||
if (null === $this->dateFrom || null === $this->dateTo) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$period->isLeading()
|
||||
? $period->setDates($this->dateFrom, $this->dateFrom->modify(sprintf('+%d days', $nights)))
|
||||
: $period->setDates($this->dateTo->modify(sprintf('-%d days', $nights)), $this->dateTo);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Its nights go back to the main period. */
|
||||
public function removePeriod(AccommodationBookingPeriod $period): self
|
||||
{
|
||||
if ($this->periods->removeElement($period) && $period->getBooking() === $this) {
|
||||
$period->setBooking(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLeadingPeriod(): ?AccommodationBookingPeriod
|
||||
{
|
||||
return $this->periodsAt(AccommodationBookingPeriodPosition::Leading)[0] ?? null;
|
||||
}
|
||||
|
||||
public function getTrailingPeriod(): ?AccommodationBookingPeriod
|
||||
{
|
||||
return $this->periodsAt(AccommodationBookingPeriodPosition::Trailing)[0] ?? null;
|
||||
}
|
||||
|
||||
/** Arrival of the group the booking's own parameters price: the stay's, unless a period leads it. */
|
||||
public function getMainDateFrom(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->getLeadingPeriod()?->getDateTo() ?? $this->dateFrom;
|
||||
}
|
||||
|
||||
/** Departure of the group the booking's own parameters price: the stay's, unless a period trails it. */
|
||||
public function getMainDateTo(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->getTrailingPeriod()?->getDateFrom() ?? $this->dateTo;
|
||||
}
|
||||
|
||||
public function getMainNights(): int
|
||||
{
|
||||
$from = $this->getMainDateFrom();
|
||||
$to = $this->getMainDateTo();
|
||||
|
||||
if (null === $from || null === $to || $from >= $to) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $from->diff($to)->days;
|
||||
}
|
||||
|
||||
/** In minor units, the sum of every line of every split-off period. */
|
||||
public function getPeriodsTotal(): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($this->periods as $period) {
|
||||
$total += $period->getTotal();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/** @return list<AccommodationBookingPeriod> */
|
||||
private function periodsAt(AccommodationBookingPeriodPosition $position): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->periods->toArray(),
|
||||
fn (AccommodationBookingPeriod $p) => $p->getPosition() === $position,
|
||||
));
|
||||
}
|
||||
|
||||
private static function sameDay(\DateTimeImmutable $a, \DateTimeImmutable $b): bool
|
||||
{
|
||||
return $a->format('Y-m-d') === $b->format('Y-m-d');
|
||||
}
|
||||
|
||||
public function getPaxCount(): int
|
||||
{
|
||||
return $this->paxCount;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use App\Entity\TimestampableParentAwareInterface;
|
||||
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A stretch of nights split off the start or the end of a stay, such as the day an advance team
|
||||
* arrives before the group. It carries no pax, board or services of its own and is never priced
|
||||
* from the catalog: the office quotes it as free-form lines, which are added to the booking's
|
||||
* total and fall under its fixed total discount. The booking's own parameters price only what
|
||||
* remains between the leading and the trailing periods.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
class AccommodationBookingPeriod implements TimestampableParentAwareInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'periods')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?AccommodationBooking $booking = null;
|
||||
|
||||
#[ORM\Column(length: 10, enumType: AccommodationBookingPeriodPosition::class)]
|
||||
private AccommodationBookingPeriodPosition $position;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
private \DateTimeImmutable $dateFrom;
|
||||
|
||||
// Exclusive, like the booking's: the departure day, not the last night.
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
private \DateTimeImmutable $dateTo;
|
||||
|
||||
/** What the price breakdown calls the period, e.g. "Vorausteam"; without one it shows the dates alone. */
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $label = null;
|
||||
|
||||
/**
|
||||
* Amounts in minor units of the booking's pricing currency; a line has no currency of its own.
|
||||
*
|
||||
* The column is not called `lines`: that is a reserved word in MariaDB, and the ORM does
|
||||
* not quote column names in the statements it writes.
|
||||
*
|
||||
* @var list<array{label: string, amount: int}>
|
||||
*/
|
||||
#[ORM\Column(name: 'price_lines', type: Types::JSON)]
|
||||
private array $lines = [];
|
||||
|
||||
public function __construct(AccommodationBookingPeriodPosition $position, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo)
|
||||
{
|
||||
$this->position = $position;
|
||||
$this->dateFrom = $dateFrom;
|
||||
$this->dateTo = $dateTo;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
$this->booking = null;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getBooking(): ?AccommodationBooking
|
||||
{
|
||||
return $this->booking;
|
||||
}
|
||||
|
||||
public function setBooking(?AccommodationBooking $booking): self
|
||||
{
|
||||
$this->booking = $booking;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimestampableParent(): ?TimestampableEntityInterface
|
||||
{
|
||||
return $this->booking;
|
||||
}
|
||||
|
||||
public function getPosition(): AccommodationBookingPeriodPosition
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function isLeading(): bool
|
||||
{
|
||||
return AccommodationBookingPeriodPosition::Leading === $this->position;
|
||||
}
|
||||
|
||||
public function getDateFrom(): \DateTimeImmutable
|
||||
{
|
||||
return $this->dateFrom;
|
||||
}
|
||||
|
||||
public function getDateTo(): \DateTimeImmutable
|
||||
{
|
||||
return $this->dateTo;
|
||||
}
|
||||
|
||||
public function setDates(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): self
|
||||
{
|
||||
$this->dateFrom = $dateFrom;
|
||||
$this->dateTo = $dateTo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): self
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNights(): int
|
||||
{
|
||||
return $this->dateFrom < $this->dateTo ? $this->dateFrom->diff($this->dateTo)->days : 0;
|
||||
}
|
||||
|
||||
/** @return list<array{label: string, amount: int}> */
|
||||
public function getLines(): array
|
||||
{
|
||||
return $this->lines;
|
||||
}
|
||||
|
||||
/** @param list<array{label: string, amount: int}> $lines */
|
||||
public function setLines(array $lines): self
|
||||
{
|
||||
$this->lines = $lines;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return array_sum(array_column($this->lines, 'amount'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user