Files
compras/app/Services/WorkflowTaskInbox.php
T
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

617 lines
38 KiB
PHP

<?php
namespace App\Services;
use App\Models\AdmissionDecision;
use App\Models\ExternalPayment;
use App\Models\Invoice;
use App\Models\OrderCancellation;
use App\Models\OrderCancellationPolicy;
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\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\SupplierResponse;
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->suppliers($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->suggestions($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->inventoryCorrections($actor), ...$this->lotSetups($actor), ...$this->finance($actor)];
if ($area === 'procurement') {
$rows = [...$rows, ...$this->supplierImprovements($actor), ...$this->supplierQuality($actor), ...$this->supplyFollowUp($actor), ...$this->lotQuality($actor)];
if (config('order_cancellation.enabled') && Schema::hasTable('order_cancellations')) {
foreach (OrderCancellation::query()->where('status', 'pending')->lazyById(100) as $record) {
if (app(OrderCancellationWorkflow::class)->canDecide($actor, $record)) {
$rows[] = ['id' => 'order-cancellation:'.$record->id, 'module' => 'finance', 'reference' => $record->snapshot['order_number'],
'label' => 'Conferir cancelamento de ordem emitida', 'task' => 'order_cancellation_review', 'status' => 'pending', 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/ordens/cancelamentos?cancellation_id='.$record->id.'#cancellation-'.$record->id];
}
}
foreach (OrderCancellationPolicy::query()->where('status', 'pending')->lazyById(100) as $policy) {
if (app(OrderCancellationPolicyWorkflow::class)->canReview($actor, $policy)) {
$rows[] = ['id' => 'order-cancellation-policy:'.$policy->id, 'module' => 'finance', 'reference' => 'Política de cancelamento v'.$policy->revision,
'label' => 'Conferir política de cancelamento de ordens', 'task' => 'order_cancellation_policy_review', 'status' => 'pending', 'blocked' => false,
'created_at' => $this->date($policy->created_at), 'due_on' => null, 'href' => '/ordens/cancelamentos#policy-'.$policy->id];
}
}
foreach (OrderCancellation::query()->where('status', 'approved')->whereNull('revised_requisition_id')->lazyById(100) as $record) {
if (app(OrderCancellationWorkflow::class)->canRevise($actor, $record)) {
$rows[] = ['id' => 'order-cancellation-revision:'.$record->id, 'module' => 'purchases', 'reference' => $record->snapshot['order_number'],
'label' => 'Preparar nova requisição após cancelamento de ordem', 'task' => 'order_cancellation_revision', 'status' => 'approved', 'blocked' => false,
'created_at' => $this->date($record->reviewed_at), 'due_on' => null, 'href' => '/ordens/cancelamentos?cancellation_id='.$record->id.'#cancellation-'.$record->id];
}
}
}
}
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 lotQuality(User $actor): array
{
$lots = app(InventoryLots::class);
if (! Schema::hasTable('inventory_lot_allocations')
|| (! $lots->allows($actor, 'inventory_lot_prepare') && ! $lots->allows($actor, 'inventory_lot_release') && ! $lots->allows($actor, 'inventory_lot_hold'))) {
return [];
}
$rows = [];
foreach ($lots->activeSetups()->lazyById(100) as $setup) {
$context = $lots->context($setup->stock_item_id, $setup->warehouse_id);
foreach ($context['lots'] ?? [] as $entry) {
if ($entry['quantity_milli'] <= 0) {
continue;
}
$metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail();
$action = $entry['state'] === 'unidentified' && $lots->canIdentify($actor, $metadata) ? 'identify'
: ($entry['state'] === 'held' && $lots->canDecideLot($actor, $metadata, $context, $entry, 'release') ? 'release'
: ($entry['quality_released'] && ! $entry['eligible'] && $lots->canDecideLot($actor, $metadata, $context, $entry, 'hold') ? 'hold' : null));
if ($action === null) {
continue;
}
$rows[] = ['id' => 'lot-quality:'.$setup->warehouse_id.':'.$entry['id'].':'.$action.':'.$entry['version'].':'.$entry['state_version'], 'module' => 'warehouse',
'reference' => $setup->snapshot['item']['code'].' / LOTE-'.$entry['id'].' / '.$setup->snapshot['warehouse']['code'],
'label' => match ($action) {
'identify' => 'Completar identificação de lote retido', 'release' => 'Conferir qualidade para libertação independente do lote', default => 'Conferir retenção de lote anteriormente libertado com validade insuficiente'
},
'task' => 'lot_'.$action, 'status' => $entry['state'], 'blocked' => false, 'created_at' => $this->date($metadata->created_at), 'due_on' => null,
'href' => '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$setup->warehouse_id.'#lot-'.$setup->warehouse_id.'-'.$entry['id']];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplyFollowUp(User $actor): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$planning = app(SupplyPlanning::class);
$coordination = app(SupplyCoordination::class);
$rows = [];
foreach (['reservation' => 'supply_reserve', 'delivery' => 'supply_delivery'] as $kind => $permission) {
if (! $planning->allows($actor, $permission)) {
continue;
}
foreach ($planning->latest($kind)->whereIn('state', $kind === 'reservation' ? ['active', 'fulfilled'] : ['active'])->lazyById(100) as $record) {
if (! $coordination->canManage($actor, $record, 'edit')) {
continue;
}
$remaining = $kind === 'reservation' ? $coordination->remainingReservation($record) : $coordination->remainingDelivery($record);
if ($remaining <= 0) {
continue;
}
$rows[] = ['id' => 'coordination:'.$record->id.':followup', 'module' => 'supply',
'reference' => trim(($record->snapshot['item']['code'] ?? '').' '.($record->data['reference'] ?? $record->snapshot['order']['number'] ?? 'FOR-'.$record->id)),
'label' => $kind === 'reservation' ? 'Acompanhar necessidade planeada e associar saída efectiva' : 'Acompanhar entrega confirmada ainda por receber',
'task' => $kind.'_followup', 'status' => $record->state, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => $record->data[$kind === 'reservation' ? 'needed_on' : 'expected_on'],
'href' => '/compras/fornecimento?view='.($kind === 'reservation' ? 'reservations' : 'deliveries').'&record_id='.$record->id.'#supply-record-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplierQuality(User $actor): array
{
if (! Schema::hasTable('supplier_evaluations') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) {
return [];
}
$workflow = app(SupplierEvaluationWorkflow::class);
$responses = app(SupplierResponseWorkflow::class);
$rows = [];
foreach (SupplierEvaluation::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review'])->lazyById(100) as $evaluation) {
$action = $evaluation->status === 'review' ? 'review' : 'edit';
if (! $workflow->canAct($actor, $evaluation, $action)
|| ($action === 'edit' && ! in_array($actor->id, $evaluation->contributors, true))) {
continue;
}
$rows[] = ['id' => 'supplier-evaluation:'.$evaluation->id.':'.$action.':'.$evaluation->version, 'module' => 'purchases',
'reference' => 'AVA-'.$evaluation->id.' / R'.$evaluation->revision,
'label' => $action === 'review' ? 'Conferir avaliação de qualidade do fornecedor' : 'Completar e submeter avaliação de qualidade do fornecedor',
'task' => 'supplier_evaluation_'.$action, 'status' => $evaluation->status, 'blocked' => false,
'created_at' => $this->date($evaluation->created_at), 'due_on' => null, 'href' => '/fornecedores/avaliacoes/'.$evaluation->id];
}
if (! Schema::hasTable('supplier_responses')) {
return $rows;
}
foreach (SupplierResponse::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review'])
->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $record) {
$evaluation = $record->evaluation;
$action = $record->status === 'review' ? 'review' : 'edit';
if (! $responses->canAct($actor, $evaluation, $record, $action)
|| ($action === 'edit' && ! in_array($actor->id, $record->contributors, true))) {
continue;
}
$rows[] = ['id' => 'supplier-response:'.$record->id.':'.$action.':'.$record->version, 'module' => 'purchases',
'reference' => 'AVA-'.$evaluation->id.' / CTR-'.$record->id,
'label' => $action === 'review' ? 'Analisar esclarecimento ou contraditório do fornecedor' : 'Completar e submeter esclarecimento do fornecedor',
'task' => 'supplier_response_'.$action, 'status' => $record->status, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/fornecedores/avaliacoes/'.$evaluation->id.'#contraditorio-'.$record->id];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplierImprovements(User $actor): array
{
if (! Schema::hasTable('supplier_improvements') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) {
return [];
}
$workflow = app(SupplierImprovementWorkflow::class);
$rows = [];
foreach (SupplierImprovement::query()->visibleTo($actor)->whereIn('status', ['open', 'returned', 'review'])
->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $measure) {
$action = $measure->status === 'review' ? 'review' : 'execute';
if (! $workflow->canAct($actor, $measure->evaluation, $measure, $action)) {
continue;
}
$rows[] = ['id' => 'supplier-improvement:'.$measure->id.':'.$action.':'.$measure->version, 'module' => 'purchases',
'reference' => 'AVA-'.$measure->supplier_evaluation_id.' / MED-'.$measure->id,
'label' => $action === 'review' ? 'Conferir resultado e evidência da medida de melhoria' : 'Registar execução da medida de melhoria atribuída',
'task' => 'supplier_improvement_'.$action, 'status' => $measure->status, 'blocked' => false,
'created_at' => $this->date($measure->created_at), 'due_on' => $measure->due_on,
'href' => '/fornecedores/avaliacoes/'.$measure->supplier_evaluation_id.'#medida-'.$measure->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 suppliers(User $actor): array
{
$access = app(SupplierParticipation::class);
if (! $access->canReviewRole($actor)) {
return [];
}
$rows = [];
foreach (Supplier::query()->where('status', 'pending')->lazyById(100) as $supplier) {
if (! $access->canReview($actor, $supplier)) {
continue;
}
$rows[] = ['id' => 'supplier:'.$supplier->id.':review:'.$supplier->version, 'module' => 'purchases', 'reference' => 'FOR-'.$supplier->id,
'label' => 'Conferir cadastro do fornecedor', 'task' => 'supplier_review', 'status' => $supplier->status, 'blocked' => false,
'created_at' => $this->date($supplier->created_at), 'due_on' => null, 'href' => '/fornecedores/'.$supplier->id.'#supplier-review'];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function inventoryCorrections(User $actor): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$rows = [];
foreach (['stock_event' => app(InventoryDispositions::class), 'stock_reversal' => app(InventoryReversals::class)] as $kind => $workflow) {
if (! $workflow->allows($actor, 'review') && ! $workflow->allows($actor, 'execute')) {
continue;
}
$prefix = $kind === 'stock_event' ? 'disposition' : 'reversal';
$path = $kind === 'stock_event' ? '/stock/ocorrencias' : '/stock/estornos';
foreach (app(SupplyPlanning::class)->latest($kind)->whereIn('state', ['draft', 'returned', 'approved'])->lazyById(100) as $record) {
$sourceCurrent = $record->state !== 'approved' || $workflow->executionSourceCurrent($record);
$action = $record->state === 'approved' ? ($workflow->canAct($actor, $record, 'execute') ? 'execute' : 'return') : 'approve';
if ($action === 'return' && $sourceCurrent) {
continue;
}
if (! $workflow->canAct($actor, $record, $action)) {
continue;
}
$rows[] = ['id' => $prefix.':'.$record->id.':'.$action, 'module' => 'warehouse', 'reference' => $record->data['reference'],
'label' => ! $sourceCurrent ? ($action === 'return' ? 'Conferir origem ou saldo alterados para devolução' : 'Conferir impedimento da correcção física autorizada')
: ($kind === 'stock_reversal' ? ($action === 'execute' ? 'Conferir e executar estorno integral autorizado' : 'Conferir correcção integral de stock')
: ($action === 'execute' ? 'Conferir e confirmar saída física autorizada' : 'Conferir devolução ou abate físico')),
'task' => $prefix.'_'.$action, 'status' => $record->state, 'blocked' => ! $sourceCurrent, 'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => $path.'?record_id='.$record->id.'#'.$prefix.'-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function lotSetups(User $actor): array
{
$workflow = app(InventoryLots::class);
if (! Schema::hasTable('inventory_lot_allocations') || ! $workflow->allows($actor, 'inventory_lot_review')) {
return [];
}
$rows = [];
foreach (app(SupplyPlanning::class)->latest('lot_setup')->where('state', 'draft')->lazyById(100) as $record) {
if (! $workflow->canActivate($actor, $record)) {
continue;
}
$rows[] = ['id' => 'lot-setup:'.$record->id.':activate', 'module' => 'warehouse',
'reference' => $record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code'], 'label' => 'Conferir configuração e saldo inicial por lotes',
'task' => 'lot_setup_activate', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/stock/lotes?record_id='.$record->id.'#lot-setup-'.$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') && ! $planning->allows($actor, 'supply_history_review') && ! $planning->allows($actor, 'supply_technical_review'))) {
return [];
}
$rows = [];
foreach (['agreement', 'policy', 'auto_rule', 'history', 'alternative'] as $kind) {
$permission = match ($kind) {
'history' => 'supply_history_review', 'alternative' => 'supply_technical_review', default => 'supply_review'
};
if (! $planning->allows($actor, $permission)) {
continue;
}
foreach ($planning->latest($kind)->whereIn('state', ['draft', 'returned'])->lazyById(100) as $record) {
$eligible = match ($kind) {
'auto_rule' => app(AutomaticPurchases::class)->canReview($actor, $record),
'history' => app(SupplyHistory::class)->canReview($actor, $record),
'alternative' => app(SupplyCoordination::class)->canReviewAlternative($actor, $record),
default => $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', 'history' => 'Conferir histórico de consumo', 'alternative' => 'Conferir alternativa técnica', 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='.match ($kind) {
'agreement' => 'agreements', 'history' => 'history', 'alternative' => 'alternatives', default => 'calendar'
}.'&record_id='.$record->id.'#supply-record-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function suggestions(User $actor): array
{
$planning = app(SupplyPlanning::class);
if (! Schema::hasTable('supply_records') || (! $planning->allows($actor, 'supply_suggest_review') && ! $planning->allows($actor, 'supply_suggest_convert'))) {
return [];
}
$service = app(SupplySuggestions::class);
$rows = [];
foreach ($planning->latest('suggestion')->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->lazyById(100) as $record) {
$action = match ($record->state) {
'approved' => 'convert', 'converted' => 'close', default => 'approve'
};
if (! $service->canAct($actor, $record, $action)) {
continue;
}
$rows[] = ['id' => 'suggestion:'.$record->id.':'.$action, 'module' => 'supply',
'reference' => trim($record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code']),
'label' => match ($action) {
'convert' => 'Preparar compra a partir da sugestão aprovada', 'close' => 'Conferir e encerrar acompanhamento da reposição', default => 'Conferir sugestão de reposição'
},
'task' => 'suggestion_'.$action, 'status' => $record->state, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/compras/fornecimento?view=suggestions&record_id='.$record->id.'#supply-suggestion-'.$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;
}
}