assertTenantId($tenantId); $statement = $this->pdo->prepare( 'SELECT m.id AS member_id, m.display_name, m.email, m.status, m.created_at, COALESCE(balance_summary.balance_cents, 0) AS balance_cents, balance_summary.last_occurred_at FROM members m LEFT JOIN ( SELECT ll.member_id, SUM(ll.amount_cents) AS balance_cents, MAX(le.occurred_at) AS last_occurred_at FROM ledger_lines ll INNER JOIN ledger_entries le ON le.id = ll.ledger_entry_id AND le.tenant_id = ll.tenant_id WHERE ll.tenant_id = :balance_tenant_id AND ll.account_code = "member_balance" AND ll.member_id IS NOT NULL GROUP BY ll.member_id ) balance_summary ON balance_summary.member_id = m.id WHERE m.tenant_id = :tenant_id ORDER BY m.display_name ASC, m.id ASC' ); $statement->execute([ 'balance_tenant_id' => $tenantId, 'tenant_id' => $tenantId, ]); $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId)); try { $this->writeCsvRow($handle, [ 'mitglied_id', 'anzeigename', 'email', 'status', 'saldo_cents', 'saldo_eur', 'letzte_buchung_am', 'angelegt_am', ]); $rowCount = 0; while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { $balanceCents = (int) $row['balance_cents']; $this->writeCsvRow($handle, [ (int) $row['member_id'], (string) $row['display_name'], $row['email'], (string) $row['status'], $balanceCents, $this->formatMoneyFromCents($balanceCents), $row['last_occurred_at'], (string) $row['created_at'], ]); $rowCount++; $this->flushIfNeeded($rowCount); } } finally { fclose($handle); } } public function streamBookingsCsv( int $tenantId, ?int $memberId = null, ?string $from = null, ?string $until = null, ?string $filename = null ): void { $this->assertTenantId($tenantId); if ($memberId !== null && $memberId <= 0) { throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.'); } $from = $this->normalizeDateTimeFilter($from, false); $until = $this->normalizeDateTimeFilter($until, true); $sql = 'SELECT le.id AS booking_id, le.type, le.reference_type, le.reference_id, le.description, le.occurred_at, le.created_at AS entry_created_at, le.member_id, m.display_name AS member_name, s.code AS source_code, s.name AS source_name, COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents, COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents, COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents, c.id AS consumption_event_id, c.quantity, c.unit_price_cents, c.total_cents, c.effective_at, c.recorded_at, c.source_reference, c.notes, c.paper_sheet_line_id, p.id AS product_id, p.name AS product_name FROM ledger_entries le LEFT JOIN members m ON m.id = le.member_id AND m.tenant_id = le.tenant_id LEFT JOIN capture_sources s ON s.id = le.source_id AND s.tenant_id = le.tenant_id LEFT JOIN ( SELECT ledger_entry_id, SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents, SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents, SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents FROM ledger_lines WHERE tenant_id = :line_tenant_id GROUP BY ledger_entry_id ) line_totals ON line_totals.ledger_entry_id = le.id LEFT JOIN consumption_events c ON c.id = le.reference_id AND c.tenant_id = le.tenant_id AND le.reference_type = "consumption_event" LEFT JOIN products p ON p.id = c.product_id AND p.tenant_id = c.tenant_id WHERE le.tenant_id = :tenant_id'; $params = [ 'line_tenant_id' => $tenantId, 'tenant_id' => $tenantId, ]; if ($memberId !== null) { $sql .= ' AND le.member_id = :member_id'; $params['member_id'] = $memberId; } if ($from !== null) { $sql .= ' AND le.occurred_at >= :from_occurred_at'; $params['from_occurred_at'] = $from; } if ($until !== null) { $sql .= ' AND le.occurred_at <= :until_occurred_at'; $params['until_occurred_at'] = $until; } $sql .= ' ORDER BY le.occurred_at DESC, le.id DESC'; $statement = $this->pdo->prepare($sql); $statement->execute($params); $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId)); try { $this->writeCsvRow($handle, [ 'buchung_id', 'typ', 'buchungsdatum', 'mitglied_id', 'mitglied_name', 'beschreibung', 'quellcode', 'quelle', 'referenz_typ', 'referenz_id', 'saldo_delta_cents', 'saldo_delta_eur', 'kasse_delta_cents', 'kasse_delta_eur', 'umsatz_delta_cents', 'umsatz_delta_eur', 'produkt_id', 'produkt_name', 'menge', 'einzelpreis_cents', 'einzelpreis_eur', 'gesamt_cents', 'gesamt_eur', 'notizen', 'wirksam_am', 'erfasst_am', 'quellreferenz', 'papierlisten_zeile_id', 'angelegt_am', ]); $rowCount = 0; while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { $memberBalanceCents = (int) $row['member_balance_cents']; $cashboxCents = (int) $row['cashbox_cents']; $coffeeRevenueCents = (int) $row['coffee_revenue_cents']; $unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null; $totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null; $recordedAt = $row['recorded_at'] ?? $row['entry_created_at']; $this->writeCsvRow($handle, [ (int) $row['booking_id'], (string) $row['type'], (string) $row['occurred_at'], $row['member_id'] !== null ? (int) $row['member_id'] : '', $row['member_name'], (string) $row['description'], $row['source_code'], $row['source_name'], $row['reference_type'], $row['reference_id'], $memberBalanceCents, $this->formatMoneyFromCents($memberBalanceCents), $cashboxCents, $this->formatMoneyFromCents($cashboxCents), $coffeeRevenueCents, $this->formatMoneyFromCents($coffeeRevenueCents), $row['product_id'] !== null ? (int) $row['product_id'] : '', $row['product_name'], $this->formatQuantity($row['quantity']), $unitPriceCents ?? '', $unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '', $totalCents ?? '', $totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '', $row['notes'], $row['effective_at'] ?? $row['occurred_at'], $recordedAt, $row['source_reference'], $row['paper_sheet_line_id'], (string) $row['entry_created_at'], ]); $rowCount++; $this->flushIfNeeded($rowCount); } } finally { fclose($handle); } } private function assertTenantId(int $tenantId): void { if ($tenantId <= 0) { throw new RuntimeException('Die Standort-ID muss größer als 0 sein.'); } } private function defaultFilename(string $prefix, int $tenantId): string { return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His')); } private function openCsvOutput(string $filename) { if (headers_sent($file, $line)) { throw new RuntimeException(sprintf('Export-Header können nicht mehr gesendet werden (%s:%d).', $file, $line)); } $safeFilename = $this->sanitizeFilename($filename); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename)); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); header('X-Accel-Buffering: no'); header('X-Content-Type-Options: nosniff'); $handle = fopen('php://output', 'wb'); if ($handle === false) { throw new RuntimeException('Die Exportausgabe konnte nicht geöffnet werden.'); } fwrite($handle, "\xEF\xBB\xBF"); return $handle; } private function sanitizeFilename(string $filename): string { $sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? ''; $sanitized = trim($sanitized, '.-'); if ($sanitized === '') { $sanitized = 'export'; } if (!str_ends_with(strtolower($sanitized), '.csv')) { $sanitized .= '.csv'; } return $sanitized; } private function writeCsvRow($handle, array $row): void { $values = array_map(function (mixed $value): string { return $this->sanitizeCsvValue($value); }, $row); if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) { throw new RuntimeException('Die Exportzeile konnte nicht geschrieben werden.'); } } private function sanitizeCsvValue(mixed $value): string { if ($value === null) { return ''; } if (is_bool($value)) { return $value ? '1' : '0'; } $string = str_replace(["\r\n", "\r"], "\n", (string) $value); // Prevent spreadsheet formula execution for user-controlled text cells. if ( $string !== '' && in_array($string[0], ['=', '+', '-', '@', "\t"], true) && !is_numeric($string) ) { return "'" . $string; } return $string; } private function formatMoneyFromCents(int $amountCents): string { return number_format($amountCents / 100, 2, ',', '.'); } private function formatQuantity(mixed $quantity): string { if ($quantity === null || $quantity === '') { return ''; } return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.'); } private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string { $value = trim((string) $value); if ($value === '') { return null; } $value = str_replace('T', ' ', $value); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); } if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) { return $value . ':00'; } return $value; } private function flushIfNeeded(int $rowCount): void { if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) { return; } if (ob_get_level() > 0) { ob_flush(); } flush(); } }