Files
compras/tests/Feature/EquipmentSparePartsTest.php
T

228 lines
19 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\Services\EquipmentWorkflow;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
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';
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);
});