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; } }