Files
compras/tests/Feature/EquipmentSparePartsTest.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

368 lines
31 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\CriticalityLevel;
use App\Models\Department;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\Requisition;
use App\Models\SparePart;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\TechnicalReview;
use App\Models\User;
use App\Models\WarehouseRequest;
use App\Services\EquipmentWorkflow;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\StockWorkflow;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
function equipmentWarehouseDraft(array $f, bool $mixed = false): WarehouseRequest
{
return app(WarehouseRequests::class)->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);
});