Files
compras/app/Services/WorkflowTaskInbox.php
T
2026-11-02 20:27:11 +02:00

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.'&quotation_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;
}
}