274 lines
21 KiB
PHP
274 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\Invoice;
|
|
use App\Models\StockMovement;
|
|
use App\Models\StockOperation;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
use App\Services\InventoryDispositions;
|
|
use App\Services\InventoryLots;
|
|
use App\Services\InvoiceWorkflow;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\StockWorkflow;
|
|
use App\Services\SupplyPlanning;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
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');
|
|
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_disposition_prepare', 'inventory_lot_review']);
|
|
supplyGrant($this->f['issuer'], ['procurement', 'inventory_disposition_review', 'inventory_lot_prepare']);
|
|
supplyGrant($this->f['otherReceiver'], ['warehouse']);
|
|
$this->actingAs($this->f['receiver']);
|
|
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
|
$this->source = $this->receipt->items->first();
|
|
});
|
|
|
|
function dispositionData($test, array $extra = []): array
|
|
{
|
|
return ['kind' => 'supplier_return', 'reference' => 'RET-'.Str::uuid(), 'stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
|
'receipt_item_id' => $test->source->id, 'quantity' => '3.000', ...$extra];
|
|
}
|
|
function approvedDisposition($test, array $extra = []): SupplyRecord
|
|
{
|
|
$test->actingAs($test->f['receiver']);
|
|
$draft = app(InventoryDispositions::class)->save($test->f['receiver'], supplyInput(dispositionData($test, $extra)));
|
|
$test->actingAs($test->f['issuer']);
|
|
|
|
return app(InventoryDispositions::class)->save($test->f['issuer'], supplyInput([], 'approve', $draft));
|
|
}
|
|
function executeDisposition($test, SupplyRecord $record, ?array $payload = null): SupplyRecord
|
|
{
|
|
$test->actingAs($test->f['receiver']);
|
|
|
|
return app(InventoryDispositions::class)->save($test->f['receiver'], $payload ?? supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada de saída física no ensaio.'], 'execute', $record));
|
|
}
|
|
function trackedReturnSource($test): array
|
|
{
|
|
$test->actingAs($test->f['issuer']);
|
|
$draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
|
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'));
|
|
$test->actingAs($test->f['receiver']);
|
|
app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
|
$payload = receiptPayload($test->f, '5.000');
|
|
$payload['items'][0]['lots'] = [['code' => 'RETURNABLE', 'expires_on' => today()->addDay()->toDateString()]];
|
|
$receipt = app(ReceiptWorkflow::class)->confirm($test->f['receiver'], $test->f['order'], $payload);
|
|
$test->source = $receipt->items->first();
|
|
|
|
return collect(app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id)['lots'])->firstWhere('data.code', 'RETURNABLE');
|
|
}
|
|
|
|
test('return reuses accepted receipt and records one physical exit without rewriting acceptance or money', function () {
|
|
$approved = approvedDisposition($this);
|
|
expect(StockMovement::query()->count())->toBe(1);
|
|
$executed = executeDisposition($this, $approved);
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(7000)
|
|
->and($this->source->fresh()->accepted_milli)->toBe(10000)->and(StockOperation::query()->sole()->kind)->toBe('supplier_return')
|
|
->and($executed->snapshot['source']['receipt']['supplier_id'])->toBe($this->f['order']->supplier_id)
|
|
->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(3000)
|
|
->and(Invoice::query()->count())->toBe(0)->and(BudgetEntry::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('tracked return permits removing expired quarantined material with exact source allocation', function () {
|
|
$lot = trackedReturnSource($this);
|
|
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
|
$this->travel(2)->days();
|
|
$executed = executeDisposition($this, $approved);
|
|
$context = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id);
|
|
expect($context['reconciled'])->toBeTrue()->and($context['physical']['quantity'])->toBe(12000)->and($context['usable_milli'])->toBe(0)
|
|
->and(collect($context['lots'])->firstWhere('id', $lot['id'])['quantity_milli'])->toBe(2000)
|
|
->and(StockMovement::query()->where('stock_operation_id', $executed->data['stock_operation_id'])->sole()->movement_type)->toBe('supplier_return');
|
|
});
|
|
|
|
test('baseline identities cannot pretend to originate from a supplier receipt and foreign sources stay isolated', function () {
|
|
trackedReturnSource($this);
|
|
$baseline = collect(app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'])->firstWhere('data.code', null);
|
|
$this->actingAs($this->f['receiver']);
|
|
expect(fn () => app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this, ['lot_id' => $baseline['id']]))))->toThrow(ValidationException::class);
|
|
$foreign = receiptFixture();
|
|
$this->actingAs($this->f['receiver'])->postJson('/stock/ocorrencias', supplyInput(dispositionData($this, ['stock_item_id' => $foreign['stock']->id])))->assertNotFound();
|
|
});
|
|
|
|
test('self approval and execution by the reviewer remain forbidden despite accumulated functions', function () {
|
|
$this->actingAs($this->f['receiver']);
|
|
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
|
expect(fn () => app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput([], 'approve', $draft)))->toThrow(ValidationException::class);
|
|
$this->actingAs($this->f['issuer']);
|
|
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
|
supplyGrant($this->f['issuer'], ['warehouse']);
|
|
expect(fn () => app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo fictício para testar independência.'], 'execute', $approved)))->toThrow(ValidationException::class);
|
|
expect(StockMovement::query()->count())->toBe(1);
|
|
});
|
|
|
|
test('execution retries cannot repeat the exit and cumulative supplier returns respect the original acceptance', function () {
|
|
$approved = approvedDisposition($this, ['quantity' => '7.000']);
|
|
$payload = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada fictícia para testar repetição.'], 'execute', $approved);
|
|
$executed = executeDisposition($this, $approved, $payload);
|
|
expect(executeDisposition($this, $approved, $payload)->id)->toBe($executed->id)->and(StockMovement::query()->count())->toBe(2);
|
|
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
|
expect(fn () => approvedDisposition($this, ['quantity' => '4.000']))->toThrow(ValidationException::class);
|
|
$second = executeDisposition($this, approvedDisposition($this, ['quantity' => '3.000']));
|
|
expect($second->state)->toBe('executed')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(10000);
|
|
});
|
|
|
|
test('a later stock shortage blocks an approved disposition without a partial exit', function () {
|
|
$approved = approvedDisposition($this, ['quantity' => '8.000']);
|
|
$this->actingAs($this->f['receiver']);
|
|
app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'USE', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id,
|
|
'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'quantity' => '3.000', 'reason' => 'Consumo real posterior à preparação.']);
|
|
expect(fn () => executeDisposition($this, $approved))->toThrow(ValidationException::class);
|
|
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('approved')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(0);
|
|
});
|
|
|
|
test('write off is a separate physical event and does not count as consumption or supplier return', function () {
|
|
$lot = trackedReturnSource($this);
|
|
$approved = approvedDisposition($this, ['kind' => 'write_off', 'lot_id' => $lot['id'], 'quantity' => '2.000']);
|
|
executeDisposition($this, $approved);
|
|
expect(StockOperation::query()->sole()->kind)->toBe('write_off')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(0)
|
|
->and(StockMovement::query()->where('movement_type', 'issue')->count())->toBe(0);
|
|
});
|
|
|
|
test('an audit failure rolls back stock, partitions and disposition execution together', function () {
|
|
$lot = trackedReturnSource($this);
|
|
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
|
expect(fn () => executeDisposition($this, $approved))->toThrow(RuntimeException::class);
|
|
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('approved')->and(StockOperation::query()->count())->toBe(0)
|
|
->and(app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['reconciled'])->toBeTrue();
|
|
});
|
|
|
|
test('new invoice matching subtracts executed supplier returns without changing existing documents', function () {
|
|
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
|
supplyGrant($finance, ['finance']);
|
|
$this->actingAs($finance);
|
|
$payload = ['number' => 'FT-BEFORE', 'issued_on' => today()->toDateString(), 'subtotal' => '1000.00', 'tax' => '0.00', 'total' => '1000.00',
|
|
'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '10.000', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]];
|
|
$invoice = app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload);
|
|
executeDisposition($this, approvedDisposition($this));
|
|
expect($invoice->fresh()->total_minor)->toBe(100000)->and($invoice->fresh()->status)->toBe('pending');
|
|
$this->actingAs($finance);
|
|
$payload['number'] = 'FT-AFTER';
|
|
expect(fn () => app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload))->toThrow(ValidationException::class);
|
|
$payload['subtotal'] = $payload['total'] = '700.00';
|
|
$payload['items'][0]['quantity'] = '7.000';
|
|
expect(app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload)->total_minor)->toBe(70000);
|
|
});
|
|
|
|
test('metadata changes after approval and unsupported execution dates require a fresh decision', function () {
|
|
$lot = trackedReturnSource($this);
|
|
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
|
$this->actingAs($this->f['issuer']);
|
|
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(3)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
|
expect(fn () => executeDisposition($this, $approved))->toThrow(ValidationException::class);
|
|
$this->actingAs($this->f['issuer']);
|
|
$returned = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'return', $approved));
|
|
$this->actingAs($this->f['receiver']);
|
|
$fresh = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this, ['lot_id' => $lot['id'], 'reference' => $approved->data['reference']]), 'save', $returned));
|
|
$this->actingAs($this->f['issuer']);
|
|
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $fresh));
|
|
expect(fn () => executeDisposition($this, $approved, supplyInput(['event_on' => today()->addDay()->toDateString(), 'evidence' => 'Comprovativo fictício com data futura.'], 'execute', $approved)))->toThrow(ValidationException::class);
|
|
expect(StockOperation::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('HTTP menu and commands use the same functional permissions without automatic administration writes', function () {
|
|
$this->actingAs($this->f['receiver'])->get('/stock/ocorrencias')->assertOk();
|
|
$this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertRedirect();
|
|
$draft = app(SupplyPlanning::class)->latest('stock_event')->sole();
|
|
$this->actingAs($this->f['issuer'])->postJson('/stock/ocorrencias', supplyInput([], 'approve', $draft))->assertRedirect();
|
|
$approved = app(SupplyPlanning::class)->latest('stock_event')->sole();
|
|
$this->actingAs($this->f['otherReceiver'])->postJson('/stock/ocorrencias', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada para o percurso HTTP real.'], 'execute', $approved))->assertRedirect();
|
|
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
|
supplyGrant($admin, ['admin']);
|
|
$this->actingAs($admin)->get('/stock/ocorrencias')->assertOk();
|
|
$this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertForbidden();
|
|
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('executed');
|
|
});
|
|
test('disposition task connects independent review and physical execution once without financial documents', function () {
|
|
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
$path = '/stock/ocorrencias?record_id='.$draft->id;
|
|
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
|
supplyGrant($this->f['issuer'], ['warehouse']);
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false));
|
|
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
|
->where('tasks.data.0.task', 'disposition_approve')->where('tasks.data.0.href', $path.'#disposition-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
|
$before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()];
|
|
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('focusChanged', false)->where('records.0.canApprove', true));
|
|
expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()])->toBe($before);
|
|
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', false)->where('records.0.canReturn', true));
|
|
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_execute'));
|
|
$command = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Saída física confirmada no ensaio descartável.'], 'execute', $approved);
|
|
$this->postJson('/stock/ocorrencias', $command)->assertRedirect();
|
|
$this->postJson('/stock/ocorrencias', $command)->assertRedirect();
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(7000)
|
|
->and(Invoice::query()->count())->toBe($before[3])->and(StockMovement::query()->count())->toBe($before[1] + 1);
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
});
|
|
|
|
test('disposition task keeps changed stock sources pending for return without permitting silent execution', function () {
|
|
$approved = approvedDisposition($this, ['kind' => 'write_off']);
|
|
$this->f['stock']->update(['name' => 'Identificação revista depois da autorização']);
|
|
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.blocked', true));
|
|
$this->postJson('/stock/ocorrencias', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'A saída não pode ignorar alteração da origem.'], 'execute', $approved))->assertUnprocessable();
|
|
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
|
$this->actingAs($this->f['issuer']);
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_return')->where('tasks.data.0.blocked', true));
|
|
$returned = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'return', $approved));
|
|
expect($returned->state)->toBe('returned')->and($returned->snapshot['source'])->toBe($approved->snapshot['source']);
|
|
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
});
|
|
|
|
test('disposition task excludes every preparer while retaining the separately authorised physical executor', function () {
|
|
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
supplyGrant($this->f['issuer'], ['inventory_disposition_prepare']);
|
|
$this->actingAs($this->f['issuer']);
|
|
$updated = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput(dispositionData($this), 'save', $draft));
|
|
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
|
foreach ([$this->f['issuer'], $this->f['receiver']] as $person) {
|
|
expect(app(InventoryDispositions::class)->canAct($person, $updated, 'approve'))->toBeFalse();
|
|
}
|
|
supplyGrant($this->f['otherReceiver'], ['inventory_disposition_review']);
|
|
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
|
$approved = app(InventoryDispositions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated));
|
|
expect(app(InventoryDispositions::class)->canAct($this->f['otherReceiver'], $approved, 'execute'))->toBeFalse()
|
|
->and(app(InventoryDispositions::class)->canAct($this->f['receiver'], $approved, 'execute'))->toBeTrue();
|
|
});
|
|
|
|
test('disposition task preserves oldest pending events beyond the initial display cap', function () {
|
|
$first = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
for ($index = 1; $index < 105; $index++) {
|
|
app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
}
|
|
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)
|
|
->where('tasks.data.0.id', 'disposition:'.$first->id.':approve'));
|
|
$this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
|
$this->get('/stock/ocorrencias')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105));
|
|
$this->get('/stock/ocorrencias?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)->where('records.0.canApprove', true));
|
|
});
|
|
|
|
test('disposition task preserves company isolation maintenance and expired assignments', function () {
|
|
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
|
$path = '/stock/ocorrencias?record_id='.$draft->id;
|
|
$foreign = receiptFixture();
|
|
supplyGrant($foreign['receiver'], ['warehouse']);
|
|
$this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable();
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
|
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
|
$this->get($path)->assertForbidden();
|
|
expect(app(InventoryDispositions::class)->canAct($this->f['issuer'], $draft, 'approve'))->toBeFalse();
|
|
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
|
$this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden();
|
|
});
|