dolibarr.bankimport/vendor/nemiah/php-fints/lib/Fhp/Action/GetElectronicStatement.php
data 014a943f78 feat: HKEKA-Implementierung, PDF-Bugfixes, Sortierung, Umsatz-Umbenennung
- HKEKA v3/v4/v5 Segmente fuer phpFinTS implementiert (VR Bank unterstuetzt kein HKEKP)
- GetElectronicStatement Action mit Base64-Erkennung und Quittungscode
- PDF-Deduplizierung per MD5 (Bank sendet identische Saldenmitteilungen)
- Saldenmitteilungen ohne Auszugsnummer werden uebersprungen
- Datums-Validierung: 30.02. (Bank-Konvention) wird auf 28.02. korrigiert
- Numerische Sortierung fuer statement_number (CAST statt String-Sort)
- Jahr-Filter: statement_year=0 ausgeschlossen
- Menue/Button: "Kontoauszuege" -> "Umsaetze" (statements.php zeigt MT940, nicht PDFs)
- Redirect nach FinTS-Abruf auf aktuelles Jahr statt year=0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:10:59 +01:00

359 lines
14 KiB
PHP
Executable file

<?php
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\AnonymousSegment;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\Common\KtvV3;
use Fhp\Segment\EKA\HIEKA;
use Fhp\Segment\EKA\HIEKAS;
use Fhp\Segment\EKA\HKEKAv3;
use Fhp\Segment\EKA\HKEKAv4;
use Fhp\Segment\EKA\HKEKAv5;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\UnsupportedException;
/**
* Ruft elektronische Kontoauszuege von der Bank ab (HKEKA).
*
* Im Gegensatz zu GetBankStatement (HKEKP, nur PDF) unterstuetzt HKEKA
* verschiedene Formate: 1=MT940, 2=ISO8583, 3=PDF.
* Das gewuenschte Format wird im Request mitgesendet.
*/
class GetElectronicStatement extends PaginateableAction
{
// Kontoauszugsformate
const FORMAT_MT940 = '1';
const FORMAT_ISO8583 = '2';
const FORMAT_PDF = '3';
// Request-Parameter
/** @var SEPAAccount */
private $account;
/** @var string|null Gewuenschtes Format (1=MT940, 2=ISO8583, 3=PDF) */
private $format;
/** @var string|null Auszugsnummer */
private $statementNumber;
/** @var string|null Jahr (JJJJ) */
private $year;
// Response: Gesammelte Daten
/** @var array Array von ['data' => string, 'format' => string|null] */
private $statements = [];
/**
* @param SEPAAccount $account Das Konto fuer das Auszuege abgerufen werden sollen.
* @param string|null $format Gewuenschtes Format (FORMAT_MT940/FORMAT_ISO8583/FORMAT_PDF), null=Bank-Standard.
* @param string|null $statementNumber Optionale Auszugsnummer.
* @param string|null $year Optionales Jahr (JJJJ).
* @return GetElectronicStatement
*/
public static function create(
SEPAAccount $account,
?string $format = null,
?string $statementNumber = null,
?string $year = null
): GetElectronicStatement {
$result = new GetElectronicStatement();
$result->account = $account;
$result->format = $format;
$result->statementNumber = $statementNumber;
$result->year = $year;
return $result;
}
/**
* @deprecated
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->account,
$this->format,
$this->statementNumber,
$this->year,
];
}
/**
* @deprecated
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->account,
$this->format,
$this->statementNumber,
$this->year
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
/**
* @return array Array von ['data' => string, 'format' => string|null] pro Kontoauszug.
*/
public function getStatements(): array
{
$this->ensureDone();
return $this->statements;
}
/**
* Hilfsfunktion: Gibt nur die PDF-Daten zurueck (filtert nach Format=3 oder erkennt %PDF-).
* @return string[] Array von PDF-Binaerdaten.
*/
public function getPdfStatements(): array
{
$this->ensureDone();
$pdfs = [];
foreach ($this->statements as $stmt) {
$data = $stmt['data'];
$format = $stmt['format'] ?? null;
// PDF wenn Format=3 oder Daten mit %PDF- beginnen
if ($format === self::FORMAT_PDF || str_starts_with($data, '%PDF-')) {
$pdfs[] = $data;
}
}
return $pdfs;
}
/**
* Ermittelt die hoechste unterstuetzte HIEKAS-Version aus den BPD.
* Funktioniert auch mit AnonymousSegments (wenn unsere typisierten Klassen
* nicht zur Bank-Antwort passen).
*/
private function resolveHiekasVersion(BPD $bpd): int
{
// Erst typisierte Segmente versuchen
$hiekas = $bpd->getLatestSupportedParameters('HIEKAS');
if ($hiekas !== null) {
return $hiekas->getVersion();
}
// Fallback: Version aus anonymen BPD-Segmenten lesen
if (isset($bpd->parameters['HIEKAS'])) {
$versions = array_keys($bpd->parameters['HIEKAS']);
// Bereits absteigend sortiert (krsort in BPD::extractFromResponse)
$version = reset($versions);
error_log("[BankImport HKEKA] HIEKAS nur als AnonymousSegment verfuegbar, Version=" . $version);
return (int) $version;
}
throw new UnsupportedException('HIEKAS nicht in BPD gefunden');
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
error_log("[BankImport HKEKA] createRequest() aufgerufen");
$version = $this->resolveHiekasVersion($bpd);
error_log("[BankImport HKEKA] HIEKAS Version=" . $version);
error_log("[BankImport HKEKA] Account IBAN=" . $this->account->getIban() . ", BIC=" . $this->account->getBic());
error_log("[BankImport HKEKA] Format=" . ($this->format ?: 'null (Bank-Standard)')
. ", Auszugsnummer=" . ($this->statementNumber ?: 'null')
. ", Jahr=" . ($this->year ?: 'null'));
switch ($version) {
case 3:
error_log("[BankImport HKEKA] Erstelle HKEKAv3 (KtvV3-basiert, BLZ)");
return HKEKAv3::create(
KtvV3::fromAccount($this->account),
$this->format,
$this->statementNumber,
$this->year
);
case 4:
error_log("[BankImport HKEKA] Erstelle HKEKAv4 (Kti-basiert, IBAN/BIC)");
return HKEKAv4::create(
Kti::fromAccount($this->account),
$this->format,
$this->statementNumber,
$this->year
);
case 5:
error_log("[BankImport HKEKA] Erstelle HKEKAv5 (Kti-basiert, IBAN/BIC)");
return HKEKAv5::create(
Kti::fromAccount($this->account),
$this->format,
$this->statementNumber,
$this->year
);
default:
error_log("[BankImport HKEKA] FEHLER: Nicht unterstuetzte Version " . $version);
throw new UnsupportedException('Nicht unterstuetzte HKEKA-Version: ' . $version);
}
}
/**
* Loggt Diagnose-Informationen ueber anonyme HIEKA-Segmente in der Antwort.
* Wird aufgerufen wenn findSegments(HIEKA::class) leer ist, um die tatsaechliche
* Segment-Struktur der Bank zu analysieren.
*/
private function logAnonymousHiekaSegments(Message $response): void
{
foreach ($response->plainSegments as $seg) {
$name = $seg->getName();
if ($name !== 'HIEKA') {
continue;
}
$version = $seg->getVersion();
$class = get_class($seg);
error_log("[BankImport HKEKA] DIAGNOSE: Segment {$name}v{$version} class={$class}");
if ($seg instanceof AnonymousSegment) {
// Reflection um private 'elements' zu lesen
try {
$ref = new \ReflectionClass($seg);
$elProp = $ref->getProperty('elements');
$elProp->setAccessible(true);
$elements = $elProp->getValue($seg);
error_log("[BankImport HKEKA] DIAGNOSE: " . count($elements) . " Elemente im Segment");
foreach ($elements as $idx => $el) {
if ($el === null) {
error_log("[BankImport HKEKA] [{$idx}] NULL (leer)");
} elseif (is_array($el)) {
// DEG (Data Element Group)
$parts = array_map(function ($v) {
if ($v === null) return 'NULL';
$s = (string) $v;
return strlen($s) > 40 ? substr($s, 0, 40) . '...(' . strlen($s) . 'B)' : $s;
}, $el);
error_log("[BankImport HKEKA] [{$idx}] DEG: " . implode(' : ', $parts));
} else {
$val = (string) $el;
if (strlen($val) > 80) {
// Binaerdaten oder lange Strings kuerzen
$hex = bin2hex(substr($val, 0, 16));
error_log("[BankImport HKEKA] [{$idx}] BIN/LANG: " . strlen($val)
. " Bytes, Hex-Start=" . $hex
. ", Text-Start=" . substr($val, 0, 30));
} else {
error_log("[BankImport HKEKA] [{$idx}] " . $val);
}
}
}
} catch (\Throwable $e) {
error_log("[BankImport HKEKA] DIAGNOSE: Reflection fehlgeschlagen: " . $e->getMessage());
}
}
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
error_log("[BankImport HKEKA] processResponse() aufgerufen");
parent::processResponse($response);
// Bank sendet 3010 wenn keine Auszuege verfuegbar
$isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
$responseSegments = $response->findSegments(HIEKA::class);
error_log("[BankImport HKEKA] isUnavailable=" . ($isUnavailable ? 'JA' : 'NEIN')
. ", HIEKA-Segmente=" . count($responseSegments)
. ", Request-Segment-Nummern=" . implode(',', $this->getRequestSegmentNumbers()));
// Alle Rueckmeldungen loggen
try {
$rueckmeldungen = $response->findSegments(\Fhp\Segment\HIRMS\HIRMSv2::class);
foreach ($rueckmeldungen as $hirms) {
foreach ($hirms->rueckmeldung as $rm) {
error_log("[BankImport HKEKA] Rueckmeldung: Code=" . $rm->rueckmeldungscode
. " Ref=" . $rm->bezugsdatenelement
. " Text=" . $rm->rueckmeldungstext);
}
}
} catch (\Throwable $e) {
error_log("[BankImport HKEKA] Rueckmeldungen konnten nicht gelesen werden: " . $e->getMessage());
}
// Wenn keine typisierten HIEKA-Segmente gefunden: Diagnose-Logging
if (count($responseSegments) === 0) {
error_log("[BankImport HKEKA] Keine typisierten HIEKA-Segmente, pruefe anonyme...");
$this->logAnonymousHiekaSegments($response);
}
if (!$isUnavailable && count($responseSegments) === 0 && count($this->getRequestSegmentNumbers()) > 0) {
error_log("[BankImport HKEKA] FEHLER: Keine HIEKA-Segmente in Antwort!");
throw new UnexpectedResponseException('Keine HIEKA-Antwort-Segmente erhalten!');
}
/** @var HIEKA $hieka */
foreach ($responseSegments as $segIdx => $hieka) {
error_log("[BankImport HKEKA] Verarbeite HIEKA-Segment " . ($segIdx + 1) . "/" . count($responseSegments));
$format = $hieka->getKontoauszugsformat();
error_log("[BankImport HKEKA] Format=" . ($format ?: 'null')
. " (1=MT940, 2=ISO8583, 3=PDF)");
$data = $hieka->getKontoauszug()->getData();
$rawLen = strlen($data);
$rawStart = substr($data, 0, 20);
error_log("[BankImport HKEKA] Rohdaten: " . $rawLen . " Bytes, Anfang='" . $rawStart
. "', Hex=" . bin2hex(substr($data, 0, 10)));
// Pruefen ob Base64-kodiert
if ($format === self::FORMAT_PDF && !str_starts_with($data, '%PDF-')) {
error_log("[BankImport HKEKA] PDF-Format aber beginnt NICHT mit %PDF-, pruefe Base64...");
$decoded = base64_decode($data, true);
if ($decoded !== false && str_starts_with($decoded, '%PDF-')) {
error_log("[BankImport HKEKA] Base64-Dekodierung erfolgreich! " . strlen($decoded) . " Bytes");
$data = $decoded;
} else {
error_log("[BankImport HKEKA] WARNUNG: Base64-Dekodierung fehlgeschlagen, verwende Rohdaten");
}
} elseif ($format === null && !str_starts_with($data, '%PDF-')) {
// Kein Format angegeben, trotzdem Base64 pruefen
$decoded = base64_decode($data, true);
if ($decoded !== false && str_starts_with($decoded, '%PDF-')) {
error_log("[BankImport HKEKA] Ohne Format-Angabe: Base64-PDF erkannt! " . strlen($decoded) . " Bytes");
$data = $decoded;
$format = self::FORMAT_PDF;
}
}
// Quittung pruefen
$quittung = $hieka->getQuittung();
if ($quittung !== null) {
error_log("[BankImport HKEKA] Quittung vorhanden: " . strlen($quittung->getData()) . " Bytes");
}
if (!empty($data)) {
$this->statements[] = [
'data' => $data,
'format' => $format,
];
error_log("[BankImport HKEKA] Statement hinzugefuegt (Format=" . ($format ?: 'unbekannt')
. ", gesamt: " . count($this->statements) . ")");
} else {
error_log("[BankImport HKEKA] WARNUNG: Leere Daten, uebersprungen");
}
}
error_log("[BankImport HKEKA] processResponse() fertig, " . count($this->statements) . " Statements gesammelt");
}
}