← Code-Übersicht

ProcessInvoiceUseCase.php

Pfad: src/Application/Rechnung/ProcessInvoiceUseCase.php

Ext: php

Größe: 5643 Bytes

Geändert: 2026-07-16 17:43:25+02

<?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;
use Throwable;

final class ProcessInvoiceUseCase
{
    public function __construct(
        private readonly InvoiceStatePort $statePort,
        private readonly DownloadInvoiceUseCase $download,
        private readonly ExtractInvoiceDataUseCase $extract,
        private readonly ResolveVendorUseCase $resolveVendor,
        private readonly PostInvoiceUseCase $post,
        private readonly InvoiceLoggerPort $logger,
        private readonly ClockPort $clock,
        private readonly int $maxRetries,
        private readonly float $amountTolerance,
    ) {
    }

    public function execute(string $runId, SourceInvoice $source, InvoiceState $state): InvoiceProcessOutcome
    {
        $record = $state->findBySourceId($source->sourceId) ?? InvoiceRecord::fromSourceInvoice($source);
        for ($attempt = 1; ; $attempt++) {
            $record = $record->withRetry($attempt, $this->clock->now());
            $phase = 'download';
            try {
                $record = $this->download->execute($runId, $source, $record);
                if ($this->isDuplicate($state, $record)) {
                    return $this->skipped($runId, $source, $state, $record);
                }
                $phase = 'persist';
                $state = $this->persist($state, $record);
                $phase = 'extract';
                $data = $this->extract->execute($runId, $record);
                if (!$data->hasPlausibleAmount($this->amountTolerance)) {
                    throw new InvoiceProcessException(InvoiceErrorCode::EXTRACT, 'extract', 'Betragslogik nicht plausibel');
                }
                $record = $record->withExtraction($data);
                if ($this->isDuplicate($state, $record)) {
                    return $this->skipped($runId, $source, $state, $record);
                }
                $phase = 'persist';
                $state = $this->persist($state, $record);
                $phase = 'resolve_vendor';
                $contactId = $this->resolveVendor->execute($runId, $data);
                $record = $record->withContact($contactId, 'resolved');
                $phase = 'persist';
                $state = $this->persist($state, $record);
                $phase = 'post';
                $posting = $this->post->execute($runId, $record, $data, $contactId);
                $record = $record->withPosting($posting)->withStatus(InvoiceStatus::POSTED);
                $phase = 'persist';
                $state = $this->persist($state, $record);
                $this->logger->logStep($runId, 'invoice', 'DONE', ['sourceId' => $source->sourceId, 'voucher' => $record->lexofficeVoucherId]);
                return new InvoiceProcessOutcome(InvoiceProcessOutcome::PROCESSED, $state, $record);
            } catch (Throwable $error) {
                [$code, $step] = $this->classify($error, $phase);
                $record = $record->withError(InvoiceErrorCode::statusForError($code), $code, $step, $error->getMessage(), $this->clock->now());
                $state = $this->persist($state, $record);
                $this->logger->logStep($runId, 'invoice', 'ERROR', ['sourceId' => $source->sourceId, 'code' => $code, 'message' => $error->getMessage()]);
                if ($attempt < $this->maxRetries && InvoiceErrorCode::isRetryable($code)) {
                    sleep(min(4, $attempt));
                    continue;
                }
                return new InvoiceProcessOutcome(InvoiceProcessOutcome::ERROR, $state, $record);
            }
        }
    }

    private function persist(InvoiceState $state, InvoiceRecord $record): InvoiceState
    {
        $state = $state->withRecord($record);
        $this->statePort->save($state);
        return $state;
    }

    private function isDuplicate(InvoiceState $state, InvoiceRecord $record): bool
    {
        $hashMatch = $record->payloadSha256 === null ? null : $state->findByPayloadSha256($record->payloadSha256);
        $keyMatch = $state->findByRechnungsschluessel($record->rechnungsschluessel);
        foreach ([$hashMatch, $keyMatch] as $match) {
            if ($match !== null && $match->quelleId !== $record->quelleId && InvoiceStatus::isSuccess($match->status)) {
                return true;
            }
        }
        return false;
    }

    private function skipped(string $runId, SourceInvoice $source, InvoiceState $state, InvoiceRecord $record): InvoiceProcessOutcome
    {
        $this->logger->logStep($runId, 'invoice', 'SKIPPED_DUPLICATE', ['sourceId' => $source->sourceId]);
        return new InvoiceProcessOutcome(InvoiceProcessOutcome::SKIPPED, $state, $record);
    }

    /** @return array{string,string} */
    private function classify(Throwable $error, string $phase): array
    {
        if ($error instanceof InvoiceProcessException) {
            return [$error->errorCode, $error->step];
        }
        $code = match ($phase) {
            'download' => InvoiceErrorCode::DOWNLOAD,
            'extract' => InvoiceErrorCode::EXTRACT,
            'resolve_vendor' => InvoiceErrorCode::VENDOR_MATCH,
            'post' => InvoiceErrorCode::POSTING,
            default => InvoiceErrorCode::PERSISTENCE,
        };
        return [$code, InvoiceErrorCode::stepForCode($code)];
    }
}