> */ public function build(User $actor, string $area): array { abort_unless(auth()->id() === $actor->id && in_array($area, ['procurement', 'people'], true), 403); abort_unless($area !== 'people' || app(PeopleHiring::class)->kinds($actor) !== [], 403); if (app(OrganizationAccess::class)->maintenanceOnly($actor)) { return []; } $rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->finance($actor)]; usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id'])); return $rows; } /** @return list> */ private function purchases(User $actor): array { $rows = []; $prepare = app(OrganizationAccess::class)->canRequest($actor); $query = Requisition::query()->visibleTo($actor)->where(function ($q) use ($actor, $prepare) { $q->where('status', 'pending'); if ($prepare) { $q->orWhere(fn ($draft) => $draft->where('status', 'draft')->where('requester_id', $actor->id)); } }); foreach ($query->with('steps')->lazyById(100) as $record) { $draft = $record->status === 'draft' && $prepare && $record->requester_id === $actor->id; if (! $draft && ! $record->canDecide($actor)) { continue; } $step = $draft ? null : $record->steps->firstWhere('decision', 'pending'); $rows[] = ['id' => 'purchase:'.$record->id.':'.($step->id ?? 'draft'), 'module' => 'purchases', 'reference' => $record->number, 'label' => $draft ? 'Conferir e submeter requisição de compra' : 'Decidir a etapa de aprovação', 'task' => $draft ? 'prepare' : 'approve', 'status' => $record->status, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$record->id]; } return $rows; } /** @return list> */ private function warehouse(User $actor): array { if (! Schema::hasTable('warehouse_requests') || ! Schema::hasTable('warehouse_request_events')) { return []; } $desk = app(WarehouseWorkdesk::class); $rows = []; foreach (['review', 'reserve', 'delivery', 'receipt'] as $view) { $query = $desk->forView($desk->visible($actor), $actor, $view); foreach ($query->lazyById(100) as $record) { $progress = app(WarehouseFulfilment::class)->progress($record); $health = in_array($view, ['reserve', 'delivery'], true) ? $desk->health($record, $progress) : null; if ($view === 'reserve' && ! $health['needs_reservation'] && ! $health['needs_check']) { continue; } $base = ['module' => 'warehouse', 'reference' => 'RA-'.$record->id, 'status' => $progress['state'], 'created_at' => $this->date($record->created_at), 'due_on' => null]; if ($view === 'receipt') { foreach ($progress['events'] as $event) { if ($event['action'] === 'deliver' && ! in_array($event['id'], $progress['received_deliveries'], true)) { $rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':receive:'.$event['id'], 'task' => 'receive', 'label' => 'Confirmar recepção da entrega #'.$event['id'], 'blocked' => false, 'created_at' => $this->date($event['created_at']), 'href' => '/stock/requisicoes/'.$record->id.'#warehouse-command-receive-'.$event['id']]; } } continue; } $action = $view === 'review' ? 'authorize' : ($view === 'delivery' ? 'deliver' : 'reserve'); $blocked = $view === 'delivery' && ! $health['delivery_ready']; $rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':'.$view, 'task' => $view, 'label' => match ($view) { 'review' => 'Autorizar ou recusar requisição interna', 'reserve' => 'Conferir reposição e actualizar reserva', default => $blocked ? 'Conferir stock antes da entrega' : 'Confirmar a entrega física', }, 'blocked' => $blocked, 'href' => '/stock/requisicoes/'.$record->id.($blocked ? '' : '#warehouse-command-'.$action)]; } } return $rows; } /** @return list> */ private function people(User $actor): array { $kinds = app(PeopleHiring::class)->kinds($actor); abort_unless($kinds !== [], 403); if (! Schema::hasTable('people_processes') || ! Schema::hasTable('people_process_access')) { return []; } $query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor); $desk = app(PeopleWorkdesk::class)->build(clone $query, $actor); $dates = (clone $query)->pluck('created_at', 'id'); $rows = []; foreach ([...$desk['tasks'], ...$desk['approvals']] as $task) { $rows[] = ['id' => 'people:'.$task['id'], 'module' => 'people', 'reference' => $task['reference'], 'label' => $task['label'], 'task' => $task['action'], 'status' => $task['status'], 'blocked' => false, 'created_at' => $this->date($dates->get($task['process_id'])), 'due_on' => $task['due_on'], 'href' => $task['href']]; } return $rows; } /** @return list> */ private function finance(User $actor): array { if (! $actor->hasFunctions(['finance'])) { return []; } $rows = []; foreach (Invoice::query()->where('status', 'pending')->lazyById(100) as $invoice) { if (! app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)) { continue; } $rows[] = ['id' => 'invoice:'.$invoice->id, 'module' => 'finance', 'reference' => $invoice->number, 'label' => 'Conferir factura de fornecedor', 'task' => 'invoice_review', 'status' => $invoice->status, 'blocked' => false, 'created_at' => $this->date($invoice->created_at), 'due_on' => null, 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id]; } foreach (ExternalPayment::query()->where('status', 'pending')->lazyById(100) as $payment) { if (! app(ExternalPaymentWorkflow::class)->reviewerEligible($actor, $payment)) { continue; } $invoice = Invoice::query()->whereKey($payment->invoice_id)->firstOrFail(); $rows[] = ['id' => 'payment:'.$payment->id, 'module' => 'finance', 'reference' => $invoice->number, 'label' => 'Conferir registo de pagamento externo', 'task' => 'payment_review', 'status' => $payment->status, 'blocked' => false, 'created_at' => $this->date($payment->created_at), 'due_on' => null, 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id]; } return $rows; } /** @return list> */ private function awards(User $actor): array { if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) { return []; } $workflow = app(PurchaseWorkflow::class); $rows = []; $requests = Requisition::query()->visibleTo($actor); $query = PurchaseAward::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereIn('status', ['pending', 'approved']); foreach ($query->lazyById(100) as $award) { $request = (clone $requests)->whereKey($award->requisition_id)->firstOrFail(); $decide = $workflow->canDecide($actor, $award, $request); if (! $decide && ! $workflow->canIssue($actor, $award, $request)) { continue; } $step = $decide ? $award->approvals()->where('decision', 'pending')->orderBy('position')->first() : null; $rows[] = ['id' => 'award:'.$award->id.':'.($step->id ?? 'issue'), 'module' => 'purchases', 'reference' => $request->number, 'label' => $decide ? 'Decidir a etapa da adjudicação' : 'Conferir adjudicação para emitir ordem de compra', 'task' => $decide ? 'award_approve' : 'order_issue_review', 'status' => $award->status, 'blocked' => false, 'created_at' => $this->date($award->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$request->id.'/adjudicacao']; } return $rows; } private function date(mixed $value): ?string { return $value === null ? null : CarbonImmutable::parse($value)->utc()->toIso8601String(); } /** @return list> */ private function formalReviews(User $actor): array { if (! $actor->hasFunctions(['finance', 'department_head'])) { return []; } $rows = []; $admission = app(FormalAdmission::class); $recommendation = app(FormalRecommendation::class); $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id')); foreach ($requests->lazyById(100) as $request) { $base = ['module' => 'purchases', 'reference' => $request->number, 'blocked' => false, 'status' => 'pending', 'due_on' => null]; $adoption = $admission->adoption($request); if ($adoption !== null) { $decisions = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->whereNotExists(fn ($query) => $query->selectRaw('1')->from('admission_decisions as newer') ->whereColumn('newer.tenant_id', 'admission_decisions.tenant_id')->whereColumn('newer.admission_adoption_id', 'admission_decisions.admission_adoption_id') ->whereColumn('newer.quotation_id', 'admission_decisions.quotation_id')->whereColumn('newer.revision', '>', 'admission_decisions.revision')); foreach ($decisions->lazyById(100) as $decision) { if ($admission->canReview($actor, $request, $decision)) { $rows[] = [...$base, 'id' => 'admission:'.$decision->id.':review', 'task' => 'admission_review', 'label' => 'Conferir proposta de admissão formal', 'created_at' => $this->date($decision->created_at), 'href' => '/requisicoes/'.$request->id.'/admissao?version='.$adoption->id.'&decision_id='.$decision->id.'#admission-'.$decision->id]; } } } $adoption = $recommendation->adoption($request); $attempt = $adoption === null ? null : RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first(); if ($adoption !== null && $attempt !== null && $recommendation->canReview($actor, $request, $attempt)) { $rows[] = [...$base, 'id' => 'recommendation:'.$attempt->id.':review', 'task' => 'recommendation_review', 'label' => 'Conferir recomendação formal de adjudicação', 'created_at' => $this->date($attempt->created_at), 'href' => '/requisicoes/'.$request->id.'/recomendacao?version='.$adoption->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id]; } } return $rows; } /** Only a person's own unfinished analysis, never a new mandatory analyst. * @return list> */ private function documentary(User $actor): array { if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) { return []; } $rows = []; $workflow = app(DocumentaryQualificationWorkflow::class); $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id')); $query = QualificationSet::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_sets as newer') ->whereColumn('newer.tenant_id', 'qualification_sets.tenant_id')->whereColumn('newer.requisition_id', 'qualification_sets.requisition_id')->whereColumn('newer.revision', '>', 'qualification_sets.revision')); foreach ($query->lazyById(100) as $set) { $request = (clone $requests)->whereKey($set->requisition_id)->firstOrFail(); if (! $workflow->canAssess($actor, $request, $set)) { continue; } $records = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('analyst_id', $actor->id)->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_assessments as newer') ->whereColumn('newer.tenant_id', 'qualification_assessments.tenant_id')->whereColumn('newer.qualification_set_id', 'qualification_assessments.qualification_set_id') ->whereColumn('newer.analyst_id', 'qualification_assessments.analyst_id')->whereColumn('newer.quotation_id', 'qualification_assessments.quotation_id')->whereColumn('newer.revision', '>', 'qualification_assessments.revision')); foreach ($records->lazyById(100) as $record) { if (! collect($record->answers)->contains('result', 'pending')) { continue; } $rows[] = ['id' => 'qualification:'.$record->id.':complete', 'module' => 'purchases', 'reference' => $request->number, 'label' => 'Completar a minha análise documental', 'task' => 'qualification_complete', 'status' => 'pending', 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$request->id.'/qualificacao?version='.$set->id.'"ation_id='.$record->quotation_id.'#qualification-assess']; } } return $rows; } /** @return list> */ private function supply(User $actor): array { $planning = app(SupplyPlanning::class); if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) { return []; } $rows = []; foreach (['agreement', 'policy', 'auto_rule'] as $kind) { foreach ($planning->latest($kind)->whereIn('state', ['draft', 'returned'])->lazyById(100) as $record) { $eligible = $kind === 'auto_rule' ? app(AutomaticPurchases::class)->canReview($actor, $record) : $planning->canReview($actor, $record); if (! $eligible) { continue; } $rows[] = ['id' => 'supply:'.$record->id.':review', 'module' => 'supply', 'reference' => trim(($record->snapshot['item']['code'] ?? 'Fornecimento').' '.($record->snapshot['warehouse']['code'] ?? '')), 'label' => match ($kind) { 'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', default => 'Conferir parametrização de compra automática', }, 'task' => $kind.'_review', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => $kind === 'auto_rule' ? '/stock/reposicao?rule_id='.$record->id.'#supply-rule-'.$record->id : '/compras/fornecimento?view='.($kind === 'agreement' ? 'agreements' : 'calendar').'&record_id='.$record->id.'#supply-record-'.$record->id]; } } return $rows; } /** @return list> */ private function quotations(User $actor): array { if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) { return []; } $access = app(QuotationParticipation::class); $rows = []; $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved') ->whereNotIn('id', PurchaseAward::query()->select('requisition_id')); foreach ($requests->lazyById(100) as $request) { $prepare = $access->canPrepare($actor, $request); if (! $prepare && ! $access->canEvaluateProcess($actor, $request)) { continue; } $comparison = app(ProposalComparison::class)->inspect($request); $path = '/requisicoes/'.$request->id.'/cotacoes'; $base = ['module' => 'purchases', 'reference' => $request->number, 'status' => $request->status, 'blocked' => false, 'created_at' => $this->date($request->created_at), 'due_on' => null]; if ($prepare) { if (count($comparison['rows']) < $comparison['policy']['minimum_proposals']) { $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':prepare', 'task' => 'quotation_prepare', 'label' => 'Completar consulta ao mercado', 'href' => $path.'?prepare=1#quotation-create']; } elseif (! $comparison['ready'] && collect($comparison['rows'])->contains('eligible', false)) { $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':check', 'task' => 'quotation_check', 'label' => 'Conferir propostas ou impedimentos da comparação', 'href' => $path]; } elseif ($comparison['ready'] && $actor->id !== $request->requester_id) { $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':result', 'task' => 'comparison_review', 'label' => 'Conferir comparação e requisitos da adjudicação', 'href' => '/requisicoes/'.$request->id.'/adjudicacao']; } } $quotes = Quotation::query()->where('requisition_id', $request->id)->with('supplier')->get()->keyBy('id'); foreach ($comparison['rows'] as $row) { $quote = $quotes->get($row['id']); if (! $row['eligible'] || $row['evaluation_count'] >= $comparison['policy']['minimum_evaluators'] || $quote === null || ! $access->canEvaluate($actor, $request, $quote)) { continue; } $rows[] = [...$base, 'id' => 'quotation:'.$quote->id.':evaluate', 'task' => 'quotation_evaluate', 'label' => 'Avaliar proposta '.$quote->reference, 'created_at' => $this->date($quote->created_at), 'href' => $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id]; } } return $rows; } }