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)];
}
}