@@ -36,7 +36,7 @@ final class CommonBudget
|
||||
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
|
||||
}
|
||||
},
|
||||
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(),
|
||||
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->first()?->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class CurrentActor
|
||||
throw new \LogicException('Revalidar o operador dentro da transacção.');
|
||||
}
|
||||
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id);
|
||||
abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403);
|
||||
abort_unless($roles === null || $user->hasFunctions($roles), 403);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class DocumentaryQualificationWorkflow
|
||||
}
|
||||
$answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason];
|
||||
}
|
||||
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]);
|
||||
|
||||
return $assessment;
|
||||
|
||||
@@ -59,7 +59,7 @@ class EquipmentWorkflow
|
||||
|
||||
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);
|
||||
return $requisition->tenant_id === $user->tenant_id && $user->hasFunctions([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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class EvaluationNavigation
|
||||
{
|
||||
public static function local(Request $request): bool
|
||||
{
|
||||
return app()->environment('payable-testing')
|
||||
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
|
||||
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true);
|
||||
}
|
||||
|
||||
public static function returnPath(mixed $intended, Request $request): string
|
||||
{
|
||||
$fallback = '/dashboard';
|
||||
if (! is_string($intended) || preg_match('/[\x00-\x20\\\\]/', $intended)) {
|
||||
return $fallback;
|
||||
}
|
||||
$parts = parse_url($intended);
|
||||
if ($parts === false) {
|
||||
return $fallback;
|
||||
}
|
||||
if (isset($parts['scheme']) || isset($parts['host'])) {
|
||||
$port = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
|
||||
if (($parts['scheme'] ?? '') !== $request->getScheme() || ($parts['host'] ?? '') !== $request->getHost() || $port !== $request->getPort()) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
$path = $parts['path'] ?? '';
|
||||
if (! str_starts_with($path, '/') || str_starts_with($path, '//')
|
||||
|| in_array($path, ['/login', '/logout', '/avaliar', '/avaliar/entrar'], true)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $path.(isset($parts['query']) ? '?'.$parts['query'] : '');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ExternalPaymentWorkflow
|
||||
{
|
||||
private function actor(User $user, int $tenantId): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->purchasing_role === PurchasingRole::Finance, 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
@@ -127,7 +127,7 @@ class ExternalPaymentWorkflow
|
||||
}
|
||||
$this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor);
|
||||
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by);
|
||||
if (! $author || $author->purchasing_role !== PurchasingRole::Finance) {
|
||||
if (! $author || ! $author->hasFunctions([PurchasingRole::Finance])) {
|
||||
$this->invalid('O autor do registo deixou de ser elegível.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ class FormalAdmission
|
||||
|
||||
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
|
||||
@@ -213,7 +213,7 @@ class FormalAdmission
|
||||
}
|
||||
}
|
||||
}
|
||||
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
|
||||
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ class FormalRecommendation
|
||||
|
||||
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
|
||||
@@ -191,7 +191,7 @@ class FormalRecommendation
|
||||
}
|
||||
}
|
||||
}
|
||||
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
|
||||
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class InvestmentWorkflow
|
||||
{
|
||||
private function admin(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
@@ -68,7 +68,7 @@ class InvestmentWorkflow
|
||||
if ($decision === 'approved') {
|
||||
$ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
|
||||
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
|
||||
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
|
||||
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
|
||||
throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class InvoiceWorkflow
|
||||
{
|
||||
private function access(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Finance, 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class OrganizationAccess
|
||||
{
|
||||
public const PERMISSIONS = [
|
||||
'requester' => 'Solicitar e acompanhar compras próprias',
|
||||
'department_head' => 'Supervisão e aprovação do departamento',
|
||||
'procurement' => 'Preparação e gestão operacional de compras',
|
||||
'finance' => 'Conferência e operações financeiras',
|
||||
'warehouse' => 'Recepção e operações de armazém',
|
||||
'admin' => 'Administrar a empresa, utilizadores e parametrização',
|
||||
];
|
||||
|
||||
public function active(?int $tenant): bool
|
||||
{
|
||||
return $tenant !== null && Schema::hasTable('organization_access') && DB::table('organization_access')->where('tenant_id', $tenant)->where('active', true)->exists();
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function permissions(User $user): array
|
||||
{
|
||||
if ($user->tenant_id === null) {
|
||||
return [];
|
||||
}
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
|
||||
}
|
||||
$rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
|
||||
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
|
||||
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']);
|
||||
$permissions = [];
|
||||
foreach ($rows as $row) {
|
||||
$permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)];
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
/** @param list<PurchasingRole|string> $functions */
|
||||
public function allows(User $user, array $functions): bool
|
||||
{
|
||||
$requested = array_map(fn ($f) => $f instanceof PurchasingRole ? $f->value : $f, $functions);
|
||||
|
||||
return $user->tenant_id !== null && count(array_intersect($this->permissions($user), $requested)) > 0;
|
||||
}
|
||||
|
||||
public function maintenanceOnly(User $user): bool
|
||||
{
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return false;
|
||||
}
|
||||
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
||||
|
||||
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
|
||||
}
|
||||
|
||||
public function departmentScoped(User $user): bool
|
||||
{
|
||||
$permissions = $this->permissions($user);
|
||||
|
||||
return in_array('department_head', $permissions, true) && ! array_intersect($permissions, ['admin', 'procurement', 'finance']);
|
||||
}
|
||||
|
||||
public function canRequest(User $user): bool
|
||||
{
|
||||
return $user->tenant_id !== null && ($this->active($user->tenant_id) ? $this->allows($user, ['requester']) : ($user->purchasing_role !== null && $user->purchasing_role !== PurchasingRole::Configured));
|
||||
}
|
||||
|
||||
public function profileLabel(User $user): string
|
||||
{
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return $user->purchasing_role?->label() ?? 'Utilizador';
|
||||
}
|
||||
|
||||
return DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
|
||||
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->orderBy('p.name')->pluck('p.name')->unique()->implode(' · ');
|
||||
}
|
||||
|
||||
/** @param list<string> $fields
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function actorSnapshot(User $user, array $fields): array
|
||||
{
|
||||
$snapshot = $user->only($fields);
|
||||
if ($this->active($user->tenant_id)) {
|
||||
if (array_key_exists('purchasing_role', $snapshot)) {
|
||||
$snapshot['purchasing_role'] = $user->purchasing_role->value ?? '';
|
||||
}
|
||||
$snapshot['profile_label'] = $this->profileLabel($user);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class OrganizationConfiguration
|
||||
{
|
||||
/** @param array<string,mixed> $data
|
||||
* @return array<string,int|bool>
|
||||
*/
|
||||
public static function rules(array $data): array
|
||||
{
|
||||
$first = BigDecimal::of($data['first_limit'])->multipliedBy(100)->toInt();
|
||||
$second = BigDecimal::of($data['second_limit'])->multipliedBy(100)->toInt();
|
||||
if ($second < $first) {
|
||||
throw ValidationException::withMessages(['second_limit' => 'O segundo limite deve ser igual ou superior ao primeiro.']);
|
||||
}
|
||||
|
||||
return ['first_limit_minor' => $first, 'second_limit_minor' => $second, 'approval_includes_tax' => (bool) $data['includes_tax'], 'rules_provisional' => false];
|
||||
}
|
||||
|
||||
public function administrator(User $actor): User
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
|
||||
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
abort_unless($actor->hasFunctions(['admin']), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function execute(User $actor, string $command, array $data): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $command, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = $this->administrator($actor);
|
||||
DB::table('organization_access')->insertOrIgnore(['tenant_id' => $actor->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
|
||||
if ((int) $data['version'] !== (int) $state->version) {
|
||||
throw ValidationException::withMessages(['version' => 'A parametrização foi alterada. Actualize e compare antes de guardar.']);
|
||||
}
|
||||
$before = null;
|
||||
if ($command === 'profile') {
|
||||
$profile = isset($data['id']) ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('id', $data['id'])->first() : null;
|
||||
abort_if(isset($data['id']) && ! $profile, 404);
|
||||
if ($profile && in_array('admin', $data['permissions'], true) && DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('profile_id', $profile->id)->where('maintenance', true)->exists()) {
|
||||
throw ValidationException::withMessages(['permissions' => 'Retire as atribuições de manutenção antes de dar administração a este perfil.']);
|
||||
}
|
||||
$before = $profile ? (array) $profile : null;
|
||||
$values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()];
|
||||
if ($profile) {
|
||||
DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]);
|
||||
$id = $profile->id;
|
||||
} else {
|
||||
$id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]);
|
||||
}
|
||||
$after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']];
|
||||
} elseif ($command === 'user') {
|
||||
$department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists();
|
||||
abort_unless($department, 422, 'Departamento não autorizado.');
|
||||
$user = new User;
|
||||
$user->forceFill(['tenant_id' => $actor->tenant_id, 'department_id' => $data['department_id'], 'name' => $data['name'], 'email' => $data['email'], 'password' => $data['password'], 'purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$this->assign($actor, $user->id, $data['profile_ids'], false, null, $data['reason']);
|
||||
$after = [...$user->only(['id', 'name', 'email', 'department_id']), 'profile_ids' => $data['profile_ids'], 'reason' => $data['reason']];
|
||||
} elseif ($command === 'assign') {
|
||||
$target = User::query()->where('tenant_id', $actor->tenant_id)->whereKey((int) $data['user_id'])->firstOrFail();
|
||||
$before = ['user_id' => $target->id, 'memberships' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $target->id)->get(['profile_id', 'maintenance', 'expires_at', 'reason'])->map(fn ($m) => (array) $m)->all()];
|
||||
if (($data['maintenance'] ?? false) && ! $state->active) {
|
||||
throw ValidationException::withMessages(['maintenance' => 'Active a parametrização antes de atribuir manutenção temporária.']);
|
||||
}
|
||||
$this->assign($actor, $target->id, $data['profile_ids'], $data['maintenance'] ?? false, $data['expires_at'] ?? null, $data['reason']);
|
||||
$after = ['user_id' => $target->id, 'profile_ids' => $data['profile_ids'], 'maintenance' => $data['maintenance'] ?? false, 'expires_at' => $data['expires_at'] ?? null, 'reason' => $data['reason']];
|
||||
} elseif ($command === 'rules') {
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id);
|
||||
$before = $company->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
|
||||
$after = [...self::rules($data), 'reason' => $data['reason']];
|
||||
$company->fill(self::rules($data))->save();
|
||||
} else {
|
||||
abort_unless($command === 'activate', 404);
|
||||
if (Tenant::query()->findOrFail($actor->tenant_id)->rules_provisional) {
|
||||
throw ValidationException::withMessages(['rules' => 'Defina e guarde as regras de aprovação da empresa antes da activação.']);
|
||||
}
|
||||
DB::table('organization_access')->where('id', $state->id)->update(['active' => true]);
|
||||
$after = ['active' => true, 'reason' => $data['reason']];
|
||||
}
|
||||
if ($state->active || $command === 'activate') {
|
||||
$this->twoAdministrators((int) $actor->tenant_id);
|
||||
}
|
||||
DB::table('organization_access')->where('id', $state->id)->update(['version' => $state->version + 1, 'updated_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'organization.'.$command, null, $before, $after);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<int> $profiles */
|
||||
private function assign(User $actor, int $user, array $profiles, bool $maintenance, ?string $expires, string $reason): void
|
||||
{
|
||||
$ids = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $profiles)->pluck('id')->all();
|
||||
if (count($ids) !== count($profiles)) {
|
||||
throw ValidationException::withMessages(['profile_ids' => 'Escolha apenas perfis desta empresa.']);
|
||||
}
|
||||
if ($maintenance && ($expires === null || now()->parse($expires)->isPast())) {
|
||||
throw ValidationException::withMessages(['expires_at' => 'O acesso de manutenção exige uma data futura de expiração.']);
|
||||
}
|
||||
if ($maintenance && DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $ids)->pluck('permissions')->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
|
||||
throw ValidationException::withMessages(['profile_ids' => 'A manutenção temporária não pode administrar os acessos da empresa.']);
|
||||
}
|
||||
DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $user)->delete();
|
||||
foreach ($ids as $id) {
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $actor->tenant_id, 'user_id' => $user, 'profile_id' => $id, 'maintenance' => $maintenance, 'expires_at' => $expires === null ? null : CarbonImmutable::parse($expires)->utc()->format('Y-m-d H:i:s'), 'reason' => $reason, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function twoAdministrators(int $tenant): void
|
||||
{
|
||||
$count = 0;
|
||||
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
|
||||
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
if ($count < 2) {
|
||||
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function initialise(User $first, User $second): void
|
||||
{
|
||||
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
foreach ([$first, $second] as $user) {
|
||||
$this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.');
|
||||
}
|
||||
DB::table('organization_access')->insert(['tenant_id' => $first->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\ProposalOpening;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
@@ -28,7 +31,7 @@ class ProcurementReviewReport
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
}
|
||||
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
abort_unless(in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId);
|
||||
$comparison = app(ProposalComparison::class)->inspect($request);
|
||||
$rows = [];
|
||||
@@ -59,6 +62,14 @@ class ProcurementReviewReport
|
||||
$opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||
$award = PurchaseAward::query()->where('requisition_id', $request->id)->first();
|
||||
$order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first();
|
||||
$receipts = $order ? GoodsReceipt::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'received_on', 'delivery_note'])->map(fn (GoodsReceipt $receipt) => [
|
||||
'id' => $receipt->id, 'received_on' => $receipt->received_on->format('Y-m-d'), 'delivery_note' => $receipt->delivery_note,
|
||||
])->all() : [];
|
||||
$financeVisible = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
|
||||
$invoices = $financeVisible && $order ? Invoice::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'number', 'status', 'issued_on', 'total_minor', 'snapshot']) : collect();
|
||||
$payments = $financeVisible ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->orderBy('id')->get(['id', 'invoice_id', 'status', 'amount_minor', 'currency', 'paid_on'])->map(fn ($payment) => [
|
||||
'id' => $payment->id, 'invoice_id' => $payment->invoice_id, 'status' => $payment->status, 'amount_minor' => (string) $payment->amount_minor, 'currency' => $payment->currency, 'paid_on' => $payment->paid_on,
|
||||
])->all() : null;
|
||||
|
||||
return [
|
||||
'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name,
|
||||
@@ -71,6 +82,11 @@ class ProcurementReviewReport
|
||||
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
|
||||
'award' => $award?->only(['id', 'status', 'total_minor']),
|
||||
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
|
||||
'fulfilment' => [
|
||||
'receipts' => $receipts, 'finance_visible' => $financeVisible,
|
||||
'invoices' => $financeVisible ? $invoices->map(fn (Invoice $invoice) => ['id' => $invoice->id, 'number' => $invoice->number, 'status' => $invoice->status, 'issued_on' => $invoice->issued_on, 'total_minor' => (string) $invoice->total_minor, 'currency' => $invoice->snapshot['currency'] ?? null])->all() : null,
|
||||
'payments' => $payments,
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ class ProposalComparison
|
||||
public function eligibleRole(User $user, Requisition $requisition): bool
|
||||
{
|
||||
return $user->tenant_id === $requisition->tenant_id &&
|
||||
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
|
||||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
|
||||
($user->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]) ||
|
||||
($user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $weights */
|
||||
|
||||
@@ -13,7 +13,7 @@ class PurchaseControlPolicyWorkflow
|
||||
{
|
||||
private function admin(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
@@ -62,7 +62,7 @@ class PurchaseControlPolicyWorkflow
|
||||
if ($decision === 'approved') {
|
||||
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
|
||||
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
|
||||
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
|
||||
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class PurchasePlanningWorkflow
|
||||
{
|
||||
private function manager(User $actor): bool
|
||||
{
|
||||
return in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
return $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
}
|
||||
|
||||
private function responsible(User $actor, int $id): void
|
||||
|
||||
@@ -29,7 +29,7 @@ class PurchaseWorkflow
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class PurchaseWorkflow
|
||||
$steps = $award->approvals()->get();
|
||||
$next = $steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value &&
|
||||
return $next !== null && $user->hasFunctions([$next->role]) &&
|
||||
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
|
||||
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ class PurchaseWorkflow
|
||||
return DB::transaction(function () use ($user, $award) {
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
// An already committed issue is returned, never re-created or re-snapshotted.
|
||||
@@ -281,7 +281,7 @@ class PurchaseWorkflow
|
||||
}
|
||||
foreach ($steps as $index => $step) {
|
||||
$user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id);
|
||||
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
|
||||
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || ! $user->hasFunctions([$step->role]) ||
|
||||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
|
||||
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class RequisitionWorkflow
|
||||
return DB::transaction(function () use ($user, $data, $existing) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user);
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
|
||||
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
|
||||
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
|
||||
}
|
||||
@@ -76,6 +77,7 @@ class RequisitionWorkflow
|
||||
DB::transaction(function () use ($user, $requisition) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user);
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
if ($user->department_id !== $requisition->department_id) {
|
||||
|
||||
@@ -27,7 +27,7 @@ class StockWorkflow
|
||||
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
|
||||
public function record(User $actor, array $data): StockOperation
|
||||
{
|
||||
abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403);
|
||||
abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $data) {
|
||||
// Same first lock as receipts: includes empty balances and concurrent receipts.
|
||||
|
||||
@@ -24,7 +24,7 @@ class SupplierBankWorkflow
|
||||
public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail
|
||||
{
|
||||
$this->actor($user, $supplier);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
|
||||
return DB::transaction(function () use ($user, $supplier, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
@@ -44,7 +44,7 @@ class SupplierBankWorkflow
|
||||
|
||||
public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void
|
||||
{
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Finance && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Finance]) && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
|
||||
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
|
||||
throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class SupplierBankWorkflow
|
||||
}
|
||||
foreach ($reviews as $review) {
|
||||
$user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by);
|
||||
if (! $user || $user->purchasing_role !== PurchasingRole::Finance || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
|
||||
if (! $user || ! $user->hasFunctions([PurchasingRole::Finance]) || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
|
||||
return 'blocked';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?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 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);
|
||||
|
||||
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<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_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<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.');
|
||||
$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);
|
||||
$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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SupplierImprovementWorkflow
|
||||
{
|
||||
public function canParticipate(User $actor, SupplierEvaluation $evaluation): bool
|
||||
{
|
||||
$guard = app(ConflictGuard::class);
|
||||
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, null)) {
|
||||
return false;
|
||||
}
|
||||
foreach (PurchaseOrder::query()->whereIn('id', $evaluation->payload['order_ids'])->pluck('requisition_id') as $id) {
|
||||
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, (int) $id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function snapshot(SupplierImprovement $measure): array
|
||||
{
|
||||
return $measure->only(['id', 'supplier_evaluation_id', 'created_by', 'responsible_id', 'title', 'description', 'due_on', 'version', 'status', 'contributors', 'result', 'evidence', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
private function assignee(User $actor, SupplierEvaluation $evaluation, int $id): User
|
||||
{
|
||||
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($id);
|
||||
if (! $user || ! $user->hasFunctions(SupplierEvaluationWorkflow::ROLES)
|
||||
|| ($user->departmentScoped() && ($user->department_id === null || $user->department_id !== $evaluation->department_id))
|
||||
|| ! $this->canParticipate($user, $evaluation)) {
|
||||
throw ValidationException::withMessages(['responsible_id' => 'Escolha um responsável autorizado desta empresa, sem conflito no âmbito avaliado.']);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierImprovement
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
|
||||
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
|
||||
abort_unless($evaluation->status === 'approved', 409, 'Associe medidas à revisão aprovada da avaliação.');
|
||||
abort_unless($this->canParticipate($actor, $evaluation), 403, 'Existe um conflito no âmbito desta avaliação.');
|
||||
if ($command === 'create') {
|
||||
$plan = ['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => (int) $data['responsible_id'], 'due_on' => $data['due_on']];
|
||||
$hash = hash('sha256', json_encode([$evaluationId, $plan], JSON_THROW_ON_ERROR));
|
||||
$existing = SupplierImprovement::query()->where('operation_key', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado.');
|
||||
|
||||
return $existing;
|
||||
}
|
||||
$responsible = $this->assignee($actor, $evaluation, $plan['responsible_id']);
|
||||
$measure = SupplierImprovement::query()->create([...$plan, 'tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
|
||||
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
|
||||
'contributors' => array_values(array_unique([$actor->id, $responsible->id]))]);
|
||||
$before = null;
|
||||
} else {
|
||||
$measure = SupplierImprovement::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
|
||||
if ($measure->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A medida foi alterada. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
|
||||
}
|
||||
abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.');
|
||||
$before = $this->snapshot($measure);
|
||||
if ($command === 'plan') {
|
||||
abort_unless(in_array($measure->status, ['open', 'returned'], true), 409);
|
||||
$responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']);
|
||||
$measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'],
|
||||
'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]);
|
||||
if (! $measure->isDirty()) {
|
||||
return $measure;
|
||||
}
|
||||
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
|
||||
throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']);
|
||||
}
|
||||
} elseif (in_array($command, ['progress', 'submit'], true)) {
|
||||
abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403);
|
||||
$measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']);
|
||||
if ($command === 'submit') {
|
||||
if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') {
|
||||
throw ValidationException::withMessages(['evidence' => 'Registe resultado e referência da evidência antes de pedir encerramento.']);
|
||||
}
|
||||
$measure->status = 'review';
|
||||
}
|
||||
if (! $measure->isDirty()) {
|
||||
return $measure;
|
||||
}
|
||||
} else {
|
||||
abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409);
|
||||
abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
|
||||
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
|
||||
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']);
|
||||
}
|
||||
$measure->fill(['status' => $command === 'close' ? 'closed' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
|
||||
}
|
||||
$measure->version++;
|
||||
$measure->save();
|
||||
}
|
||||
$after = $this->snapshot($measure);
|
||||
if ($command === 'plan') {
|
||||
$after['change_reason'] = $data['reason'];
|
||||
}
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'supplier_improvement.'.$command, null, $before, $after);
|
||||
|
||||
return $measure;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ final class SupplierPayables
|
||||
|
||||
public function can(User $u, string $action): bool
|
||||
{
|
||||
if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SupplierResponseWorkflow
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function snapshot(SupplierResponse $record): array
|
||||
{
|
||||
return $record->only(['id', 'supplier_evaluation_id', 'created_by', 'version', 'status', 'payload', 'contributors', 'decision', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalise(array $payload): array
|
||||
{
|
||||
return ['title' => $payload['title'], 'received_on' => $payload['received_on'] ?? null, 'source_reference' => $payload['source_reference'] ?? '', 'statement' => $payload['statement'] ?? ''];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierResponse
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
|
||||
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
|
||||
abort_unless($evaluation->status === 'approved', 409, 'O contraditório refere-se à revisão aprovada.');
|
||||
abort_unless(app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation), 403, 'Existe conflito no âmbito da avaliação.');
|
||||
if ($command === 'create') {
|
||||
$payload = $this->normalise($data['payload']);
|
||||
$hash = hash('sha256', json_encode([$evaluationId, $payload], JSON_THROW_ON_ERROR));
|
||||
$existing = SupplierResponse::query()->where('operation_key', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido já utilizado com outro conteúdo.');
|
||||
|
||||
return $existing;
|
||||
}
|
||||
$record = SupplierResponse::query()->create(['tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
|
||||
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
|
||||
'payload' => $payload, 'contributors' => [$actor->id]]);
|
||||
$before = null;
|
||||
} else {
|
||||
$record = SupplierResponse::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
|
||||
if ($record->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'O registo foi alterado. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
|
||||
}
|
||||
abort_if($record->status === 'decided', 409, 'A conclusão já está registada e preservada.');
|
||||
$before = $this->snapshot($record);
|
||||
if ($command === 'save') {
|
||||
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
|
||||
$payload = $this->normalise($data['payload']);
|
||||
if ($payload === $record->payload) {
|
||||
return $record;
|
||||
}
|
||||
$record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
|
||||
} elseif ($command === 'submit') {
|
||||
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
|
||||
$errors = [];
|
||||
foreach (['received_on', 'source_reference', 'statement'] as $key) {
|
||||
if (trim($record->payload[$key] ?? '') === '') {
|
||||
$errors['payload.'.$key] = 'Complete a data, a origem documental e os argumentos recebidos antes de submeter.';
|
||||
}
|
||||
}
|
||||
if (($record->payload['received_on'] ?? '') > today()->toDateString()) {
|
||||
$errors['payload.received_on'] = 'A data de recepção não pode ser futura.';
|
||||
}
|
||||
if ($errors) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
$record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
|
||||
} else {
|
||||
abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409);
|
||||
abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
|
||||
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
|
||||
throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']);
|
||||
}
|
||||
if ($command === 'decide') {
|
||||
if (in_array($data['outcome'], ['accepted', 'partially_accepted'], true) && mb_strlen(trim($data['follow_up'] ?? '')) < 10) {
|
||||
throw ValidationException::withMessages(['follow_up' => 'Descreva como será dado seguimento ao acolhimento dos argumentos.']);
|
||||
}
|
||||
$record->decision = ['outcome' => $data['outcome'], 'reason' => $data['reason'], 'follow_up' => $data['follow_up'] ?? '', 'response_reference' => $data['response_reference'] ?? ''];
|
||||
}
|
||||
$record->fill(['status' => $command === 'decide' ? 'decided' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
|
||||
}
|
||||
$record->version++;
|
||||
$record->save();
|
||||
}
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'supplier_response.'.$command, null, $before, $this->snapshot($record));
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user