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