← Code-Übersicht

InvoiceTextParser.php

Pfad: src/Infrastructure/Rechnung/InvoiceTextParser.php

Ext: php

Größe: 3553 Bytes

Geändert: 2026-07-16T16:57:10+02:00

Frühere Version vom 2026-07-16T16:57:10+02:00 · zur aktuellen Fassung

<?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, ['/MwSt\.?\s*(?:\d{1,2}(?:[.,]\d+)?\s*%)?\s*:?\s*([0-9.,]+)/iu']);
        if ($tax === '') {
            $tax = Amount::subtract($gross, $net);
        }
        $address = $this->match($normalized, ['/([\p{L}.-]+(?:stra(?:ß|ss)e|weg|platz)\s+\d+[^\n]*\n?\s*\d{5}\s+[\p{L} -]+)/iu']);
        $signature = sha1(strtolower(trim($vendor . ' ' . $address)));
        $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');
    }

    /** Extrahiert und normalisiert 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);
    }

    /**
     * @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 '';
    }
}