Files
compras/tests/Feature/WorkflowTaskInboxTest.php
2026-11-03 11:51:44 +02:00

196 lines
15 KiB
PHP

<?php
use App\Models\User;
use App\Services\PeopleAccess;
use App\Services\PeopleHiring;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use App\Services\WorkflowTaskInbox;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = receiptFixture('100.000');
$this->head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
supplyGrant($this->f['issuer'], ['requester', 'warehouse_request_prepare']);
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
supplyGrant($this->head, ['department_head']);
supplyGrant($this->head, ['warehouse_request_review']);
supplyGrant($this->f['otherReceiver'], ['admin']);
$this->actingAs($this->f['receiver']);
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']),
'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação sintética para reutilizar o cadastro nas tarefas.']);
});
function taskInboxRequest($test, string $quantity = '4.000'): void
{
$test->actingAs($test->f['issuer']);
$test->r = app(WarehouseRequests::class)->draft($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id,
'cost_center_id' => $test->f['r']->cost_center_id, 'priority' => 'normal', 'destination' => 'Oficina sintética', 'reason' => 'Material para manutenção no ensaio de tarefas.',
'lines' => [['stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'use' => 'internal']]]);
taskInboxCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
}
test('task deadline filtering uses the whole authorised projection before pagination and preserves module search scope', function () {
$this->travelTo(now()->startOfDay()->addHours(23)->addMinutes(59));
$rows = [];
for ($i = 0; $i < 28; $i++) {
$rows[] = ['id' => 'deadline:'.$i, 'module' => 'supply', 'reference' => 'PRAZO-'.$i, 'label' => 'Acompanhar origem existente', 'blocked' => false,
'created_at' => now()->subMonth()->toIso8601String(), 'due_on' => today()->subDay()->toDateString(), 'href' => '/compras/fornecimento?view=reservations'];
}
foreach (['today' => today()->toDateString(), 'upcoming' => today()->addDay()->toDateString(), 'undated' => null] as $status => $date) {
$rows[] = [...$rows[0], 'id' => 'deadline:'.$status, 'reference' => 'PRAZO-'.$status, 'due_on' => $date];
}
$rows[] = [...$rows[0], 'id' => 'other', 'module' => 'warehouse', 'reference' => 'OUTRO'];
$this->mock(WorkflowTaskInbox::class)->shouldReceive('build')->andReturn($rows);
$this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p
->where('tasks.total', 28)->has('tasks.data', 25)->where('tasks.data.0.due_status', 'overdue')
->where('deadlineCounts.overdue', 28)->where('deadlineCounts.today', 1)->where('deadlineCounts.upcoming', 1)->where('deadlineCounts.undated', 1)
->where('counts.warehouse', 1)->where('asOf', today()->toDateString())->where('filters.due', 'overdue')
->where('tasks.next_page_url', fn ($url) => str_contains($url, 'due=overdue') && str_contains($url, 'module=supply')));
$this->get('/requisicoes/tarefas?module=supply&due=overdue&page=2')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 3)->where('tasks.data.0.id', 'deadline:25'));
foreach (['today', 'upcoming', 'undated'] as $status) {
$this->get('/requisicoes/tarefas?module=supply&due='.$status)->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)->where('tasks.data.0.due_status', $status));
}
$this->get('/requisicoes/tarefas?module=supply&search=PRAZO-today&due=overdue')->assertInertia(fn (Assert $p) => $p
->where('tasks.total', 0)->where('deadlineCounts.today', 1)->where('deadlineCounts.overdue', 0));
$this->getJson('/requisicoes/tarefas?due=invalid')->assertUnprocessable();
});
function taskInboxCommand($test, User $actor, string $action, array $extra = [])
{
$test->actingAs($actor);
return app(WarehouseFulfilment::class)->command($actor, $test->r->id, ['operation_key' => (string) Str::uuid(),
'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Intervenção efectiva no ensaio das tarefas.', ...$extra]);
}
function taskInboxPurchase($test)
{
$test->actingAs($test->f['issuer']);
$entry = DB::table('requisition_catalogue')->where('stock_item_id', $test->f['stock']->id)->sole();
return app(RequisitionWorkflow::class)->save($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $test->f['r']->cost_center_id,
'priority' => 'normal', 'needed_by' => today()->addWeek()->toDateString(), 'justification' => 'Compra sintética para validar o encaminhamento de tarefas.',
'items' => [['catalogue_entry_id' => $entry->id, 'catalogue_version' => $entry->version, 'quantity' => '2.000', 'unit_price' => '10.00', 'tax_rate' => '0']]]);
}
test('task inbox connects purchasing draft and real human approval without granting administrative execution', function () {
$purchase = taskInboxPurchase($this);
$this->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$purchase->id));
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'approve'));
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($this->head);
app(RequisitionWorkflow::class)->decide($this->head, $purchase->fresh(), 'approved', 'Conferência humana no ensaio sintético.');
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('warehouse review delivery and individual receipt tasks follow the existing commands including cancelled balances', function () {
taskInboxRequest($this);
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'review'));
taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'delivery')->where('tasks.data.0.blocked', false));
$first = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
$second = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
taskInboxCommand($this, $this->head, 'cancel');
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)
->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$first->id)
->where('tasks.data.1.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$second->id));
taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $second->id]);
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('reservation deficits and blocked delivery remain visible to the appropriate people', function () {
taskInboxRequest($this, '25.000');
taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.task', 'reserve'));
$this->f['stock']->update(['name' => 'Cadastro alterado depois da autorização']);
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.blocked', true)->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id));
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('task filtering pagination and oldest records use the whole authorised scope', function () {
$first = null;
foreach (range(1, 30) as $i) {
$purchase = taskInboxPurchase($this);
$first ??= $purchase;
}
taskInboxRequest($this);
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 25)->where('tasks.data.0.reference', $first->number));
$this->get('/requisicoes/tarefas?page=2')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 5));
$this->get('/requisicoes/tarefas?search='.urlencode($first->number))->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 30));
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 0)->where('counts.warehouse', 1));
});
test('consulting tasks does not record decisions read receipts reservations or stock changes', function () {
taskInboxRequest($this);
$this->actingAs($this->head);
$tables = ['warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'requisitions', 'approval_steps', 'audit_logs', 'supply_records'];
$before = [];
foreach ($tables as $table) {
$before[$table] = DB::table($table)->get()->toArray();
}
$this->get('/requisicoes/tarefas')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private');
foreach ($tables as $table) {
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
}
$profile = DB::table('organization_profiles')->whereJsonContains('permissions', 'warehouse_request_review')->value('id');
DB::table('organization_memberships')->where('user_id', $this->head->id)->where('profile_id', $profile)->update(['expires_at' => now()->subMinute()]);
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('people tasks reuse the assigned team and stay separate from purchasing and warehouse', function () {
$owner = $this->f['otherReceiver'];
$reviewer = User::factory()->create(['tenant_id' => $owner->tenant_id, 'department_id' => $owner->department_id]);
supplyGrant($reviewer, ['people_service_review']);
$this->actingAs($owner);
$process = app(PeopleHiring::class)->execute($owner, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Contratação sintética do ensaio',
'purpose' => 'Necessidade documentada para testar a equipa responsável.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
app(PeopleAccess::class)->insert($process, $reviewer->id, ['view', 'review'], 'Equipa atribuída no ensaio descartável.');
$this->actingAs($reviewer)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('area', 'people')->where('tasks.total', 1)->where('tasks.data.0.module', 'people')->where('tasks.data.0.task', 'open'));
$this->getJson('/pessoas/tarefas?module=warehouse')->assertUnprocessable();
$this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$this->actingAs($reviewer);
app(PeopleHiring::class)->execute($reviewer, $process->id, 'open', ['version' => $process->version, 'no_conflict' => true, 'reason' => 'Conferência independente da abertura no ensaio.']);
$this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'apply'));
DB::table('people_process_access')->where('people_process_id', $process->id)->where('user_id', $owner->id)->update(['expires_at' => now()->subMinute()]);
$this->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
});
test('tasks do not cross company or the currently selected work context', function () {
taskInboxRequest($this);
$foreign = receiptFixture();
supplyGrant($foreign['issuer'], ['admin']);
$this->actingAs($foreign['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
$actor = $this->f['otherReceiver'];
$this->f['tenant']->update(['work_context_policy' => ['revision' => 1, 'enabled' => ['people.internal', 'procurement.internal']]]);
config(['work_context.entitlements.'.$actor->tenant_id => ['people.internal', 'procurement.internal']]);
DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['people.internal', 'procurement.internal'])]);
$this->actingAs($actor)->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
$this->get('/requisicoes/tarefas')->assertForbidden();
$this->get('/pessoas/tarefas')->assertOk();
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
$this->get('/pessoas/tarefas')->assertForbidden();
});
test('temporary maintenance does not receive operational decisions even with the corresponding profile', function () {
$purchase = taskInboxPurchase($this);
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => true]);
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
expect($purchase->fresh()->status)->toBe('pending');
});