CRM/backend/app/Services/TaskService.php

246 خطوط
10 KiB
PHP

<?php
namespace App\Services;
use App\Enums\TaskStatus;
use App\Exceptions\TaskVersionConflictException;
use App\Models\Task;
use App\Models\User;
use App\Notifications\TaskAssignedNotification;
use App\Support\AccessControl;
use App\Support\EntityResolver;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
class TaskService
{
public function create(array $data, User $actor): Task
{
$entity = null;
if (! empty($data['taskable_type'])) {
$entity = EntityResolver::resolve($data['taskable_type'], (int) $data['taskable_id']);
abort_unless(AccessControl::canAccessEntity($actor, $entity), 403, 'به موجودیت مرتبط دسترسی ندارید.');
}
$assigneeId = (int) ($data['assigned_to'] ?? $actor->id);
$this->assertAssignable($actor, $assigneeId, $assigneeId !== $actor->id);
if (! empty($data['parent_task_id'])) {
$parent = Task::findOrFail($data['parent_task_id']);
Gate::forUser($actor)->authorize('view', $parent);
}
$task = DB::transaction(function () use ($data, $actor, $entity, $assigneeId): Task {
$task = Task::create([
...Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'parent_task_id', 'estimated_minutes', 'visibility']),
'taskable_type' => $entity ? $entity::class : null,
'taskable_id' => $entity?->getKey(),
'assigned_to' => $assigneeId,
'assigned_by' => $actor->id,
'created_by' => $actor->id,
'status' => TaskStatus::Open->value,
'version' => 1,
]);
ActivityLogger::log('task_created', "Task {$task->id} created", $task, null, $this->auditState($task));
return $task;
});
try {
TaskAssignedNotification::send($task, $actor);
} catch (\Throwable) {
// Notification failure must not roll back task creation.
}
return $this->load($task);
}
public function update(Task $task, array $data, User $actor): Task
{
return DB::transaction(function () use ($task, $data): Task {
$locked = $this->locked($task, (int) $data['version']);
$before = $this->auditState($locked);
$locked->fill(Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'estimated_minutes', 'visibility']));
$locked->version++;
$locked->save();
ActivityLogger::log('task_updated', "Task {$locked->id} updated", $locked, $before, $this->auditState($locked));
return $this->load($locked);
});
}
public function assign(Task $task, int $assigneeId, int $version, User $actor): Task
{
$this->assertAssignable($actor, $assigneeId, true);
$reassigned = $task->assigned_to !== null && $task->assigned_to !== $assigneeId;
$updated = DB::transaction(function () use ($task, $assigneeId, $version, $actor, $reassigned): Task {
$locked = $this->locked($task, $version);
Gate::forUser($actor)->authorize('assign', [$locked, $assigneeId]);
$before = $this->auditState($locked);
$locked->update([
'assigned_to' => $assigneeId,
'assigned_by' => $actor->id,
'version' => $locked->version + 1,
]);
ActivityLogger::log($reassigned ? 'task_reassigned' : 'task_assigned', "Task {$locked->id} assignment changed", $locked, $before, $this->auditState($locked));
return $this->load($locked);
});
try {
TaskAssignedNotification::send($updated, $actor, $reassigned);
} catch (\Throwable) {
// Notification failure must not roll back assignment.
}
return $updated;
}
public function transition(Task $task, TaskStatus $target, int $version, User $actor): Task
{
return DB::transaction(function () use ($task, $target, $version, $actor): Task {
$locked = $this->locked($task, $version);
Gate::forUser($actor)->authorize('transition', $locked);
$this->assertTransition($locked, $target);
$before = $this->auditState($locked);
$attributes = ['status' => $target->value, 'version' => $locked->version + 1];
if ($target === TaskStatus::InProgress) {
$attributes['started_at'] = $locked->started_at ?? now();
$attributes['completed_at'] = null;
} elseif ($target === TaskStatus::Done) {
$attributes['completed_at'] = now();
} elseif ($target === TaskStatus::Open) {
$attributes['completed_at'] = null;
} elseif ($target === TaskStatus::Cancelled) {
$attributes['completed_at'] = null;
}
$locked->update($attributes);
ActivityLogger::log('task_'.$target->value, "Task {$locked->id} status changed", $locked, $before, $this->auditState($locked));
return $this->load($locked);
});
}
public function bulkAssign(array $ids, int $assigneeId, User $actor): array
{
Gate::forUser($actor)->authorize('bulkManage', Task::class);
$this->assertAssignable($actor, $assigneeId, true);
$tasks = DB::transaction(function () use ($ids, $assigneeId, $actor) {
$tasks = $this->bulkTasks($ids, $actor, 'assign', $assigneeId);
foreach ($tasks as $task) {
$before = $this->auditState($task);
$task->update(['assigned_to' => $assigneeId, 'assigned_by' => $actor->id, 'version' => $task->version + 1]);
ActivityLogger::log('task_bulk_assigned', "Task {$task->id} bulk assigned", $task, $before, $this->auditState($task));
}
return $tasks;
});
foreach ($tasks as $task) {
try {
TaskAssignedNotification::send($task->fresh(), $actor, true);
} catch (\Throwable) {
// Assignment remains valid if notification delivery fails.
}
}
return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all();
}
public function bulkComplete(array $ids, User $actor): array
{
Gate::forUser($actor)->authorize('bulkManage', Task::class);
$tasks = DB::transaction(function () use ($ids, $actor) {
$tasks = $this->bulkTasks($ids, $actor, 'transition');
foreach ($tasks as $task) {
$before = $this->auditState($task);
$task->update(['status' => TaskStatus::Done->value, 'completed_at' => now(), 'version' => $task->version + 1]);
ActivityLogger::log('task_bulk_completed', "Task {$task->id} bulk completed", $task, $before, $this->auditState($task));
}
return $tasks;
});
return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all();
}
public function delete(Task $task): void
{
DB::transaction(function () use ($task): void {
$before = $this->auditState($task);
$task->delete();
ActivityLogger::log('task_deleted', "Task {$task->id} deleted", $task, $before, ['deleted' => true]);
});
}
private function assertAssignable(User $actor, int $assigneeId, bool $requiresPermission): User
{
$assignee = User::whereKey($assigneeId)->where('is_active', true)->first();
if (! $assignee) {
throw ValidationException::withMessages(['assigned_to' => ['کاربر انتخاب‌شده فعال نیست.']]);
}
if ($requiresPermission && ! $actor->can('assign_tasks') && ! $actor->can('reassign_tasks')) {
abort(403, 'مجوز تخصیص کار به دیگران را ندارید.');
}
abort_unless(AccessControl::canAssignUser($actor, $assigneeId), 403, 'کاربر انتخاب‌شده خارج از محدوده تیم شما است.');
return $assignee;
}
private function locked(Task $task, int $version): Task
{
$locked = Task::whereKey($task->id)->lockForUpdate()->firstOrFail();
if ($locked->version !== $version) {
throw new TaskVersionConflictException;
}
return $locked;
}
private function assertTransition(Task $task, TaskStatus $target): void
{
$allowed = match ($target) {
TaskStatus::InProgress => [$task->status === TaskStatus::Open],
TaskStatus::Done => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)],
TaskStatus::Open => [in_array($task->status, [TaskStatus::Done, TaskStatus::Cancelled], true)],
TaskStatus::Cancelled => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)],
};
if (! $allowed[0]) {
throw ValidationException::withMessages(['status' => ['این تغییر وضعیت برای وضعیت فعلی کار مجاز نیست.']]);
}
}
private function bulkTasks(array $ids, User $actor, string $ability, ?int $assigneeId = null)
{
$uniqueIds = collect($ids)->map(fn ($id) => (int) $id)->unique()->values();
$tasks = Task::whereKey($uniqueIds)->lockForUpdate()->get();
if ($tasks->count() !== $uniqueIds->count()) {
throw ValidationException::withMessages(['task_ids' => ['یک یا چند کار معتبر نیست.']]);
}
foreach ($tasks as $task) {
$arguments = $ability === 'assign' ? [$task, $assigneeId] : $task;
Gate::forUser($actor)->authorize($ability, $arguments);
}
return $tasks;
}
private function auditState(Task $task): array
{
return $task->only(['assigned_to', 'assigned_by', 'priority', 'status', 'due_at', 'reminder_at', 'visibility', 'version']);
}
private function load(Task $task): Task
{
return $task->load(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject']);
}
}