id() === $actor->id && $actor->tenant_id !== null, 403); $fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id); abort_unless($fresh->hasFunctions(self::ROLES), 403); return $fresh; } /** * @param array $payload * @return array */ private function normalise(User $actor, Supplier $supplier, array $payload): array { $orders = array_values(array_unique(array_map('intval', $payload['order_ids'] ?? []))); sort($orders); $payload = ['title' => $payload['title'], 'nature' => $payload['nature'], 'scope' => $payload['scope'] ?? '', 'period_start' => $payload['period_start'] ?? null, 'period_end' => $payload['period_end'] ?? null, 'order_ids' => $orders, 'criteria' => array_map(fn ($row) => ['label' => $row['label'] ?? '', 'result' => $row['result'], 'reason' => $row['reason'] ?? '', 'evidence' => $row['evidence'] ?? ''], $payload['criteria']), 'conclusion' => $payload['conclusion'] ?? '', 'critical_occurrences' => $payload['critical_occurrences'] ?? '', 'critical_evidence' => $payload['critical_evidence'] ?? '']; if ($payload['period_start'] && $payload['period_end'] && $payload['period_end'] < $payload['period_start']) { throw ValidationException::withMessages(['payload.period_end' => 'O fim do período deve ser igual ou posterior ao início.']); } $orders = PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $supplier->id)->whereIn('id', $payload['order_ids'])->count(); if ($orders !== count($payload['order_ids'])) { throw ValidationException::withMessages(['payload.order_ids' => 'Escolha apenas ordens autorizadas deste fornecedor.']); } foreach (PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->pluck('requisition_id') as $requisitionId) { abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, (int) $requisitionId), 403, 'Existe uma declaração de conflito num dos processos abrangidos.'); } return $payload; } /** @param array $payload */ private function department(User $actor, array $payload): ?int { if (! $payload['order_ids']) { return $actor->department_id; } $departments = Requisition::query()->whereIn('id', PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->select('requisition_id'))->distinct()->pluck('department_id'); return $departments->count() === 1 ? (int) $departments->first() : null; } private function actor(User $actor): User { app(ConflictGuard::class)->lockCompany($actor); return app(CurrentActor::class)->lock($actor, self::ROLES); } private function participating(User $actor, int $supplierId): void { abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplierId, null), 403, 'Existe uma declaração de conflito com este fornecedor.'); } /** @return array */ public function snapshot(SupplierEvaluation $evaluation): array { return ['evaluation_id' => $evaluation->id, ...$evaluation->only(['id', 'supplier_id', 'department_id', 'created_by', 'submitted_by', 'reviewed_by', 'previous_id', 'revision', 'version', 'status', 'payload', 'supplier_snapshot', 'contributors', 'review_reason', 'reviewed_at', 'created_at', 'updated_at'])]; } /** @param array|null $before */ private function audit(User $actor, string $action, SupplierEvaluation $evaluation, ?array $before): void { app(RequisitionWorkflow::class)->audit($actor, 'supplier_evaluation.'.$action, null, $before, $this->snapshot($evaluation)); } /** @param array $payload */ public function create(User $actor, int $supplierId, string $operation, array $payload): SupplierEvaluation { return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) { $actor = $this->actor($actor); abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.'); $supplier = Supplier::query()->findOrFail($supplierId); $this->participating($actor, $supplier->id); $payload = $this->normalise($actor, $supplier, $payload); $hash = hash('sha256', json_encode([$supplierId, $payload], JSON_THROW_ON_ERROR)); $existing = SupplierEvaluation::query()->where('operation_key', $operation)->first(); if ($existing) { abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado com outros dados.'); return $existing; } $evaluation = SupplierEvaluation::query()->create([ 'tenant_id' => $actor->tenant_id, 'supplier_id' => $supplier->id, 'department_id' => $this->department($actor, $payload), 'created_by' => $actor->id, 'operation_key' => $operation, 'creation_hash' => $hash, 'supplier_snapshot' => $supplier->only(['id', 'name', 'nuit']), 'contributors' => [$actor->id], 'payload' => $payload, ]); $this->audit($actor, 'created', $evaluation, null); return $evaluation; }); } /** @param array $payload */ public function save(User $actor, int $id, int $version, array $payload): SupplierEvaluation { return DB::transaction(function () use ($actor, $id, $version, $payload) { $actor = $this->actor($actor); $evaluation = $this->locked($actor, $id, $version); $this->participating($actor, $evaluation->supplier_id); abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.'); $payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload); if ($payload === $evaluation->payload) { return $evaluation; } $before = $this->snapshot($evaluation); $evaluation->fill(['payload' => $payload, 'department_id' => $this->department($actor, $payload), 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'version' => $version + 1, 'status' => 'draft', 'submitted_by' => null, 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null])->save(); $this->audit($actor, 'updated', $evaluation, $before); return $evaluation; }); } private function locked(User $actor, int $id, int $version): SupplierEvaluation { $evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id); if ($evaluation->version !== $version) { throw ValidationException::withMessages(['version' => 'A avaliação foi alterada. Os seus dados não foram sobrepostos; consulte a versão actual antes de comparar.']); } return $evaluation; } /** @param array $payload */ private function complete(array $payload, bool $approving): void { $errors = []; foreach (['scope' => 'Descreva o âmbito avaliado.', 'period_start' => 'Indique o início do período.', 'period_end' => 'Indique o fim do período.', 'conclusion' => 'Registe a conclusão.'] as $key => $message) { if (empty($payload[$key])) { $errors['payload.'.$key] = $message; } } if (! $payload['criteria']) { $errors['payload.criteria'] = 'Indique pelo menos um critério e a situação da evidência.'; } foreach ($payload['criteria'] as $index => $row) { if (trim($row['label']) === '' || trim($row['reason']) === '') { $errors['payload.criteria.'.$index.'.reason'] = 'Identifique e justifique cada critério.'; } if ($row['result'] === 'observed' && trim($row['evidence']) === '') { $errors['payload.criteria.'.$index.'.evidence'] = 'A observação precisa de uma referência de evidência.'; } if ($approving && in_array($row['result'], ['pending', 'insufficient'], true)) { $errors['payload.criteria.'.$index.'.result'] = 'Complete as evidências em falta ou devolva a avaliação para correcção.'; } } if ($payload['critical_occurrences'] !== '' && trim($payload['critical_evidence']) === '') { $errors['payload.critical_evidence'] = 'Indique a evidência das ocorrências críticas.'; } if ($errors) { throw ValidationException::withMessages($errors); } } public function transition(User $actor, int $id, int $version, string $action, ?string $reason): SupplierEvaluation { return DB::transaction(function () use ($actor, $id, $version, $action, $reason) { $actor = $this->actor($actor); $evaluation = $this->locked($actor, $id, $version); $this->participating($actor, $evaluation->supplier_id); $before = $this->snapshot($evaluation); $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload); if ($action === 'submit') { abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409); $this->complete($evaluation->payload, false); $evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]); } else { abort_unless(in_array($action, ['approve', 'return'], true) && $evaluation->status === 'review', 409); if ($reason === null || mb_strlen(trim($reason)) < 10) { throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']); } abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.'); if ($action === 'approve') { $this->complete($evaluation->payload, true); } $evaluation->fill(['status' => $action === 'approve' ? 'approved' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $reason]); } $evaluation->version = $version + 1; $evaluation->save(); $this->audit($actor, $action, $evaluation, $before); return $evaluation; }); } public function revise(User $actor, int $id, int $version): SupplierEvaluation { return DB::transaction(function () use ($actor, $id, $version) { $actor = $this->actor($actor); $previous = $this->locked($actor, $id, $version); $this->participating($actor, $previous->supplier_id); $this->normalise($actor, Supplier::query()->findOrFail($previous->supplier_id), $previous->payload); abort_unless($previous->status === 'approved', 409); $child = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->first(); if ($child) { abort_unless($child->created_by === $actor->id, 409, 'Já existe uma revisão posterior; consulte o histórico.'); return $child; } $evaluation = SupplierEvaluation::query()->create([ 'tenant_id' => $actor->tenant_id, 'supplier_id' => $previous->supplier_id, 'department_id' => $previous->department_id, 'created_by' => $actor->id, 'previous_id' => $id, 'revision' => $previous->revision + 1, 'operation_key' => (string) Str::uuid(), 'creation_hash' => hash('sha256', 'revision:'.$id.':'.$version), 'contributors' => [$actor->id], 'supplier_snapshot' => $previous->supplier_snapshot, 'payload' => $previous->payload, ]); $this->audit($actor, 'revised', $evaluation, null); return $evaluation; }); } }