140 lines
5.3 KiB
PHP
140 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ValueObjects;
|
|
|
|
/**
|
|
* Wie ist der CSV-Export der Bank aufgebaut? Trennzeichen, Anführungszeichen, Zeichensatz, Zahl- und
|
|
* Datumsformat sowie die Zuordnung fachliches Feld -> Spaltenüberschrift.
|
|
*
|
|
* Zahlartneutral und damit bewusst außerhalb von {@see \App\EventPaymentModules}: Überweisung und
|
|
* (künftig) SEPA-Lastschrift lesen denselben Kontoauszug, nur werten sie ihn verschieden aus.
|
|
*
|
|
* Ein Tenant überschreibt das Ruleset **ganz oder gar nicht** ({@see fromConfiguration()}). Ein
|
|
* feldweiser Merge wäre die schlechtere Zusage: wer das Trennzeichen umstellt, weil seine Bank ein
|
|
* anderes Format liefert, bekäme sonst weiterhin die Spaltennamen der GLS untergeschoben und suchte
|
|
* den Fehler an der falschen Stelle.
|
|
*/
|
|
final readonly class BankStatementRuleset
|
|
{
|
|
/** Fachliche Felder, ohne die sich eine Zahlung nicht verarbeiten lässt. */
|
|
public const array REQUIRED_COLUMNS = ['payment_date', 'purpose', 'amount'];
|
|
|
|
/** Auswählbare Zeichensätze; 'auto' probiert UTF-8 und fällt sonst auf Windows-1252 zurück. */
|
|
public const array CHARSETS = ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15'];
|
|
|
|
/**
|
|
* @param array<string, string> $columns fachliches Feld => Spaltenüberschrift
|
|
*/
|
|
public function __construct(
|
|
public string $delimiter,
|
|
public string $enclosure,
|
|
public string $charset,
|
|
public bool $hasHeader,
|
|
public string $dateFormat,
|
|
public string $decimalSeparator,
|
|
public string $thousandsSeparator,
|
|
public array $columns,
|
|
) {
|
|
}
|
|
|
|
/** Der app-weite Standard aus config/bankStatement.php. */
|
|
public static function default(): self
|
|
{
|
|
return self::fromArray((array) config('bankStatement.default_ruleset', []));
|
|
}
|
|
|
|
/**
|
|
* Ruleset aus einer Modul-Konfiguration. Leerer/fehlender Override -> App-Standard.
|
|
*
|
|
* @param array<string, mixed>|null $override
|
|
*/
|
|
public static function fromConfiguration(?array $override): self
|
|
{
|
|
if ($override === null || $override === []) {
|
|
return self::default();
|
|
}
|
|
|
|
return self::fromArray($override);
|
|
}
|
|
|
|
/**
|
|
* Einzelne fehlende Schlüssel werden aus dem Config-Standard ergänzt -- das ist kein Widerspruch
|
|
* zum „ganz oder gar nicht": greift nur, wenn überhaupt ein Override existiert, und verhindert,
|
|
* dass ein unvollständig gespeichertes Formular einen leeren Delimiter erzeugt.
|
|
*
|
|
* @param array<string, mixed> $values
|
|
*/
|
|
private static function fromArray(array $values): self
|
|
{
|
|
$fallback = (array) config('bankStatement.default_ruleset', []);
|
|
|
|
$columns = (array) ($values['columns'] ?? $fallback['columns'] ?? []);
|
|
$columns = array_filter(
|
|
array_map(static fn ($column): string => trim((string) $column), $columns),
|
|
static fn (string $column): bool => $column !== '',
|
|
);
|
|
|
|
$charset = (string) ($values['charset'] ?? $fallback['charset'] ?? 'auto');
|
|
|
|
return new self(
|
|
delimiter: self::firstNonEmpty($values['delimiter'] ?? null, $fallback['delimiter'] ?? null, ';'),
|
|
// Der Leerstring ist hier ein gültiger Wert (keine Anführungszeichen) und darf nicht
|
|
// durch einen Fallback ersetzt werden.
|
|
enclosure: (string) ($values['enclosure'] ?? $fallback['enclosure'] ?? ''),
|
|
charset: in_array($charset, self::CHARSETS, true) ? $charset : 'auto',
|
|
hasHeader: (bool) ($values['has_header'] ?? $fallback['has_header'] ?? true),
|
|
dateFormat: self::firstNonEmpty($values['date_format'] ?? null, $fallback['date_format'] ?? null, 'd.m.Y'),
|
|
decimalSeparator: self::firstNonEmpty($values['decimal_separator'] ?? null, $fallback['decimal_separator'] ?? null, ','),
|
|
thousandsSeparator: (string) ($values['thousands_separator'] ?? $fallback['thousands_separator'] ?? '.'),
|
|
columns: $columns,
|
|
);
|
|
}
|
|
|
|
private static function firstNonEmpty(mixed ...$candidates): string
|
|
{
|
|
foreach ($candidates as $candidate) {
|
|
if ($candidate !== null && (string) $candidate !== '') {
|
|
return (string) $candidate;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/** Spaltenüberschrift für ein fachliches Feld, oder null wenn nicht gemappt. */
|
|
public function column(string $field): ?string
|
|
{
|
|
return $this->columns[$field] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Pflichtfelder, für die keine Spalte gemappt ist.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public function missingRequiredColumns(): array
|
|
{
|
|
return array_values(array_filter(
|
|
self::REQUIRED_COLUMNS,
|
|
fn (string $field): bool => $this->column($field) === null,
|
|
));
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'delimiter' => $this->delimiter,
|
|
'enclosure' => $this->enclosure,
|
|
'charset' => $this->charset,
|
|
'has_header' => $this->hasHeader,
|
|
'date_format' => $this->dateFormat,
|
|
'decimal_separator' => $this->decimalSeparator,
|
|
'thousands_separator' => $this->thousandsSeparator,
|
|
'columns' => $this->columns,
|
|
];
|
|
}
|
|
}
|