Files
compras/tests/Feature/StockOperationsTest.php

165 lines
9.9 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\Warehouse;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->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);
});