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(); });