*/ private array $startedAtByRunId = []; public function __construct(private readonly ImportAuditRunRepository $importAuditRunRepository) { } public function startRun( string $profileKey, array $mappedTargets, ?string $sourceFilename, int $userId, ?int $currentTenantId ): ?int { $profileKey = trim($profileKey); if ($profileKey === '') { return null; } try { $runId = $this->importAuditRunRepository->createRunning([ 'run_uuid' => RepoQuery::uuidV4(), 'profile_key' => $profileKey, 'status' => 'running', 'source_filename' => $this->normalizeSourceFilename($sourceFilename), 'mapped_targets_csv' => $this->normalizeMappedTargetsCsv($mappedTargets), 'user_id' => $userId > 0 ? $userId : null, 'current_tenant_id' => ($currentTenantId ?? 0) > 0 ? (int) $currentTenantId : null, ]); } catch (\Throwable $exception) { return null; } if (!$runId) { return null; } $this->startedAtByRunId[(int) $runId] = microtime(true); return (int) $runId; } public function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void { $runId = (int) ($runId ?? 0); if ($runId <= 0) { return; } $rowsTotal = max(0, (int) ($result['processed'] ?? 0)); $createdCount = max(0, (int) ($result['created'] ?? 0)); $skippedCount = max(0, (int) ($result['skipped'] ?? 0)); $failedCount = max(0, (int) ($result['failed'] ?? 0)); $status = $this->normalizeStatus($forcedStatus); if ($status === null) { if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) { $status = 'failed'; } elseif ($failedCount <= 0) { $status = 'success'; } elseif ($createdCount > 0 || $skippedCount > 0) { $status = 'partial'; } else { $status = 'failed'; } } $durationMs = null; if (isset($this->startedAtByRunId[$runId])) { $durationMs = (int) round((microtime(true) - $this->startedAtByRunId[$runId]) * 1000); if ($durationMs < 0) { $durationMs = 0; } unset($this->startedAtByRunId[$runId]); } $errorCodesJson = $this->encodeErrorCounts($result); try { $this->importAuditRunRepository->finishById($runId, [ 'status' => $status, 'rows_total' => $rowsTotal, 'created_count' => $createdCount, 'skipped_count' => $skippedCount, 'failed_count' => $failedCount, 'error_codes_json' => $errorCodesJson, 'duration_ms' => $durationMs, ]); } catch (\Throwable $exception) { // Fail-open: import flow must not fail because audit logging fails. } } public function listPaged(array $filters): array { return $this->importAuditRunRepository->listPaged($filters); } public function find(int $id): ?array { return $this->importAuditRunRepository->find($id); } public function purgeExpired(): int { return $this->importAuditRunRepository->purgeOlderThanDays(self::RETENTION_DAYS); } /** * @param array $mappedTargets */ private function normalizeMappedTargetsCsv(array $mappedTargets): ?string { $normalized = []; foreach ($mappedTargets as $target) { $value = trim((string) $target); if ($value === '') { continue; } $normalized[$value] = true; } if (!$normalized) { return null; } $list = array_keys($normalized); sort($list, SORT_STRING); return implode(',', $list); } private function normalizeSourceFilename(?string $sourceFilename): ?string { $sourceFilename = trim((string) ($sourceFilename ?? '')); if ($sourceFilename === '') { return null; } $base = basename(str_replace("\0", '', $sourceFilename)); if ($base === '' || $base === '.' || $base === '..') { return null; } return strlen($base) > 255 ? substr($base, 0, 255) : $base; } private function normalizeStatus(?string $status): ?string { $status = strtolower(trim((string) ($status ?? ''))); if ($status === '') { return null; } return in_array($status, ['running', 'success', 'partial', 'failed'], true) ? $status : null; } private function encodeErrorCounts(array $result): ?string { $counts = []; $fromResult = $result['error_counts'] ?? null; if (is_array($fromResult)) { foreach ($fromResult as $rawCode => $rawCount) { $code = trim((string) $rawCode); $count = (int) $rawCount; if ($code === '' || $count <= 0) { continue; } $counts[$code] = ($counts[$code] ?? 0) + $count; } } if (!$counts) { $errors = $result['errors'] ?? []; if (is_array($errors)) { foreach ($errors as $error) { if (!is_array($error)) { continue; } $code = trim((string) ($error['code'] ?? '')); if ($code === '') { continue; } $counts[$code] = ($counts[$code] ?? 0) + 1; } } } if (!$counts) { return null; } ksort($counts, SORT_STRING); $encoded = json_encode($counts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } }