408 lines
16 KiB
PHP
408 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Constants;
|
|
use App\BusProNet\Model\Booking;
|
|
use App\BusProNet\Model\Insurance;
|
|
use App\BusProNet\Model\Service;
|
|
use App\BusProNet\Utility\DirectionMapper;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\ParticipantDto;
|
|
|
|
/**
|
|
* Assembles display-ready pricing breakdowns for booking summaries and diagnostics.
|
|
*
|
|
* Transforms raw booking data into structured arrays of rooms, service groups,
|
|
* and surcharges ready for rendering in Twig templates or diagnostic payloads.
|
|
* For numeric price totals only, use BookingPriceCalculator.
|
|
*/
|
|
class BookingPricingAssembler
|
|
{
|
|
/**
|
|
* BusPro's label for the per-leg boarding surcharge, suffixed " (Hinfahrt)" or " (Rückfahrt)".
|
|
* A booking response's `<zuschlag>` carries no type attribute, so the label is all there is.
|
|
*/
|
|
private const BOARDING_SURCHARGE_LABEL = 'Abfahrtsortzuschlag';
|
|
|
|
public function __construct(
|
|
private readonly RoomPricingCalculator $roomPricingCalculator,
|
|
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Assembles a complete pricing breakdown for display or diagnostic use.
|
|
*
|
|
* @return array{rooms: array<int, array<string, mixed>>, services: array<int, array<string, mixed>>, grandTotal: float, surcharges?: array<int, array<string, mixed>>}
|
|
*/
|
|
public function getPricingBreakdown(
|
|
BookingDto $bookingDto,
|
|
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
|
): array {
|
|
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
|
$servicePricing = $this->calculateServicePricing($bookingDto);
|
|
|
|
$grandTotal = array_sum(array_column($roomPricing, 'totalPrice'))
|
|
+ array_sum(array_column($servicePricing, 'groupTotal'));
|
|
|
|
$result = [
|
|
'rooms' => $roomPricing,
|
|
'services' => $servicePricing,
|
|
'grandTotal' => $grandTotal,
|
|
];
|
|
|
|
if (null !== $bookingDto->booking && [] !== $bookingDto->booking->surcharges) {
|
|
$surchargePricing = $this->calculateSurchargePricing($bookingDto->booking);
|
|
|
|
if ([] !== $surchargePricing) {
|
|
$result['surcharges'] = $surchargePricing;
|
|
$result['grandTotal'] += array_sum(array_column($surchargePricing, 'totalPrice'));
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Calculates pricing for all selected services across all participants, grouped by subtype.
|
|
*
|
|
* Only includes services from billed participants: not cancelled, and eligible (those with available
|
|
* skipasses for their age).
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function calculateServicePricing(BookingDto $bookingDto): array
|
|
{
|
|
$participants = $bookingDto->getParticipants();
|
|
|
|
if ([] === $participants) {
|
|
return [];
|
|
}
|
|
|
|
$serviceAggregation = [];
|
|
|
|
foreach ($participants as $participantIndex => $participant) {
|
|
if (false === $this->isParticipantBilled($bookingDto, $participantIndex, $participant)) {
|
|
continue;
|
|
}
|
|
|
|
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
|
}
|
|
|
|
foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) {
|
|
$serviceAggregation[$key] = $transportationItem;
|
|
}
|
|
|
|
return $this->groupServicesBySubtype($serviceAggregation);
|
|
}
|
|
|
|
/**
|
|
* Calculates surcharge pricing grouped by label (edit mode only).
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function calculateSurchargePricing(Booking $bookingData): array
|
|
{
|
|
$surchargePricing = [];
|
|
|
|
foreach ($bookingData->surcharges as $surcharge) {
|
|
// BusPro reports a Zuschlag line even when it nets to nothing, e.g. Stornokosten of 0,00 €
|
|
if (0.0 === round($surcharge->totalPrice ?? 0.0, 2)) {
|
|
continue;
|
|
}
|
|
|
|
// Listed per leg among the transportation services, from the participants' current
|
|
// selection rather than the stored booking, so BusPro's copy would count it twice
|
|
if (str_starts_with($surcharge->label ?? '', self::BOARDING_SURCHARGE_LABEL)) {
|
|
continue;
|
|
}
|
|
|
|
$surchargePricing[] = [
|
|
'label' => $surcharge->label ?? 'unbekannt',
|
|
'participantCount' => count($surcharge->mapping),
|
|
'totalPrice' => $surcharge->totalPrice ?? 0.0,
|
|
];
|
|
}
|
|
|
|
return $surchargePricing;
|
|
}
|
|
|
|
/**
|
|
* Aggregates all transportation-related services and pricing into separate line items.
|
|
*
|
|
* Creates separate entries for:
|
|
* - Beförderung: Sum of all positive base transportation costs
|
|
* - Beförderung - Rabatt: Sum of all negative base transportation prices (discounts)
|
|
* - Abfahrtsortzuschlag (Hinfahrt) / (Rückfahrt): Boarding surcharges per leg, labelled as
|
|
* BusPro bills them, so the breakdown reads like the invoice
|
|
* - Parkplatz: Sum of all parking service prices
|
|
*
|
|
* Only includes transportation costs from eligible participants.
|
|
*
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function aggregateTransportationServices(BookingDto $bookingDto): array
|
|
{
|
|
$participants = $bookingDto->getParticipants();
|
|
|
|
$totals = [
|
|
'transportation_positive' => 0.0,
|
|
'transportation_discount' => 0.0,
|
|
'transportation_pickup' => 0.0,
|
|
'transportation_drop_off' => 0.0,
|
|
'transportation_parking' => 0.0,
|
|
];
|
|
$participantCounts = array_fill_keys(array_keys($totals), 0);
|
|
|
|
foreach ($participants as $participantIndex => $participant) {
|
|
if (false === $this->isParticipantBilled($bookingDto, $participantIndex, $participant)) {
|
|
continue;
|
|
}
|
|
|
|
$participantCosts = array_fill_keys(array_keys($totals), 0.0);
|
|
|
|
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $transportation) {
|
|
if (null !== $transportation && null !== $transportation->price) {
|
|
$participantCosts[$transportation->price < 0 ? 'transportation_discount' : 'transportation_positive'] += $transportation->price;
|
|
}
|
|
}
|
|
|
|
$hasOutboundBus = null !== $participant->transportationOutbound
|
|
&& DirectionMapper::isBus($participant->transportationOutbound->subType);
|
|
$hasInboundBus = null !== $participant->transportationInbound
|
|
&& DirectionMapper::isBus($participant->transportationInbound->subType);
|
|
|
|
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
|
|
$participantCosts['transportation_pickup'] += $participant->pickup->price;
|
|
}
|
|
|
|
if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
|
$participantCosts['transportation_drop_off'] += $participant->dropOff->price;
|
|
}
|
|
|
|
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
|
$participantCosts['transportation_parking'] += $participant->parkingService->price;
|
|
}
|
|
|
|
foreach ($participantCosts as $key => $cost) {
|
|
if (0.0 !== round($cost, 2)) {
|
|
$totals[$key] += $cost;
|
|
++$participantCounts[$key];
|
|
}
|
|
}
|
|
}
|
|
|
|
$definitions = [
|
|
'transportation_positive' => ['Beförderung', Constants::GROUP_TRANSPORTATION],
|
|
'transportation_discount' => ['Beförderung - Rabatt', Constants::GROUP_TRANSPORTATION.'_discount'],
|
|
'transportation_pickup' => [self::BOARDING_SURCHARGE_LABEL.' (Hinfahrt)', Constants::GROUP_TRANSPORTATION],
|
|
'transportation_drop_off' => [self::BOARDING_SURCHARGE_LABEL.' (Rückfahrt)', Constants::GROUP_TRANSPORTATION],
|
|
'transportation_parking' => [Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], Constants::GROUP_TRANSPORTATION],
|
|
];
|
|
|
|
$transportationItems = [];
|
|
|
|
foreach ($definitions as $key => [$label, $subType]) {
|
|
if (0 === $participantCounts[$key]) {
|
|
continue;
|
|
}
|
|
|
|
$transportationItems[$key] = [
|
|
'serviceId' => $key,
|
|
'label' => $label,
|
|
'unitPrice' => null,
|
|
'participantCount' => $participantCounts[$key],
|
|
'totalPrice' => $totals[$key],
|
|
'subType' => $subType,
|
|
];
|
|
}
|
|
|
|
return $transportationItems;
|
|
}
|
|
|
|
/**
|
|
* Groups services by their subtypes for display, separating positive costs and discounts.
|
|
*
|
|
* @param array<string, array<string, mixed>> $serviceAggregation
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function groupServicesBySubtype(array $serviceAggregation): array
|
|
{
|
|
$groupedServices = [];
|
|
$servicesBySubtypeAndSign = [];
|
|
|
|
foreach ($serviceAggregation as $serviceData) {
|
|
$subType = $serviceData['subType'] ?? 'other';
|
|
|
|
if ('' === $subType) {
|
|
$subType = 'other';
|
|
}
|
|
|
|
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
|
$subType = Constants::GROUP_RENTALS;
|
|
}
|
|
|
|
if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) {
|
|
$subType = Constants::GROUP_INSURANCE;
|
|
}
|
|
|
|
$isDiscount = $serviceData['totalPrice'] < 0;
|
|
$bucketKey = $subType.($isDiscount ? '_discount' : '_regular');
|
|
|
|
if (false === isset($servicesBySubtypeAndSign[$bucketKey])) {
|
|
$servicesBySubtypeAndSign[$bucketKey] = [
|
|
'subType' => $subType,
|
|
'isDiscount' => $isDiscount,
|
|
'services' => [],
|
|
'total' => 0.0,
|
|
'participantCount' => 0,
|
|
];
|
|
}
|
|
|
|
$servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData;
|
|
$servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice'];
|
|
$servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount'];
|
|
}
|
|
|
|
foreach ($servicesBySubtypeAndSign as $bucketData) {
|
|
$baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']);
|
|
$groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName;
|
|
$services = array_map(
|
|
fn (array $serviceData): array => $this->createServicePricingLineItem($serviceData),
|
|
$bucketData['services']
|
|
);
|
|
|
|
$groupedServices[] = [
|
|
'groupName' => $groupName,
|
|
'services' => $services,
|
|
'groupTotal' => $bucketData['total'],
|
|
];
|
|
}
|
|
|
|
return $groupedServices;
|
|
}
|
|
|
|
/**
|
|
* Whether the participant's services count towards the breakdown.
|
|
*
|
|
* A cancelled participant is billed only through BusPro's Stornokosten surcharge. Their services
|
|
* are never sent back to BusPro, so counting them would put the breakdown above BusPro's total.
|
|
*/
|
|
private function isParticipantBilled(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
|
{
|
|
return false === $participant->isCanceled()
|
|
&& true === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex);
|
|
}
|
|
|
|
private function getGroupNameForSubtype(string $subType): string
|
|
{
|
|
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
|
return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS];
|
|
}
|
|
|
|
return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen';
|
|
}
|
|
|
|
/** @param array<string, array<string, mixed>> $serviceAggregation */
|
|
private function aggregateParticipantServices(
|
|
ParticipantDto $participant,
|
|
array &$serviceAggregation,
|
|
BookingDto $bookingDto,
|
|
): void {
|
|
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
|
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass);
|
|
}
|
|
|
|
if (null !== $participant->veg && null !== $participant->veg->price) {
|
|
$this->addToServiceAggregation($serviceAggregation, $participant->veg);
|
|
}
|
|
|
|
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
|
|
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance);
|
|
}
|
|
|
|
// The session DTO is kept truthful by ApplicantInsuranceCascade, which materialises
|
|
// bulk and family assignments as they are made - so this is simply what the participant holds.
|
|
$insuranceToAggregate = $participant->insurance;
|
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) {
|
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
|
|
}
|
|
|
|
$multipleServiceArrays = [
|
|
'courses' => $participant->courses,
|
|
'additionalServices' => $participant->additionalServices,
|
|
'board' => $participant->board,
|
|
'rentals' => $participant->rentals,
|
|
];
|
|
|
|
foreach ($multipleServiceArrays as $serviceArray) {
|
|
foreach ($serviceArray as $service) {
|
|
if (null !== $service->price) {
|
|
$this->addToServiceAggregation($serviceAggregation, $service);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @param array<string, array<string, mixed>> $serviceAggregation */
|
|
private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void
|
|
{
|
|
$serviceKey = $service->id.'_'.$service->label;
|
|
|
|
if (false === isset($serviceAggregation[$serviceKey])) {
|
|
$serviceAggregation[$serviceKey] = [
|
|
'serviceId' => $service->id,
|
|
'label' => $service->label,
|
|
'unitPrice' => $service->price,
|
|
'participantCount' => 0,
|
|
'totalPrice' => 0.0,
|
|
'subType' => $service->subType,
|
|
];
|
|
}
|
|
|
|
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
|
$serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity;
|
|
}
|
|
|
|
/** @param array<string, array<string, mixed>> $serviceAggregation */
|
|
private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void
|
|
{
|
|
$serviceKey = $insurance->id.'_'.$insurance->label;
|
|
|
|
if (false === isset($serviceAggregation[$serviceKey])) {
|
|
$serviceAggregation[$serviceKey] = [
|
|
'serviceId' => $insurance->id,
|
|
'label' => $insurance->label,
|
|
'unitPrice' => $insurance->price,
|
|
'participantCount' => 0,
|
|
'totalPrice' => 0.0,
|
|
'subType' => $insurance->getSubType(),
|
|
];
|
|
}
|
|
|
|
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
|
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $serviceData
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function createServicePricingLineItem(array $serviceData): array
|
|
{
|
|
return [
|
|
'serviceId' => $serviceData['serviceId'] ?? null,
|
|
'label' => (string) ($serviceData['label'] ?? ''),
|
|
'unitPrice' => array_key_exists('unitPrice', $serviceData) ? $serviceData['unitPrice'] : null,
|
|
'participantCount' => (int) ($serviceData['participantCount'] ?? 0),
|
|
'totalPrice' => (float) ($serviceData['totalPrice'] ?? 0.0),
|
|
'subType' => $serviceData['subType'] ?? null,
|
|
];
|
|
}
|
|
}
|