133 lines
5.3 KiB
PHP
133 lines
5.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Service;
|
||
|
||
use App\Entity\Groups\AccommodationBooking;
|
||
use App\Repository\Groups\AccommodationPriceRepository;
|
||
|
||
class AccommodationBookingBreakdownCalculator
|
||
{
|
||
public function __construct(
|
||
private readonly GroupsPriceCalculator $priceCalculator,
|
||
private readonly AccommodationPriceRepository $priceRepo,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* Returns the authoritative stored snapshot, falling back to current prices only for
|
||
* bookings created before snapshot persistence was introduced, enriched with the
|
||
* discount rows and the discounted total for rendering.
|
||
*
|
||
* @return array<string, mixed>|null null when no accommodation or dates are set
|
||
*/
|
||
public function compute(AccommodationBooking $booking): ?array
|
||
{
|
||
$breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking);
|
||
|
||
return null !== $breakdown ? $this->withDiscounts($booking, $breakdown) : null;
|
||
}
|
||
|
||
/**
|
||
* Adds the discount rows and the price the customer actually pays. Derived on every read
|
||
* and never persisted — refreshPriceSnapshot() stores the raw breakdown from
|
||
* computeCurrent() — so the stored total and this one come out of the same code and
|
||
* cannot drift apart.
|
||
*
|
||
* @param array<string, mixed> $breakdown
|
||
*
|
||
* @return array<string, mixed> the breakdown plus `discounts`, `discountSubtotal`,
|
||
* `totalDiscountDetails` and `discountedTotal`
|
||
*/
|
||
public function withDiscounts(AccommodationBooking $booking, array $breakdown): array
|
||
{
|
||
// The labels carry their own "Rabatt" prefix so that every row renders as plain
|
||
// {label} {percent} %, including the freely named discount on the total below.
|
||
$buckets = [
|
||
['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)],
|
||
['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)],
|
||
['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)],
|
||
];
|
||
|
||
$discounts = [];
|
||
$discountSum = 0;
|
||
|
||
foreach ($buckets as [$label, $percent, $base]) {
|
||
$amount = self::discountAmount($base, $percent);
|
||
|
||
// Nothing to show for a discount of 0 %, or one on a bucket this booking has no
|
||
// price in — a "– 0,00 €" row only raises questions.
|
||
if (0 === $amount) {
|
||
continue;
|
||
}
|
||
|
||
$discountSum += $amount;
|
||
$discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount];
|
||
}
|
||
|
||
// Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while
|
||
// computing the new total, where the stored one is still the outdated value.
|
||
$subtotal = (int) ($breakdown['total'] ?? 0) - $discountSum;
|
||
|
||
// Compounds on the already-reduced subtotal rather than on the gross total: the discount
|
||
// is granted on what the customer would otherwise pay, not on a figure no longer asked.
|
||
$totalDiscountAmount = self::discountAmount($subtotal, $booking->getTotalDiscount());
|
||
|
||
$breakdown['discounts'] = $discounts;
|
||
$breakdown['totalDiscountDetails'] = 0 !== $totalDiscountAmount
|
||
? [
|
||
'label' => (string) $booking->getTotalDiscountLabel(),
|
||
'percent' => $booking->getTotalDiscount(),
|
||
'amount' => $totalDiscountAmount,
|
||
]
|
||
: null;
|
||
// Only worth a row when it sits between two sets of discounts — with no section discount
|
||
// above it, a subtotal would merely restate the total one line up.
|
||
$breakdown['discountSubtotal'] = 0 !== $totalDiscountAmount && [] !== $discounts ? $subtotal : null;
|
||
$breakdown['discountedTotal'] = $subtotal - $totalDiscountAmount;
|
||
|
||
return $breakdown;
|
||
}
|
||
|
||
private static function discountAmount(int $base, ?int $percent): int
|
||
{
|
||
return null !== $percent ? (int) round($base * $percent / 100) : 0;
|
||
}
|
||
|
||
/**
|
||
* Calculates against the current catalog. Use only while creating or explicitly editing
|
||
* a booking, or as a compatibility fallback for records created before price snapshots.
|
||
*
|
||
* @return array<string, mixed>|null
|
||
*/
|
||
public function computeCurrent(AccommodationBooking $booking): ?array
|
||
{
|
||
$accommodation = $booking->getAccommodation();
|
||
$dateFrom = $booking->getDateFrom();
|
||
$dateTo = $booking->getDateTo();
|
||
|
||
if (null === $accommodation || null === $dateFrom || null === $dateTo) {
|
||
return null;
|
||
}
|
||
|
||
$prices = $this->priceRepo->findByHotelCodeAndDateRange(
|
||
$accommodation->getCalendarCode() ?? '',
|
||
$dateFrom,
|
||
$dateTo,
|
||
);
|
||
|
||
return $this->priceCalculator->calculateFromSnapshots(
|
||
$booking->getPaxCount(),
|
||
$booking->getMinorsCount(),
|
||
$booking->getNights(),
|
||
$dateFrom,
|
||
$dateTo,
|
||
$prices,
|
||
$booking->getBoardServicePrice(),
|
||
$booking->getAdditionalServices(),
|
||
$accommodation->getCurrency(),
|
||
);
|
||
}
|
||
}
|