resolveUser((string) $this->argument('user')); if ($user === null) { $this->error('User not found.'); return self::FAILURE; } if (! $gate->allows($user)) { $this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).'); return self::FAILURE; } $pendingIds = Transaction::query() ->where('user_id', $user->id) ->whereNull('category_id') ->whereNull('description_iv') ->pluck('id'); if ($pendingIds->isEmpty()) { $this->info('No uncategorized transactions to backfill.'); return self::SUCCESS; } $rulesBefore = $this->aiRuleCount($user); $applied = 0; $batchSize = max(1, (int) config('ai_categorization.group_batch_size')); // Chunk a fixed snapshot of ids so transactions left blank (below the // confidence bar) are never re-processed on a later iteration. $this->withProgressBar( $pendingIds->chunk($batchSize), function ($chunkIds) use ($user, $categorizer, &$applied): void { $chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get(); $outcomes = $categorizer->run($user, new Collection($chunk->all())); $applied += count(array_filter($outcomes, fn ($outcome): bool => $outcome->applied)); }, ); $this->newLine(2); $this->components->info(sprintf( 'Backfill complete: %d transaction(s) categorized, %d AI rule(s) learned.', $applied, $this->aiRuleCount($user) - $rulesBefore, )); return self::SUCCESS; } private function aiRuleCount(User $user): int { return AutomationRule::query() ->where('user_id', $user->id) ->origin(RuleOrigin::Ai) ->count(); } private function resolveUser(string $identifier): ?User { return User::query()->where('email', $identifier)->first() ?? User::query()->find($identifier); } }