332 lines
19 KiB
PHP
332 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\AdmissionDecision;
|
|
use App\Models\ExternalPayment;
|
|
use App\Models\Invoice;
|
|
use App\Models\PeopleProcess;
|
|
use App\Models\PurchaseAward;
|
|
use App\Models\QualificationAssessment;
|
|
use App\Models\QualificationSet;
|
|
use App\Models\Quotation;
|
|
use App\Models\RecommendationAttempt;
|
|
use App\Models\Requisition;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
/** Current tasks delegated to the existing module authorities, never another workflow. */
|
|
class WorkflowTaskInbox
|
|
{
|
|
/** @return list<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>>
|
|
*/
|
|
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<array<string,mixed>> */
|
|
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<array<string,mixed>> */
|
|
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;
|
|
}
|
|
}
|