Files
fotobox-webspite/src/Services/BookingService.php
T
2026-05-05 19:18:05 +02:00

732 lines
27 KiB
PHP

<?php
declare(strict_types=1);
final class BookingService
{
private const BLOCKING_STATUSES = ['requested', 'reserved', 'confirmed'];
public function __construct(
private readonly RecordRepositoryInterface $bookingRepository,
private readonly RecordRepositoryInterface $invoiceRepository,
private readonly array $config,
) {
}
public function createPublicBooking(array $input): array
{
$payload = $this->normalizeBookingInput($input, false);
return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
$this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
$booking = $this->buildBookingRecord($payload, 'customer_form');
array_unshift($records, $booking);
return $booking;
});
}
public function createAdminBooking(array $input): array
{
$payload = $this->normalizeBookingInput($input, true);
return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
if ($this->isBlockingStatus($payload['status'])) {
$this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
}
$booking = $this->buildBookingRecord($payload, 'admin_manual');
array_unshift($records, $booking);
return $booking;
});
}
public function updateBooking(string $bookingId, array $input): array
{
return $this->bookingRepository->transaction(function (array &$records) use ($bookingId, $input): array {
$index = $this->findBookingIndex($records, $bookingId);
if ($index === null) {
throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
}
$booking = $records[$index];
$status = (string) ($input['status'] ?? $booking['status']);
$paymentStatus = (string) ($input['payment_status'] ?? $booking['payment_status']);
if (!array_key_exists($status, $this->getStatusOptions())) {
throw new RuntimeException('Der gewählte Status ist ungültig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
throw new RuntimeException('Der gewählte Zahlungsstatus ist ungültig.');
}
if ($this->isBlockingStatus($status)) {
$this->assertAvailability($records, $booking['start_date'], $booking['end_date'], $booking['id']);
}
$booking['status'] = $status;
$booking['status_label'] = $this->getStatusOptions()[$status];
$booking['payment_status'] = $paymentStatus;
$booking['payment_status_label'] = $this->getPaymentStatusOptions()[$paymentStatus];
$booking['internal_notes'] = trim((string) ($input['internal_notes'] ?? $booking['internal_notes']));
$booking['notes_customer'] = trim((string) ($input['notes_customer'] ?? $booking['notes_customer']));
$booking['updated_at'] = gmdate('c');
$records[$index] = $booking;
return $booking;
});
}
public function createInvoiceForBooking(string $bookingId, array $input): string
{
$booking = $this->findBooking($bookingId);
if ($booking === null) {
throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
}
if (!in_array($booking['status'], ['reserved', 'confirmed', 'completed'], true)) {
throw new RuntimeException('Für diesen Auftrag kann noch keine Rechnung erstellt werden.');
}
if (!empty($booking['invoice_id'])) {
return $booking['invoice_id'];
}
$invoice = $this->invoiceRepository->transaction(function (array &$records) use ($booking, $input): array {
$issueDate = gmdate('Y-m-d');
$dueDate = (new DateTimeImmutable($issueDate))->modify('+14 days')->format('Y-m-d');
$invoiceNumber = $this->nextInvoiceNumber($records);
$invoiceId = $this->generateId('inv');
$company = $this->config['company'];
$lineItems = [
[
'label' => 'Fotobox-Miete ' . formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']) . ' (' . $booking['total_days'] . ' Miettag' . ($booking['total_days'] === 1 ? '' : 'e') . ')',
'quantity' => $booking['total_days'],
'unit_price_cents' => $booking['price_per_day_cents'],
'total_cents' => $booking['subtotal_cents'],
],
];
$invoice = [
'id' => $invoiceId,
'invoice_number' => $invoiceNumber,
'booking_id' => $booking['id'],
'status' => 'issued',
'status_label' => 'Ausgestellt',
'issue_date' => $issueDate,
'due_date' => (string) ($input['due_date'] ?? $dueDate),
'created_at' => gmdate('c'),
'currency' => $this->config['pricing']['currency'],
'company' => $company,
'customer_snapshot' => $booking['customer'],
'payment_method' => $booking['payment_method'],
'payment_method_label' => $booking['payment_method_label'],
'line_items' => $lineItems,
'subtotal_cents' => $booking['subtotal_cents'],
'total_cents' => $booking['subtotal_cents'],
'notes' => trim((string) ($input['invoice_notes'] ?? 'Vielen Dank für deinen Auftrag.')),
];
array_unshift($records, $invoice);
return $invoice;
});
$this->bookingRepository->transaction(function (array &$records) use ($bookingId, $invoice): void {
$index = $this->findBookingIndex($records, $bookingId);
if ($index === null) {
throw new RuntimeException('Der Auftrag wurde beim Verknüpfen der Rechnung nicht gefunden.');
}
$records[$index]['invoice_id'] = $invoice['id'];
if ($records[$index]['status'] === 'requested') {
$records[$index]['status'] = 'confirmed';
$records[$index]['status_label'] = $this->getStatusOptions()['confirmed'];
}
$records[$index]['updated_at'] = gmdate('c');
});
return $invoice['id'];
}
public function getBookings(): array
{
$bookings = $this->bookingRepository->all();
usort($bookings, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
return $bookings;
}
public function getInvoices(): array
{
$invoices = $this->invoiceRepository->all();
usort($invoices, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
return $invoices;
}
public function getBookingsByStatuses(array $statuses): array
{
$bookings = array_values(array_filter(
$this->getBookings(),
static fn(array $booking): bool => in_array((string) $booking['status'], $statuses, true)
));
usort($bookings, static function (array $a, array $b): int {
$dateComparison = strcmp($a['start_date'], $b['start_date']);
if ($dateComparison !== 0) {
return $dateComparison;
}
return strcmp($a['created_at'], $b['created_at']);
});
return $bookings;
}
public function getHighlightedBookings(): array
{
$bookings = array_values(array_filter(
$this->getBookings(),
fn(array $booking): bool => $this->isBlockingStatus($booking['status'])
));
usort($bookings, static fn(array $a, array $b): int => strcmp($a['start_date'], $b['start_date']));
return array_slice($bookings, 0, 4);
}
public function getPublicAvailabilityBookings(): array
{
$today = gmdate('Y-m-d');
return array_values(array_filter(
$this->getBookingsByStatuses(['requested', 'reserved', 'confirmed']),
static fn(array $booking): bool => (string) $booking['end_date'] >= $today
));
}
public function getPublicCalendarMonths(int $monthCount = 4): array
{
$months = [];
$monthCount = max(1, $monthCount);
$today = gmdate('Y-m-d');
$bookings = $this->getPublicAvailabilityBookings();
$monthCursor = (new DateTimeImmutable('first day of this month'))->setTime(0, 0);
for ($index = 0; $index < $monthCount; $index++) {
$monthStart = $monthCursor->modify('+' . $index . ' month');
$monthEnd = $monthStart->modify('first day of next month');
$monthKey = $monthStart->format('Y-m');
$entries = array_values(array_filter(
$bookings,
fn(array $booking): bool => $this->bookingIntersectsRange(
$booking,
$monthStart->format('Y-m-d'),
$monthEnd->format('Y-m-d')
)
));
$days = [];
for ($blank = 1; $blank < (int) $monthStart->format('N'); $blank++) {
$days[] = ['is_padding' => true];
}
$dayCursor = $monthStart;
while ($dayCursor < $monthEnd) {
$date = $dayCursor->format('Y-m-d');
$dayBooking = $this->findBookingForDate($entries, $date);
$days[] = [
'is_padding' => false,
'date' => $date,
'day' => (int) $dayCursor->format('j'),
'is_today' => $date === $today,
'is_booked' => $dayBooking !== null,
'status' => $dayBooking['status'] ?? '',
'status_label' => $dayBooking['status_label'] ?? '',
];
$dayCursor = $dayCursor->modify('+1 day');
}
while (count($days) % 7 !== 0) {
$days[] = ['is_padding' => true];
}
$months[] = [
'label' => $this->formatMonthLabel($monthKey),
'month_key' => $monthKey,
'entry_count' => count($entries),
'days' => $days,
'entries' => array_map(
fn(array $booking): array => $this->buildPublicCalendarEntry($booking),
$entries
),
];
}
return $months;
}
public function getDashboardStats(): array
{
$bookings = $this->getBookings();
$invoices = $this->getInvoices();
$activeRevenue = 0;
$openRequests = 0;
$confirmed = 0;
foreach ($bookings as $booking) {
if (in_array($booking['status'], ['requested', 'reserved'], true)) {
$openRequests++;
}
if (in_array($booking['status'], ['confirmed', 'completed'], true)) {
$confirmed++;
$activeRevenue += $booking['subtotal_cents'];
}
}
$unpaidInvoices = 0;
foreach ($invoices as $invoice) {
if ($invoice['status'] !== 'paid') {
$unpaidInvoices++;
}
}
return [
'bookings_total' => count($bookings),
'open_requests' => $openRequests,
'confirmed_bookings' => $confirmed,
'revenue_cents' => $activeRevenue,
'invoice_total' => count($invoices),
'invoice_open' => $unpaidInvoices,
];
}
public function getCalendarGroups(): array
{
$groups = [];
foreach ($this->getBookingsByStatuses(['requested', 'reserved', 'confirmed', 'completed']) as $booking) {
$monthKey = substr((string) $booking['start_date'], 0, 7);
$label = $this->formatMonthLabel($monthKey);
$groups[$label][] = $booking;
}
return $groups;
}
public function getCustomers(): array
{
$customers = [];
foreach ($this->getBookings() as $booking) {
$customer = $booking['customer'];
$key = strtolower(trim((string) $customer['email'])) . '|' . strtolower(trim((string) $customer['phone']));
if (!isset($customers[$key])) {
$customers[$key] = [
'name' => $customer['name'],
'company' => $customer['company'],
'email' => $customer['email'],
'phone' => $customer['phone'],
'city' => $customer['city'],
'booking_count' => 0,
'revenue_cents' => 0,
'last_booking_date' => $booking['start_date'],
'last_reference' => $booking['reference'],
'last_status_label' => $booking['status_label'],
];
}
$customers[$key]['booking_count']++;
$customers[$key]['revenue_cents'] += (int) $booking['subtotal_cents'];
if ($booking['start_date'] >= $customers[$key]['last_booking_date']) {
$customers[$key]['last_booking_date'] = $booking['start_date'];
$customers[$key]['last_reference'] = $booking['reference'];
$customers[$key]['last_status_label'] = $booking['status_label'];
}
}
usort($customers, static function (array $a, array $b): int {
$nameComparison = strcmp($a['name'], $b['name']);
if ($nameComparison !== 0) {
return $nameComparison;
}
return strcmp($a['email'], $b['email']);
});
return $customers;
}
public function getAdminDefaults(): array
{
return [
'price_per_day_cents' => $this->config['pricing']['default_day_rate_cents'],
'status' => 'confirmed',
'payment_status' => 'unpaid',
'payment_method' => 'invoice_transfer',
'delivery_mode' => 'self_pickup',
'delivery_zone' => 'self_pickup',
];
}
public function findBooking(string $bookingId): ?array
{
return $this->bookingRepository->find($bookingId);
}
public function findInvoice(string $invoiceId): ?array
{
return $this->invoiceRepository->find($invoiceId);
}
public function getStatusOptions(): array
{
return [
'requested' => 'Neue Anfrage',
'reserved' => 'Reserviert',
'confirmed' => 'Bestätigt',
'completed' => 'Abgeschlossen',
'cancelled' => 'Storniert',
];
}
public function getPaymentStatusOptions(): array
{
return [
'unpaid' => 'Offen',
'paid' => 'Bezahlt',
'refunded' => 'Erstattet',
];
}
public function getDeliveryModeOptions(): array
{
return [
'self_pickup' => 'Selbstabholung',
'delivery_setup' => 'Lieferung und Aufbau',
'on_site_support' => 'Lieferung, Aufbau und Vor-Ort-Betreuung',
];
}
public function getDeliveryZoneOptions(): array
{
return $this->config['pricing']['delivery_rates'] ?? [];
}
private function normalizeBookingInput(array $input, bool $adminMode): array
{
$customerName = trim((string) ($input['customer_name'] ?? ''));
$email = trim((string) ($input['email'] ?? ''));
$phone = trim((string) ($input['phone'] ?? ''));
$street = trim((string) ($input['street'] ?? ''));
$postalCode = trim((string) ($input['postal_code'] ?? ''));
$city = trim((string) ($input['city'] ?? ''));
$eventType = trim((string) ($input['event_type'] ?? ''));
$eventLocation = trim((string) ($input['event_location'] ?? ''));
$company = trim((string) ($input['company'] ?? ''));
$startDate = trim((string) ($input['start_date'] ?? ''));
$endDate = trim((string) ($input['end_date'] ?? ''));
$paymentMethod = trim((string) ($input['payment_method'] ?? 'invoice_transfer'));
$deliveryMode = trim((string) ($input['delivery_mode'] ?? 'self_pickup'));
$deliveryZone = trim((string) ($input['delivery_zone'] ?? 'self_pickup'));
$notesCustomer = trim((string) ($input['notes_customer'] ?? ''));
$internalNotes = trim((string) ($input['internal_notes'] ?? ''));
$status = trim((string) ($input['status'] ?? ($adminMode ? 'confirmed' : 'requested')));
$paymentStatus = trim((string) ($input['payment_status'] ?? 'unpaid'));
$privacyAccepted = (string) ($input['privacy_accepted'] ?? '') === '1';
$termsAccepted = (string) ($input['terms_accepted'] ?? '') === '1';
foreach ([
'Name' => $customerName,
'E-Mail' => $email,
'Telefon' => $phone,
'Straße' => $street,
'PLZ' => $postalCode,
'Ort' => $city,
'Abholdatum' => $startDate,
'Rückgabedatum' => $endDate,
] as $label => $value) {
if ($value === '') {
throw new RuntimeException($label . ' ist ein Pflichtfeld.');
}
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Bitte gib eine gültige E-Mail-Adresse an.');
}
if (!$adminMode && !$privacyAccepted) {
throw new RuntimeException('Bitte bestätige die Datenschutzerklärung.');
}
if (!$adminMode && !$termsAccepted) {
throw new RuntimeException('Bitte bestätige die Mietbedingungen.');
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
throw new RuntimeException('Bitte wähle gültige Mietdaten aus.');
}
$totalDays = $this->calculateRentalDays($startDate, $endDate);
if ($totalDays < 1) {
throw new RuntimeException('Die Rückgabe muss nach der Abholung liegen. Ein Miettag entspricht zum Beispiel Montag auf Dienstag.');
}
if (!array_key_exists($status, $this->getStatusOptions())) {
throw new RuntimeException('Der Buchungsstatus ist ungültig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
throw new RuntimeException('Der Zahlungsstatus ist ungültig.');
}
$paymentLabels = [
'invoice_transfer' => 'Rechnung / Überweisung',
'paypal' => 'PayPal',
];
$deliveryLabels = $this->getDeliveryModeOptions();
$deliveryZones = $this->getDeliveryZoneOptions();
if (!array_key_exists($paymentMethod, $paymentLabels)) {
throw new RuntimeException('Die gewählte Zahlungsart ist ungültig.');
}
if (!array_key_exists($deliveryMode, $deliveryLabels)) {
throw new RuntimeException('Die gewählte Lieferart ist ungültig.');
}
if (!array_key_exists($deliveryZone, $deliveryZones)) {
throw new RuntimeException('Bitte wähle ein gültiges Liefergebiet aus.');
}
if ($deliveryMode === 'self_pickup') {
$deliveryZone = 'self_pickup';
} elseif ($deliveryZone === 'self_pickup') {
throw new RuntimeException('Bitte wähle für die Lieferung ein passendes Liefergebiet aus.');
}
$pricePerDay = $this->resolveRateForSelection($deliveryMode, $deliveryZone);
if ($pricePerDay < 0) {
throw new RuntimeException('Der Tagespreis ist ungültig.');
}
return [
'customer_name' => $customerName,
'company' => $company,
'email' => $email,
'phone' => $phone,
'street' => $street,
'postal_code' => $postalCode,
'city' => $city,
'event_type' => $eventType,
'event_location' => $eventLocation,
'start_date' => $startDate,
'end_date' => $endDate,
'total_days' => $totalDays,
'payment_method' => $paymentMethod,
'payment_method_label' => $paymentLabels[$paymentMethod],
'delivery_mode' => $deliveryMode,
'delivery_mode_label' => $deliveryLabels[$deliveryMode],
'delivery_zone' => $deliveryZone,
'delivery_zone_label' => $deliveryZones[$deliveryZone]['label'],
'notes_customer' => $notesCustomer,
'internal_notes' => $internalNotes,
'status' => $status,
'status_label' => $this->getStatusOptions()[$status],
'payment_status' => $paymentStatus,
'payment_status_label' => $this->getPaymentStatusOptions()[$paymentStatus],
'price_per_day_cents' => $pricePerDay,
'subtotal_cents' => $totalDays * $pricePerDay,
'privacy_accepted' => $privacyAccepted,
'terms_accepted' => $termsAccepted,
];
}
private function buildBookingRecord(array $payload, string $source): array
{
$now = gmdate('c');
return [
'id' => $this->generateId('ord'),
'reference' => 'FB-' . gmdate('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'source' => $source,
'status' => $payload['status'],
'status_label' => $payload['status_label'],
'payment_status' => $payload['payment_status'],
'payment_status_label' => $payload['payment_status_label'],
'payment_method' => $payload['payment_method'],
'payment_method_label' => $payload['payment_method_label'],
'delivery_mode' => $payload['delivery_mode'],
'delivery_mode_label' => $payload['delivery_mode_label'],
'delivery_zone' => $payload['delivery_zone'],
'delivery_zone_label' => $payload['delivery_zone_label'],
'start_date' => $payload['start_date'],
'end_date' => $payload['end_date'],
'total_days' => $payload['total_days'],
'price_per_day_cents' => $payload['price_per_day_cents'],
'subtotal_cents' => $payload['subtotal_cents'],
'notes_customer' => $payload['notes_customer'],
'internal_notes' => $payload['internal_notes'],
'invoice_id' => null,
'created_at' => $now,
'updated_at' => $now,
'customer_consents' => [
'privacy_accepted' => $payload['privacy_accepted'],
'terms_accepted' => $payload['terms_accepted'],
'recorded_at' => $now,
],
'customer' => [
'name' => $payload['customer_name'],
'company' => $payload['company'],
'email' => $payload['email'],
'phone' => $payload['phone'],
'street' => $payload['street'],
'postal_code' => $payload['postal_code'],
'city' => $payload['city'],
'event_type' => $payload['event_type'],
'event_location' => $payload['event_location'],
],
];
}
private function assertAvailability(array $records, string $startDate, string $endDate, ?string $ignoreId = null): void
{
foreach ($records as $record) {
if ($ignoreId !== null && $record['id'] === $ignoreId) {
continue;
}
if (!$this->isBlockingStatus((string) $record['status'])) {
continue;
}
if ($startDate < $record['end_date'] && $endDate > $record['start_date']) {
throw new RuntimeException(
'Die Fotobox ist im gewählten Zeitraum bereits blockiert. Bitte wähle einen anderen Termin.'
);
}
}
}
private function calculateRentalDays(string $startDate, string $endDate): int
{
$start = new DateTimeImmutable($startDate);
$end = new DateTimeImmutable($endDate);
return (int) $start->diff($end)->format('%r%a');
}
private function nextInvoiceNumber(array $records): string
{
$year = gmdate('Y');
$count = 0;
foreach ($records as $record) {
if (str_starts_with((string) ($record['invoice_number'] ?? ''), 'RE-' . $year . '-')) {
$count++;
}
}
return sprintf('RE-%s-%04d', $year, $count + 1);
}
private function findBookingIndex(array $records, string $bookingId): ?int
{
foreach ($records as $index => $record) {
if (($record['id'] ?? null) === $bookingId) {
return $index;
}
}
return null;
}
private function isBlockingStatus(string $status): bool
{
return in_array($status, self::BLOCKING_STATUSES, true);
}
private function generateId(string $prefix): string
{
return $prefix . '_' . strtolower(bin2hex(random_bytes(6)));
}
private function resolveRateForSelection(string $deliveryMode, string $deliveryZone): int
{
$zones = $this->getDeliveryZoneOptions();
if ($deliveryMode === 'self_pickup') {
return (int) ($zones['self_pickup']['price_cents'] ?? $this->config['pricing']['default_day_rate_cents']);
}
return (int) ($zones[$deliveryZone]['price_cents'] ?? $this->config['pricing']['default_day_rate_cents']);
}
private function bookingIntersectsRange(array $booking, string $rangeStart, string $rangeEnd): bool
{
return $booking['start_date'] < $rangeEnd && $booking['end_date'] > $rangeStart;
}
private function findBookingForDate(array $bookings, string $date): ?array
{
foreach ($bookings as $booking) {
if ($booking['start_date'] <= $date && $booking['end_date'] > $date) {
return $booking;
}
}
return null;
}
private function buildPublicCalendarEntry(array $booking): array
{
$deliveryLabel = (string) ($booking['delivery_zone_label'] ?: $booking['delivery_mode_label']);
return [
'date_label' => formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']),
'status' => $booking['status'],
'status_label' => $booking['status_label'],
'delivery_label' => $deliveryLabel,
'day_count_label' => $booking['total_days'] . ' Miettag' . ($booking['total_days'] === 1 ? '' : 'e'),
];
}
private function formatMonthLabel(string $monthKey): string
{
[$year, $month] = explode('-', $monthKey) + [null, null];
$monthNumber = (int) $month;
$monthLabels = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
if (!isset($monthLabels[$monthNumber]) || $year === null) {
return $monthKey;
}
return $monthLabels[$monthNumber] . ' ' . $year;
}
}