$tenant->id, 'user_id' => $user->id, 'resource' => $resource, 'action' => $action, 'idempotency_key' => $idempotencyKey, 'status' => 'pending', 'item_ids' => $itemIds, 'total_items' => $effectiveTotalItems, 'processed_items' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'failures' => [], ]); $auditLog = $this->auditLogger->log( tenant: $tenant, action: "bulk.{$resource}.{$action}.created", context: [ 'metadata' => [ 'bulk_run_id' => $run->id, 'total_items' => $effectiveTotalItems, ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, resourceType: 'bulk_operation_run', resourceId: (string) $run->id ); $run->update(['audit_log_id' => $auditLog->id]); return $run; } public function start(BulkOperationRun $run): void { $run->update(['status' => 'running']); } public function recordSuccess(BulkOperationRun $run): void { $run->increment('processed_items'); $run->increment('succeeded'); } public function recordFailure(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void { $reason = $this->sanitizeFailureReason($reason); $failures = $run->failures ?? []; $failureEntry = [ 'item_id' => $itemId, 'reason' => $reason, 'timestamp' => now()->toIso8601String(), ]; if (is_string($reasonCode) && $reasonCode !== '') { $failureEntry['reason_code'] = $reasonCode; } $failures[] = $failureEntry; $run->update([ 'failures' => $failures, 'processed_items' => $run->processed_items + 1, 'failed' => $run->failed + 1, ]); } public function recordSkipped(BulkOperationRun $run): void { $run->increment('processed_items'); $run->increment('skipped'); } public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void { $reason = $this->sanitizeFailureReason($reason); $failures = $run->failures ?? []; $failureEntry = [ 'item_id' => $itemId, 'reason' => $reason, 'type' => 'skipped', 'timestamp' => now()->toIso8601String(), ]; if (is_string($reasonCode) && $reasonCode !== '') { $failureEntry['reason_code'] = $reasonCode; } $failures[] = $failureEntry; $run->update([ 'failures' => $failures, 'processed_items' => $run->processed_items + 1, 'skipped' => $run->skipped + 1, ]); } public function complete(BulkOperationRun $run): void { $run->refresh(); if ($run->processed_items > $run->total_items) { BulkOperationRun::query() ->whereKey($run->id) ->update(['total_items' => $run->processed_items]); $run->refresh(); } if (! in_array($run->status, ['pending', 'running'], true)) { return; } $failureEntries = collect($run->failures ?? []); $hasFailures = $run->failed > 0 || $failureEntries->contains(fn (array $entry): bool => ($entry['type'] ?? 'failed') !== 'skipped'); $status = $hasFailures ? 'completed_with_errors' : 'completed'; $updated = BulkOperationRun::query() ->whereKey($run->id) ->whereIn('status', ['pending', 'running']) ->update(['status' => $status]); if ($updated === 0) { return; } $run->refresh(); $failureEntries = collect($run->failures ?? []); $failedReasons = $failureEntries ->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped') ->groupBy('reason') ->map(fn ($group) => $group->count()) ->all(); $skippedReasons = $failureEntries ->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped') ->groupBy('reason') ->map(fn ($group) => $group->count()) ->all(); $this->auditLogger->log( tenant: $run->tenant, action: "bulk.{$run->resource}.{$run->action}.{$status}", context: [ 'metadata' => [ 'bulk_run_id' => $run->id, 'succeeded' => $run->succeeded, 'failed' => $run->failed, 'skipped' => $run->skipped, 'failed_reasons' => $failedReasons, 'skipped_reasons' => $skippedReasons, ], ], actorId: $run->user_id, resourceType: 'bulk_operation_run', resourceId: (string) $run->id ); } public function fail(BulkOperationRun $run, string $reason): void { $run->update(['status' => 'failed']); $reason = $this->sanitizeFailureReason($reason); $this->auditLogger->log( tenant: $run->tenant, action: "bulk.{$run->resource}.{$run->action}.failed", context: [ 'reason' => $reason, 'metadata' => [ 'bulk_run_id' => $run->id, ], ], actorId: $run->user_id, status: 'failure', resourceType: 'bulk_operation_run', resourceId: (string) $run->id ); } public function abort(BulkOperationRun $run, string $reason): void { $run->update(['status' => 'aborted']); $reason = $this->sanitizeFailureReason($reason); $this->auditLogger->log( tenant: $run->tenant, action: "bulk.{$run->resource}.{$run->action}.aborted", context: [ 'reason' => $reason, 'metadata' => [ 'bulk_run_id' => $run->id, 'succeeded' => $run->succeeded, 'failed' => $run->failed, 'skipped' => $run->skipped, ], ], actorId: $run->user_id, status: 'failure', resourceType: 'bulk_operation_run', resourceId: (string) $run->id ); } }