first&last

This commit is contained in:
sbb45
2026-03-20 15:47:17 +05:00
commit d6441abdd2
230 changed files with 20367 additions and 0 deletions
+36
View File
@@ -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(),
]);
}
}
+90
View File
@@ -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);
}
}
+116
View File
@@ -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();
}
}
+173
View File
@@ -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,
]);
}
}
}
+30
View File
@@ -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 : [];
}
}