first&last
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivityLogService
|
||||
{
|
||||
public function log(
|
||||
?User $user,
|
||||
Model|string $model,
|
||||
int|string|null $modelId,
|
||||
string $action,
|
||||
string $description,
|
||||
?string $ipAddress = null
|
||||
): ActivityLog {
|
||||
if ($model instanceof Model) {
|
||||
$modelClass = $model::class;
|
||||
$modelId = $model->getKey();
|
||||
} else {
|
||||
$modelClass = $model;
|
||||
}
|
||||
|
||||
return ActivityLog::query()->create([
|
||||
'user_id' => $user?->id,
|
||||
'model_type' => $modelClass,
|
||||
'model_id' => $modelId ?? 0,
|
||||
'action' => $action,
|
||||
'description' => $description,
|
||||
'ip_address' => $ipAddress,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ServiceRequest;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RequestAssignedNotification;
|
||||
use App\Notifications\RequestCommentAddedNotification;
|
||||
use App\Notifications\RequestOverdueNotification;
|
||||
use App\Notifications\RequestStatusChangedNotification;
|
||||
use App\Notifications\SlaWarningNotification;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public function __construct(private readonly SettingsService $settingsService)
|
||||
{
|
||||
}
|
||||
|
||||
public function notifyRequestAssigned(ServiceRequest $request): void
|
||||
{
|
||||
$assignee = $request->assignee;
|
||||
|
||||
if (!$assignee || !$this->enabled('assigned')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignee->notify(new RequestAssignedNotification($request));
|
||||
}
|
||||
|
||||
public function notifyStatusChanged(ServiceRequest $request): void
|
||||
{
|
||||
if (!$this->enabled('status_changed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->client?->notify(new RequestStatusChangedNotification($request));
|
||||
}
|
||||
|
||||
public function notifyCommentAdded(ServiceRequest $request, User $author): void
|
||||
{
|
||||
if (!$this->enabled('comment_added')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = collect([$request->client, $request->assignee])
|
||||
->filter()
|
||||
->reject(fn (User $user) => $user->id === $author->id);
|
||||
|
||||
/** @var User $recipient */
|
||||
foreach ($recipients as $recipient) {
|
||||
$recipient->notify(new RequestCommentAddedNotification($request, $author));
|
||||
}
|
||||
}
|
||||
|
||||
public function notifyOverdue(ServiceRequest $request, Collection $managers): void
|
||||
{
|
||||
if (!$this->enabled('overdue')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = collect([$request->assignee])->filter()->merge($managers);
|
||||
|
||||
/** @var User $recipient */
|
||||
foreach ($recipients->unique('id') as $recipient) {
|
||||
$recipient->notify(new RequestOverdueNotification($request));
|
||||
}
|
||||
}
|
||||
|
||||
public function notifySlaWarning(ServiceRequest $request, Collection $managers): void
|
||||
{
|
||||
if (!$this->enabled('sla_warning')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = collect([$request->assignee])->filter()->merge($managers);
|
||||
|
||||
/** @var User $recipient */
|
||||
foreach ($recipients->unique('id') as $recipient) {
|
||||
$recipient->notify(new SlaWarningNotification($request));
|
||||
}
|
||||
}
|
||||
|
||||
private function enabled(string $key): bool
|
||||
{
|
||||
$map = $this->settingsService->emailNotifications();
|
||||
|
||||
return (bool) ($map[$key] ?? true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\RequestType;
|
||||
use App\Models\ServiceRequest;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ReportService
|
||||
{
|
||||
public function requestBaseQuery(array $filters = []): Builder
|
||||
{
|
||||
return ServiceRequest::query()
|
||||
->with(['assignee', 'type', 'status', 'priority'])
|
||||
->when($filters['date_from'] ?? null, fn (Builder $q, $v) => $q->whereDate('created_at', '>=', $v))
|
||||
->when($filters['date_to'] ?? null, fn (Builder $q, $v) => $q->whereDate('created_at', '<=', $v))
|
||||
->when($filters['technician_id'] ?? null, fn (Builder $q, $v) => $q->where('assigned_to', $v))
|
||||
->when($filters['type_id'] ?? null, fn (Builder $q, $v) => $q->where('type_id', $v))
|
||||
->when($filters['status_id'] ?? null, fn (Builder $q, $v) => $q->where('status_id', $v));
|
||||
}
|
||||
|
||||
public function requestsByStatus(array $filters = []): Collection
|
||||
{
|
||||
return $this->requestBaseQuery($filters)
|
||||
->selectRaw('status_id, COUNT(*) as total')
|
||||
->groupBy('status_id')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$status = Status::query()->find($item->status_id);
|
||||
|
||||
return [
|
||||
'status' => $status?->name ?? 'Неизвестно',
|
||||
'color' => $status?->color_hex ?? '#2563eb',
|
||||
'total' => (int) $item->total,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function requestsByPriority(array $filters = []): Collection
|
||||
{
|
||||
return $this->requestBaseQuery($filters)
|
||||
->selectRaw('priority_id, COUNT(*) as total')
|
||||
->groupBy('priority_id')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'priority' => Priority::query()->find($item->priority_id)?->name,
|
||||
'total' => (int) $item->total,
|
||||
]);
|
||||
}
|
||||
|
||||
public function technicianPerformance(array $filters = []): Collection
|
||||
{
|
||||
$technicians = User::query()->where('role', 'technician')->get();
|
||||
|
||||
return $technicians->map(function (User $tech) use ($filters) {
|
||||
$query = $this->requestBaseQuery($filters)->where('assigned_to', $tech->id);
|
||||
$assigned = (clone $query)->count();
|
||||
$resolved = (clone $query)->whereHas('status', fn (Builder $q) => $q->whereIn('slug', ['resolved', 'closed']))->count();
|
||||
$overdue = (clone $query)->overdue()->count();
|
||||
$avgResolution = (clone $query)
|
||||
->whereNotNull('completed_at')
|
||||
->selectRaw('AVG(TIMESTAMPDIFF(HOUR, created_at, completed_at)) as avg_hours')
|
||||
->value('avg_hours');
|
||||
$rating = (clone $query)->whereNotNull('client_rating')->avg('client_rating');
|
||||
|
||||
return [
|
||||
'technician' => $tech->name,
|
||||
'assigned' => $assigned,
|
||||
'resolved' => $resolved,
|
||||
'avg_resolution_hours' => $avgResolution ? round((float) $avgResolution, 1) : 0,
|
||||
'rating' => $rating ? round((float) $rating, 2) : 0,
|
||||
'overdue' => $overdue,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function slaCompliance(array $filters = []): Collection
|
||||
{
|
||||
$rows = $this->requestBaseQuery($filters)->with('type')->get()->groupBy('type_id');
|
||||
|
||||
return $rows->map(function (Collection $requests, $typeId) {
|
||||
$type = RequestType::query()->find($typeId);
|
||||
$total = $requests->count();
|
||||
$withinSla = $requests
|
||||
->filter(fn (ServiceRequest $request) => $request->completed_at && $request->deadline_at && $request->completed_at->lte($request->deadline_at))
|
||||
->count();
|
||||
|
||||
return [
|
||||
'type' => $type?->name ?? 'Неизвестно',
|
||||
'total' => $total,
|
||||
'within_sla' => $withinSla,
|
||||
'percent' => $total > 0 ? round(($withinSla / $total) * 100, 2) : 0,
|
||||
];
|
||||
})->values();
|
||||
}
|
||||
|
||||
public function equipmentFailureFrequency(array $filters = []): Collection
|
||||
{
|
||||
return $this->requestBaseQuery($filters)
|
||||
->whereNotNull('equipment_id')
|
||||
->with('equipment')
|
||||
->get()
|
||||
->groupBy('equipment_id')
|
||||
->map(fn (Collection $requests, $equipmentId) => [
|
||||
'equipment' => $requests->first()?->equipment?->name ?? 'Неизвестно',
|
||||
'total_requests' => $requests->count(),
|
||||
'open' => $requests->filter(fn (ServiceRequest $r) => !$r->status?->is_final)->count(),
|
||||
'closed' => $requests->filter(fn (ServiceRequest $r) => $r->status?->is_final)->count(),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\StatusSlug;
|
||||
use App\Events\RequestAssigned;
|
||||
use App\Events\RequestCreated;
|
||||
use App\Events\RequestStatusChanged;
|
||||
use App\Models\RequestType;
|
||||
use App\Models\ServiceRequest;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RequestService
|
||||
{
|
||||
public function __construct(private readonly SettingsService $settingsService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(array $data, User $actor): ServiceRequest
|
||||
{
|
||||
return DB::transaction(function () use ($data, $actor): ServiceRequest {
|
||||
$payload = $this->preparePayload($data);
|
||||
$payload['ticket_number'] = $this->generateTicketNumber();
|
||||
|
||||
$request = ServiceRequest::query()->create($payload);
|
||||
|
||||
$this->persistAttachments($request, Arr::get($data, 'attachments', []), $actor);
|
||||
|
||||
event(new RequestCreated($request, $actor));
|
||||
|
||||
if ($request->assigned_to) {
|
||||
event(new RequestAssigned($request, $actor));
|
||||
}
|
||||
|
||||
return $request;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(ServiceRequest $request, array $data, User $actor): ServiceRequest
|
||||
{
|
||||
return DB::transaction(function () use ($request, $data, $actor): ServiceRequest {
|
||||
$oldAssignee = $request->assigned_to;
|
||||
$oldStatusId = $request->status_id;
|
||||
$payload = $this->preparePayload($data, $request);
|
||||
|
||||
$request->fill($payload);
|
||||
$request->save();
|
||||
|
||||
$this->persistAttachments($request, Arr::get($data, 'attachments', []), $actor);
|
||||
|
||||
if ($oldStatusId !== $request->status_id) {
|
||||
event(new RequestStatusChanged($request->fresh('status'), $actor, $oldStatusId, $request->status_id));
|
||||
}
|
||||
|
||||
if ($oldAssignee !== $request->assigned_to && $request->assigned_to) {
|
||||
event(new RequestAssigned($request->fresh(), $actor));
|
||||
}
|
||||
|
||||
return $request->fresh(['type', 'priority', 'status', 'client', 'assignee', 'equipment']);
|
||||
});
|
||||
}
|
||||
|
||||
public function changeStatus(ServiceRequest $request, Status $targetStatus, User $actor): ServiceRequest
|
||||
{
|
||||
$oldStatusId = $request->status_id;
|
||||
|
||||
$request->status_id = $targetStatus->id;
|
||||
|
||||
if ($targetStatus->slug === StatusSlug::IN_PROGRESS->value && !$request->started_at) {
|
||||
$request->started_at = now();
|
||||
}
|
||||
|
||||
if (in_array($targetStatus->slug, [StatusSlug::RESOLVED->value, StatusSlug::CLOSED->value], true)) {
|
||||
$request->completed_at = now();
|
||||
}
|
||||
|
||||
$request->save();
|
||||
|
||||
event(new RequestStatusChanged($request->fresh('status'), $actor, $oldStatusId, $targetStatus->id));
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
public function allowedTransitions(ServiceRequest $request, User $user): array
|
||||
{
|
||||
$staffTransitions = [
|
||||
StatusSlug::NEW->value => [StatusSlug::IN_PROGRESS->value, StatusSlug::WAITING->value, StatusSlug::CANCELLED->value],
|
||||
StatusSlug::IN_PROGRESS->value => [StatusSlug::WAITING->value, StatusSlug::RESOLVED->value, StatusSlug::CANCELLED->value],
|
||||
StatusSlug::WAITING->value => [StatusSlug::IN_PROGRESS->value, StatusSlug::RESOLVED->value, StatusSlug::CANCELLED->value],
|
||||
StatusSlug::RESOLVED->value => [StatusSlug::CLOSED->value, StatusSlug::IN_PROGRESS->value],
|
||||
StatusSlug::CLOSED->value => [],
|
||||
StatusSlug::CANCELLED->value => [],
|
||||
];
|
||||
|
||||
$technicianTransitions = [
|
||||
StatusSlug::NEW->value => [StatusSlug::IN_PROGRESS->value, StatusSlug::WAITING->value],
|
||||
StatusSlug::IN_PROGRESS->value => [StatusSlug::WAITING->value, StatusSlug::RESOLVED->value],
|
||||
StatusSlug::WAITING->value => [StatusSlug::IN_PROGRESS->value, StatusSlug::RESOLVED->value],
|
||||
StatusSlug::RESOLVED->value => [StatusSlug::IN_PROGRESS->value],
|
||||
StatusSlug::CLOSED->value => [],
|
||||
StatusSlug::CANCELLED->value => [],
|
||||
];
|
||||
|
||||
$map = $user->isTechnician() ? $technicianTransitions : $staffTransitions;
|
||||
|
||||
return $map[$request->status?->slug ?? StatusSlug::NEW->value] ?? [];
|
||||
}
|
||||
|
||||
public function generateTicketNumber(): string
|
||||
{
|
||||
$maxId = ServiceRequest::withTrashed()->max('id') ?? 0;
|
||||
|
||||
return sprintf('SRV-%04d', $maxId + 1);
|
||||
}
|
||||
|
||||
private function preparePayload(array $data, ?ServiceRequest $request = null): array
|
||||
{
|
||||
$typeId = Arr::get($data, 'type_id', $request?->type_id);
|
||||
$type = $typeId ? RequestType::query()->find($typeId) : null;
|
||||
|
||||
$deadlineAt = Arr::get($data, 'deadline_at');
|
||||
|
||||
if (!$deadlineAt && $type) {
|
||||
$baseDate = Arr::get($data, 'planned_at') ?: now();
|
||||
$deadlineAt = Carbon::parse((string) $baseDate)->addHours($type->sla_hours)->toDateTimeString();
|
||||
}
|
||||
|
||||
if (!$deadlineAt && !$type) {
|
||||
$fallbackHours = $this->settingsService->defaultSlaHours();
|
||||
$baseDate = Arr::get($data, 'planned_at') ?: now();
|
||||
$deadlineAt = Carbon::parse((string) $baseDate)->addHours($fallbackHours)->toDateTimeString();
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => Arr::get($data, 'title'),
|
||||
'description' => Arr::get($data, 'description'),
|
||||
'type_id' => $typeId,
|
||||
'priority_id' => Arr::get($data, 'priority_id'),
|
||||
'status_id' => Arr::get($data, 'status_id', $request?->status_id),
|
||||
'client_id' => Arr::get($data, 'client_id', $request?->client_id),
|
||||
'assigned_to' => Arr::get($data, 'assigned_to'),
|
||||
'equipment_id' => Arr::get($data, 'equipment_id'),
|
||||
'location' => Arr::get($data, 'location'),
|
||||
'planned_at' => Arr::get($data, 'planned_at'),
|
||||
'deadline_at' => $deadlineAt,
|
||||
'started_at' => Arr::get($data, 'started_at', $request?->started_at),
|
||||
'completed_at' => Arr::get($data, 'completed_at', $request?->completed_at),
|
||||
'resolution_notes' => Arr::get($data, 'resolution_notes'),
|
||||
'client_rating' => Arr::get($data, 'client_rating'),
|
||||
'client_comment' => Arr::get($data, 'client_comment'),
|
||||
];
|
||||
}
|
||||
|
||||
private function persistAttachments(ServiceRequest $request, array $attachments, User $actor): void
|
||||
{
|
||||
/** @var UploadedFile $attachment */
|
||||
foreach ($attachments as $attachment) {
|
||||
$path = $attachment->store('request-attachments/'.$request->id, 'local');
|
||||
|
||||
$request->attachments()->create([
|
||||
'user_id' => $actor->id,
|
||||
'filename' => $attachment->getClientOriginalName(),
|
||||
'path' => $path,
|
||||
'size' => $attachment->getSize() ?: 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Setting::getValue($key, $default);
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value, string $type = 'string'): Setting
|
||||
{
|
||||
return Setting::setValue($key, $value, $type);
|
||||
}
|
||||
|
||||
public function defaultSlaHours(): int
|
||||
{
|
||||
return (int) $this->get('default_sla_hours', 24);
|
||||
}
|
||||
|
||||
public function emailNotifications(): array
|
||||
{
|
||||
$value = $this->get('email_notifications', []);
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user