← Code-Übersicht

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