200 lines
13 KiB
PHP
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');
|
|
});
|