File manager - Edit - /var/www/html/portalHomolog/app/Http/Services/Accounts/Cronogramas/CronogramasService.php
Back
<?php namespace App\Http\Services\Accounts\Cronogramas; use App\Enums\Accounts\Cronogramas\CronogramaActivityStatus; use App\Enums\Accounts\Cronogramas\CronogramaProjectScopeType; use App\Enums\Accounts\Cronogramas\CronogramaProjectStatus; use App\Enums\Accounts\Cronogramas\CronogramaResponsibleType; use App\Helpers\FileUploadHelper; use App\Http\Resources\Accounts\Cronogramas\CronogramasResource; use App\Models\Accounts\Cronogramas\Activity; use App\Models\Accounts\Cronogramas\ActivityResponsible; use App\Models\Accounts\Cronogramas\Attachment; use App\Models\Accounts\Cronogramas\Comment; use App\Models\Accounts\Cronogramas\Dependency; use App\Models\Accounts\Cronogramas\Project; use App\Models\Accounts\Cronogramas\ProjectScope; use App\Models\Empresa; use App\Models\Structure; use App\Models\UnidadeOrganizacional; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; /** * Persistência e operações de banco do módulo Cronogramas. */ class CronogramasService { public function __construct( private readonly CronogramasResource $resource, ) {} public function paginateProjects(Request $request, User $user, string $tab = 'table'): LengthAwarePaginator { $q = Project::query()->whereNull('deleted_at'); $this->resource->applyProjectScopeFilter($q, $user); if ($tab === 'archived') { $q->where('status', CronogramaProjectStatus::Archived->value); } else { $q->where('status', '!=', CronogramaProjectStatus::Archived->value); } if ($request->filled('q')) { $term = trim((string) $request->get('q')); $q->where('name', 'like', '%' . $term . '%'); } if ($request->filled('status')) { $st = (string) $request->get('status'); if (in_array($st, CronogramaProjectStatus::values(), true)) { $q->where('status', $st); } } if ($request->filled('date_from')) { $q->whereDate('start_date', '>=', $request->get('date_from')); } if ($request->filled('date_to')) { $q->whereDate('planned_end_date', '<=', $request->get('date_to')); } $sort = (string) $request->get('sort', 'updated_at'); $dir = strtolower((string) $request->get('dir', 'desc')) === 'asc' ? 'asc' : 'desc'; if ($sort === 'name') { $q->orderBy('name', $dir); } elseif ($sort === 'completion_percent') { $q->orderBy('completion_percent', $dir); } elseif ($sort === 'start_date') { $q->orderBy('start_date', $dir); } else { $q->orderBy('updated_at', $dir); } $pageName = $tab === 'archived' ? 'arp' : 'page'; return $q->withCount(['activeActivities as activities_count']) ->paginate(15, ['*'], $pageName) ->withQueryString(); } public function countArchivedProjects(User $user): int { $query = Project::query() ->whereNull('deleted_at') ->where('status', CronogramaProjectStatus::Archived->value); $this->resource->applyProjectScopeFilter($query, $user); return $query->count(); } /** * Atualiza status de atraso em lote para todas as atividades ativas do projeto. */ public function resolveOverdueStatusesForProject(Project $project): void { $this->resource->batchResolveOverdueStatuses($project); } /** * Todas as atividades ativas para Gantt, Kanban e busca global. * * @return Collection<int, Activity> */ public function loadActiveActivitiesForCharts(Project $project): Collection { return Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->with([ 'responsibles.responsible', 'predecessors.predecessor', 'comments' => fn ($c) => $c->whereNull('deleted_at'), 'attachments' => fn ($a) => $a->whereNull('deleted_at'), ]) ->orderBy('sort_order') ->get(); } /** * Atividades raiz para seleção de atividade pai no formulário. * * @return Collection<int, Activity> */ public function listRootActivitiesForSelect(Project $project): Collection { return Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->whereNull('parent_id') ->orderBy('sort_order') ->get(['id', 'code', 'title']); } /** * Todas as atividades ativas para seleção de predecessoras. * * @return Collection<int, Activity> */ public function listAllActivitiesForSelect(Project $project): Collection { return Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->orderBy('sort_order') ->get(['id', 'code', 'title']); } /** * Pagina todas as atividades ativas da tabela (ordem global por sort_order). * * @return array{paginator: LengthAwarePaginator, activities: Collection<int, Activity>} */ public function paginateProjectActivities(Project $project, Request $request): array { $perPage = $this->resource->resolveActivitiesPerPage($request); [$sort, $dir] = $this->resource->resolveActivitySort($request); $query = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->with([ 'responsibles.responsible', 'predecessors.predecessor', 'comments' => fn ($c) => $c->whereNull('deleted_at'), 'attachments' => fn ($a) => $a->whereNull('deleted_at'), ]); $this->applyActivityFilters($query, $this->resource->resolveActivityFilters($request)); $this->applyActivitySort($query, $sort, $dir); $paginator = $query ->paginate($perPage, ['*'], 'ap') ->withQueryString(); return [ 'paginator' => $paginator, 'activities' => $paginator->getCollection(), 'index' => $this->loadActivityIndex($project), ]; } /** * Índice global (id => atividade leve) com todas as atividades ativas do projeto. * Usado para resolver pais e predecessoras na renderização paginada. * * @return Collection<string, Activity> */ public function loadActivityIndex(Project $project): Collection { return Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->get(['id', 'code', 'parent_id']) ->keyBy('id'); } /** * Aplica ordenação whitelistada à query de atividades. * * @param \Illuminate\Database\Eloquent\Builder<Activity> $query */ private function applyActivitySort($query, string $sort, string $dir): void { switch ($sort) { case 'chronological': $query->orderByRaw('CASE WHEN planned_start_date IS NULL THEN 1 ELSE 0 END') ->orderBy('planned_start_date', $dir) ->orderBy('sort_order'); break; case 'code': $query->orderBy('code', $dir)->orderBy('sort_order'); break; case 'title': $query->orderBy('title', $dir)->orderBy('sort_order'); break; case 'status': $query->orderBy('status', $dir)->orderBy('sort_order'); break; case 'progress': $query->orderBy('completion_percent', $dir)->orderBy('sort_order'); break; case 'start': $query->orderByRaw('CASE WHEN planned_start_date IS NULL THEN 1 ELSE 0 END') ->orderBy('planned_start_date', $dir) ->orderBy('sort_order'); break; case 'end': $query->orderByRaw('CASE WHEN planned_end_date IS NULL THEN 1 ELSE 0 END') ->orderBy('planned_end_date', $dir) ->orderBy('sort_order'); break; case 'updated': $query->orderBy('updated_at', $dir)->orderBy('sort_order'); break; case 'manual': default: $query->orderBy('sort_order', $dir); break; } } public function paginateArchivedActivities(Project $project, Request $request, int $perPage = 15): LengthAwarePaginator { return Activity::onlyTrashed() ->where('project_id', $project->id) ->with(['responsibles.responsible', 'predecessors.predecessor']) ->orderByDesc('deleted_at') ->paginate($perPage, ['*'], 'arp') ->withQueryString(); } public function paginateTimelineActivities(Project $project, Request $request): LengthAwarePaginator { $perPage = $this->resource->resolveActivitiesPerPage($request); [$sort, $dir] = $this->resource->resolveActivitySort($request); $query = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->with(['responsibles.responsible', 'predecessors.predecessor']); $this->applyActivityFilters($query, $this->resource->resolveActivityFilters($request)); $this->applyActivitySort($query, $sort, $dir); return $query ->paginate($perPage, ['*'], 'tp') ->withQueryString(); } /** * Aplica os filtros server-side whitelistados à query de atividades. * * @param \Illuminate\Database\Eloquent\Builder<Activity> $query * @param array<string, mixed> $filters */ private function applyActivityFilters($query, array $filters): void { if (! empty($filters['q'])) { $term = $filters['q']; $query->where(function ($sub) use ($term) { $sub->where('code', 'like', '%' . $term . '%') ->orWhere('title', 'like', '%' . $term . '%') ->orWhere('description', 'like', '%' . $term . '%') ->orWhere('notes', 'like', '%' . $term . '%'); }); } if (! empty($filters['status'])) { $query->where('status', $filters['status']); } $today = now()->toDateString(); $terminalStatuses = [ CronogramaActivityStatus::Completed->value, CronogramaActivityStatus::Cancelled->value, ]; if (($filters['overdue'] ?? null) === 'yes') { $query->whereNotIn('status', $terminalStatuses) ->whereNotNull('planned_end_date') ->whereDate('planned_end_date', '<', $today); } elseif (($filters['overdue'] ?? null) === 'no') { $query->where(function ($sub) use ($terminalStatuses, $today) { $sub->whereIn('status', $terminalStatuses) ->orWhereNull('planned_end_date') ->orWhereDate('planned_end_date', '>=', $today); }); } if (($filters['has_parent'] ?? null) === 'yes') { $query->whereNotNull('parent_id'); } elseif (($filters['has_parent'] ?? null) === 'no') { $query->whereNull('parent_id'); } if (($filters['has_pred'] ?? null) === 'yes') { $query->whereHas('predecessors'); } elseif (($filters['has_pred'] ?? null) === 'no') { $query->whereDoesntHave('predecessors'); } if (! empty($filters['resp_type']) && ! empty($filters['resp_id'])) { $query->whereHas('responsibles', function ($sub) use ($filters) { $sub->where('responsible_type', $filters['resp_type']) ->where('responsible_id', $filters['resp_id']); }); } if (! empty($filters['start_from'])) { $query->whereDate('planned_start_date', '>=', $filters['start_from']); } if (! empty($filters['start_to'])) { $query->whereDate('planned_start_date', '<=', $filters['start_to']); } if (! empty($filters['end_from'])) { $query->whereDate('planned_end_date', '>=', $filters['end_from']); } if (! empty($filters['end_to'])) { $query->whereDate('planned_end_date', '<=', $filters['end_to']); } if (($filters['prog_min'] ?? null) !== null) { $query->where('completion_percent', '>=', $filters['prog_min']); } if (($filters['prog_max'] ?? null) !== null) { $query->where('completion_percent', '<=', $filters['prog_max']); } } /** * Comentários paginados do offcanvas de detalhes da atividade. */ public function paginateActivityComments(Activity $activity, Request $request, int $perPage = 10): LengthAwarePaginator { return Comment::query() ->where('activity_id', $activity->id) ->whereNull('deleted_at') ->with('user') ->orderByDesc('created_at') ->paginate($perPage, ['*'], 'cp') ->withQueryString(); } /** * Histórico paginado do offcanvas de detalhes da atividade. */ public function paginateActivityHistory(Activity $activity, Request $request, int $perPage = 15): LengthAwarePaginator { return $activity->historyLogs() ->with('user') ->orderByDesc('created_at') ->paginate($perPage, ['*'], 'hp') ->withQueryString(); } public function assertProjectVisible(Project $project, User $user): void { if (! $this->resource->canManageProject($user, $project)) { throw ValidationException::withMessages([ 'project' => __('Projeto não encontrado ou sem permissão de acesso.'), ]); } } /** * @param array<string, mixed> $data */ public function createProject(User $user, array $data): Project { return DB::transaction(function () use ($user, $data) { $project = new Project(); $project->user_id = $user->id; $project->name = $data['name']; $project->description = $this->resource->sanitizePlainText($data['description'] ?? null); $project->start_date = $data['start_date'] ?? null; $project->planned_end_date = $data['planned_end_date'] ?? null; $project->status = CronogramaProjectStatus::Active->value; $project->completion_percent = 0; $project->save(); $this->syncProjectScopes($project, $data); $this->resource->logHistory($user->id, $project->id, null, 'project_create'); return $project; }); } /** * @param array<string, mixed> $data */ public function updateProject(Project $project, User $user, array $data): Project { $this->resource->assertProjectWritable($project); return DB::transaction(function () use ($project, $user, $data) { $old = $project->only(['name', 'description', 'start_date', 'planned_end_date', 'actual_end_date', 'status']); $project->name = $data['name']; $project->description = $this->resource->sanitizePlainText($data['description'] ?? null); $project->start_date = $data['start_date'] ?? null; $project->planned_end_date = $data['planned_end_date'] ?? null; $project->save(); $this->syncProjectScopes($project, $data); $this->resource->logHistory( $user->id, $project->id, null, 'project_update', $old, $project->only(['name', 'description', 'start_date', 'planned_end_date', 'actual_end_date', 'status']), ); return $project; }); } public function archiveProject(Project $project, User $user): Project { $this->resource->assertProjectWritable($project); return DB::transaction(function () use ($project, $user) { $old = ['status' => $project->status]; $project->status = CronogramaProjectStatus::Archived->value; $project->save(); $this->resource->logHistory($user->id, $project->id, null, 'project_archive', $old, ['status' => $project->status]); return $project; }); } public function unarchiveProject(Project $project, User $user): Project { if ($project->status !== CronogramaProjectStatus::Archived->value) { throw ValidationException::withMessages([ 'project' => __('Somente projetos arquivados podem ser desarquivados.'), ]); } return DB::transaction(function () use ($project, $user) { $old = ['status' => $project->status]; $project->status = CronogramaProjectStatus::Active->value; $project->save(); $this->resource->logHistory($user->id, $project->id, null, 'project_unarchive', $old, ['status' => $project->status]); return $project; }); } public function closeProject(Project $project, User $user): Project { if ($project->status === CronogramaProjectStatus::Closed->value) { return $project; } return DB::transaction(function () use ($project, $user) { $old = ['status' => $project->status]; $project->status = CronogramaProjectStatus::Closed->value; $project->actual_end_date = now()->toDateString(); $project->save(); $this->resource->logHistory($user->id, $project->id, null, 'project_close', $old, ['status' => $project->status]); return $project; }); } public function destroyProject(Project $project, User $user): void { if ($project->status !== CronogramaProjectStatus::Archived->value) { throw ValidationException::withMessages([ 'project' => __('Somente projetos arquivados podem ser excluídos. Arquive o projeto primeiro.'), ]); } $this->forceDeleteProject($project, $user); } /** * Exclui permanentemente vários projetos arquivados. * * @param list<string> $projectIds * @return list<string> IDs efetivamente excluídos */ public function destroyArchivedProjectsBulk(User $user, array $projectIds): array { $projectIds = array_values(array_unique(array_filter($projectIds))); if ($projectIds === []) { throw ValidationException::withMessages([ 'project_ids' => __('Selecione ao menos um projeto arquivado.'), ]); } $deletedIds = []; DB::transaction(function () use ($user, $projectIds, &$deletedIds) { foreach ($projectIds as $id) { $project = Project::query() ->whereNull('deleted_at') ->where('id', $id) ->where('status', CronogramaProjectStatus::Archived->value) ->first(); if (! $project) { throw ValidationException::withMessages([ 'project_ids' => __('Um ou mais projetos selecionados são inválidos ou já foram excluídos.'), ]); } $this->assertProjectVisible($project, $user); $this->forceDeleteProject($project, $user); $deletedIds[] = $project->id; } }); return $deletedIds; } private function forceDeleteProject(Project $project, User $user): void { DB::transaction(function () use ($project, $user) { $attachments = Attachment::withTrashed() ->whereIn('activity_id', Activity::withTrashed() ->where('project_id', $project->id) ->select('id')) ->get(); foreach ($attachments as $attachment) { $this->resource->deleteStorageFile($attachment->storage_key); } $this->resource->logHistory( $user->id, $project->id, null, 'project_delete', [ 'name' => $project->name, 'status' => $project->status, ], [ 'permanent' => true, 'deleted_at' => now()->toIso8601String(), ], ); $activityIds = Activity::withTrashed() ->where('project_id', $project->id) ->pluck('id'); if ($activityIds->isNotEmpty()) { Dependency::query() ->where(function ($q) use ($activityIds) { $q->whereIn('activity_id', $activityIds) ->orWhereIn('predecessor_id', $activityIds); }) ->delete(); } Activity::withTrashed()->where('project_id', $project->id)->forceDelete(); $project->forceDelete(); }); } /** * Opções para seleção de escopos organizacionais no formulário de projeto. * * @return array{ * structures: Collection<int, Structure>, * unidades: Collection<int, UnidadeOrganizacional>, * empresas: Collection<int, Empresa> * } */ public function listProjectScopeOptions(): array { return [ 'structures' => Structure::query() ->where('status', 'ATIVADO') ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'type']), 'unidades' => UnidadeOrganizacional::query() ->where('status', 'ATIVADO') ->with('secretaria:id,name,abbreviation') ->orderBy('abbreviation') ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'structure_id']), 'empresas' => Empresa::query() ->where('status', 'ATIVADO') ->orderBy('nomeFantasia') ->orderBy('razaoSocial') ->get(['id', 'nomeFantasia', 'razaoSocial', 'cnpj']), ]; } /** * IDs de escopos já associados ao projeto, agrupados por tipo. * * @return array{structures: list<string>, unidades: list<string>, empresas: list<string>} */ public function getProjectScopeIds(Project $project): array { $project->loadMissing('scopes'); return [ 'structures' => $project->scopes ->filter(fn (ProjectScope $scope) => $scope->scope_type === CronogramaProjectScopeType::Structure) ->pluck('scope_id') ->values() ->all(), 'unidades' => $project->scopes ->filter(fn (ProjectScope $scope) => $scope->scope_type === CronogramaProjectScopeType::Unidade) ->pluck('scope_id') ->values() ->all(), 'empresas' => $project->scopes ->filter(fn (ProjectScope $scope) => $scope->scope_type === CronogramaProjectScopeType::Empresa) ->pluck('scope_id') ->values() ->all(), ]; } /** * @param array<string, mixed> $data */ public function syncProjectScopes(Project $project, array $data): void { $rows = []; foreach ((array) ($data['scope_structures'] ?? []) as $scopeId) { if ($scopeId) { $rows[] = [ 'scope_type' => CronogramaProjectScopeType::Structure->value, 'scope_id' => (string) $scopeId, ]; } } foreach ((array) ($data['scope_unidades'] ?? []) as $scopeId) { if ($scopeId) { $rows[] = [ 'scope_type' => CronogramaProjectScopeType::Unidade->value, 'scope_id' => (string) $scopeId, ]; } } foreach ((array) ($data['scope_empresas'] ?? []) as $scopeId) { if ($scopeId) { $rows[] = [ 'scope_type' => CronogramaProjectScopeType::Empresa->value, 'scope_id' => (string) $scopeId, ]; } } ProjectScope::query()->where('project_id', $project->id)->delete(); if ($rows === []) { return; } $now = now(); ProjectScope::insert(array_map( fn (array $row) => [ 'id' => (string) Str::uuid(), 'project_id' => $project->id, 'scope_type' => $row['scope_type'], 'scope_id' => $row['scope_id'], 'created_at' => $now, 'updated_at' => $now, ], $rows, )); } /** * Carrega as atividades ativas do projeto aplicando os filtros server-side da * requisição (usado na exportação). Sem filtros, retorna todas as atividades. * * @return Collection<int, Activity> */ public function loadFilteredActivitiesForExport(Project $project, Request $request): Collection { $this->resource->batchResolveOverdueStatuses($project); $query = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->with([ 'responsibles.responsible', 'predecessors.predecessor', 'comments' => fn ($c) => $c->whereNull('deleted_at'), 'attachments' => fn ($a) => $a->whereNull('deleted_at'), ]); $this->applyActivityFilters($query, $this->resource->resolveActivityFilters($request)); return $query->orderBy('sort_order')->get(); } public function loadProjectWithActivities(Project $project): Project { $project->load([ 'user', 'activeActivities' => function ($q) { $q->with([ 'responsibles.responsible', 'predecessors.predecessor', 'comments' => fn ($c) => $c->whereNull('deleted_at')->with('user'), 'attachments' => fn ($a) => $a->whereNull('deleted_at'), ]); }, 'historyLogs' => fn ($h) => $h->with('user')->limit(100), ]); // Resolve status de atividades atrasadas em batch $this->resource->batchResolveOverdueStatuses($project); return $project; } /** * @param array<string, mixed> $data */ public function createActivity(Project $project, User $user, array $data): Activity { $this->resource->assertProjectWritable($project); return DB::transaction(function () use ($project, $user, $data) { $maxOrder = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->max('sort_order'); $activity = new Activity(); $activity->project_id = $project->id; $activity->code = $data['code'] ?? $this->resource->generateActivityCode($project); $activity->title = $data['title']; $activity->description = $this->resource->sanitizePlainText($data['description'] ?? null); $activity->parent_id = $data['parent_id'] ?? null; $activity->status = $data['status'] ?? CronogramaActivityStatus::NotStarted->value; if (array_key_exists('completion_percent', $data)) { $activity->completion_percent = (float) $data['completion_percent']; } else { $activity->completion_percent = $this->resource->defaultCompletionPercentForStatus($activity->status) ?? 0.0; } $activity->planned_start_date = $data['planned_start_date'] ?? null; $activity->planned_end_date = $data['planned_end_date'] ?? null; $activity->notes = $this->resource->sanitizePlainText($data['notes'] ?? null); $activity->sort_order = ($maxOrder ?? 0) + 1; $activity->status = $this->resource->resolveActivityStatus($activity); $activity->save(); if (! empty($data['predecessor_ids'])) { $this->syncPredecessors($activity, (array) $data['predecessor_ids']); } $this->resource->syncActivityResponsibles($activity, $data['responsibles'] ?? []); $this->resource->logHistory( $user->id, $project->id, $activity->id, 'activity_create', null, $this->resource->activitySnapshotForHistory($activity), ); $this->resource->recalculateProjectCompletion($project); return $this->reloadActivity($activity); }); } /** * @param array<string, mixed> $data */ public function updateActivity(Activity $activity, User $user, array $data): Activity { $project = $activity->project; $this->resource->assertProjectWritable($project); return DB::transaction(function () use ($activity, $user, $data, $project) { $old = $activity->only([ 'code', 'title', 'description', 'parent_id', 'completion_percent', 'status', 'planned_start_date', 'planned_end_date', 'actual_start_date', 'actual_end_date', 'notes', ]); $oldResponsibles = $this->resource->responsibleLabel($activity); $previousStatus = $activity->status; $explicitPercentProvided = array_key_exists('completion_percent', $data); $updatable = [ 'code', 'title', 'description', 'parent_id', 'completion_percent', 'planned_start_date', 'planned_end_date', 'actual_start_date', 'actual_end_date', 'notes', 'status', ]; foreach ($updatable as $field) { if (! array_key_exists($field, $data)) { continue; } if (in_array($field, ['description', 'notes'], true)) { $activity->{$field} = $this->resource->sanitizePlainText($data[$field]); continue; } $activity->{$field} = $field === 'completion_percent' ? (float) $data[$field] : $data[$field]; } $this->resource->applyCompletionPercentForStatusChange($activity, $previousStatus, $explicitPercentProvided); $activity->status = $this->resource->resolveActivityStatus($activity); $activity->save(); if (array_key_exists('predecessor_ids', $data)) { $this->syncPredecessors($activity, (array) $data['predecessor_ids']); } if (array_key_exists('responsibles', $data)) { $this->resource->syncActivityResponsibles($activity, (array) $data['responsibles']); } $new = $activity->only([ 'code', 'title', 'description', 'parent_id', 'completion_percent', 'status', 'planned_start_date', 'planned_end_date', 'actual_start_date', 'actual_end_date', 'notes', ]); $new['responsibles'] = $this->resource->responsibleLabel($activity); $old['responsibles'] = $oldResponsibles; $this->resource->logHistory( $user->id, $project->id, $activity->id, 'activity_update', $old, $new, ); $this->resource->recalculateProjectCompletion($project); return $this->reloadActivity($activity); }); } public function duplicateActivity(Activity $activity, User $user): Activity { $project = $activity->project; $this->resource->assertProjectWritable($project); return DB::transaction(function () use ($activity, $user, $project) { $copy = $activity->replicate(['id', 'created_at', 'updated_at', 'deleted_at']); $copy->title = $activity->title . ' (' . __('Cópia') . ')'; $copy->code = $this->resource->generateActivityCode($project); $copy->status = CronogramaActivityStatus::NotStarted->value; $copy->completion_percent = 0; $copy->sort_order = $activity->sort_order + 1; $copy->save(); $activity->loadMissing('responsibles'); $this->resource->syncActivityResponsibles( $copy, $activity->responsibles ->map(fn ($r) => ['type' => $r->responsible_type, 'id' => $r->responsible_id]) ->all(), ); $this->resource->logHistory( $user->id, $project->id, $copy->id, 'activity_duplicate', $this->resource->activitySnapshotForHistory($activity), $this->resource->activitySnapshotForHistory($copy), ); $this->resource->recalculateProjectCompletion($project); return $this->reloadActivity($copy); }); } /** * Recarrega apenas as relações da atividade (evita re-buscar o registro principal). */ public function reloadActivity(Activity $activity): Activity { $activity->load(['responsibles.responsible', 'predecessors.predecessor', 'attachments', 'comments']); return $activity; } /** * Carrega a atividade com tudo que o offcanvas de detalhes precisa. */ public function loadActivityForDetail(Activity $activity): Activity { $activity->load([ 'responsibles.responsible', 'predecessors.predecessor', 'attachments' => fn ($a) => $a->whereNull('deleted_at')->with('uploader')->orderBy('created_at'), ]); return $activity; } /** * Localiza atividade do projeto (ativa, arquivada ou ambas). */ public function findProjectActivity( Project $project, string $activityId, bool $onlyTrashed = false, bool $withTrashed = false, ): Activity { $query = Activity::query()->where('project_id', $project->id); if ($onlyTrashed) { $query->onlyTrashed(); } elseif ($withTrashed) { $query->withTrashed(); } else { $query->whereNull('deleted_at'); } return $query->where('id', $activityId)->firstOrFail(); } /** * Arquiva atividade (soft delete) e subatividades em cascata. * * @return list<string> IDs arquivados */ public function archiveActivity(Activity $activity, User $user): array { $project = $activity->project; $this->resource->assertProjectWritable($project); if ($activity->trashed()) { throw ValidationException::withMessages([ 'activity' => __('Esta atividade já está arquivada.'), ]); } return DB::transaction(function () use ($activity, $user, $project) { $activity = $this->reloadActivity($activity); $descendantIds = $this->resource->collectDescendantActivityIds($project, $activity->id); $idsToArchive = collect([$activity->id]) ->merge($descendantIds) ->unique() ->values() ->all(); $activities = Activity::query() ->whereIn('id', $idsToArchive) ->get(); foreach ($activities as $act) { $this->resource->logHistory( $user->id, $project->id, $act->id, 'activity_archive', $this->resource->activitySnapshotForHistory($act), [ 'archived_at' => now()->toIso8601String(), 'cascade' => count($idsToArchive) > 1, ], ); } Dependency::query() ->where(function ($q) use ($idsToArchive) { $q->whereIn('activity_id', $idsToArchive) ->orWhereIn('predecessor_id', $idsToArchive); }) ->delete(); Activity::query()->whereIn('id', $idsToArchive)->delete(); $this->resource->recalculateProjectCompletion($project); return $idsToArchive; }); } /** * Restaura atividade arquivada e subatividades arquivadas em cascata. * * @return list<string> IDs restaurados */ public function restoreActivity(Activity $activity, User $user): array { $project = $activity->project; $this->resource->assertProjectWritable($project); if (! $activity->trashed()) { throw ValidationException::withMessages([ 'activity' => __('Somente atividades arquivadas podem ser restauradas.'), ]); } return DB::transaction(function () use ($activity, $user, $project) { $descendantIds = $this->resource->collectTrashedDescendantActivityIds($project, $activity->id); $idsToRestore = collect([$activity->id]) ->merge($descendantIds) ->unique() ->values() ->all(); $activities = Activity::onlyTrashed() ->whereIn('id', $idsToRestore) ->get(); foreach ($activities as $act) { $this->resource->logHistory( $user->id, $project->id, $act->id, 'activity_restore', [ 'archived_at' => $act->deleted_at?->toIso8601String(), ], $this->resource->activitySnapshotForHistory($act), ); } Activity::onlyTrashed()->whereIn('id', $idsToRestore)->restore(); $this->resource->recalculateProjectCompletion($project); return $idsToRestore; }); } /** * Exclui permanentemente atividade arquivada e subatividades arquivadas em cascata. * * @return list<string> IDs excluídos */ public function destroyActivity(Activity $activity, User $user): array { $project = $activity->project; $this->resource->assertProjectWritable($project); if (! $activity->trashed()) { throw ValidationException::withMessages([ 'activity' => __('Somente atividades arquivadas podem ser excluídas. Arquive a atividade primeiro.'), ]); } return DB::transaction(function () use ($activity, $user, $project) { $descendantIds = $this->resource->collectTrashedDescendantActivityIds($project, $activity->id); $idsToDelete = collect([$activity->id]) ->merge($descendantIds) ->unique() ->values() ->all(); $this->forceDeleteTrashedActivities($project, $user, $idsToDelete); return $idsToDelete; }); } /** * Exclui permanentemente várias atividades arquivadas (e subatividades em cascata). * * @param list<string> $activityIds * @return list<string> IDs efetivamente excluídos */ public function destroyArchivedActivitiesBulk(Project $project, User $user, array $activityIds): array { $this->resource->assertProjectWritable($project); $activityIds = array_values(array_unique(array_filter($activityIds))); if ($activityIds === []) { throw ValidationException::withMessages([ 'activity_ids' => __('Selecione ao menos uma atividade arquivada.'), ]); } return DB::transaction(function () use ($project, $user, $activityIds) { $idsToDelete = []; foreach ($activityIds as $id) { $activity = Activity::onlyTrashed() ->where('project_id', $project->id) ->where('id', $id) ->first(); if (! $activity) { throw ValidationException::withMessages([ 'activity_ids' => __('Uma ou mais atividades selecionadas são inválidas ou já foram excluídas.'), ]); } $descendantIds = $this->resource->collectTrashedDescendantActivityIds($project, $activity->id); $idsToDelete = array_merge( $idsToDelete, [$activity->id], $descendantIds, ); } $idsToDelete = array_values(array_unique($idsToDelete)); $this->forceDeleteTrashedActivities($project, $user, $idsToDelete); return $idsToDelete; }); } /** * @param list<string> $idsToDelete */ private function forceDeleteTrashedActivities(Project $project, User $user, array $idsToDelete): void { if ($idsToDelete === []) { return; } $activities = Activity::onlyTrashed() ->where('project_id', $project->id) ->whereIn('id', $idsToDelete) ->get(); foreach ($activities as $act) { $this->resource->logHistory( $user->id, $project->id, $act->id, 'activity_delete', $this->resource->activitySnapshotForHistory($act), [ 'permanent' => true, 'deleted_at' => now()->toIso8601String(), ], ); } Dependency::query() ->where(function ($q) use ($idsToDelete) { $q->whereIn('activity_id', $idsToDelete) ->orWhereIn('predecessor_id', $idsToDelete); }) ->delete(); Comment::query()->whereIn('activity_id', $idsToDelete)->delete(); $attachments = Attachment::withTrashed() ->whereIn('activity_id', $idsToDelete) ->get(); foreach ($attachments as $attachment) { $this->resource->deleteStorageFile($attachment->storage_key); } Attachment::withTrashed()->whereIn('activity_id', $idsToDelete)->forceDelete(); Activity::onlyTrashed() ->where('project_id', $project->id) ->whereIn('id', $idsToDelete) ->forceDelete(); $this->resource->recalculateProjectCompletion($project); } /** * @param list<string> $orderedIds */ public function reorderActivities(Project $project, User $user, array $orderedIds): void { $this->resource->assertProjectWritable($project); DB::transaction(function () use ($project, $user, $orderedIds) { $now = now(); foreach (array_values($orderedIds) as $index => $id) { Activity::query() ->where('id', $id) ->where('project_id', $project->id) ->update([ 'sort_order' => $index + 1, 'updated_at' => $now, ]); } $this->resource->logHistory($user->id, $project->id, null, 'activities_reorder', null, ['order' => $orderedIds]); }); } /** * @param array<string, mixed> $data */ public function inlineUpdateActivity(Activity $activity, User $user, array $data): Activity { return $this->updateActivity($activity, $user, $data); } /** * @param list<string> $predecessorIds */ private function syncPredecessors(Activity $activity, array $predecessorIds): void { $predecessorIds = array_values(array_unique(array_filter($predecessorIds))); $this->resource->assertNoSelfPredecessor($activity, $predecessorIds); Dependency::query()->where('activity_id', $activity->id)->delete(); if (empty($predecessorIds)) { return; } // Bulk insert: 1 query em vez de N inserts individuais $now = now(); $rows = array_map(fn ($predId) => [ 'id' => (string) Str::uuid(), 'activity_id' => $activity->id, 'predecessor_id' => $predId, 'created_at' => $now, 'updated_at' => $now, ], $predecessorIds); Dependency::insert($rows); } /** * @param array<string, mixed> $data */ public function storeComment(Activity $activity, User $user, array $data): Comment { $this->resource->assertProjectWritable($activity->project); return DB::transaction(function () use ($activity, $user, $data) { $comment = Comment::create([ 'activity_id' => $activity->id, 'user_id' => $user->id, 'content' => (string) $this->resource->sanitizePlainText($data['content']), ]); $this->resource->logHistory($user->id, $activity->project_id, $activity->id, 'comment_create', null, ['content' => $comment->content]); return $comment->load('user'); }); } public function destroyComment(Comment $comment, User $user): void { $activity = $comment->activity; $this->resource->assertProjectWritable($activity->project); DB::transaction(function () use ($comment, $user, $activity) { $this->resource->logHistory( $user->id, $activity->project_id, $activity->id, 'comment_delete', ['content' => $comment->content], null, ); $comment->delete(); }); } /** * @param list<UploadedFile> $files */ public function storeAttachments(Activity $activity, User $user, array $files): Collection { $this->resource->assertProjectWritable($activity->project); return DB::transaction(function () use ($activity, $user, $files) { $currentBytes = Attachment::query() ->where('activity_id', $activity->id) ->whereNull('deleted_at') ->sum('file_size_bytes'); $maxFile = (int) config('cronogramas.upload.max_file_bytes'); $maxTotal = (int) config('cronogramas.upload.max_total_bytes_per_activity'); $stored = collect(); foreach ($files as $file) { if (! $file instanceof UploadedFile) { continue; } $size = $file->getSize() ?: 0; if ($size > $maxFile) { throw ValidationException::withMessages([ 'attachments' => __('Arquivo excede o tamanho máximo permitido.'), ]); } if ($currentBytes + $size > $maxTotal) { throw ValidationException::withMessages([ 'attachments' => __('Limite total de anexos da atividade excedido.'), ]); } if (FileUploadHelper::containsDangerousContent($file)) { throw ValidationException::withMessages([ 'attachments' => __('Arquivo rejeitado por motivo de segurança.'), ]); } $key = $this->resource->storeAttachmentFile($file); $att = Attachment::create([ 'activity_id' => $activity->id, 'original_name' => $file->getClientOriginalName(), 'storage_key' => $key, 'mime_type' => $file->getMimeType() ?? 'application/octet-stream', 'file_size_bytes' => $size, 'uploaded_by' => $user->id, ]); $currentBytes += $size; $stored->push($att); } if ($stored->isNotEmpty()) { $this->resource->logHistory($user->id, $activity->project_id, $activity->id, 'attachments_upload', null, [ 'count' => $stored->count(), 'files' => $stored->pluck('original_name')->values()->all(), ]); } return $stored; }); } public function downloadAttachment(Activity $activity, Attachment $attachment): Attachment { if ($attachment->activity_id !== $activity->id) { throw ValidationException::withMessages([ 'attachment' => __('Anexo inválido.'), ]); } return $attachment; } public function destroyAttachment(Activity $activity, Attachment $attachment, User $user): void { if ($attachment->activity_id !== $activity->id) { throw ValidationException::withMessages([ 'attachment' => __('Anexo inválido.'), ]); } $this->resource->assertProjectWritable($activity->project); DB::transaction(function () use ($activity, $attachment, $user) { $this->resource->logHistory($user->id, $activity->project_id, $activity->id, 'attachment_delete', [ 'name' => $attachment->original_name, 'size' => $this->resource->formatBytes((int) $attachment->file_size_bytes), ], null); $this->resource->deleteStorageFile($attachment->storage_key); $attachment->delete(); }); } /** * @return Collection<int, User> */ public function listResponsibles(): Collection { return $this->listResponsibleOptions()['users']; } /** * Opções de responsável restritas aos que já estão vinculados às atividades * (ativas) do projeto. Torna o filtro "Responsável" objetivo, evitando * carregar a tabela inteira de pessoas, empresas, órgãos e unidades. * * @return array{ * users: Collection<int, User>, * empresas: Collection<int, Empresa>, * structures: Collection<int, Structure>, * unidades: Collection<int, UnidadeOrganizacional> * } */ public function listProjectResponsibleOptions(Project $project): array { $pairs = ActivityResponsible::query() ->whereHas('activity', function ($q) use ($project) { $q->where('project_id', $project->id) ->whereNull('deleted_at'); }) ->get(['responsible_type', 'responsible_id']) ->groupBy('responsible_type'); $idsByType = static fn (string $type): array => ($pairs[$type] ?? collect()) ->pluck('responsible_id') ->unique() ->values() ->all(); $userIds = $idsByType(CronogramaResponsibleType::User->value); $empresaIds = $idsByType(CronogramaResponsibleType::Empresa->value); $structureIds = $idsByType(CronogramaResponsibleType::Structure->value); $unidadeIds = $idsByType(CronogramaResponsibleType::Unidade->value); return [ 'users' => empty($userIds) ? collect() : User::query() ->whereIn('id', $userIds) ->orderBy('firstName') ->orderBy('lastName') ->get(['id', 'firstName', 'lastName', 'email']), 'empresas' => empty($empresaIds) ? collect() : Empresa::query() ->whereIn('id', $empresaIds) ->orderBy('nomeFantasia') ->orderBy('razaoSocial') ->get(['id', 'nomeFantasia', 'razaoSocial', 'cnpj']), 'structures' => empty($structureIds) ? collect() : Structure::query() ->whereIn('id', $structureIds) ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'type']), 'unidades' => empty($unidadeIds) ? collect() : UnidadeOrganizacional::query() ->whereIn('id', $unidadeIds) ->with('secretaria:id,name,abbreviation') ->orderBy('abbreviation') ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'structure_id']), ]; } /** * Opções de responsável por tipo (pessoa, empresa, órgão e unidade organizacional). * * @return array{ * users: Collection<int, User>, * empresas: Collection<int, Empresa>, * structures: Collection<int, Structure>, * unidades: Collection<int, UnidadeOrganizacional> * } */ public function listResponsibleOptions(): array { return Cache::remember('cronogramas.responsible_options.v2', 1_800, fn () => [ 'users' => User::query() ->where('status', 'ATIVADO') ->orderBy('firstName') ->orderBy('lastName') ->get(['id', 'firstName', 'lastName', 'email']), 'empresas' => Empresa::query() ->where('status', 'ATIVADO') ->orderBy('nomeFantasia') ->orderBy('razaoSocial') ->get(['id', 'nomeFantasia', 'razaoSocial', 'cnpj']), 'structures' => Structure::query() ->where('status', 'ATIVADO') ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'type']), 'unidades' => UnidadeOrganizacional::query() ->where('status', 'ATIVADO') ->with('secretaria:id,name,abbreviation') ->orderBy('abbreviation') ->orderBy('name') ->get(['id', 'name', 'abbreviation', 'structure_id']), ]); } /** * @return Collection<int, Activity> */ public function listArchivedActivities(Project $project): Collection { return Activity::onlyTrashed() ->where('project_id', $project->id) ->with(['responsibles.responsible', 'predecessors.predecessor']) ->orderByDesc('deleted_at') ->get(); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings