Phase1 Bearbeitung
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user