f = receiptFixture(); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); $this->dest = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']); $this->p = ['kind' => 'issue', 'reference' => 'SAI-001', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['issuer']->id, 'destination' => 'Produção / REQ-001', 'reason' => 'Consumo de produção', 'quantity' => '0.600']; }); test('issues are exact, idempotent and cannot make stock negative', function () { $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('created', false); $this->p['quantity'] = '0.500'; $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable(); $this->p['reference'] = 'SAI-002'; $this->p['idempotency_key'] = (string) Str::uuid(); $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('quantity'); expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(400)->and(StockOperation::query()->count())->toBe(1); }); test('transfer creates exactly two opposite ledger entries and rejects repeated references', function () { $this->p['kind'] = 'transfer'; $this->p['destination_warehouse_id'] = $this->dest->id; $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $op = StockOperation::query()->firstOrFail(); $moves = StockMovement::query()->where('stock_operation_id', $op->id); expect($moves->count())->toBe(2)->and((int) $moves->sum('quantity_milli'))->toBe(0); expect((int) StockMovement::query()->where('warehouse_id', $this->dest->id)->sum('quantity_milli'))->toBe(600); $this->p['idempotency_key'] = (string) Str::uuid(); $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('reference'); $this->p['reference'] = 'OTHER'; $this->p['destination_warehouse_id'] = $this->f['warehouse']->id; $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable(); }); test('counts preserve theoretical quantities and adjustments apply a justified difference once', function () { $this->p['kind'] = 'count'; $this->p['quantity'] = '0'; $this->p['ledger_version'] = StockMovement::query()->max('id'); $id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id'); expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000); $this->p['kind'] = 'adjustment'; $this->p['count_id'] = $id; $this->p['reference'] = 'AJ-001'; $this->p['idempotency_key'] = (string) Str::uuid(); $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $this->postJson('/stock/operacoes', $this->p)->assertOk(); expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(0); $this->p['reference'] = 'AJ-002'; $this->p['idempotency_key'] = (string) Str::uuid(); $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable(); expect(StockOperation::query()->findOrFail($id)->theoretical_milli)->toBe(1000); }); test('intervening receipts and issues invalidate counts and stale count forms', function () { $count = [...$this->p, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => StockMovement::query()->max('id')]; $id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id'); $this->p['reference'] = 'SAI-NEW'; $this->p['idempotency_key'] = (string) Str::uuid(); $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $this->postJson('/stock/operacoes', [...$count, 'reference' => 'COUNT-NEW', 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('ledger_version'); $this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'adjustment', 'reference' => 'AJ-NEW', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('count_id'); }); test('operational role cannot be replaced by administration or other profiles', function ($role) { $this->f['receiver']->forceFill(['purchasing_role' => $role])->save(); $this->actingAs($this->f['receiver']); $this->postJson('/stock/operacoes', $this->p)->assertForbidden(); })->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::DepartmentHead, PurchasingRole::Requester]); test('foreign company resources cannot be used', function ($field) { $other = receiptFixture(); $this->actingAs($this->f['receiver']); $values = ['warehouse_id' => $other['warehouse']->id, 'destination_warehouse_id' => $other['warehouse']->id, 'stock_item_id' => $other['stock']->id, 'responsible_id' => $other['receiver']->id]; if ($field === 'destination_warehouse_id') { $this->p['kind'] = 'transfer'; } $this->p[$field] = $values[$field]; $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field); })->with(['warehouse_id', 'destination_warehouse_id', 'stock_item_id', 'responsible_id']); test('audit failure rolls back both halves of transfer and operation', function () { $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable')); $this->p['kind'] = 'transfer'; $this->p['destination_warehouse_id'] = $this->dest->id; $this->withoutExceptionHandling(); try { $this->postJson('/stock/operacoes', $this->p); $this->fail('Expected exception'); } catch (RuntimeException $e) { expect($e->getMessage())->toBe('Audit unavailable'); } expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1); }); test('operations are immutable and new public screen contains only demonstration props', function () { $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $op = StockOperation::query()->firstOrFail(); expect(fn () => $op->update(['reason' => 'rewrite']))->toThrow(LogicException::class); expect(fn () => $op->delete())->toThrow(LogicException::class); $this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->component('stock/operations')->has('operations.data', 1)->where('canOperate', true)); $this->get('/consulta/operacoes-stock')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'operations')->missing('operations')->where('auth.user', null)); }); test('invalid precision and missing justification or destination do not write', function ($field, $value) { $this->p[$field] = $value; $this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field); expect(StockOperation::query()->count())->toBe(0); })->with([['quantity', '-1'], ['quantity', '0'], ['quantity', '0.0001'], ['reason', ' '], ['destination', ''], ['responsible_id', null]]); test('positive adjustments credit only the difference and matching counts create no movements', function () { $count = [...$this->p, 'kind' => 'count', 'quantity' => '1.250', 'ledger_version' => StockMovement::query()->max('id')]; $id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id'); $adjustment = [...$this->p, 'kind' => 'adjustment', 'count_id' => $id, 'reference' => 'AJ-POS', 'idempotency_key' => (string) Str::uuid()]; $this->postJson('/stock/operacoes', $adjustment)->assertCreated(); expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1250); $id = $this->postJson('/stock/operacoes', [...$count, 'reference' => 'MATCH', 'idempotency_key' => (string) Str::uuid(), 'ledger_version' => StockMovement::query()->max('id')])->assertCreated()->json('id'); $this->postJson('/stock/operacoes', [...$adjustment, 'reference' => 'NOOP', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable(); expect(StockMovement::query()->count())->toBe(2); }); test('failure creating transfer credit rolls back its debit', function () { StockMovement::creating(function (StockMovement $movement) { if ($movement->movement_type === 'transfer_in') { throw new RuntimeException('Credit failed'); } }); $this->withoutExceptionHandling(); try { $this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'transfer', 'destination_warehouse_id' => $this->dest->id]); $this->fail('Expected failure'); } catch (RuntimeException $error) { expect($error->getMessage())->toBe('Credit failed'); } expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1); }); test('stock views isolate operations and counts from other companies', function () { $this->postJson('/stock/operacoes', $this->p)->assertCreated(); $other = receiptFixture(); $this->actingAs($other['receiver']); $this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->has('operations.data', 0)->has('counts', 0)); }); test('form encoded retry identifiers and equivalent quantities retain idempotency', function () { $id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id'); foreach (['warehouse_id', 'stock_item_id', 'responsible_id'] as $key) { $this->p[$key] = (string) $this->p[$key]; } $this->p['quantity'] = '0.6'; $this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('id', $id); });