join('permission_grants as g', 'g.company_id', '=', 'c.id') ->where('g.user_id', $user)->where('g.permission', 'company.view')->whereNull('g.unit_id') ->select('c.id', 'c.tenant_id', 'c.name')->orderBy('c.name')->get()->all(); } public function units(int $user, int $tenant, int $company): array { return DB::table('units')->where('tenant_id', $tenant)->where('company_id', $company) ->whereIn('id', $this->permissions->unitIds($user, $tenant, $company)) ->select('id', 'name')->orderBy('name')->get()->all(); } public function snapshot(Request $request): array { $user = $request->user(); $result = ['user' => $user ? ['id' => $user->id, 'name' => $user->name, 'email' => $user->email] : null, 'csrf' => csrf_token(), 'companies' => [], 'context' => null, 'setup_pending' => app()->environment('local') && ! DB::table('users')->exists()]; if (! $user) { return $result; } $result['companies'] = $this->companies($user->id); $selected = $request->session()->get('company_context'); if (! $selected) { return $result; } try { $this->context->selectFor($user->id, $selected['tenant_id'], $selected['company_id'], $selected['unit_id']); $company = DB::table('companies')->where('id', $selected['company_id'])->first(['id', 'name', 'tenant_id']); $unit = $selected['unit_id'] ? DB::table('units')->where('id', $selected['unit_id'])->first(['id', 'name']) : null; $grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id) ->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all(); $result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id), 'token' => $selected['token'], 'grants' => $grants, 'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context), 'module_id' => $selected['module_id'] ?? null, 'single_installation'=>app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true, 'geography_enabled'=>app()->environment('budget-testing')&&config('geography.enabled')===true, 'analysis_qa_enabled'=>app()->environment('budget-testing')&&config('analysis_qa.enabled')===true, 'bank_qa_enabled'=>app()->environment('budget-testing')&&config('bank_qa.enabled')===true, 'fiscal_rules_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_rules_qa.enabled')===true, 'internal_statements_enabled'=>app()->environment('budget-testing')&&config('internal_statements.enabled')===true, 'hr_personnel_enabled'=>app()->environment('budget-testing')&&config('hr_personnel.enabled')===true, 'remunerations_enabled'=>app()->environment('budget-testing')&&config('remunerations.enabled')===true, 'fiscal_obligations_enabled'=>app()->environment('budget-testing')&&config('fiscal_obligations.enabled')===true, 'fiscal_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_qa.enabled')===true, 'cash_enabled'=>app()->environment('budget-testing')&&config('cash_qa.enabled')===true, 'service_documents_enabled'=>app()->environment('budget-testing')&&config('service_documents.enabled')===true, 'commercial_quotes_enabled'=>app()->environment('budget-testing')&&config('commercial_quotes.enabled')===true, 'workshop_catalogue_enabled'=>app()->environment('budget-testing')&&config('workshop_catalogue.enabled')===true, 'workshop_schedule_enabled'=>app()->environment('budget-testing')&&config('workshop_schedule.enabled')===true, 'stock'=>['catalogue'=>app()->environment('budget-testing')&&config('stock_catalogue.enabled')===true,'inactive_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_inactive_enabled')===true,'completion'=>app()->environment('budget-testing')&&config('stock_completion.enabled')===true,'final'=>app()->environment('budget-testing')&&config('stock_operations.final_enabled')===true,'period_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_period_enabled')===true,'operations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('stock_operations.enabled')===true], 'purchasing' => ['internal_requisitions' => app()->environment('budget-testing') && config('internal_requisitions.qa_enabled') === true, 'receipts'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true, 'orders'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.orders_enabled')===true, 'supplier_obligations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.obligations_enabled')===true, 'supplier_invoices'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.invoices_enabled')===true, 'source_requests'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.requests_enabled')===true, 'suppliers'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.suppliers_enabled')===true, 'sourcing'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.sourcing_enabled')===true], 'accounting' => ['single_book'=>app()->environment('budget-testing')&&config('sgf_single_installation.ledger_enabled')===true,'stock_valuation_read' => app()->environment('budget-testing') && config('stock_ledger.read_sources.'.$selected['tenant_id'].':'.$selected['company_id'].':'.($selected['unit_id']??'')) !== null]]; if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) { $result['context']['module_id'] = null; $result['context']['token'] = bin2hex(random_bytes(24)); $request->session()->put('company_context', array_replace($selected, ['module_id'=>null, 'token'=>$result['context']['token']])); $result['notice'] = 'O acesso ao módulo mudou. Seleccione um módulo autorizado.'; } } catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) { if ($exception->getStatusCode() !== 403) { throw $exception; } $request->session()->forget('company_context'); $result['notice'] = 'O acesso ao contexto anterior mudou. Seleccione um contexto autorizado.'; } finally { $this->context->clear(); } return $result; } public function select(Request $request): array { $data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1', 'module_id'=>'nullable|string|max:40']); $unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null; // Selection is explicit, never silently carries the old unit into another company. $this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit); $module = $data['module_id'] ?? null; abort_if($module !== null && !in_array($module, array_column(app(\App\Shared\Context\ModuleNavigation::class)->available($this->context), 'id'), true), 403, 'Módulo não autorizado ou não configurado.'); $previous = $request->session()->get('company_context.module_id'); $oldSelection=$request->session()->get('company_context'); try { return DB::transaction(function()use($request,$data,$unit,$module,$previous){ $request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'], 'unit_id' => $unit, 'module_id'=>$module, 'token' => bin2hex(random_bytes(24))]); Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit, details:['module_id'=>$module, 'previous_module_id'=>$previous]); return $this->snapshot($request); }); } catch (\Throwable $failure) { if($oldSelection===null)$request->session()->forget('company_context'); else $request->session()->put('company_context',$oldSelection); throw $failure; } } public function renameCompany(Request $request, int $company): void { $data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']); $this->mutate($request, function ($ids, $user) use ($company, $data): void { abort_unless($company === $ids['company_id'], 403); $this->permissions->require($user, $ids['tenant_id'], $company, 'company.edit'); $before = DB::table('companies')->where('id', $company)->value('name'); DB::table('companies')->where('id', $company)->update(['name' => trim($data['name']), 'updated_at' => now()]); Audit::record('company.renamed', $user, $ids['tenant_id'], $company, details: ['before_name' => $before, 'after_name' => trim($data['name'])]); }); } public function renameUnit(Request $request, int $unit): void { $data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']); $this->mutate($request, function ($ids, $user) use ($unit, $data): void { abort_unless($this->context->unitId() === $unit, 409, 'Seleccione esta unidade antes de editar.'); $this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.edit', $unit); $before = DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->value('name'); abort_if($before === null, 403); DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->update(['name' => trim($data['name']), 'updated_at' => now()]); Audit::record('unit.renamed', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['before_name' => $before, 'after_name' => trim($data['name'])]); }); } public function createUnit(Request $request): int { $data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']); return $this->mutate($request, function ($ids, $user) use ($data): int { $this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.create'); $unit = DB::table('units')->insertGetId($ids + ['name' => trim($data['name']), 'created_at' => now(), 'updated_at' => now()]); Audit::record('unit.created', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['after_name' => trim($data['name'])]); foreach (Permissions::UNIT as $permission) { $this->insertGrant($user, $ids, $unit, $permission); Audit::record('access.granted_on_creation', $user, $ids['tenant_id'], $ids['company_id'], $unit, $user, ['permission' => $permission]); } return $unit; }); } public function accessList(Request $request): array { $ids = $this->context->identifiers(); $this->permissions->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'access.manage'); $allowedUnits = $this->context->permittedUnitIds(); $members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id') ->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get(); $grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id']) ->whereIn('permission', array_merge(Permissions::COMPANY, Permissions::UNIT)) ->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']); return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]]; } public function changeAccess(Request $request, bool $grant): void { $data = $request->validate(['email' => 'required|email|max:255', 'permission' => 'required|string', 'unit_id' => 'nullable|integer|min:1', 'reason' => 'required|string|max:500', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'user_id' => 'prohibited']); $this->mutate($request, function ($ids, $actor) use ($data, $grant): void { $company = $ids['company_id']; $tenant = $ids['tenant_id']; $this->permissions->require($actor, $tenant, $company, 'access.manage'); $unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null; $catalogue = $unit === null ? Permissions::COMPANY : Permissions::UNIT; abort_unless(in_array($data['permission'], $catalogue, true), 422, 'Permissão ou âmbito inválido.'); $this->permissions->require($actor, $tenant, $company, $data['permission'], $unit); if ($unit !== null) { $this->permissions->require($actor, $tenant, $company, 'unit.view', $unit); abort_unless(DB::table('units')->where('id', $unit)->where('tenant_id', $tenant)->where('company_id', $company)->exists(), 403); } $target = User::query()->where('email', mb_strtolower($data['email']))->first(); if (! $target) { throw ValidationException::withMessages(['email' => 'Não foi possível atribuir este acesso. Confirme o utilizador pelo procedimento local.']); } $membership = DB::table('company_memberships')->where($ids)->where('user_id', $target->id); if (! $membership->exists()) { abort_unless($grant && $data['permission'] === 'company.view' && $unit === null, 403, 'Conceda primeiro consulta da empresa.'); DB::table('company_memberships')->insert($ids + ['user_id' => $target->id]); } if ($grant && $data['permission'] !== 'company.view') { $this->permissions->require($target->id, $tenant, $company, 'company.view'); if ($unit && $data['permission'] === 'unit.edit') { $this->permissions->require($target->id, $tenant, $company, 'unit.view', $unit); } } $removed = []; if ($grant) { $this->insertGrant($target->id, $ids, $unit, $data['permission']); } else { $query = DB::table('permission_grants')->where($ids)->where('user_id', $target->id); if ($data['permission'] === 'company.view') { // Revocation deactivates all derived grants; reject partial authority rather than silently exceeding it. foreach ($query->get() as $existing) { $this->permissions->require($actor, $tenant, $company, $existing->permission, $existing->unit_id ? (int) $existing->unit_id : null); } $removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all(); $query->delete(); } else { if ($data['permission'] === 'unit.view') { if ($this->permissions->has($target->id, $tenant, $company, 'unit.edit', $unit)) { $this->permissions->require($actor, $tenant, $company, 'unit.edit', $unit); } $query->where('unit_id', $unit)->whereIn('permission', Permissions::UNIT); } else { $query->where('unit_id', $unit)->where('permission', $data['permission']); } $removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all(); $query->delete(); } } Audit::record($grant ? 'access.granted' : 'access.revoked', $actor, $tenant, $company, $unit, $target->id, ['permission' => $data['permission'], 'reason' => $data['reason'], 'grants' => $grant ? [['permission' => $data['permission'], 'unit_id' => $unit]] : $removed]); }); } private function insertGrant(int $user, array $ids, ?int $unit, string $permission): void { DB::table('permission_grants')->insertOrIgnore($ids + ['user_id' => $user, 'unit_id' => $unit, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]); } private function mutate(Request $request, callable $operation): mixed { $selected = $this->context->identifiers(); return DB::transaction(function () use ($request, $operation, $selected) { // All access changes and business mutations serialize on the same company row. DB::table('companies')->where('id', $selected['company_id'])->where('tenant_id', $selected['tenant_id'])->lockForUpdate()->firstOrFail(); $this->context->selectFor($request->user()->id, $selected['tenant_id'], $selected['company_id'], $this->context->unitId()); return $operation($selected, $request->user()->id); }); } }