commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 10:55:18 +02:00
parent 0235c7609e
commit 48347e2d3a
161 changed files with 11814 additions and 265 deletions
+1 -1
View File
@@ -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,
);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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
+40
View File
@@ -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'] : '');
}
}
+2 -2
View File
@@ -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.');
}
}
+2 -2
View File
@@ -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());
});
}
+2 -2
View File
@@ -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());
});
}
+2 -2
View File
@@ -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.']);
}
}
+1 -1
View File
@@ -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
+101
View File
@@ -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;
}
}
+142
View File
@@ -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()]);
}
}
+17 -1
View File
@@ -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,
],
];
});
}
+2 -2
View File
@@ -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.']);
}
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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.']);
}
+2
View File
@@ -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) {
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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';
}
}
+235
View File
@@ -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;
});
}
}
+4
View File
@@ -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();
}
+100
View File
@@ -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;
});
}
}