f = receiptFixture('100.000'); supplyGrant($this->f['receiver'], ['warehouse', 'inventory_reversal_prepare', 'inventory_disposition_prepare', 'inventory_lot_review', 'inventory_lot_release', 'supply_reserve']); supplyGrant($this->f['issuer'], ['inventory_reversal_review', 'inventory_disposition_review', 'inventory_lot_prepare', 'inventory_adjust_review']); supplyGrant($this->f['otherReceiver'], ['warehouse', 'inventory_lot_release']); $this->actingAs($this->f['receiver']); $this->receiptPayload = receiptPayload($this->f, '10.000'); $this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptPayload); $this->receiptItem = $this->receipt->items->first(); }); function reversalStock($test, string $quantity = '3.000', array $extra = []): StockOperation { $test->actingAs($test->f['receiver']); return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'MOV-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'responsible_id' => $test->f['receiver']->id, 'quantity' => $quantity, 'reason' => 'Movimento fictício para conferir correcção rastreável.', ...$extra]); } function reversalSetup($test, array $extra = []): SupplyRecord { $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' => [['code' => 'BASE', 'expires_on' => today()->addDays(30)->toDateString()]], ...$extra], 'prepare')); $test->actingAs($test->f['receiver']); return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft)); } function reversalContext($test, ?int $warehouse = null): array { return app(InventoryLots::class)->context($test->f['stock']->id, $warehouse ?? $test->f['warehouse']->id); } function reversalRelease($test, int $warehouse, array $lot, $actor = null): SupplyRecord { $actor ??= $test->f['receiver']; $test->actingAs($actor); return app(InventoryLots::class)->save($actor, supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $warehouse, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id']))); } function approvedReversal($test, array $source): SupplyRecord { $test->actingAs($test->f['receiver']); $draft = app(InventoryReversals::class)->save($test->f['receiver'], supplyInput(['reference' => 'REV-'.Str::uuid(), ...$source])); $test->actingAs($test->f['issuer']); return app(InventoryReversals::class)->save($test->f['issuer'], supplyInput([], 'approve', $draft)); } function executeReversal($test, SupplyRecord $approved, ?array $payload = null): SupplyRecord { $test->actingAs($test->f['receiver']); return app(InventoryReversals::class)->save($test->f['receiver'], $payload ?? supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo da correcção e existência física no ensaio.'], 'execute', $approved)); } function reverseSourceOperation($test, StockOperation $operation): SupplyRecord { return executeReversal($test, approvedReversal($test, ['source_kind' => 'operation', 'source_operation_id' => $operation->id])); } test('full issue reversal preserves the original and is exactly once even on an execution retry', function () { $original = reversalStock($this); $approved = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]); $payload = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo fictício para testar repetição do estorno.'], 'execute', $approved); $executed = executeReversal($this, $approved, $payload); expect(executeReversal($this, $approved, $payload)->id)->toBe($executed->id) ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000) ->and(StockMovement::query()->where('stock_operation_id', $original->id)->sole()->quantity_milli)->toBe(-3000) ->and(StockMovement::query()->count())->toBe(3); expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]))->toThrow(ValidationException::class); }); test('a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () { $reservation = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'quantity_milli' => 3000, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => 'NEED-REV'])); $issue = reversalStock($this); $fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 3000], 'fulfil', $reservation)); reverseSourceOperation($this, $issue); $events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString()); expect($events['reservations'][0]['quantity'])->toBe(3000)->and($events['fulfilled_needs'])->toBe([])->and($events['consumed_today'])->toBe(0) ->and($fulfilled->fresh()->state)->toBe('fulfilled')->and($fulfilled->data['applications'])->toHaveCount(1); $this->actingAs($this->f['receiver']); expect(fn () => app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 1], 'fulfil', $fulfilled)))->toThrow(ValidationException::class); $nextIssue = reversalStock($this); $again = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $nextIssue->id, 'quantity_milli' => 3000], 'fulfil', $fulfilled)); expect(app(SupplyCoordination::class)->remainingReservation($again))->toBe(0)->and($again->data['applications'])->toHaveCount(2); expect($again->snapshot['effective_allocated_milli'])->toBe(3000)->and($again->snapshot['reversed_operations'])->toContain($issue->id); }); test('a reversal outside a closed history period invalidates its signature and corrects effective consumption', function () { $day = today()->toDateString(); $issue = reversalStock($this); $before = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, $day, $day); $this->travel(2)->days(); reverseSourceOperation($this, $issue); $after = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, $day, $day); expect($before['total_milli'])->toBe(3000)->and($after['total_milli'])->toBe(0)->and($after['source_hash'])->not->toBe($before['source_hash']); }); test('receipt reversal restores authorised quantities without deleting acceptance and a new correction receipt is not a duplicate', function () { $executed = executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id])); expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'received_milli'))->toBe(0) ->and(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(0) ->and($this->receiptItem->fresh()->accepted_milli)->toBe(10000) ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(0); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptPayload); expect(StockMovement::query()->count())->toBe(2); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000')); expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(10000) ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000) ->and($executed->snapshot['source']['receipt']['accepted_milli'])->toBe(10000); }); test('receipt line reversal removes the full documentary quantities but only the accepted physical entry', function () { $payload = receiptPayload($this->f, '2.000'); $payload['items'][0] = [...$payload['items'][0], 'accepted_quantity' => '1.000', 'quality_status' => 'reservations', 'notes' => 'Divergência fictícia da aceitação parcial.']; $receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $payload); executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $receipt->items->first()->id])); expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'received_milli'))->toBe(10000) ->and(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(10000) ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000); }); test('tracked transfer reversal removes original destination lots and quarantines the restored source identity', function () { reversalSetup($this); reversalRelease($this, $this->f['warehouse']->id, reversalContext($this)['lots'][0]); $destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']); reversalSetup($this, ['warehouse_id' => $destination->id, 'lots' => []]); $original = reversalStock($this, '2.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]); $lot = reversalContext($this)['lots'][0]; $reversal = reverseSourceOperation($this, $original); expect(reversalContext($this)['physical']['quantity'])->toBe(10000)->and(reversalContext($this)['usable_milli'])->toBe(0) ->and(reversalContext($this, $destination->id)['physical']['quantity'])->toBe(0) ->and(reversalContext($this)['reconciled'])->toBeTrue()->and(reversalContext($this, $destination->id)['reconciled'])->toBeTrue() ->and(StockMovement::query()->where('stock_operation_id', $reversal->data['stock_operation_id'])->count())->toBe(2); reversalRelease($this, $this->f['warehouse']->id, reversalContext($this)['lots'][0], $this->f['otherReceiver']); expect(reversalContext($this)['usable_milli'])->toBe(10000)->and(reversalContext($this)['lots'][0]['id'])->toBe($lot['id']); }); test('mixed lot adjustments can be reversed without turning corrections into consumption', function () { reversalSetup($this, ['lots' => [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'], ['code' => 'B', 'expires_on' => today()->addDays(20)->toDateString(), 'quantity' => '4.000']]]); $lots = reversalContext($this)['lots']; $count = reversalStock($this, '10.000', ['kind' => 'count', 'ledger_version' => reversalContext($this)['physical']['version'], 'lot_version' => app(InventoryCounts::class)->source($this->f['stock']->id, $this->f['warehouse']->id)['hash'], 'lot_counts' => [['lot_id' => $lots[0]['id'], 'quantity' => '5.000'], ['lot_id' => $lots[1]['id'], 'quantity' => '5.000']]]); $this->actingAs($this->f['issuer']); $adjustment = app(StockWorkflow::class)->record($this->f['issuer'], ['kind' => 'adjustment', 'reference' => 'ADJ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id, 'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['issuer']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Conferência independente da contagem mista.']); reverseSourceOperation($this, $adjustment); expect(array_column(reversalContext($this)['lots'], 'quantity_milli'))->toBe([6000, 4000])->and(reversalContext($this)['physical']['quantity'])->toBe(10000) ->and(reversalContext($this)['reconciled'])->toBeTrue()->and(StockMovement::query()->where('movement_type', 'issue')->count())->toBe(0); }); test('restoring an old untracked issue after cutover creates an unidentified held lot without fabricating receipt origin', function () { $original = reversalStock($this); reversalSetup($this); reverseSourceOperation($this, $original); $context = reversalContext($this); $unknown = collect($context['lots'])->firstWhere('data.code', null); expect($context['physical']['quantity'])->toBe(10000)->and($context['usable_milli'])->toBe(0)->and($context['reconciled'])->toBeTrue() ->and($unknown['quantity_milli'])->toBe(3000)->and($unknown['origin'])->toHaveKey('reversal_key')->and($unknown['origin'])->not->toHaveKey('receipt_item_id'); }); test('receipt reversal cannot silently associate an old entry with a baseline lot', function () { reversalSetup($this); expect(fn () => approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]))->toThrow(ValidationException::class); expect(reversalContext($this)['physical']['quantity'])->toBe(10000); }); test('a supplier return reversal restores net receipt entitlement but cannot itself be reversed', function () { $this->actingAs($this->f['receiver']); $draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['kind' => 'supplier_return', 'reference' => 'RET', 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'receipt_item_id' => $this->receiptItem->id, 'quantity' => '3.000'])); $this->actingAs($this->f['issuer']); $approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft)); $this->actingAs($this->f['receiver']); $returned = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada da devolução fictícia.'], 'execute', $approved)); expect(app(InventoryDispositions::class)->returnedForReceipt($this->receiptItem->id))->toBe(3000); expect(fn () => approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]))->toThrow(ValidationException::class); $corrected = reverseSourceOperation($this, StockOperation::query()->findOrFail($returned->data['stock_operation_id'])); expect(app(InventoryDispositions::class)->returnedForReceipt($this->receiptItem->id))->toBe(0)->and($returned->fresh()->state)->toBe('executed') ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000); expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $corrected->data['stock_operation_id']]))->toThrow(ValidationException::class); }); test('an insufficient original lot or warehouse blocks an approved reversal without compensating from another lot', function () { reversalSetup($this); $payload = receiptPayload($this->f, '5.000'); $payload['items'][0]['lots'] = [['code' => 'EXACT', 'expires_on' => today()->addDays(30)->toDateString()]]; $this->actingAs($this->f['receiver']); $receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $payload); $lot = collect(reversalContext($this)['lots'])->firstWhere('data.code', 'EXACT'); reversalRelease($this, $this->f['warehouse']->id, $lot, $this->f['otherReceiver']); $approved = approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $receipt->items->first()->id]); reversalStock($this, '3.000'); expect(fn () => executeReversal($this, $approved))->toThrow(ValidationException::class); expect(reversalContext($this)['physical']['quantity'])->toBe(12000)->and(reversalContext($this)['reconciled'])->toBeTrue() ->and(app(InventoryReversals::class)->receiptReversed($receipt->items->first()->id))->toBeFalse(); }); test('approval independence and source isolation apply even with accumulated roles', function () { $original = reversalStock($this); $this->actingAs($this->f['receiver']); $draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'REV', 'source_kind' => 'operation', 'source_operation_id' => $original->id])); supplyGrant($this->f['receiver'], ['inventory_reversal_review']); expect(fn () => app(InventoryReversals::class)->save($this->f['receiver'], supplyInput([], 'approve', $draft)))->toThrow(ValidationException::class); $foreign = receiptFixture(); $this->actingAs($foreign['receiver']); $foreignReceipt = app(ReceiptWorkflow::class)->confirm($foreign['receiver'], $foreign['order'], receiptPayload($foreign, '1.000')); $this->actingAs($this->f['receiver'])->postJson('/stock/estornos', supplyInput(['reference' => 'FOREIGN', 'source_kind' => 'receipt', 'receipt_item_id' => $foreignReceipt->items->first()->id]))->assertNotFound(); }); test('a reviewer cannot execute their own decision and duplicate approved plans cannot reverse the original twice', function () { $original = reversalStock($this); $first = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]); $second = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]); supplyGrant($this->f['issuer'], ['warehouse']); $this->actingAs($this->f['issuer']); expect(fn () => app(InventoryReversals::class)->save($this->f['issuer'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Tentativa de executar a própria conferência.'], 'execute', $first)))->toThrow(ValidationException::class); executeReversal($this, $first); expect(fn () => executeReversal($this, $second))->toThrow(ValidationException::class); expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000); }); test('a final audit failure rolls back all reversal movements and leaves the original approval intact', function () { $original = reversalStock($this); $approved = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]); $before = StockMovement::query()->count(); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable')); expect(fn () => executeReversal($this, $approved))->toThrow(RuntimeException::class); expect(StockMovement::query()->count())->toBe($before)->and($approved->fresh()->state)->toBe('approved') ->and(app(InventoryReversals::class)->operationReversed($original->id))->toBeFalse(); }); test('HTTP permissions and source views expose the correction without changing original documents', function () { $original = reversalStock($this); $this->postJson('/stock/estornos', supplyInput(['reference' => 'HTTP-REV', 'source_kind' => 'operation', 'source_operation_id' => $original->id]))->assertRedirect(); $draft = app(SupplyPlanning::class)->latest('stock_reversal')->sole(); $this->actingAs($this->f['issuer'])->postJson('/stock/estornos', supplyInput([], 'approve', $draft))->assertRedirect(); $approved = app(SupplyPlanning::class)->latest('stock_reversal')->sole(); $this->actingAs($this->f['receiver'])->postJson('/stock/estornos', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo para o percurso HTTP do estorno.'], 'execute', $approved))->assertRedirect(); $this->get('/stock/estornos')->assertOk(); $this->get('/stock/operacoes')->assertOk(); $this->actingAs($this->f['issuer'])->get('/stock/ocorrencias')->assertOk(); $admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id]); supplyGrant($admin, ['admin']); $this->actingAs($admin)->get('/stock/estornos')->assertOk(); $this->postJson('/stock/estornos', supplyInput(['reference' => 'NO-WRITE', 'source_kind' => 'operation', 'source_operation_id' => $original->id]))->assertForbidden(); }); test('voided receipt matching blocks new invoices while preserving invoice facts already registered', function () { $finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id]); supplyGrant($finance, ['finance']); $this->actingAs($finance); $data = ['number' => 'OLD-INVOICE', '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'], $data); $snapshot = $invoice->fresh(['items'])->toArray(); executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id])); expect($invoice->fresh(['items'])->toArray())->toBe($snapshot)->and(app(InventoryReversals::class)->financialNotices())->toHaveCount(1); $this->actingAs($finance); $data['number'] = 'NEW-INVOICE'; expect(fn () => app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $data))->toThrow(ValidationException::class); $this->get('/facturas')->assertOk(); $this->actingAs($this->f['receiver'])->get('/recepcoes/'.$this->receipt->id)->assertOk(); });