whisper-money/app/Http/Controllers/Settings/AutomationRuleApplicationCo...

223 lines
7.1 KiB
PHP

<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ApplyAutomationRuleRequest;
use App\Jobs\ApplySingleAutomationRuleJob;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Services\AutomationRuleService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AutomationRuleApplicationController extends Controller
{
use AuthorizesRequests;
private const SYNC_THRESHOLD = 100;
private const MATCHES_CACHE_TTL_MINUTES = 15;
private const PER_PAGE_DEFAULT = 50;
private const PER_PAGE_MAX = 100;
/**
* Return paginated transactions matching this rule.
*/
public function matches(
Request $request,
AutomationRule $automationRule,
AutomationRuleService $service,
): JsonResponse {
$this->authorize('update', $automationRule);
$onlyUncategorized = $request->boolean('only_uncategorized', true);
$offset = max(0, (int) $request->integer('offset', 0));
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->integer('per_page', self::PER_PAGE_DEFAULT)));
$matchingIds = $this->resolveMatchingIds($automationRule, $service, $onlyUncategorized);
$total = count($matchingIds);
$pageIds = array_slice($matchingIds, $offset, $perPage);
$transactions = Transaction::query()
->whereIn('id', $pageIds)
->with(['account:id,name,bank_id', 'account.bank:id,name', 'category:id,name,icon,color', 'labels:id,name,color'])
->orderByDesc('transaction_date')
->orderByDesc('created_at')
->get();
$byId = $transactions->keyBy('id');
$ordered = collect($pageIds)
->map(fn (string $id) => $byId->get($id))
->filter()
->values();
$nextOffset = $offset + $transactions->count();
return response()->json([
'data' => $ordered,
'total' => $total,
'next_offset' => $nextOffset < $total ? $nextOffset : null,
]);
}
/**
* Apply the rule's actions to all matching transactions.
*
* Runs synchronously when the match count is below the threshold, otherwise
* dispatches a queued job and returns a job id for status polling.
*/
public function apply(
ApplyAutomationRuleRequest $request,
AutomationRule $automationRule,
AutomationRuleService $service,
): JsonResponse {
$this->authorize('update', $automationRule);
$automationRule->loadMissing('labels');
$onlyUncategorized = (bool) $request->boolean('only_uncategorized', true);
$matchingIds = $this->resolveMatchingIds($automationRule, $service, $onlyUncategorized);
$total = count($matchingIds);
if ($total === 0) {
return response()->json([
'status' => 'done',
'processed' => 0,
'total' => 0,
'applied' => 0,
'updated' => 0,
]);
}
if ($total <= self::SYNC_THRESHOLD) {
$changed = 0;
$transactions = Transaction::query()
->where('user_id', $automationRule->user_id)
->whereIn('id', $matchingIds)
->whereNull('description_iv')
->with(['account.bank', 'category', 'labels'])
->get();
foreach ($transactions as $transaction) {
if ($service->applyRuleActions($transaction, $automationRule)) {
$changed++;
}
}
$applied = $transactions->count();
$this->forgetMatchesCache($automationRule, $onlyUncategorized);
return response()->json([
'status' => 'done',
'processed' => $applied,
'total' => $total,
'applied' => $applied,
'updated' => $changed,
]);
}
$jobId = (string) Str::uuid();
Cache::put(
ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId),
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0, 'updated' => 0],
now()->addHour(),
);
ApplySingleAutomationRuleJob::dispatch($automationRule, $jobId, $matchingIds);
$this->forgetMatchesCache($automationRule, $onlyUncategorized);
return response()->json([
'job_id' => $jobId,
'total' => $total,
], 202);
}
/**
* Return progress for a running apply job.
*/
public function status(Request $request, string $jobId): JsonResponse
{
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId));
if ($progress === null) {
return response()->json(['message' => 'Job not found.'], 404);
}
return response()->json($progress);
}
/**
* Resolve and cache the list of transaction IDs matching this rule.
*
* @return array<int, string>
*/
private function resolveMatchingIds(
AutomationRule $rule,
AutomationRuleService $service,
bool $onlyUncategorized,
): array {
$cacheKey = $this->matchesCacheKey($rule, $onlyUncategorized);
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return array_values(array_unique($cached));
}
$rule->loadMissing('labels');
$ids = [];
$eagerLoads = $service->eagerLoadsForRuleEvaluation($rule);
if ($onlyUncategorized && $rule->action_category_id === null) {
$eagerLoads[] = 'labels';
}
Transaction::query()
->where('user_id', $rule->user_id)
->whereNull('description_iv')
->with(array_values(array_unique($eagerLoads)))
->orderByDesc('transaction_date')
->orderByDesc('created_at')
->chunkById(500, function ($transactions) use ($rule, $service, $onlyUncategorized, &$ids) {
foreach ($transactions as $transaction) {
if ($onlyUncategorized && $service->shouldSkipForOnlyUncategorized($rule, $transaction)) {
continue;
}
if ($service->ruleMatches($rule, $transaction)) {
$ids[] = $transaction->id;
}
}
});
$ids = array_values(array_unique($ids));
Cache::put($cacheKey, $ids, now()->addMinutes(self::MATCHES_CACHE_TTL_MINUTES));
return $ids;
}
private function matchesCacheKey(AutomationRule $rule, bool $onlyUncategorized): string
{
$flag = $onlyUncategorized ? '1' : '0';
$stamp = $rule->updated_at?->getTimestamp() ?? 0;
return "automation_rule_matches:{$rule->user_id}:{$rule->id}:{$flag}:{$stamp}";
}
private function forgetMatchesCache(AutomationRule $rule, bool $onlyUncategorized): void
{
Cache::forget($this->matchesCacheKey($rule, $onlyUncategorized));
}
}