InvoiceTextParser.php
Pfad: src/Infrastructure/Rechnung/InvoiceTextParser.php
Ext: php
Größe: 4141 Bytes
Geändert: 2026-07-16 17:34:48+02
<?php
declare(strict_types=1);
namespace Demo\Infrastructure\Rechnung;
use DateTimeImmutable;
use Demo\Domain\Rechnung\Amount;
use Demo\Domain\Rechnung\ExtractedInvoiceData;
use RuntimeException;
/**
* Gemeinsamer labelbasierter Parser fuer PDF-Text und OCR-Text.
*/
final class InvoiceTextParser
{
/**
* Extrahiert fachliche Felder mit deterministischen Regeln.
*/
public function parse(string $text, float $baseConfidence): ExtractedInvoiceData
{
$normalized = str_replace("\r", '', $text);
$vendor = $this->vendor($normalized);
$number = $this->match($normalized, [
'/\b(RF\d{6,})\b/i',
'/Rechnungs(?:nummer|-Nr\.)\s*:?\s*([A-Z0-9-]{5,})/iu',
]);
$dateRaw = $this->match($normalized, [
'/Rechnungsdatum\s*:?\s*(\d{2}\.\d{2}\.\d{4})/isu',
'/Rechnung\s+f[üu]r.*?(\d{2}\.\d{2}\.\d{4})/isu',
]);
$date = $this->date($dateRaw);
$net = $this->amount($normalized, [
'/BETRAG\s+EXKL\.\s*MWST\.?\s*([0-9.,]+)/iu',
'/SUMME\s+IN\s+EURO\s*\(NETTO\)\s*:?\s*([0-9.,]+)/iu',
'/Netto(?:betrag)?\s*:?\s*([0-9.,]+)/iu',
]);
$gross = $this->amount($normalized, [
'/GESAMTRECHNUNGSBETRAG\s+INKL\.\s*MWST\.?\s*(?:SUMME\s*:)?\s*([0-9.,]+)/iu',
'/Zu\s+zahlender\s+Betrag.*?([0-9.,]+)/iu',
'/Brutto(?:betrag)?\s*:?\s*([0-9.,]+)/iu',
]);
$tax = $this->match($normalized, [
'/(?:Mehrwertsteuer|Umsatzsteuer|MwSt[.-]?Betrag)\s*:?\s*([0-9.,]+)/iu',
'/(?:^|\n)\s*\d{1,2}(?:[.,]\d+)?\s*%\s*MwSt\.?\s*:?\s*([0-9.,]+)\s*(?:EUR|€)?\s*$/imu',
]);
if ($tax === '') {
$tax = Amount::subtract($gross, $net);
}
$signature = $this->addressSignature($normalized);
$confidence = min(1.0, $baseConfidence + ($number !== '' && $date !== '' && $gross !== '0.0000' ? 0.15 : 0.0));
return new ExtractedInvoiceData($vendor, $signature, strtoupper($number), $date, new Amount($net, $tax, $gross, 'EUR'), $confidence);
}
/** Ermittelt einen Firmennamen ohne den gesamten Dokumenttext zu uebernehmen. */
private function vendor(string $text): string
{
if (preg_match('/\bWEtell\s+GmbH\b/i', $text) === 1) {
return 'WEtell GmbH';
}
return $this->match($text, ['/\b([\p{Lu}][\p{L}&. -]{1,70}\s(?:GmbH|AG|UG|KG|e\.K\.))\b/u']);
}
/** Normalisiert ein deutsches Rechnungsdatum nach Y-m-d. */
private function date(string $raw): string
{
$date = DateTimeImmutable::createFromFormat('!d.m.Y', $raw);
return $date === false ? '' : $date->format('Y-m-d');
}
/** @param list<string> $patterns Extrahiert einen Betrag oder liefert exakt null. */
private function amount(string $text, array $patterns): string
{
$raw = $this->match($text, $patterns);
return $raw === '' ? '0.0000' : Amount::decimal($raw);
}
/** Liefert die fuer Kontaktabgleich und -anlage kanonische Anschrift. */
private function addressSignature(string $text): string
{
$pattern = '/([\p{L}.-]+(?:stra(?:ß|ss)e|weg|platz)\s+\d+[a-z]?)\s*(?:,|\n)+\s*(\d{5})\s+([\p{L} -]+)/iu';
$result = preg_match($pattern, $text, $matches);
if ($result === false) {
throw new RuntimeException('Ungueltiges Adressmuster');
}
if ($result !== 1) {
return '';
}
return trim($matches[1]) . '|' . $matches[2] . ' ' . trim($matches[3]) . '|DE';
}
/**
* @param list<string> $patterns Regex-Muster mit einer Capture-Gruppe.
*/
private function match(string $text, array $patterns): string
{
foreach ($patterns as $pattern) {
$result = preg_match($pattern, $text, $matches);
if ($result === false) {
throw new RuntimeException('Ungueltiges Extraktionsmuster');
}
if ($result === 1 && isset($matches[1])) {
return trim((string) $matches[1]);
}
}
return '';
}
}
Frühere Versionen
| Version | Zeitpunkt | Operation |
|---|---|---|
| 41 | 2026-07-16 19:55:01.889209+02 | UPDATE |