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'); });