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

186 lines
14 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\OrderCancellation;
use App\Models\PurchaseOrder;
use App\Models\User;
use App\Services\InvoiceWorkflow;
use App\Services\OrderCancellationPolicyWorkflow;
use App\Services\OrderCancellationWorkflow;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\WorkflowTaskInbox;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Testing\AssertableInertia;
use Symfony\Component\HttpKernel\Exception\HttpException;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/IssuedOrderCancellationFixture.php';
beforeEach(function () {
$this->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);
});