Phase1 Bearbeitung

This commit is contained in:
2026-06-15 18:36:57 +02:00
parent e4fa714419
commit 06645f1e9c
29 changed files with 3367 additions and 300 deletions
+408
View File
@@ -0,0 +1,408 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class ExportService
{
private const CSV_DELIMITER = ';';
private const FLUSH_EVERY_ROWS = 250;
public function __construct(private readonly PDO $pdo)
{
}
public function streamMemberBalancesCsv(int $tenantId, ?string $filename = null): void
{
$this->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 Tenant-ID muss groesser 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('CSV-Header koennen 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('php://output konnte nicht geoeffnet 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 CSV-Zeile 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();
}
}