Files
compras/app/Services/OrganizationConfiguration.php
T
2026-11-02 08:08:05 +02:00

186 lines
14 KiB
PHP

<?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\Support\Facades\Schema;
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 (array_key_exists('work_contexts', $data)) {
abort_unless(Schema::hasColumn('organization_profiles', 'work_contexts'), 503, 'Instale a migração de contexto antes de associar perfis.');
$values['work_contexts'] = json_encode($data['work_contexts'], JSON_THROW_ON_ERROR);
}
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'], 'work_contexts' => $data['work_contexts'] ?? null];
} elseif ($command === 'work_contexts') {
abort_unless(Schema::hasColumn('tenants', 'work_context_policy'), 503, 'Instalação do contexto de trabalho pendente.');
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['work_context_policy']);
if (array_diff($data['enabled'], app(WorkContext::class)->purchased($actor)) !== []) {
throw ValidationException::withMessages(['enabled' => 'Escolha apenas opções contratadas para esta empresa.']);
}
$policy = ['revision' => ($company->work_context_policy['revision'] ?? 0) + 1, 'enabled' => $data['enabled']];
$company->update(['work_context_policy' => $policy]);
$after = ['work_context_policy' => $policy, 'reason' => $data['reason'], 'effect' => 'access_immediate_existing_processes_unchanged'];
} 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 === 'comparison') {
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['comparison_policy']);
$current = app(ComparisonPolicy::class)->company($company);
$policy = app(ComparisonPolicy::class)->checked([...$data, 'revision' => $current['revision'] + 1, 'configured' => true]);
$company->update(['comparison_policy' => $policy]);
$after = ['comparison_policy' => $policy, 'reason' => $data['reason'], 'scope' => 'new_requisitions_only'];
} elseif ($command === 'ai_plan') {
abort_unless(Schema::hasColumn('tenants', 'intelligence_policy'), 503, 'Instalação da configuração de inteligência pendente.');
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['intelligence_policy']);
$policy = app(IntelligencePolicy::class)->company($company);
$policy = [...$policy, 'revision' => $policy['revision'] + 1, 'configured' => true, 'ai_plan' => app(AiPreparationPlan::class)->checked($data)];
$company->update(['intelligence_policy' => $policy]);
$after = ['intelligence_policy' => $policy, 'reason' => $data['reason'], 'scope' => 'draft_preparations_only', 'external_processing_enabled' => false];
} elseif ($command === 'intelligence') {
abort_unless(Schema::hasColumn('tenants', 'intelligence_policy'), 503, 'Instalação da configuração de inteligência pendente.');
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['intelligence_policy']);
$policy = app(IntelligencePolicy::class)->next($company, $data);
$company->update(['intelligence_policy' => $policy]);
$after = ['intelligence_policy' => $policy, 'reason' => $data['reason'], 'scope' => 'future_consultations_only', 'external_processing_enabled' => false];
} elseif ($command === 'tax') {
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['default_tax_rate']);
$company->update(['default_tax_rate' => (string) BigDecimal::of($data['default_tax_rate'])->toScale(2)]);
$after = [...$company->only(['default_tax_rate']), 'reason' => $data['reason'], 'scope' => 'new_lines_only'];
} 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
{
Tenant::query()->whereKey($first->tenant_id)->update(['work_context_policy' => json_encode(['revision' => 1, 'enabled' => []], JSON_THROW_ON_ERROR)]);
$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()]);
}
}