f = receiptFixture(); $this->data = ['title' => 'Plano fictício de materiais', 'nature' => 'goods', 'department_id' => $this->f['department']->id, 'responsible_id' => $this->f['receiver']->id, 'requisition_id' => null]; }); function createPlanningItem(object $test, array $overrides = []): PurchasePlanItem { $test->actingAs($test->f['issuer'])->post('/planeamento', [...$test->data, ...$overrides])->assertSessionHasNoErrors(); return PurchasePlanItem::query()->latest('id')->firstOrFail(); } function createPlanningMilestone(object $test, PurchasePlanItem $item): PurchasePlanMilestone { $test->actingAs($test->f['issuer'])->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->subDays(2)->format('Y-m-d')])->assertSessionHasNoErrors(); return PurchasePlanMilestone::query()->latest('id')->firstOrFail(); } test('planning records responsibility without changing financial or requisition state', function () { $item = createPlanningItem($this, ['requisition_id' => (string) $this->f['r']->id, 'department_id' => (string) $this->f['department']->id]); expect($item->requisition_id)->toBe($this->f['r']->id)->and(BudgetEntry::query()->count())->toBe(0)->and($this->f['r']->refresh()->status)->toBe('approved'); $this->get('/planeamento')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') ->assertInertia(fn (Assert $page) => $page->component('planning/index')->where('items.data.0.responsible', $this->f['receiver']->name)->where('items.data.0.requisition.number', $this->f['r']->number)->has('milestones', 0)); }); test('planning milestones are chosen per purchase and duplicate labels are rejected', function () { $item = createPlanningItem($this); $milestone = createPlanningMilestone($this, $item); expect($milestone->actual_on)->toBeNull(); $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->format('Y-m-d')])->assertSessionHasErrors('label'); expect(PurchasePlanMilestone::query()->count())->toBe(1); }); test('assigned responsible can declare an actual date but cannot reschedule the plan', function () { $milestone = createPlanningMilestone($this, createPlanningItem($this)); $this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Registo real declarado no teste'])->assertSessionHasNoErrors(); expect($milestone->refresh()->actual_on)->toBe(today()->format('Y-m-d'))->and($milestone->version)->toBe(2); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 2, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Tentativa de alterar a previsão'])->assertForbidden(); }); test('planning rejects stale dates and preserves their prior values and author', function () { $milestone = createPlanningMilestone($this, createPlanningItem($this)); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->format('Y-m-d'), 'reason' => 'Reprogramação fundamentada de teste'])->assertSessionHasNoErrors(); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Segunda gravação desactualizada'])->assertSessionHasErrors('version'); $event = AuditLog::query()->where('action', 'planning.milestone_updated')->sole(); expect($event->before['planned_on'])->toBe(today()->subDays(2)->format('Y-m-d'))->and($event->after['milestone']['planned_on'])->toBe(today()->format('Y-m-d'))->and($event->user_id)->toBe($this->f['issuer']->id); }); test('future actual dates and missing reasons are refused', function () { $milestone = createPlanningMilestone($this, createPlanningItem($this)); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->addDay()->format('Y-m-d'), 'reason' => 'Data futura não realizada'])->assertSessionHasErrors('actual_on'); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d')])->assertSessionHasErrors('reason'); expect($milestone->refresh()->version)->toBe(1); }); test('finance is a planning reader and unrelated staff cannot alter or inspect assigned plans', function () { $item = createPlanningItem($this); $milestone = createPlanningMilestone($this, $item); $finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($finance)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->has('items.data', 1)->has('users', 0)->has('history', 0)); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador sem responsabilidade atribuída'])->assertForbidden(); $this->actingAs($this->f['otherReceiver'])->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)->has('milestones', 0)->has('history', 0)); $this->post('/planeamento', $this->data)->assertForbidden(); }); test('planning protects company and department boundaries', function () { $item = createPlanningItem($this); $other = Tenant::query()->create(['name' => 'Outra empresa fictícia']); $foreign = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($foreign)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)); $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco indevido', 'planned_on' => today()->format('Y-m-d')])->assertNotFound(); $this->actingAs($this->f['issuer'])->post('/planeamento', [...$this->data, 'responsible_id' => $foreign->id])->assertSessionHasErrors('responsible_id'); $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)); }); test('a later requisition link is unique and cannot replace its predecessor', function () { $item = createPlanningItem($this); $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação posterior ao planeamento'])->assertSessionHasNoErrors(); expect($item->refresh()->requisition_id)->toBe($this->f['r']->id)->and($item->version)->toBe(2); $this->post('/planeamento', [...$this->data, 'requisition_id' => $this->f['r']->id])->assertSessionHasErrors('requisition_id'); $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 2, 'requisition_id' => $this->f['r']->id, 'reason' => 'Não substituir ligação já existente'])->assertSessionHasErrors('requisition_id'); }); test('responsibility changes preserve history and revoke previous write access', function () { $item = createPlanningItem($this); $milestone = createPlanningMilestone($this, $item); $this->post('/planeamento/'.$item->id.'/responsavel', ['version' => 1, 'responsible_id' => $this->f['otherReceiver']->id, 'reason' => 'Mudança de responsável no teste'])->assertSessionHasNoErrors(); $this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador anterior já substituído'])->assertNotFound(); expect(AuditLog::query()->where('action', 'planning.assigned')->count())->toBe(1); }); test('planning refreshes revoked operator roles inside the transaction', function () { $item = createPlanningItem($this); DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']); $this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco após revogação', 'planned_on' => today()->format('Y-m-d')])->assertForbidden(); expect(PurchasePlanMilestone::query()->count())->toBe(0); }); test('audit failure rolls back milestone dates and optimistic version together', function () { $milestone = createPlanningMilestone($this, createPlanningItem($this)); AuditLog::creating(function (AuditLog $event) { if ($event->action === 'planning.milestone_updated') { throw new RuntimeException('Auditoria fictícia indisponível'); } }); try { expect(fn () => app(PurchasePlanningWorkflow::class)->update($this->f['issuer'], $milestone, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Teste de atomicidade']))->toThrow(RuntimeException::class); } finally { AuditLog::flushEventListeners(); } expect($milestone->refresh()->actual_on)->toBeNull()->and($milestone->version)->toBe(1); }); test('planning links only requisitions of its department and appears in the visible process', function () { $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']); $item = createPlanningItem($this, ['department_id' => $department->id]); $this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação a departamento diferente'])->assertSessionHasErrors('requisition_id'); $linked = createPlanningItem($this, ['requisition_id' => $this->f['r']->id]); $this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('href', '/planeamento?item='.$linked->id))); $this->get('/planeamento?item='.$linked->id)->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.id', $linked->id)); }); test('an explicitly assigned department head sees only the plan beyond department access', function () { $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $item = createPlanningItem($this, ['responsible_id' => $head->id, 'requisition_id' => $this->f['r']->id]); $milestone = createPlanningMilestone($this, $item); $this->actingAs($head)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.requisition', null)->where('items.data.0.canRecord', true)); $this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Responsável explicitamente designado'])->assertSessionHasNoErrors(); $this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertNotFound(); });