166 lines
13 KiB
PHP
166 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Models\StockItem;
|
|
use App\Models\User;
|
|
use App\Models\WarehouseRequestEvent;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\StockWorkflow;
|
|
use App\Services\WarehouseFulfilment;
|
|
use App\Services\WarehouseRequests;
|
|
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->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
|
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
|
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
|
|
supplyGrant($this->reviewer, ['warehouse_request_review', 'warehouse_request_read']);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
|
$this->actingAs($this->f['issuer']);
|
|
$this->draftData = ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
|
|
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]];
|
|
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $this->draftData);
|
|
});
|
|
|
|
function warehouseCommand($test, $actor, string $action, array $extra = []): array
|
|
{
|
|
$test->actingAs($actor);
|
|
$input = ['operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Registo efectivo da intervenção de teste.', ...$extra];
|
|
$test->postJson('/stock/requisicoes/'.$test->r->id.'/eventos', $input)->assertRedirect();
|
|
|
|
return $input;
|
|
}
|
|
|
|
test('internal request is the exit document and partial deliveries consume only actual quantities with independent receipt', function () {
|
|
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
$delivery = warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
|
|
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $delivery)->assertRedirect();
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(8500);
|
|
$p = app(WarehouseFulfilment::class)->progress($this->r);
|
|
expect($p['state'])->toBe('partial')->and($p['remaining'])->toBe([2500]);
|
|
$batch = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
|
|
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $batch->id]);
|
|
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
|
|
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->first();
|
|
$confirm = warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
|
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $confirm)->assertRedirect();
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('closed')
|
|
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(6000)
|
|
->and(DB::table('invoices')->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0);
|
|
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/request')->where('progress.state', 'closed')->where('progress.delivered.0', 4000));
|
|
});
|
|
|
|
test('reservations protect stock from unrelated issues and cancellation releases only the undelivered balance', function () {
|
|
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000]);
|
|
$this->actingAs($this->f['receiver']);
|
|
$issue=['kind'=>'issue','reference'=>'UNRELATED','idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$this->f['issuer']->id,'warehouse_id'=>$this->f['warehouse']->id,'stock_item_id'=>$this->f['stock']->id,'quantity'=>'7.000','destination'=>'Outra oficina','reason'=>'Tentativa de saída que consumiria reserva.'];
|
|
expect(fn()=>app(StockWorkflow::class)->record($this->f['receiver'],$issue))->toThrow(\Illuminate\Validation\ValidationException::class);
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
warehouseCommand($this,$this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'1.500']]]);
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([2500]);
|
|
$cancel=warehouseCommand($this,$this->reviewer,'cancel');
|
|
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',$cancel)->assertRedirect();
|
|
$p=app(WarehouseFulfilment::class)->progress($this->r);
|
|
expect($p['state'])->toBe('cancelled')->and($p['reserved'])->toBe([0])->and($p['cancelled'])->toBe([2500])->and($p['remaining'])->toBe([0]);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(StockWorkflow::class)->record($this->f['receiver'],$issue);
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(1500);
|
|
});
|
|
|
|
test('competing integral authorizations do not overbook and partial reservations can be replenished explicitly', function () {
|
|
warehouseCommand($this,$this->f['issuer'],'submit');
|
|
warehouseCommand($this,$this->reviewer,'authorize');
|
|
$original=$this->r;
|
|
$this->actingAs($this->f['issuer']);
|
|
$data=$this->draftData;$data['operation_key']=(string)Str::uuid();$data['lines'][0]['quantity']='8.000';
|
|
$this->r=app(WarehouseRequests::class)->draft($this->f['issuer'],$data);
|
|
warehouseCommand($this,$this->f['issuer'],'submit',['partial_allowed'=>true]);
|
|
$this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',['operation_key'=>(string)Str::uuid(),'version'=>1,'action'=>'authorize','reason'=>'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('submitted');
|
|
warehouseCommand($this,$this->reviewer,'authorize',['partial_allowed'=>true]);
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([6000]);
|
|
$second=$this->r;$this->r=$original;
|
|
warehouseCommand($this,$this->reviewer,'cancel');
|
|
$this->r=$second;
|
|
warehouseCommand($this,$this->reviewer,'reserve');
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([8000]);
|
|
expect(fn()=>DB::table('warehouse_reservations')->where('warehouse_request_id',$second->id)->update(['quantity_milli'=>0]))->toThrow(\Illuminate\Database\QueryException::class);
|
|
});
|
|
|
|
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
|
|
supplyGrant($this->f['issuer'], ['warehouse_request_review']);
|
|
warehouseCommand($this, $this->f['issuer'], 'submit');
|
|
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
|
|
$this->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de auto aprovação.'])->assertForbidden();
|
|
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'deliver', 'reason' => 'Entrega não autorizada.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
|
|
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'authorize', 'reason' => 'Decisão numa página antiga.'])->assertUnprocessable();
|
|
warehouseCommand($this, $this->reviewer, 'authorize');
|
|
supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
|
|
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Decisor tenta entregar material.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertForbidden();
|
|
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega parcial sem autorização.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
});
|
|
|
|
test('shortage on a later line rolls back the entire delivery and preserves the authorization', function () {
|
|
$other = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'ZERO', 'name' => 'Artigo sem saldo', 'unit' => 'cx']);
|
|
$data = $this->draftData;
|
|
$data['operation_key'] = (string) Str::uuid();
|
|
$data['lines'][] = ['stock_item_id' => $other->id, 'quantity' => '1.000', 'use' => 'internal'];
|
|
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
|
|
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
$count = DB::table('stock_movements')->count();
|
|
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega de conjunto com saldo insuficiente.',
|
|
'lines' => [['index' => 0, 'quantity' => '1.000'], ['index' => 1, 'quantity' => '1.000']]])->assertUnprocessable();
|
|
expect(DB::table('stock_movements')->count())->toBe($count)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
|
|
});
|
|
|
|
test('rejection prevents delivery without changing stock', function () {
|
|
warehouseCommand($this, $this->f['issuer'], 'submit');
|
|
warehouseCommand($this, $this->reviewer, 'reject');
|
|
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa sobre requisição rejeitada.',
|
|
'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
});
|
|
|
|
test('changed source and excess quantities are refused on the live document', function () {
|
|
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
|
|
$payload = ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega efectivamente apresentada.', 'lines' => [['index' => 0, 'quantity' => '4.001']]];
|
|
$this->actingAs($this->f['receiver'])->postJson($url, $payload)->assertUnprocessable();
|
|
$this->f['stock']->update(['name' => 'Especificação alterada depois da autorização']);
|
|
$payload['lines'][0]['quantity'] = '1.000';
|
|
$payload['operation_key'] = (string) Str::uuid();
|
|
$this->postJson($url, $payload)->assertUnprocessable();
|
|
expect(app(WarehouseFulfilment::class)->progress($this->r)['delivered'])->toBe([0]);
|
|
});
|
|
|
|
test('failure after recording the physical issue rolls back document event and stock together', function () {
|
|
warehouseCommand($this, $this->f['issuer'], 'submit');
|
|
warehouseCommand($this, $this->reviewer, 'authorize');
|
|
$movements = DB::table('stock_movements')->count();
|
|
$operations = DB::table('stock_operations')->count();
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andReturnUsing(function ($actor, $action) {
|
|
if ($action === 'warehouse_request.deliver') {
|
|
throw new RuntimeException('Injected final document audit failure');
|
|
}
|
|
});
|
|
$this->actingAs($this->f['receiver']);
|
|
expect(fn () => app(WarehouseFulfilment::class)->command($this->f['receiver'], $this->r->id, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver',
|
|
'reason' => 'Entrega integral de teste.', 'lines' => [['index' => 0, 'quantity' => '4.000']]]))->toThrow(RuntimeException::class);
|
|
expect(DB::table('stock_movements')->count())->toBe($movements)->and(DB::table('stock_operations')->count())->toBe($operations)
|
|
->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
|
|
});
|