Files
compras/tests/Feature/ReceiptStockTest.php
T

200 lines
13 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Department;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\User;
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']);
$this->url = '/ordens/'.$this->f['order']->id.'/recepcoes';
$this->payload = receiptPayload($this->f);
});
test('partial then total receipts preserve pending quantities and credit only accepted stock', function () {
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertCreated()->assertJsonPath('status', 'partial');
$first = GoodsReceipt::query()->firstOrFail();
$this->postJson($this->url, receiptPayload($this->f, '0.600'))->assertCreated()->assertJsonPath('status', 'complete');
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
expect($progress['state'])->toBe('complete')->and($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['accepted_milli'])->toBe(1000);
expect(StockMovement::query()->count())->toBe(2)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000);
expect($first->refresh()->status)->toBe('partial');
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(2);
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->component('stock/index')->where('balances.0.quantity_milli', 1000)->has('movements.data', 2));
});
test('overreceipt is rejected against cumulative delivered quantities', function () {
$this->postJson($this->url, receiptPayload($this->f, '0.700'))->assertCreated();
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertUnprocessable()->assertJsonValidationErrors('items');
expect(GoodsReceipt::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(700);
});
test('idempotent retries preserve one receipt and movement but changed payload cannot reuse the key', function () {
$first = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
$this->postJson($this->url, $this->payload)->assertOk()->assertJsonPath('id', $first)->assertJsonPath('created', false);
$changed = $this->payload;
$changed['items'][0]['received_quantity'] = '0.500';
$changed['items'][0]['accepted_quantity'] = '0.500';
$this->postJson($this->url, $changed)->assertUnprocessable()->assertJsonValidationErrors('idempotency_key');
expect(GoodsReceipt::query()->count())->toBe(1)->and(StockMovement::query()->count())->toBe(1);
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(1);
});
test('another token cannot duplicate the same normalized delivery note', function () {
$this->payload['delivery_note'] = 'gr teste 001';
$this->postJson($this->url, $this->payload)->assertCreated();
$this->payload['idempotency_key'] = (string) Str::uuid();
$this->payload['delivery_note'] = ' GR TESTE 001 ';
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('delivery_note');
expect(StockMovement::query()->count())->toBe(1);
});
test('reservations and rejections record divergences without crediting rejected goods or authorizing replacements', function () {
$body = receiptPayload($this->f, '0.600');
$body['items'][0] = [...$body['items'][0], 'accepted_quantity' => '0.400', 'quality_status' => 'reservations', 'notes' => 'Duas décimas danificadas.'];
$this->postJson($this->url, $body)->assertCreated();
$rejected = receiptPayload($this->f, '0.400');
$rejected['warehouse_id'] = null;
$rejected['items'][0] = [...$rejected['items'][0], 'accepted_quantity' => '0', 'quality_status' => 'nonconforming', 'stock_item_id' => null, 'notes' => 'Material não conforme.'];
$this->postJson($this->url, $rejected)->assertCreated()->assertJsonPath('status', 'rejected');
expect(StockMovement::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(400);
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
expect($progress['state'])->toBe('quality_pending')->and($progress['has_divergences'])->toBeTrue();
expect($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['acceptance_pending_milli'])->toBe(600);
$this->postJson($this->url, receiptPayload($this->f, '0.100'))->assertUnprocessable();
});
test('quality and quantity inconsistencies cannot confirm stock', function (string $received, string $accepted, string $quality, ?string $notes) {
$this->payload['items'][0] = [...$this->payload['items'][0], 'received_quantity' => $received, 'accepted_quantity' => $accepted, 'quality_status' => $quality, 'notes' => $notes];
$this->postJson($this->url, $this->payload)->assertUnprocessable();
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0);
})->with([['0.500', '0.600', 'reservations', 'Excesso inválido'], ['0.500', '0.400', 'compliant', null], ['0.500', '0.100', 'nonconforming', 'Não conforme'], ['0.500', '0.400', 'reservations', null], ['-1', '0', 'nonconforming', 'Negativo'], ['0.0001', '0.0001', 'compliant', null], ['0', '0', 'compliant', null]]);
test('stock items require the correct warehouse and unit', function (string $missing) {
if ($missing === 'warehouse') {
$this->payload['warehouse_id'] = null;
} elseif ($missing === 'item') {
$this->payload['items'][0]['stock_item_id'] = null;
} else {
$this->f['stock']->update(['unit' => 'kg']);
}
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
expect(StockMovement::query()->count())->toBe(0);
})->with(['warehouse', 'item', 'unit']);
test('services can be accepted without creating physical stock', function () {
$f = receiptFixture('1.000', 'service');
$this->actingAs($f['receiver']);
$body = receiptPayload($f, '1.000');
$body['warehouse_id'] = null;
$body['items'][0]['stock_item_id'] = null;
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', $body)->assertCreated()->assertJsonPath('status', 'complete');
expect(StockMovement::query()->count())->toBe(0);
});
test('an issuer cannot receive their order even after becoming warehouse staff', function () {
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
$this->actingAs($this->f['issuer'])->postJson($this->url, $this->payload)->assertForbidden();
});
test('confirmation is exclusive to warehouse staff', function (PurchasingRole $role) {
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]);
$this->actingAs($user)->postJson($this->url, $this->payload)->assertForbidden();
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::Requester, PurchasingRole::DepartmentHead]);
test('warehouse order view exposes receiving quantities without the financial snapshot', function () {
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('receipts/order')->where('canConfirm', true)->where('progress.lines.0.ordered_milli', 1000)->missing('order.snapshot')->missing('order.total_minor')->missing('order.currency'));
$this->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 1));
});
test('companies cannot consult or confirm another company receipt or stock', function () {
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
$foreign = receiptFixture();
$this->actingAs($foreign['receiver'])->get($this->url)->assertNotFound();
$this->postJson($this->url, $this->payload)->assertNotFound();
$this->get('/recepcoes/'.$id)->assertNotFound();
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->has('balances', 0)->has('movements.data', 0)->has('items', 1)->where('items.0.id', $foreign['stock']->id));
$body = receiptPayload($foreign);
$body['warehouse_id'] = $this->f['warehouse']->id;
$body['items'][0]['stock_item_id'] = $this->f['stock']->id;
$this->postJson('/ordens/'.$foreign['order']->id.'/recepcoes', $body)->assertUnprocessable()->assertJsonValidationErrors(['warehouse_id', 'items.0.stock_item_id']);
});
test('department heads see only their department receipts and cannot inspect stock', function () {
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head)->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('receipts.data', 0));
$this->get('/recepcoes/'.$id)->assertForbidden();
$this->get('/stock')->assertForbidden();
$head->forceFill(['department_id' => $this->f['department']->id])->save();
$this->actingAs($head)->get('/recepcoes/'.$id)->assertOk();
});
test('a line from another order and duplicate lines are rejected', function () {
$foreign = receiptFixture();
$this->actingAs($this->f['receiver']);
$this->payload['items'][0]['purchase_order_item_id'] = $foreign['line']->id;
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
$body = receiptPayload($this->f);
$body['items'][] = $body['items'][0];
$this->postJson($this->url, $body)->assertUnprocessable();
});
test('receipts items and movements are append only and unsupported HTTP edits are absent', function () {
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
$receipt = GoodsReceipt::query()->findOrFail($id);
$item = GoodsReceiptItem::query()->firstOrFail();
$movement = StockMovement::query()->firstOrFail();
foreach ([$receipt, $item, $movement] as $record) {
expect(fn () => $record->delete())->toThrow(LogicException::class);
}
expect(fn () => $receipt->update(['delivery_note' => 'Outra']))->toThrow(LogicException::class);
expect(fn () => $item->update(['accepted_milli' => 1000]))->toThrow(LogicException::class);
expect(fn () => $movement->update(['quantity_milli' => 1000]))->toThrow(LogicException::class);
$this->put('/recepcoes/'.$id, [])->assertStatus(405);
$this->delete('/recepcoes/'.$id)->assertStatus(405);
});
test('stock is derived from receipts not initial balances supplied by browser', function () {
$this->post('/stock/artigos', ['code' => ' novo-art ', 'name' => 'Novo artigo', 'unit' => ' CX ', 'quantity_milli' => 999999, 'tenant_id' => 999])->assertSessionHasNoErrors();
$item = StockItem::query()->where('code', 'NOVO-ART')->firstOrFail();
expect($item->tenant_id)->toBe($this->f['tenant']->id)->and($item->unit)->toBe('cx');
expect(StockMovement::query()->count())->toBe(0);
$this->post('/stock/artigos', ['code' => 'novo-art', 'name' => 'Duplicado', 'unit' => 'cx'])->assertSessionHasErrors('code');
$this->post('/stock/movimentos', [])->assertNotFound();
});
test('audit failure rolls back receipt lines and all stock entries', function () {
$this->mock(RequisitionWorkflow::class, function ($mock) {
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha simulada de auditoria da recepção.'));
});
$this->postJson($this->url, $this->payload)->assertStatus(500);
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0)->and(GoodsReceiptItem::query()->count())->toBe(0);
expect(app(ReceiptWorkflow::class)->progress($this->f['order'])['lines'][0]['delivery_pending_milli'])->toBe(1000);
});
test('receipt dates and server controlled fields are enforced', function () {
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->subDay()->format('Y-m-d')])->assertUnprocessable()->assertJsonValidationErrors('received_on');
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->addDay()->format('Y-m-d')])->assertUnprocessable();
$this->postJson($this->url, [...$this->payload, 'received_by' => $this->f['issuer']->id, 'status' => 'complete'])->assertUnprocessable()->assertJsonValidationErrors(['received_by', 'status']);
});
test('guests cannot access receiving and stock operations', function () {
auth()->logout();
$this->get('/recepcoes')->assertRedirect('/login');
$this->get('/stock')->assertRedirect('/login');
$this->post($this->url, $this->payload)->assertRedirect('/login');
});