262 lines
14 KiB
PHP
262 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\Requisition;
|
|
use App\Models\Supplier;
|
|
use App\Models\SupplierEvaluation;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class SupplierEvaluationWorkflow
|
|
{
|
|
public const ROLES = [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead];
|
|
|
|
public function canPrepare(User $actor): bool
|
|
{
|
|
return $actor->tenant_id !== null && $actor->hasFunctions(self::ROLES)
|
|
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
|
&& (! $actor->departmentScoped() || $actor->department_id !== null);
|
|
}
|
|
|
|
public function canAct(User $actor, SupplierEvaluation $evaluation, string $action): bool
|
|
{
|
|
if (! app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation)) {
|
|
return false;
|
|
}
|
|
|
|
return match ($action) {
|
|
'edit' => in_array($evaluation->status, ['draft', 'returned'], true),
|
|
'review' => $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
|
|
'revise' => $evaluation->status === 'approved',
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
public function reader(User $actor): User
|
|
{
|
|
abort_unless(auth()->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<string,mixed> $payload
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $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);
|
|
|
|
$actor = app(CurrentActor::class)->lock($actor, self::ROLES);
|
|
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
|
|
|
return $actor;
|
|
}
|
|
|
|
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<string,mixed> */
|
|
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<string,mixed>|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<string,mixed> $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_unless($this->canPrepare($actor), 403, 'A preparação exige um perfil operacional e o 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<string,mixed> $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.');
|
|
abort_unless($this->canAct($actor, $evaluation, 'edit'), 403);
|
|
$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<string,mixed> $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);
|
|
abort_unless($this->canAct($actor, $evaluation, 'edit'), 403);
|
|
$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_unless($this->canAct($actor, $evaluation, 'review'), 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;
|
|
});
|
|
}
|
|
}
|