draft($f['receiver'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $f['warehouse']->id, 'cost_center_id' => $f['r']->cost_center_id, 'destination' => 'Destino do equipamento', 'reason' => 'Manutenção interna com material já registado.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $f['stock']->id, 'quantity' => '0.100', 'use' => 'equipment', 'equipment_id' => $f['asset']->id], ...($mixed ? [['stock_item_id' => $f['stock']->id, 'quantity' => '9.000', 'use' => 'internal', 'equipment_id' => null]] : [])]]); } test('equipment warehouse requests use native scope and expose only matching lines without mutations', function () { $this->withoutVite(); supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_prepare']); $record = equipmentWarehouseDraft($this->f, true); $before = []; foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'stock_operations', 'stock_movements', 'audit_logs'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $this->get('/equipamentos/'.$this->f['asset']->id)->assertOk()->assertInertia(fn (Assert $p) => $p ->where('warehouseRequests.total', 1)->where('warehouseRequests.data.0.id', $record->id)->where('warehouseRequests.data.0.state', 'draft') ->has('warehouseRequests.data.0.lines', 1)->where('warehouseRequests.data.0.lines.0.requested_milli', 100) ->where('warehouseRequests.data.0.lines.0.delivered_milli', 0)->where('warehouseRequests.data.0.lines.0.remaining_milli', 100) ->missing('warehouseRequests.data.0.snapshot')->missing('warehouseRequests.data.0.events')); foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } supplyGrant($this->f['finance'], ['finance']); $this->actingAs($this->f['finance'])->get('/equipamentos/'.$this->f['asset']->id)->assertOk() ->assertInertia(fn (Assert $p) => $p->where('warehouseRequests', null)); supplyGrant($this->f['issuer'], ['procurement', 'warehouse_request_prepare']); $this->actingAs($this->f['issuer'])->get('/equipamentos/'.$this->f['asset']->id)->assertOk() ->assertInertia(fn (Assert $p) => $p->where('warehouseRequests.total', 0)); }); test('equipment warehouse requests paginate old pending documents independently', function () { $this->withoutVite(); supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_prepare']); foreach (range(1, 16) as $index) { $last = equipmentWarehouseDraft($this->f); } $url = '/equipamentos/'.$this->f['asset']->id.'?requests_page=2&purchases_page=1&movements_page=1'; $response = $this->get($url)->assertOk()->assertInertia(fn (Assert $p) => $p->where('warehouseRequests.total', 16) ->has('warehouseRequests.data', 1)->where('warehouseRequests.data.0.id', $last->id)->where('purchases.current_page', 1)->where('movements.current_page', 1)); expect($response->inertiaProps('warehouseRequests.prev_page_url'))->toContain('purchases_page=1', 'movements_page=1'); $this->getJson('/equipamentos/'.$this->f['asset']->id.'?requests_page=0')->assertUnprocessable(); }); test('equipment warehouse requests retain department and tenant equipment boundaries', function () { $this->withoutVite(); supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_prepare']); equipmentWarehouseDraft($this->f); supplyGrant($this->f['head'], ['department_head', 'warehouse_request_review']); $this->actingAs($this->f['head'])->get('/equipamentos/'.$this->f['asset']->id)->assertOk() ->assertInertia(fn (Assert $p) => $p->where('warehouseRequests.total', 1)); $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra área']); $this->f['asset']->update(['department_id' => $department->id]); $this->get('/equipamentos/'.$this->f['asset']->id)->assertNotFound(); $foreign = receiptFixture(); $this->actingAs($foreign['receiver'])->get('/equipamentos/'.$this->f['asset']->id)->assertNotFound(); }); test('equipment integration block reuses the stock operation produced by an authorized internal warehouse delivery', function () { $this->withoutVite(); supplyGrant($this->f['issuer'], ['warehouse_request_prepare']); supplyGrant($this->f['head'], ['warehouse_request_review', 'warehouse_request_read']); supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); $this->actingAs($this->f['issuer']); $request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'destination' => 'Linha do equipamento', 'reason' => 'Peça para manutenção interna do equipamento.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '0.100', 'use' => 'equipment', 'equipment_id' => $this->f['asset']->id]]]); $flow = app(WarehouseFulfilment::class); foreach ([[$this->f['issuer'], 'submit'], [$this->f['head'], 'authorize'], [$this->f['receiver'], 'deliver']] as [$actor, $action]) { $this->actingAs($actor); $flow->command($actor, $request->id, ['operation_key' => (string) Str::uuid(), 'version' => $flow->progress($request)['version'], 'action' => $action, 'reason' => 'Intervenção nativa do percurso interno de teste.', ...($action === 'deliver' ? ['lines' => [['index' => 0, 'quantity' => '0.100']]] : [])]); } $operation = StockOperation::query()->where('equipment_id', $this->f['asset']->id)->sole(); $before = DB::table('stock_movements')->count(); $this->get('/equipamentos/'.$this->f['asset']->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('movements.total', 1) ->where('movements.data.0.id', $operation->id)->where('movements.data.0.quantity_milli', 100) ->where('warehouseRequests.total', 1)->where('warehouseRequests.data.0.state', 'delivered') ->where('warehouseRequests.data.0.lines.0.delivered_milli', 100)->where('warehouseRequests.data.0.lines.0.remaining_milli', 0)); expect(DB::table('stock_movements')->count())->toBe($before)->and($flow->progress($request)['state'])->toBe('delivered'); }); test('equipment integration block separates native purchase requests from actual equipment stock issues without mutations', function () { $this->withoutVite(); $request = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f, 'spare_part')); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); $operation = app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'EQ-ISSUE', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['receiver']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'equipment_id' => $this->f['asset']->id, 'quantity' => '0.100', 'destination' => 'Equipamento de teste', 'reason' => 'Saída física nativa para o equipamento.']); $before = []; foreach (['audit_logs', 'requisitions', 'requisition_items', 'approval_steps', 'stock_movements', 'stock_operations', 'equipment'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $this->get('/equipamentos/'.$this->f['asset']->id)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') ->assertInertia(fn (Assert $p) => $p->component('equipment/show')->where('equipment.code', 'EQ-TEST')->where('purchases.total', 1) ->where('purchases.data.0.id', $request->id)->where('purchases.data.0.items.0.quantity', '1.000')->missing('purchases.data.0.items.0.unit_price_minor') ->where('movements.total', 1)->where('movements.data.0.id', $operation->id)->where('movements.data.0.quantity_milli', 100)->missing('movements.data.0.snapshot')); foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } }); test('equipment integration block preserves department and native requisition visibility with tenant isolation', function () { $this->withoutVite(); $request = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $this->actingAs($this->f['finance'])->get('/equipamentos/'.$this->f['asset']->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('purchases.total', 1)); $otherDepartment = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra área']); $request->update(['department_id' => $otherDepartment->id]); $this->actingAs($this->f['head'])->get('/equipamentos/'.$this->f['asset']->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('purchases.total', 0)); $this->f['asset']->update(['department_id' => $otherDepartment->id]); $this->get('/equipamentos/'.$this->f['asset']->id)->assertNotFound(); $foreign = receiptFixture(); $this->actingAs($foreign['receiver'])->get('/equipamentos/'.$this->f['asset']->id)->assertNotFound(); $user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Requester]); $this->actingAs($user)->get('/equipamentos/'.$this->f['asset']->id)->assertForbidden(); }); test('equipment integration block keeps old purchase and movement pages independently accessible', function () { $this->withoutVite(); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); foreach (range(1, 16) as $index) { app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'EQ-PAGE-'.$index, 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['receiver']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'equipment_id' => $this->f['asset']->id, 'quantity' => '0.001', 'reason' => 'Saída física nativa para paginação.']); } $this->get('/equipamentos/'.$this->f['asset']->id.'?purchases_page=2&movements_page=1')->assertOk() ->assertInertia(fn (Assert $p) => $p->where('purchases.total', 16)->has('purchases.data', 1)->where('purchases.current_page', 2)->has('movements.data', 15)->where('movements.current_page', 1)); $this->get('/equipamentos/'.$this->f['asset']->id.'?purchases_page=1&movements_page=2')->assertOk() ->assertInertia(fn (Assert $p) => $p->has('purchases.data', 15)->has('movements.data', 1)->where('movements.data.0.reference', 'EQ-PAGE-16')); }); function equipmentRequestData(array $f, string $type = 'equipment'): array { return ['cost_center_id' => $f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Equipamento para uma linha de produção de teste.', 'items' => [['description' => 'Máquina de teste', 'category' => 'Produção', 'purchase_type' => $type, 'quantity' => '1', 'unit' => 'cx', 'unit_price' => '1.00', 'tax_rate' => '0', 'production_area' => 'Linha fictícia', 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id]]]; } beforeEach(function () { $this->f = receiptFixture(); $f = &$this->f; $f['admin'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Admin]); $f['head'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $f['finance'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Finance]); $f['level'] = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['admin']->id, 'code' => 'CRIT-TEST', 'name' => 'Categoria de teste', 'criteria' => 'Critério fictício definido explicitamente para teste', 'is_critical' => true, 'created_at' => now()]); $f['asset'] = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $f['level']->id, 'code' => 'EQ-TEST', 'description' => 'Compressor de teste', 'area' => 'Linha A', 'status' => 'stopped']); $f['part'] = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $f['level']->id, 'minimum_milli' => 2000]); EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id, 'created_at' => now()]); $this->actingAs($f['receiver']); }); test('criticality requires explicit company criteria and critical flag without defaults', function () { $this->actingAs($this->f['admin']); $this->postJson('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria'])->assertUnprocessable()->assertJsonValidationErrors(['criteria', 'is_critical']); $this->post('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria', 'criteria' => 'Critério decidido pela empresa de teste', 'is_critical' => false])->assertSessionHasNoErrors()->assertRedirect(); $level = CriticalityLevel::query()->where('code', 'NEW')->firstOrFail(); expect($level->is_critical)->toBeFalse(); expect(fn () => $level->update(['criteria' => 'Rewrite']))->toThrow(LogicException::class); }); test('only administration configures criticality', function ($role) { $this->f['receiver']->forceFill(['purchasing_role' => $role])->save(); $this->actingAs($this->f['receiver']); $this->post('/equipamentos/categorias', ['code' => 'X', 'name' => 'X', 'criteria' => 'X', 'is_critical' => true])->assertForbidden(); })->with([PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead, PurchasingRole::Requester]); test('catalogue requires explicit minimum and supports optimistic audited updates', function () { $payload = ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id]; $this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('minimum_quantity'); $payload['minimum_quantity'] = '0'; $this->post('/equipamentos/pecas', $payload)->assertSessionHasNoErrors(); expect($this->f['part']->refresh()->minimum_milli)->toBe(0)->and($this->f['part']->version)->toBe(2); $this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('version'); }); test('warehouse stock drives minimum alert without duplicate inventory balances', function () { app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); $this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->component('equipment/index')->where('parts.0.balance_milli', 1000)->where('parts.0.below_minimum', true)); $this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable(); expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000)->and(EquipmentSparePart::query()->count())->toBe(1); }); test('parts require compatible destination and snapshot equipment in issue and movement', function () { app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); $p = ['kind' => 'issue', 'reference' => 'SAI-EQ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Linha A', 'reason' => 'Substituição de rolamento', 'quantity' => '0.500']; $this->postJson('/stock/operacoes', $p)->assertUnprocessable()->assertJsonValidationErrors('equipment_id'); $p['equipment_id'] = $this->f['asset']->id; $this->postJson('/stock/operacoes', $p)->assertCreated(); $this->postJson('/stock/operacoes', $p)->assertOk(); $op = StockOperation::query()->firstOrFail(); expect($op->equipment_id)->toBe($this->f['asset']->id)->and($op->spare_part_id)->toBe($this->f['part']->id)->and($op->snapshot['equipment']['description'])->toBe('Compressor de teste'); $this->f['asset']->update(['description' => 'Nome novo']); expect($op->refresh()->snapshot['equipment']['description'])->toBe('Compressor de teste'); $movement = StockMovement::query()->where('stock_operation_id', $op->id)->firstOrFail(); expect($movement->equipment_id)->toBe($op->equipment_id)->and($movement->snapshot['equipment']['description'])->toBe('Compressor de teste'); }); test('cross company associations and catalogue references are rejected', function ($resource) { $other = receiptFixture(); $this->actingAs($this->f['receiver']); if ($resource === 'equipment') { $this->postJson('/equipamentos/activos', ['code' => 'OTHER', 'description' => 'Test', 'area' => 'A', 'status' => 'active', 'department_id' => $other['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('department_id'); } else { $this->postJson('/equipamentos/pecas', ['stock_item_id' => $other['stock']->id, 'minimum_quantity' => '0', 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('stock_item_id'); } })->with(['equipment', 'stock']); test('unrelated and foreign equipment cannot receive a designated spare part', function () { $asset = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id, 'code' => 'UNLINKED', 'description' => 'Other', 'area' => 'A', 'status' => 'active']); expect(fn () => app(EquipmentWorkflow::class)->linkedPart($this->f['part']->id, $asset->id))->toThrow(ValidationException::class); $other = receiptFixture(); $this->actingAs($other['receiver']); $this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->has('equipment', 0)->has('parts', 0)->has('history.data', 0)); $this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable()->assertJsonValidationErrors(['equipment_id', 'spare_part_id']); }); test('equipment requisition demands technical validation and adds finance for every value', function () { $data = equipmentRequestData($this->f); $this->post('/requisicoes', $data)->assertSessionHasNoErrors(); $r = Requisition::query()->latest('id')->firstOrFail(); $this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical'); $basis = app(EquipmentWorkflow::class)->basis($r); $this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Especificações adequadas à linha de teste'])->assertSessionHasNoErrors(); $this->actingAs($this->f['receiver'])->post('/requisicoes/'.$r->id.'/submeter')->assertSessionHasNoErrors(); expect($r->refresh()->status)->toBe('pending')->and($r->steps()->pluck('role')->all())->toBe(['department_head', 'finance']); $this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors(); expect($r->refresh()->status)->toBe('pending'); $this->actingAs($this->f['finance'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors(); $this->actingAs($this->f['issuer'])->postJson('/requisicoes/'.$r->id.'/adjudicacao', ['quotation_id' => $this->f['order']->purchase_award_id, 'justification' => 'Justificação de teste', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Linha A'])->assertUnprocessable()->assertJsonValidationErrors('purchase_type'); }); test('technical decision cannot be made by requester admin or warehouse', function ($role) { $r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $actor = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]); if ($role === PurchasingRole::DepartmentHead) { $r->update(['requester_id' => $actor->id]); } $this->actingAs($actor)->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden(); })->with([PurchasingRole::DepartmentHead, PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Finance]); test('technical decision is immutable and stale review cannot approve edited request', function () { $r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $basis = app(EquipmentWorkflow::class)->basis($r); $this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Validação do teste'])->assertSessionHasNoErrors(); $review = TechnicalReview::query()->firstOrFail(); expect(fn () => $review->delete())->toThrow(LogicException::class); $data = equipmentRequestData($this->f); $data['justification'] = 'Mudança da finalidade técnica do equipamento'; $this->actingAs($this->f['receiver'])->put('/requisicoes/'.$r->id, $data)->assertSessionHasNoErrors(); $this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical'); $this->actingAs($this->f['head'])->postJson('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Teste'])->assertUnprocessable()->assertJsonValidationErrors('basis_hash'); expect(TechnicalReview::query()->count())->toBe(1); }); test('rejected technical review does not permit submission', function () { $r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'rejected', 'reason' => 'Não cumpre a especificação técnica'])->assertSessionHasNoErrors(); $this->actingAs($this->f['receiver'])->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical'); }); test('urgent critical spare parts require downtime without granting quotation exceptions', function () { $data = equipmentRequestData($this->f, 'spare_part'); $data['priority'] = 'urgent'; $this->postJson('/requisicoes', $data)->assertUnprocessable()->assertJsonValidationErrors('items'); $data['items'][0]['downtime_started_at'] = now()->subHour()->format('Y-m-d\TH:i'); $this->post('/requisicoes', $data)->assertSessionHasNoErrors(); $r = Requisition::query()->latest('id')->firstOrFail(); expect($r->items()->firstOrFail()->equipment_id)->toBe($this->f['asset']->id)->and($r->items()->firstOrFail()->downtime_started_at)->not->toBeNull(); }); test('spare receipts preserve authorised item and equipment traceability', function () { $f = receiptFixture('1.000', 'spare_part'); $this->actingAs($f['receiver']); // Attach already authorised links only inside the isolated fixture. $level = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'code' => 'PART', 'name' => 'Test', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]); $asset = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ', 'description' => 'Test', 'area' => 'A', 'status' => 'active']); $part = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]); EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]); DB::table('purchase_order_items')->where('id', $f['line']->id)->update(['equipment_id' => $asset->id, 'spare_part_id' => $part->id]); $this->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f, '1.000'))->assertCreated(); $movement = StockMovement::query()->firstOrFail(); expect($movement->equipment_id)->toBe($asset->id)->and($movement->spare_part_id)->toBe($part->id); }); test('public equipment page has fictional props only', function () { $this->get('/consulta/equipamentos')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'equipment')->where('auth.user', null)->missing('equipment')->missing('parts')); }); test('technical reviewer of another department and foreign company cannot decide', function () { $r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra área']); $this->f['head']->forceFill(['department_id' => $dept->id])->save(); $this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden(); $other = receiptFixture(); $this->actingAs($other['receiver']); $this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertNotFound(); }); test('technical audit failure rolls back decision', function () { $r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f)); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit failed')); $this->actingAs($this->f['head']); $this->withoutExceptionHandling(); try { $this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste']); $this->fail('Expected exception'); } catch (RuntimeException $e) { expect($e->getMessage())->toBe('Audit failed'); } expect(TechnicalReview::query()->count())->toBe(0); }); test('foreign classifications cannot be used in equipment or part configuration', function () { $other = receiptFixture(); $level = CriticalityLevel::query()->create(['tenant_id' => $other['tenant']->id, 'created_by' => $other['receiver']->id, 'code' => 'FOREIGN', 'name' => 'Outra', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]); $this->actingAs($this->f['receiver']); $this->postJson('/equipamentos/activos', ['code' => 'FOREIGN', 'description' => 'Máquina', 'area' => 'Linha', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $level->id])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id'); $this->postJson('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_quantity' => '1'])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id'); }); test('classification and minimum edits do not erase earlier movement snapshots', function () { app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); $p = ['kind' => 'issue', 'reference' => 'SNAP', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Produção', 'reason' => 'Reposição', 'quantity' => '0.001', 'equipment_id' => $this->f['asset']->id]; $this->postJson('/stock/operacoes', $p)->assertCreated(); $this->post('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '5.000'])->assertSessionHasNoErrors(); expect(StockOperation::query()->firstOrFail()->snapshot['spare_part']['minimum_milli'])->toBe(2000); }); test('finance can consult but cannot change equipment or parts', function () { $this->actingAs($this->f['finance'])->get('/equipamentos')->assertOk(); $this->post('/equipamentos/pecas', ['stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '1'])->assertForbidden(); }); test('new catalogue forms accept empty optional identity and version fields', function () { $this->post('/equipamentos/activos', ['id' => '', 'version' => '', 'stock_item_id' => '', 'serial_number' => '', 'code' => 'NEW-FORM', 'description' => 'Equipamento novo', 'area' => 'Linha B', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertSessionHasNoErrors(); expect(Equipment::query()->where('code', 'NEW-FORM')->count())->toBe(1); });