CRM/backend/app/Services/DealPipelineService.php

96 خطوط
4.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\Deal;
use App\Models\DealStage;
use App\Models\DealStageHistory;
use App\Models\Pipeline;
use App\Models\User;
use App\Support\AccessControl;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class DealPipelineService
{
public function __construct(private AutomationDispatcher $automations) {}
public function board(Pipeline $pipeline, User $user, array $filters = []): array
{
$pipeline->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']);
});
}
}