load(['stages' => fn ($query) => $query->where('is_active', true)]); $query = Deal::with('owner:id,name', 'company:id,name', 'contact:id,name') ->where('pipeline_id', $pipeline->id); AccessControl::scopeDeals($query, $user); foreach (['owner_id', 'forecast_category', 'status'] as $filter) { if (! empty($filters[$filter])) { $query->where($filter, $filters[$filter]); } } if (! empty($filters['search'])) { $search = $filters['search']; $query->where(fn ($q) => $q->where('title', 'like', "%{$search}%") ->orWhereHas('company', fn ($company) => $company->where('name', 'like', "%{$search}%"))); } $deals = $query->orderByDesc('estimated_value')->get(); $openDeals = $deals->filter(fn (Deal $deal) => ! in_array($deal->status, ['won', 'lost'], true)); return [ 'pipeline' => $pipeline, 'stages' => $pipeline->stages->map(fn (DealStage $stage) => [ ...$stage->toArray(), 'deals' => $deals->where('deal_stage_id', $stage->id)->values(), 'total_value' => (float) $deals->where('deal_stage_id', $stage->id)->sum('estimated_value'), ]), 'summary' => [ 'count' => $openDeals->count(), 'total_value' => (float) $openDeals->sum('estimated_value'), 'weighted_value' => round((float) $openDeals->sum(fn (Deal $deal) => ((float) $deal->estimated_value * $deal->win_probability) / 100), 2), ], ]; } public function move(Deal $deal, DealStage $stage, User $user, int $version, ?string $reason = null, ?float $finalAmount = null): Deal { return DB::transaction(function () use ($deal, $stage, $user, $version, $reason, $finalAmount): Deal { $locked = Deal::lockForUpdate()->findOrFail($deal->id); if ($locked->version !== $version) { throw ValidationException::withMessages(['version' => 'این فرصت هم‌زمان تغییر کرده است؛ برد را تازه‌سازی کنید.']); } if ($locked->pipeline_id !== $stage->pipeline_id) { throw ValidationException::withMessages(['deal_stage_id' => 'مرحله باید متعلق به پایپ‌لاین فرصت باشد.']); } if (($stage->is_won || $stage->is_lost) && blank($reason)) { throw ValidationException::withMessages(['reason' => 'برای بستن فرصت، دلیل الزامی است.']); } $before = $locked->only(['deal_stage_id', 'status', 'version']); $fromStage = $locked->deal_stage_id; $status = $stage->is_won ? 'won' : ($stage->is_lost ? 'lost' : 'open'); $locked->update([ 'deal_stage_id' => $stage->id, 'sales_stage' => $stage->slug, 'win_probability' => $stage->probability, 'status' => $status, 'won_lost_reason' => ($stage->is_won || $stage->is_lost) ? $reason : null, 'final_amount' => $stage->is_won ? ($finalAmount ?? $locked->estimated_value) : null, 'closed_at' => ($stage->is_won || $stage->is_lost) ? now() : null, 'last_activity_at' => now(), 'version' => $locked->version + 1, ]); DealStageHistory::create([ 'deal_id' => $locked->id, 'from_stage_id' => $fromStage, 'to_stage_id' => $stage->id, 'changed_by' => $user->id, 'note' => $reason, ]); ActivityLogger::log('deal_stage_changed', "Deal {$locked->id} moved to {$stage->name}", $locked, $before, $locked->fresh()->only(['deal_stage_id', 'status', 'version'])); $this->automations->dispatch('deal_stage_changed', $locked, "deal-stage:{$locked->id}:{$locked->version}", ['from_stage_id' => $fromStage, 'to_stage_id' => $stage->id, 'status' => $status]); return $locked->fresh(['pipeline', 'stage', 'owner:id,name', 'company:id,name']); }); } }