← Code-Übersicht

IngestInvoicesUseCase.php

Pfad: src/Application/Rechnung/IngestInvoicesUseCase.php

Ext: php

Größe: 8932 Bytes

Geändert: 2026-07-16T14:13:30+02:00

Frühere Version vom 2026-07-16T14:13:30+02:00 · zur aktuellen Fassung

<?php
declare(strict_types=1);

namespace Demo\Application\Rechnung;

use Demo\Domain\Rechnung\ClockPort;
use Demo\Domain\Rechnung\InvoiceErrorCode;
use Demo\Domain\Rechnung\InvoiceLoggerPort;
use Demo\Domain\Rechnung\InvoiceRecord;
use Demo\Domain\Rechnung\InvoiceState;
use Demo\Domain\Rechnung\InvoiceStatePort;
use Demo\Domain\Rechnung\InvoiceStatus;
use Demo\Domain\Rechnung\SourceInvoice;

/**
 * Orchestriert den End-to-End-Lauf:
 * Beschaffung -> Extraktion -> Kontaktabgleich -> Buchung.
 */
final class IngestInvoicesUseCase
{
    /**
     * @param InvoiceStatePort       $statePort       Persistenz.
     * @param DiscoverInvoicesUseCase $discover       Entdeckung.
     * @param DownloadInvoiceUseCase  $download       Download.
     * @param ExtractInvoiceDataUseCase $extract       Extraktion.
     * @param ResolveVendorUseCase   $resolveVendor   Kontaktabgleich.
     * @param PostInvoiceUseCase     $post           Buchung.
     * @param InvoiceLoggerPort      $logger         Logging.
     * @param ClockPort              $clock          Uhrzeit.
     * @param int                   $maxRetries     Retry-Versuche pro Rechnung.
     * @param float                 $amountTolerance Betrags-Toleranz.
     */
    public function __construct(
        private InvoiceStatePort $statePort,
        private DiscoverInvoicesUseCase $discover,
        private DownloadInvoiceUseCase $download,
        private ExtractInvoiceDataUseCase $extract,
        private ResolveVendorUseCase $resolveVendor,
        private PostInvoiceUseCase $post,
        private InvoiceLoggerPort $logger,
        private ClockPort $clock,
        private int $maxRetries,
        private float $amountTolerance
    ) {
    }

    /**
     * @param string $runId    Laufkennung.
     * @param string $username User.
     * @param string $password Passwort.
     * @return IngestResult Ergebnis.
     */
    public function execute(string $runId, string $username, string $password): IngestResult
    {
        $state = $this->statePort->load();
        $state = new InvoiceState(
            schemaVersion: $state->schemaVersion,
            generatedAt: $this->clock->now(),
            records: $state->all()
        );

        $sourceInvoices = $this->discover->execute($runId, $username, $password);
        if (count($sourceInvoices) === 0) {
            $this->statePort->save($state);

            return new IngestResult($runId, InvoiceStatus::DONE_NO_WORK, 0, 0, 0, 0, ['summary' => 'leer']);
        }

        $processed = 0;
        $skipped = 0;
        $errors = 0;

        foreach ($sourceInvoices as $sourceInvoice) {
            if (!$this->shouldProcess($sourceInvoice, $state)) {
                $skipped++;
                continue;
            }

            $record = $state->findBySourceId($sourceInvoice->sourceId) ?? InvoiceRecord::fromSourceInvoice($sourceInvoice);
            $state = $state->withRecord($record->withRetry(0, $this->clock->now()));

            $attempt = 0;
            while (true) {
                $attempt++;
                $record = $record->withRetry($attempt, $this->clock->now());

                try {
                    $record = $this->download->execute($runId, $sourceInvoice, $record);
                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $extracted = $this->extract->execute($runId, $record);
                    if (!$extracted->hasPlausibleAmount($this->amountTolerance)) {
                        throw new InvoiceProcessException(
                            InvoiceErrorCode::EXTRACT,
                            'extract',
                            'Betragslogik nicht plausibel'
                        );
                    }

                    $record = $record->withExtraction($extracted);
                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $contactId = $this->resolveVendor->execute($runId, $extracted);
                    $record = $record->withContact($contactId, 'resolved');
                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $posting = $this->post->execute($runId, $record, $extracted, $contactId);
                    $record = $record->withPosting($posting);
                    $record = $record->withStatus(InvoiceStatus::POSTED);

                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $this->logger->logStep($runId, 'invoice', 'DONE', [
                        'sourceId' => $sourceInvoice->sourceId,
                        'voucher' => $record->lexofficeVoucherId,
                    ]);

                    $processed++;
                    break;
                } catch (InvoiceProcessException $exception) {
                    $status = InvoiceErrorCode::statusForError($exception->errorCode);
                    $record = $record->withError(
                        $status,
                        $exception->errorCode,
                        $exception->step,
                        $exception->getMessage(),
                        $this->clock->now()
                    );
                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $this->logger->logStep($runId, 'invoice', 'ERROR', [
                        'sourceId' => $sourceInvoice->sourceId,
                        'code' => $exception->errorCode,
                        'message' => $exception->getMessage(),
                    ]);

                    if ($attempt < $this->maxRetries && InvoiceErrorCode::isRetryable($exception->errorCode)) {
                        sleep(min(4, $attempt));
                        continue;
                    }

                    $errors++;
                    break;
                } catch (\Throwable $error) {
                    $record = $record->withError(
                        InvoiceStatus::ERROR_POSTING,
                        InvoiceErrorCode::PERSISTENCE,
                        'pipeline',
                        $error->getMessage(),
                        $this->clock->now()
                    );
                    $state = $state->withRecord($record);
                    $this->statePort->save($state);

                    $this->logger->logStep($runId, 'invoice', 'ERROR', [
                        'sourceId' => $sourceInvoice->sourceId,
                        'code' => InvoiceErrorCode::PERSISTENCE,
                        'message' => $error->getMessage(),
                    ]);

                    $errors++;
                    break;
                }
            }

            $state = $state->withRecord($record);
            $this->statePort->save($state);
        }

        $status = $this->deriveRunStatus($sourceInvoices, $processed, $errors);
        $this->logger->logStep($runId, 'run', $status, [
            'discovered' => count($sourceInvoices),
            'processed' => $processed,
            'errors' => $errors,
            'skipped' => $skipped,
        ]);

        return new IngestResult(
            runId: $runId,
            status: $status,
            discovered: count($sourceInvoices),
            processed: $processed,
            skipped: $skipped,
            errors: $errors,
            stats: [
                'processed' => $processed,
                'skipped' => $skipped,
                'errors' => $errors,
            ]
        );
    }

    /**
     * @param SourceInvoice $source Invoice aus der Quelle.
     * @param InvoiceState  $state  Aktueller Zustand.
     * @return bool True, wenn der Datensatz weiterverarbeitet werden soll.
     */
    private function shouldProcess(SourceInvoice $source, InvoiceState $state): bool
    {
        $existing = $state->findBySourceId($source->sourceId);
        if ($existing === null) {
            return true;
        }

        if ($existing->status === InvoiceStatus::POSTED) {
            return false;
        }

        return true;
    }

    /**
     * @param list<SourceInvoice> $sourceInvoices Alle entdeckten Datensaetze.
     * @param int                 $processed      Erfolgreich gebuchte.
     * @param int                 $errors         Fehler.
     * @return string Gesamtstatus.
     */
    private function deriveRunStatus(array $sourceInvoices, int $processed, int $errors): string
    {
        if (count($sourceInvoices) === 0) {
            return InvoiceStatus::DONE_NO_WORK;
        }

        if ($errors === 0) {
            return InvoiceStatus::DONE;
        }

        $errorRate = ($errors / count($sourceInvoices)) * 100;
        if ($errorRate >= 20.0) {
            return InvoiceStatus::DONE_PARTIAL;
        }

        return InvoiceStatus::DONE_WITH_WARNINGS;
    }
}