withoutVite(); issuedOrderCancellationFixture($this); }); function issuedCancellation(object $test): OrderCancellation { $test->actingAs($test->buyer); return app(OrderCancellationWorkflow::class)->request($test->buyer, $test->f['order'], $test->data); } test('issued cancellation approves once with independent finance and preserves order approvals and original commitment', function () { $order = $this->f['order']->fresh()->getAttributes(); $original = DB::table('budget_entries')->orderBy('id')->get()->toArray(); $approvals = DB::table('approval_steps')->get()->toArray(); $record = issuedCancellation($this); expect(app(OrderCancellationWorkflow::class)->state($this->f['order']))->toBe('cancellation_pending'); expect(PurchaseOrder::query()->operational()->whereKey($this->f['order']->id)->exists())->toBeFalse(); $this->actingAs($this->finance); expect(collect(app(WorkflowTaskInbox::class)->build($this->finance, 'procurement'))->pluck('task')->all())->toContain('order_cancellation_review'); app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Decisão financeira independente no ensaio fictício.'); app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Decisão financeira independente no ensaio fictício.'); expect((int) DB::table('budget_entries')->sum('committed_minor'))->toBe(0)->and(DB::table('budget_entries')->count())->toBe(4) ->and($this->f['order']->fresh()->getAttributes())->toBe($order)->and($this->f['r']->fresh()->status)->toBe('cancelled') ->and(DB::table('budget_entries')->orderBy('id')->limit(3)->get()->toArray())->toEqual($original) ->and(DB::table('approval_steps')->get()->toArray())->toEqual($approvals) ->and(app(OrderCancellationWorkflow::class)->state($this->f['order']))->toBe('cancelled'); expect(issuedCancellation($this)->id)->toBe($record->id); $this->actingAs($this->f['issuer']); expect(collect(app(WorkflowTaskInbox::class)->build($this->f['issuer'], 'procurement'))->pluck('task')->all())->toContain('order_cancellation_revision'); $draft = app(OrderCancellationWorkflow::class)->revise($this->f['issuer'], $record, today()->addWeek()->toDateString(), 'Nova necessidade fictícia para revisão após cancelamento.'); expect($draft->status)->toBe('draft')->and($draft->steps()->count())->toBe(0)->and($draft->items()->count())->toBe(1) ->and(DB::table('budget_entries')->where('requisition_id', $draft->id)->count())->toBe(0); expect(app(OrderCancellationWorkflow::class)->revise($this->f['issuer'], $record, today()->addWeek()->toDateString(), 'Nova necessidade fictícia para revisão após cancelamento.')->id)->toBe($draft->id); expect(collect(app(WorkflowTaskInbox::class)->build($this->f['issuer'], 'procurement'))->pluck('task')->all())->not->toContain('order_cancellation_revision'); }); test('issued cancellation pending hold blocks receipt invoice and reissue while rejection preserves commitment', function () { $record = issuedCancellation($this); $this->actingAs($this->f['receiver']); expect(fn () => app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)))->toThrow(ValidationException::class); config(['order_cancellation.enabled' => false]); expect(fn () => app(OrderCancellationWorkflow::class)->assertOperational($this->f['order']))->toThrow(ValidationException::class); config(['order_cancellation.enabled' => true]); $this->actingAs($this->finance); expect(fn () => app(InvoiceWorkflow::class)->register($this->finance, $this->f['order'], []))->toThrow(ValidationException::class); app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'rejected', 'Rejeição fundamentada para conservar a ordem original.'); expect((int) DB::table('budget_entries')->sum('committed_minor'))->toBe(10000)->and(app(OrderCancellationWorkflow::class)->state($this->f['order']))->toBe('issued'); expect(PurchaseOrder::query()->operational()->whereKey($this->f['order']->id)->exists())->toBeTrue(); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); expect(DB::table('goods_receipts')->count())->toBe(1); }); test('issued cancellation refuses any existing receipt or invoice without creating a request', function (string $source) { if ($source === 'receipt') { $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); } else { Invoice::query()->create(['tenant_id' => $this->f['tenant']->id, 'purchase_order_id' => $this->f['order']->id, 'supplier_id' => $this->f['order']->supplier_id, 'created_by' => $this->finance->id, 'number' => 'INV-TEST', 'issued_on' => today()->toDateString(), 'subtotal_minor' => 100, 'tax_minor' => 0, 'total_minor' => 100, 'snapshot' => []]); } expect(fn () => issuedCancellation($this))->toThrow(ValidationException::class); expect(OrderCancellation::query()->count())->toBe(0)->and((int) DB::table('budget_entries')->sum('committed_minor'))->toBe(10000); })->with(['receipt', 'invoice']); test('issued cancellation rolls back reversal decision and request state when the final audit fails', function () { $record = issuedCancellation($this); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Injected final audit failure')); $this->actingAs($this->finance); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Decisão financeira fictícia para ensaio de falha.'))->toThrow(RuntimeException::class); expect(OrderCancellation::query()->findOrFail($record->id)->status)->toBe('pending')->and(DB::table('budget_entries')->count())->toBe(3) ->and((int) DB::table('budget_entries')->sum('committed_minor'))->toBe(10000)->and($this->f['r']->fresh()->status)->toBe('approved'); }); test('issued cancellation rejects involved or revoked finance foreign tenant and altered replay', function () { $record = issuedCancellation($this); $this->actingAs($this->buyer); $this->buyer->forceFill(['purchasing_role' => PurchasingRole::Finance])->save(); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->buyer->fresh(), $record, 'approved', 'Tentativa do autor do pedido com função alterada.'))->toThrow(HttpException::class); $this->actingAs($this->finance); $this->finance->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->finance->fresh(), $record, 'approved', 'Tentativa com função financeira retirada.'))->toThrow(HttpException::class); $this->buyer->forceFill(['purchasing_role' => PurchasingRole::Procurement])->save(); $this->actingAs($this->buyer->fresh()); expect(fn () => app(OrderCancellationWorkflow::class)->request($this->buyer->fresh(), $this->f['order'], [...$this->data, 'reason' => 'Outro conteúdo na mesma chave de operação.']))->toThrow(HttpException::class); $foreign = receiptFixture(); $this->actingAs($foreign['issuer']); expect(fn () => app(OrderCancellationWorkflow::class)->request($foreign['issuer'], $this->f['order'], $this->data))->toThrow(ModelNotFoundException::class); }); test('issued cancellation stays disabled and unapproved policies cannot authorize execution', function () { config(['order_cancellation.enabled' => false]); expect(fn () => issuedCancellation($this))->toThrow(HttpException::class); $this->get('/ordens/cancelamentos')->assertStatus(503); config(['order_cancellation.enabled' => true]); $this->actingAs($this->admins[0]); app(OrderCancellationPolicyWorkflow::class)->propose($this->admins[0], ['previous_id' => $this->policy->id, 'mode' => 'issued_order', 'enabled' => false, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addWeek()->toDateString(), 'reason' => 'Nova proposta ainda não publicada no ensaio.']); // A pending proposal does not silently replace the previously published policy. expect(app(OrderCancellationPolicyWorkflow::class)->requireCurrent()->id)->toBe($this->policy->id); }); test('issued cancellation HTTP connects preparation independent decision and preserved order consultation', function () { $this->post('/ordens/'.$this->f['order']->id.'/cancelamento', $this->data)->assertRedirect('/ordens/cancelamentos'); $record = OrderCancellation::query()->sole(); $this->actingAs($this->finance)->get('/ordens/cancelamentos')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p ->component('purchase-controls/orders')->where('records.data.0.canDecide', true)); $this->get('/ordens/cancelamentos?cancellation_id='.$record->id)->assertOk()->assertInertia(fn (AssertableInertia $p) => $p->where('records.data.0.id', $record->id)); $this->post('/ordens/cancelamentos/'.$record->id.'/decidir', ['decision' => 'approved', 'reason' => 'Conferência financeira independente através do controlador.'])->assertRedirect(); $this->get('/ordens/'.$this->f['order']->id)->assertOk()->assertInertia(fn (AssertableInertia $p) => $p->where('orderCancellation.state', 'cancelled')->where('order.status', 'issued')); }); test('issued cancellation expired policy can be rejected but cannot release a commitment', function () { $record = issuedCancellation($this); $this->travel(8)->days(); try { $this->actingAs($this->finance); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Tentativa após expiração da política fictícia.'))->toThrow(HttpException::class); app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'rejected', 'Rejeitar pedido sem política vigente para retomar a execução.'); expect((int) DB::table('budget_entries')->sum('committed_minor'))->toBe(10000); } finally { $this->travelBack(); } }); test('issued cancellation refuses changed financial basis and unavailable reversal artifact without partial effects', function () { $record = issuedCancellation($this); $this->actingAs($this->finance); config(['order_cancellation.reversal_sha256' => 'unavailable']); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Ensaio de capacidade de reversão não disponível.'))->toThrow(HttpException::class); expect(OrderCancellation::query()->sole()->status)->toBe('pending')->and(DB::table('budget_entries')->count())->toBe(3); DB::table('budget_entries')->insert(['tenant_id' => $this->f['tenant']->id, 'budget_id' => $this->budget->id, 'requisition_id' => $this->f['r']->id, 'created_by' => $this->finance->id, 'kind' => 'test-changed-basis', 'reserved_minor' => 0, 'committed_minor' => -1, 'created_at' => now()]); expect(fn () => app(OrderCancellationWorkflow::class)->decide($this->finance, $record, 'approved', 'Ensaio de base financeira diferente da proposta.'))->toThrow(HttpException::class); expect(OrderCancellation::query()->sole()->status)->toBe('pending'); }); test('issued cancellation policy preserves publication separation and rejects a revoked proposer', function () { $this->actingAs($this->admins[0]); $flow = app(OrderCancellationPolicyWorkflow::class); $policy = $flow->propose($this->admins[0], ['previous_id' => $this->policy->id, 'mode' => 'issued_order', 'enabled' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addWeek()->toDateString(), 'reason' => 'Outra proposta de política para testar a segregação.']); expect(fn () => $flow->review($this->admins[0], $policy, 'approved', 'O proponente não pode conferir a sua própria proposta.'))->toThrow(HttpException::class); $this->actingAs($this->admins[1]); expect($flow->canReview($this->admins[1], $policy))->toBeTrue(); expect(collect(app(WorkflowTaskInbox::class)->build($this->admins[1], 'procurement'))->pluck('task')->all())->toContain('order_cancellation_policy_review'); $flow->review($this->admins[1], $policy, 'approved', 'Primeira conferência administrativa independente.'); expect($flow->canReview($this->admins[1], $policy))->toBeFalse(); $this->admins[0]->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); $this->actingAs($this->admins[2]); expect(fn () => $flow->review($this->admins[2], $policy, 'approved', 'Tentativa de publicação após retirada da função do proponente.'))->toThrow(ValidationException::class); expect($policy->fresh()->status)->toBe('pending'); }); test('issued cancellation requires a published policy in the same company before accepting a request', function () { $foreign = receiptFixture(); $buyer = User::factory()->create(['tenant_id' => $foreign['tenant']->id, 'department_id' => $foreign['department']->id, 'purchasing_role' => PurchasingRole::Procurement]); $this->actingAs($buyer); expect(fn () => app(OrderCancellationWorkflow::class)->request($buyer, $foreign['order'], $this->data))->toThrow(HttpException::class); expect(DB::table('order_cancellations')->where('tenant_id', $foreign['tenant']->id)->count())->toBe(0); });