src/Controller/AdminController.php line 517

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. /*
  4.  * To change this license header, choose License Headers in Project Properties.
  5.  * To change this template file, choose Tools | Templates
  6.  * and open the template in the editor.
  7.  */
  8. use App\Config\GstockAutoMappings;
  9. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  10. use Symfony\Component\HttpFoundation\Session\Session;
  11. use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
  12. use Symfony\Component\HttpFoundation\JsonResponse;
  13. use Symfony\Component\HttpFoundation\StreamedResponse;
  14. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  15. use Symfony\Component\HttpFoundation\Request;
  16. use Doctrine\ORM\EntityManagerInterface;
  17. use App\Entity\Empresa;
  18. use App\Entity\Usuario;
  19. use App\Entity\ConexionBD;
  20. use App\Service\LicenseContractService;
  21. use App\Service\SuperuserProvisioningService;
  22. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  23. use Symfony\Component\HttpFoundation\File\UploadedFile;
  24. use Symfony\Component\Routing\Annotation\Route;
  25. /**
  26.  * Description of AdminController
  27.  *
  28.  * @author joseangelparra
  29.  */
  30. class AdminController extends AbstractController
  31. {
  32.     //put your code here
  33.     private const AZURE_DI_API_VERSION '2024-11-30';
  34.     private const EXTRACTOR_TYPE_AZURE_DI 'azure-di';
  35.     private const EXTRACTOR_TYPE_AZURE_OPENAI 'azure-openai';
  36.     private const GLOBAL_IS_ADMIN_SUPERADMIN 3;
  37.     private const GLOBAL_IS_ADMIN_SUPERUSER 4;
  38.     private const GLOBAL_IS_ADMINS = [self::GLOBAL_IS_ADMIN_SUPERADMINself::GLOBAL_IS_ADMIN_SUPERUSER];
  39.     private const PROFILE_SUPERUSER 0;
  40.     private const PROFILE_SUPERADMIN 1;
  41.     private $params;
  42.     private LicenseContractService $licenseContractService;
  43.     public function __construct(ParameterBagInterface $paramsLicenseContractService $licenseContractService)
  44.     {
  45.         $this->session = new Session();
  46.         $this->params $params;
  47.         $this->licenseContractService $licenseContractService;
  48.     }
  49.     #[Route('/'name'login')]
  50.     public function Login(AuthenticationUtils $authenticationUtils)
  51.     {
  52.         $error $authenticationUtils->getLastAuthenticationError();
  53.         $lastUsername $authenticationUtils->getLastUSername();
  54.         return $this->render('base.html.twig', array(
  55.             'error' => $error,
  56.             'last_username' => $lastUsername
  57.         ));
  58.     }
  59.     public function changePWD(UserPasswordHasherInterface $encoderEntityManagerInterface $em)
  60.     {
  61.         $user $em->getRepository(Usuario::class)->find(10);
  62.         $encoded $encoder->hashPassword($user'docuManager2025');
  63.         $user->setPassword($encoded);
  64.         $em->persist($user);
  65.         $flush $em->flush();
  66.         die();
  67.     }
  68.     public function checkUserExists(Request $requestEntityManagerInterface $em)
  69.     {
  70.         $email trim((string)$request->request->get("user"''));
  71.         $empresaId = (int)$request->request->get("empresa_id"0);
  72.         $connectionId = (int)$request->request->get("connection"0);
  73.         if ($email === '') {
  74.             return new JsonResponse(['exists' => false'message' => 'user_required'], 400);
  75.         }
  76.         $qb $em->createQueryBuilder()
  77.             ->select('u.id')
  78.             ->from(Usuario::class, 'u')
  79.             ->where('LOWER(u.email) = :email')
  80.             ->setParameter('email'strtolower($email));
  81.         // Validar por ambito de empresa/conexion cuando se proporciona.
  82.         if ($empresaId 0) {
  83.             $qb->andWhere('IDENTITY(u.empresa) = :empresaId')
  84.                 ->setParameter('empresaId'$empresaId);
  85.         } elseif ($connectionId 0) {
  86.             $qb->andWhere('u.connection = :connectionId')
  87.                 ->setParameter('connectionId'$connectionId);
  88.         }
  89.         $existsScoped = !empty($qb->setMaxResults(1)->getQuery()->getArrayResult());
  90.         // Mantiene dato global para compatibilidad/diagnostico.
  91.         $existsGlobal = !empty($em->createQueryBuilder()
  92.             ->select('u2.id')
  93.             ->from(Usuario::class, 'u2')
  94.             ->where('LOWER(u2.email) = :email')
  95.             ->setParameter('email'strtolower($email))
  96.             ->setMaxResults(1)
  97.             ->getQuery()
  98.             ->getArrayResult());
  99.         return new JsonResponse([
  100.             'exists' => $existsScoped,
  101.             'exists_scoped' => $existsScoped,
  102.             'exists_global' => $existsGlobal,
  103.             'scope' => ($empresaId 'empresa' : ($connectionId 'connection' 'global')),
  104.         ]);
  105.     }
  106.     private function azurePortForTenant(int $tenantIdint $base 12000int $max 20999): int
  107.     {
  108.         $port $base $tenantId;
  109.         if ($port $max) {
  110.             $range max(1$max $base);
  111.             $port  $base + ($tenantId $range);
  112.         }
  113.         return $port;
  114.     }
  115.     private function azureDiWorkdirFromEnv(): string
  116.     {
  117.         return rtrim((string)($_ENV['AZURE_DI_WORKDIR'] ?? ''), '/');
  118.     }
  119.     private function azureDiPhpBaseUrl(Request $request): string
  120.     {
  121.         $host strtolower(trim((string)$request->getHost()));
  122.         if ($host !== '' && preg_match('/^(console|newdev)\.(.+)$/'$host$matches) === 1) {
  123.             $mappedSubdomain $matches[1] === 'console' 'app' 'platform';
  124.             return 'https://' $mappedSubdomain '.' $matches[2] . '/newdocu/public';
  125.         }
  126.         return rtrim(trim((string)($_ENV['AZURE_DI_DOCU_PHP_BASE_URL'] ?? '')), '/');
  127.     }
  128.     private function clampDocuMaxThreads($valueint $default 4): int
  129.     {
  130.         if ($value === null || $value === '') {
  131.             return $default;
  132.         }
  133.         $threads = (int)$value;
  134.         if ($threads 1) {
  135.             return 1;
  136.         }
  137.         if ($threads 16) {
  138.             return 16;
  139.         }
  140.         return $threads;
  141.     }
  142.     private function normalizeOcrMode($value): string
  143.     {
  144.         $mode strtolower(trim((string)$value));
  145.         if ($mode === 'plus_glm') {
  146.             return 'plus_glm';
  147.         }
  148.         if ($mode === 'v2_zxing') {
  149.             return 'v2_zxing';
  150.         }
  151.         return 'base';
  152.     }
  153.     private function isOcrV2Available(): bool
  154.     {
  155.         $forced $_ENV['OCR_V2_AVAILABLE'] ?? null;
  156.         if ($forced !== null && $forced !== '') {
  157.             return in_array(strtolower((string)$forced), ['1''true''yes''on'], true);
  158.         }
  159.         $ocrV2Dir = (string)($_ENV['OCR_V2_DIR'] ?? '/home/docunecta/ocr/documanager_ocr+zxing');
  160.         if ($ocrV2Dir === '') {
  161.             return false;
  162.         }
  163.         if ($this->isPathAllowedByOpenBaseDir($ocrV2Dir)) {
  164.             return @is_dir($ocrV2Dir);
  165.         }
  166.         $sudoResult $this->checkDirExistsWithSudo($ocrV2Dir);
  167.         return $sudoResult ?? false;
  168.     }
  169.     private function isOcrPlusAvailable(): bool
  170.     {
  171.         $forced $_ENV['OCR_PLUS_AVAILABLE'] ?? null;
  172.         if ($forced !== null && $forced !== '') {
  173.             return in_array(strtolower((string)$forced), ['1''true''yes''on'], true);
  174.         }
  175.         $ocrPlusDir = (string)($_ENV['OCR_PLUS_DIR'] ?? '/home/docunecta/ocr/TEST_documanager_ocr+glm+zxing');
  176.         if ($ocrPlusDir === '') {
  177.             return false;
  178.         }
  179.         if ($this->isPathAllowedByOpenBaseDir($ocrPlusDir)) {
  180.             return @is_dir($ocrPlusDir);
  181.         }
  182.         $sudoResult $this->checkDirExistsWithSudo($ocrPlusDir);
  183.         return $sudoResult ?? false;
  184.     }
  185.     private function isPathAllowedByOpenBaseDir(string $path): bool
  186.     {
  187.         $openBaseDir = (string)ini_get('open_basedir');
  188.         if ($openBaseDir === '') {
  189.             return true;
  190.         }
  191.         $normalizedPath rtrim(str_replace('\\''/'$path), '/') . '/';
  192.         $allowedParts array_filter(array_map('trim'explode(PATH_SEPARATOR$openBaseDir)));
  193.         foreach ($allowedParts as $allowed) {
  194.             $normalizedAllowed rtrim(str_replace('\\''/', (string)$allowed), '/') . '/';
  195.             if (str_starts_with($normalizedPath$normalizedAllowed)) {
  196.                 return true;
  197.             }
  198.         }
  199.         return false;
  200.     }
  201.     private function checkDirExistsWithSudo(string $path): ?bool
  202.     {
  203.         $cmd 'sudo -n /usr/bin/test -d ' escapeshellarg($path) . ' && echo 1 || echo 0';
  204.         $out = @shell_exec($cmd ' 2>/dev/null');
  205.         if ($out === null) {
  206.             return null;
  207.         }
  208.         $out trim($out);
  209.         if ($out === '1') {
  210.             return true;
  211.         }
  212.         if ($out === '0') {
  213.             return false;
  214.         }
  215.         return null;
  216.     }
  217.     private function ensureMailMonitorService(int $companyIdstring $dbHoststring $dbPortstring $dbUserstring $dbPassstring $dbNamestring $filesPath): void
  218.     {
  219.         $workdir $_ENV['MAIL_IMPORTER_WORKDIR'] ?? '';
  220.         $script  $_ENV['MAIL_IMPORTER_SCRIPT'] ?? '';
  221.         $envFile $_ENV['MAIL_IMPORTER_COMMON_ENV'] ?? '';
  222.         $missing = [];
  223.         if ($workdir === ''$missing[] = 'MAIL_IMPORTER_WORKDIR';
  224.         if ($script === ''$missing[] = 'MAIL_IMPORTER_SCRIPT';
  225.         if ($envFile === ''$missing[] = 'MAIL_IMPORTER_COMMON_ENV';
  226.         if (trim($filesPath) === ''$missing[] = 'FILES_PATH';
  227.         if (!empty($missing)) {
  228.             $msg 'Mail Monitor no creado: faltan variables de entorno: ' implode(', '$missing);
  229.             $this->addFlash('warning'$msg);
  230.             error_log($msg);
  231.             return;
  232.         }
  233.         $serviceName $companyId "-mailMonitor.service";
  234.         $timerName   $companyId "-mailMonitor.timer";
  235.         $filesRoot   rtrim($filesPath'/') . '/' $companyId;
  236.         $serviceContent = <<<EOT
  237. [Unit]
  238. Description=DocuManager Mail Monitor (empresa {$companyId})
  239. Wants=network-online.target
  240. After=network-online.target
  241. [Service]
  242. Type=oneshot
  243. WorkingDirectory={$workdir}
  244. EnvironmentFile={$envFile}
  245. Environment=MAIL_IMPORTER_DB_HOST={$dbHost}
  246. Environment=MAIL_IMPORTER_DB_PORT={$dbPort}
  247. Environment=MAIL_IMPORTER_DB_USER={$dbUser}
  248. Environment=MAIL_IMPORTER_DB_PASS={$dbPass}
  249. Environment=MAIL_IMPORTER_DB_NAME={$dbName}
  250. Environment=MAIL_IMPORTER_FILES_ROOT={$filesRoot}
  251. ExecStart=/usr/bin/python3 {$script} --once --log-level INFO
  252. User=docunecta
  253. Group=docunecta
  254. [Install]
  255. WantedBy=multi-user.target
  256. EOT;
  257.         $timerContent = <<<EOT
  258. [Unit]
  259. Description=DocuManager Mail Monitor Timer (empresa {$companyId})
  260. [Timer]
  261. OnBootSec=2min
  262. OnUnitActiveSec=15min
  263. AccuracySec=1min
  264. Persistent=true
  265. [Install]
  266. WantedBy=timers.target
  267. EOT;
  268.         $tmpServicePath "/tmp/{$serviceName}";
  269.         $tmpTimerPath   "/tmp/{$timerName}";
  270.         file_put_contents($tmpServicePath$serviceContent);
  271.         file_put_contents($tmpTimerPath$timerContent);
  272.         @chmod($tmpServicePath0644);
  273.         @chmod($tmpTimerPath0644);
  274.         $cmds = [
  275.             "sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
  276.             "sudo /bin/mv {$tmpTimerPath} /etc/systemd/system/{$timerName}",
  277.             "sudo /bin/systemctl daemon-reload",
  278.             "sudo /bin/systemctl enable --now {$timerName}",
  279.         ];
  280.         foreach ($cmds as $cmd) {
  281.             $out \shell_exec($cmd " 2>&1");
  282.             if ($out !== null) {
  283.                 error_log("MAIL-MONITOR CMD: $cmd\n$out");
  284.             }
  285.         }
  286.     }
  287.     private function disableMailMonitorService(int $companyId): void
  288.     {
  289.         $serviceName $companyId "-mailMonitor.service";
  290.         $timerName   $companyId "-mailMonitor.timer";
  291.         $cmds = [
  292.             "sudo /bin/systemctl disable --now {$timerName}",
  293.             "sudo /bin/systemctl stop {$serviceName}",
  294.             "sudo /bin/rm -f /etc/systemd/system/{$serviceName}",
  295.             "sudo /bin/rm -f /etc/systemd/system/{$timerName}",
  296.             "sudo /bin/systemctl daemon-reload",
  297.         ];
  298.         foreach ($cmds as $cmd) {
  299.             $out \shell_exec($cmd " 2>&1");
  300.             if ($out !== null) {
  301.                 error_log("MAIL-MONITOR CMD: $cmd\n$out");
  302.             }
  303.         }
  304.     }
  305.     private function removeCompanyFilesDir(int $companyId): void
  306.     {
  307.         $base $_ENV['FILES_PATH'] ?? '';
  308.         if (trim($base) === '') {
  309.             $msg 'No se ha podido borrar carpeta de files: falta FILES_PATH en entorno.';
  310.             $this->addFlash('warning'$msg);
  311.             error_log($msg);
  312.             return;
  313.         }
  314.         $base rtrim($base'/');
  315.         $target $base '/' $companyId;
  316.         if (!is_dir($target)) {
  317.             return;
  318.         }
  319.         $errors = [];
  320.         $it = new \RecursiveIteratorIterator(
  321.             new \RecursiveDirectoryIterator($target\FilesystemIterator::SKIP_DOTS),
  322.             \RecursiveIteratorIterator::CHILD_FIRST
  323.         );
  324.         foreach ($it as $file) {
  325.             try {
  326.                 if ($file->isDir()) {
  327.                     @rmdir($file->getPathname());
  328.                 } else {
  329.                     @unlink($file->getPathname());
  330.                 }
  331.             } catch (\Throwable $e) {
  332.                 $errors[] = $e->getMessage();
  333.             }
  334.         }
  335.         @rmdir($target);
  336.         if (!empty($errors)) {
  337.             $msg 'No se pudo borrar completamente la carpeta de files: ' $target;
  338.             $this->addFlash('warning'$msg);
  339.             error_log($msg ' | ' implode(' | '$errors));
  340.         }
  341.     }
  342.     private function removeCompanyLogsDir(int $companyId): void
  343.     {
  344.         $base $_ENV['LOGS_ROOT'] ?? '';
  345.         if (trim($base) === '') {
  346.             $msg 'No se ha podido borrar carpeta de logs: falta LOGS_ROOT en entorno.';
  347.             $this->addFlash('warning'$msg);
  348.             error_log($msg);
  349.             return;
  350.         }
  351.         $base rtrim($base'/');
  352.         $target $base '/' $companyId;
  353.         if (!is_dir($target)) {
  354.             return;
  355.         }
  356.         $errors = [];
  357.         $it = new \RecursiveIteratorIterator(
  358.             new \RecursiveDirectoryIterator($target\FilesystemIterator::SKIP_DOTS),
  359.             \RecursiveIteratorIterator::CHILD_FIRST
  360.         );
  361.         foreach ($it as $file) {
  362.             try {
  363.                 if ($file->isDir()) {
  364.                     @rmdir($file->getPathname());
  365.                 } else {
  366.                     @unlink($file->getPathname());
  367.                 }
  368.             } catch (\Throwable $e) {
  369.                 $errors[] = $e->getMessage();
  370.             }
  371.         }
  372.         @rmdir($target);
  373.         if (!empty($errors)) {
  374.             $msg 'No se pudo borrar completamente la carpeta de logs: ' $target;
  375.             $this->addFlash('warning'$msg);
  376.             error_log($msg ' | ' implode(' | '$errors));
  377.         }
  378.     }
  379.     private function getCompanyUserMediaPaths(\mysqli $mysqli): array
  380.     {
  381.         $paths = [];
  382.         try {
  383.             $res $mysqli->query("SELECT avatar, firma FROM users");
  384.             if ($res) {
  385.                 while ($r $res->fetch_assoc()) {
  386.                     foreach (['avatar''firma'] as $col) {
  387.                         $path trim((string)($r[$col] ?? ''));
  388.                         if ($path === '' || str_starts_with($path'http')) {
  389.                             continue;
  390.                         }
  391.                         $paths[$path] = true;
  392.                     }
  393.                 }
  394.                 $res->free();
  395.             }
  396.         } catch (\Throwable $e) {
  397.             error_log('Error leyendo usuarios para borrar media: ' $e->getMessage());
  398.         }
  399.         return array_keys($paths);
  400.     }
  401.     private function callPlatformCleanup(int $companyId, array $mediaPaths = []): void
  402.     {
  403.         $url $_ENV['PLATFORM_CLEANUP_URL'] ?? '';
  404.         $secret $_ENV['PLATFORM_CLEANUP_SECRET'] ?? '';
  405.         if (trim($url) === '' || trim($secret) === '') {
  406.             $msg 'Cleanup no ejecutado: faltan PLATFORM_CLEANUP_URL o PLATFORM_CLEANUP_SECRET.';
  407.             $this->addFlash('warning'$msg);
  408.             error_log($msg);
  409.             return;
  410.         }
  411.         $payload = [
  412.             'company_id' => $companyId,
  413.             'media_paths' => array_values($mediaPaths),
  414.         ];
  415.         $body json_encode($payloadJSON_UNESCAPED_SLASHES);
  416.         if ($body === false) {
  417.             error_log('Cleanup: no se pudo serializar payload JSON.');
  418.             return;
  419.         }
  420.         $ts time();
  421.         $sig hash_hmac('sha256'$ts "\n" $body$secret);
  422.         $ch curl_init();
  423.         curl_setopt($chCURLOPT_URL$url);
  424.         curl_setopt($chCURLOPT_POSTtrue);
  425.         curl_setopt($chCURLOPT_POSTFIELDS$body);
  426.         curl_setopt($chCURLOPT_HTTPHEADER, [
  427.             'Content-Type: application/json',
  428.             'X-Docu-Timestamp: ' $ts,
  429.             'X-Docu-Signature: ' $sig,
  430.         ]);
  431.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  432.         curl_setopt($chCURLOPT_TIMEOUT8);
  433.         curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse);
  434.         curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  435.         $response curl_exec($ch);
  436.         $error curl_error($ch);
  437.         $code curl_getinfo($chCURLINFO_HTTP_CODE);
  438.         curl_close($ch);
  439.         if ($error || $code 200 || $code >= 300) {
  440.             $msg 'Cleanup remoto fallo (' $code '): ' . ($error ?: (string)$response);
  441.             $this->addFlash('warning'$msg);
  442.             error_log($msg);
  443.         }
  444.     }
  445.     #[Route('/list'name'list')]
  446.     public function List(EntityManagerInterface $entityManager)
  447.     {
  448.         if (!$this->getUser() || !is_object($this->getUser())) {
  449.             return $this->redirectToRoute('logout');
  450.         }
  451.         $empresas $entityManager->getRepository(Empresa::class)->findAll();
  452.         return $this->render('listusers.html.twig', array(
  453.             'empresas' => $empresas,
  454.         ));
  455.     }
  456.     public function appChanges(Request $requestEntityManagerInterface $em)
  457.     {
  458.         if (!$this->getUser() || !is_object($this->getUser())) {
  459.             return $this->redirectToRoute('logout');
  460.         }
  461.         $defaults = [
  462.             'extract_process' => 1,
  463.             'mail_monitor_process' => 0,
  464.             'ocr_process' => 0,
  465.             's3_global' => 0,
  466.             'release_notice' => [
  467.                 'enabled' => 0,
  468.                 'starts_at' => '',
  469.                 'ends_at' => '',
  470.             ],
  471.             'custom_notices' => [],
  472.         ];
  473.         $toggles $this->loadAppChangesToggles($em$defaults);
  474.         if ($request->isMethod('POST')) {
  475.             $incoming = [
  476.                 'extract_process' => (int)($request->request->get('extract_process'0) ? 0),
  477.                 'mail_monitor_process' => (int)($request->request->get('mail_monitor_process'0) ? 0),
  478.                 'ocr_process' => (int)($request->request->get('ocr_process'0) ? 0),
  479.                 's3_global' => (int)($request->request->get('s3_global'0) ? 0),
  480.                 'release_notice' => $this->normalizeReleaseNoticeFromRequest($request),
  481.                 'custom_notices' => $this->normalizeCustomNoticesFromRequest($request),
  482.             ];
  483.             $toggles array_merge($defaults$incoming);
  484.             try {
  485.                 $result $this->saveAppChangesToggles($em$toggles);
  486.                 if (($result['updated'] ?? 0) > 0) {
  487.                     $this->addFlash('success''Ajustes App guardados correctamente.');
  488.                 } else {
  489.                     $this->addFlash('info''No se ha guardado nada porque no había cambios.');
  490.                 }
  491.             } catch (\Throwable $e) {
  492.                 $this->addFlash('danger''Error al guardar Ajustes App: ' $e->getMessage());
  493.             }
  494.             return $this->redirectToRoute('app_changes');
  495.         }
  496.         return $this->render('app_changes/index.html.twig', [
  497.             'toggles' => $toggles,
  498.             'module_options' => $this->appChangesModuleOptions(),
  499.         ]);
  500.     }
  501.     private function appChangesModuleOptions(): array
  502.     {
  503.         return [
  504.             'extraccion' => 'Extracción',
  505.             'mailMonitor' => 'Monitor de correo',
  506.             'etiquetas' => 'Etiquetas',
  507.             'calendario' => 'Calendario',
  508.             'calExt' => 'Calendario externo',
  509.             'estados' => 'Estados',
  510.             'subida' => 'Subida',
  511.             'busquedaNatural' => 'Búsqueda natural',
  512.             'lineas' => 'Líneas',
  513.             'agora' => 'Agora',
  514.             'gstock' => 'Gstock',
  515.             'expowin' => 'Expowin',
  516.             'prinex' => 'Prinex',
  517.         ];
  518.     }
  519.     private function normalizeReleaseNoticeFromRequest(Request $request): array
  520.     {
  521.         $enabled = (int)($request->request->get('release_notice_enabled'0) ? 0);
  522.         $startsAt trim((string)$request->request->get('release_notice_starts_at'''));
  523.         $endsAt trim((string)$request->request->get('release_notice_ends_at'''));
  524.         if ($enabled === && $startsAt === '') {
  525.             $startsAt date('Y-m-d H:i:s');
  526.         }
  527.         return [
  528.             'enabled' => $enabled,
  529.             'starts_at' => $startsAt,
  530.             'ends_at' => $endsAt,
  531.         ];
  532.     }
  533.     private function normalizeReleaseNotice($value): array
  534.     {
  535.         if (is_array($value)) {
  536.             return [
  537.                 'enabled' => (int)(!empty($value['enabled']) ? 0),
  538.                 'starts_at' => trim((string)($value['starts_at'] ?? '')),
  539.                 'ends_at' => trim((string)($value['ends_at'] ?? '')),
  540.             ];
  541.         }
  542.         return [
  543.             'enabled' => (int)($value 0),
  544.             'starts_at' => '',
  545.             'ends_at' => '',
  546.         ];
  547.     }
  548.     private function loadAppChangesToggles(EntityManagerInterface $em, array $defaults): array
  549.     {
  550.         $empresas $em->getRepository(Empresa::class)
  551.             ->createQueryBuilder('e')
  552.             ->leftJoin('e.conexionBD''c')
  553.             ->addSelect('c')
  554.             ->where('c.id IS NOT NULL')
  555.             ->orderBy('e.id''ASC')
  556.             ->getQuery()
  557.             ->getResult();
  558.         foreach ($empresas as $empresa) {
  559.             $cx $empresa->getConexionBD();
  560.             if ($cx === null) {
  561.                 continue;
  562.             }
  563.             $mysqli = @new \mysqli(
  564.                 $cx->getDbUrl(),
  565.                 $cx->getDbUser(),
  566.                 $cx->getDbPassword(),
  567.                 $cx->getDbName(),
  568.                 (int)$cx->getDbPort()
  569.             );
  570.             if ($mysqli->connect_error) {
  571.                 continue;
  572.             }
  573.             $mysqli->set_charset('utf8mb4');
  574.             $stmt $mysqli->prepare("SELECT valor FROM parametros WHERE nombre = ? ORDER BY id ASC LIMIT 1");
  575.             if (!$stmt) {
  576.                 $mysqli->close();
  577.                 continue;
  578.             }
  579.             $name 'app_dynamic_toggles';
  580.             $stmt->bind_param('s'$name);
  581.             $stmt->execute();
  582.             $res $stmt->get_result();
  583.             $row $res $res->fetch_assoc() : null;
  584.             $stmt->close();
  585.             $mysqli->close();
  586.             if (!is_array($row) || !isset($row['valor'])) {
  587.                 continue;
  588.             }
  589.             $decoded json_decode((string)$row['valor'], true);
  590.             if (!is_array($decoded)) {
  591.                 continue;
  592.             }
  593.             $normalized $defaults;
  594.             foreach (['extract_process''mail_monitor_process''ocr_process''s3_global'] as $key) {
  595.                 if (array_key_exists($key$decoded)) {
  596.                     $normalized[$key] = (int)($decoded[$key] ? 0);
  597.                 }
  598.             }
  599.             if (array_key_exists('release_notice'$decoded)) {
  600.                 $normalized['release_notice'] = $this->normalizeReleaseNotice($decoded['release_notice']);
  601.             }
  602.             $normalized['custom_notices'] = $this->normalizeCustomNotices($decoded['custom_notices'] ?? []);
  603.             return $normalized;
  604.         }
  605.         return $defaults;
  606.     }
  607.     private function saveAppChangesToggles(EntityManagerInterface $em, array $toggles): array
  608.     {
  609.         $normalizedToggles = [
  610.             'extract_process' => (int)(!empty($toggles['extract_process']) ? 0),
  611.             'mail_monitor_process' => (int)(!empty($toggles['mail_monitor_process']) ? 0),
  612.             'ocr_process' => (int)(!empty($toggles['ocr_process']) ? 0),
  613.             's3_global' => (int)(!empty($toggles['s3_global']) ? 0),
  614.             'release_notice' => $this->normalizeReleaseNotice($toggles['release_notice'] ?? []),
  615.             'custom_notices' => $this->normalizeCustomNotices($toggles['custom_notices'] ?? []),
  616.         ];
  617.         $payload json_encode($normalizedTogglesJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES);
  618.         if ($payload === false) {
  619.             throw new \RuntimeException('No se pudo serializar app_dynamic_toggles.');
  620.         }
  621.         $empresas $em->getRepository(Empresa::class)
  622.             ->createQueryBuilder('e')
  623.             ->leftJoin('e.conexionBD''c')
  624.             ->addSelect('c')
  625.             ->where('c.id IS NOT NULL')
  626.             ->orderBy('e.id''ASC')
  627.             ->getQuery()
  628.             ->getResult();
  629.         $errors = [];
  630.         $updated 0;
  631.         $unchanged 0;
  632.         foreach ($empresas as $empresa) {
  633.             $cx $empresa->getConexionBD();
  634.             if ($cx === null) {
  635.                 continue;
  636.             }
  637.             $mysqli = @new \mysqli(
  638.                 $cx->getDbUrl(),
  639.                 $cx->getDbUser(),
  640.                 $cx->getDbPassword(),
  641.                 $cx->getDbName(),
  642.                 (int)$cx->getDbPort()
  643.             );
  644.             $label sprintf('[%d] %s', (int)$empresa->getId(), (string)$empresa->getName());
  645.             if ($mysqli->connect_error) {
  646.                 $errors[] = $label ': ' $mysqli->connect_error;
  647.                 continue;
  648.             }
  649.             $mysqli->set_charset('utf8mb4');
  650.             try {
  651.                 $name 'app_dynamic_toggles';
  652.                 $select $mysqli->prepare("SELECT valor FROM parametros WHERE nombre = ? LIMIT 1");
  653.                 if (!$select) {
  654.                     throw new \RuntimeException($mysqli->error);
  655.                 }
  656.                 $select->bind_param('s'$name);
  657.                 if (!$select->execute()) {
  658.                     throw new \RuntimeException($select->error);
  659.                 }
  660.                 $res $select->get_result();
  661.                 $row $res $res->fetch_assoc() : null;
  662.                 $select->close();
  663.                 $current is_array($row) ? (string)($row['valor'] ?? '') : null;
  664.                 if ($current !== null && $current === $payload) {
  665.                     $unchanged++;
  666.                     continue;
  667.                 }
  668.                 $upsert $mysqli->prepare("
  669.                     INSERT INTO parametros (nombre, valor)
  670.                     VALUES (?, ?)
  671.                     ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  672.                 ");
  673.                 if (!$upsert) {
  674.                     throw new \RuntimeException($mysqli->error);
  675.                 }
  676.                 $upsert->bind_param('ss'$name$payload);
  677.                 if (!$upsert->execute()) {
  678.                     throw new \RuntimeException($upsert->error);
  679.                 }
  680.                 $upsert->close();
  681.                 $updated++;
  682.             } catch (\Throwable $e) {
  683.                 $errors[] = $label ': ' $e->getMessage();
  684.             } finally {
  685.                 $mysqli->close();
  686.             }
  687.         }
  688.         if (!empty($errors)) {
  689.             throw new \RuntimeException('No se pudieron guardar los ajustes en todas las empresas: ' implode(' | '$errors));
  690.         }
  691.         return [
  692.             'updated' => $updated,
  693.             'unchanged' => $unchanged,
  694.         ];
  695.     }
  696.     private function normalizeCustomNoticesFromRequest(Request $request): array
  697.     {
  698.         $ids $request->request->all('custom_id');
  699.         $rowIndexes $request->request->all('custom_row_index');
  700.         $enabledMap $request->request->all('custom_enabled');
  701.         $scopes $request->request->all('custom_scope');
  702.         $scopeModuleMap $request->request->all('custom_scope_module');
  703.         $modules $request->request->all('custom_module');
  704.         $messages $request->request->all('custom_message');
  705.         $max max(
  706.             is_array($ids) ? count($ids) : 0,
  707.             is_array($scopes) ? count($scopes) : 0,
  708.             is_array($modules) ? count($modules) : 0,
  709.             is_array($messages) ? count($messages) : 0
  710.         );
  711.         if (!is_array($ids)) {
  712.             $ids = [];
  713.         }
  714.         if (!is_array($rowIndexes)) {
  715.             $rowIndexes = [];
  716.         }
  717.         if (!is_array($enabledMap)) {
  718.             $enabledMap = [];
  719.         }
  720.         if (!is_array($scopes)) {
  721.             $scopes = [];
  722.         }
  723.         if (!is_array($scopeModuleMap)) {
  724.             $scopeModuleMap = [];
  725.         }
  726.         if (!is_array($modules)) {
  727.             $modules = [];
  728.         }
  729.         if (!is_array($messages)) {
  730.             $messages = [];
  731.         }
  732.         $getByKeyOrPosition = static function (array $valuesstring $keyint $position): string {
  733.             if (array_key_exists($key$values)) {
  734.                 return trim((string)$values[$key]);
  735.             }
  736.             if (array_key_exists($position$values)) {
  737.                 return trim((string)$values[$position]);
  738.             }
  739.             return '';
  740.         };
  741.         $rowKeys = [];
  742.         foreach ($rowIndexes as $value) {
  743.             $rowKeys[] = (string)$value;
  744.         }
  745.         foreach ([$ids$scopes$scopeModuleMap$modules$messages] as $arr) {
  746.             foreach (array_keys($arr) as $key) {
  747.                 $rowKeys[] = (string)$key;
  748.             }
  749.         }
  750.         $rowKeys array_values(array_unique($rowKeys));
  751.         if (count($rowKeys) === && $max 0) {
  752.             for ($i 0$i $max$i++) {
  753.                 $rowKeys[] = (string)$i;
  754.             }
  755.         }
  756.         $rows = [];
  757.         foreach ($rowKeys as $position => $rowKey) {
  758.             $id $getByKeyOrPosition($ids$rowKey$position);
  759.             $scope strtolower($getByKeyOrPosition($scopes$rowKey$position));
  760.             if ($scope === '') {
  761.                 $scope 'global';
  762.             }
  763.             $module $getByKeyOrPosition($modules$rowKey$position);
  764.             $message $getByKeyOrPosition($messages$rowKey$position);
  765.             $rowIndex $rowKey;
  766.             $enabled = isset($enabledMap[$rowIndex]) ? 0;
  767.             if (isset($scopeModuleMap[$rowIndex])) {
  768.                 $scope 'module';
  769.             }
  770.             if ($id === '' && $message === '') {
  771.                 continue;
  772.             }
  773.             if ($id === '') {
  774.                 $id 'custom_' . ($position 1);
  775.             }
  776.             if ($scope !== 'module') {
  777.                 $scope 'global';
  778.                 $module '';
  779.             }
  780.             $rows[] = [
  781.                 'id' => $id,
  782.                 'enabled' => $enabled,
  783.                 'scope' => $scope,
  784.                 'module' => $module,
  785.                 'message' => $message,
  786.             ];
  787.         }
  788.         return $this->normalizeCustomNotices($rows);
  789.     }
  790.     private function normalizeCustomNotices($rows): array
  791.     {
  792.         if (!is_array($rows)) {
  793.             return [];
  794.         }
  795.         $normalized = [];
  796.         foreach ($rows as $row) {
  797.             if (!is_array($row)) {
  798.                 continue;
  799.             }
  800.             $id trim((string)($row['id'] ?? ''));
  801.             $message trim((string)($row['message'] ?? ''));
  802.             if ($id === '' || $message === '') {
  803.                 continue;
  804.             }
  805.             $scope strtolower(trim((string)($row['scope'] ?? 'global')));
  806.             $module trim((string)($row['module'] ?? ''));
  807.             if ($scope !== 'module') {
  808.                 $scope 'global';
  809.                 $module '';
  810.             }
  811.             $normalized[] = [
  812.                 'id' => $id,
  813.                 'enabled' => (int)(!empty($row['enabled']) ? 0),
  814.                 'scope' => $scope,
  815.                 'module' => $module,
  816.                 'message' => $message,
  817.             ];
  818.         }
  819.         return array_values($normalized);
  820.     }
  821.     public function superusersList(EntityManagerInterface $em)
  822.     {
  823.         if (!$this->getUser() || !is_object($this->getUser())) {
  824.             return $this->redirectToRoute('logout');
  825.         }
  826.         $rows $em->getConnection()->fetchAllAssociative("
  827.             SELECT
  828.                 LOWER(email) AS email_key,
  829.                 MIN(email) AS email,
  830.                 COUNT(*) AS empresas_count,
  831.                 SUM(CASE WHEN status IN ('ENABLED', '1', 1) THEN 1 ELSE 0 END) AS enabled_count
  832.             FROM users
  833.             WHERE is_admin IN (" self::GLOBAL_IS_ADMIN_SUPERADMIN ", " self::GLOBAL_IS_ADMIN_SUPERUSER ")
  834.             GROUP BY LOWER(email)
  835.             ORDER BY MIN(email) ASC
  836.         ");
  837.         foreach ($rows as &$row) {
  838.             $email $this->normalizeSuperuserEmail((string)($row['email'] ?? ''));
  839.             $links $em->getConnection()->fetchAllAssociative(
  840.                 "SELECT empresa_id, is_admin
  841.                  FROM users
  842.                  WHERE LOWER(email) = :email AND is_admin IN (" self::GLOBAL_IS_ADMIN_SUPERADMIN ", " self::GLOBAL_IS_ADMIN_SUPERUSER ")
  843.                  ORDER BY empresa_id ASC",
  844.                 ['email' => $email]
  845.             );
  846.             $empresaIds = [];
  847.             foreach ($links as $link) {
  848.                 $empresaIds[] = (int)($link['empresa_id'] ?? 0);
  849.             }
  850.             $empresaIds array_values(array_unique(array_filter($empresaIds)));
  851.             $account $this->resolveGlobalAccountType($em$email$empresaIds$links);
  852.             $row['account_type'] = $account['key'];
  853.             $row['account_type_label'] = $account['label'];
  854.         }
  855.         unset($row);
  856.         return $this->render('superusers/list.html.twig', [
  857.             'superusers' => $rows,
  858.         ]);
  859.     }
  860.     public function superusersNew(Request $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  861.     {
  862.         if (!$this->getUser() || !is_object($this->getUser())) {
  863.             return $this->redirectToRoute('logout');
  864.         }
  865.         $empresas $em->getRepository(Empresa::class)->findAll();
  866.         $formData = [
  867.             'email' => '',
  868.             'status' => 'ENABLED',
  869.             'empresas' => [],
  870.             'account_type' => 'superadmin',
  871.         ];
  872.         if ($request->isMethod('POST')) {
  873.             $email $this->normalizeSuperuserEmail((string)$request->request->get('email'''));
  874.             $password = (string)$request->request->get('password''');
  875.             $enabled strtoupper((string)$request->request->get('status''ENABLED')) === 'ENABLED';
  876.             $empresaIds $this->readEmpresaIdsFromRequest($request);
  877.             $targetProfile $this->targetProfileFromRequest($request);
  878.             $formData['email'] = $email;
  879.             $formData['status'] = $enabled 'ENABLED' 'DISABLED';
  880.             $formData['empresas'] = $empresaIds;
  881.             $formData['account_type'] = $this->accountTypeFromProfile($targetProfile);
  882.             try {
  883.                 $service->createSuperuser($email$password$empresaIds$enabled$targetProfile);
  884.                 $this->addFlash('success''Usuario global creado correctamente.');
  885.                 return $this->redirectToRoute('superusers_list');
  886.             } catch (\Throwable $e) {
  887.                 $this->addFlash('danger'$e->getMessage());
  888.             }
  889.         }
  890.         return $this->render('superusers/form.html.twig', [
  891.             'title' => 'Crear usuario global',
  892.             'is_edit' => false,
  893.             'form' => $formData,
  894.             'empresas' => $empresas,
  895.         ]);
  896.     }
  897.     public function superusersEdit(string $emailRequest $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  898.     {
  899.         if (!$this->getUser() || !is_object($this->getUser())) {
  900.             return $this->redirectToRoute('logout');
  901.         }
  902.         $email $this->normalizeSuperuserEmail($email);
  903.         $existingRows $em->getConnection()->fetchAllAssociative(
  904.             "SELECT empresa_id, status, is_admin
  905.              FROM users
  906.              WHERE LOWER(email) = :email AND is_admin IN (" self::GLOBAL_IS_ADMIN_SUPERADMIN ", " self::GLOBAL_IS_ADMIN_SUPERUSER ")",
  907.             ['email' => $email]
  908.         );
  909.         if (count($existingRows) === 0) {
  910.             $this->addFlash('warning''Usuario global no encontrado.');
  911.             return $this->redirectToRoute('superusers_list');
  912.         }
  913.         $selectedEmpresaIds = [];
  914.         $hasEnabled false;
  915.         $isAdminCandidates = [];
  916.         foreach ($existingRows as $row) {
  917.             $empresaId = (int)$row['empresa_id'];
  918.             $selectedEmpresaIds[] = $empresaId;
  919.             $hasEnabled $hasEnabled || strtoupper((string)$row['status']) === 'ENABLED';
  920.             $isAdminCandidates[] = (int)($row['is_admin'] ?? 0);
  921.         }
  922.         $selectedEmpresaIds array_values(array_unique($selectedEmpresaIds));
  923.         sort($selectedEmpresaIds);
  924.         $isAdminCandidates array_values(array_unique($isAdminCandidates));
  925.         $resolvedProfile count($isAdminCandidates) === 1
  926.             $this->profileFromIsAdmin((int)$isAdminCandidates[0])
  927.             : self::PROFILE_SUPERADMIN;
  928.         $accountType $this->accountTypeFromProfile($resolvedProfile);
  929.         $empresas $em->getRepository(Empresa::class)->findAll();
  930.         $formData = [
  931.             'email' => $email,
  932.             'status' => $hasEnabled 'ENABLED' 'DISABLED',
  933.             'empresas' => $selectedEmpresaIds,
  934.             'account_type' => $accountType,
  935.         ];
  936.         if ($request->isMethod('POST')) {
  937.             $newPassword trim((string)$request->request->get('password'''));
  938.             $enabled strtoupper((string)$request->request->get('status''ENABLED')) === 'ENABLED';
  939.             $empresaIds $this->readEmpresaIdsFromRequest($request);
  940.             $targetProfile $this->targetProfileFromRequest($request);
  941.             $formData['status'] = $enabled 'ENABLED' 'DISABLED';
  942.             $formData['empresas'] = $empresaIds;
  943.             $formData['account_type'] = $this->accountTypeFromProfile($targetProfile);
  944.             try {
  945.                 $service->updateSuperuser($email, ($newPassword === '' null $newPassword), $empresaIds$enabled$targetProfile);
  946.                 $this->addFlash('success''Usuario global actualizado correctamente.');
  947.                 return $this->redirectToRoute('superusers_list');
  948.             } catch (\Throwable $e) {
  949.                 $this->addFlash('danger'$e->getMessage());
  950.             }
  951.         }
  952.         return $this->render('superusers/form.html.twig', [
  953.             'title' => 'Editar usuario global',
  954.             'is_edit' => true,
  955.             'form' => $formData,
  956.             'empresas' => $empresas,
  957.         ]);
  958.     }
  959.     public function superusersDeleteLink(string $emailint $empresaIdRequest $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  960.     {
  961.         if (!$this->getUser() || !is_object($this->getUser())) {
  962.             return $this->redirectToRoute('logout');
  963.         }
  964.         if (!$request->isMethod('POST')) {
  965.             return $this->redirectToRoute('superusers_list');
  966.         }
  967.         $email $this->normalizeSuperuserEmail($email);
  968.         $rows $em->getConnection()->fetchAllAssociative(
  969.             "SELECT empresa_id, status, is_admin
  970.              FROM users
  971.              WHERE LOWER(email) = :email AND is_admin IN (" self::GLOBAL_IS_ADMIN_SUPERADMIN ", " self::GLOBAL_IS_ADMIN_SUPERUSER ")",
  972.             ['email' => $email]
  973.         );
  974.         if (count($rows) === 0) {
  975.             $this->addFlash('warning''Usuario global no encontrado.');
  976.             return $this->redirectToRoute('superusers_list');
  977.         }
  978.         $targetEmpresaIds = [];
  979.         $enabled false;
  980.         $isAdminCandidates = [];
  981.         foreach ($rows as $row) {
  982.             $currentEmpresaId = (int)$row['empresa_id'];
  983.             if ($currentEmpresaId !== $empresaId) {
  984.                 $targetEmpresaIds[] = $currentEmpresaId;
  985.             }
  986.             $enabled $enabled || strtoupper((string)$row['status']) === 'ENABLED';
  987.             $isAdminCandidates[] = (int)($row['is_admin'] ?? 0);
  988.         }
  989.         $isAdminCandidates array_values(array_unique($isAdminCandidates));
  990.         $resolvedProfile count($isAdminCandidates) === 1
  991.             $this->profileFromIsAdmin((int)$isAdminCandidates[0])
  992.             : self::PROFILE_SUPERADMIN;
  993.         try {
  994.             $service->updateSuperuser($emailnull$targetEmpresaIds$enabled$resolvedProfile);
  995.             $this->addFlash('success''Vinculación eliminada correctamente.');
  996.         } catch (\Throwable $e) {
  997.             $this->addFlash('danger'$e->getMessage());
  998.         }
  999.         return $this->redirectToRoute('superusers_edit', ['email' => $email]);
  1000.     }
  1001.     public function superusersDeleteGlobal(string $emailRequest $requestSuperuserProvisioningService $service)
  1002.     {
  1003.         if (!$this->getUser() || !is_object($this->getUser())) {
  1004.             return $this->redirectToRoute('logout');
  1005.         }
  1006.         if (!$request->isMethod('POST')) {
  1007.             return $this->redirectToRoute('superusers_list');
  1008.         }
  1009.         $email $this->normalizeSuperuserEmail($email);
  1010.         try {
  1011.             $service->deleteSuperuser($email);
  1012.             $this->addFlash('success''Usuario global eliminado de todas las empresas.');
  1013.         } catch (\Throwable $e) {
  1014.             $this->addFlash('danger'$e->getMessage());
  1015.         }
  1016.         return $this->redirectToRoute('superusers_list');
  1017.     }
  1018.     public function superusersCheckEmail(Request $requestEntityManagerInterface $em): JsonResponse
  1019.     {
  1020.         $email $this->normalizeSuperuserEmail((string)$request->request->get('email'''));
  1021.         $empresaIds $this->readEmpresaIdsFromRequest($request);
  1022.         if ($email === '') {
  1023.             return new JsonResponse(['exists' => false'message' => 'email_required'], 400);
  1024.         }
  1025.         if (count($empresaIds) === 0) {
  1026.             return new JsonResponse(['exists' => false'message' => 'no_empresas']);
  1027.         }
  1028.         $count = (int)$em->createQueryBuilder()
  1029.             ->select('COUNT(u.id)')
  1030.             ->from(Usuario::class, 'u')
  1031.             ->where('LOWER(u.email) = :email')
  1032.             ->andWhere('u.isAdmin IN (:isAdmins)')
  1033.             ->andWhere('IDENTITY(u.empresa) IN (:empresaIds)')
  1034.             ->setParameter('email'$email)
  1035.             ->setParameter('isAdmins'self::GLOBAL_IS_ADMINS)
  1036.             ->setParameter('empresaIds'$empresaIds)
  1037.             ->getQuery()
  1038.             ->getSingleScalarResult();
  1039.         return new JsonResponse([
  1040.             'exists' => $count 0,
  1041.             'count' => $count,
  1042.         ]);
  1043.     }
  1044.     private function normalizeSuperuserEmail(string $email): string
  1045.     {
  1046.         return strtolower(trim($email));
  1047.     }
  1048.     private function targetProfileFromRequest(Request $request): int
  1049.     {
  1050.         $accountType strtolower(trim((string)$request->request->get('account_type''superadmin')));
  1051.         return $this->profileFromAccountType($accountType);
  1052.     }
  1053.     private function profileFromAccountType(string $accountType): int
  1054.     {
  1055.         return $accountType === 'superuser'
  1056.             self::PROFILE_SUPERUSER
  1057.             self::PROFILE_SUPERADMIN;
  1058.     }
  1059.     private function accountTypeFromProfile(int $profile): string
  1060.     {
  1061.         return $profile === self::PROFILE_SUPERUSER 'superuser' 'superadmin';
  1062.     }
  1063.     private function accountTypeLabelFromProfile(int $profile): string
  1064.     {
  1065.         return $profile === self::PROFILE_SUPERUSER 'Superusuario' 'Superadministrador';
  1066.     }
  1067.     private function profileFromIsAdmin(int $isAdmin): int
  1068.     {
  1069.         return $isAdmin === self::GLOBAL_IS_ADMIN_SUPERUSER
  1070.             self::PROFILE_SUPERUSER
  1071.             self::PROFILE_SUPERADMIN;
  1072.     }
  1073.     private function resolveGlobalAccountType(EntityManagerInterface $emstring $email, array $empresaIds, array $linkRows = []): array
  1074.     {
  1075.         if ($email === '' || count($empresaIds) === 0) {
  1076.             return ['key' => 'indeterminate''label' => 'Indeterminado'];
  1077.         }
  1078.         $isAdmins = [];
  1079.         if (!empty($linkRows)) {
  1080.             foreach ($linkRows as $row) {
  1081.                 $isAdmin = (int)($row['is_admin'] ?? 0);
  1082.                 if (in_array($isAdminself::GLOBAL_IS_ADMINStrue)) {
  1083.                     $isAdmins[] = $isAdmin;
  1084.                 }
  1085.             }
  1086.         }
  1087.         if (empty($isAdmins)) {
  1088.             $linkRows $em->getConnection()->fetchAllAssociative(
  1089.                 "SELECT is_admin
  1090.                  FROM users
  1091.                  WHERE LOWER(email) = :email AND is_admin IN (" self::GLOBAL_IS_ADMIN_SUPERADMIN ", " self::GLOBAL_IS_ADMIN_SUPERUSER ")",
  1092.                 ['email' => $email]
  1093.             );
  1094.             foreach ($linkRows as $row) {
  1095.                 $isAdmin = (int)($row['is_admin'] ?? 0);
  1096.                 if (in_array($isAdminself::GLOBAL_IS_ADMINStrue)) {
  1097.                     $isAdmins[] = $isAdmin;
  1098.                 }
  1099.             }
  1100.         }
  1101.         $isAdmins array_values(array_unique($isAdmins));
  1102.         if (count($isAdmins) !== 1) {
  1103.             return ['key' => 'indeterminate''label' => 'Indeterminado'];
  1104.         }
  1105.         $profile $this->profileFromIsAdmin((int)$isAdmins[0]);
  1106.         return [
  1107.             'key' => $this->accountTypeFromProfile($profile),
  1108.             'label' => $this->accountTypeLabelFromProfile($profile),
  1109.         ];
  1110.     }
  1111.     /**
  1112.      * @return int[]
  1113.      */
  1114.     private function readEmpresaIdsFromRequest(Request $request): array
  1115.     {
  1116.         $raw $request->request->all('empresas');
  1117.         if (!is_array($raw)) {
  1118.             $raw = [];
  1119.         }
  1120.         $ids = [];
  1121.         foreach ($raw as $empresaId) {
  1122.             $id = (int)$empresaId;
  1123.             if ($id 0) {
  1124.                 $ids[] = $id;
  1125.             }
  1126.         }
  1127.         $ids array_values(array_unique($ids));
  1128.         sort($ids);
  1129.         return $ids;
  1130.     }
  1131.     public function azureResourcesList(EntityManagerInterface $em)
  1132.     {
  1133.         if (!$this->getUser() || !is_object($this->getUser())) {
  1134.             return $this->redirectToRoute('logout');
  1135.         }
  1136.         $resources = [];
  1137.         try {
  1138.             $resources $this->loadAzureResources($em);
  1139.         } catch (\Throwable $e) {
  1140.             $this->addFlash('danger''No se pudo cargar el listado de recursos IA: ' $e->getMessage());
  1141.         }
  1142.         return $this->render('azure_resources/list.html.twig', [
  1143.             'resources' => $resources,
  1144.         ]);
  1145.     }
  1146.     public function azureResourcesNew(Request $requestEntityManagerInterface $em)
  1147.     {
  1148.         if (!$this->getUser() || !is_object($this->getUser())) {
  1149.             return $this->redirectToRoute('logout');
  1150.         }
  1151.         $resource = [
  1152.             'name' => '',
  1153.             'endpoint' => '',
  1154.             'api_key' => '',
  1155.             'extractor_type' => self::EXTRACTOR_TYPE_AZURE_DI,
  1156.             'model_id' => '',
  1157.             'base_prompt' => '',
  1158.         ];
  1159.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  1160.             $resource['name'] = trim((string)$request->request->get('name'''));
  1161.             $resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint'''));
  1162.             $resource['api_key'] = trim((string)$request->request->get('api_key'''));
  1163.             $resource['extractor_type'] = $this->normalizeExtractorType((string)$request->request->get('extractor_type'self::EXTRACTOR_TYPE_AZURE_DI));
  1164.             $resource['model_id'] = trim((string)$request->request->get('model_id'''));
  1165.             $resource['base_prompt'] = trim((string)$request->request->get('base_prompt'''));
  1166.             if (
  1167.                 $resource['name'] === '' ||
  1168.                 $resource['endpoint'] === '' ||
  1169.                 $resource['api_key'] === ''
  1170.             ) {
  1171.                 $this->addFlash('danger''Nombre, endpoint y api key son obligatorios.');
  1172.             } elseif (
  1173.                 $resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_OPENAI &&
  1174.                 ($resource['model_id'] === '' || $resource['base_prompt'] === '')
  1175.             ) {
  1176.                 $this->addFlash('danger''Para recursos Azure OpenAI debes indicar model_id y prompt general.');
  1177.             } else {
  1178.                 if ($resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_DI) {
  1179.                     $resource['model_id'] = '';
  1180.                     $resource['base_prompt'] = '';
  1181.                 }
  1182.                 try {
  1183.                     $em->getConnection()->insert('azure_di_resources', [
  1184.                         'name' => $resource['name'],
  1185.                         'endpoint' => $resource['endpoint'],
  1186.                         'api_key' => $resource['api_key'],
  1187.                         'extractor_type' => $resource['extractor_type'],
  1188.                         'model_id' => $resource['model_id'],
  1189.                         'base_prompt' => $resource['base_prompt'],
  1190.                     ]);
  1191.                     $this->addFlash('success''Recurso IA creado correctamente.');
  1192.                     return $this->redirectToRoute('azure_resources_list');
  1193.                 } catch (\Throwable $e) {
  1194.                     $this->addFlash('danger''No se pudo crear el recurso: ' $e->getMessage());
  1195.                 }
  1196.             }
  1197.         }
  1198.         return $this->render('azure_resources/new.html.twig', [
  1199.             'resource' => $resource,
  1200.         ]);
  1201.     }
  1202.     public function azureResourcesEdit(Request $requestEntityManagerInterface $em)
  1203.     {
  1204.         if (!$this->getUser() || !is_object($this->getUser())) {
  1205.             return $this->redirectToRoute('logout');
  1206.         }
  1207.         $id = (int)$request->get('id');
  1208.         $resource $this->loadAzureResourceById($em$id);
  1209.         if (!$resource) {
  1210.             $this->addFlash('warning''Recurso no encontrado.');
  1211.             return $this->redirectToRoute('azure_resources_list');
  1212.         }
  1213.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  1214.             $resource['name'] = trim((string)$request->request->get('name'''));
  1215.             $resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint'''));
  1216.             $resource['api_key'] = trim((string)$request->request->get('api_key'''));
  1217.             $resource['extractor_type'] = $this->normalizeExtractorType((string)$request->request->get('extractor_type'self::EXTRACTOR_TYPE_AZURE_DI));
  1218.             $resource['model_id'] = trim((string)$request->request->get('model_id'''));
  1219.             $resource['base_prompt'] = trim((string)$request->request->get('base_prompt'''));
  1220.             if (
  1221.                 $resource['name'] === '' ||
  1222.                 $resource['endpoint'] === '' ||
  1223.                 $resource['api_key'] === ''
  1224.             ) {
  1225.                 $this->addFlash('danger''Nombre, endpoint y api key son obligatorios.');
  1226.             } elseif (
  1227.                 $resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_OPENAI &&
  1228.                 ($resource['model_id'] === '' || $resource['base_prompt'] === '')
  1229.             ) {
  1230.                 $this->addFlash('danger''Para recursos Azure OpenAI debes indicar model_id y prompt general.');
  1231.             } else {
  1232.                 if ($resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_DI) {
  1233.                     $resource['model_id'] = '';
  1234.                     $resource['base_prompt'] = '';
  1235.                 }
  1236.                 try {
  1237.                     $em->getConnection()->update('azure_di_resources', [
  1238.                         'name' => $resource['name'],
  1239.                         'endpoint' => $resource['endpoint'],
  1240.                         'api_key' => $resource['api_key'],
  1241.                         'extractor_type' => $resource['extractor_type'],
  1242.                         'model_id' => $resource['model_id'],
  1243.                         'base_prompt' => $resource['base_prompt'],
  1244.                     ], [
  1245.                         'id' => $id,
  1246.                     ]);
  1247.                     $this->addFlash('success''Recurso IA actualizado correctamente.');
  1248.                     return $this->redirectToRoute('azure_resources_list');
  1249.                 } catch (\Throwable $e) {
  1250.                     $this->addFlash('danger''No se pudo actualizar el recurso: ' $e->getMessage());
  1251.                 }
  1252.             }
  1253.         }
  1254.         return $this->render('azure_resources/edit.html.twig', [
  1255.             'resource' => $resource,
  1256.         ]);
  1257.     }
  1258.     public function azureResourcesDelete(Request $requestEntityManagerInterface $em)
  1259.     {
  1260.         if (!$this->getUser() || !is_object($this->getUser())) {
  1261.             return $this->redirectToRoute('logout');
  1262.         }
  1263.         $id = (int)$request->get('id');
  1264.         if ($id <= 0) {
  1265.             $this->addFlash('warning''Identificador de recurso no valido.');
  1266.             return $this->redirectToRoute('azure_resources_list');
  1267.         }
  1268.         try {
  1269.             $em->getConnection()->delete('azure_di_resources', ['id' => $id]);
  1270.             $this->addFlash('success''Recurso IA eliminado correctamente.');
  1271.         } catch (\Throwable $e) {
  1272.             $this->addFlash('danger''No se pudo eliminar el recurso: ' $e->getMessage());
  1273.         }
  1274.         return $this->redirectToRoute('azure_resources_list');
  1275.     }
  1276.     public function azureApiResourceModels(Request $requestEntityManagerInterface $em): JsonResponse
  1277.     {
  1278.         if (!$this->getUser() || !is_object($this->getUser())) {
  1279.             return new JsonResponse(['error' => 'unauthorized'], 401);
  1280.         }
  1281.         $resource $this->loadAzureResourceById($em, (int)$request->get('id'));
  1282.         if (!$resource) {
  1283.             return new JsonResponse(['error' => 'resource_not_found'], 404);
  1284.         }
  1285.         if (($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  1286.             return new JsonResponse(['error' => 'resource_type_not_supported_for_di_models'], 400);
  1287.         }
  1288.         try {
  1289.             $payload $this->azureRequest(
  1290.                 (string)$resource['endpoint'],
  1291.                 (string)$resource['api_key'],
  1292.                 '/documentintelligence/documentModels'
  1293.             );
  1294.         } catch (\Throwable $e) {
  1295.             $status = (int)$e->getCode();
  1296.             if ($status 400 || $status 599) {
  1297.                 $status 502;
  1298.             }
  1299.             return new JsonResponse(['error' => $e->getMessage()], $status);
  1300.         }
  1301.         $models $payload['value'] ?? [];
  1302.         if (!is_array($models)) {
  1303.             $models = [];
  1304.         }
  1305.         $normalizedModels = [];
  1306.         foreach ($models as $model) {
  1307.             if (!is_array($model)) {
  1308.                 continue;
  1309.             }
  1310.             $normalizedModels[] = [
  1311.                 'modelId' => (string)($model['modelId'] ?? ''),
  1312.                 'description' => (string)($model['description'] ?? ''),
  1313.                 'createdDateTime' => (string)($model['createdDateTime'] ?? ''),
  1314.                 'expirationDateTime' => (string)($model['expirationDateTime'] ?? ''),
  1315.                 'type' => $this->classifyModelType($model),
  1316.             ];
  1317.         }
  1318.         usort($normalizedModels, static function (array $a, array $b): int {
  1319.             return strcmp($a['modelId'] ?? ''$b['modelId'] ?? '');
  1320.         });
  1321.         return new JsonResponse([
  1322.             'resource' => [
  1323.                 'id' => (int)$resource['id'],
  1324.                 'name' => (string)$resource['name'],
  1325.             ],
  1326.             'models' => $normalizedModels,
  1327.         ]);
  1328.     }
  1329.     public function azureApiResourceModelDetail(Request $requestEntityManagerInterface $em): JsonResponse
  1330.     {
  1331.         if (!$this->getUser() || !is_object($this->getUser())) {
  1332.             return new JsonResponse(['error' => 'unauthorized'], 401);
  1333.         }
  1334.         $resource $this->loadAzureResourceById($em, (int)$request->get('id'));
  1335.         if (!$resource) {
  1336.             return new JsonResponse(['error' => 'resource_not_found'], 404);
  1337.         }
  1338.         if (($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  1339.             return new JsonResponse(['error' => 'resource_type_not_supported_for_di_models'], 400);
  1340.         }
  1341.         $modelId trim((string)$request->get('modelId'));
  1342.         if ($modelId === '') {
  1343.             return new JsonResponse(['error' => 'model_id_required'], 400);
  1344.         }
  1345.         try {
  1346.             $detail $this->azureRequest(
  1347.                 (string)$resource['endpoint'],
  1348.                 (string)$resource['api_key'],
  1349.                 '/documentintelligence/documentModels/' rawurlencode($modelId)
  1350.             );
  1351.             $mapped $this->mapAzureSchemaToDefinitions($detail);
  1352.         } catch (\Throwable $e) {
  1353.             $status = (int)$e->getCode();
  1354.             if ($status 400 || $status 599) {
  1355.                 $status 502;
  1356.             }
  1357.             return new JsonResponse(['error' => $e->getMessage()], $status);
  1358.         }
  1359.         return new JsonResponse([
  1360.             'model' => $detail,
  1361.             'type' => $this->classifyModelType($detail),
  1362.             'preview' => [
  1363.                 'header' => $mapped['header'],
  1364.                 'lines' => $mapped['lines'],
  1365.             ],
  1366.         ]);
  1367.     }
  1368.     public function empresaApiExtractionModelDetail(Request $requestEntityManagerInterface $em): JsonResponse
  1369.     {
  1370.         if (!$this->getUser() || !is_object($this->getUser())) {
  1371.             return new JsonResponse(['error' => 'unauthorized'], 401);
  1372.         }
  1373.         $id = (int)$request->get('id');
  1374.         $modelLocalId = (int)$request->get('modelId');
  1375.         if ($id <= || $modelLocalId <= 0) {
  1376.             return new JsonResponse(['error' => 'invalid_params'], 400);
  1377.         }
  1378.         $empresa $em->getRepository(Empresa::class)->find($id);
  1379.         if (!$empresa) {
  1380.             return new JsonResponse(['error' => 'empresa_not_found'], 404);
  1381.         }
  1382.         $cx $empresa->getConexionBD();
  1383.         $mysqli = @new \mysqli(
  1384.             $cx->getDbUrl(),
  1385.             $cx->getDbUser(),
  1386.             $cx->getDbPassword(),
  1387.             $cx->getDbName(),
  1388.             (int)$cx->getDbPort()
  1389.         );
  1390.         if ($mysqli->connect_error) {
  1391.             return new JsonResponse(['error' => 'db_connection_error'], 500);
  1392.         }
  1393.         $provider '';
  1394.         try {
  1395.             $stmt $mysqli->prepare('SELECT provider FROM extraction_models WHERE id = ? LIMIT 1');
  1396.             if ($stmt) {
  1397.                 $stmt->bind_param('i'$modelLocalId);
  1398.                 if ($stmt->execute()) {
  1399.                     $res $stmt->get_result();
  1400.                     if ($res && ($row $res->fetch_assoc())) {
  1401.                         $provider strtolower(trim((string)($row['provider'] ?? '')));
  1402.                     }
  1403.                 }
  1404.                 $stmt->close();
  1405.             }
  1406.         } catch (\Throwable $e) {
  1407.             $provider '';
  1408.         }
  1409.         if ($provider === '') {
  1410.             $mysqli->close();
  1411.             return new JsonResponse(['error' => 'model_not_found'], 404);
  1412.         }
  1413.         $fields = [];
  1414.         $aoaiResourceId 0;
  1415.         if ($provider === 'azure_openai' || $provider === 'azure-openai') {
  1416.             $fields $this->loadAoaiFieldsForModel($mysqli$modelLocalId);
  1417.             $aoaiResourceId $this->resolveAoaiResourceIdForModel($mysqli$modelLocalId$em);
  1418.         }
  1419.         $mysqli->close();
  1420.         return new JsonResponse([
  1421.             'model_id' => $modelLocalId,
  1422.             'provider' => $provider,
  1423.             'aoai_resource_id' => $aoaiResourceId,
  1424.             'fields' => $fields,
  1425.         ]);
  1426.     }
  1427.     private function normalizeExtractorType(string $value): string
  1428.     {
  1429.         $value strtolower(trim($value));
  1430.         return $value === self::EXTRACTOR_TYPE_AZURE_OPENAI
  1431.             self::EXTRACTOR_TYPE_AZURE_OPENAI
  1432.             self::EXTRACTOR_TYPE_AZURE_DI;
  1433.     }
  1434.     private function loadAoaiFieldsForModel(\mysqli $mysqliint $modelId): array
  1435.     {
  1436.         $fields = [];
  1437.         $stmtHeader $mysqli->prepare(
  1438.             'SELECT field_key, prompt, value_type FROM definitions_header WHERE model_id = ? ORDER BY order_index ASC, id ASC'
  1439.         );
  1440.         if ($stmtHeader) {
  1441.             $stmtHeader->bind_param('i'$modelId);
  1442.             if ($stmtHeader->execute()) {
  1443.                 $resHeader $stmtHeader->get_result();
  1444.                 while ($resHeader && ($row $resHeader->fetch_assoc())) {
  1445.                     $fields[] = [
  1446.                         'scope' => 'header',
  1447.                         'field_key' => (string)($row['field_key'] ?? ''),
  1448.                         'prompt' => (string)($row['prompt'] ?? ''),
  1449.                         'value_type' => $this->normalizeAoaiValueType((string)($row['value_type'] ?? 'string')),
  1450.                     ];
  1451.                 }
  1452.             }
  1453.             $stmtHeader->close();
  1454.         }
  1455.         $stmtLines $mysqli->prepare(
  1456.             'SELECT field_key, prompt, value_type FROM definitions_lines WHERE model_id = ? ORDER BY order_index ASC, id ASC'
  1457.         );
  1458.         if ($stmtLines) {
  1459.             $stmtLines->bind_param('i'$modelId);
  1460.             if ($stmtLines->execute()) {
  1461.                 $resLines $stmtLines->get_result();
  1462.                 while ($resLines && ($row $resLines->fetch_assoc())) {
  1463.                     $fields[] = [
  1464.                         'scope' => 'lines',
  1465.                         'field_key' => (string)($row['field_key'] ?? ''),
  1466.                         'prompt' => (string)($row['prompt'] ?? ''),
  1467.                         'value_type' => $this->normalizeAoaiValueType((string)($row['value_type'] ?? 'string')),
  1468.                     ];
  1469.                 }
  1470.             }
  1471.             $stmtLines->close();
  1472.         }
  1473.         return $fields;
  1474.     }
  1475.     private function resolveAoaiResourceIdForModel(\mysqli $mysqliint $modelIdEntityManagerInterface $em): int
  1476.     {
  1477.         $currentModelId '';
  1478.         $currentEndpoint '';
  1479.         $stmtModelMeta $mysqli->prepare(
  1480.             "SELECT model_id, endpoint FROM extraction_models WHERE id = ? AND provider IN ('azure_openai', 'azure-openai') LIMIT 1"
  1481.         );
  1482.         if ($stmtModelMeta) {
  1483.             $stmtModelMeta->bind_param('i'$modelId);
  1484.             if ($stmtModelMeta->execute()) {
  1485.                 $resMeta $stmtModelMeta->get_result();
  1486.                 if ($resMeta && ($metaRow $resMeta->fetch_assoc())) {
  1487.                     $currentModelId trim((string)($metaRow['model_id'] ?? ''));
  1488.                     $currentEndpoint $this->normalizeAzureEndpoint((string)($metaRow['endpoint'] ?? ''));
  1489.                 }
  1490.             }
  1491.             $stmtModelMeta->close();
  1492.         }
  1493.         if ($currentModelId === '' || $currentEndpoint === '') {
  1494.             return 0;
  1495.         }
  1496.         $azureOpenAiResources = [];
  1497.         try {
  1498.             $allResources $this->loadAzureResources($em);
  1499.             foreach ($allResources as $resourceRow) {
  1500.                 if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1501.                     $azureOpenAiResources[] = $resourceRow;
  1502.                 }
  1503.             }
  1504.         } catch (\Throwable $e) {
  1505.             $azureOpenAiResources = [];
  1506.         }
  1507.         foreach ($azureOpenAiResources as $resourceRow) {
  1508.             $resourceModelId trim((string)($resourceRow['model_id'] ?? ''));
  1509.             $resourceEndpoint $this->normalizeAzureEndpoint((string)($resourceRow['endpoint'] ?? ''));
  1510.             if ($resourceModelId === $currentModelId && $resourceEndpoint === $currentEndpoint) {
  1511.                 return (int)($resourceRow['id'] ?? 0);
  1512.             }
  1513.         }
  1514.         return 0;
  1515.     }
  1516.     private function loadAzureResources(EntityManagerInterface $em, ?string $extractorType null): array
  1517.     {
  1518.         $rows = [];
  1519.         $sql 'SELECT id, name, endpoint, api_key, extractor_type, model_id, base_prompt, created_at, updated_at
  1520.                 FROM azure_di_resources';
  1521.         $params = [];
  1522.         if ($extractorType !== null && $extractorType !== '') {
  1523.             $sql .= ' WHERE extractor_type = :extractor_type';
  1524.             $params['extractor_type'] = $this->normalizeExtractorType($extractorType);
  1525.         }
  1526.         $sql .= ' ORDER BY name ASC';
  1527.         try {
  1528.             $rows $em->getConnection()->executeQuery($sql$params)->fetchAllAssociative();
  1529.         } catch (\Throwable $e) {
  1530.             // Compatibilidad temporal con esquemas antiguos sin columnas nuevas.
  1531.             $legacyRows $em->getConnection()->executeQuery(
  1532.                 'SELECT id, name, endpoint, api_key, created_at, updated_at FROM azure_di_resources ORDER BY name ASC'
  1533.             )->fetchAllAssociative();
  1534.             foreach ($legacyRows as &$legacy) {
  1535.                 $legacy['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  1536.                 $legacy['model_id'] = '';
  1537.                 $legacy['base_prompt'] = '';
  1538.             }
  1539.             $rows $legacyRows;
  1540.         }
  1541.         foreach ($rows as &$row) {
  1542.             $row['id'] = (int)$row['id'];
  1543.             $row['extractor_type'] = $this->normalizeExtractorType((string)($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  1544.             $row['model_id'] = (string)($row['model_id'] ?? '');
  1545.             $row['base_prompt'] = (string)($row['base_prompt'] ?? '');
  1546.         }
  1547.         return $rows;
  1548.     }
  1549.     private function loadAzureResourceById(EntityManagerInterface $emint $id): ?array
  1550.     {
  1551.         if ($id <= 0) {
  1552.             return null;
  1553.         }
  1554.         $row null;
  1555.         try {
  1556.             $row $em->getConnection()->fetchAssociative(
  1557.                 'SELECT id, name, endpoint, api_key, extractor_type, model_id, base_prompt, created_at, updated_at
  1558.                  FROM azure_di_resources
  1559.                  WHERE id = :id',
  1560.                 ['id' => $id]
  1561.             );
  1562.         } catch (\Throwable $e) {
  1563.             $row $em->getConnection()->fetchAssociative(
  1564.                 'SELECT id, name, endpoint, api_key, created_at, updated_at
  1565.                  FROM azure_di_resources
  1566.                  WHERE id = :id',
  1567.                 ['id' => $id]
  1568.             );
  1569.             if ($row) {
  1570.                 $row['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  1571.                 $row['model_id'] = '';
  1572.                 $row['base_prompt'] = '';
  1573.             }
  1574.         }
  1575.         if (!$row) {
  1576.             return null;
  1577.         }
  1578.         $row['id'] = (int)$row['id'];
  1579.         $row['extractor_type'] = $this->normalizeExtractorType((string)($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  1580.         $row['model_id'] = (string)($row['model_id'] ?? '');
  1581.         $row['base_prompt'] = (string)($row['base_prompt'] ?? '');
  1582.         return $row;
  1583.     }
  1584.     private function loadAzureDiResources(EntityManagerInterface $em): array
  1585.     {
  1586.         return $this->loadAzureResources($emself::EXTRACTOR_TYPE_AZURE_DI);
  1587.     }
  1588.     private function loadAzureDiResourceById(EntityManagerInterface $emint $id): ?array
  1589.     {
  1590.         $row $this->loadAzureResourceById($em$id);
  1591.         if (!$row) {
  1592.             return null;
  1593.         }
  1594.         return ($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_DI
  1595.             $row
  1596.             null;
  1597.     }
  1598.     private function normalizeAzureEndpoint(string $endpoint): string
  1599.     {
  1600.         $endpoint trim($endpoint);
  1601.         if ($endpoint === '') {
  1602.             return '';
  1603.         }
  1604.         if (!preg_match('#^https?://#i'$endpoint)) {
  1605.             $endpoint 'https://' $endpoint;
  1606.         }
  1607.         return rtrim($endpoint'/');
  1608.     }
  1609.     private function azureRequest(string $endpointstring $apiKeystring $path, array $query = []): array
  1610.     {
  1611.         $endpoint $this->normalizeAzureEndpoint($endpoint);
  1612.         if ($endpoint === '' || trim($apiKey) === '') {
  1613.             throw new \RuntimeException('Recurso IA incompleto.'400);
  1614.         }
  1615.         $query array_merge(['api-version' => self::AZURE_DI_API_VERSION], $query);
  1616.         $url $endpoint '/' ltrim($path'/') . '?' http_build_query($query);
  1617.         $ch curl_init();
  1618.         curl_setopt($chCURLOPT_URL$url);
  1619.         curl_setopt($chCURLOPT_HTTPHEADER, [
  1620.             'Accept: application/json',
  1621.             'Ocp-Apim-Subscription-Key: ' $apiKey,
  1622.         ]);
  1623.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  1624.         curl_setopt($chCURLOPT_CONNECTTIMEOUT8);
  1625.         curl_setopt($chCURLOPT_TIMEOUT25);
  1626.         $response curl_exec($ch);
  1627.         $curlError curl_error($ch);
  1628.         $statusCode = (int)curl_getinfo($chCURLINFO_HTTP_CODE);
  1629.         curl_close($ch);
  1630.         if ($curlError) {
  1631.             throw new \RuntimeException('Error de red con recurso IA: ' $curlError0);
  1632.         }
  1633.         $decoded = [];
  1634.         if (is_string($response) && trim($response) !== '') {
  1635.             $decoded json_decode($responsetrue);
  1636.             if (!is_array($decoded)) {
  1637.                 throw new \RuntimeException('Respuesta no valida del recurso IA.'502);
  1638.             }
  1639.         }
  1640.         if ($statusCode 200 || $statusCode >= 300) {
  1641.             $detail $decoded['error']['message'] ?? $decoded['message'] ?? ('HTTP ' $statusCode);
  1642.             throw new \RuntimeException((string)$detail$statusCode);
  1643.         }
  1644.         return $decoded;
  1645.     }
  1646.     private function classifyModelType(array $model): string
  1647.     {
  1648.         $expiration trim((string)($model['expirationDateTime'] ?? ''));
  1649.         return $expiration !== '' 'custom' 'prebuilt';
  1650.     }
  1651.     public function addEmpresa(Request $reqEntityManagerInterface $em)
  1652.     {
  1653.         //dd(\shell_exec('whoami'));
  1654.         if (!$this->getUser() || !is_object($this->getUser())) {
  1655.             return $this->redirectToRoute('logout');
  1656.         }
  1657.         $azureResources = [];
  1658.         $azureDiResources = [];
  1659.         $azureOpenAiResources = [];
  1660.         try {
  1661.             $azureResources $this->loadAzureResources($em);
  1662.             foreach ($azureResources as $resourceRow) {
  1663.                 if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1664.                     $azureOpenAiResources[] = $resourceRow;
  1665.                 } else {
  1666.                     $azureDiResources[] = $resourceRow;
  1667.                 }
  1668.             }
  1669.         } catch (\Throwable $e) {
  1670.             $this->addFlash('warning''No se pudo cargar el catalogo de recursos IA: ' $e->getMessage());
  1671.         }
  1672.         $ocrPlusAvailable $this->isOcrPlusAvailable();
  1673.         $ocrV2Available $this->isOcrV2Available();
  1674.         
  1675.         if ($req->request->get("submit") != "") {
  1676.             $data $req->request->all();
  1677.             $maxThreads $this->clampDocuMaxThreads($data['maxThreads'] ?? null4);
  1678.             $data['ocr_mode'] = $this->normalizeOcrMode($data['ocr_mode'] ?? 'base');
  1679.             if (!$ocrV2Available && $data['ocr_mode'] === 'v2_zxing') {
  1680.                 $data['ocr_mode'] = 'base';
  1681.             }
  1682.             if (!$ocrPlusAvailable && $data['ocr_mode'] === 'plus_glm') {
  1683.                 $data['ocr_mode'] = 'base';
  1684.             }
  1685.             $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  1686.             $mailMonitorEnabled = isset($data['modulo_mailMonitor']) && $toBool($data['modulo_mailMonitor']);
  1687.             $data['modulo_extraccion'] = isset($data['modulo_extraccion']) && $toBool($data['modulo_extraccion']) ? 0;
  1688.             $data['modulo_expowin'] = isset($data['modulo_expowin']) && $toBool($data['modulo_expowin']) ? 0;
  1689.             $data['modulo_prinex'] = isset($data['modulo_prinex']) && $toBool($data['modulo_prinex']) ? 0;
  1690.             $extractorType $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  1691.             $data['extractor_type'] = $extractorType;
  1692.             $azureResource null;
  1693.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  1694.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  1695.             $aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
  1696.             $aoaiFields $this->collectAoaiFieldsFromRequest($req);
  1697.             $renderAddWithData = function () use ($azureResources$azureDiResources$azureOpenAiResources$data$aoaiFields) {
  1698.                 $contractPreview $this->licenseContractService->defaultContract();
  1699.                 try {
  1700.                     $contractPreview $this->licenseContractService->contractFromPayload($data);
  1701.                 } catch (\Throwable $e) {
  1702.                     // Mantener preview por defecto si los datos de licencia son inválidos.
  1703.                 }
  1704.                 return $this->render('empresa/_add.html.twig', [
  1705.                     'azure_resources' => $azureResources,
  1706.                     'azure_di_resources' => $azureDiResources,
  1707.                     'azure_openai_resources' => $azureOpenAiResources,
  1708.                     'modulos' => [
  1709.                         'extraction_model' => 0,
  1710.                     ],
  1711.                     'form_data' => $data,
  1712.                     'activeLicenseContract' => $contractPreview,
  1713.                     'aoai_fields' => $aoaiFields,
  1714.                     'ocr_plus_available' => $ocrPlusAvailable,
  1715.                     'ocr_v2_available' => $ocrV2Available,
  1716.                 ]);
  1717.             };
  1718.             try {
  1719.                 $data $this->licenseContractService->normalizePayload($datafalse);
  1720.             } catch (\InvalidArgumentException $e) {
  1721.                 $this->addFlash('danger'$e->getMessage());
  1722.                 return $renderAddWithData();
  1723.             }
  1724.             if ($data['modulo_extraccion'] === 1) {
  1725.                 if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1726.                     if ($aoaiResourceId <= 0) {
  1727.                         $this->addFlash('danger''Para Azure OpenAI debes seleccionar un recurso IA.');
  1728.                         return $renderAddWithData();
  1729.                     }
  1730.                     $azureResource $this->loadAzureResourceById($em$aoaiResourceId);
  1731.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1732.                         $this->addFlash('danger''El recurso Azure OpenAI seleccionado no existe.');
  1733.                         return $renderAddWithData();
  1734.                     }
  1735.                     $validationError $this->validateAoaiFields($aoaiFields);
  1736.                     if ($validationError !== null) {
  1737.                         $this->addFlash('danger'$validationError);
  1738.                         return $renderAddWithData();
  1739.                     }
  1740.                 } else {
  1741.                     if ($azureResourceId <= || $azureModelId === '') {
  1742.                         $this->addFlash('danger''Para activar extraccion debes seleccionar recurso y modelo IA.');
  1743.                         return $renderAddWithData();
  1744.                     }
  1745.                     $azureResource $this->loadAzureResourceById($em$azureResourceId);
  1746.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  1747.                         $this->addFlash('danger''El recurso IA seleccionado no existe o no es de tipo Azure DI.');
  1748.                         return $renderAddWithData();
  1749.                     }
  1750.                     try {
  1751.                         $this->azureRequest(
  1752.                             (string)$azureResource['endpoint'],
  1753.                             (string)$azureResource['api_key'],
  1754.                             '/documentintelligence/documentModels/' rawurlencode($azureModelId)
  1755.                         );
  1756.                     } catch (\Throwable $e) {
  1757.                         $this->addFlash('danger''No se pudo validar el modelo IA: ' $e->getMessage());
  1758.                         return $renderAddWithData();
  1759.                     }
  1760.                 }
  1761.             } else {
  1762.                 $data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  1763.                 $data['azure_resource_id'] = 0;
  1764.                 $data['azure_model_id'] = '';
  1765.                 $data['aoai_resource_id'] = 0;
  1766.                 $data['extraction_model'] = 0;
  1767.                 $data['modulo_expowin'] = 0;
  1768.                 $data['modulo_prinex'] = 0;
  1769.             }
  1770.             // Crear nombre de base de datos y credenciales aleatorios
  1771.             $dbName 'doc_' bin2hex(random_bytes(3));
  1772.             $dbUser 'doc_' bin2hex(random_bytes(2));
  1773.             $dbPass bin2hex(random_bytes(8));
  1774.             $dbHost 'localhost';
  1775.             $dbPort '3306';
  1776.             // Credenciales de HestiaCP desde variables de entorno
  1777.             $hestiaApiUrl $_ENV['HESTIA_API_URL'];
  1778.             $hestiaApiUser $_ENV['HESTIA_API_USER'];
  1779.             $hestiaApiPass $_ENV['HESTIA_API_PASS'];
  1780.             $hestiaOwner   $_ENV['HESTIA_OWNER'];
  1781.             $accessKeyId   $_ENV['HESTIA_ACCESS_KEY_ID'] ?? '';
  1782.             $secretKey     $_ENV['HESTIA_SECRET_KEY'] ?? '';
  1783.             // Variables para el script de bash
  1784.             $ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
  1785.             $ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
  1786.             $ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
  1787.             $ocrMode = ($data['ocr_mode'] ?? 'base');
  1788.             if ($ocrMode === 'plus_glm') {
  1789.                 $ocrBinary $ocrBinaryPlus;
  1790.             } elseif ($ocrMode === 'v2_zxing') {
  1791.                 $ocrBinary $ocrBinaryV2;
  1792.             } else {
  1793.                 $ocrBinary $ocrBinaryBase;
  1794.             }
  1795.             $filesPath  $_ENV['FILES_PATH'];
  1796.             if (($data['ocr_mode'] ?? 'base') === 'plus_glm' && trim($ocrBinaryPlus) === '') {
  1797.                 $this->addFlash('danger''OCR+ seleccionado pero falta configurar OCR_PLUS_BINARY en .env.local.');
  1798.                 return $renderAddWithData();
  1799.             }
  1800.             if (($data['ocr_mode'] ?? 'base') === 'v2_zxing' && trim($ocrBinaryV2) === '') {
  1801.                 $this->addFlash('danger''OCR v2 seleccionado pero falta configurar OCR_BINARY_V2 en .env.local.');
  1802.                 return $renderAddWithData();
  1803.             }
  1804.             if (($data['ocr_mode'] ?? 'base') === 'base' && trim($ocrBinaryBase) === '') {
  1805.                 $this->addFlash('danger''OCR Base seleccionado pero falta configurar OCR_BINARY en .env.local.');
  1806.                 return $renderAddWithData();
  1807.             }
  1808.             if (trim((string)$filesPath) === '') {
  1809.                 $this->addFlash('danger''Falta configurar FILES_PATH en .env.local.');
  1810.                 return $renderAddWithData();
  1811.             }
  1812.             $owner $hestiaOwner// o el dueńo del hosting
  1813.             $postFields http_build_query([
  1814.                 'user' => $hestiaApiUser,
  1815.                 'password' => $hestiaApiPass,
  1816.                 'returncode' => 'yes',
  1817.                 'cmd' => 'v-add-database',
  1818.                 'arg1' => $owner,
  1819.                 'arg2' => $dbName,
  1820.                 'arg3' => $dbUser,
  1821.                 'arg4' => $dbPass,
  1822.                 'arg5' => 'mysql'
  1823.             ]);
  1824.             //dd($postFields);            
  1825.             $headers = [
  1826.                 'Authorization: Bearer ' $accessKeyId ':' $secretKey
  1827.             ];
  1828.             $ch curl_init();
  1829.             curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  1830.             curl_setopt($chCURLOPT_HTTPHEADER$headers);
  1831.             curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  1832.             curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  1833.             curl_setopt($chCURLOPT_POSTtrue);
  1834.             curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  1835.             curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  1836.             $response curl_exec($ch);
  1837.             $error curl_error($ch);
  1838.             curl_close($ch);
  1839.             if ($error || trim($response) !== '0') {
  1840.                 $this->addFlash('danger''Error al crear la base de datos en HestiaCP: ' . ($error ?: $response));
  1841.                 return $this->redirectToRoute('app_empresa_new');
  1842.             }
  1843.             //Añadir sql
  1844.             $sqlFile __DIR__ '/../../db/db_base.sql'// Ajusta la ruta si está en otro sitio
  1845.             if (!file_exists($sqlFile)) {
  1846.                 $this->addFlash('danger''Archivo db_base.sql no encontrado.');
  1847.                 return $this->redirectToRoute('app_empresa_new');
  1848.             }
  1849.             $mysqli = new \mysqli($dbHost"{$owner}_{$dbUser}"$dbPass"{$owner}_{$dbName}", (int)$dbPort);
  1850.             if ($mysqli->connect_error) {
  1851.                 $this->addFlash('danger''Error al conectar a la base de datos: ' $mysqli->connect_error);
  1852.                 return $this->redirectToRoute('app_empresa_new');
  1853.             }
  1854.             $sql file_get_contents($sqlFile);
  1855.             // Eliminar lĂ­neas con DELIMITER
  1856.             $sql preg_replace('/DELIMITER\s+\$\$/'''$sql);
  1857.             $sql preg_replace('/DELIMITER\s+;/'''$sql);
  1858.             // Separar por ';;' si los triggers usan ese delimitador (ajusta si es $$)
  1859.             $statements explode('$$'$sql);
  1860.             foreach ($statements as $statementIndex => $stmt) {
  1861.                 $stmt trim($stmt);
  1862.                 if ($stmt) {
  1863.                     try {
  1864.                         if (!$mysqli->multi_query($stmt)) {
  1865.                             $this->addFlash('danger''Error ejecutando SQL: ' $mysqli->error);
  1866.                             return $this->redirectToRoute('app_empresa_new');
  1867.                         }
  1868.                         // Limpiar cualquier resultado intermedio del bloque ejecutado.
  1869.                         do {
  1870.                             if ($result $mysqli->store_result()) {
  1871.                                 $result->free();
  1872.                             }
  1873.                         } while ($mysqli->more_results() && $mysqli->next_result());
  1874.                     } catch (\mysqli_sql_exception $e) {
  1875.                         $sqlSnippet mb_substr(preg_replace('/\s+/'' '$stmt), 0220);
  1876.                         $this->addFlash(
  1877.                             'danger',
  1878.                             sprintf(
  1879.                                 'Error ejecutando SQL en bloque %d: %s. Sentencia: %s',
  1880.                                 $statementIndex 1,
  1881.                                 $e->getMessage(),
  1882.                                 $sqlSnippet
  1883.                             )
  1884.                         );
  1885.                         return $this->redirectToRoute('app_empresa_new');
  1886.                     }
  1887.                 }
  1888.             }
  1889.             $updateSql "UPDATE users SET email = '" $mysqli->real_escape_string((string)$data["user"]) . "' WHERE id = 1";
  1890.             if (!$mysqli->query($updateSql)) {
  1891.                 $this->addFlash('danger''Error al actualizar usuario: ' $mysqli->error);
  1892.                 return $this->redirectToRoute('app_empresa_new');
  1893.             }
  1894.             // Guardar parámetros (activeUsers + modulos_*) en la BD del cliente
  1895.             $localExtractionModelId 0;
  1896.             if ($data['modulo_extraccion'] === 1) {
  1897.                 try {
  1898.                     if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1899.                         $localExtractionModelId $this->registerAoaiModelInClientDb(
  1900.                             $mysqli,
  1901.                             $azureResource ?? [],
  1902.                             $aoaiFields
  1903.                         );
  1904.                     } else {
  1905.                         $localExtractionModelId $this->registerDiModelInClientDb(
  1906.                             $mysqli,
  1907.                             $azureResource ?? [],
  1908.                             $azureModelId
  1909.                         );
  1910.                     }
  1911.                 } catch (\Throwable $e) {
  1912.                     $this->addFlash('danger''No se pudo registrar el modelo en la BBDD cliente: ' $e->getMessage());
  1913.                     $mysqli->close();
  1914.                     return $this->redirectToRoute('app_empresa_new');
  1915.                 }
  1916.             }
  1917.             $data['extraction_model'] = $localExtractionModelId;
  1918.             // SUBIR LOGO PERSONALIZADO (SI LO HAY) ===
  1919.             $customLogoFile null;
  1920.             try {
  1921.                 $customLogoFile $this->uploadEmpresaLogo($req);
  1922.             } catch (\Throwable $e) {
  1923.                 // Aquí decides si quieres que esto sea fatal o solo un aviso
  1924.                 $this->addFlash('warning''El logo personalizado no se pudo subir: ' $e->getMessage());
  1925.                 // Si quieres abortar todo el proceso por fallo de logo, haz return+redirect aquí.
  1926.             }
  1927.             // Guardar logo en la BD del cliente
  1928.             $this->saveEmpresaLogo($mysqli$data$customLogoFile);
  1929.             // Actualizar/insertar license (capacityGb y users) en la BD del cliente
  1930.             $this->upsertLicense(
  1931.                 $mysqli,
  1932.                 $data,
  1933.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== '' ? (int)$data['maxDiskQuota'] : 200,
  1934.                 isset($data['maxActiveUsers']) && $data['maxActiveUsers'] !== '' ? (int)$data['maxActiveUsers'] : 3
  1935.             );
  1936.             // Guardar parametros (activeUsers + modulos_*) en la BD del cliente
  1937.             $this->saveEmpresaParametros($mysqli$data);
  1938.             $this->licenseContractService->createInitialContract($mysqli$data);
  1939.             if ((int)($data['modulo_gstock'] ?? 0) === && (int)($data['extraction_model'] ?? 0) > 0) {
  1940.                 try {
  1941.                     $this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
  1942.                 } catch (\Throwable $e) {
  1943.                     $this->addFlash('warning''No se pudo completar el automapeo de Gstock: ' $e->getMessage());
  1944.                 }
  1945.             }
  1946.             // Crear y persistir la empresa
  1947.             $emp = new Empresa();
  1948.             $emp->setName($data["name"]);
  1949.             $emp->setMaxDiskQuota((int)$data["maxDiskQuota"]);
  1950.             $emp->setMaxThreads($maxThreads);
  1951.             $em->persist($emp);
  1952.             $em->flush();
  1953.             $conexionBD = new ConexionBD();
  1954.             $conexionBD->setDbName($owner "_" $dbName);
  1955.             $conexionBD->setDbUser($owner "_" $dbUser);
  1956.             $conexionBD->setDbPassword($dbPass);
  1957.             $conexionBD->setDbUrl($dbHost);
  1958.             $conexionBD->setDbPort($dbPort);
  1959.             $em->persist($conexionBD);
  1960.             $em->flush();
  1961.             $emp->setConexionBD($conexionBD);
  1962.             $em->persist($emp);
  1963.             $em->flush();
  1964.             //crear usuario
  1965.             $user = new \App\Entity\Usuario();
  1966.             $user->setEmail($data["user"]);
  1967.             $user->setEmpresa($emp);
  1968.             $user->setPassword("dscsdcsno2234dwvw");
  1969.             $user->setStatus(1);
  1970.             $user->setIsAdmin(2);
  1971.             $user->setConnection($conexionBD->getId());
  1972.             $em->persist($user);
  1973.             $em->flush();
  1974.             //crear el script de bash
  1975.             $company_name $emp->getId();
  1976.             // "DOCU_MAX_THREADS=" por defecto 4
  1977.             // "NO" al final es para desactivar FTP
  1978.             // Crear archivo .service
  1979.             $empresaName = (string)($data['name'] ?? '');
  1980.             $serviceContent = <<<EOT
  1981. [Unit]
  1982. Description={$empresaName} DocuManager OCR
  1983. Requires=mariadb.service
  1984. After=mariadb.service
  1985. [Service]
  1986. Type=simple
  1987. Environment="DOCU_MAX_THREADS=$maxThreads"
  1988. ExecStart=$ocrBinary localhost/{$owner}_{$dbName} {$owner}_{$dbUser} {$dbPass} {$filesPath}/{$company_name} NO
  1989. Restart=always
  1990. User=docunecta
  1991. [Install]
  1992. WantedBy=multi-user.target
  1993. EOT;
  1994.             // Guardar contenido temporal en un archivo dentro de /tmp
  1995.             $serviceName $company_name "-documanager.service";
  1996.             $tmpServicePath "/tmp/$serviceName";
  1997.             file_put_contents($tmpServicePath$serviceContent);
  1998.             \chmod($tmpServicePath0644);
  1999.             // Mover el archivo y habilitar el servicio desde PHP con shell_exec
  2000.             $commands = [
  2001.                 "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  2002.                 "sudo /bin/systemctl daemon-reload",
  2003.                 "sudo /bin/systemctl enable $serviceName",
  2004.                 "sudo /bin/systemctl start $serviceName",
  2005.             ];
  2006.             $errors = [];
  2007.             foreach ($commands as $cmd) {
  2008.                 $output \shell_exec($cmd " 2>&1");
  2009.                 if ($output !== null) {
  2010.                     // Puedes loguearlo si quieres para ver errores
  2011.                     error_log("CMD OUTPUT: $cmd\n$output");
  2012.                     $errors[] = "CMD OUTPUT: $cmd\n$output";
  2013.                 }
  2014.             }
  2015.             // === Crear servicio AZURE DI por tenant ===
  2016.             $azureBasePort 12000;
  2017.             $tenantId      = (int)$emp->getId();
  2018.             $port          $this->azurePortForTenant($tenantId$azureBasePort20999);
  2019.             $serviceName   $tenantId "-azuredi.service";
  2020.             $workdir       $this->azureDiWorkdirFromEnv();
  2021.             $docuPhpBaseUrl $this->azureDiPhpBaseUrl($req);
  2022.             if ($workdir !== '' && $docuPhpBaseUrl !== '') {
  2023.                 $logsDir $workdir "/logs/" $tenantId;
  2024.                 // Asegura carpeta de logs
  2025.                 @mkdir($logsDir0775true);
  2026.                 $serviceContent = <<<EOT
  2027. [Unit]
  2028. Description=DocuManager Azure DI {$tenantId}
  2029. After=network.target
  2030. [Service]
  2031. User=root
  2032. WorkingDirectory={$workdir}
  2033. Environment=APP_HOST=127.0.0.1
  2034. Environment=APP_PORT={$port}
  2035. Environment=LOG_DIR={$logsDir}
  2036. Environment=PYTHONUNBUFFERED=1
  2037. Environment=MAX_CONCURRENT=20
  2038. Environment="PATH=/opt/azure-di/.venv/bin:/usr/local/bin:/usr/bin"
  2039. EnvironmentFile=-{$workdir}/.env
  2040. # ---- Scheduler interno de extracción ----
  2041. Environment=EXTRACT_TICK_ENABLED=1
  2042. Environment=EXTRACT_TICK_INTERVAL_SEC=10
  2043. Environment=DOCU_TENANT_ID={$tenantId}
  2044. Environment=EXTRACT_TICK_LIMIT=30
  2045. Environment=EXTRACT_TICK_TIMEOUT_SEC=25
  2046. Environment=EXTRACT_TICK_LOCK_FILE=/tmp/azure_di_extract_tick_{$tenantId}.lock
  2047. # Ajustar URL base pública/interna de PHP
  2048. Environment=DOCU_PHP_BASE_URL={$docuPhpBaseUrl}
  2049. # Debe coincidir con EXTRACT_INTERNAL_TOKEN del lado PHP
  2050. Environment=EXTRACT_INTERNAL_TOKEN=8c7e7a1b4d0f6e2a9c1d3f5b7a8e0c2d4f6a1b3c5d7e9f0a2c4e6b8d0f1a3c5
  2051. ExecStart=/opt/azure-di/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port {$port} --proxy-headers --workers 2
  2052. Restart=always
  2053. RestartSec=2
  2054. StandardOutput=journal
  2055. StandardError=journal
  2056. [Install]
  2057. WantedBy=multi-user.target
  2058. EOT;
  2059.                 $tmpServicePath "/tmp/{$serviceName}";
  2060.                 file_put_contents($tmpServicePath$serviceContent);
  2061.                 @chmod($tmpServicePath0644);
  2062.                 $cmds = [
  2063.                     "sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
  2064.                     "sudo /bin/systemctl daemon-reload",
  2065.                     "sudo /bin/systemctl enable {$serviceName}",
  2066.                     "sudo /bin/systemctl start {$serviceName}",
  2067.                 ];
  2068.                 foreach ($cmds as $cmd) {
  2069.                     $out \shell_exec($cmd " 2>&1");
  2070.                     if ($out !== null) {
  2071.                         error_log("AZURE-DI CMD: $cmd\n$out");
  2072.                     }
  2073.                 }
  2074.             } else {
  2075.                 $missing = [];
  2076.                 if ($workdir === ''$missing[] = 'AZURE_DI_WORKDIR';
  2077.                 if ($docuPhpBaseUrl === ''$missing[] = 'AZURE_DI_DOCU_PHP_BASE_URL';
  2078.                 $this->addFlash('warning''No se pudo crear el servicio de extraccion IA: faltan variables de entorno: ' implode(', '$missing));
  2079.             }
  2080.             // === Crear servicio/timer Mail Monitor si el modulo esta activo ===
  2081.             if ($mailMonitorEnabled) {
  2082.                 $this->ensureMailMonitorService(
  2083.                     (int)$company_name,
  2084.                     (string)$dbHost,
  2085.                     (string)$dbPort,
  2086.                     (string)$owner "_" . (string)$dbUser,
  2087.                     (string)$dbPass,
  2088.                     (string)$owner "_" . (string)$dbName,
  2089.                     (string)$filesPath
  2090.                 );
  2091.             }
  2092.             if (count($errors) > 0) {
  2093.                 $this->addFlash('success''Empresa y base de datos creadas correctamente: ' implode(" | "$errors));
  2094.             }
  2095.             $this->addFlash('success''Empresa y base de datos creadas correctamente.');
  2096.             return $this->redirectToRoute('list');
  2097.         } else {
  2098.             // Carga recursos IA para el formulario de alta
  2099.             return $this->render('empresa/_add.html.twig', [
  2100.                 'azure_resources' => $azureResources,
  2101.                 'azure_di_resources' => $azureDiResources,
  2102.                 'azure_openai_resources' => $azureOpenAiResources,
  2103.                 'modulos' => [
  2104.                     'extraction_model' => 0,
  2105.                 ],
  2106.                 'form_data' => [
  2107.                     'extractor_type' => self::EXTRACTOR_TYPE_AZURE_DI,
  2108.                     'ocr_mode' => 'base',
  2109.                     'license_type' => 'page',
  2110.                     'license_mode' => 'monthly',
  2111.                     'license_limit_mode' => 'block',
  2112.                     'license_start_date' => date('Y-m-d'),
  2113.                     'license_units_total' => 1000,
  2114.                 ],
  2115.                 'activeLicenseContract' => $this->licenseContractService->defaultContract(),
  2116.                 'aoai_fields' => [],
  2117.                 'ocr_plus_available' => $ocrPlusAvailable,
  2118.                 'ocr_v2_available' => $ocrV2Available,
  2119.             ]);
  2120.         }
  2121.     }
  2122.     private function saveEmpresaParametros(\mysqli $mysqli, array $data): void
  2123.     {
  2124.         // Helpers
  2125.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  2126.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  2127.         // Keys y valores tal y como quieres guardarlos
  2128.         $paramMap = [
  2129.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  2130.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  2131.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  2132.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  2133.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  2134.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  2135.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  2136.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  2137.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  2138.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  2139.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  2140.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  2141.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  2142.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  2143.             'modulo_prinex'      => $getFlag($data'modulo_prinex'),
  2144.             'extraction_model'  => $getInt($data'extraction_model'0),
  2145.             'ocr_mode'          => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
  2146.             'tokensContratados' => max(0$getInt($data'tokensContratados'0)),
  2147.         ];
  2148.         if ($paramMap['modulo_extraccion'] === 0) {
  2149.             $paramMap['modulo_expowin'] = 0;
  2150.             $paramMap['modulo_prinex'] = 0;
  2151.         }
  2152.         // RECOMENDADO en tu SQL base:
  2153.         // ALTER TABLE parametros ADD UNIQUE KEY uniq_nombre (nombre);
  2154.         $mysqli->begin_transaction();
  2155.         try {
  2156.             $stmt $mysqli->prepare("
  2157.                 INSERT INTO parametros (nombre, valor)
  2158.                 VALUES (?, ?)
  2159.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  2160.             ");
  2161.             if (!$stmt) {
  2162.                 throw new \RuntimeException('Prepare parametros: ' $mysqli->error);
  2163.             }
  2164.             foreach ($paramMap as $nombre => $valor) {
  2165.                 // valor es TEXT en tu esquema: bindeamos como string
  2166.                 $v = (string)$valor;
  2167.                 $stmt->bind_param('ss'$nombre$v);
  2168.                 if (!$stmt->execute()) {
  2169.                     throw new \RuntimeException("Guardar parámetro $nombre: " $stmt->error);
  2170.                 }
  2171.             }
  2172.             $stmt->close();
  2173.             $mysqli->commit();
  2174.         } catch (\Throwable $e) {
  2175.             $mysqli->rollback();
  2176.             // Si NO puedes ańadir UNIQUE(nombre), usa fallback DELETE+INSERT:
  2177.             // $this->saveParametrosFallback($mysqli, $paramMap);
  2178.             throw $e;
  2179.         }
  2180.     }
  2181.     private function applyGstockAutoMappings(\mysqli $mysqliint $modelId): void
  2182.     {
  2183.         if ($modelId <= 0) {
  2184.             return;
  2185.         }
  2186.         $available = [
  2187.             'header' => [],
  2188.             'line' => [],
  2189.         ];
  2190.         $stmtHeader $mysqli->prepare('SELECT field_key FROM definitions_header WHERE model_id = ?');
  2191.         if (!$stmtHeader) {
  2192.             throw new \RuntimeException('Prepare SELECT definitions_header para automapeo Gstock: ' $mysqli->error);
  2193.         }
  2194.         $stmtHeader->bind_param('i'$modelId);
  2195.         if (!$stmtHeader->execute()) {
  2196.             $stmtHeader->close();
  2197.             throw new \RuntimeException('Execute SELECT definitions_header para automapeo Gstock: ' $stmtHeader->error);
  2198.         }
  2199.         $resHeader $stmtHeader->get_result();
  2200.         while ($resHeader && ($row $resHeader->fetch_assoc())) {
  2201.             $fieldKey = (string)($row['field_key'] ?? '');
  2202.             if ($fieldKey !== '') {
  2203.                 $available['header'][$fieldKey] = true;
  2204.             }
  2205.         }
  2206.         $stmtHeader->close();
  2207.         $stmtLines $mysqli->prepare('SELECT field_key FROM definitions_lines WHERE model_id = ?');
  2208.         if (!$stmtLines) {
  2209.             throw new \RuntimeException('Prepare SELECT definitions_lines para automapeo Gstock: ' $mysqli->error);
  2210.         }
  2211.         $stmtLines->bind_param('i'$modelId);
  2212.         if (!$stmtLines->execute()) {
  2213.             $stmtLines->close();
  2214.             throw new \RuntimeException('Execute SELECT definitions_lines para automapeo Gstock: ' $stmtLines->error);
  2215.         }
  2216.         $resLines $stmtLines->get_result();
  2217.         while ($resLines && ($row $resLines->fetch_assoc())) {
  2218.             $fieldKey = (string)($row['field_key'] ?? '');
  2219.             if ($fieldKey !== '') {
  2220.                 $available['line'][$fieldKey] = true;
  2221.             }
  2222.         }
  2223.         $stmtLines->close();
  2224.         $existing = [];
  2225.         $stmtExisting $mysqli->prepare('SELECT type, source, destination FROM gstock_mapping WHERE model_id = ?');
  2226.         if (!$stmtExisting) {
  2227.             throw new \RuntimeException('Prepare SELECT gstock_mapping para automapeo: ' $mysqli->error);
  2228.         }
  2229.         $stmtExisting->bind_param('i'$modelId);
  2230.         if (!$stmtExisting->execute()) {
  2231.             $stmtExisting->close();
  2232.             throw new \RuntimeException('Execute SELECT gstock_mapping para automapeo: ' $stmtExisting->error);
  2233.         }
  2234.         $resExisting $stmtExisting->get_result();
  2235.         while ($resExisting && ($row $resExisting->fetch_assoc())) {
  2236.             $type = (string)($row['type'] ?? '');
  2237.             $source = (string)($row['source'] ?? '');
  2238.             $destination = (string)($row['destination'] ?? '');
  2239.             if ($type !== '' && $source !== '' && $destination !== '') {
  2240.                 $existing[$type '|' $source '|' $destination] = true;
  2241.             }
  2242.         }
  2243.         $stmtExisting->close();
  2244.         $mysqli->begin_transaction();
  2245.         try {
  2246.             $stmtInsert $mysqli->prepare(
  2247.                 'INSERT INTO gstock_mapping (model_id, type, source, destination) VALUES (?, ?, ?, ?)'
  2248.             );
  2249.             if (!$stmtInsert) {
  2250.                 throw new \RuntimeException('Prepare INSERT gstock_mapping para automapeo: ' $mysqli->error);
  2251.             }
  2252.             foreach (GstockAutoMappings::MAPPINGS as $type => $pairs) {
  2253.                 foreach ($pairs as $pair) {
  2254.                     $source = (string)($pair['source'] ?? '');
  2255.                     $destination = (string)($pair['destination'] ?? '');
  2256.                     if ($source === '' || $destination === '') {
  2257.                         continue;
  2258.                     }
  2259.                     if ($type === 'header' && !isset($available['header'][$source])) {
  2260.                         continue;
  2261.                     }
  2262.                     if ($type === 'line' && !isset($available['line'][$source])) {
  2263.                         continue;
  2264.                     }
  2265.                     $key $type '|' $source '|' $destination;
  2266.                     if (isset($existing[$key])) {
  2267.                         continue;
  2268.                     }
  2269.                     $stmtInsert->bind_param('isss'$modelId$type$source$destination);
  2270.                     if (!$stmtInsert->execute()) {
  2271.                         $stmtInsert->close();
  2272.                         throw new \RuntimeException('Execute INSERT gstock_mapping para automapeo: ' $stmtInsert->error);
  2273.                     }
  2274.                     $existing[$key] = true;
  2275.                 }
  2276.             }
  2277.             $stmtInsert->close();
  2278.             $mysqli->commit();
  2279.         } catch (\Throwable $e) {
  2280.             $mysqli->rollback();
  2281.             throw $e;
  2282.         }
  2283.     }
  2284.     private function saveEmpresaLogo(\mysqli $mysqli, array $data, ?string $customLogoFile null): void
  2285.     {
  2286.         $this->logLogo('empresa_logo_save.log''--- NUEVA LLAMADA saveEmpresaLogo ---');
  2287.         $this->logLogo('empresa_logo_save.log''customLogoFile = ' var_export($customLogoFiletrue));
  2288.         $this->logLogo('empresa_logo_save.log''data[empresa_vendor] = ' var_export($data['empresa_vendor'] ?? nulltrue));
  2289.         // 1) Decidir qué logo vamos a guardar
  2290.         $logoFile null;
  2291.         // --- PRIORIDAD: LOGO PERSONALIZADO ---
  2292.         if ($customLogoFile !== null && $customLogoFile !== '') {
  2293.             $logoFile $customLogoFile;
  2294.             $this->logLogo('empresa_logo_save.log''Usando logo personalizado: ' $logoFile);
  2295.         } else {
  2296.             // --- SI NO HAY PERSONALIZADO, USAMOS EL SELECT DE EMPRESA ---
  2297.             if (!isset($data['empresa_vendor']) || $data['empresa_vendor'] === '') {
  2298.                 $this->logLogo('empresa_logo_save.log''No hay empresa_vendor y no hay logo custom. No hago nada.');
  2299.                 return;
  2300.             }
  2301.             $empresa $data['empresa_vendor'];
  2302.             $empresaKey strtolower(trim((string)$empresa));
  2303.             $logoMap = [
  2304.                 'docunecta'  => 'DocuManager_transparente.png',
  2305.                 'docuindexa' => 'DocuIndexa.png',
  2306.             ];
  2307.             $vendorLabelMap = [
  2308.                 'docunecta'  => 'Docunecta',
  2309.                 'docuindexa' => 'Docuindexa',
  2310.             ];
  2311.             if (!isset($logoMap[$empresaKey])) {
  2312.                 $this->logLogo('empresa_logo_save.log'"empresa_vendor $empresa no está en logoMap. No hago nada.");
  2313.                 return;
  2314.             }
  2315.             $logoFile $logoMap[$empresaKey];
  2316.             $vendorLabel $vendorLabelMap[$empresaKey] ?? null;
  2317.             $this->logLogo('empresa_logo_save.log''Usando logo por vendor: ' $logoFile);
  2318.         }
  2319.         if ($logoFile === null || $logoFile === '') {
  2320.             $this->logLogo('empresa_logo_save.log''logoFile está vacío. No hago nada.');
  2321.             return;
  2322.         }
  2323.         $this->logLogo('empresa_logo_save.log''Voy a guardar en parametros.logo: ' $logoFile);
  2324.         $mysqli->begin_transaction();
  2325.         try {
  2326.             $stmt $mysqli->prepare("
  2327.                 INSERT INTO parametros (nombre, valor)
  2328.                 VALUES (?, ?)
  2329.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  2330.             ");
  2331.             if (!$stmt) {
  2332.                 $this->logLogo('empresa_logo_save.log''Error prepare: ' $mysqli->error);
  2333.                 throw new \RuntimeException('Prepare logo: ' $mysqli->error);
  2334.             }
  2335.             $paramName 'logo';
  2336.             $stmt->bind_param('ss'$paramName$logoFile);
  2337.             if (!$stmt->execute()) {
  2338.                 $this->logLogo('empresa_logo_save.log''Error execute: ' $stmt->error);
  2339.                 throw new \RuntimeException('Guardar parámetro logo: ' $stmt->error);
  2340.             }
  2341.             if (isset($vendorLabel) && $vendorLabel !== '') {
  2342.                 $paramName 'vendor';
  2343.                 $stmt->bind_param('ss'$paramName$vendorLabel);
  2344.                 if (!$stmt->execute()) {
  2345.                     $this->logLogo('empresa_logo_save.log''Error execute vendor: ' $stmt->error);
  2346.                     throw new \RuntimeException('Guardar parámetro vendor: ' $stmt->error);
  2347.                 }
  2348.             }
  2349.             $stmt->close();
  2350.             $mysqli->commit();
  2351.             $this->logLogo('empresa_logo_save.log''Logo guardado correctamente en BD.');
  2352.         } catch (\Throwable $e) {
  2353.             $mysqli->rollback();
  2354.             $this->logLogo('empresa_logo_save.log''EXCEPCIÓN: ' $e->getMessage());
  2355.             throw $e;
  2356.         }
  2357.     }
  2358.     private function uploadEmpresaLogo(Request $req): ?string
  2359.     {
  2360.         $this->logLogo('empresa_logo_upload.log''--- NUEVA LLAMADA uploadEmpresaLogo ---');
  2361.         // name del checkbox en el formulario (ajústalo si usas otro)
  2362.         $useCustomLogo $req->request->get('customLogoCheck');
  2363.         $this->logLogo('empresa_logo_upload.log''customLogoCheck = ' var_export($useCustomLogotrue));
  2364.         // Si no marcaron "usar logo personalizado", no hacemos nada
  2365.         if (!$useCustomLogo) {
  2366.             $this->logLogo('empresa_logo_upload.log''No se ha marcado customLogoCheck. Salgo sin subir.');
  2367.             return null;
  2368.         }
  2369.         /** @var UploadedFile|null $file */
  2370.         $file $req->files->get('logo_personalizado'); // name="logo_personalizado" en el input file
  2371.         $this->logLogo('empresa_logo_upload.log''FILES[logo_personalizado] = ' print_r($filetrue));
  2372.         if (!$file instanceof UploadedFile || !$file->isValid()) {
  2373.             $this->logLogo('empresa_logo_upload.log''File no es UploadedFile válido. Salgo sin subir.');
  2374.             return null;
  2375.         }
  2376.         // VALIDACIONES BÁSICAS
  2377.         $maxSize 1024 1024// 2 MB por ejemplo
  2378.         if ($file->getSize() > $maxSize) {
  2379.             $this->logLogo('empresa_logo_upload.log''Tamańo demasiado grande: ' $file->getSize());
  2380.             throw new \RuntimeException('El logo personalizado es demasiado grande (máx 2MB).');
  2381.         }
  2382.         $mime $file->getMimeType();
  2383.         $allowedMimeTypes = ['image/png''image/jpeg''image/webp''image/svg+xml'];
  2384.         $this->logLogo('empresa_logo_upload.log''MIME = ' $mime);
  2385.         if (!in_array($mime$allowedMimeTypestrue)) {
  2386.             $this->logLogo('empresa_logo_upload.log''MIME no permitido.');
  2387.             throw new \RuntimeException('Formato de logo no permitido. Usa PNG, JPG, WEBP o SVG.');
  2388.         }
  2389.         // Directorio destino según entorno (configurado en .env/.env.local)
  2390.         $targetDir $_ENV['APP_LOGO_DIR'] ?? null;
  2391.         $this->logLogo('empresa_logo_upload.log''APP_LOGO_DIR = ' var_export($targetDirtrue));
  2392.         if (!$targetDir) {
  2393.             throw new \RuntimeException('APP_LOGO_DIR no está configurado en el entorno.');
  2394.         }
  2395.         if (!is_dir($targetDir)) {
  2396.             $this->logLogo('empresa_logo_upload.log'"El directorio no existe: $targetDir");
  2397.             throw new \RuntimeException("El directorio de logos no existe: $targetDir");
  2398.         }
  2399.         if (!is_writable($targetDir)) {
  2400.             $this->logLogo('empresa_logo_upload.log'"El directorio no es escribible: $targetDir");
  2401.             throw new \RuntimeException("El directorio de logos no es escribible: $targetDir");
  2402.         }
  2403.         // Nombre de archivo "seguro" y único
  2404.         $ext $file->guessExtension() ?: 'png';
  2405.         $fileName 'logo_empresa_' bin2hex(random_bytes(6)) . '.' $ext;
  2406.         $this->logLogo('empresa_logo_upload.log'"Voy a mover archivo como: $fileName");
  2407.         // Mover físicamente el archivo
  2408.         $file->move($targetDir$fileName);
  2409.         $this->logLogo('empresa_logo_upload.log'"Fichero movido OK a $targetDir/$fileName");
  2410.         // Devolvemos SOLO el nombre, que es lo que se guardará en parametros.logo
  2411.         return $fileName;
  2412.     }
  2413.     private function logLogo(string $fileNamestring $message): void
  2414.     {
  2415.         // Directorio de logs de Symfony (donde está dev.log/prod.log)
  2416.         $logDir $this->getParameter('kernel.logs_dir');
  2417.         $fullPath rtrim($logDir'/') . '/' $fileName;
  2418.         $line sprintf(
  2419.             "[%s] %s\n",
  2420.             date('Y-m-d H:i:s'),
  2421.             $message
  2422.         );
  2423.         file_put_contents($fullPath$lineFILE_APPEND);
  2424.     }
  2425.     private function loadEmpresaLogo(\mysqli $mysqli): ?string
  2426.     {
  2427.         $sql "SELECT valor FROM parametros WHERE nombre = 'logo' LIMIT 1";
  2428.         $res $mysqli->query($sql);
  2429.         if (!$res) {
  2430.             return null;
  2431.         }
  2432.         if ($row $res->fetch_assoc()) {
  2433.             return $row['valor'] ?? null;
  2434.         }
  2435.         return null;
  2436.     }
  2437.     private function upsertLicense(\mysqli $mysqli, array $dataint $capacityGb 200int $activeUsers 3): void
  2438.     {
  2439.         $clientName = (string)($data['name'] ?? '');
  2440.         $licenseStr  'Documanager';
  2441.         $initialDate date('Y-m-d');
  2442.         $price       0;
  2443.         $emailSender 'Documanager.es';
  2444.         $emailFrom   'no-reply@docunecta.com';
  2445.         $emailName   'Documanager';
  2446.         $ins $mysqli->prepare("
  2447.             INSERT INTO license
  2448.                 (client, license, initialDate, capacityGb, users, price, emailSender, emailFrom, emailName)
  2449.             VALUES
  2450.                 (?,      ?,       ?,          ?,          ?,     ?,     ?,           ?,         ?)
  2451.         ");
  2452.         if (!$ins) {
  2453.             throw new \RuntimeException('Prepare INSERT license: ' $mysqli->error);
  2454.         }
  2455.         $ins->bind_param(
  2456.             'sssiiisss',
  2457.             $clientName,
  2458.             $licenseStr,
  2459.             $initialDate,
  2460.             $capacityGb,
  2461.             $activeUsers,
  2462.             $price,
  2463.             $emailSender,
  2464.             $emailFrom,
  2465.             $emailName
  2466.         );
  2467.         if (!$ins->execute()) {
  2468.             $ins->close();
  2469.             throw new \RuntimeException('Execute INSERT license: ' $ins->error);
  2470.         }
  2471.         $ins->close();
  2472.     }
  2473.     private function mapAzureSchemaToDefinitions(array $modelDetail): array
  2474.     {
  2475.         $docTypes $modelDetail['docTypes'] ?? [];
  2476.         if (!is_array($docTypes) || $docTypes === []) {
  2477.             return ['header' => [], 'lines' => []];
  2478.         }
  2479.         $modelId = (string)($modelDetail['modelId'] ?? '');
  2480.         $docTypeKey = ($modelId !== '' && array_key_exists($modelId$docTypes))
  2481.             ? $modelId
  2482.             array_key_first($docTypes);
  2483.         if (!is_string($docTypeKey) || !isset($docTypes[$docTypeKey]) || !is_array($docTypes[$docTypeKey])) {
  2484.             return ['header' => [], 'lines' => []];
  2485.         }
  2486.         $fieldSchema $docTypes[$docTypeKey]['fieldSchema'] ?? [];
  2487.         if (!is_array($fieldSchema)) {
  2488.             return ['header' => [], 'lines' => []];
  2489.         }
  2490.         $rawHeader = [];
  2491.         $rawLines = [];
  2492.         foreach ($fieldSchema as $fieldKey => $fieldDef) {
  2493.             if (!is_string($fieldKey) || !is_array($fieldDef)) {
  2494.                 continue;
  2495.             }
  2496.             $this->flattenFieldSchema($fieldKey$fieldDef$rawHeader$rawLines);
  2497.         }
  2498.         $header = [];
  2499.         $lines = [];
  2500.         $seenHeader = [];
  2501.         $seenLines = [];
  2502.         foreach ($rawHeader as $item) {
  2503.             $key = (string)($item['field_key'] ?? '');
  2504.             if ($key === '' || isset($seenHeader[$key])) {
  2505.                 continue;
  2506.             }
  2507.             $seenHeader[$key] = true;
  2508.             $header[] = [
  2509.                 'field_key' => $key,
  2510.                 'label' => $key,
  2511.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  2512.             ];
  2513.         }
  2514.         foreach ($rawLines as $item) {
  2515.             $key = (string)($item['field_key'] ?? '');
  2516.             if ($key === '' || isset($seenLines[$key])) {
  2517.                 continue;
  2518.             }
  2519.             $seenLines[$key] = true;
  2520.             $lines[] = [
  2521.                 'field_key' => $key,
  2522.                 'label' => $key,
  2523.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  2524.             ];
  2525.         }
  2526.         foreach ($header as $index => &$item) {
  2527.             $order $index 1;
  2528.             $item['order_index'] = $order;
  2529.             $item['order_index_table'] = $order;
  2530.             $item['visibility'] = 1;
  2531.             $item['visibility_table'] = 1;
  2532.         }
  2533.         foreach ($lines as $index => &$item) {
  2534.             $item['order_index'] = $index 1;
  2535.             $item['visibility'] = 1;
  2536.         }
  2537.         return ['header' => $header'lines' => $lines];
  2538.     }
  2539.     private function flattenFieldSchema(
  2540.         string $fieldKey,
  2541.         array $fieldDef,
  2542.         array &$header,
  2543.         array &$lines,
  2544.         bool $insideItems false,
  2545.         string $parentPath ''
  2546.     ): void {
  2547.         $type strtolower((string)($fieldDef['type'] ?? 'string'));
  2548.         $currentPath $parentPath !== '' $parentPath '.' $fieldKey $fieldKey;
  2549.         if ($insideItems) {
  2550.             if ($type === 'object') {
  2551.                 $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  2552.                 if (is_array($properties)) {
  2553.                     foreach ($properties as $childKey => $childDef) {
  2554.                         if (is_string($childKey) && is_array($childDef)) {
  2555.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue$currentPath);
  2556.                         }
  2557.                     }
  2558.                 }
  2559.                 return;
  2560.             }
  2561.             if ($type === 'array') {
  2562.                 $itemsDef $fieldDef['items'] ?? [];
  2563.                 $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  2564.                 if ($itemType === 'object') {
  2565.                     $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  2566.                     if (is_array($properties)) {
  2567.                         $arrayPath $currentPath '[*]';
  2568.                         foreach ($properties as $childKey => $childDef) {
  2569.                             if (is_string($childKey) && is_array($childDef)) {
  2570.                                 $this->flattenFieldSchema($childKey$childDef$header$linestrue$arrayPath);
  2571.                             }
  2572.                         }
  2573.                     }
  2574.                 } else {
  2575.                     $lines[] = ['field_key' => $currentPath'azure_type' => $itemType];
  2576.                 }
  2577.                 return;
  2578.             }
  2579.             $lines[] = ['field_key' => $currentPath'azure_type' => $type];
  2580.             return;
  2581.         }
  2582.         if ($type === 'object') {
  2583.             $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  2584.             if (is_array($properties)) {
  2585.                 foreach ($properties as $childKey => $childDef) {
  2586.                     if (is_string($childKey) && is_array($childDef)) {
  2587.                         $this->flattenFieldSchema($childKey$childDef$header$linesfalse$currentPath);
  2588.                     }
  2589.                 }
  2590.             }
  2591.             return;
  2592.         }
  2593.         if ($type === 'array') {
  2594.             $itemsDef $fieldDef['items'] ?? [];
  2595.             $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  2596.             $isItems strtolower($fieldKey) === 'items';
  2597.             if ($itemType === 'object') {
  2598.                 $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  2599.                 if (!is_array($properties)) {
  2600.                     return;
  2601.                 }
  2602.                 if ($isItems) {
  2603.                     foreach ($properties as $childKey => $childDef) {
  2604.                         if (is_string($childKey) && is_array($childDef)) {
  2605.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue'');
  2606.                         }
  2607.                     }
  2608.                 } else {
  2609.                     $arrayPath $currentPath '[*]';
  2610.                     foreach ($properties as $childKey => $childDef) {
  2611.                         if (is_string($childKey) && is_array($childDef)) {
  2612.                             $this->flattenFieldSchema($childKey$childDef$header$linesfalse$arrayPath);
  2613.                         }
  2614.                     }
  2615.                 }
  2616.                 return;
  2617.             }
  2618.             $header[] = ['field_key' => $currentPath'azure_type' => $itemType];
  2619.             return;
  2620.         }
  2621.         $header[] = ['field_key' => $currentPath'azure_type' => $type];
  2622.     }
  2623.     private function mapAzureTypeToValueType(string $azureType): string
  2624.     {
  2625.         $azureType strtolower(trim($azureType));
  2626.         if ($azureType === 'date') {
  2627.             return 'date';
  2628.         }
  2629.         if ($azureType === 'number' || $azureType === 'integer') {
  2630.             return 'number';
  2631.         }
  2632.         return 'string';
  2633.     }
  2634.     private function registerDiModelInClientDb(\mysqli $clientMysqli, array $resourcestring $modelId): int
  2635.     {
  2636.         $modelId trim($modelId);
  2637.         if ($modelId === '') {
  2638.             throw new \RuntimeException('ModelId de recurso IA obligatorio.'400);
  2639.         }
  2640.         $modelDetail $this->azureRequest(
  2641.             (string)($resource['endpoint'] ?? ''),
  2642.             (string)($resource['api_key'] ?? ''),
  2643.             '/documentintelligence/documentModels/' rawurlencode($modelId)
  2644.         );
  2645.         $type $this->classifyModelType($modelDetail);
  2646.         $definitions $this->mapAzureSchemaToDefinitions($modelDetail);
  2647.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  2648.         $apiKey = (string)($resource['api_key'] ?? '');
  2649.         $showConfidenceBadges 0;
  2650.         $clientMysqli->begin_transaction();
  2651.         try {
  2652.             $existingId 0;
  2653.             $stmtSelect $clientMysqli->prepare(
  2654.                 "SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? LIMIT 1"
  2655.             );
  2656.             if (!$stmtSelect) {
  2657.                 throw new \RuntimeException('Prepare SELECT extraction_models: ' $clientMysqli->error);
  2658.             }
  2659.             $provider self::EXTRACTOR_TYPE_AZURE_DI;
  2660.             $stmtSelect->bind_param('ss'$provider$modelId);
  2661.             if (!$stmtSelect->execute()) {
  2662.                 $stmtSelect->close();
  2663.                 throw new \RuntimeException('Execute SELECT extraction_models: ' $stmtSelect->error);
  2664.             }
  2665.             $result $stmtSelect->get_result();
  2666.             if ($result && ($row $result->fetch_assoc())) {
  2667.                 $existingId = (int)$row['id'];
  2668.             }
  2669.             $stmtSelect->close();
  2670.             if ($existingId 0) {
  2671.                 $stmtUpdate $clientMysqli->prepare(
  2672.                     'UPDATE extraction_models
  2673.                      SET endpoint = ?, api_key = ?, type = ?, show_confidence_badges = ?
  2674.                      WHERE id = ?'
  2675.                 );
  2676.                 if (!$stmtUpdate) {
  2677.                     throw new \RuntimeException('Prepare UPDATE extraction_models: ' $clientMysqli->error);
  2678.                 }
  2679.                 $stmtUpdate->bind_param('sssii'$endpoint$apiKey$type$showConfidenceBadges$existingId);
  2680.                 if (!$stmtUpdate->execute()) {
  2681.                     $stmtUpdate->close();
  2682.                     throw new \RuntimeException('Execute UPDATE extraction_models: ' $stmtUpdate->error);
  2683.                 }
  2684.                 $stmtUpdate->close();
  2685.                 $localModelId $existingId;
  2686.             } else {
  2687.                 $provider self::EXTRACTOR_TYPE_AZURE_DI;
  2688.                 $stmtInsert $clientMysqli->prepare(
  2689.                     'INSERT INTO extraction_models (provider, model_id, endpoint, api_key, type, show_confidence_badges)
  2690.                      VALUES (?, ?, ?, ?, ?, ?)'
  2691.                 );
  2692.                 if (!$stmtInsert) {
  2693.                     throw new \RuntimeException('Prepare INSERT extraction_models: ' $clientMysqli->error);
  2694.                 }
  2695.                 $stmtInsert->bind_param('sssssi'$provider$modelId$endpoint$apiKey$type$showConfidenceBadges);
  2696.                 if (!$stmtInsert->execute()) {
  2697.                     $stmtInsert->close();
  2698.                     throw new \RuntimeException('Execute INSERT extraction_models: ' $stmtInsert->error);
  2699.                 }
  2700.                 $localModelId = (int)$stmtInsert->insert_id;
  2701.                 $stmtInsert->close();
  2702.             }
  2703.             $stmtDelHeader $clientMysqli->prepare('DELETE FROM definitions_header WHERE model_id = ?');
  2704.             if (!$stmtDelHeader) {
  2705.                 throw new \RuntimeException('Prepare DELETE definitions_header: ' $clientMysqli->error);
  2706.             }
  2707.             $stmtDelHeader->bind_param('i'$localModelId);
  2708.             if (!$stmtDelHeader->execute()) {
  2709.                 $stmtDelHeader->close();
  2710.                 throw new \RuntimeException('Execute DELETE definitions_header: ' $stmtDelHeader->error);
  2711.             }
  2712.             $stmtDelHeader->close();
  2713.             $stmtDelLines $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
  2714.             if (!$stmtDelLines) {
  2715.                 throw new \RuntimeException('Prepare DELETE definitions_lines: ' $clientMysqli->error);
  2716.             }
  2717.             $stmtDelLines->bind_param('i'$localModelId);
  2718.             if (!$stmtDelLines->execute()) {
  2719.                 $stmtDelLines->close();
  2720.                 throw new \RuntimeException('Execute DELETE definitions_lines: ' $stmtDelLines->error);
  2721.             }
  2722.             $stmtDelLines->close();
  2723.             if (!empty($definitions['header'])) {
  2724.                 $stmtHeader $clientMysqli->prepare(
  2725.                     'INSERT INTO definitions_header
  2726.                     (model_id, field_key, label, value_type, order_index, visibility, order_index_table, visibility_table)
  2727.                     VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
  2728.                 );
  2729.                 if (!$stmtHeader) {
  2730.                     throw new \RuntimeException('Prepare INSERT definitions_header: ' $clientMysqli->error);
  2731.                 }
  2732.                 foreach ($definitions['header'] as $item) {
  2733.                     $fieldKey = (string)$item['field_key'];
  2734.                     $label = (string)$item['label'];
  2735.                     $valueType = (string)$item['value_type'];
  2736.                     $orderIndex = (int)$item['order_index'];
  2737.                     $visibility = (int)$item['visibility'];
  2738.                     $orderIndexTable = (int)$item['order_index_table'];
  2739.                     $visibilityTable = (int)$item['visibility_table'];
  2740.                     $stmtHeader->bind_param(
  2741.                         'isssiiii',
  2742.                         $localModelId,
  2743.                         $fieldKey,
  2744.                         $label,
  2745.                         $valueType,
  2746.                         $orderIndex,
  2747.                         $visibility,
  2748.                         $orderIndexTable,
  2749.                         $visibilityTable
  2750.                     );
  2751.                     if (!$stmtHeader->execute()) {
  2752.                         $stmtHeader->close();
  2753.                         throw new \RuntimeException('Execute INSERT definitions_header: ' $stmtHeader->error);
  2754.                     }
  2755.                 }
  2756.                 $stmtHeader->close();
  2757.             }
  2758.             if (!empty($definitions['lines'])) {
  2759.                 $stmtLines $clientMysqli->prepare(
  2760.                     'INSERT INTO definitions_lines
  2761.                     (model_id, field_key, label, value_type, order_index, visibility)
  2762.                     VALUES (?, ?, ?, ?, ?, ?)'
  2763.                 );
  2764.                 if (!$stmtLines) {
  2765.                     throw new \RuntimeException('Prepare INSERT definitions_lines: ' $clientMysqli->error);
  2766.                 }
  2767.                 foreach ($definitions['lines'] as $item) {
  2768.                     $fieldKey = (string)$item['field_key'];
  2769.                     $label = (string)$item['label'];
  2770.                     $valueType = (string)$item['value_type'];
  2771.                     $orderIndex = (int)$item['order_index'];
  2772.                     $visibility = (int)$item['visibility'];
  2773.                     $stmtLines->bind_param(
  2774.                         'isssii',
  2775.                         $localModelId,
  2776.                         $fieldKey,
  2777.                         $label,
  2778.                         $valueType,
  2779.                         $orderIndex,
  2780.                         $visibility
  2781.                     );
  2782.                     if (!$stmtLines->execute()) {
  2783.                         $stmtLines->close();
  2784.                         throw new \RuntimeException('Execute INSERT definitions_lines: ' $stmtLines->error);
  2785.                     }
  2786.                 }
  2787.                 $stmtLines->close();
  2788.             }
  2789.             $clientMysqli->commit();
  2790.             return $localModelId;
  2791.         } catch (\Throwable $e) {
  2792.             $clientMysqli->rollback();
  2793.             throw $e;
  2794.         }
  2795.     }
  2796.     private function registerModelInClientDb(\mysqli $clientMysqli, array $resourcestring $modelId): int
  2797.     {
  2798.         return $this->registerDiModelInClientDb($clientMysqli$resource$modelId);
  2799.     }
  2800.     private function normalizeAoaiFieldScope(string $scope): string
  2801.     {
  2802.         $scope strtolower(trim($scope));
  2803.         return $scope === 'lines' 'lines' 'header';
  2804.     }
  2805.     private function normalizeAoaiValueType(string $valueType): string
  2806.     {
  2807.         $valueType strtolower(trim($valueType));
  2808.         if ($valueType === 'number') {
  2809.             return 'number';
  2810.         }
  2811.         if ($valueType === 'date') {
  2812.             return 'date';
  2813.         }
  2814.         return 'string';
  2815.     }
  2816.     private function collectAoaiFieldsFromRequest(Request $request): array
  2817.     {
  2818.         $scopes $request->request->all('aoai_fields_scope');
  2819.         $keys $request->request->all('aoai_fields_key');
  2820.         $prompts $request->request->all('aoai_fields_prompt');
  2821.         $valueTypes $request->request->all('aoai_fields_type');
  2822.         if (!is_array($scopes)) {
  2823.             $scopes = [];
  2824.         }
  2825.         if (!is_array($keys)) {
  2826.             $keys = [];
  2827.         }
  2828.         if (!is_array($prompts)) {
  2829.             $prompts = [];
  2830.         }
  2831.         if (!is_array($valueTypes)) {
  2832.             $valueTypes = [];
  2833.         }
  2834.         $max max(count($scopes), count($keys), count($prompts), count($valueTypes));
  2835.         $fields = [];
  2836.         for ($i 0$i $max$i++) {
  2837.             $scope $this->normalizeAoaiFieldScope((string)($scopes[$i] ?? 'header'));
  2838.             $fieldKey trim((string)($keys[$i] ?? ''));
  2839.             $prompt trim((string)($prompts[$i] ?? ''));
  2840.             $valueType $this->normalizeAoaiValueType((string)($valueTypes[$i] ?? 'string'));
  2841.             if ($fieldKey === '' && $prompt === '') {
  2842.                 continue;
  2843.             }
  2844.             $fields[] = [
  2845.                 'scope' => $scope,
  2846.                 'field_key' => $fieldKey,
  2847.                 'prompt' => $prompt,
  2848.                 'value_type' => $valueType,
  2849.             ];
  2850.         }
  2851.         return $fields;
  2852.     }
  2853.     private function validateAoaiFields(array $fields): ?string
  2854.     {
  2855.         if (count($fields) === 0) {
  2856.             return 'Debes indicar al menos un campo para Azure OpenAI.';
  2857.         }
  2858.         $seen = [];
  2859.         foreach ($fields as $item) {
  2860.             $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  2861.             $fieldKey trim((string)($item['field_key'] ?? ''));
  2862.             $prompt trim((string)($item['prompt'] ?? ''));
  2863.             $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  2864.             if ($fieldKey === '' || $prompt === '') {
  2865.                 return 'Todos los campos de Azure OpenAI deben tener nombre y prompt.';
  2866.             }
  2867.             if (!in_array($valueType, ['string''number''date'], true)) {
  2868.                 return 'El tipo de dato permitido es string, number o date.';
  2869.             }
  2870.             $uniq $scope '|' strtolower($fieldKey);
  2871.             if (isset($seen[$uniq])) {
  2872.                 return 'No se permiten campos repetidos dentro del mismo alcance (cabecera o líneas).';
  2873.             }
  2874.             $seen[$uniq] = true;
  2875.         }
  2876.         return null;
  2877.     }
  2878.     private function registerAoaiModelInClientDb(\mysqli $clientMysqli, array $resource, array $aoaiFields): int
  2879.     {
  2880.         $extractorType $this->normalizeExtractorType((string)($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  2881.         if ($extractorType !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  2882.             throw new \RuntimeException('El recurso seleccionado no es de tipo Azure OpenAI.'400);
  2883.         }
  2884.         $validationError $this->validateAoaiFields($aoaiFields);
  2885.         if ($validationError !== null) {
  2886.             throw new \RuntimeException($validationError400);
  2887.         }
  2888.         $modelId trim((string)($resource['model_id'] ?? ''));
  2889.         $basePrompt trim((string)($resource['base_prompt'] ?? ''));
  2890.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  2891.         $apiKey trim((string)($resource['api_key'] ?? ''));
  2892.         if ($modelId === '' || $basePrompt === '' || $endpoint === '' || $apiKey === '') {
  2893.             throw new \RuntimeException('El recurso Azure OpenAI está incompleto.'400);
  2894.         }
  2895.         $provider 'azure_openai';
  2896.         $type 'custom';
  2897.         $showConfidenceBadges 0;
  2898.         $clientMysqli->begin_transaction();
  2899.         try {
  2900.             $existingId 0;
  2901.             $stmtSelect $clientMysqli->prepare(
  2902.                 'SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? LIMIT 1'
  2903.             );
  2904.             if (!$stmtSelect) {
  2905.                 throw new \RuntimeException('Prepare SELECT extraction_models AOAI: ' $clientMysqli->error);
  2906.             }
  2907.             $stmtSelect->bind_param('ss'$provider$modelId);
  2908.             if (!$stmtSelect->execute()) {
  2909.                 $stmtSelect->close();
  2910.                 throw new \RuntimeException('Execute SELECT extraction_models AOAI: ' $stmtSelect->error);
  2911.             }
  2912.             $result $stmtSelect->get_result();
  2913.             if ($result && ($row $result->fetch_assoc())) {
  2914.                 $existingId = (int)$row['id'];
  2915.             }
  2916.             $stmtSelect->close();
  2917.             if ($existingId 0) {
  2918.                 $stmtUpdate $clientMysqli->prepare(
  2919.                     'UPDATE extraction_models
  2920.                      SET endpoint = ?, api_key = ?, base_prompt = ?, type = ?, show_confidence_badges = ?
  2921.                      WHERE id = ?'
  2922.                 );
  2923.                 if (!$stmtUpdate) {
  2924.                     throw new \RuntimeException('Prepare UPDATE extraction_models AOAI: ' $clientMysqli->error);
  2925.                 }
  2926.                 $stmtUpdate->bind_param('ssssii'$endpoint$apiKey$basePrompt$type$showConfidenceBadges$existingId);
  2927.                 if (!$stmtUpdate->execute()) {
  2928.                     $stmtUpdate->close();
  2929.                     throw new \RuntimeException('Execute UPDATE extraction_models AOAI: ' $stmtUpdate->error);
  2930.                 }
  2931.                 $stmtUpdate->close();
  2932.                 $localModelId $existingId;
  2933.             } else {
  2934.                 $stmtInsert $clientMysqli->prepare(
  2935.                     'INSERT INTO extraction_models (provider, model_id, endpoint, api_key, base_prompt, type, show_confidence_badges)
  2936.                      VALUES (?, ?, ?, ?, ?, ?, ?)'
  2937.                 );
  2938.                 if (!$stmtInsert) {
  2939.                     throw new \RuntimeException('Prepare INSERT extraction_models AOAI: ' $clientMysqli->error);
  2940.                 }
  2941.                 $stmtInsert->bind_param('ssssssi'$provider$modelId$endpoint$apiKey$basePrompt$type$showConfidenceBadges);
  2942.                 if (!$stmtInsert->execute()) {
  2943.                     $stmtInsert->close();
  2944.                     throw new \RuntimeException('Execute INSERT extraction_models AOAI: ' $stmtInsert->error);
  2945.                 }
  2946.                 $localModelId = (int)$stmtInsert->insert_id;
  2947.                 $stmtInsert->close();
  2948.             }
  2949.             $existingHeaderKeys = [];
  2950.             $existingLinesKeys = [];
  2951.             $stmtExistingHeader $clientMysqli->prepare(
  2952.                 'SELECT field_key FROM definitions_header WHERE model_id = ?'
  2953.             );
  2954.             if (!$stmtExistingHeader) {
  2955.                 throw new \RuntimeException('Prepare SELECT definitions_header AOAI: ' $clientMysqli->error);
  2956.             }
  2957.             $stmtExistingHeader->bind_param('i'$localModelId);
  2958.             if (!$stmtExistingHeader->execute()) {
  2959.                 $stmtExistingHeader->close();
  2960.                 throw new \RuntimeException('Execute SELECT definitions_header AOAI: ' $stmtExistingHeader->error);
  2961.             }
  2962.             $resExistingHeader $stmtExistingHeader->get_result();
  2963.             while ($resExistingHeader && ($row $resExistingHeader->fetch_assoc())) {
  2964.                 $existingHeaderKeys[(string)($row['field_key'] ?? '')] = true;
  2965.             }
  2966.             $stmtExistingHeader->close();
  2967.             $stmtExistingLines $clientMysqli->prepare(
  2968.                 'SELECT field_key FROM definitions_lines WHERE model_id = ?'
  2969.             );
  2970.             if (!$stmtExistingLines) {
  2971.                 throw new \RuntimeException('Prepare SELECT definitions_lines AOAI: ' $clientMysqli->error);
  2972.             }
  2973.             $stmtExistingLines->bind_param('i'$localModelId);
  2974.             if (!$stmtExistingLines->execute()) {
  2975.                 $stmtExistingLines->close();
  2976.                 throw new \RuntimeException('Execute SELECT definitions_lines AOAI: ' $stmtExistingLines->error);
  2977.             }
  2978.             $resExistingLines $stmtExistingLines->get_result();
  2979.             while ($resExistingLines && ($row $resExistingLines->fetch_assoc())) {
  2980.                 $existingLinesKeys[(string)($row['field_key'] ?? '')] = true;
  2981.             }
  2982.             $stmtExistingLines->close();
  2983.             $stmtInsertHeader $clientMysqli->prepare(
  2984.                 'INSERT INTO definitions_header
  2985.                 (model_id, field_key, label, prompt, value_type, order_index, visibility, order_index_table, visibility_table)
  2986.                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
  2987.             );
  2988.             if (!$stmtInsertHeader) {
  2989.                 throw new \RuntimeException('Prepare INSERT definitions_header AOAI: ' $clientMysqli->error);
  2990.             }
  2991.             $stmtUpdateHeader $clientMysqli->prepare(
  2992.                 'UPDATE definitions_header
  2993.                  SET prompt = ?, value_type = ?, order_index = ?, order_index_table = ?
  2994.                  WHERE model_id = ? AND field_key = ?'
  2995.             );
  2996.             if (!$stmtUpdateHeader) {
  2997.                 $stmtInsertHeader->close();
  2998.                 throw new \RuntimeException('Prepare UPDATE definitions_header AOAI: ' $clientMysqli->error);
  2999.             }
  3000.             $stmtInsertLines $clientMysqli->prepare(
  3001.                 'INSERT INTO definitions_lines
  3002.                 (model_id, field_key, label, prompt, value_type, order_index, visibility)
  3003.                 VALUES (?, ?, ?, ?, ?, ?, ?)'
  3004.             );
  3005.             if (!$stmtInsertLines) {
  3006.                 $stmtInsertHeader->close();
  3007.                 $stmtUpdateHeader->close();
  3008.                 throw new \RuntimeException('Prepare INSERT definitions_lines AOAI: ' $clientMysqli->error);
  3009.             }
  3010.             $stmtUpdateLines $clientMysqli->prepare(
  3011.                 'UPDATE definitions_lines
  3012.                  SET prompt = ?, value_type = ?, order_index = ?
  3013.                  WHERE model_id = ? AND field_key = ?'
  3014.             );
  3015.             if (!$stmtUpdateLines) {
  3016.                 $stmtInsertHeader->close();
  3017.                 $stmtUpdateHeader->close();
  3018.                 $stmtInsertLines->close();
  3019.                 throw new \RuntimeException('Prepare UPDATE definitions_lines AOAI: ' $clientMysqli->error);
  3020.             }
  3021.             $headerOrder 1;
  3022.             $lineOrder 1;
  3023.             $incomingHeaderKeys = [];
  3024.             $incomingLinesKeys = [];
  3025.             foreach ($aoaiFields as $item) {
  3026.                 $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  3027.                 $fieldKey trim((string)($item['field_key'] ?? ''));
  3028.                 $prompt trim((string)($item['prompt'] ?? ''));
  3029.                 $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  3030.                 $visibility 1;
  3031.                 if ($scope === 'lines') {
  3032.                     $orderIndex $lineOrder++;
  3033.                     $incomingLinesKeys[$fieldKey] = true;
  3034.                     if (isset($existingLinesKeys[$fieldKey])) {
  3035.                         $stmtUpdateLines->bind_param(
  3036.                             'ssiis',
  3037.                             $prompt,
  3038.                             $valueType,
  3039.                             $orderIndex,
  3040.                             $localModelId,
  3041.                             $fieldKey
  3042.                         );
  3043.                         if (!$stmtUpdateLines->execute()) {
  3044.                             throw new \RuntimeException('Execute UPDATE definitions_lines AOAI: ' $stmtUpdateLines->error);
  3045.                         }
  3046.                     } else {
  3047.                         $label $fieldKey;
  3048.                         $stmtInsertLines->bind_param(
  3049.                             'issssii',
  3050.                             $localModelId,
  3051.                             $fieldKey,
  3052.                             $label,
  3053.                             $prompt,
  3054.                             $valueType,
  3055.                             $orderIndex,
  3056.                             $visibility
  3057.                         );
  3058.                         if (!$stmtInsertLines->execute()) {
  3059.                             throw new \RuntimeException('Execute INSERT definitions_lines AOAI: ' $stmtInsertLines->error);
  3060.                         }
  3061.                     }
  3062.                 } else {
  3063.                     $orderIndex $headerOrder++;
  3064.                     $orderIndexTable $orderIndex;
  3065.                     $incomingHeaderKeys[$fieldKey] = true;
  3066.                     if (isset($existingHeaderKeys[$fieldKey])) {
  3067.                         $stmtUpdateHeader->bind_param(
  3068.                             'ssiiss',
  3069.                             $prompt,
  3070.                             $valueType,
  3071.                             $orderIndex,
  3072.                             $orderIndexTable,
  3073.                             $localModelId,
  3074.                             $fieldKey
  3075.                         );
  3076.                         if (!$stmtUpdateHeader->execute()) {
  3077.                             throw new \RuntimeException('Execute UPDATE definitions_header AOAI: ' $stmtUpdateHeader->error);
  3078.                         }
  3079.                     } else {
  3080.                         $label $fieldKey;
  3081.                         $visibilityTable 1;
  3082.                         $stmtInsertHeader->bind_param(
  3083.                             'issssiiii',
  3084.                             $localModelId,
  3085.                             $fieldKey,
  3086.                             $label,
  3087.                             $prompt,
  3088.                             $valueType,
  3089.                             $orderIndex,
  3090.                             $visibility,
  3091.                             $orderIndexTable,
  3092.                             $visibilityTable
  3093.                         );
  3094.                         if (!$stmtInsertHeader->execute()) {
  3095.                             throw new \RuntimeException('Execute INSERT definitions_header AOAI: ' $stmtInsertHeader->error);
  3096.                         }
  3097.                     }
  3098.                 }
  3099.             }
  3100.             $stmtInsertHeader->close();
  3101.             $stmtUpdateHeader->close();
  3102.             $stmtInsertLines->close();
  3103.             $stmtUpdateLines->close();
  3104.             $headerToDelete array_values(array_diff(array_keys($existingHeaderKeys), array_keys($incomingHeaderKeys)));
  3105.             if (count($headerToDelete) > 0) {
  3106.                 $ph implode(','array_fill(0count($headerToDelete), '?'));
  3107.                 $types 'i' str_repeat('s'count($headerToDelete));
  3108.                 $sql "DELETE FROM definitions_header WHERE model_id = ? AND field_key IN ($ph)";
  3109.                 $stmtDeleteHeader $clientMysqli->prepare($sql);
  3110.                 if (!$stmtDeleteHeader) {
  3111.                     throw new \RuntimeException('Prepare DELETE definitions_header AOAI selective: ' $clientMysqli->error);
  3112.                 }
  3113.                 $params array_merge([$localModelId], $headerToDelete);
  3114.                 $bind = [$types];
  3115.                 foreach ($params as $k => $v) {
  3116.                     $bind[] = &$params[$k];
  3117.                 }
  3118.                 call_user_func_array([$stmtDeleteHeader'bind_param'], $bind);
  3119.                 if (!$stmtDeleteHeader->execute()) {
  3120.                     $stmtDeleteHeader->close();
  3121.                     throw new \RuntimeException('Execute DELETE definitions_header AOAI selective: ' $stmtDeleteHeader->error);
  3122.                 }
  3123.                 $stmtDeleteHeader->close();
  3124.             }
  3125.             $linesToDelete array_values(array_diff(array_keys($existingLinesKeys), array_keys($incomingLinesKeys)));
  3126.             if (count($linesToDelete) > 0) {
  3127.                 $ph implode(','array_fill(0count($linesToDelete), '?'));
  3128.                 $types 'i' str_repeat('s'count($linesToDelete));
  3129.                 $sql "DELETE FROM definitions_lines WHERE model_id = ? AND field_key IN ($ph)";
  3130.                 $stmtDeleteLines $clientMysqli->prepare($sql);
  3131.                 if (!$stmtDeleteLines) {
  3132.                     throw new \RuntimeException('Prepare DELETE definitions_lines AOAI selective: ' $clientMysqli->error);
  3133.                 }
  3134.                 $params array_merge([$localModelId], $linesToDelete);
  3135.                 $bind = [$types];
  3136.                 foreach ($params as $k => $v) {
  3137.                     $bind[] = &$params[$k];
  3138.                 }
  3139.                 call_user_func_array([$stmtDeleteLines'bind_param'], $bind);
  3140.                 if (!$stmtDeleteLines->execute()) {
  3141.                     $stmtDeleteLines->close();
  3142.                     throw new \RuntimeException('Execute DELETE definitions_lines AOAI selective: ' $stmtDeleteLines->error);
  3143.                 }
  3144.                 $stmtDeleteLines->close();
  3145.             }
  3146.             $clientMysqli->commit();
  3147.             return $localModelId;
  3148.         } catch (\Throwable $e) {
  3149.             $clientMysqli->rollback();
  3150.             throw $e;
  3151.         }
  3152.     }
  3153.     public function Empresa(Request $reqEntityManagerInterface $em)
  3154.     {
  3155.         if (!$this->getUser() || !is_object($this->getUser())) {
  3156.             return $this->redirectToRoute('logout');
  3157.         }
  3158.         $id = (int)$req->get("id");
  3159.         if (!$id) {
  3160.             $this->addFlash('warning''Empresa no encontrada.');
  3161.             return $this->redirectToRoute("list");
  3162.         }
  3163.         $empresa $em->getRepository(Empresa::class)->find($id);
  3164.         if (!$empresa) {
  3165.             $this->addFlash('warning''Empresa no encontrada.');
  3166.             return $this->redirectToRoute("list");
  3167.         }
  3168.         $users $em->getRepository(Usuario::class)->findBy([
  3169.             "empresa" => $empresa->getId()
  3170.         ]);
  3171.         // Valores por defecto por si algo falla al conectar con la BD del cliente
  3172.         $activeUsers            null;
  3173.         $modulos                = [];
  3174.         $empresaLogo            null;
  3175.         $extractionModelLabel   null;
  3176.         $activeLicenseContract  = [];
  3177.         $licenseContractHistory = [];
  3178.         $visualActiveContractId null;
  3179.         $diskUsedBytes          null;
  3180.         $diskUsedGb             null;
  3181.         try {
  3182.             $cx $empresa->getConexionBD();
  3183.             if ($cx) {
  3184.                 $mysqli = @new \mysqli(
  3185.                     $cx->getDbUrl(),
  3186.                     $cx->getDbUser(),
  3187.                     $cx->getDbPassword(),
  3188.                     $cx->getDbName(),
  3189.                     (int)$cx->getDbPort()
  3190.                 );
  3191.                 if (!$mysqli->connect_error) {
  3192.                     // parámetros (modulos, límites, etc.)
  3193.                     [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  3194.                     // logo guardado en la BD del cliente
  3195.                     $empresaLogo $this->loadEmpresaLogo($mysqli);
  3196.                     $activeLicenseContract $this->licenseContractService->loadActiveContract($mysqli);
  3197.                     $licenseContractHistory $this->licenseContractService->loadContractHistory($mysqli);
  3198.                     $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($licenseContractHistory);
  3199.                     $diskUsedBytes $this->loadEmpresaDiskUsageBytes($mysqli);
  3200.                     $diskUsedGb = ($diskUsedBytes !== null)
  3201.                         ? round($diskUsedBytes 1024 1024 10242)
  3202.                         : null;
  3203.                     // Si tiene extracción y modelo seleccionado, buscamos el ID legible del modelo
  3204.                     if (
  3205.                         !empty($modulos['modulo_extraccion']) &&
  3206.                         !empty($modulos['extraction_model'])
  3207.                     ) {
  3208.                         try {
  3209.                             $stmt $mysqli->prepare('SELECT model_id FROM extraction_models WHERE id = ? LIMIT 1');
  3210.                             if ($stmt) {
  3211.                                 $modelParam = (int)$modulos['extraction_model'];
  3212.                                 $stmt->bind_param('i'$modelParam);
  3213.                                 if ($stmt->execute()) {
  3214.                                     $res $stmt->get_result();
  3215.                                     if ($res && ($row $res->fetch_assoc()) && isset($row['model_id'])) {
  3216.                                         $extractionModelLabel $row['model_id'];
  3217.                                     }
  3218.                                 }
  3219.                                 $stmt->close();
  3220.                             }
  3221.                         } catch (\Throwable $e) {
  3222.                             // Si falla, simplemente no mostramos el texto bonito del modelo
  3223.                             $extractionModelLabel null;
  3224.                         }
  3225.                     }
  3226.                     $mysqli->close();
  3227.                 }
  3228.             }
  3229.         } catch (\Throwable $e) {
  3230.             // Aquí podrías loguear el error si quieres, pero no rompemos la pantalla
  3231.         }
  3232.         return $this->render('empresa_detail.html.twig', [
  3233.             'empresa'              => $empresa,
  3234.             'users'                => $users,
  3235.             'activeUsers'          => $activeUsers,
  3236.             'modulos'              => $modulos,
  3237.             'empresaLogo'          => $empresaLogo,
  3238.             'extractionModelLabel' => $extractionModelLabel,
  3239.             'diskUsedGb' => $diskUsedGb,
  3240.             'activeLicenseContract' => $activeLicenseContract,
  3241.             'licenseContractHistory' => $licenseContractHistory,
  3242.             'visualActiveContractId' => $visualActiveContractId,
  3243.         ]);
  3244.     }
  3245.     public function deleteEmpresa(Request $requestEntityManagerInterface $em)
  3246.     {
  3247.         $id $request->get("id");
  3248.         $empresa $em->getRepository(Empresa::class)->find($id);
  3249.         $conexion $empresa->getConexionBD();
  3250.         $usuarios $em->getRepository(Usuario::class)->findBy(array("empresa" => $empresa->getId()));
  3251.         // Recoger avatar/firma de usuarios antes de eliminar la BD del cliente
  3252.         $mediaPaths = [];
  3253.         try {
  3254.             $mysqliMedia = @new \mysqli(
  3255.                 $conexion->getDbUrl(),
  3256.                 $conexion->getDbUser(),
  3257.                 $conexion->getDbPassword(),
  3258.                 $conexion->getDbName(),
  3259.                 (int)$conexion->getDbPort()
  3260.             );
  3261.             if (!$mysqliMedia->connect_error) {
  3262.                 $mediaPaths $this->getCompanyUserMediaPaths($mysqliMedia);
  3263.                 $mysqliMedia->close();
  3264.             } else {
  3265.                 error_log('No se pudo conectar a BD cliente para borrar media: ' $mysqliMedia->connect_error);
  3266.             }
  3267.         } catch (\Throwable $e) {
  3268.             error_log('Error borrando media de usuarios: ' $e->getMessage());
  3269.         }
  3270.         $hestiaApiUrl 'https://200.234.237.107:8083/api/';
  3271.         $owner 'docunecta'// o el dueño del hosting
  3272.         $postFields http_build_query([
  3273.             'user' => 'admin',
  3274.             'password' => 'i9iQiSmxb2EpvgLq',
  3275.             'returncode' => 'yes',
  3276.             'cmd' => 'v-delete-database',
  3277.             'arg1' => 'admin',
  3278.             'arg2' => $conexion->getDbName(),
  3279.         ]);
  3280.         $accessKeyId 'cWYbt9ShyFQ3yVRsUE8u';
  3281.         $secretKey 'e2M_5wk2_jUAlPorF7V8zfwo3_0ihu90WoLPMKwj';
  3282.         $headers = [
  3283.             'Authorization: Bearer ' $accessKeyId ':' $secretKey
  3284.         ];
  3285.         $ch curl_init();
  3286.         curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  3287.         curl_setopt($chCURLOPT_HTTPHEADER$headers);
  3288.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  3289.         curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  3290.         curl_setopt($chCURLOPT_POSTtrue);
  3291.         curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  3292.         curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  3293.         $response curl_exec($ch);
  3294.         $error curl_error($ch);
  3295.         curl_close($ch);
  3296.         if (($error || trim($response) !== '0') && trim($response) !== '3') {
  3297.             $this->addFlash('danger''Error al eliminar la base de datos en HestiaCP: ' . ($error ?: $response));
  3298.             return $this->redirectToRoute('list');
  3299.         }
  3300.         // Eliminar el servicio systemd asociado a la empresa
  3301.         $company_name $empresa->getId();
  3302.         $serviceName $company_name "-documanager.service";
  3303.         $servicePath "/etc/systemd/system/$serviceName";
  3304.         $cmds = [
  3305.             "sudo /bin/systemctl stop $serviceName",
  3306.             "sudo /bin/systemctl disable $serviceName",
  3307.             "sudo /bin/rm -f $servicePath",
  3308.             "sudo /bin/systemctl daemon-reload"
  3309.         ];
  3310.         $serviceErrors = [];
  3311.         foreach ($cmds as $cmd) {
  3312.             $output = @\shell_exec($cmd " 2>&1");
  3313.             if ($output !== null && trim($output) !== '') {
  3314.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  3315.             }
  3316.         }
  3317.         $azureService $company_name "-azuredi.service";
  3318.         $servicePathAzure "/etc/systemd/system/$azureService";
  3319.         $cmdsAzure = [
  3320.             "sudo /bin/systemctl stop $azureService",
  3321.             "sudo /bin/systemctl disable $azureService",
  3322.             "sudo /bin/rm -f $servicePathAzure",
  3323.             "sudo /bin/systemctl daemon-reload",
  3324.         ];
  3325.         foreach ($cmdsAzure as $cmd) {
  3326.             $output = @\shell_exec($cmd " 2>&1");
  3327.             if ($output) {
  3328.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  3329.             }
  3330.         }
  3331.         // Eliminar mail monitor service + timer (si existen)
  3332.         $this->disableMailMonitorService((int)$company_name);
  3333.         // Pedir a platform que elimine files/logs/media del cliente
  3334.         $this->callPlatformCleanup((int)$company_name$mediaPaths);
  3335.         //eliminamos usuarios
  3336.         foreach ($usuarios as $user) {
  3337.             $em->remove($user);
  3338.             $em->flush();
  3339.         }
  3340.         //eliminamos conexiĂłn
  3341.         $em->remove($conexion);
  3342.         $em->flush();
  3343.         //eliminamos empresa
  3344.         $em->remove($empresa);
  3345.         $em->flush();
  3346.         $msg 'Empresa y base de datos eliminadas correctamente.';
  3347.         if (count($serviceErrors) > 0) {
  3348.             $msg .= ' ' implode('<br>'$serviceErrors);
  3349.         }
  3350.         $this->addFlash('success'$msg);
  3351.         return $this->redirectToRoute('list');
  3352.     }
  3353.     public function editEmpresa(Request $requestEntityManagerInterface $em)
  3354.     {
  3355.         if (!$this->getUser() || !is_object($this->getUser())) {
  3356.             return $this->redirectToRoute('logout');
  3357.         }
  3358.         $id = (int)$request->get('id');
  3359.         $empresa $em->getRepository(Empresa::class)->find($id);
  3360.         if (!$empresa) {
  3361.             throw $this->createNotFoundException('Empresa no encontrada');
  3362.         }
  3363.         // 1) Conectar a la BD del cliente con las credenciales de la central
  3364.         $cx $empresa->getConexionBD();
  3365.         $mysqli = @new \mysqli(
  3366.             $cx->getDbUrl(),
  3367.             $cx->getDbUser(),
  3368.             $cx->getDbPassword(),
  3369.             $cx->getDbName(),
  3370.             (int)$cx->getDbPort()
  3371.         );
  3372.         if ($mysqli->connect_error) {
  3373.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3374.             return $this->redirectToRoute('list');
  3375.         }
  3376.         $ocrPlusAvailable $this->isOcrPlusAvailable();
  3377.         $ocrV2Available $this->isOcrV2Available();
  3378.         $azureResources = [];
  3379.         $azureDiResources = [];
  3380.         $azureOpenAiResources = [];
  3381.         try {
  3382.             $azureResources $this->loadAzureResources($em);
  3383.             foreach ($azureResources as $resourceRow) {
  3384.                 if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3385.                     $azureOpenAiResources[] = $resourceRow;
  3386.                 } else {
  3387.                     $azureDiResources[] = $resourceRow;
  3388.                 }
  3389.             }
  3390.         } catch (\Throwable $e) {
  3391.             $this->addFlash('warning''No se pudo cargar el catalogo de recursos IA: ' $e->getMessage());
  3392.         }
  3393.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  3394.             // Estado previo de modulos (para detectar activacion de Mail Monitor)
  3395.             [$activeUsersPrev$modulosPrev] = $this->loadEmpresaParametros($mysqli);
  3396.             $prevMailMonitor = (int)($modulosPrev['modulo_mailMonitor'] ?? 0);
  3397.             $data $request->request->all();
  3398.             $maxThreads $this->clampDocuMaxThreads($data['maxThreads'] ?? null$empresa->getMaxThreads() ?: 4);
  3399.             $data['ocr_mode'] = $this->normalizeOcrMode($data['ocr_mode'] ?? 'base');
  3400.             if (!$ocrV2Available && $data['ocr_mode'] === 'v2_zxing') {
  3401.                 $data['ocr_mode'] = 'base';
  3402.             }
  3403.             if (!$ocrPlusAvailable && $data['ocr_mode'] === 'plus_glm') {
  3404.                 $data['ocr_mode'] = 'base';
  3405.             }
  3406.             // 2) Actualizar SOLO central
  3407.             $empresa->setName((string)($data['name'] ?? $empresa->getName()));
  3408.             $empresa->setMaxDiskQuota(
  3409.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== ''
  3410.                     ? (int)$data['maxDiskQuota']
  3411.                     : $empresa->getMaxDiskQuota()
  3412.             );
  3413.             $empresa->setMaxThreads($maxThreads);
  3414.             $em->persist($empresa);
  3415.             // ---- Normalización de POST (checkboxes / select) ----
  3416.             $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  3417.             $extractionModel 0;
  3418.             if (isset($data['extraction_model']) && $data['extraction_model'] !== '') {
  3419.                 $extractionModel = (int)$data['extraction_model'];
  3420.             }
  3421.             $data['extraction_model'] = $extractionModel;
  3422.             $data['extractor_type'] = $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  3423.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  3424.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  3425.             $aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
  3426.             $aoaiFields $this->collectAoaiFieldsFromRequest($request);
  3427.             $data['modulo_extraccion']       = isset($data['modulo_extraccion'])       && $toBool($data['modulo_extraccion'])       ? 0;
  3428.             $data['modulo_etiquetas']        = isset($data['modulo_etiquetas'])        && $toBool($data['modulo_etiquetas'])        ? 0;
  3429.             $data['modulo_calendario']       = isset($data['modulo_calendario'])       && $toBool($data['modulo_calendario'])       ? 0;
  3430.             $data['modulo_calendarioExterno'] = isset($data['modulo_calendarioExterno']) && $toBool($data['modulo_calendarioExterno']) ? 0;
  3431.             $data['modulo_estados']          = isset($data['modulo_estados'])          && $toBool($data['modulo_estados'])          ? 0;
  3432.             $data['modulo_lineas']           = isset($data['modulo_lineas'])           && $toBool($data['modulo_lineas'])           ? 0;
  3433.             $data['modulo_agora']            = isset($data['modulo_agora'])           && $toBool($data['modulo_agora'])             ? 0;
  3434.             $data['modulo_gstock']           = isset($data['modulo_gstock'])           && $toBool($data['modulo_gstock'])           ? 0;
  3435.             $data['modulo_expowin']          = isset($data['modulo_expowin'])          && $toBool($data['modulo_expowin'])          ? 0;
  3436.             $data['modulo_prinex']           = isset($data['modulo_prinex'])           && $toBool($data['modulo_prinex'])           ? 0;
  3437.             $data['modulo_mailMonitor']      = isset($data['modulo_mailMonitor'])      && $toBool($data['modulo_mailMonitor'])      ? 0;
  3438.             $data['modulo_busquedaNatural']  = isset($data['modulo_busquedaNatural'])  && $toBool($data['modulo_busquedaNatural'])  ? 0;
  3439.             $data['soloExtraccion']          = isset($data['soloExtraccion'])          && $toBool($data['soloExtraccion'])          ? 0;
  3440.             // Dependencias
  3441.             if (!$data['modulo_calendario']) {
  3442.                 $data['modulo_calendarioExterno'] = 0;
  3443.             }
  3444.             if (!$data['modulo_extraccion']) {
  3445.                 $data['modulo_lineas']   = 0;
  3446.                 $data['extraction_model'] = 0;
  3447.                 $data['modulo_agora']    = 0;
  3448.                 $data['modulo_gstock']   = 0;
  3449.                 $data['modulo_expowin']  = 0;
  3450.                 $data['modulo_prinex']   = 0;
  3451.                 $data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  3452.                 $azureResourceId 0;
  3453.                 $azureModelId '';
  3454.                 $aoaiResourceId 0;
  3455.             }
  3456.             // 3) Guardar en BD del cliente: parametros + license
  3457.             if ($data['modulo_extraccion'] === 1) {
  3458.                 if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3459.                     if ($aoaiResourceId <= 0) {
  3460.                         $this->addFlash('danger''Para Azure OpenAI debes seleccionar un recurso IA.');
  3461.                         $mysqli->close();
  3462.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3463.                     }
  3464.                     $azureResource $this->loadAzureResourceById($em$aoaiResourceId);
  3465.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3466.                         $this->addFlash('danger''El recurso Azure OpenAI seleccionado no existe.');
  3467.                         $mysqli->close();
  3468.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3469.                     }
  3470.                     $validationError $this->validateAoaiFields($aoaiFields);
  3471.                     if ($validationError !== null) {
  3472.                         $this->addFlash('danger'$validationError);
  3473.                         $mysqli->close();
  3474.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3475.                     }
  3476.                     try {
  3477.                         $data['extraction_model'] = $this->registerAoaiModelInClientDb(
  3478.                             $mysqli,
  3479.                             $azureResource,
  3480.                             $aoaiFields
  3481.                         );
  3482.                         $this->addFlash('success''Modelo Azure OpenAI importado/actualizado correctamente.');
  3483.                     } catch (\Throwable $e) {
  3484.                         $this->addFlash('danger''No se pudo registrar el modelo IA: ' $e->getMessage());
  3485.                         $mysqli->close();
  3486.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3487.                     }
  3488.                 } else {
  3489.                     if (($azureResourceId && $azureModelId === '') || ($azureResourceId <= && $azureModelId !== '')) {
  3490.                         $this->addFlash('danger''Para importar desde recurso IA debes indicar recurso y modelo.');
  3491.                         $mysqli->close();
  3492.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3493.                     }
  3494.                     if ($azureResourceId && $azureModelId !== '') {
  3495.                         $azureResource $this->loadAzureResourceById($em$azureResourceId);
  3496.                         if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  3497.                             $this->addFlash('danger''El recurso IA seleccionado no existe o no es de tipo Azure DI.');
  3498.                             $mysqli->close();
  3499.                             return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3500.                         }
  3501.                         try {
  3502.                             $data['extraction_model'] = $this->registerDiModelInClientDb(
  3503.                                 $mysqli,
  3504.                                 $azureResource,
  3505.                                 $azureModelId
  3506.                             );
  3507.                             $this->addFlash('success''Modelo IA importado/actualizado correctamente.');
  3508.                         } catch (\Throwable $e) {
  3509.                             $this->addFlash('danger''No se pudo registrar el modelo IA: ' $e->getMessage());
  3510.                             $mysqli->close();
  3511.                             return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3512.                         }
  3513.                     }
  3514.                     if ((int)$data['extraction_model'] <= 0) {
  3515.                         $this->addFlash('danger''Con extraccion activa debes seleccionar un modelo local o importar uno desde recurso IA.');
  3516.                         $mysqli->close();
  3517.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  3518.                     }
  3519.                 }
  3520.             }
  3521.             $this->updateEmpresaParametros($mysqli$data);
  3522.             if ((int)($data['modulo_gstock'] ?? 0) === && (int)($data['extraction_model'] ?? 0) > 0) {
  3523.                 try {
  3524.                     $this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
  3525.                 } catch (\Throwable $e) {
  3526.                     $this->addFlash('warning''No se pudo completar el automapeo de Gstock: ' $e->getMessage());
  3527.                 }
  3528.             }
  3529.             $this->updateLicense(
  3530.                 $mysqli,
  3531.                 $data,
  3532.                 $empresa->getName()
  3533.             );
  3534.             // Actualizar servicio OCR (hilos + binario segun modo OCR)
  3535.             $ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
  3536.             $ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
  3537.             $ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
  3538.             $ocrMode = ($data['ocr_mode'] ?? 'base');
  3539.             if ($ocrMode === 'plus_glm') {
  3540.                 $ocrBinary $ocrBinaryPlus;
  3541.             } elseif ($ocrMode === 'v2_zxing') {
  3542.                 $ocrBinary $ocrBinaryV2;
  3543.             } else {
  3544.                 $ocrBinary $ocrBinaryBase;
  3545.             }
  3546.             $filesPath = (string)($_ENV['FILES_PATH'] ?? '');
  3547.             if ($ocrBinary !== '' && $filesPath !== '') {
  3548.                 $companyId = (int)$empresa->getId();
  3549.                 $dbHost = (string)$cx->getDbUrl();
  3550.                 $dbName = (string)$cx->getDbName();
  3551.                 $dbUser = (string)$cx->getDbUser();
  3552.                 $dbPass = (string)$cx->getDbPassword();
  3553.                 $empresaName = (string)$empresa->getName();
  3554.                 $serviceContent = <<<EOT
  3555. [Unit]
  3556. Description={$empresaName} DocuManager OCR
  3557. Requires=mariadb.service
  3558. After=mariadb.service
  3559. [Service]
  3560. Type=simple
  3561. Environment="DOCU_MAX_THREADS=$maxThreads"
  3562. ExecStart=$ocrBinary {$dbHost}/{$dbName} {$dbUser} {$dbPass} {$filesPath}/{$companyId} NO
  3563. Restart=always
  3564. User=root
  3565. [Install]
  3566. WantedBy=multi-user.target
  3567. EOT;
  3568.                 $serviceName $companyId "-documanager.service";
  3569.                 $tmpServicePath "/tmp/$serviceName";
  3570.                 file_put_contents($tmpServicePath$serviceContent);
  3571.                 \chmod($tmpServicePath0644);
  3572.                 $cmds = [
  3573.                     "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  3574.                     "sudo /bin/systemctl daemon-reload",
  3575.                     "sudo /bin/systemctl restart $serviceName",
  3576.                 ];
  3577.                 $serviceErrors = [];
  3578.                 foreach ($cmds as $cmd) {
  3579.                     $output \shell_exec($cmd " 2>&1");
  3580.                     if ($output !== null && trim($output) !== '') {
  3581.                         error_log("CMD OUTPUT: $cmd\n$output");
  3582.                         $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  3583.                     }
  3584.                 }
  3585.                 if (count($serviceErrors) > 0) {
  3586.                     $this->addFlash('warning''Servicio OCR actualizado con avisos: ' implode(' | '$serviceErrors));
  3587.                 }
  3588.             } else {
  3589.                 if (($data['ocr_mode'] ?? 'base') === 'plus_glm') {
  3590.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_PLUS_BINARY o FILES_PATH.');
  3591.                 } elseif (($data['ocr_mode'] ?? 'base') === 'v2_zxing') {
  3592.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_BINARY_V2 o FILES_PATH.');
  3593.                 } else {
  3594.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_BINARY o FILES_PATH.');
  3595.                 }
  3596.             }
  3597.             // Si se activa Mail Monitor y antes estaba desactivado, crear servicio/timer
  3598.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === && $filesPath !== '') {
  3599.                 $this->ensureMailMonitorService(
  3600.                     (int)$empresa->getId(),
  3601.                     (string)$cx->getDbUrl(),
  3602.                     (string)$cx->getDbPort(),
  3603.                     (string)$cx->getDbUser(),
  3604.                     (string)$cx->getDbPassword(),
  3605.                     (string)$cx->getDbName(),
  3606.                     (string)$filesPath
  3607.                 );
  3608.             }
  3609.             // Si se desactiva Mail Monitor, parar y eliminar service/timer
  3610.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === 0) {
  3611.                 $this->disableMailMonitorService((int)$empresa->getId());
  3612.             }
  3613.             $em->flush();
  3614.             $mysqli->close();
  3615.             // Mensaje de éxito
  3616.             $this->addFlash('success''Empresa editada correctamente.');
  3617.             return $this->redirectToRoute('app_empresa_show', ['id' => $id]);
  3618.         }
  3619.         // 4) GET: precargar desde BD del cliente
  3620.         [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  3621.         $license $this->loadLicense($mysqli);
  3622.         $extractorType self::EXTRACTOR_TYPE_AZURE_DI;
  3623.         $selectedAoaiResourceId 0;
  3624.         $aoaiFields = [];
  3625.         if (!empty($modulos['extraction_model'])) {
  3626.             try {
  3627.                 $stmtCurrentModel $mysqli->prepare('SELECT provider FROM extraction_models WHERE id = ? LIMIT 1');
  3628.                 if ($stmtCurrentModel) {
  3629.                     $currentModelId = (int)$modulos['extraction_model'];
  3630.                     $stmtCurrentModel->bind_param('i'$currentModelId);
  3631.                     if ($stmtCurrentModel->execute()) {
  3632.                         $resCurrentModel $stmtCurrentModel->get_result();
  3633.                         if ($resCurrentModel && ($modelRow $resCurrentModel->fetch_assoc())) {
  3634.                             $provider strtolower(trim((string)($modelRow['provider'] ?? '')));
  3635.                             if ($provider === 'azure_openai' || $provider === 'azure-openai') {
  3636.                                 $extractorType self::EXTRACTOR_TYPE_AZURE_OPENAI;
  3637.                             }
  3638.                         }
  3639.                     }
  3640.                     $stmtCurrentModel->close();
  3641.                 }
  3642.             } catch (\Throwable $e) {
  3643.                 $extractorType self::EXTRACTOR_TYPE_AZURE_DI;
  3644.             }
  3645.         }
  3646.         if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI && !empty($modulos['extraction_model'])) {
  3647.             try {
  3648.                 $modelId = (int)$modulos['extraction_model'];
  3649.                 $selectedAoaiResourceId $this->resolveAoaiResourceIdForModel($mysqli$modelId$em);
  3650.                 $aoaiFields $this->loadAoaiFieldsForModel($mysqli$modelId);
  3651.             } catch (\Throwable $e) {
  3652.                 $aoaiFields = [];
  3653.             }
  3654.         }
  3655.         $extractionModels = [];
  3656.         try {
  3657.             $res $mysqli->query("SELECT id, model_id, provider FROM extraction_models WHERE provider IN ('azure-di', 'azure_openai', 'azure-openai') ORDER BY model_id");
  3658.             if ($res) {
  3659.                 while ($row $res->fetch_assoc()) {
  3660.                     $extractionModels[] = [
  3661.                         'id' => (int)$row['id'],
  3662.                         'model_id' => (string)$row['model_id'],
  3663.                         'provider' => (string)($row['provider'] ?? ''),
  3664.                     ];
  3665.                 }
  3666.                 $res->free();
  3667.             }
  3668.         } catch (\Throwable $e) {
  3669.             $extractionModels = [];
  3670.         }
  3671.         $mysqli->close();
  3672.         return $this->render('empresa/_edit.html.twig', [
  3673.             'empresa'      => $empresa,   // central: name + maxDiskQuota
  3674.             'id'           => $id,
  3675.             'activeUsers'  => $activeUsers// cliente
  3676.             'modulos'      => $modulos,     // cliente
  3677.             'license'      => $license,     // opcional
  3678.             'azure_resources' => $azureResources,
  3679.             'azure_di_resources' => $azureDiResources,
  3680.             'azure_openai_resources' => $azureOpenAiResources,
  3681.             'extraction_models' => $extractionModels,
  3682.             'form_data' => [
  3683.                 'extractor_type' => $extractorType,
  3684.                 'aoai_resource_id' => $selectedAoaiResourceId,
  3685.                 'ocr_mode' => $this->normalizeOcrMode($modulos['ocr_mode'] ?? 'base'),
  3686.             ],
  3687.             'aoai_fields' => $aoaiFields,
  3688.             'ocr_plus_available' => $ocrPlusAvailable,
  3689.             'ocr_v2_available' => $ocrV2Available,
  3690.         ]);
  3691.     }
  3692.     public function empresaLicenseEdit(Request $requestEntityManagerInterface $em)
  3693.     {
  3694.         if (!$this->getUser() || !is_object($this->getUser())) {
  3695.             return $this->redirectToRoute('logout');
  3696.         }
  3697.         $id = (int)$request->get('id');
  3698.         $empresa $em->getRepository(Empresa::class)->find($id);
  3699.         if (!$empresa) {
  3700.             throw $this->createNotFoundException('Empresa no encontrada');
  3701.         }
  3702.         $cx $empresa->getConexionBD();
  3703.         $mysqli = @new \mysqli(
  3704.             $cx->getDbUrl(),
  3705.             $cx->getDbUser(),
  3706.             $cx->getDbPassword(),
  3707.             $cx->getDbName(),
  3708.             (int)$cx->getDbPort()
  3709.         );
  3710.         if ($mysqli->connect_error) {
  3711.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3712.             return $this->redirectToRoute('list');
  3713.         }
  3714.         $editContractId max(0, (int)$request->query->get('edit_contract_id'0));
  3715.         $editLicenseFormData = [];
  3716.         if ($editContractId 0) {
  3717.             $editContract $this->licenseContractService->loadContractById($mysqli$editContractId);
  3718.             if (!empty($editContract)) {
  3719.                 $editLicenseFormData = [
  3720.                     'license_type' => (string)($editContract['type'] ?? 'page'),
  3721.                     'license_mode' => (string)($editContract['mode'] ?? 'monthly'),
  3722.                     'license_limit_mode' => (string)($editContract['limit_mode'] ?? 'block'),
  3723.                     'license_units_total' => (string)($editContract['units_total'] ?? '0'),
  3724.                     'license_units_used' => (string)($editContract['units_used'] ?? '0'),
  3725.                     'license_units_exceeded' => (string)($editContract['units_exceeded'] ?? '0'),
  3726.                     'license_start_date' => (string)($editContract['start_date'] ?? ''),
  3727.                     'license_end_date' => (string)($editContract['end_date'] ?? ''),
  3728.                 ];
  3729.             } else {
  3730.                 $editContractId 0;
  3731.                 $this->addFlash('warning''La licencia indicada no existe.');
  3732.             }
  3733.         }
  3734.         $response $this->renderEmpresaLicensePage($empresa$mysqli$editContractId $editContractId null$editLicenseFormData);
  3735.         $mysqli->close();
  3736.         return $response;
  3737.     }
  3738.     public function empresaLicenseUpdate(Request $requestEntityManagerInterface $em)
  3739.     {
  3740.         if (!$this->getUser() || !is_object($this->getUser())) {
  3741.             return $this->redirectToRoute('logout');
  3742.         }
  3743.         $id = (int)$request->get('id');
  3744.         $contractId = (int)$request->get('contractId');
  3745.         $empresa $em->getRepository(Empresa::class)->find($id);
  3746.         if (!$empresa) {
  3747.             throw $this->createNotFoundException('Empresa no encontrada');
  3748.         }
  3749.         $cx $empresa->getConexionBD();
  3750.         $mysqli = @new \mysqli(
  3751.             $cx->getDbUrl(),
  3752.             $cx->getDbUser(),
  3753.             $cx->getDbPassword(),
  3754.             $cx->getDbName(),
  3755.             (int)$cx->getDbPort()
  3756.         );
  3757.         if ($mysqli->connect_error) {
  3758.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3759.             return $this->redirectToRoute('list');
  3760.         }
  3761.         $data $request->request->all();
  3762.         try {
  3763.             $normalizedData $this->licenseContractService->normalizePayload($datatrue);
  3764.             $this->licenseContractService->updateContract($mysqli$contractId$normalizedData);
  3765.             $this->addFlash('success''Licencia actualizada correctamente.');
  3766.             $mysqli->close();
  3767.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  3768.         } catch (\InvalidArgumentException $e) {
  3769.             $this->addFlash('danger'$e->getMessage());
  3770.         } catch (\Throwable $e) {
  3771.             $this->addFlash('danger''Error al actualizar la licencia: ' $e->getMessage());
  3772.         }
  3773.         $response $this->renderEmpresaLicensePage($empresa$mysqli$contractId$data);
  3774.         $mysqli->close();
  3775.         return $response;
  3776.     }
  3777.     public function empresaLicenseDelete(Request $requestEntityManagerInterface $em)
  3778.     {
  3779.         if (!$this->getUser() || !is_object($this->getUser())) {
  3780.             return $this->redirectToRoute('logout');
  3781.         }
  3782.         $id = (int)$request->get('id');
  3783.         $contractId = (int)$request->get('contractId');
  3784.         $empresa $em->getRepository(Empresa::class)->find($id);
  3785.         if (!$empresa) {
  3786.             throw $this->createNotFoundException('Empresa no encontrada');
  3787.         }
  3788.         $cx $empresa->getConexionBD();
  3789.         $mysqli = @new \mysqli(
  3790.             $cx->getDbUrl(),
  3791.             $cx->getDbUser(),
  3792.             $cx->getDbPassword(),
  3793.             $cx->getDbName(),
  3794.             (int)$cx->getDbPort()
  3795.         );
  3796.         if ($mysqli->connect_error) {
  3797.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3798.             return $this->redirectToRoute('list');
  3799.         }
  3800.         try {
  3801.             $this->licenseContractService->deleteContract($mysqli$contractId);
  3802.             $this->addFlash('success''Licencia eliminada correctamente.');
  3803.         } catch (\Throwable $e) {
  3804.             $this->addFlash('danger''Error al eliminar la licencia: ' $e->getMessage());
  3805.         }
  3806.         $mysqli->close();
  3807.         return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  3808.     }
  3809.     public function empresaLicenseExport(Request $requestEntityManagerInterface $em)
  3810.     {
  3811.         if (!$this->getUser() || !is_object($this->getUser())) {
  3812.             return $this->redirectToRoute('logout');
  3813.         }
  3814.         $id = (int)$request->get('id');
  3815.         $empresa $em->getRepository(Empresa::class)->find($id);
  3816.         if (!$empresa) {
  3817.             throw $this->createNotFoundException('Empresa no encontrada');
  3818.         }
  3819.         try {
  3820.             $mysqli $this->openEmpresaMysqli($empresa);
  3821.         } catch (\Throwable $e) {
  3822.             $this->addFlash('danger'$e->getMessage());
  3823.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  3824.         }
  3825.         try {
  3826.             $rows $this->buildTenantLicenseExportRows(
  3827.                 $empresa,
  3828.                 $mysqli
  3829.             );
  3830.         } catch (\Throwable $e) {
  3831.             $mysqli->close();
  3832.             $this->addFlash('danger''Error al exportar licencias: ' $e->getMessage());
  3833.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  3834.         }
  3835.         $mysqli->close();
  3836.         return $this->createLicenseCsvResponse(
  3837.             sprintf('license-history-tenant-%d.csv'$id),
  3838.             $this->tenantLicenseExportColumns(),
  3839.             $rows
  3840.         );
  3841.     }
  3842.     public function empresaLicenseExportAll(Request $requestEntityManagerInterface $em)
  3843.     {
  3844.         if (!$this->getUser() || !is_object($this->getUser())) {
  3845.             return $this->redirectToRoute('logout');
  3846.         }
  3847.         try {
  3848.             $range $this->licenseContractService->validateExportRange(
  3849.                 $request->query->get('export_start_date'),
  3850.                 $request->query->get('export_end_date')
  3851.             );
  3852.         } catch (\InvalidArgumentException $e) {
  3853.             $this->addFlash('danger'$e->getMessage());
  3854.             return $this->redirectToRoute('list');
  3855.         }
  3856.         $rows = [];
  3857.         $empresas $em->getRepository(Empresa::class)->findAll();
  3858.         foreach ($empresas as $empresa) {
  3859.             try {
  3860.                 $mysqli $this->openEmpresaMysqli($empresa);
  3861.                 try {
  3862.                     $tenantRows $this->buildTenantLicenseSummaryRows(
  3863.                         $empresa,
  3864.                         $mysqli,
  3865.                         $range['start_date'],
  3866.                         $range['end_date']
  3867.                     );
  3868.                     foreach ($tenantRows as $row) {
  3869.                         $rows[] = $row;
  3870.                     }
  3871.                 } finally {
  3872.                     $mysqli->close();
  3873.                 }
  3874.             } catch (\Throwable $e) {
  3875.                 $rows[] = $this->createTenantConnectionErrorRow(
  3876.                     $empresa,
  3877.                     $range['start_date'],
  3878.                     $range['end_date']
  3879.                 );
  3880.             }
  3881.         }
  3882.         return $this->createLicenseCsvResponse(
  3883.             sprintf(
  3884.                 'license-summary-all-tenants-%s-to-%s.csv',
  3885.                 $range['start_date'],
  3886.                 $range['end_date']
  3887.             ),
  3888.             $this->licenseSummaryExportColumns(),
  3889.             $rows
  3890.         );
  3891.     }
  3892.     private function renderEmpresaLicensePage(Empresa $empresa\mysqli $mysqli, ?int $editContractId null, array $editLicenseFormData = [])
  3893.     {
  3894.         $activeLicenseContract $this->licenseContractService->loadActiveContract($mysqli);
  3895.         $licenseContractHistory $this->licenseContractService->loadContractHistory($mysqli);
  3896.         $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($licenseContractHistory);
  3897.         return $this->render('empresa/licenses.html.twig', [
  3898.             'empresa' => $empresa,
  3899.             'activeLicenseContract' => $activeLicenseContract,
  3900.             'licenseContractHistory' => $licenseContractHistory,
  3901.             'visualActiveContractId' => $visualActiveContractId,
  3902.             'editContractId' => $editContractId,
  3903.             'editLicenseFormData' => $editLicenseFormData,
  3904.         ]);
  3905.     }
  3906.     public function empresaLicenseCreate(Request $requestEntityManagerInterface $em)
  3907.     {
  3908.         if (!$this->getUser() || !is_object($this->getUser())) {
  3909.             return $this->redirectToRoute('logout');
  3910.         }
  3911.         $id = (int)$request->get('id');
  3912.         $empresa $em->getRepository(Empresa::class)->find($id);
  3913.         if (!$empresa) {
  3914.             throw $this->createNotFoundException('Empresa no encontrada');
  3915.         }
  3916.         $cx $empresa->getConexionBD();
  3917.         $mysqli = @new \mysqli(
  3918.             $cx->getDbUrl(),
  3919.             $cx->getDbUser(),
  3920.             $cx->getDbPassword(),
  3921.             $cx->getDbName(),
  3922.             (int)$cx->getDbPort()
  3923.         );
  3924.         if ($mysqli->connect_error) {
  3925.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3926.             return $this->redirectToRoute('list');
  3927.         }
  3928.         $data $request->request->all();
  3929.         try {
  3930.             $normalizedData $this->licenseContractService->normalizePayload($datafalse);
  3931.             $this->licenseContractService->createNewContract($mysqli$normalizedData);
  3932.             $this->addFlash('success''Nueva licencia creada correctamente.');
  3933.         } catch (\InvalidArgumentException $e) {
  3934.             $this->addFlash('danger'$e->getMessage());
  3935.         } catch (\Throwable $e) {
  3936.             $this->addFlash('danger''Error al crear la licencia: ' $e->getMessage());
  3937.         }
  3938.         $mysqli->close();
  3939.         return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  3940.     }
  3941.     private function openEmpresaMysqli(Empresa $empresa): \mysqli
  3942.     {
  3943.         $cx $empresa->getConexionBD();
  3944.         if (!$cx) {
  3945.             throw new \RuntimeException('La empresa no tiene configurada una conexion de base de datos.');
  3946.         }
  3947.         $mysqli = @new \mysqli(
  3948.             $cx->getDbUrl(),
  3949.             $cx->getDbUser(),
  3950.             $cx->getDbPassword(),
  3951.             $cx->getDbName(),
  3952.             (int)$cx->getDbPort()
  3953.         );
  3954.         if ($mysqli->connect_error) {
  3955.             throw new \RuntimeException('No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3956.         }
  3957.         return $mysqli;
  3958.     }
  3959.     private function tenantLicenseExportColumns(): array
  3960.     {
  3961.         return [
  3962.             'tenant_id',
  3963.             'tenant_name',
  3964.             'license_status',
  3965.             'contract_type',
  3966.             'contract_mode',
  3967.             'limit_mode',
  3968.             'units_total',
  3969.             'units_used',
  3970.             'units_exceeded',
  3971.             'contract_start_date',
  3972.             'contract_end_date',
  3973.         ];
  3974.     }
  3975.     private function licenseSummaryExportColumns(): array
  3976.     {
  3977.         return [
  3978.             'tenant_id',
  3979.             'tenant_name',
  3980.             'range_start',
  3981.             'range_end',
  3982.             'contract_type',
  3983.             'contract_mode',
  3984.             'limit_mode',
  3985.             'units_total',
  3986.             'units_used',
  3987.             'units_exceeded',
  3988.         ];
  3989.     }
  3990.     private function buildTenantLicenseExportRows(Empresa $empresa\mysqli $mysqli): array
  3991.     {
  3992.         $history $this->licenseContractService->loadContractHistory($mysqli);
  3993.         $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($history);
  3994.         $rows = [];
  3995.         foreach ($history as $contract) {
  3996.             $rows[] = [
  3997.                 'tenant_id' => (string)$empresa->getId(),
  3998.                 'tenant_name' => (string)$empresa->getName(),
  3999.                 'license_status' => ((int)($contract['id'] ?? 0) === (int)$visualActiveContractId) ? 'Activa' 'Cerrada',
  4000.                 'contract_type' => (string)($contract['type'] ?? ''),
  4001.                 'contract_mode' => (string)($contract['mode'] ?? ''),
  4002.                 'limit_mode' => (string)($contract['limit_mode'] ?? ''),
  4003.                 'units_total' => (string)max(0, (int)($contract['units_total'] ?? 0)),
  4004.                 'units_used' => (string)max(0, (int)($contract['units_used'] ?? 0)),
  4005.                 'units_exceeded' => (string)max(0, (int)($contract['units_exceeded'] ?? 0)),
  4006.                 'contract_start_date' => (string)($contract['start_date'] ?? ''),
  4007.                 'contract_end_date' => (string)($contract['end_date'] ?? ''),
  4008.             ];
  4009.         }
  4010.         return $rows;
  4011.     }
  4012.     private function buildTenantLicenseSummaryRows(Empresa $empresa\mysqli $mysqlistring $rangeStartstring $rangeEnd): array
  4013.     {
  4014.         $contracts $this->licenseContractService->loadContractsStartingWithinRange($mysqli$rangeStart$rangeEnd);
  4015.         $aggregatedRows = [];
  4016.         foreach ($contracts as $contract) {
  4017.             $contractType trim((string)($contract['type'] ?? ''));
  4018.             $contractMode trim((string)($contract['mode'] ?? ''));
  4019.             $limitMode trim((string)($contract['limit_mode'] ?? ''));
  4020.             $groupKey implode('|', [
  4021.                 $empresa->getId(),
  4022.                 $contractType,
  4023.                 $contractMode,
  4024.                 $limitMode,
  4025.             ]);
  4026.             if (!isset($aggregatedRows[$groupKey])) {
  4027.                 $aggregatedRows[$groupKey] = [
  4028.                     'tenant_id' => (string)$empresa->getId(),
  4029.                     'tenant_name' => (string)$empresa->getName(),
  4030.                     'range_start' => $rangeStart,
  4031.                     'range_end' => $rangeEnd,
  4032.                     'contract_type' => $contractType,
  4033.                     'contract_mode' => $contractMode,
  4034.                     'limit_mode' => $limitMode,
  4035.                     'units_total' => 0,
  4036.                     'units_used' => 0,
  4037.                     'units_exceeded' => 0,
  4038.                 ];
  4039.             }
  4040.             $aggregatedRows[$groupKey]['units_total'] += max(0, (int)($contract['units_total'] ?? 0));
  4041.             $aggregatedRows[$groupKey]['units_used'] += max(0, (int)($contract['units_used'] ?? 0));
  4042.             $aggregatedRows[$groupKey]['units_exceeded'] += max(0, (int)($contract['units_exceeded'] ?? 0));
  4043.         }
  4044.         $rows = [];
  4045.         foreach ($aggregatedRows as $row) {
  4046.             $rows[] = [
  4047.                 'tenant_id' => (string)$empresa->getId(),
  4048.                 'tenant_name' => (string)$empresa->getName(),
  4049.                 'range_start' => $rangeStart,
  4050.                 'range_end' => $rangeEnd,
  4051.                 'contract_type' => (string)$row['contract_type'],
  4052.                 'contract_mode' => (string)$row['contract_mode'],
  4053.                 'limit_mode' => (string)$row['limit_mode'],
  4054.                 'units_total' => (string)$row['units_total'],
  4055.                 'units_used' => (string)$row['units_used'],
  4056.                 'units_exceeded' => (string)$row['units_exceeded'],
  4057.             ];
  4058.         }
  4059.         return $rows;
  4060.     }
  4061.     private function createTenantConnectionErrorRow(Empresa $empresastring $rangeStartstring $rangeEnd): array
  4062.     {
  4063.         return [
  4064.             'tenant_id' => (string)$empresa->getId(),
  4065.             'tenant_name' => (string)$empresa->getName(),
  4066.             'range_start' => $rangeStart,
  4067.             'range_end' => $rangeEnd,
  4068.             'contract_type' => '',
  4069.             'contract_mode' => '',
  4070.             'limit_mode' => '',
  4071.             'units_total' => '',
  4072.             'units_used' => '',
  4073.             'units_exceeded' => '',
  4074.         ];
  4075.     }
  4076.     private function createLicenseCsvResponse(string $filename, array $columns, array $rows): StreamedResponse
  4077.     {
  4078.         $response = new StreamedResponse(function () use ($columns$rows): void {
  4079.             $out fopen('php://output''wb');
  4080.             if ($out === false) {
  4081.                 throw new \RuntimeException('No se pudo abrir la salida CSV.');
  4082.             }
  4083.             fwrite($out"\xEF\xBB\xBF");
  4084.             fputcsv($out$columns';');
  4085.             foreach ($rows as $row) {
  4086.                 $csvRow = [];
  4087.                 foreach ($columns as $column) {
  4088.                     $csvRow[] = (string)($row[$column] ?? '');
  4089.                 }
  4090.                 fputcsv($out$csvRow';');
  4091.             }
  4092.             fclose($out);
  4093.         });
  4094.         $response->headers->set('Content-Type''text/csv; charset=UTF-8');
  4095.         $response->headers->set('Content-Disposition''attachment; filename="' $filename '"');
  4096.         return $response;
  4097.     }
  4098.     private function loadEmpresaParametros(\mysqli $mysqli): array
  4099.     {
  4100.         // claves que nos interesan en la tabla parametros
  4101.         $keys = [
  4102.             'activeUsers',
  4103.             'soloExtraccion',
  4104.             'modulo_etiquetas',
  4105.             'modulo_calendario',
  4106.             'modulo_calExt',
  4107.             'modulo_estados',
  4108.             'modulo_subida',
  4109.             'modulo_mailMonitor',
  4110.             'modulo_busquedaNatural',
  4111.             'modulo_extraccion',
  4112.             'modulo_lineas',
  4113.             'modulo_agora',
  4114.             'modulo_gstock',
  4115.             'modulo_expowin',
  4116.             'modulo_prinex',
  4117.             'extraction_model',
  4118.             'ocr_mode',
  4119.             'tokensContratados',
  4120.             'tokensUsados',
  4121.         ];
  4122.         $placeholders implode(','array_fill(0count($keys), '?'));
  4123.         $sql "SELECT nombre, valor FROM parametros WHERE nombre IN ($placeholders)";
  4124.         $stmt $mysqli->prepare($sql);
  4125.         if (!$stmt) {
  4126.             throw new \RuntimeException('Prepare SELECT parametros: ' $mysqli->error);
  4127.         }
  4128.         // bind dinámico
  4129.         $types str_repeat('s'count($keys));
  4130.         $stmt->bind_param($types, ...$keys);
  4131.         if (!$stmt->execute()) {
  4132.             $stmt->close();
  4133.             throw new \RuntimeException('Execute SELECT parametros: ' $stmt->error);
  4134.         }
  4135.         $res $stmt->get_result();
  4136.         $map = [];
  4137.         while ($row $res->fetch_assoc()) {
  4138.             $map[$row['nombre']] = $row['valor'];
  4139.         }
  4140.         $stmt->close();
  4141.         // defaults seguros
  4142.         $activeUsers = isset($map['activeUsers']) ? (int)$map['activeUsers'] : 3;
  4143.         $flags = [
  4144.             'soloExtraccion'   => isset($map['soloExtraccion'])   ? (int)$map['soloExtraccion']   : 0,
  4145.             'modulo_etiquetas'  => isset($map['modulo_etiquetas'])  ? (int)$map['modulo_etiquetas']  : 0,
  4146.             'modulo_calendario' => isset($map['modulo_calendario']) ? (int)$map['modulo_calendario'] : 0,
  4147.             'modulo_calExt'     => isset($map['modulo_calExt'])     ? (int)$map['modulo_calExt']     : 0,
  4148.             'modulo_estados'    => isset($map['modulo_estados'])    ? (int)$map['modulo_estados']    : 0,
  4149.             'modulo_subida'     => isset($map['modulo_subida'])     ? (int)$map['modulo_subida']     : 0,
  4150.             'modulo_extraccion' => isset($map['modulo_extraccion']) ? (int)$map['modulo_extraccion'] : 0,
  4151.             'modulo_lineas'     => isset($map['modulo_lineas'])     ? (int)$map['modulo_lineas']     : 0,
  4152.             'modulo_agora'      => isset($map['modulo_agora'])      ? (int)$map['modulo_agora']      : 0,
  4153.             'modulo_gstock'     => isset($map['modulo_gstock'])     ? (int)$map['modulo_gstock']     : 0,
  4154.             'modulo_expowin'    => isset($map['modulo_expowin'])    ? (int)$map['modulo_expowin']    : 0,
  4155.             'modulo_prinex'     => isset($map['modulo_prinex'])     ? (int)$map['modulo_prinex']     : 0,
  4156.             'modulo_mailMonitor' => isset($map['modulo_mailMonitor']) ? (int)$map['modulo_mailMonitor'] : 0,
  4157.             'modulo_busquedaNatural' => isset($map['modulo_busquedaNatural']) ? (int)$map['modulo_busquedaNatural'] : 0,
  4158.         ];
  4159.         // extraction_model: default 0 (sin modelo)
  4160.         $flags['extraction_model'] = isset($map['extraction_model']) && $map['extraction_model'] !== '' ? (int)$map['extraction_model'] : 0;
  4161.         $flags['ocr_mode'] = $this->normalizeOcrMode($map['ocr_mode'] ?? 'base');
  4162.         $flags['tokensContratados'] = isset($map['tokensContratados']) && $map['tokensContratados'] !== ''
  4163.             max(0, (int)$map['tokensContratados'])
  4164.             : 0;
  4165.         $flags['tokensUsados'] = isset($map['tokensUsados']) && $map['tokensUsados'] !== ''
  4166.             max(0, (int)$map['tokensUsados'])
  4167.             : 0;
  4168.         return [$activeUsers$flags];
  4169.     }
  4170.     private function updateEmpresaParametros(\mysqli $mysqli, array $data): void
  4171.     {
  4172.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  4173.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  4174.         $paramMap = [
  4175.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  4176.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  4177.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  4178.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  4179.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  4180.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  4181.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  4182.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  4183.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  4184.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  4185.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  4186.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  4187.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  4188.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  4189.             'modulo_prinex'      => $getFlag($data'modulo_prinex'),
  4190.             'extraction_model'   => $getInt($data'extraction_model'0),
  4191.             'ocr_mode'           => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
  4192.             'tokensContratados'  => max(0$getInt($data'tokensContratados'0)),
  4193.         ];
  4194.         if ($paramMap['modulo_extraccion'] === 0) {
  4195.             $paramMap['modulo_lineas'] = 0;
  4196.             $paramMap['extraction_model'] = 0;
  4197.             $paramMap['modulo_agora'] = 0;
  4198.             $paramMap['modulo_gstock'] = 0;
  4199.             $paramMap['modulo_expowin'] = 0;
  4200.             $paramMap['modulo_prinex'] = 0;
  4201.         }
  4202.         if ($paramMap['modulo_calendario'] === 0) {
  4203.             $paramMap['modulo_calExt'] = 0;
  4204.         }
  4205.         $mysqli->begin_transaction();
  4206.         try {
  4207.             $stmt $mysqli->prepare("
  4208.                 INSERT INTO parametros (nombre, valor)
  4209.                 VALUES (?, ?)
  4210.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  4211.             ");
  4212.             if (!$stmt) {
  4213.                 throw new \RuntimeException('Prepare UPSERT parametros: ' $mysqli->error);
  4214.             }
  4215.             foreach ($paramMap as $nombre => $valor) {
  4216.                 $v = (string)$valor;
  4217.                 if (!$stmt->bind_param('ss'$nombre$v) || !$stmt->execute()) {
  4218.                     $stmt->close();
  4219.                     throw new \RuntimeException("Guardar parámetro {$nombre}{$stmt->error}");
  4220.                 }
  4221.             }
  4222.             $stmt->close();
  4223.             $mysqli->commit();
  4224.         } catch (\Throwable $e) {
  4225.             $mysqli->rollback();
  4226.             throw $e;
  4227.         }
  4228.     }
  4229.     private function loadLicense(\mysqli $mysqli): array
  4230.     {
  4231.         // Carga opcional para mostrar en la edición: capacityGb/users (u otros)
  4232.         $sql "SELECT client, capacityGb, users FROM license LIMIT 1";
  4233.         $res $mysqli->query($sql);
  4234.         if ($res && $row $res->fetch_assoc()) {
  4235.             return $row;
  4236.         }
  4237.         return [];
  4238.     }
  4239.     private function updateLicense(\mysqli $mysqli, array $datastring $clientName): void
  4240.     {
  4241.         $capacityGb  = isset($data['maxDiskQuota']) ? (int)$data['maxDiskQuota'] : 200;
  4242.         $activeUsers = isset($data['maxActiveUsers']) ? (int)$data['maxActiveUsers'] : 3;
  4243.         $stmt $mysqli->prepare("UPDATE license SET capacityGb = ?, users = ? WHERE client = ?");
  4244.         if (!$stmt) {
  4245.             throw new \RuntimeException('Prepare UPDATE license: ' $mysqli->error);
  4246.         }
  4247.         $stmt->bind_param('iis'$capacityGb$activeUsers$clientName);
  4248.         $stmt->execute();
  4249.         $stmt->close();
  4250.     }
  4251.     public function usersListEmpresa(Request $requestEntityManagerInterface $em)
  4252.     {
  4253.         $id $request->get("id");
  4254.         $users_empresa $em->getRepository(Usuario::class)->findBy(array("empresa" => $id));
  4255.         return $this->render('empresa/usersList.html.twig', array(
  4256.             'users' => $users_empresa,
  4257.             'id' => $id
  4258.         ));
  4259.     }
  4260.     private function loadEmpresaDiskUsageBytes(\mysqli $mysqli): ?int
  4261.     {
  4262.         // Si la tabla no existe o hay error, devolvemos null para no romper la vista
  4263.         $sql "SELECT COALESCE(SUM(size_bytes), 0) AS total_bytes FROM files";
  4264.         $res $mysqli->query($sql);
  4265.         if (!$res) {
  4266.             return null;
  4267.         }
  4268.         $row $res->fetch_assoc();
  4269.         $res->free();
  4270.         return isset($row['total_bytes']) ? (int)$row['total_bytes'] : 0;
  4271.     }
  4272.     // =======================================================================
  4273.     // VER DOCUMENTACION DE EMPRESAS
  4274.     // =======================================================================
  4275.     #[Route('/empresa/{id}/documentos'name'empresa_documentos'methods: ['GET'])]
  4276.     public function documentosEmpresa(Request $requestEntityManagerInterface $em)
  4277.     {
  4278.         if (!$this->getUser() || !is_object($this->getUser())) {
  4279.             return $this->redirectToRoute('logout');
  4280.         }
  4281.         $id = (int) $request->get('id');
  4282.         $empresa $em->getRepository(Empresa::class)->find($id);
  4283.         if (!$empresa) {
  4284.             $this->addFlash('warning''Empresa no encontrada.');
  4285.             return $this->redirectToRoute('list');
  4286.         }
  4287.         // Para el desplegable
  4288.         $empresas $em->getRepository(Empresa::class)->findAll();
  4289.         $baseUrl $this->getParameter('documanager_base_url');
  4290.         return $this->render('empresa/documentos.html.twig', [
  4291.             'empresaId' => $id,
  4292.             'empresas'  => $empresas,
  4293.             'documanagerBaseUrl' => $baseUrl,
  4294.         ]);
  4295.     }
  4296.     #[Route('/api/empresa/{id}/documentos'name'empresa_documentos_api'methods: ['GET'])]
  4297.     public function documentosEmpresaApi(Request $requestEntityManagerInterface $em): JsonResponse
  4298.     {
  4299.         if (!$this->getUser() || !is_object($this->getUser())) {
  4300.             return new JsonResponse(['error' => 'unauthorized'], 401);
  4301.         }
  4302.         $id = (int) $request->get('id');
  4303.         $empresa $em->getRepository(Empresa::class)->find($id);
  4304.         if (!$empresa) {
  4305.             return new JsonResponse(['error' => 'Empresa no encontrada'], 404);
  4306.         }
  4307.         $cx $empresa->getConexionBD();
  4308.         if (!$cx) {
  4309.             return new JsonResponse(['error' => 'La empresa no tiene conexión configurada'], 400);
  4310.         }
  4311.         $page    max(1, (int) $request->query->get('page'1));
  4312.         $perPage = (int) $request->query->get('per_page'50);
  4313.         $perPage min(max($perPage10), 200);
  4314.         $q       = (string) $request->query->get('q''');
  4315.         $offset  = ($page 1) * $perPage;
  4316.         $mysqli = @new \mysqli(
  4317.             $cx->getDbUrl(),
  4318.             $cx->getDbUser(),
  4319.             $cx->getDbPassword(),
  4320.             $cx->getDbName(),
  4321.             (int) $cx->getDbPort()
  4322.         );
  4323.         if ($mysqli->connect_error) {
  4324.             return new JsonResponse(['error' => 'Error conectando a la BD del cliente: ' $mysqli->connect_error], 500);
  4325.         }
  4326.         $mysqli->set_charset('utf8mb4');
  4327.         // --- TOTAL ---
  4328.         if ($q === '') {
  4329.             $sqlTotal "SELECT COUNT(*) AS c FROM files";
  4330.             $res $mysqli->query($sqlTotal);
  4331.             $row $res $res->fetch_assoc() : null;
  4332.             $total = (int)($row['c'] ?? 0);
  4333.         } else {
  4334.             $sqlTotal "SELECT COUNT(*) AS c
  4335.                          FROM files
  4336.                          WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?";
  4337.             $qLike '%' $q '%';
  4338.             $st $mysqli->prepare($sqlTotal);
  4339.             $st->bind_param('ssss'$qLike$qLike$qLike$qLike);
  4340.             $st->execute();
  4341.             $res $st->get_result();
  4342.             $row $res $res->fetch_assoc() : null;
  4343.             $total = (int)($row['c'] ?? 0);
  4344.             $st->close();
  4345.         }
  4346.         // --- ITEMS ---
  4347.         $items = [];
  4348.         if ($q === '') {
  4349.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  4350.                     FROM files
  4351.                     ORDER BY `date` DESC
  4352.                     LIMIT ? OFFSET ?";
  4353.             $st $mysqli->prepare($sql);
  4354.             $st->bind_param('ii'$perPage$offset);
  4355.         } else {
  4356.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  4357.                     FROM files
  4358.                     WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?
  4359.                     ORDER BY `date` DESC
  4360.                     LIMIT ? OFFSET ?";
  4361.             $qLike '%' $q '%';
  4362.             $st $mysqli->prepare($sql);
  4363.             $st->bind_param('ssssii'$qLike$qLike$qLike$qLike$perPage$offset);
  4364.         }
  4365.         $st->execute();
  4366.         $res $st->get_result();
  4367.         if ($res) {
  4368.             while ($r $res->fetch_assoc()) {
  4369.                 $items[] = $r;
  4370.             }
  4371.         }
  4372.         $st->close();
  4373.         $mysqli->close();
  4374.         return new JsonResponse([
  4375.             'company_id' => $id,
  4376.             'page'       => $page,
  4377.             'per_page'   => $perPage,
  4378.             'total'      => $total,
  4379.             'items'      => $items,
  4380.         ]);
  4381.     }
  4382. }