release 1.0
This commit is contained in:
@@ -0,0 +1,743 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Transformiert Spalten gemäß Konfiguration
|
||||
*
|
||||
* Unterstützte Transformationstypen (canonical names):
|
||||
* - map: Spalte kopieren/umbenennen (Standard)
|
||||
* - replace: String-Replacement (str_replace)
|
||||
* - regex: Regex-Replace mit preg_replace (Backreferenzen: $1, $2 …)
|
||||
* - dateformat: Datum-Formatierung
|
||||
* - split: Spalte bei Delimiter teilen
|
||||
* - regexextract: Mit Regex extrahieren
|
||||
* - trim: Whitespace entfernen
|
||||
* - uppercase: In Grossbuchstaben umwandeln
|
||||
* - lowercase: In Kleinbuchstaben umwandeln
|
||||
* - ucwordsfirst: Ersten Buchstaben nach Worttrennern gross
|
||||
* - truncate: String auf maximale Länge kürzen
|
||||
* - constantvalue: Konstanten-Wert aus Metadaten
|
||||
* - pipeline: Mehrere Transformationen hintereinander (via steps[])
|
||||
* - custom: Custom PHP-Callback
|
||||
*
|
||||
* Unterstützte outputAction-Werte:
|
||||
* - create / overwrite: Ziel-Spalte setzen (Standard)
|
||||
* - append: Wert anhängen
|
||||
* - append-line: Wert auf neuer Zeile anhängen (kein Leerzeichen wenn Ziel leer)
|
||||
* - overwrite-if-empty: Nur setzen wenn Ziel-Spalte leer
|
||||
* - overwrite-if-not-empty: Nur setzen wenn Ergebnis nicht leer
|
||||
*/
|
||||
class ColumnTransformer
|
||||
{
|
||||
private array $transformations;
|
||||
private array $metadata;
|
||||
private array $outputColumns;
|
||||
private array $globalExceptions;
|
||||
|
||||
/**
|
||||
* Initialisiert ColumnTransformer mit Transformationsregeln
|
||||
*
|
||||
* @param array $transformations Transformationskonfiguration aus config.json
|
||||
* @param array $metadata Extrahierte Metadaten aus CSV-Header
|
||||
* @param array $globalExceptions Globale Ausnahmeliste für ucwordsfirst
|
||||
*/
|
||||
public function __construct(array $transformations, array $metadata = [], array $globalExceptions = [])
|
||||
{
|
||||
$this->transformations = $transformations;
|
||||
$this->metadata = $metadata;
|
||||
$this->outputColumns = [];
|
||||
$this->globalExceptions = $globalExceptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert eine einzelne Datenzeile
|
||||
*
|
||||
* Wendet alle definierten Transformationen auf die Zeile an.
|
||||
* Kann neue Spalten generieren (z.B. bei regex_extract).
|
||||
*
|
||||
* @param array $row Datenzeile mit Header-Keys als Array-Keys
|
||||
*
|
||||
* @return array Transformierte Datenzeile
|
||||
*/
|
||||
public function transformRow(array $row): array
|
||||
{
|
||||
$transformedRow = $row;
|
||||
|
||||
foreach ($this->transformations as $config) {
|
||||
// Multi-Output Detection (für split)
|
||||
if (isset($config['outputs']) && is_array($config['outputs'])) {
|
||||
// Multi-Output Transformation (z.B. split in mehrere Spalten)
|
||||
$multiOutputResult = $this->handleMultiOutputTransformation($transformedRow, $config);
|
||||
|
||||
// Merge Ergebnisse in transformedRow
|
||||
foreach ($multiOutputResult as $columnName => $value) {
|
||||
$transformedRow[$columnName] = $value;
|
||||
|
||||
// Registriere neue Spalten
|
||||
if (!in_array($columnName, $this->outputColumns)) {
|
||||
$this->outputColumns[] = $columnName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fahre mit nächster Transformation fort
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetColumn = $config['outputColumn'] ?? null;
|
||||
$sourceColumn = $config['sourceColumn'] ?? $targetColumn;
|
||||
$outputAction = strtolower($config['outputAction'] ?? 'overwrite');
|
||||
|
||||
if (empty($targetColumn)) {
|
||||
throw new \RuntimeException(
|
||||
"Transformation fehlt 'outputColumn' Feld: " . json_encode($config)
|
||||
);
|
||||
}
|
||||
|
||||
// Track output columns
|
||||
if (!in_array($targetColumn, $this->outputColumns)) {
|
||||
$this->outputColumns[] = $targetColumn;
|
||||
}
|
||||
|
||||
// Handle 'custom' type separately — it operates on the whole row
|
||||
$singleType = $this->normalizeTransformType($config['type'] ?? '');
|
||||
if ($singleType === 'custom' && empty($config['transformations'])) {
|
||||
$transformedRow = $this->transformCustom($transformedRow, $config);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get source value ('_constant_' is a virtual source with no column data)
|
||||
$sourceValue = ($sourceColumn === '_constant_') ? '' : ($transformedRow[$sourceColumn] ?? '');
|
||||
|
||||
// Apply transformation(s)
|
||||
if (!empty($config['transformations']) && is_array($config['transformations'])) {
|
||||
// Inline pipeline: array of transformation steps per column entry
|
||||
$resultValue = $sourceValue;
|
||||
foreach ($config['transformations'] as $step) {
|
||||
$resultValue = $this->applySingleTransformation($resultValue, $step);
|
||||
}
|
||||
} else {
|
||||
// Single transformation (flat canonical or legacy form)
|
||||
$resultValue = $this->applySingleTransformation($sourceValue, $config);
|
||||
}
|
||||
|
||||
// Apply output action
|
||||
switch ($outputAction) {
|
||||
case 'append':
|
||||
$transformedRow[$targetColumn] = ($transformedRow[$targetColumn] ?? '') . $resultValue;
|
||||
break;
|
||||
case 'append-line':
|
||||
// Wert auf neuer Zeile anhängen; kein führender Zeilenumbruch wenn Ziel leer
|
||||
if ($resultValue !== '') {
|
||||
$existing = $transformedRow[$targetColumn] ?? '';
|
||||
$transformedRow[$targetColumn] = $existing !== '' ? $existing . "\n" . $resultValue : $resultValue;
|
||||
}
|
||||
break;
|
||||
case 'overwrite-if-empty':
|
||||
// Nur überschreiben wenn Ziel-Spalte leer ist
|
||||
if (($transformedRow[$targetColumn] ?? '') === '') {
|
||||
$transformedRow[$targetColumn] = $resultValue;
|
||||
}
|
||||
break;
|
||||
case 'overwrite-if-not-empty':
|
||||
// Nur überschreiben wenn das Transformations-Ergebnis nicht leer ist
|
||||
if ($resultValue !== '') {
|
||||
$transformedRow[$targetColumn] = $resultValue;
|
||||
}
|
||||
break;
|
||||
case 'create':
|
||||
case 'overwrite':
|
||||
default:
|
||||
$transformedRow[$targetColumn] = $resultValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $transformedRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet eine einzelne Transformation auf einen Stringwert an
|
||||
*
|
||||
* Normalisiert den Typ-Namen (snake_case, PascalCase, no-separator alle akzeptiert)
|
||||
* und delegiert an die jeweilige transformXxx()-Methode.
|
||||
*
|
||||
* @param string $value Eingabewert
|
||||
* @param array $config Transformationskonfiguration
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function applySingleTransformation(string $value, array $config): string
|
||||
{
|
||||
$transformType = $this->normalizeTransformType($config['type'] ?? 'map');
|
||||
|
||||
switch ($transformType) {
|
||||
case 'map':
|
||||
return $value;
|
||||
|
||||
case 'replace':
|
||||
return $this->transformReplace($value, $config);
|
||||
|
||||
case 'regex':
|
||||
return $this->transformRegex($value, $config);
|
||||
|
||||
case 'dateformat':
|
||||
return $this->transformDate($value, $config);
|
||||
|
||||
case 'split':
|
||||
return $this->transformSplit($value, $config);
|
||||
|
||||
case 'regexextract':
|
||||
$extracted = $this->transformRegexExtract($value, $config);
|
||||
return $extracted ?? '';
|
||||
|
||||
case 'trim':
|
||||
return $this->transformTrim($value);
|
||||
|
||||
case 'uppercase':
|
||||
return $this->transformUppercase($value);
|
||||
|
||||
case 'lowercase':
|
||||
return $this->transformLowercase($value);
|
||||
|
||||
case 'ucwordsfirst':
|
||||
return $this->transformUcwordsFirst($value, $config);
|
||||
|
||||
case 'pipeline':
|
||||
return $this->transformPipeline($value, $config);
|
||||
|
||||
case 'truncate':
|
||||
$maxLength = (int)($config['maxLength'] ?? 255);
|
||||
return mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||
|
||||
case 'constantvalue':
|
||||
$metaKey = $config['metadataKey'] ?? '';
|
||||
return (string)($this->metadata[$metaKey] ?? '');
|
||||
|
||||
default:
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert Transformationstyp-Namen: lowercase, Trennzeichen entfernt.
|
||||
* Erlaubt z.B. dass 'dateformat' und 'dateFormat' beide funktionieren.
|
||||
*/
|
||||
private function normalizeTransformType(string $type): string
|
||||
{
|
||||
return strtolower(str_replace(['_', '-', ' '], '', $type));
|
||||
}
|
||||
|
||||
/**
|
||||
* String-Replacement Transformation
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "replace",
|
||||
* "search": "Alt",
|
||||
* "replace": "Neu"
|
||||
* ```
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformReplace(string $value, array $config): string
|
||||
{
|
||||
$search = $config['search'] ?? '';
|
||||
$replace = $config['replace'] ?? '';
|
||||
|
||||
if (empty($search)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return str_replace($search, $replace, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex-Replace Transformation
|
||||
*
|
||||
* Wendet einen regulären Ausdruck auf den Wert an und ersetzt den Treffer.
|
||||
* Backreferenz-Syntax: $1, $2 usw. im replace-String.
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "regex",
|
||||
* "pattern": "SumUp \\*+(.*)",
|
||||
* "replace": "[$1]"
|
||||
* ```
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformRegex(string $value, array $config): string
|
||||
{
|
||||
$pattern = $config['pattern'] ?? '';
|
||||
$replace = $config['replace'] ?? '';
|
||||
|
||||
if (empty($pattern)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$delimitedPattern = '#' . str_replace('#', '\#', $pattern) . '#u';
|
||||
$result = preg_replace($delimitedPattern, $replace, $value);
|
||||
|
||||
return $result ?? $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum-Format Transformation
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "date_format",
|
||||
* "fromFormat": "d.m.Y",
|
||||
* "toFormat": "Y-m-d"
|
||||
* ```
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformDate(string $value, array $config): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$fromFormat = $config['fromFormat'] ?? 'd.m.Y';
|
||||
$toFormat = $config['toFormat'] ?? 'Y-m-d';
|
||||
|
||||
try {
|
||||
$date = \DateTime::createFromFormat($fromFormat, $value);
|
||||
if ($date === false) {
|
||||
return $value;
|
||||
}
|
||||
return $date->format($toFormat);
|
||||
} catch (\Exception $e) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split Transformation
|
||||
*
|
||||
* Teilt einen Wert bei einem Delimiter und behaelt einen definierten Teil
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: "Coop Pronto Chur;7007 Chur"
|
||||
* Config: delimiter=";", part=0
|
||||
* Output: "Coop Pronto Chur"
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "split",
|
||||
* "delimiter": ";",
|
||||
* "part": 0
|
||||
* ```
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformSplit(string $value, array $config): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$delimiter = $config['delimiter'] ?? ';';
|
||||
$part = $config['part'] ?? 0;
|
||||
|
||||
$parts = explode($delimiter, $value);
|
||||
DebugLogger::log('transformation', 'Applied split transformation', [
|
||||
'input' => $value,
|
||||
'delimiter' => $delimiter,
|
||||
'part' => $part,
|
||||
'parts_count' => count($parts),
|
||||
'output' => $parts[$part] ?? null,
|
||||
]);
|
||||
|
||||
if (!isset($parts[$part])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return trim($parts[$part]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex Extract Transformation
|
||||
*
|
||||
* Extrahiert einen Teil mit Regex und erstellt neue Spalte
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: "Coop Pronto Chur;7007 Chur"
|
||||
* Config: pattern="(\d{4,} .*)"
|
||||
* Output: "7007 Chur" (in neuer Spalte "Location")
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "Location": {
|
||||
* "type": "regex_extract",
|
||||
* "sourceColumn": "Merchant/Description",
|
||||
* "pattern": "(\\d{4,} .*)"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return string|null Extrahierter Wert oder null
|
||||
*/
|
||||
private function transformRegexExtract(string $value, array $config): ?string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pattern = $config['pattern'] ?? '';
|
||||
|
||||
if (empty($pattern)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pattern = '#' . str_replace('#', '\#', $pattern) . '#';
|
||||
|
||||
if (!preg_match($pattern, $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
DebugLogger::log('transformation', 'Applied regexextract transformation', [
|
||||
'input' => $value,
|
||||
'pattern' => $pattern,
|
||||
'output' => $matches[1] ?? $matches[0] ?? null,
|
||||
]);
|
||||
|
||||
return $matches[1] ?? $matches[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim Transformation
|
||||
*
|
||||
* Entfernt Leerzeichen am Anfang und Ende eines Strings
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "trim"
|
||||
* ```
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: " Coop Pronto "
|
||||
* Output: "Coop Pronto"
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformTrim(string $value): string
|
||||
{
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase Transformation
|
||||
*
|
||||
* Wandelt einen String in Kleinbuchstaben um (UTF-8 safe)
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "lowercase"
|
||||
* ```
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: "COOP PRONTO CHUR"
|
||||
* Output: "coop pronto chur"
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformLowercase(string $value): string
|
||||
{
|
||||
return mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uppercase Transformation
|
||||
*
|
||||
* Wandelt einen String in Grossbuchstaben um (UTF-8 safe)
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "uppercase"
|
||||
* ```
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: "Coop Pronto Chur"
|
||||
* Output: "COOP PRONTO CHUR"
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformUppercase(string $value): string
|
||||
{
|
||||
return mb_strtoupper($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ucwords First Transformation
|
||||
*
|
||||
* Grossschreibung nur des ersten Buchstabens nach Worttrennern.
|
||||
* Alle anderen Buchstaben werden zu Kleinbuchstaben.
|
||||
* Funktioniert auch, wenn Input komplett in Grossbuchstaben vorliegt.
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "ucwords_first"
|
||||
* ```
|
||||
*
|
||||
* Mit Ausnahmeliste (Wörter, die exakt erhalten bleiben):
|
||||
* ```
|
||||
* "type": "ucwords_first",
|
||||
* "exceptions": ["SBB", "UBS", "AG", "GmbH"]
|
||||
* ```
|
||||
*
|
||||
* Beispiele:
|
||||
* "COOP PRONTO CHUR" → "Coop Pronto Chur"
|
||||
* "migros-rail city zuerich" → "Migros-Rail City Zuerich"
|
||||
* "O'NEILL STORE" → "O'Neill Store"
|
||||
* "SAINT-JEAN-DE-MAURIENNE" → "Saint-Jean-De-Maurienne"
|
||||
*
|
||||
* Wortgrenzen definiert durch: Leerzeichen, Bindestrich, Apostroph,
|
||||
* Slash, Punkt, Komma, Semikolon, Doppelpunkt, Klammern, Anführungszeichen
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
*
|
||||
* @return string Transformierter Wert
|
||||
*/
|
||||
private function transformUcwordsFirst(string $value, array $config = []): string
|
||||
{
|
||||
// Schritt 1: Alles zu Kleinbuchstaben
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
|
||||
// Schritt 2: Definiere Wortgrenzen (Trennzeichen)
|
||||
// Diese Zeichen markieren Grenzen, nach denen grossgeschrieben wird
|
||||
$delimiters = [
|
||||
' ', // Leerzeichen
|
||||
'-', // Bindestrich
|
||||
'\'', // Apostroph
|
||||
'/', // Slash
|
||||
'.', // Punkt
|
||||
',', // Komma
|
||||
';', // Semikolon
|
||||
':', // Doppelpunkt
|
||||
'(', // Oeffnende Klammer
|
||||
')', // Schliessende Klammer
|
||||
'[', // Oeffnende eckige Klammer
|
||||
']', // Schliessende eckige Klammer
|
||||
'{', // Oeffnende geschweifte Klammer
|
||||
'}', // Schliessende geschweifte Klammer
|
||||
'"', // Anführungszeichen
|
||||
'&', // Ampersand
|
||||
'+' // Plus
|
||||
];
|
||||
|
||||
// Schritt 3: Regex-Pattern fuer "Stringanfang ODER Delimiter, gefolgt von Buchstabe"
|
||||
// Die u-Flag ermoeglicht Unicode-Unterstaetzung (\p{L})
|
||||
$escapedDelimiters = array_map(function ($char) {
|
||||
return preg_quote($char, '/');
|
||||
}, $delimiters);
|
||||
$delimiterPattern = implode('', $escapedDelimiters);
|
||||
|
||||
$pattern = '/(^|[' . $delimiterPattern . '])(\p{L})/u';
|
||||
|
||||
// Schritt 4: Callback fuer preg_replace_callback
|
||||
// Grossschreibe den gefangenen Buchstaben (Capture Group 2)
|
||||
$callback = function (array $matches): string {
|
||||
// $matches[1] = Stringanfang oder Trennzeichen
|
||||
// $matches[2] = Buchstabe, der grossgeschrieben werden soll
|
||||
return $matches[1] . mb_strtoupper($matches[2], 'UTF-8');
|
||||
};
|
||||
|
||||
// Schritt 5: Anwende Transformation
|
||||
$result = preg_replace_callback($pattern, $callback, $value) ?? $value;
|
||||
|
||||
// Schritt 6: Ausnahmeliste anwenden (Wörter die exakt erhalten bleiben sollen, z.B. SBB, UBS, GmbH)
|
||||
$exceptions = $config['exceptions'] ?? $this->globalExceptions;
|
||||
foreach ($exceptions as $exception) {
|
||||
if (!is_string($exception) || $exception === '') {
|
||||
continue;
|
||||
}
|
||||
$exceptionPattern = '/\b' . preg_quote($exception, '/') . '\b/iu';
|
||||
$result = preg_replace($exceptionPattern, $exception, $result) ?? $result;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline Transformation
|
||||
*
|
||||
* Wendet mehrere Transformationen nacheinander auf einen Wert an.
|
||||
* Jeder Schritt benutzt das Ergebnis des vorherigen Schrittes.
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "Merchant": {
|
||||
* "type": "pipeline",
|
||||
* "sourceColumn": "Merchant/Description",
|
||||
* "steps": [
|
||||
* { "type": "trim" },
|
||||
* { "type": "lowercase" },
|
||||
* { "type": "ucwords_first" }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Beispiel:
|
||||
* Input: " COOP PRONTO CHUR "
|
||||
* Step 1 (trim): "COOP PRONTO CHUR"
|
||||
* Step 2 (lowercase): "coop pronto chur"
|
||||
* Step 3 (ucwords_first): "Coop Pronto Chur"
|
||||
* Output: "Coop Pronto Chur"
|
||||
*
|
||||
* @param string $value Ursprungswert
|
||||
* @param array $config Transformationskonfiguration mit 'steps' Array
|
||||
*
|
||||
* @return string Transformierter Wert nach allen Schritten
|
||||
*/
|
||||
private function transformPipeline(string $value, array $config): string
|
||||
{
|
||||
$steps = $config['steps'] ?? [];
|
||||
|
||||
if (empty($steps) || !is_array($steps)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Wende jeden Schritt nacheinander an
|
||||
foreach ($steps as $step) {
|
||||
if (!empty($step['type'] ?? $step['transform'] ?? null)) {
|
||||
$value = $this->applySingleTransformation($value, $step);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Callback Transformation
|
||||
*
|
||||
* Ruft eine Custom-Funktion auf, die komplexe Logik implementiert
|
||||
*
|
||||
* Konfiguration:
|
||||
* ```
|
||||
* "type": "custom",
|
||||
* "callback": "myCustomFunction"
|
||||
* ```
|
||||
*
|
||||
* Die Callback-Funktion erhaelt die gesamte Zeile und gibt die
|
||||
* modifizierte Zeile zurueck.
|
||||
*
|
||||
* @param array $row Gesamte Datenzeile
|
||||
* @param array $config Transformationskonfiguration
|
||||
*
|
||||
* @return array Transformierte Datenzeile
|
||||
*/
|
||||
private function transformCustom(array $row, array $config): array
|
||||
{
|
||||
$callback = $config['callback'] ?? null;
|
||||
|
||||
if (empty($callback) || !is_callable($callback)) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
try {
|
||||
return call_user_func($callback, $row);
|
||||
} catch (\Exception $e) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt Multi-Output Transformationen
|
||||
* Aktuell nur für 'split' implementiert.
|
||||
*
|
||||
* Config-Beispiel:
|
||||
* {
|
||||
* "outputs": ["FirstName", "LastName"],
|
||||
* "sourceColumn": "FullName",
|
||||
* "type": "split",
|
||||
* "delimiter": " "
|
||||
* }
|
||||
*
|
||||
* @param array $row Input-Zeile
|
||||
* @param array $config Transformations-Konfiguration
|
||||
* @return array Assoziatives Array: columnName => value
|
||||
* @throws \RuntimeException wenn Transformation-Type nicht unterstützt
|
||||
*/
|
||||
private function handleMultiOutputTransformation(array $row, array $config): array
|
||||
{
|
||||
$outputs = $config['outputs'];
|
||||
$sourceColumn = $config['sourceColumn'] ?? '';
|
||||
$transformType = $this->normalizeTransformType($config['type'] ?? '');
|
||||
|
||||
if (empty($outputs) || empty($sourceColumn) || empty($transformType)) {
|
||||
throw new \RuntimeException("Multi-Output Transformation benötigt 'outputs', 'sourceColumn' und 'type'");
|
||||
}
|
||||
|
||||
$sourceValue = $row[$sourceColumn] ?? '';
|
||||
|
||||
if ($transformType !== 'split') {
|
||||
throw new \RuntimeException("Multi-Output nur für 'split' unterstützt, gegeben: {$transformType}");
|
||||
}
|
||||
|
||||
return $this->handleMultiOutputSplit($sourceValue, $outputs, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split-Transformation mit Multi-Output
|
||||
* Teilt einen String und verteilt die Teile auf mehrere Spalten
|
||||
*
|
||||
* @param string $value Zu teilender String
|
||||
* @param array $outputs Liste der Ziel-Spaltennamen
|
||||
* @param array $config Transformation-Config
|
||||
* @return array Assoziatives Array: columnName => value
|
||||
*/
|
||||
|
||||
private function handleMultiOutputSplit(string $value, array $outputs, array $config): array
|
||||
{
|
||||
$delimiter = $config['delimiter'] ?? ';';
|
||||
|
||||
// Führe Split durch
|
||||
$parts = explode($delimiter, $value);
|
||||
|
||||
// Mappe Parts zu Output-Spalten
|
||||
$result = [];
|
||||
foreach ($outputs as $index => $columnName) {
|
||||
// Wenn Teil existiert: verwenden (getrimmt) // Wenn nicht: leerer String
|
||||
$result[$columnName] = isset($parts[$index]) ? trim($parts[$index]) : '';
|
||||
}
|
||||
|
||||
// Debug-Logging
|
||||
DebugLogger::log('transformation', 'Applied Multi-Output Split', ['input' => $value, 'delimiter' => $delimiter, 'parts_count' => count($parts), 'outputs' => $outputs, 'result' => $result]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der Output-Spalten zurueck
|
||||
*
|
||||
* Zaehlt Original-Spalten plus neu generierte Spalten (z.B. bei regex_extract)
|
||||
*
|
||||
* @return int Anzahl Output-Spalten
|
||||
*/
|
||||
public function getOutputColumns(): int
|
||||
{
|
||||
return count(array_unique($this->outputColumns));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Lädt und validiert JSON-Konfigurationsdateien
|
||||
*/
|
||||
class ConfigurationLoader
|
||||
{
|
||||
private string $configFile;
|
||||
private array $config = [];
|
||||
|
||||
public function __construct(string $configFile)
|
||||
{
|
||||
$this->configFile = $configFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Konfigurationsdatei
|
||||
*
|
||||
* @return array Die geladene und validierte Konfiguration
|
||||
* @throws \RuntimeException wenn Datei nicht gefunden oder ungültig
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
if (!file_exists($this->configFile)) {
|
||||
throw new \RuntimeException("Konfigurationsdatei nicht gefunden: {$this->configFile}");
|
||||
}
|
||||
|
||||
if (pathinfo($this->configFile, PATHINFO_EXTENSION) !== 'json') {
|
||||
throw new \RuntimeException("Konfigurationsdatei muss eine JSON-Datei sein: {$this->configFile}");
|
||||
}
|
||||
|
||||
$this->config = $this->loadJson($this->configFile);
|
||||
|
||||
$this->validate();
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine JSON-Datei
|
||||
*
|
||||
* @param string $file Pfad zur JSON-Datei
|
||||
* @return array Geparste Konfiguration
|
||||
*/
|
||||
private function loadJson(string $file): array
|
||||
{
|
||||
$json = file_get_contents($file);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException("Konnte JSON-Datei nicht lesen: {$file}");
|
||||
}
|
||||
|
||||
$config = json_decode($json, true);
|
||||
|
||||
if ($config === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException("Ungültiges JSON: " . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die geladene Konfiguration auf erforderliche Felder
|
||||
*
|
||||
* @throws \RuntimeException wenn erforderliche Felder fehlen
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
// Metadata erforderlich
|
||||
if (empty($this->config['metadata'])) {
|
||||
throw new \RuntimeException("Konfiguration: 'metadata' Section erforderlich");
|
||||
}
|
||||
|
||||
if (!isset($this->config['metadata']['extractionRules']) || !is_array($this->config['metadata']['extractionRules'])) {
|
||||
throw new \RuntimeException("Konfiguration: 'metadata.extractionRules' erforderlich (kann leer sein: [])");
|
||||
}
|
||||
|
||||
// CSV-Struktur erforderlich
|
||||
if (empty($this->config['csvStructure'])) {
|
||||
throw new \RuntimeException("Konfiguration: 'csvStructure' Section erforderlich");
|
||||
}
|
||||
|
||||
if (!isset($this->config['csvStructure']['headerLine'])) {
|
||||
throw new \RuntimeException("Konfiguration: 'csvStructure.headerLine' erforderlich");
|
||||
}
|
||||
|
||||
// Column Transformations erforderlich
|
||||
if (empty($this->config['columnTransformations'])) {
|
||||
throw new \RuntimeException("Konfiguration: 'columnTransformations' erforderlich");
|
||||
}
|
||||
|
||||
// Directories validieren (wenn auto-import genutzt wird)
|
||||
if (!empty($this->config['directories'])) {
|
||||
foreach (['source', 'output', 'archive', 'error'] as $dir) {
|
||||
if (empty($this->config['directories'][$dir])) {
|
||||
throw new \RuntimeException("Konfiguration: 'directories.{$dir}' erforderlich für Auto-Import");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validiere CSV-Struktur Werte
|
||||
$headerLine = $this->config['csvStructure']['headerLine'] ?? 1;
|
||||
if (!is_int($headerLine) || $headerLine < 1) {
|
||||
throw new \Exception(
|
||||
'Konfiguration csvStructure.headerLine muss eine positive Ganzzahl sein'
|
||||
);
|
||||
}
|
||||
|
||||
$delimiter = $this->config['csvStructure']['inputDelimiter'] ?? '';
|
||||
if (strlen($delimiter) === 0) {
|
||||
throw new \Exception(
|
||||
'Konfiguration csvStructure.inputDelimiter darf nicht leer sein'
|
||||
);
|
||||
}
|
||||
|
||||
// Validiere Encoding
|
||||
$encoding = $this->config['csvStructure']['encoding'] ?? 'UTF-8';
|
||||
if (!in_array($encoding, ['UTF-8', 'ISO-8859-1', 'CP1252'])) {
|
||||
throw new \Exception(
|
||||
'Konfiguration csvStructure.encoding: ' . $encoding . ' nicht unterstützt'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine einzelne Konfigurationsoption zurück
|
||||
*
|
||||
* @param string $key Dot-Notation Key (z.B. 'metadata.extractionRules')
|
||||
* @param mixed $default Standardwert wenn Key nicht existiert
|
||||
* @return mixed Der Konfigurationswert
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die vollständige Konfiguration zurück
|
||||
*
|
||||
* @return array Die komplette Konfiguration
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen Konfigurationswert (überschreibt bestehenden Wert)
|
||||
*
|
||||
* @param string $key Dot-Notation Key (z.B. 'directories.output')
|
||||
* @param mixed $value Neuer Wert
|
||||
* @return void
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$ref = &$this->config;
|
||||
|
||||
foreach ($keys as $i => $k) {
|
||||
if ($i === count($keys) - 1) {
|
||||
$ref[$k] = $value;
|
||||
} else {
|
||||
if (!isset($ref[$k]) || !is_array($ref[$k])) {
|
||||
$ref[$k] = [];
|
||||
}
|
||||
$ref = &$ref[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Konfigurationsschlüssel existiert
|
||||
*
|
||||
* @param string $key Dot-Notation Key
|
||||
* @return bool
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return false;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Liest und parst CSV-Dateien
|
||||
*
|
||||
* Diese Klasse liest CSV-Dateien mit konfigurierbarem Delimiter
|
||||
* und separiert Metadaten-Zeilen von den eigentlichen Datenzeilen.
|
||||
*/
|
||||
class CsvReader
|
||||
{
|
||||
private string $filePath;
|
||||
private string $delimiter;
|
||||
private int $headerLine;
|
||||
private bool $hasBom;
|
||||
|
||||
/**
|
||||
* @param string $filePath Pfad zur CSV-Datei
|
||||
* @param array $csvStructure CSV-Struktur aus Konfiguration
|
||||
*/
|
||||
public function __construct(string $filePath, array $csvStructure)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->delimiter = $csvStructure['inputDelimiter'] ?? ';';
|
||||
$this->headerLine = $csvStructure['headerLine'] ?? 1;
|
||||
$this->hasBom = $csvStructure['hasBom'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest alle Zeilen aus der Datei
|
||||
*
|
||||
* @param int $maxLines Maximale Anzahl Zeilen (0 = alle)
|
||||
* @return array Array mit Zeilen (ohne Newlines)
|
||||
* @throws \RuntimeException wenn Datei nicht gelesen werden kann
|
||||
*/
|
||||
public function readLines(int $maxLines = 0): array
|
||||
{
|
||||
if (!file_exists($this->filePath) || !is_readable($this->filePath)) {
|
||||
throw new \RuntimeException("Konnte Datei nicht lesen: {$this->filePath}");
|
||||
}
|
||||
|
||||
$lines = file($this->filePath, FILE_IGNORE_NEW_LINES);
|
||||
|
||||
if ($lines === false) {
|
||||
throw new \RuntimeException("Konnte Datei nicht lesen: {$this->filePath}");
|
||||
}
|
||||
|
||||
// BOM entfernen falls vorhanden
|
||||
if ($this->hasBom && !empty($lines)) {
|
||||
$lines[0] = $this->removeBom($lines[0]);
|
||||
}
|
||||
|
||||
if ($maxLines > 0 && count($lines) > $maxLines) {
|
||||
$lines = array_slice($lines, 0, $maxLines);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Metadaten-Zeilen (vor der Header-Zeile)
|
||||
*
|
||||
* @return array Array mit Metadaten-Zeilen
|
||||
*/
|
||||
public function readMetadataLines(): array
|
||||
{
|
||||
$lines = $this->readLines();
|
||||
|
||||
if ($this->headerLine <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice($lines, 0, $this->headerLine - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die CSV-Daten mit Headers
|
||||
*
|
||||
* @param int $maxDataRows Maximale Anzahl Datenzeilen (0 = alle)
|
||||
* @return array Array von assoziativen Arrays (mit Spalten-Namen als Keys)
|
||||
* @throws \RuntimeException wenn Header-Zeile nicht gefunden
|
||||
*/
|
||||
public function readCsvData(int $maxDataRows = 0): array
|
||||
{
|
||||
$lines = $this->readLines();
|
||||
|
||||
if ($this->headerLine > count($lines)) {
|
||||
throw new \RuntimeException("Header-Zeile {$this->headerLine} nicht gefunden in Datei mit " . count($lines) . " Zeilen");
|
||||
}
|
||||
|
||||
// Header parsen
|
||||
$headerLineContent = $lines[$this->headerLine - 1];
|
||||
$headers = str_getcsv($headerLineContent, $this->delimiter, '"', '\\');
|
||||
$headers = array_map(static fn(?string $v): string => trim($v ?? ''), $headers);
|
||||
|
||||
// Datenzeilen parsen
|
||||
$data = [];
|
||||
$dataStartLine = $this->headerLine; // 0-basiert
|
||||
$lineCount = 0;
|
||||
|
||||
for ($i = $dataStartLine; $i < count($lines); $i++) {
|
||||
if ($maxDataRows > 0 && $lineCount >= $maxDataRows) {
|
||||
break;
|
||||
}
|
||||
|
||||
$lineContent = $lines[$i];
|
||||
|
||||
// Leere Zeilen überspringen
|
||||
if (trim($lineContent) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = str_getcsv($lineContent, $this->delimiter, '"', '\\');
|
||||
$row = array_map(static fn(?string $v): string => trim($v ?? ''), $row);
|
||||
|
||||
// Zeile mit Header-Keys kombinieren
|
||||
$rowData = [];
|
||||
foreach ($headers as $index => $header) {
|
||||
$rowData[$header] = $row[$index] ?? '';
|
||||
}
|
||||
|
||||
$data[] = $rowData;
|
||||
$lineCount++;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Spalten-Header zurück
|
||||
*
|
||||
* @return array Array mit Spalten-Namen
|
||||
* @throws \RuntimeException wenn Header-Zeile nicht gefunden
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
$lines = $this->readLines();
|
||||
|
||||
if ($this->headerLine > count($lines)) {
|
||||
throw new \RuntimeException("Header-Zeile {$this->headerLine} nicht gefunden");
|
||||
}
|
||||
|
||||
$headerLineContent = $lines[$this->headerLine - 1];
|
||||
$headers = str_getcsv($headerLineContent, $this->delimiter, '"', '\\');
|
||||
|
||||
return array_map(static fn(?string $v): string => trim($v ?? ''), $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt UTF-8 BOM (Byte Order Mark) von String
|
||||
*
|
||||
* @param string $text String mit potenziellem BOM
|
||||
* @return string String ohne BOM
|
||||
*/
|
||||
private function removeBom(string $text): string
|
||||
{
|
||||
if (str_starts_with($text, "\xEF\xBB\xBF")) {
|
||||
return substr($text, 3);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Gesamtzahl der Zeilen in der Datei zurück
|
||||
*
|
||||
* @return int Anzahl Zeilen
|
||||
*/
|
||||
public function countLines(): int
|
||||
{
|
||||
return count($this->readLines());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der Datenzeilen zurück (ohne Header und Metadaten)
|
||||
*
|
||||
* @return int Anzahl Datenzeilen
|
||||
*/
|
||||
public function countDataRows(): int
|
||||
{
|
||||
return count($this->readCsvData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Schreibt transformierte Daten in CSV-Datei
|
||||
*
|
||||
* Diese Klasse schreibt die transformierten Daten in eine
|
||||
* Firefly III-kompatible CSV-Datei.
|
||||
*/
|
||||
class CsvWriter
|
||||
{
|
||||
private string $outputFile;
|
||||
private string $delimiter;
|
||||
|
||||
/**
|
||||
* @param string $outputFile Pfad zur Output-Datei
|
||||
* @param array $csvStructure CSV-Struktur aus Konfiguration
|
||||
*/
|
||||
public function __construct(string $outputFile, array $csvStructure = [])
|
||||
{
|
||||
$this->outputFile = $outputFile;
|
||||
$this->delimiter = $csvStructure['outputDelimiter'] ?? ',';
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt Daten in CSV-Datei
|
||||
*
|
||||
* @param array $data Array von assoziativen Arrays (Zeilen)
|
||||
* @throws \RuntimeException wenn Datei nicht geschrieben werden kann
|
||||
*/
|
||||
public function write(array $data): void
|
||||
{
|
||||
if (empty($data)) {
|
||||
throw new \RuntimeException("Keine Daten zum Schreiben");
|
||||
}
|
||||
|
||||
// Output-Verzeichnis erstellen falls nicht vorhanden
|
||||
$dir = dirname($this->outputFile);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException("Konnte Output-Verzeichnis nicht erstellen: {$dir}");
|
||||
}
|
||||
}
|
||||
|
||||
$fp = fopen($this->outputFile, 'w');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException("Konnte Output-Datei nicht erstellen: {$this->outputFile}");
|
||||
}
|
||||
|
||||
try {
|
||||
// Headers schreiben (Spalten-Namen aus erster Zeile)
|
||||
$headers = array_keys($data[0]);
|
||||
$this->writeCsvLine($fp, $headers);
|
||||
|
||||
// Datenzeilen schreiben
|
||||
foreach ($data as $row) {
|
||||
// Sicherstellen dass alle Spalten vorhanden sind
|
||||
$values = [];
|
||||
foreach ($headers as $header) {
|
||||
$values[] = $row[$header] ?? '';
|
||||
}
|
||||
|
||||
$this->writeCsvLine($fp, $values);
|
||||
}
|
||||
} finally {
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine CSV-Zeile mit fputcsv
|
||||
*
|
||||
* @param resource $fp File-Handle
|
||||
* @param array $values Array mit Werten
|
||||
* @throws \RuntimeException wenn Schreiben fehlschlägt
|
||||
*/
|
||||
private function writeCsvLine($fp, array $values): void
|
||||
{
|
||||
$result = fputcsv($fp, $values, $this->delimiter, '"', '\\');
|
||||
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException("Fehler beim Schreiben der CSV-Zeile");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Pfad zur Output-Datei zurück
|
||||
*
|
||||
* @return string Output-Dateipfad
|
||||
*/
|
||||
public function getOutputFile(): string
|
||||
{
|
||||
return $this->outputFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Output-Datei erstellt wurde
|
||||
*
|
||||
* @return bool True wenn Datei existiert
|
||||
*/
|
||||
public function fileExists(): bool
|
||||
{
|
||||
return file_exists($this->outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Größe der Output-Datei zurück
|
||||
*
|
||||
* @return int|false Dateigröße in Bytes oder false bei Fehler
|
||||
*/
|
||||
public function getFileSize(): int|false
|
||||
{
|
||||
if (!$this->fileExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filesize($this->outputFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Zentraler Debug-Logger für Transparenz
|
||||
*
|
||||
* Sammelt Debug-Informationen aus allen Komponenten und macht die
|
||||
* Verarbeitung nachvollziehbar. Ermöglicht Transparenz über alle
|
||||
* Verarbeitungsschritte: Metadaten-Extraktion, Transformationen,
|
||||
* CSV-Lesevorgänge etc.
|
||||
*
|
||||
* Verwendung:
|
||||
* - DebugLogger::enable() → Debug-Modus aktivieren
|
||||
* - DebugLogger::log('category', 'message', $data) → Nachricht loggen
|
||||
* - DebugLogger::getLogs() → Alle Logs abrufen
|
||||
* - DebugLogger::reset() → Logs zurücksetzen
|
||||
*
|
||||
* Beispiel:
|
||||
* ```php
|
||||
* DebugLogger::enable();
|
||||
* DebugLogger::log('metadata', 'IBAN extrahiert', ['iban' => 'CH9300762011623852957']);
|
||||
* $logs = DebugLogger::getLogs();
|
||||
* ```
|
||||
*/
|
||||
class DebugLogger
|
||||
{
|
||||
/**
|
||||
* @var bool Ist Debug-Modus aktiviert?
|
||||
*/
|
||||
private static bool $enabled = false;
|
||||
|
||||
/**
|
||||
* @var array Gesammelte Logs mit Timestamp, Kategorie, Nachricht und Daten
|
||||
*/
|
||||
private static array $logs = [];
|
||||
|
||||
/**
|
||||
* Aktiviert den Debug-Modus
|
||||
*
|
||||
* Nach Aktivierung werden alle DebugLogger::log() Aufrufe protokolliert.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function enable(): void
|
||||
{
|
||||
self::$enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktiviert den Debug-Modus
|
||||
*
|
||||
* Nach Deaktivierung werden DebugLogger::log() Aufrufe ignoriert.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function disable(): void
|
||||
{
|
||||
self::$enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protokolliert eine Debug-Nachricht
|
||||
*
|
||||
* Sammelt Informationen über jeden Verarbeitungsschritt mit Timestamp,
|
||||
* Kategorie, Nachricht und optionalen Daten. Die Logs werden nur
|
||||
* gesammelt, wenn der Debug-Modus aktiviert ist.
|
||||
*
|
||||
* @param string $category Kategorie der Log-Nachricht
|
||||
* z.B. 'metadata', 'transformation', 'csv_reader', 'config'
|
||||
* @param string $message Beschreibung der Aktion oder des Ereignisses
|
||||
* @param mixed $data Zusätzliche Kontextdaten (Array oder beliebiger Wert)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function log(string $category, string $message, $data = null): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$logs[] = [
|
||||
'timestamp' => microtime(true),
|
||||
'category' => $category,
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle gesammelten Logs zurück
|
||||
*
|
||||
* Liefert ein Array aller protokollierten Ereignisse mit vollständigen
|
||||
* Informationen für Analyse und Debugging.
|
||||
*
|
||||
* @return array Array von Log-Einträgen, jeder mit:
|
||||
* - timestamp: Mikrosekunden-Zeitstempel
|
||||
* - category: Log-Kategorie
|
||||
* - message: Beschreibung
|
||||
* - data: Zusätzliche Daten
|
||||
*/
|
||||
public static function getLogs(): array
|
||||
{
|
||||
return self::$logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle Logs zurück
|
||||
*
|
||||
* Löscht den gesamten Log-Buffer. Nützlich um zwischen mehreren
|
||||
* Transformationen einen sauberen State zu haben.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$logs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der gesammelten Log-Einträge zurück
|
||||
*
|
||||
* @return int Anzahl protokollierter Ereignisse
|
||||
*/
|
||||
public static function count(): int
|
||||
{
|
||||
return count(self::$logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Debug-Modus aktiviert ist
|
||||
*
|
||||
* @return bool true wenn aktiviert, false sonst
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return self::$enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt einen formattierten String aller Logs zurück
|
||||
*
|
||||
* Konvertiert den Log-Buffer in ein lesbares Format für Konsolen-Ausgabe.
|
||||
*
|
||||
* @param bool $includeData true = auch Daten ausgeben, false = nur Messages
|
||||
*
|
||||
* @return string Formatierte Log-Ausgabe
|
||||
*/
|
||||
public static function format(bool $includeData = true): string
|
||||
{
|
||||
if (empty(self::$logs)) {
|
||||
return "Keine Debug-Logs vorhanden.\n";
|
||||
}
|
||||
|
||||
$output = "\n=== DEBUG LOGS ===\n";
|
||||
foreach (self::$logs as $index => $log) {
|
||||
$output .= sprintf(
|
||||
"%d. [%s] %s: %s",
|
||||
$index + 1,
|
||||
$log['category'],
|
||||
date('H:i:s', intval($log['timestamp'])),
|
||||
$log['message']
|
||||
);
|
||||
|
||||
if ($includeData && $log['data'] !== null) {
|
||||
$output .= "\n Data: " . json_encode($log['data'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$output .= "\n";
|
||||
}
|
||||
$output .= "===================\n";
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Firefly III Data Importer Integration
|
||||
*
|
||||
* Diese Klasse integriert den Firefly III Data Importer.
|
||||
* Der Import erfolgt über die offizielle Firefly III Data Importer CLI.
|
||||
*
|
||||
* SETUP-VORAUSSETZUNGEN:
|
||||
* ----------------------
|
||||
*
|
||||
* 1. Firefly III Data Importer installiert und konfiguriert
|
||||
* - Docker: firefly/data-importer:latest
|
||||
* - Oder: Standalone Installation
|
||||
*
|
||||
* 2. Import-Konfiguration erstellt (config.json):
|
||||
* - In Firefly III Web-UI: Import → Configure
|
||||
* - CSV-Format konfigurieren
|
||||
* - JSON-Konfiguration herunterladen
|
||||
* - Speichern als z.B.: /opt/firefly/configs/ubs-import.json
|
||||
*
|
||||
* 3. Umgebungsvariablen für Firefly Data Importer:
|
||||
* - FIREFLY_III_URL=https://your-firefly-instance.com
|
||||
* - FIREFLY_III_ACCESS_TOKEN=<personal_access_token>
|
||||
* - VANITY_URL (optional)
|
||||
*
|
||||
* INTEGRATION IN config.yaml:
|
||||
* ---------------------------
|
||||
*
|
||||
* fireflyImport:
|
||||
* # Pfad zur JSON-Konfiguration (aus Firefly III exportiert)
|
||||
* jsonConfig: '/opt/firefly/configs/ubs-import.json'
|
||||
*
|
||||
* # Firefly Data Importer Kommando
|
||||
* # Option 1: Docker
|
||||
* importerCommand: 'docker exec -it firefly-importer php artisan importer:import'
|
||||
*
|
||||
* # Option 2: Standalone
|
||||
* # importerCommand: 'cd /opt/firefly-data-importer && php artisan importer:import'
|
||||
*
|
||||
* # Automatisch nach Transformation importieren?
|
||||
* autoImport: true
|
||||
*
|
||||
* # Output-Datei nach erfolgreichem Import löschen?
|
||||
* deleteAfterImport: true
|
||||
*
|
||||
* # Timeout für Import (Sekunden)
|
||||
* timeout: 300
|
||||
*
|
||||
* # Environment-Variablen für Firefly Data Importer
|
||||
* environment:
|
||||
* FIREFLY_III_URL: 'https://your-firefly.com'
|
||||
* FIREFLY_III_ACCESS_TOKEN: 'your-token-here'
|
||||
*
|
||||
* VERWENDUNG:
|
||||
* -----------
|
||||
*
|
||||
* // Automatisch beim Auto-Import
|
||||
* ./bin/transformer auto-import config/config.yaml
|
||||
*
|
||||
* // Oder manuell nach Transformation
|
||||
* $importer = new FireflyImporter($config['fireflyImport']);
|
||||
* $result = $importer->import('/path/to/transformed.csv');
|
||||
*/
|
||||
class FireflyImporter
|
||||
{
|
||||
private array $config;
|
||||
private string $jsonConfigPath;
|
||||
private string $importerCommand;
|
||||
private bool $deleteAfterImport;
|
||||
private array $environment;
|
||||
|
||||
/**
|
||||
* @param array $config Firefly Import-Konfiguration aus config.yaml
|
||||
* @throws \RuntimeException wenn Konfiguration ungültig
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
|
||||
// JSON-Konfigurationspfad validieren
|
||||
$this->jsonConfigPath = $config['jsonConfig'] ?? '';
|
||||
if (empty($this->jsonConfigPath)) {
|
||||
throw new \RuntimeException("Firefly Import: 'jsonConfig' nicht konfiguriert");
|
||||
}
|
||||
|
||||
if (!file_exists($this->jsonConfigPath)) {
|
||||
throw new \RuntimeException("Firefly JSON-Konfiguration nicht gefunden: {$this->jsonConfigPath}");
|
||||
}
|
||||
|
||||
// Importer-Kommando
|
||||
$this->importerCommand = $config['importerCommand'] ?? '';
|
||||
if (empty($this->importerCommand)) {
|
||||
throw new \RuntimeException("Firefly Import: 'importerCommand' nicht konfiguriert");
|
||||
}
|
||||
|
||||
// Optionale Einstellungen
|
||||
$this->deleteAfterImport = $config['deleteAfterImport'] ?? false;
|
||||
$this->environment = $config['environment'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiert eine transformierte CSV-Datei in Firefly III
|
||||
*
|
||||
* Der Import erfolgt über den Firefly III Data Importer CLI:
|
||||
* php artisan importer:import <csv_file> <config_file>
|
||||
*
|
||||
* @param string $csvFile Pfad zur transformierten CSV-Datei
|
||||
* @return array Import-Ergebnis mit Status und Ausgabe
|
||||
*/
|
||||
public function import(string $csvFile): array
|
||||
{
|
||||
if (!file_exists($csvFile)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => "CSV-Datei nicht gefunden: {$csvFile}",
|
||||
'output' => '',
|
||||
'exit_code' => -1
|
||||
];
|
||||
}
|
||||
|
||||
// Kommando zusammenbauen
|
||||
$command = $this->buildImportCommand($csvFile);
|
||||
|
||||
// Environment-Variablen setzen
|
||||
$env = $this->buildEnvironment();
|
||||
|
||||
// Import ausführen
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// Kommando ausführen mit Timeout
|
||||
$descriptors = [
|
||||
0 => ["pipe", "r"], // stdin
|
||||
1 => ["pipe", "w"], // stdout
|
||||
2 => ["pipe", "w"] // stderr
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, null, $env);
|
||||
|
||||
if (!is_resource($process)) {
|
||||
throw new \RuntimeException("Konnte Import-Prozess nicht starten");
|
||||
}
|
||||
|
||||
// stdin schließen
|
||||
fclose($pipes[0]);
|
||||
|
||||
// stdout und stderr lesen
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
// Auf Prozess-Ende warten
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
$output = [
|
||||
'stdout' => $stdout,
|
||||
'stderr' => $stderr
|
||||
];
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
|
||||
$success = ($exitCode === 0);
|
||||
|
||||
// Bei Erfolg: Optional CSV-Datei löschen
|
||||
if ($success && $this->deleteAfterImport) {
|
||||
@unlink($csvFile);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'exit_code' => $exitCode,
|
||||
'output' => $output,
|
||||
'duration' => round($duration, 2),
|
||||
'csv_file' => $csvFile,
|
||||
'config_file' => $this->jsonConfigPath,
|
||||
'deleted' => ($success && $this->deleteAfterImport)
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'output' => $output,
|
||||
'exit_code' => $exitCode
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Import-Kommando zusammen
|
||||
*
|
||||
* @param string $csvFile Pfad zur CSV-Datei
|
||||
* @return string Vollständiges Kommando
|
||||
*/
|
||||
private function buildImportCommand(string $csvFile): string
|
||||
{
|
||||
// Firefly Data Importer CLI-Format:
|
||||
// php artisan importer:import <csv_file> <config_file>
|
||||
|
||||
return sprintf(
|
||||
'%s %s %s',
|
||||
$this->importerCommand,
|
||||
escapeshellarg($csvFile),
|
||||
escapeshellarg($this->jsonConfigPath)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut Environment-Variablen zusammen
|
||||
*
|
||||
* @return array|null Environment-Variablen oder null
|
||||
*/
|
||||
private function buildEnvironment(): ?array
|
||||
{
|
||||
if (empty($this->environment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aktuelle Environment übernehmen und mit Custom-Vars erweitern
|
||||
$env = $_ENV;
|
||||
|
||||
foreach ($this->environment as $key => $value) {
|
||||
$env[$key] = $value;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Testet die Firefly-Verbindung
|
||||
*
|
||||
* @return array Test-Ergebnis
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
// Test ob Importer-Kommando verfügbar ist
|
||||
$testCommand = str_replace('importer:import', '--version', $this->importerCommand);
|
||||
|
||||
exec($testCommand . ' 2>&1', $output, $exitCode);
|
||||
|
||||
return [
|
||||
'available' => ($exitCode === 0),
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die JSON-Konfiguration
|
||||
*
|
||||
* @return array Validierungsergebnis
|
||||
*/
|
||||
public function validateConfig(): array
|
||||
{
|
||||
if (!file_exists($this->jsonConfigPath)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'JSON-Konfiguration nicht gefunden'
|
||||
];
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->jsonConfigPath);
|
||||
if ($json === false) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Konfigurationsdatei nicht lesbar'
|
||||
];
|
||||
}
|
||||
$config = json_decode($json, true);
|
||||
|
||||
if ($config === null) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Ungültiges JSON: ' . json_last_error_msg()
|
||||
];
|
||||
}
|
||||
|
||||
// Prüfe erforderliche Felder in Firefly-Config
|
||||
$requiredFields = ['file_type', 'import_account'];
|
||||
$missingFields = [];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($config[$field])) {
|
||||
$missingFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingFields)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Fehlende Felder: ' . implode(', ', $missingFields)
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'config' => $config
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Konfiguration zurück
|
||||
*
|
||||
* @return array Firefly Import-Konfiguration
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
/**
|
||||
* Extrahiert Metadaten aus Header-Zeilen mit Regex
|
||||
*
|
||||
* Diese Klasse extrahiert konstante Werte aus den Metadatenzeilen
|
||||
* (Header-Zeilen vor der eigentlichen CSV-Tabelle) mittels Regex-Regeln.
|
||||
*/
|
||||
class MetadataExtractor
|
||||
{
|
||||
private array $rules;
|
||||
|
||||
public function __construct(array $rules = [])
|
||||
{
|
||||
$this->rules = $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Metadaten aus den übergebenen Zeilen
|
||||
*
|
||||
* @param array $lines Array von Zeilen aus dem CSV-Header
|
||||
* @return array Extrahierte Metadaten
|
||||
*/
|
||||
public function extract(array $lines): array
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
// Validiere erforderliche Felder
|
||||
if (empty($rule['name']) || empty($rule['regex'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ruleName = $rule['name'];
|
||||
$lineNumber = $rule['lineNumber'] ?? 1;
|
||||
$regex = $rule['regex'];
|
||||
|
||||
// ✅ KORRIGIERT: Off-by-One Fix
|
||||
// config.json: "lineNumber": 1, 2, 3 (1-basiert, für Menschen lesbar)
|
||||
// PHP Arrays: $lines[0], $lines[1], $lines[2] (0-basiert)
|
||||
// Konvertierung: arrayIndex = lineNumber - 1
|
||||
$arrayIndex = $lineNumber - 1;
|
||||
|
||||
// Prüfe ob Zeile existiert
|
||||
if (!isset($lines[$arrayIndex])) {
|
||||
// Zeile existiert nicht - Debug-Info für Support
|
||||
DebugLogger::log('metadata_warning', "Extraction rule not found", [
|
||||
'rule_name' => $ruleName,
|
||||
'expected_lineNumber' => $lineNumber,
|
||||
'array_index' => $arrayIndex,
|
||||
'available_lines' => count($lines)
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $lines[$arrayIndex];
|
||||
|
||||
// Regex mit '#' als Delimiter (erlaubt '/' in User-Patterns); '#' im Pattern escapen
|
||||
$pattern = '#' . str_replace('#', '\#', $regex) . '#u';
|
||||
$matchResult = @preg_match_all($pattern, $line, $matches);
|
||||
if ($matchResult === false) {
|
||||
DebugLogger::log('metadata_error', "Invalid regex pattern", [
|
||||
'rule_name' => $ruleName,
|
||||
'pattern' => $regex,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if ($matchResult === 0) {
|
||||
// Regex matched nicht auf dieser Zeile
|
||||
DebugLogger::log('metadata_warning', "Regex did not match", [
|
||||
'rule_name' => $ruleName,
|
||||
'lineNumber' => $lineNumber,
|
||||
'regex_pattern' => $regex,
|
||||
'line_content' => substr($line, 0, 100)
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ✅ KORRIGIERT: captureGroup benutzen
|
||||
// captureGroup definiert welche Klammer-Gruppe extrahiert wird
|
||||
// 0 = komplette Match
|
||||
// 1 = erste Klammer-Gruppe (...)
|
||||
// 2 = zweite Klammer-Gruppe, etc.
|
||||
$captureGroup = isset($rule['captureGroup']) ? intval($rule['captureGroup']) : 1;
|
||||
|
||||
// Sicherstellen dass die Capture Group existiert
|
||||
if (!isset($matches[$captureGroup]) || empty($matches[$captureGroup])) {
|
||||
// Fallback: Nutze komplette Match wenn Gruppe nicht existiert
|
||||
$metadata[$ruleName] = $matches[0][0] ?? '';
|
||||
// echo "DEBUG: extraction_rule '{$ruleName}' - captureGroup {$captureGroup} not found, falling back to complete match\n";
|
||||
} else {
|
||||
// Nutze die spezifische Capture Group
|
||||
$metadata[$ruleName] = $matches[$captureGroup][0] ?? '';
|
||||
}
|
||||
|
||||
DebugLogger::log('metadata', "Extraction rule applied", [
|
||||
'rule_name' => $ruleName,
|
||||
'value' => $metadata[$ruleName] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der definierten Extraction-Rules zurück
|
||||
*
|
||||
* @return int Anzahl Rules
|
||||
*/
|
||||
public function getRuleCount(): int
|
||||
{
|
||||
return count($this->rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle definierten Extraction-Rules zurück
|
||||
*
|
||||
* @return array Die Rules
|
||||
*/
|
||||
public function getRules(): array
|
||||
{
|
||||
return $this->rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer;
|
||||
|
||||
use UbsCsvTransformer\CsvReader;
|
||||
use UbsCsvTransformer\CsvWriter;
|
||||
use UbsCsvTransformer\ConfigurationLoader;
|
||||
use UbsCsvTransformer\MetadataExtractor;
|
||||
use UbsCsvTransformer\ColumnTransformer;
|
||||
use UbsCsvTransformer\FireflyImporter;
|
||||
|
||||
/**
|
||||
* Orchestriert die gesamte CSV-Transformations-Pipeline
|
||||
*
|
||||
* Koordiniert alle Schritte von CSV-Einlesen über Metadaten-Extraktion
|
||||
* und Spalten-Transformation bis zur Ausgabe und optional zum Import in Firefly III.
|
||||
*
|
||||
* @property ConfigurationLoader $configLoader Verwaltet Konfiguration
|
||||
* @property CsvWriter $csvWriter Schreibt Output-CSV
|
||||
* @property MetadataExtractor $metadataExtractor Extrahiert Metadaten aus Header
|
||||
* @property ColumnTransformer $columnTransformer Transformiert Spalten
|
||||
* @property array $csvStructure CSV-Struktur-Konfiguration
|
||||
*/
|
||||
class TransformerEngine
|
||||
{
|
||||
private ConfigurationLoader $configLoader;
|
||||
private CsvWriter $csvWriter;
|
||||
private MetadataExtractor $metadataExtractor;
|
||||
private ColumnTransformer $columnTransformer;
|
||||
private array $csvStructure;
|
||||
private array $sampleData = [];
|
||||
private int $rowsProcessed = 0;
|
||||
private bool $debugMode = false;
|
||||
|
||||
/**
|
||||
* Initialisiert TransformerEngine mit Konfiguration
|
||||
*
|
||||
* Lädt alle erforderlichen Konfigurationen und initialisiert
|
||||
* die Komponenten (MetadataExtractor, ColumnTransformer, CsvWriter).
|
||||
* CsvReader wird später in transform() und validate() initialisiert mit dem Dateipfad.
|
||||
*
|
||||
* @param ConfigurationLoader $configLoader Lädt Konfigurationsdateien
|
||||
* @param bool $debugMode true = Debug-Modus aktivieren
|
||||
*
|
||||
* @throws \RuntimeException wenn erforderliche Konfigurationen fehlen
|
||||
*/
|
||||
public function __construct(ConfigurationLoader $configLoader, bool $debugMode = false)
|
||||
{
|
||||
$this->configLoader = $configLoader;
|
||||
$this->debugMode = $debugMode;
|
||||
|
||||
$config = $configLoader->getAll();
|
||||
|
||||
$this->csvStructure = $config['csvStructure'] ?? [];
|
||||
|
||||
$this->metadataExtractor = new MetadataExtractor(
|
||||
$config['metadata']['extractionRules'] ?? []
|
||||
);
|
||||
|
||||
$this->columnTransformer = new ColumnTransformer(
|
||||
$config['columnTransformations'] ?? [],
|
||||
[],
|
||||
$config['capitalizationExceptions'] ?? []
|
||||
);
|
||||
|
||||
// Bestimme Output-Dateiname aus Konfiguration
|
||||
$outputDir = $config['directories']['output'] ?? './output';
|
||||
$outputFileName = $config['csvStructure']['outputFilename'] ?? 'transformed.csv';
|
||||
$outputFile = rtrim($outputDir, '/') . '/' . $outputFileName;
|
||||
|
||||
$this->csvWriter = new CsvWriter(
|
||||
$outputFile,
|
||||
$config['csvStructure'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert oder deaktiviert den Debug-Modus
|
||||
*
|
||||
* @param bool $enabled true = Debug-Modus aktiviert
|
||||
* @return void
|
||||
*/
|
||||
public function setDebugMode(bool $enabled): void
|
||||
{
|
||||
$this->debugMode = $enabled;
|
||||
if ($enabled) {
|
||||
DebugLogger::enable();
|
||||
} else {
|
||||
DebugLogger::disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert eine CSV-Datei
|
||||
*
|
||||
* Führt folgende Schritte durch:
|
||||
* 1. CSV-Datei einlesen mit CsvReader
|
||||
* 2. Metadaten aus Header extrahieren
|
||||
* 3. Spalten gemäß Konfiguration transformieren
|
||||
* 4. Daten in Output-CSV schreiben
|
||||
* 5. Beispiel-Daten sammeln (maximal 3 Zeilen oder maxRows)
|
||||
*
|
||||
* Der Output-Dateipfad wird aus der Konfiguration bestimmt und kann nicht überschrieben werden.
|
||||
*
|
||||
* @param string $inputFile Pfad zur Input-CSV-Datei
|
||||
* @param int $maxRows Maximale Anzahl Datenzeilen zu transformieren (0 = alle).
|
||||
* Beispiel-Daten werden begrenzt auf min(3, maxRows)
|
||||
*
|
||||
* @return array Transformations-Ergebnis mit:
|
||||
* - success: bool (true = erfolgreich, false = Fehler)
|
||||
* - inputFile: string (Input-Dateipfad, nur bei Erfolg)
|
||||
* - outputFile: string (Output-Dateipfad, nur bei Erfolg)
|
||||
* - rowsProcessed: int (tatsächlich verarbeitete Datenzeilen)
|
||||
* - sampleData: array (Erste Beispiel-Zeilen, max 3 oder maxRows)
|
||||
* - metadata: array (Extrahierte Metadaten, nur bei Erfolg)
|
||||
* - outputColumns: int (Anzahl Output-Spalten)
|
||||
* - error: string (Fehlermeldung, nur bei Fehler)
|
||||
*/
|
||||
public function transform(string $inputFile, int $maxRows = 0): array
|
||||
{
|
||||
$this->sampleData = [];
|
||||
$this->rowsProcessed = 0;
|
||||
DebugLogger::reset();
|
||||
|
||||
try {
|
||||
if ($this->debugMode) {
|
||||
DebugLogger::log('transformer', 'Transformation started', [
|
||||
'inputFile' => $inputFile,
|
||||
'maxRows' => $maxRows
|
||||
]);
|
||||
}
|
||||
|
||||
// Validiere Input-Datei
|
||||
if (!file_exists($inputFile)) {
|
||||
throw new \RuntimeException("Input-Datei nicht gefunden: {$inputFile}");
|
||||
}
|
||||
|
||||
// Initialisiere CsvReader mit Dateipfad und Konfiguration
|
||||
$csvReader = new CsvReader($inputFile, $this->csvStructure);
|
||||
|
||||
// Lese Metadaten-Zeilen (vor der Header-Zeile)
|
||||
$metadataLines = $csvReader->readMetadataLines();
|
||||
|
||||
// Extrahiere Metadaten aus den Metadaten-Zeilen
|
||||
$metadata = $this->metadataExtractor->extract($metadataLines);
|
||||
|
||||
// Initialisiere ColumnTransformer mit extrahierten Metadaten
|
||||
$this->columnTransformer = new ColumnTransformer(
|
||||
$this->configLoader->get('columnTransformations', []),
|
||||
$metadata,
|
||||
$this->configLoader->get('capitalizationExceptions', [])
|
||||
);
|
||||
|
||||
// Lese CSV-Daten mit Header-Keys als Array-Keys
|
||||
$dataRows = $csvReader->readCsvData($maxRows);
|
||||
if (empty($dataRows)) {
|
||||
throw new \RuntimeException("Keine Datenzeilen in CSV-Datei");
|
||||
}
|
||||
|
||||
// Berechne Limit für Beispiel-Daten
|
||||
$sampleLimit = $maxRows == 0 ? 3 : $maxRows;
|
||||
|
||||
// Transformiere Zeilen und sammle sie
|
||||
$transformedData = [];
|
||||
|
||||
foreach ($dataRows as $row) {
|
||||
// Prüfe ob maxRows erreicht
|
||||
if ($maxRows > 0 && $this->rowsProcessed >= $maxRows) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Transformiere Zeile
|
||||
$transformedRow = $this->columnTransformer->transformRow($row);
|
||||
$transformedData[] = $transformedRow;
|
||||
|
||||
// Speichere Beispiel-Daten
|
||||
if (count($this->sampleData) < $sampleLimit) {
|
||||
$this->sampleData[] = $transformedRow;
|
||||
}
|
||||
|
||||
$this->rowsProcessed++;
|
||||
}
|
||||
|
||||
// Entferne Spalten die aus dem Output ausgeschlossen werden sollen
|
||||
$excludeColumns = $this->csvStructure['excludeOutputColumns'] ?? [];
|
||||
if (!empty($excludeColumns)) {
|
||||
$excludeMap = array_flip($excludeColumns);
|
||||
$transformedData = array_map(
|
||||
static fn(array $row): array => array_diff_key($row, $excludeMap),
|
||||
$transformedData
|
||||
);
|
||||
$this->sampleData = array_map(
|
||||
static fn(array $row): array => array_diff_key($row, $excludeMap),
|
||||
$this->sampleData
|
||||
);
|
||||
}
|
||||
|
||||
// Schreibe alle transformierten Daten in Output-CSV
|
||||
$this->csvWriter->write($transformedData);
|
||||
|
||||
$result = [
|
||||
'success' => true,
|
||||
'inputFile' => $inputFile,
|
||||
'outputFile' => $this->csvWriter->getOutputFile(),
|
||||
'rowsProcessed' => $this->rowsProcessed,
|
||||
'sampleData' => $this->sampleData,
|
||||
'metadata' => $metadata,
|
||||
'outputColumns' => $this->columnTransformer->getOutputColumns(),
|
||||
];
|
||||
|
||||
if ($this->debugMode) {
|
||||
$result['debug_logs'] = DebugLogger::getLogs();
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'rowsProcessed' => $this->rowsProcessed,
|
||||
'sampleData' => $this->sampleData,
|
||||
'outputColumns' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert und importiert CSV in Firefly III
|
||||
*
|
||||
* Führt Transformation durch und importiert die Ausgabe-Datei
|
||||
* in Firefly III wenn in der Konfiguration aktiviert.
|
||||
*
|
||||
* Rückwärts-kompatibel mit legacy Signatur.
|
||||
*
|
||||
* @param string $inputFile Pfad zur Input-CSV-Datei
|
||||
* @param int $maxRows Maximale Anzahl Datenzeilen zu verarbeiten (0 = alle)
|
||||
*
|
||||
* @return array Transformations- und Import-Ergebnis mit:
|
||||
* - success: bool (true = transformation erfolgreich)
|
||||
* - inputFile: string
|
||||
* - outputFile: string
|
||||
* - rowsProcessed: int
|
||||
* - sampleData: array
|
||||
* - metadata: array
|
||||
* - outputColumns: int
|
||||
* - import: array (Firefly Import-Ergebnis, wenn autoImport aktiv)
|
||||
* - error: string (falls Fehler)
|
||||
*/
|
||||
public function transformAndImport(string $inputFile, int $maxRows = 0): array
|
||||
{
|
||||
// Zuerst transformieren
|
||||
$transformResult = $this->transform($inputFile, $maxRows);
|
||||
|
||||
if (!$transformResult['success']) {
|
||||
return $transformResult;
|
||||
}
|
||||
|
||||
// Prüfe ob Auto-Import in Konfiguration aktiviert ist
|
||||
$fireflyConfig = $this->configLoader->get('fireflyImport', []);
|
||||
if (empty($fireflyConfig['autoImport'])) {
|
||||
return $transformResult;
|
||||
}
|
||||
|
||||
// Führe Firefly-Import durch
|
||||
try {
|
||||
$importer = new FireflyImporter($fireflyConfig);
|
||||
$importResult = $importer->import($transformResult['outputFile']);
|
||||
$transformResult['import'] = $importResult;
|
||||
|
||||
return $transformResult;
|
||||
} catch (\Exception $e) {
|
||||
$transformResult['import'] = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
return $transformResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert eine CSV-Datei gegen die Konfiguration
|
||||
*
|
||||
* Prüft ob erforderliche Metadaten vorhanden sind
|
||||
* und ob die CSV-Struktur der Konfiguration entspricht.
|
||||
*
|
||||
* @param string $inputFile Pfad zur zu validierenden CSV-Datei
|
||||
*
|
||||
* @return array Validierungs-Ergebnis mit:
|
||||
* - valid: bool (true = Validierung erfolgreich)
|
||||
* - metadata: array (Extrahierte Metadaten, wenn valid)
|
||||
* - line_count: int (Gesamtzahl Zeilen, wenn valid)
|
||||
* - error: string (Fehlermeldung, wenn nicht valid)
|
||||
* - metadata_found: array (Gefundene Metadaten trotz Fehler)
|
||||
*/
|
||||
public function validate(string $inputFile): array
|
||||
{
|
||||
try {
|
||||
if (!file_exists($inputFile)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => "Datei nicht gefunden: {$inputFile}",
|
||||
];
|
||||
}
|
||||
|
||||
// Initialisiere CsvReader mit Dateipfad
|
||||
$csvReader = new CsvReader($inputFile, $this->csvStructure);
|
||||
|
||||
// Extrahiere Metadaten-Zeilen (vor der Header-Zeile)
|
||||
$metadataLines = $csvReader->readMetadataLines();
|
||||
$metadata = $this->metadataExtractor->extract($metadataLines);
|
||||
|
||||
// Prüfe auf erforderliche Metadaten
|
||||
$requiredMetadata = [
|
||||
'account_iban',
|
||||
'currency_code',
|
||||
];
|
||||
|
||||
$missingMetadata = [];
|
||||
foreach ($requiredMetadata as $key) {
|
||||
if (empty($metadata[$key])) {
|
||||
$missingMetadata[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingMetadata)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Fehlende Metadaten: ' . implode(', ', $missingMetadata),
|
||||
'metadata_found' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
// Zähle Gesamtzahl Zeilen
|
||||
$lineCount = $csvReader->countLines();
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'metadata' => $metadata,
|
||||
'line_count' => $lineCount,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Validierungs-Fehler: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die gesammelten Beispiel-Daten zurück
|
||||
*
|
||||
* @return array Beispiel-Daten (maximal 3 oder maxRows Zeilen)
|
||||
*/
|
||||
public function getSampleData(): array
|
||||
{
|
||||
return $this->sampleData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl verarbeiteter Datenzeilen zurück
|
||||
*
|
||||
* @return int Anzahl transformierter Zeilen
|
||||
*/
|
||||
public function getRowsProcessed(): int
|
||||
{
|
||||
return $this->rowsProcessed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user