165 lines
9.9 KiB
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);
|
|
});
|