Files
compras/app/Services/EquipmentWorkflow.php
T

105 lines
5.8 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\CriticalityLevel;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\Requisition;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\TechnicalReview;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EquipmentWorkflow
{
public function linkedPart(int $partId, int $equipmentId, ?int $departmentId = null): SparePart
{
$part = SparePart::query()->findOrFail($partId);
$asset = Equipment::query()->findOrFail($equipmentId);
if (($departmentId !== null && $asset->department_id !== $departmentId) || ! EquipmentSparePart::query()->where('equipment_id', $asset->id)->where('spare_part_id', $part->id)->exists()) {
throw ValidationException::withMessages(['equipment_id' => 'A peça deve estar associada a um equipamento da área de destino e da mesma empresa.']);
}
return $part;
}
/** @param array<string,mixed> $line */
public function line(array $line, int $departmentId, string $priority): void
{
if ($line['purchase_type'] === 'equipment' && empty(trim($line['production_area'] ?? ''))) {
throw ValidationException::withMessages(['items' => 'Indique a área ou linha de produção do equipamento novo.']);
}
if ($line['purchase_type'] !== 'spare_part') {
return;
}
$part = $this->linkedPart((int) ($line['spare_part_id'] ?? 0), (int) ($line['equipment_id'] ?? 0), $departmentId);
$stock = StockItem::query()->findOrFail($part->stock_item_id);
if (mb_strtolower(trim($stock->unit)) !== mb_strtolower(trim($line['unit']))) {
throw ValidationException::withMessages(['items' => 'A unidade da peça deve coincidir com o artigo de stock, sem conversões automáticas.']);
}
$level = CriticalityLevel::query()->findOrFail($part->criticality_level_id);
if ($priority === 'urgent' && $level->is_critical && empty($line['downtime_started_at'])) {
throw ValidationException::withMessages(['items' => 'Peça crítica urgente: indique o início da paragem do equipamento (RF-AF-07).']);
}
}
public function basis(Requisition $requisition): string
{
return hash('sha256', json_encode([$requisition->only(['id', 'tenant_id', 'department_id', 'requester_id', 'priority', 'needed_by', 'justification', 'total_minor']), $requisition->items()->orderBy('id')->get()->toArray()], JSON_THROW_ON_ERROR));
}
public function canReview(User $user, Requisition $requisition): bool
{
return $requisition->status === 'draft' && $requisition->items()->where('purchase_type', 'equipment')->exists() && $this->eligible($user, $requisition);
}
private function eligible(User $user, Requisition $requisition): bool
{
return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
}
public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview
{
return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::DepartmentHead]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($this->canReview($user, $requisition), 403);
app(ConflictGuard::class)->assertParticipation($user, $requisition);
if ($basis !== $this->basis($requisition)) {
throw ValidationException::withMessages(['basis_hash' => 'O pedido mudou. Reveja a versão actual antes de decidir.']);
}
if (TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $basis)->exists()) {
throw ValidationException::withMessages(['technical' => 'Esta versão já tem uma decisão técnica. A decisão anterior permanece no histórico.']);
}
$review = TechnicalReview::query()->create(['tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'reviewed_by' => $user->id, 'basis_hash' => $basis, 'decision' => $decision, 'reason' => $reason, 'reviewed_at' => now(), 'snapshot' => ['reviewer' => $user->only(['id', 'name', 'department_id']), 'items' => $requisition->items()->get()->toArray(), 'development_only' => true, 'budget_checked' => false]]);
app(RequisitionWorkflow::class)->audit($user, 'equipment.technical_'.$decision, $requisition, null, $review->toArray());
return $review;
});
}
public function assertTechnical(Requisition $requisition): void
{
if (! $requisition->items()->where('purchase_type', 'equipment')->exists()) {
return;
}
$review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first();
$user = null;
if ($review) {
$query = User::query()->where('tenant_id', $requisition->tenant_id);
if (DB::connection()->transactionLevel() > 0) {
$query->lockForUpdate();
}
$user = $query->find($review->reviewed_by);
}
if (! $review || $review->decision !== 'approved' || ! $user || ! $this->eligible($user, $requisition)) {
throw ValidationException::withMessages(['technical' => 'É necessária validação técnica válida do responsável da área, distinto do solicitante, para a versão actual.']);
}
}
}