Awaiting Response No qualifying response yet Due Soon SLA is approaching its deadline MET SLA target achieved on time BREACHED SLA target missed
167 lines
6.0 KiB
PHP
167 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\Helpdesk\Services;
|
|
|
|
use App\Models\User;
|
|
use Modules\Helpdesk\Models\SlaPolicy;
|
|
use Modules\Helpdesk\Models\Comment;
|
|
use Modules\Helpdesk\Models\Ticket;
|
|
use Modules\Helpdesk\Models\TicketSlaEvent;
|
|
|
|
final class SlaService
|
|
{
|
|
public function applyPolicy(Ticket $ticket): Ticket
|
|
{
|
|
$policy = $this->policyFor($ticket);
|
|
|
|
if (! $policy) {
|
|
return $ticket;
|
|
}
|
|
|
|
$createdAt = $ticket->created_at ?? now();
|
|
$ticket->forceFill([
|
|
'sla_policy_id' => $policy->id,
|
|
'sla_first_response_due_at' => $createdAt->copy()->addMinutes($policy->first_response_minutes),
|
|
'sla_resolution_due_at' => $createdAt->copy()->addMinutes($policy->resolution_minutes),
|
|
])->save();
|
|
|
|
TicketSlaEvent::query()->create([
|
|
'ticket_id' => $ticket->id,
|
|
'sla_policy_id' => $policy->id,
|
|
'event' => 'sla.policy_applied',
|
|
'due_at' => $ticket->sla_resolution_due_at,
|
|
'occurred_at' => now(),
|
|
'meta' => ['policy' => $policy->name],
|
|
]);
|
|
|
|
return $ticket->fresh() ?? $ticket;
|
|
}
|
|
|
|
/**
|
|
* Record a public ticket response and satisfy the first-response SLA.
|
|
*
|
|
* A public Helpdesk response is deliberate customer-visible communication,
|
|
* so it must satisfy the first-response SLA even in local/single-user
|
|
* support workflows where the same authenticated user may be testing both
|
|
* sides of the ticket conversation.
|
|
*/
|
|
public function recordPublicResponse(Ticket $ticket, User $actor): void
|
|
{
|
|
$respondedAt = now();
|
|
|
|
if ($ticket->first_response_at === null) {
|
|
$this->markFirstResponse($ticket, $respondedAt, $actor);
|
|
}
|
|
|
|
$ticket->increment('public_replies_count');
|
|
}
|
|
|
|
/**
|
|
* Rebuild first-response SLA state from the earliest public comment.
|
|
*
|
|
* This keeps older tickets correct when public comments existed before the
|
|
* explicit Public/Internal selector was added or when counters were manually
|
|
* changed outside the SLA service.
|
|
*/
|
|
public function syncFirstResponseFromComments(Ticket $ticket): void
|
|
{
|
|
if ($ticket->first_response_at !== null) {
|
|
return;
|
|
}
|
|
|
|
/** @var Comment|null $firstPublicComment */
|
|
$firstPublicComment = Comment::query()
|
|
->where('ticket_id', $ticket->id)
|
|
->where('is_internal', false)
|
|
->whereNull('tombstoned_at')
|
|
->with('user')
|
|
->oldest('created_at')
|
|
->first();
|
|
|
|
if ($firstPublicComment === null) {
|
|
return;
|
|
}
|
|
|
|
$this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now(), $firstPublicComment->user);
|
|
}
|
|
|
|
private function markFirstResponse(Ticket $ticket, mixed $respondedAt, ?User $actor = null): void
|
|
{
|
|
$respondedAt = $respondedAt instanceof \Illuminate\Support\Carbon
|
|
? $respondedAt
|
|
: now();
|
|
|
|
$breached = $ticket->sla_first_response_due_at !== null
|
|
&& $respondedAt->greaterThan($ticket->sla_first_response_due_at);
|
|
|
|
$ticket->forceFill([
|
|
'first_response_at' => $respondedAt,
|
|
'sla_first_response_breached_at' => $breached ? $respondedAt : null,
|
|
])->save();
|
|
|
|
TicketSlaEvent::query()->firstOrCreate(
|
|
[
|
|
'ticket_id' => $ticket->id,
|
|
'event' => 'sla.first_response',
|
|
],
|
|
[
|
|
'sla_policy_id' => $ticket->sla_policy_id,
|
|
'due_at' => $ticket->sla_first_response_due_at,
|
|
'occurred_at' => $respondedAt,
|
|
'breached' => $breached,
|
|
'meta' => [
|
|
'actor_id' => $actor?->id,
|
|
'actor_name' => $actor?->name,
|
|
'response_seconds' => max(0, ($ticket->created_at ?? $respondedAt)->diffInSeconds($respondedAt)),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
public function recordClosure(Ticket $ticket): void
|
|
{
|
|
$breached = $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at);
|
|
$ticket->forceFill(['sla_resolution_breached_at' => $breached ? now() : $ticket->sla_resolution_breached_at])->save();
|
|
TicketSlaEvent::query()->create([
|
|
'ticket_id' => $ticket->id,
|
|
'sla_policy_id' => $ticket->sla_policy_id,
|
|
'event' => 'sla.resolved',
|
|
'due_at' => $ticket->sla_resolution_due_at,
|
|
'occurred_at' => now(),
|
|
'breached' => $breached,
|
|
'meta' => [
|
|
'resolved_seconds' => max(0, ($ticket->created_at ?? now())->diffInSeconds(now())),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function refreshBreaches(Ticket $ticket): void
|
|
{
|
|
$updates = [];
|
|
if ($ticket->first_response_at === null && $ticket->sla_first_response_due_at !== null && now()->greaterThan($ticket->sla_first_response_due_at)) {
|
|
$updates['sla_first_response_breached_at'] = $ticket->sla_first_response_breached_at ?? now();
|
|
}
|
|
if ($ticket->completed_at === null && $ticket->sla_resolution_due_at !== null && now()->greaterThan($ticket->sla_resolution_due_at)) {
|
|
$updates['sla_resolution_breached_at'] = $ticket->sla_resolution_breached_at ?? now();
|
|
}
|
|
if ($updates !== []) {
|
|
$ticket->forceFill($updates)->save();
|
|
}
|
|
}
|
|
|
|
private function policyFor(Ticket $ticket): ?SlaPolicy
|
|
{
|
|
return SlaPolicy::query()
|
|
->where('is_active', true)
|
|
->where(static function ($query) use ($ticket): void {
|
|
$query->whereNull('company_id')->orWhere('company_id', $ticket->company_id);
|
|
})
|
|
->orderByRaw('CASE WHEN priority_id = ? THEN 0 WHEN priority_id IS NULL THEN 1 ELSE 2 END', [$ticket->priority_id])
|
|
->orderByRaw('CASE WHEN category_id = ? THEN 0 WHEN category_id IS NULL THEN 1 ELSE 2 END', [$ticket->category_id])
|
|
->orderByDesc('is_default')
|
|
->first();
|
|
}
|
|
}
|