96 خطوط
4.6 KiB
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']);
|
|
});
|
|
}
|
|
}
|