src/Controller/AdminController.php line 55

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_lineas'] = isset($data['modulo_lineas']) && $toBool($data['modulo_lineas']) ? 0;
  1689.             $data['modulo_conciliacion'] = isset($data['modulo_conciliacion']) && $toBool($data['modulo_conciliacion']) ? 0;
  1690.             $data['modulo_precios'] = isset($data['modulo_precios']) && $toBool($data['modulo_precios']) ? 0;
  1691.             $data['modulo_ubikos'] = isset($data['modulo_ubikos']) && $toBool($data['modulo_ubikos']) ? 0;
  1692.             $data['modulo_expowin'] = isset($data['modulo_expowin']) && $toBool($data['modulo_expowin']) ? 0;
  1693.             $data['modulo_prinex'] = isset($data['modulo_prinex']) && $toBool($data['modulo_prinex']) ? 0;
  1694.             $extractorType $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  1695.             $data['extractor_type'] = $extractorType;
  1696.             $azureResource null;
  1697.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  1698.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  1699.             $aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
  1700.             $aoaiFields $this->collectAoaiFieldsFromRequest($req);
  1701.             $enricherEnabled = isset($data['enricher_enabled']) && $toBool($data['enricher_enabled']);
  1702.             $enricherResourceId = (int)($data['enricher_resource_id'] ?? 0);
  1703.             $enricherFields $this->collectEnricherFieldsFromRequest($req);
  1704.             $renderAddWithData = function () use ($azureResources$azureDiResources$azureOpenAiResources$data$aoaiFields$enricherFields$enricherEnabled) {
  1705.                 $contractPreview $this->licenseContractService->defaultContract();
  1706.                 try {
  1707.                     $contractPreview $this->licenseContractService->contractFromPayload($data);
  1708.                 } catch (\Throwable $e) {
  1709.                     // Mantener preview por defecto si los datos de licencia son inválidos.
  1710.                 }
  1711.                 return $this->render('empresa/_add.html.twig', [
  1712.                     'azure_resources' => $azureResources,
  1713.                     'azure_di_resources' => $azureDiResources,
  1714.                     'azure_openai_resources' => $azureOpenAiResources,
  1715.                     'modulos' => [
  1716.                         'extraction_model' => 0,
  1717.                     ],
  1718.                     'form_data' => $data,
  1719.                     'activeLicenseContract' => $contractPreview,
  1720.                     'aoai_fields' => $aoaiFields,
  1721.                     'enricher' => [
  1722.                         'enabled' => $enricherEnabled 0,
  1723.                         'aoai_resource_id' => (int)($data['enricher_resource_id'] ?? 0),
  1724.                         'fields' => $enricherFields,
  1725.                     ],
  1726.                     'ocr_plus_available' => $ocrPlusAvailable,
  1727.                     'ocr_v2_available' => $ocrV2Available,
  1728.                 ]);
  1729.             };
  1730.             try {
  1731.                 $data $this->licenseContractService->normalizePayload($datafalse);
  1732.             } catch (\InvalidArgumentException $e) {
  1733.                 $this->addFlash('danger'$e->getMessage());
  1734.                 return $renderAddWithData();
  1735.             }
  1736.             if ($data['modulo_extraccion'] === 1) {
  1737.                 if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1738.                     if ($aoaiResourceId <= 0) {
  1739.                         $this->addFlash('danger''Para Azure OpenAI debes seleccionar un recurso IA.');
  1740.                         return $renderAddWithData();
  1741.                     }
  1742.                     $azureResource $this->loadAzureResourceById($em$aoaiResourceId);
  1743.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1744.                         $this->addFlash('danger''El recurso Azure OpenAI seleccionado no existe.');
  1745.                         return $renderAddWithData();
  1746.                     }
  1747.                     $validationError $this->validateAoaiFields($aoaiFields);
  1748.                     if ($validationError !== null) {
  1749.                         $this->addFlash('danger'$validationError);
  1750.                         return $renderAddWithData();
  1751.                     }
  1752.                 } else {
  1753.                     if ($azureResourceId <= || $azureModelId === '') {
  1754.                         $this->addFlash('danger''Para activar extraccion debes seleccionar recurso y modelo IA.');
  1755.                         return $renderAddWithData();
  1756.                     }
  1757.                     $azureResource $this->loadAzureResourceById($em$azureResourceId);
  1758.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  1759.                         $this->addFlash('danger''El recurso IA seleccionado no existe o no es de tipo Azure DI.');
  1760.                         return $renderAddWithData();
  1761.                     }
  1762.                     try {
  1763.                         $this->azureRequest(
  1764.                             (string)$azureResource['endpoint'],
  1765.                             (string)$azureResource['api_key'],
  1766.                             '/documentintelligence/documentModels/' rawurlencode($azureModelId)
  1767.                         );
  1768.                     } catch (\Throwable $e) {
  1769.                         $this->addFlash('danger''No se pudo validar el modelo IA: ' $e->getMessage());
  1770.                         return $renderAddWithData();
  1771.                     }
  1772.                 }
  1773.                 if ($enricherEnabled) {
  1774.                     if ($enricherResourceId <= 0) {
  1775.                         $this->addFlash('danger''Para activar el enricher debes seleccionar un recurso Azure OpenAI.');
  1776.                         return $renderAddWithData();
  1777.                     }
  1778.                     $enricherResource $this->loadAzureResourceById($em$enricherResourceId);
  1779.                     if (!$enricherResource || ($enricherResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1780.                         $this->addFlash('danger''El recurso Azure OpenAI del enricher no existe.');
  1781.                         return $renderAddWithData();
  1782.                     }
  1783.                     $validationError $this->validateAoaiFields($enricherFields);
  1784.                     if ($validationError !== null) {
  1785.                         $this->addFlash('danger'$validationError);
  1786.                         return $renderAddWithData();
  1787.                     }
  1788.                 }
  1789.             } else {
  1790.                 $data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  1791.                 $data['azure_resource_id'] = 0;
  1792.                 $data['azure_model_id'] = '';
  1793.                 $data['aoai_resource_id'] = 0;
  1794.                 $data['enricher_enabled'] = 0;
  1795.                 $data['enricher_resource_id'] = 0;
  1796.                 $data['extraction_model'] = 0;
  1797.                 $data['modulo_lineas'] = 0;
  1798.                 $data['modulo_conciliacion'] = 0;
  1799.                 $data['modulo_precios'] = 0;
  1800.                 $data['modulo_ubikos'] = 0;
  1801.                 $data['modulo_expowin'] = 0;
  1802.                 $data['modulo_prinex'] = 0;
  1803.             }
  1804.             if ($data['modulo_extraccion'] === 0) {
  1805.                 $data['modulo_lineas'] = 0;
  1806.                 $data['modulo_conciliacion'] = 0;
  1807.                 $data['modulo_precios'] = 0;
  1808.                 $data['modulo_ubikos'] = 0;
  1809.             }
  1810.             if ($data['modulo_lineas'] === 0) {
  1811.                 $data['modulo_precios'] = 0;
  1812.             }
  1813.             // Crear nombre de base de datos y credenciales aleatorios
  1814.             $dbName 'doc_' bin2hex(random_bytes(3));
  1815.             $dbUser 'doc_' bin2hex(random_bytes(2));
  1816.             $dbPass bin2hex(random_bytes(8));
  1817.             $dbHost 'localhost';
  1818.             $dbPort '3306';
  1819.             // Credenciales de HestiaCP desde variables de entorno
  1820.             $hestiaApiUrl $_ENV['HESTIA_API_URL'];
  1821.             $hestiaApiUser $_ENV['HESTIA_API_USER'];
  1822.             $hestiaApiPass $_ENV['HESTIA_API_PASS'];
  1823.             $hestiaOwner   $_ENV['HESTIA_OWNER'];
  1824.             $accessKeyId   $_ENV['HESTIA_ACCESS_KEY_ID'] ?? '';
  1825.             $secretKey     $_ENV['HESTIA_SECRET_KEY'] ?? '';
  1826.             // Variables para el script de bash
  1827.             $ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
  1828.             $ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
  1829.             $ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
  1830.             $ocrMode = ($data['ocr_mode'] ?? 'base');
  1831.             if ($ocrMode === 'plus_glm') {
  1832.                 $ocrBinary $ocrBinaryPlus;
  1833.             } elseif ($ocrMode === 'v2_zxing') {
  1834.                 $ocrBinary $ocrBinaryV2;
  1835.             } else {
  1836.                 $ocrBinary $ocrBinaryBase;
  1837.             }
  1838.             $filesPath  $_ENV['FILES_PATH'];
  1839.             if (($data['ocr_mode'] ?? 'base') === 'plus_glm' && trim($ocrBinaryPlus) === '') {
  1840.                 $this->addFlash('danger''OCR+ seleccionado pero falta configurar OCR_PLUS_BINARY en .env.local.');
  1841.                 return $renderAddWithData();
  1842.             }
  1843.             if (($data['ocr_mode'] ?? 'base') === 'v2_zxing' && trim($ocrBinaryV2) === '') {
  1844.                 $this->addFlash('danger''OCR v2 seleccionado pero falta configurar OCR_BINARY_V2 en .env.local.');
  1845.                 return $renderAddWithData();
  1846.             }
  1847.             if (($data['ocr_mode'] ?? 'base') === 'base' && trim($ocrBinaryBase) === '') {
  1848.                 $this->addFlash('danger''OCR Base seleccionado pero falta configurar OCR_BINARY en .env.local.');
  1849.                 return $renderAddWithData();
  1850.             }
  1851.             if (trim((string)$filesPath) === '') {
  1852.                 $this->addFlash('danger''Falta configurar FILES_PATH en .env.local.');
  1853.                 return $renderAddWithData();
  1854.             }
  1855.             $owner $hestiaOwner// o el dueńo del hosting
  1856.             $postFields http_build_query([
  1857.                 'user' => $hestiaApiUser,
  1858.                 'password' => $hestiaApiPass,
  1859.                 'returncode' => 'yes',
  1860.                 'cmd' => 'v-add-database',
  1861.                 'arg1' => $owner,
  1862.                 'arg2' => $dbName,
  1863.                 'arg3' => $dbUser,
  1864.                 'arg4' => $dbPass,
  1865.                 'arg5' => 'mysql'
  1866.             ]);
  1867.             //dd($postFields);            
  1868.             $headers = [
  1869.                 'Authorization: Bearer ' $accessKeyId ':' $secretKey
  1870.             ];
  1871.             $ch curl_init();
  1872.             curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  1873.             curl_setopt($chCURLOPT_HTTPHEADER$headers);
  1874.             curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  1875.             curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  1876.             curl_setopt($chCURLOPT_POSTtrue);
  1877.             curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  1878.             curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  1879.             $response curl_exec($ch);
  1880.             $error curl_error($ch);
  1881.             curl_close($ch);
  1882.             if ($error || trim($response) !== '0') {
  1883.                 $this->addFlash('danger''Error al crear la base de datos en HestiaCP: ' . ($error ?: $response));
  1884.                 return $this->redirectToRoute('app_empresa_new');
  1885.             }
  1886.             //Añadir sql
  1887.             $sqlFile __DIR__ '/../../db/db_base.sql'// Ajusta la ruta si está en otro sitio
  1888.             if (!file_exists($sqlFile)) {
  1889.                 $this->addFlash('danger''Archivo db_base.sql no encontrado.');
  1890.                 return $this->redirectToRoute('app_empresa_new');
  1891.             }
  1892.             $mysqli = new \mysqli($dbHost"{$owner}_{$dbUser}"$dbPass"{$owner}_{$dbName}", (int)$dbPort);
  1893.             if ($mysqli->connect_error) {
  1894.                 $this->addFlash('danger''Error al conectar a la base de datos: ' $mysqli->connect_error);
  1895.                 return $this->redirectToRoute('app_empresa_new');
  1896.             }
  1897.             $sql file_get_contents($sqlFile);
  1898.             // Eliminar lĂ­neas con DELIMITER
  1899.             $sql preg_replace('/DELIMITER\s+\$\$/'''$sql);
  1900.             $sql preg_replace('/DELIMITER\s+;/'''$sql);
  1901.             // Separar por ';;' si los triggers usan ese delimitador (ajusta si es $$)
  1902.             $statements explode('$$'$sql);
  1903.             foreach ($statements as $statementIndex => $stmt) {
  1904.                 $stmt trim($stmt);
  1905.                 if ($stmt) {
  1906.                     try {
  1907.                         if (!$mysqli->multi_query($stmt)) {
  1908.                             $this->addFlash('danger''Error ejecutando SQL: ' $mysqli->error);
  1909.                             return $this->redirectToRoute('app_empresa_new');
  1910.                         }
  1911.                         // Limpiar cualquier resultado intermedio del bloque ejecutado.
  1912.                         do {
  1913.                             if ($result $mysqli->store_result()) {
  1914.                                 $result->free();
  1915.                             }
  1916.                         } while ($mysqli->more_results() && $mysqli->next_result());
  1917.                     } catch (\mysqli_sql_exception $e) {
  1918.                         $sqlSnippet mb_substr(preg_replace('/\s+/'' '$stmt), 0220);
  1919.                         $this->addFlash(
  1920.                             'danger',
  1921.                             sprintf(
  1922.                                 'Error ejecutando SQL en bloque %d: %s. Sentencia: %s',
  1923.                                 $statementIndex 1,
  1924.                                 $e->getMessage(),
  1925.                                 $sqlSnippet
  1926.                             )
  1927.                         );
  1928.                         return $this->redirectToRoute('app_empresa_new');
  1929.                     }
  1930.                 }
  1931.             }
  1932.             $updateSql "UPDATE users SET email = '" $mysqli->real_escape_string((string)$data["user"]) . "' WHERE id = 1";
  1933.             if (!$mysqli->query($updateSql)) {
  1934.                 $this->addFlash('danger''Error al actualizar usuario: ' $mysqli->error);
  1935.                 return $this->redirectToRoute('app_empresa_new');
  1936.             }
  1937.             // Guardar parámetros (activeUsers + modulos_*) en la BD del cliente
  1938.             $localExtractionModelId 0;
  1939.             if ($data['modulo_extraccion'] === 1) {
  1940.                 try {
  1941.                     if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  1942.                         $localExtractionModelId $this->registerAoaiModelInClientDb(
  1943.                             $mysqli,
  1944.                             $azureResource ?? [],
  1945.                             $aoaiFields
  1946.                         );
  1947.                     } else {
  1948.                         $localExtractionModelId $this->registerDiModelInClientDb(
  1949.                             $mysqli,
  1950.                             $azureResource ?? [],
  1951.                             $azureModelId
  1952.                         );
  1953.                     }
  1954.                 } catch (\Throwable $e) {
  1955.                     $this->addFlash('danger''No se pudo registrar el modelo en la BBDD cliente: ' $e->getMessage());
  1956.                     $mysqli->close();
  1957.                     return $this->redirectToRoute('app_empresa_new');
  1958.                 }
  1959.             }
  1960.             $data['extraction_model'] = $localExtractionModelId;
  1961.             if ($data['modulo_extraccion'] === && $localExtractionModelId && $enricherEnabled) {
  1962.                 try {
  1963.                     $enricherResource $this->loadAzureResourceById($em$enricherResourceId);
  1964.                     $this->registerAoaiEnricherInClientDb(
  1965.                         $mysqli,
  1966.                         $localExtractionModelId,
  1967.                         $enricherResource ?? [],
  1968.                         $enricherFields,
  1969.                         true
  1970.                     );
  1971.                 } catch (\Throwable $e) {
  1972.                     $this->addFlash('danger''No se pudo registrar el enricher en la BBDD cliente: ' $e->getMessage());
  1973.                     $mysqli->close();
  1974.                     return $this->redirectToRoute('app_empresa_new');
  1975.                 }
  1976.             }
  1977.             // SUBIR LOGO PERSONALIZADO (SI LO HAY) ===
  1978.             $customLogoFile null;
  1979.             try {
  1980.                 $customLogoFile $this->uploadEmpresaLogo($req);
  1981.             } catch (\Throwable $e) {
  1982.                 // Aquí decides si quieres que esto sea fatal o solo un aviso
  1983.                 $this->addFlash('warning''El logo personalizado no se pudo subir: ' $e->getMessage());
  1984.                 // Si quieres abortar todo el proceso por fallo de logo, haz return+redirect aquí.
  1985.             }
  1986.             // Guardar logo en la BD del cliente
  1987.             $this->saveEmpresaLogo($mysqli$data$customLogoFile);
  1988.             // Actualizar/insertar license (capacityGb y users) en la BD del cliente
  1989.             $this->upsertLicense(
  1990.                 $mysqli,
  1991.                 $data,
  1992.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== '' ? (int)$data['maxDiskQuota'] : 200,
  1993.                 isset($data['maxActiveUsers']) && $data['maxActiveUsers'] !== '' ? (int)$data['maxActiveUsers'] : 3
  1994.             );
  1995.             // Guardar parametros (activeUsers + modulos_*) en la BD del cliente
  1996.             $this->saveEmpresaParametros($mysqli$data);
  1997.             $this->licenseContractService->createInitialContract($mysqli$data);
  1998.             if ((int)($data['modulo_gstock'] ?? 0) === && (int)($data['extraction_model'] ?? 0) > 0) {
  1999.                 try {
  2000.                     $this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
  2001.                 } catch (\Throwable $e) {
  2002.                     $this->addFlash('warning''No se pudo completar el automapeo de Gstock: ' $e->getMessage());
  2003.                 }
  2004.             }
  2005.             // Crear y persistir la empresa
  2006.             $emp = new Empresa();
  2007.             $emp->setName($data["name"]);
  2008.             $emp->setMaxDiskQuota((int)$data["maxDiskQuota"]);
  2009.             $emp->setMaxThreads($maxThreads);
  2010.             $em->persist($emp);
  2011.             $em->flush();
  2012.             $conexionBD = new ConexionBD();
  2013.             $conexionBD->setDbName($owner "_" $dbName);
  2014.             $conexionBD->setDbUser($owner "_" $dbUser);
  2015.             $conexionBD->setDbPassword($dbPass);
  2016.             $conexionBD->setDbUrl($dbHost);
  2017.             $conexionBD->setDbPort($dbPort);
  2018.             $em->persist($conexionBD);
  2019.             $em->flush();
  2020.             $emp->setConexionBD($conexionBD);
  2021.             $em->persist($emp);
  2022.             $em->flush();
  2023.             //crear usuario
  2024.             $user = new \App\Entity\Usuario();
  2025.             $user->setEmail($data["user"]);
  2026.             $user->setEmpresa($emp);
  2027.             $user->setPassword("dscsdcsno2234dwvw");
  2028.             $user->setStatus(1);
  2029.             $user->setIsAdmin(2);
  2030.             $user->setConnection($conexionBD->getId());
  2031.             $em->persist($user);
  2032.             $em->flush();
  2033.             //crear el script de bash
  2034.             $company_name $emp->getId();
  2035.             // "DOCU_MAX_THREADS=" por defecto 4
  2036.             // "NO" al final es para desactivar FTP
  2037.             // Crear archivo .service
  2038.             $empresaName = (string)($data['name'] ?? '');
  2039.             $serviceContent = <<<EOT
  2040. [Unit]
  2041. Description={$empresaName} DocuManager OCR
  2042. Requires=mariadb.service
  2043. After=mariadb.service
  2044. [Service]
  2045. Type=simple
  2046. Environment="DOCU_MAX_THREADS=$maxThreads"
  2047. ExecStart=$ocrBinary localhost/{$owner}_{$dbName} {$owner}_{$dbUser} {$dbPass} {$filesPath}/{$company_name} NO
  2048. Restart=always
  2049. User=docunecta
  2050. [Install]
  2051. WantedBy=multi-user.target
  2052. EOT;
  2053.             // Guardar contenido temporal en un archivo dentro de /tmp
  2054.             $serviceName $company_name "-documanager.service";
  2055.             $tmpServicePath "/tmp/$serviceName";
  2056.             file_put_contents($tmpServicePath$serviceContent);
  2057.             \chmod($tmpServicePath0644);
  2058.             // Mover el archivo y habilitar el servicio desde PHP con shell_exec
  2059.             $commands = [
  2060.                 "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  2061.                 "sudo /bin/systemctl daemon-reload",
  2062.                 "sudo /bin/systemctl enable $serviceName",
  2063.                 "sudo /bin/systemctl start $serviceName",
  2064.             ];
  2065.             $errors = [];
  2066.             foreach ($commands as $cmd) {
  2067.                 $output \shell_exec($cmd " 2>&1");
  2068.                 if ($output !== null) {
  2069.                     // Puedes loguearlo si quieres para ver errores
  2070.                     error_log("CMD OUTPUT: $cmd\n$output");
  2071.                     $errors[] = "CMD OUTPUT: $cmd\n$output";
  2072.                 }
  2073.             }
  2074.             // === Crear servicio AZURE DI por tenant ===
  2075.             $azureBasePort 12000;
  2076.             $tenantId      = (int)$emp->getId();
  2077.             $port          $this->azurePortForTenant($tenantId$azureBasePort20999);
  2078.             $serviceName   $tenantId "-azuredi.service";
  2079.             $workdir       $this->azureDiWorkdirFromEnv();
  2080.             $docuPhpBaseUrl $this->azureDiPhpBaseUrl($req);
  2081.             if ($workdir !== '' && $docuPhpBaseUrl !== '') {
  2082.                 $logsDir $workdir "/logs/" $tenantId;
  2083.                 // Asegura carpeta de logs
  2084.                 @mkdir($logsDir0775true);
  2085.                 $serviceContent = <<<EOT
  2086. [Unit]
  2087. Description=DocuManager Azure DI {$tenantId}
  2088. After=network.target
  2089. [Service]
  2090. User=root
  2091. WorkingDirectory={$workdir}
  2092. Environment=APP_HOST=127.0.0.1
  2093. Environment=APP_PORT={$port}
  2094. Environment=LOG_DIR={$logsDir}
  2095. Environment=PYTHONUNBUFFERED=1
  2096. Environment=MAX_CONCURRENT=20
  2097. Environment="PATH=/opt/azure-di/.venv/bin:/usr/local/bin:/usr/bin"
  2098. EnvironmentFile=-{$workdir}/.env
  2099. # ---- Scheduler interno de extracción ----
  2100. Environment=EXTRACT_TICK_ENABLED=1
  2101. Environment=EXTRACT_TICK_INTERVAL_SEC=10
  2102. Environment=DOCU_TENANT_ID={$tenantId}
  2103. Environment=EXTRACT_TICK_LIMIT=30
  2104. Environment=EXTRACT_TICK_TIMEOUT_SEC=25
  2105. Environment=EXTRACT_TICK_LOCK_FILE=/tmp/azure_di_extract_tick_{$tenantId}.lock
  2106. # Ajustar URL base pública/interna de PHP
  2107. Environment=DOCU_PHP_BASE_URL={$docuPhpBaseUrl}
  2108. # Debe coincidir con EXTRACT_INTERNAL_TOKEN del lado PHP
  2109. Environment=EXTRACT_INTERNAL_TOKEN=8c7e7a1b4d0f6e2a9c1d3f5b7a8e0c2d4f6a1b3c5d7e9f0a2c4e6b8d0f1a3c5
  2110. ExecStart=/opt/azure-di/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port {$port} --proxy-headers --workers 2
  2111. Restart=always
  2112. RestartSec=2
  2113. StandardOutput=journal
  2114. StandardError=journal
  2115. [Install]
  2116. WantedBy=multi-user.target
  2117. EOT;
  2118.                 $tmpServicePath "/tmp/{$serviceName}";
  2119.                 file_put_contents($tmpServicePath$serviceContent);
  2120.                 @chmod($tmpServicePath0644);
  2121.                 $cmds = [
  2122.                     "sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
  2123.                     "sudo /bin/systemctl daemon-reload",
  2124.                     "sudo /bin/systemctl enable {$serviceName}",
  2125.                     "sudo /bin/systemctl start {$serviceName}",
  2126.                 ];
  2127.                 foreach ($cmds as $cmd) {
  2128.                     $out \shell_exec($cmd " 2>&1");
  2129.                     if ($out !== null) {
  2130.                         error_log("AZURE-DI CMD: $cmd\n$out");
  2131.                     }
  2132.                 }
  2133.             } else {
  2134.                 $missing = [];
  2135.                 if ($workdir === ''$missing[] = 'AZURE_DI_WORKDIR';
  2136.                 if ($docuPhpBaseUrl === ''$missing[] = 'AZURE_DI_DOCU_PHP_BASE_URL';
  2137.                 $this->addFlash('warning''No se pudo crear el servicio de extraccion IA: faltan variables de entorno: ' implode(', '$missing));
  2138.             }
  2139.             // === Crear servicio/timer Mail Monitor si el modulo esta activo ===
  2140.             if ($mailMonitorEnabled) {
  2141.                 $this->ensureMailMonitorService(
  2142.                     (int)$company_name,
  2143.                     (string)$dbHost,
  2144.                     (string)$dbPort,
  2145.                     (string)$owner "_" . (string)$dbUser,
  2146.                     (string)$dbPass,
  2147.                     (string)$owner "_" . (string)$dbName,
  2148.                     (string)$filesPath
  2149.                 );
  2150.             }
  2151.             if (count($errors) > 0) {
  2152.                 $this->addFlash('success''Empresa y base de datos creadas correctamente: ' implode(" | "$errors));
  2153.             }
  2154.             $this->addFlash('success''Empresa y base de datos creadas correctamente.');
  2155.             return $this->redirectToRoute('list');
  2156.         } else {
  2157.             // Carga recursos IA para el formulario de alta
  2158.             return $this->render('empresa/_add.html.twig', [
  2159.                 'azure_resources' => $azureResources,
  2160.                 'azure_di_resources' => $azureDiResources,
  2161.                 'azure_openai_resources' => $azureOpenAiResources,
  2162.                 'modulos' => [
  2163.                     'extraction_model' => 0,
  2164.                 ],
  2165.                 'form_data' => [
  2166.                     'extractor_type' => self::EXTRACTOR_TYPE_AZURE_DI,
  2167.                     'ocr_mode' => 'base',
  2168.                     'license_type' => 'page',
  2169.                     'license_mode' => 'monthly',
  2170.                     'license_limit_mode' => 'block',
  2171.                     'license_start_date' => date('Y-m-d'),
  2172.                     'license_units_total' => 1000,
  2173.                 ],
  2174.                 'activeLicenseContract' => $this->licenseContractService->defaultContract(),
  2175.                 'aoai_fields' => [],
  2176.                 'enricher' => [
  2177.                     'enabled' => 0,
  2178.                     'aoai_resource_id' => 0,
  2179.                     'fields' => [],
  2180.                 ],
  2181.                 'ocr_plus_available' => $ocrPlusAvailable,
  2182.                 'ocr_v2_available' => $ocrV2Available,
  2183.             ]);
  2184.         }
  2185.     }
  2186.     private function saveEmpresaParametros(\mysqli $mysqli, array $data): void
  2187.     {
  2188.         // Helpers
  2189.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  2190.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  2191.         // Keys y valores tal y como quieres guardarlos
  2192.         $paramMap = [
  2193.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  2194.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  2195.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  2196.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  2197.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  2198.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  2199.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  2200.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  2201.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  2202.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  2203.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  2204.             'modulo_conciliacion' => $getFlag($data'modulo_conciliacion'),
  2205.             'modulo_precios'     => $getFlag($data'modulo_precios'),
  2206.             'modulo_ubikos'      => $getFlag($data'modulo_ubikos'),
  2207.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  2208.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  2209.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  2210.             'modulo_prinex'      => $getFlag($data'modulo_prinex'),
  2211.             'extraction_model'  => $getInt($data'extraction_model'0),
  2212.             'ocr_mode'          => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
  2213.             'tokensContratados' => max(0$getInt($data'tokensContratados'0)),
  2214.         ];
  2215.         if ($paramMap['modulo_extraccion'] === 0) {
  2216.             $paramMap['modulo_lineas'] = 0;
  2217.             $paramMap['modulo_conciliacion'] = 0;
  2218.             $paramMap['modulo_precios'] = 0;
  2219.             $paramMap['modulo_ubikos'] = 0;
  2220.             $paramMap['modulo_expowin'] = 0;
  2221.             $paramMap['modulo_prinex'] = 0;
  2222.         }
  2223.         if ($paramMap['modulo_lineas'] === 0) {
  2224.             $paramMap['modulo_precios'] = 0;
  2225.         }
  2226.         // RECOMENDADO en tu SQL base:
  2227.         // ALTER TABLE parametros ADD UNIQUE KEY uniq_nombre (nombre);
  2228.         $mysqli->begin_transaction();
  2229.         try {
  2230.             $stmt $mysqli->prepare("
  2231.                 INSERT INTO parametros (nombre, valor)
  2232.                 VALUES (?, ?)
  2233.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  2234.             ");
  2235.             if (!$stmt) {
  2236.                 throw new \RuntimeException('Prepare parametros: ' $mysqli->error);
  2237.             }
  2238.             foreach ($paramMap as $nombre => $valor) {
  2239.                 // valor es TEXT en tu esquema: bindeamos como string
  2240.                 $v = (string)$valor;
  2241.                 $stmt->bind_param('ss'$nombre$v);
  2242.                 if (!$stmt->execute()) {
  2243.                     throw new \RuntimeException("Guardar parámetro $nombre: " $stmt->error);
  2244.                 }
  2245.             }
  2246.             $stmt->close();
  2247.             $mysqli->commit();
  2248.         } catch (\Throwable $e) {
  2249.             $mysqli->rollback();
  2250.             // Si NO puedes ańadir UNIQUE(nombre), usa fallback DELETE+INSERT:
  2251.             // $this->saveParametrosFallback($mysqli, $paramMap);
  2252.             throw $e;
  2253.         }
  2254.     }
  2255.     private function applyGstockAutoMappings(\mysqli $mysqliint $modelId): void
  2256.     {
  2257.         if ($modelId <= 0) {
  2258.             return;
  2259.         }
  2260.         $available = [
  2261.             'header' => [],
  2262.             'line' => [],
  2263.         ];
  2264.         $stmtHeader $mysqli->prepare('SELECT field_key FROM definitions_header WHERE model_id = ?');
  2265.         if (!$stmtHeader) {
  2266.             throw new \RuntimeException('Prepare SELECT definitions_header para automapeo Gstock: ' $mysqli->error);
  2267.         }
  2268.         $stmtHeader->bind_param('i'$modelId);
  2269.         if (!$stmtHeader->execute()) {
  2270.             $stmtHeader->close();
  2271.             throw new \RuntimeException('Execute SELECT definitions_header para automapeo Gstock: ' $stmtHeader->error);
  2272.         }
  2273.         $resHeader $stmtHeader->get_result();
  2274.         while ($resHeader && ($row $resHeader->fetch_assoc())) {
  2275.             $fieldKey = (string)($row['field_key'] ?? '');
  2276.             if ($fieldKey !== '') {
  2277.                 $available['header'][$fieldKey] = true;
  2278.             }
  2279.         }
  2280.         $stmtHeader->close();
  2281.         $stmtLines $mysqli->prepare('SELECT field_key FROM definitions_lines WHERE model_id = ?');
  2282.         if (!$stmtLines) {
  2283.             throw new \RuntimeException('Prepare SELECT definitions_lines para automapeo Gstock: ' $mysqli->error);
  2284.         }
  2285.         $stmtLines->bind_param('i'$modelId);
  2286.         if (!$stmtLines->execute()) {
  2287.             $stmtLines->close();
  2288.             throw new \RuntimeException('Execute SELECT definitions_lines para automapeo Gstock: ' $stmtLines->error);
  2289.         }
  2290.         $resLines $stmtLines->get_result();
  2291.         while ($resLines && ($row $resLines->fetch_assoc())) {
  2292.             $fieldKey = (string)($row['field_key'] ?? '');
  2293.             if ($fieldKey !== '') {
  2294.                 $available['line'][$fieldKey] = true;
  2295.             }
  2296.         }
  2297.         $stmtLines->close();
  2298.         $existing = [];
  2299.         $stmtExisting $mysqli->prepare('SELECT type, source, destination FROM gstock_mapping WHERE model_id = ?');
  2300.         if (!$stmtExisting) {
  2301.             throw new \RuntimeException('Prepare SELECT gstock_mapping para automapeo: ' $mysqli->error);
  2302.         }
  2303.         $stmtExisting->bind_param('i'$modelId);
  2304.         if (!$stmtExisting->execute()) {
  2305.             $stmtExisting->close();
  2306.             throw new \RuntimeException('Execute SELECT gstock_mapping para automapeo: ' $stmtExisting->error);
  2307.         }
  2308.         $resExisting $stmtExisting->get_result();
  2309.         while ($resExisting && ($row $resExisting->fetch_assoc())) {
  2310.             $type = (string)($row['type'] ?? '');
  2311.             $source = (string)($row['source'] ?? '');
  2312.             $destination = (string)($row['destination'] ?? '');
  2313.             if ($type !== '' && $source !== '' && $destination !== '') {
  2314.                 $existing[$type '|' $source '|' $destination] = true;
  2315.             }
  2316.         }
  2317.         $stmtExisting->close();
  2318.         $mysqli->begin_transaction();
  2319.         try {
  2320.             $stmtInsert $mysqli->prepare(
  2321.                 'INSERT INTO gstock_mapping (model_id, type, source, destination) VALUES (?, ?, ?, ?)'
  2322.             );
  2323.             if (!$stmtInsert) {
  2324.                 throw new \RuntimeException('Prepare INSERT gstock_mapping para automapeo: ' $mysqli->error);
  2325.             }
  2326.             foreach (GstockAutoMappings::MAPPINGS as $type => $pairs) {
  2327.                 foreach ($pairs as $pair) {
  2328.                     $source = (string)($pair['source'] ?? '');
  2329.                     $destination = (string)($pair['destination'] ?? '');
  2330.                     if ($source === '' || $destination === '') {
  2331.                         continue;
  2332.                     }
  2333.                     if ($type === 'header' && !isset($available['header'][$source])) {
  2334.                         continue;
  2335.                     }
  2336.                     if ($type === 'line' && !isset($available['line'][$source])) {
  2337.                         continue;
  2338.                     }
  2339.                     $key $type '|' $source '|' $destination;
  2340.                     if (isset($existing[$key])) {
  2341.                         continue;
  2342.                     }
  2343.                     $stmtInsert->bind_param('isss'$modelId$type$source$destination);
  2344.                     if (!$stmtInsert->execute()) {
  2345.                         $stmtInsert->close();
  2346.                         throw new \RuntimeException('Execute INSERT gstock_mapping para automapeo: ' $stmtInsert->error);
  2347.                     }
  2348.                     $existing[$key] = true;
  2349.                 }
  2350.             }
  2351.             $stmtInsert->close();
  2352.             $mysqli->commit();
  2353.         } catch (\Throwable $e) {
  2354.             $mysqli->rollback();
  2355.             throw $e;
  2356.         }
  2357.     }
  2358.     private function saveEmpresaLogo(\mysqli $mysqli, array $data, ?string $customLogoFile null): void
  2359.     {
  2360.         $this->logLogo('empresa_logo_save.log''--- NUEVA LLAMADA saveEmpresaLogo ---');
  2361.         $this->logLogo('empresa_logo_save.log''customLogoFile = ' var_export($customLogoFiletrue));
  2362.         $this->logLogo('empresa_logo_save.log''data[empresa_vendor] = ' var_export($data['empresa_vendor'] ?? nulltrue));
  2363.         // 1) Decidir qué logo vamos a guardar
  2364.         $logoFile null;
  2365.         // --- PRIORIDAD: LOGO PERSONALIZADO ---
  2366.         if ($customLogoFile !== null && $customLogoFile !== '') {
  2367.             $logoFile $customLogoFile;
  2368.             $this->logLogo('empresa_logo_save.log''Usando logo personalizado: ' $logoFile);
  2369.         } else {
  2370.             // --- SI NO HAY PERSONALIZADO, USAMOS EL SELECT DE EMPRESA ---
  2371.             if (!isset($data['empresa_vendor']) || $data['empresa_vendor'] === '') {
  2372.                 $this->logLogo('empresa_logo_save.log''No hay empresa_vendor y no hay logo custom. No hago nada.');
  2373.                 return;
  2374.             }
  2375.             $empresa $data['empresa_vendor'];
  2376.             $empresaKey strtolower(trim((string)$empresa));
  2377.             $logoMap = [
  2378.                 'docunecta'  => 'DocuManager_transparente.png',
  2379.                 'docuindexa' => 'DocuIndexa.png',
  2380.             ];
  2381.             $vendorLabelMap = [
  2382.                 'docunecta'  => 'Docunecta',
  2383.                 'docuindexa' => 'Docuindexa',
  2384.             ];
  2385.             if (!isset($logoMap[$empresaKey])) {
  2386.                 $this->logLogo('empresa_logo_save.log'"empresa_vendor $empresa no está en logoMap. No hago nada.");
  2387.                 return;
  2388.             }
  2389.             $logoFile $logoMap[$empresaKey];
  2390.             $vendorLabel $vendorLabelMap[$empresaKey] ?? null;
  2391.             $this->logLogo('empresa_logo_save.log''Usando logo por vendor: ' $logoFile);
  2392.         }
  2393.         if ($logoFile === null || $logoFile === '') {
  2394.             $this->logLogo('empresa_logo_save.log''logoFile está vacío. No hago nada.');
  2395.             return;
  2396.         }
  2397.         $this->logLogo('empresa_logo_save.log''Voy a guardar en parametros.logo: ' $logoFile);
  2398.         $mysqli->begin_transaction();
  2399.         try {
  2400.             $stmt $mysqli->prepare("
  2401.                 INSERT INTO parametros (nombre, valor)
  2402.                 VALUES (?, ?)
  2403.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  2404.             ");
  2405.             if (!$stmt) {
  2406.                 $this->logLogo('empresa_logo_save.log''Error prepare: ' $mysqli->error);
  2407.                 throw new \RuntimeException('Prepare logo: ' $mysqli->error);
  2408.             }
  2409.             $paramName 'logo';
  2410.             $stmt->bind_param('ss'$paramName$logoFile);
  2411.             if (!$stmt->execute()) {
  2412.                 $this->logLogo('empresa_logo_save.log''Error execute: ' $stmt->error);
  2413.                 throw new \RuntimeException('Guardar parámetro logo: ' $stmt->error);
  2414.             }
  2415.             if (isset($vendorLabel) && $vendorLabel !== '') {
  2416.                 $paramName 'vendor';
  2417.                 $stmt->bind_param('ss'$paramName$vendorLabel);
  2418.                 if (!$stmt->execute()) {
  2419.                     $this->logLogo('empresa_logo_save.log''Error execute vendor: ' $stmt->error);
  2420.                     throw new \RuntimeException('Guardar parámetro vendor: ' $stmt->error);
  2421.                 }
  2422.             }
  2423.             $stmt->close();
  2424.             $mysqli->commit();
  2425.             $this->logLogo('empresa_logo_save.log''Logo guardado correctamente en BD.');
  2426.         } catch (\Throwable $e) {
  2427.             $mysqli->rollback();
  2428.             $this->logLogo('empresa_logo_save.log''EXCEPCIÓN: ' $e->getMessage());
  2429.             throw $e;
  2430.         }
  2431.     }
  2432.     private function uploadEmpresaLogo(Request $req): ?string
  2433.     {
  2434.         $this->logLogo('empresa_logo_upload.log''--- NUEVA LLAMADA uploadEmpresaLogo ---');
  2435.         // name del checkbox en el formulario (ajústalo si usas otro)
  2436.         $useCustomLogo $req->request->get('customLogoCheck');
  2437.         $this->logLogo('empresa_logo_upload.log''customLogoCheck = ' var_export($useCustomLogotrue));
  2438.         // Si no marcaron "usar logo personalizado", no hacemos nada
  2439.         if (!$useCustomLogo) {
  2440.             $this->logLogo('empresa_logo_upload.log''No se ha marcado customLogoCheck. Salgo sin subir.');
  2441.             return null;
  2442.         }
  2443.         /** @var UploadedFile|null $file */
  2444.         $file $req->files->get('logo_personalizado'); // name="logo_personalizado" en el input file
  2445.         $this->logLogo('empresa_logo_upload.log''FILES[logo_personalizado] = ' print_r($filetrue));
  2446.         if (!$file instanceof UploadedFile || !$file->isValid()) {
  2447.             $this->logLogo('empresa_logo_upload.log''File no es UploadedFile válido. Salgo sin subir.');
  2448.             return null;
  2449.         }
  2450.         // VALIDACIONES BÁSICAS
  2451.         $maxSize 1024 1024// 2 MB por ejemplo
  2452.         if ($file->getSize() > $maxSize) {
  2453.             $this->logLogo('empresa_logo_upload.log''Tamańo demasiado grande: ' $file->getSize());
  2454.             throw new \RuntimeException('El logo personalizado es demasiado grande (máx 2MB).');
  2455.         }
  2456.         $mime $file->getMimeType();
  2457.         $allowedMimeTypes = ['image/png''image/jpeg''image/webp''image/svg+xml'];
  2458.         $this->logLogo('empresa_logo_upload.log''MIME = ' $mime);
  2459.         if (!in_array($mime$allowedMimeTypestrue)) {
  2460.             $this->logLogo('empresa_logo_upload.log''MIME no permitido.');
  2461.             throw new \RuntimeException('Formato de logo no permitido. Usa PNG, JPG, WEBP o SVG.');
  2462.         }
  2463.         // Directorio destino según entorno (configurado en .env/.env.local)
  2464.         $targetDir $_ENV['APP_LOGO_DIR'] ?? null;
  2465.         $this->logLogo('empresa_logo_upload.log''APP_LOGO_DIR = ' var_export($targetDirtrue));
  2466.         if (!$targetDir) {
  2467.             throw new \RuntimeException('APP_LOGO_DIR no está configurado en el entorno.');
  2468.         }
  2469.         if (!is_dir($targetDir)) {
  2470.             $this->logLogo('empresa_logo_upload.log'"El directorio no existe: $targetDir");
  2471.             throw new \RuntimeException("El directorio de logos no existe: $targetDir");
  2472.         }
  2473.         if (!is_writable($targetDir)) {
  2474.             $this->logLogo('empresa_logo_upload.log'"El directorio no es escribible: $targetDir");
  2475.             throw new \RuntimeException("El directorio de logos no es escribible: $targetDir");
  2476.         }
  2477.         // Nombre de archivo "seguro" y único
  2478.         $ext $file->guessExtension() ?: 'png';
  2479.         $fileName 'logo_empresa_' bin2hex(random_bytes(6)) . '.' $ext;
  2480.         $this->logLogo('empresa_logo_upload.log'"Voy a mover archivo como: $fileName");
  2481.         // Mover físicamente el archivo
  2482.         $file->move($targetDir$fileName);
  2483.         $this->logLogo('empresa_logo_upload.log'"Fichero movido OK a $targetDir/$fileName");
  2484.         // Devolvemos SOLO el nombre, que es lo que se guardará en parametros.logo
  2485.         return $fileName;
  2486.     }
  2487.     private function logLogo(string $fileNamestring $message): void
  2488.     {
  2489.         // Directorio de logs de Symfony (donde está dev.log/prod.log)
  2490.         $logDir $this->getParameter('kernel.logs_dir');
  2491.         $fullPath rtrim($logDir'/') . '/' $fileName;
  2492.         $line sprintf(
  2493.             "[%s] %s\n",
  2494.             date('Y-m-d H:i:s'),
  2495.             $message
  2496.         );
  2497.         file_put_contents($fullPath$lineFILE_APPEND);
  2498.     }
  2499.     private function loadEmpresaLogo(\mysqli $mysqli): ?string
  2500.     {
  2501.         $sql "SELECT valor FROM parametros WHERE nombre = 'logo' LIMIT 1";
  2502.         $res $mysqli->query($sql);
  2503.         if (!$res) {
  2504.             return null;
  2505.         }
  2506.         if ($row $res->fetch_assoc()) {
  2507.             return $row['valor'] ?? null;
  2508.         }
  2509.         return null;
  2510.     }
  2511.     private function upsertLicense(\mysqli $mysqli, array $dataint $capacityGb 200int $activeUsers 3): void
  2512.     {
  2513.         $clientName = (string)($data['name'] ?? '');
  2514.         $licenseStr  'Documanager';
  2515.         $initialDate date('Y-m-d');
  2516.         $price       0;
  2517.         $emailSender 'Documanager.es';
  2518.         $emailFrom   'no-reply@docunecta.com';
  2519.         $emailName   'Documanager';
  2520.         $ins $mysqli->prepare("
  2521.             INSERT INTO license
  2522.                 (client, license, initialDate, capacityGb, users, price, emailSender, emailFrom, emailName)
  2523.             VALUES
  2524.                 (?,      ?,       ?,          ?,          ?,     ?,     ?,           ?,         ?)
  2525.         ");
  2526.         if (!$ins) {
  2527.             throw new \RuntimeException('Prepare INSERT license: ' $mysqli->error);
  2528.         }
  2529.         $ins->bind_param(
  2530.             'sssiiisss',
  2531.             $clientName,
  2532.             $licenseStr,
  2533.             $initialDate,
  2534.             $capacityGb,
  2535.             $activeUsers,
  2536.             $price,
  2537.             $emailSender,
  2538.             $emailFrom,
  2539.             $emailName
  2540.         );
  2541.         if (!$ins->execute()) {
  2542.             $ins->close();
  2543.             throw new \RuntimeException('Execute INSERT license: ' $ins->error);
  2544.         }
  2545.         $ins->close();
  2546.     }
  2547.     private function mapAzureSchemaToDefinitions(array $modelDetail): array
  2548.     {
  2549.         $docTypes $modelDetail['docTypes'] ?? [];
  2550.         if (!is_array($docTypes) || $docTypes === []) {
  2551.             return ['header' => [], 'lines' => []];
  2552.         }
  2553.         $modelId = (string)($modelDetail['modelId'] ?? '');
  2554.         $docTypeKey = ($modelId !== '' && array_key_exists($modelId$docTypes))
  2555.             ? $modelId
  2556.             array_key_first($docTypes);
  2557.         if (!is_string($docTypeKey) || !isset($docTypes[$docTypeKey]) || !is_array($docTypes[$docTypeKey])) {
  2558.             return ['header' => [], 'lines' => []];
  2559.         }
  2560.         $fieldSchema $docTypes[$docTypeKey]['fieldSchema'] ?? [];
  2561.         if (!is_array($fieldSchema)) {
  2562.             return ['header' => [], 'lines' => []];
  2563.         }
  2564.         $rawHeader = [];
  2565.         $rawLines = [];
  2566.         foreach ($fieldSchema as $fieldKey => $fieldDef) {
  2567.             if (!is_string($fieldKey) || !is_array($fieldDef)) {
  2568.                 continue;
  2569.             }
  2570.             $this->flattenFieldSchema($fieldKey$fieldDef$rawHeader$rawLines);
  2571.         }
  2572.         $header = [];
  2573.         $lines = [];
  2574.         $seenHeader = [];
  2575.         $seenLines = [];
  2576.         foreach ($rawHeader as $item) {
  2577.             $key = (string)($item['field_key'] ?? '');
  2578.             if ($key === '' || isset($seenHeader[$key])) {
  2579.                 continue;
  2580.             }
  2581.             $seenHeader[$key] = true;
  2582.             $header[] = [
  2583.                 'field_key' => $key,
  2584.                 'label' => $key,
  2585.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  2586.             ];
  2587.         }
  2588.         foreach ($rawLines as $item) {
  2589.             $key = (string)($item['field_key'] ?? '');
  2590.             if ($key === '' || isset($seenLines[$key])) {
  2591.                 continue;
  2592.             }
  2593.             $seenLines[$key] = true;
  2594.             $lines[] = [
  2595.                 'field_key' => $key,
  2596.                 'label' => $key,
  2597.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  2598.             ];
  2599.         }
  2600.         foreach ($header as $index => &$item) {
  2601.             $order $index 1;
  2602.             $item['order_index'] = $order;
  2603.             $item['order_index_table'] = $order;
  2604.             $item['visibility'] = 1;
  2605.             $item['visibility_table'] = 1;
  2606.         }
  2607.         foreach ($lines as $index => &$item) {
  2608.             $item['order_index'] = $index 1;
  2609.             $item['visibility'] = 1;
  2610.         }
  2611.         return ['header' => $header'lines' => $lines];
  2612.     }
  2613.     private function flattenFieldSchema(
  2614.         string $fieldKey,
  2615.         array $fieldDef,
  2616.         array &$header,
  2617.         array &$lines,
  2618.         bool $insideItems false,
  2619.         string $parentPath ''
  2620.     ): void {
  2621.         $type strtolower((string)($fieldDef['type'] ?? 'string'));
  2622.         $currentPath $parentPath !== '' $parentPath '.' $fieldKey $fieldKey;
  2623.         if ($insideItems) {
  2624.             if ($type === 'object') {
  2625.                 $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  2626.                 if (is_array($properties)) {
  2627.                     foreach ($properties as $childKey => $childDef) {
  2628.                         if (is_string($childKey) && is_array($childDef)) {
  2629.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue$currentPath);
  2630.                         }
  2631.                     }
  2632.                 }
  2633.                 return;
  2634.             }
  2635.             if ($type === 'array') {
  2636.                 $itemsDef $fieldDef['items'] ?? [];
  2637.                 $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  2638.                 if ($itemType === 'object') {
  2639.                     $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  2640.                     if (is_array($properties)) {
  2641.                         $arrayPath $currentPath '[*]';
  2642.                         foreach ($properties as $childKey => $childDef) {
  2643.                             if (is_string($childKey) && is_array($childDef)) {
  2644.                                 $this->flattenFieldSchema($childKey$childDef$header$linestrue$arrayPath);
  2645.                             }
  2646.                         }
  2647.                     }
  2648.                 } else {
  2649.                     $lines[] = ['field_key' => $currentPath'azure_type' => $itemType];
  2650.                 }
  2651.                 return;
  2652.             }
  2653.             $lines[] = ['field_key' => $currentPath'azure_type' => $type];
  2654.             return;
  2655.         }
  2656.         if ($type === 'object') {
  2657.             $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  2658.             if (is_array($properties)) {
  2659.                 foreach ($properties as $childKey => $childDef) {
  2660.                     if (is_string($childKey) && is_array($childDef)) {
  2661.                         $this->flattenFieldSchema($childKey$childDef$header$linesfalse$currentPath);
  2662.                     }
  2663.                 }
  2664.             }
  2665.             return;
  2666.         }
  2667.         if ($type === 'array') {
  2668.             $itemsDef $fieldDef['items'] ?? [];
  2669.             $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  2670.             $isItems strtolower($fieldKey) === 'items';
  2671.             if ($itemType === 'object') {
  2672.                 $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  2673.                 if (!is_array($properties)) {
  2674.                     return;
  2675.                 }
  2676.                 if ($isItems) {
  2677.                     foreach ($properties as $childKey => $childDef) {
  2678.                         if (is_string($childKey) && is_array($childDef)) {
  2679.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue'');
  2680.                         }
  2681.                     }
  2682.                 } else {
  2683.                     $arrayPath $currentPath '[*]';
  2684.                     foreach ($properties as $childKey => $childDef) {
  2685.                         if (is_string($childKey) && is_array($childDef)) {
  2686.                             $this->flattenFieldSchema($childKey$childDef$header$linesfalse$arrayPath);
  2687.                         }
  2688.                     }
  2689.                 }
  2690.                 return;
  2691.             }
  2692.             $header[] = ['field_key' => $currentPath'azure_type' => $itemType];
  2693.             return;
  2694.         }
  2695.         $header[] = ['field_key' => $currentPath'azure_type' => $type];
  2696.     }
  2697.     private function mapAzureTypeToValueType(string $azureType): string
  2698.     {
  2699.         $azureType strtolower(trim($azureType));
  2700.         if ($azureType === 'date') {
  2701.             return 'date';
  2702.         }
  2703.         if ($azureType === 'number' || $azureType === 'integer') {
  2704.             return 'number';
  2705.         }
  2706.         return 'string';
  2707.     }
  2708.     private function registerDiModelInClientDb(\mysqli $clientMysqli, array $resourcestring $modelId): int
  2709.     {
  2710.         $modelId trim($modelId);
  2711.         if ($modelId === '') {
  2712.             throw new \RuntimeException('ModelId de recurso IA obligatorio.'400);
  2713.         }
  2714.         $modelDetail $this->azureRequest(
  2715.             (string)($resource['endpoint'] ?? ''),
  2716.             (string)($resource['api_key'] ?? ''),
  2717.             '/documentintelligence/documentModels/' rawurlencode($modelId)
  2718.         );
  2719.         $type $this->classifyModelType($modelDetail);
  2720.         $definitions $this->mapAzureSchemaToDefinitions($modelDetail);
  2721.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  2722.         $apiKey = (string)($resource['api_key'] ?? '');
  2723.         $showConfidenceBadges 0;
  2724.         $clientMysqli->begin_transaction();
  2725.         try {
  2726.             $existingId 0;
  2727.             $stmtSelect $clientMysqli->prepare(
  2728.                 "SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? AND mode = 'full_extract' LIMIT 1"
  2729.             );
  2730.             if (!$stmtSelect) {
  2731.                 throw new \RuntimeException('Prepare SELECT extraction_models: ' $clientMysqli->error);
  2732.             }
  2733.             $provider self::EXTRACTOR_TYPE_AZURE_DI;
  2734.             $stmtSelect->bind_param('ss'$provider$modelId);
  2735.             if (!$stmtSelect->execute()) {
  2736.                 $stmtSelect->close();
  2737.                 throw new \RuntimeException('Execute SELECT extraction_models: ' $stmtSelect->error);
  2738.             }
  2739.             $result $stmtSelect->get_result();
  2740.             if ($result && ($row $result->fetch_assoc())) {
  2741.                 $existingId = (int)$row['id'];
  2742.             }
  2743.             $stmtSelect->close();
  2744.             if ($existingId 0) {
  2745.                 $stmtUpdate $clientMysqli->prepare(
  2746.                     'UPDATE extraction_models
  2747.                      SET endpoint = ?, api_key = ?, type = ?, show_confidence_badges = ?
  2748.                      WHERE id = ?'
  2749.                 );
  2750.                 if (!$stmtUpdate) {
  2751.                     throw new \RuntimeException('Prepare UPDATE extraction_models: ' $clientMysqli->error);
  2752.                 }
  2753.                 $stmtUpdate->bind_param('sssii'$endpoint$apiKey$type$showConfidenceBadges$existingId);
  2754.                 if (!$stmtUpdate->execute()) {
  2755.                     $stmtUpdate->close();
  2756.                     throw new \RuntimeException('Execute UPDATE extraction_models: ' $stmtUpdate->error);
  2757.                 }
  2758.                 $stmtUpdate->close();
  2759.                 $localModelId $existingId;
  2760.             } else {
  2761.                 $provider self::EXTRACTOR_TYPE_AZURE_DI;
  2762.                 $stmtInsert $clientMysqli->prepare(
  2763.                     'INSERT INTO extraction_models (provider, mode, model_id, endpoint, api_key, type, show_confidence_badges)
  2764.                      VALUES (?, ?, ?, ?, ?, ?, ?)'
  2765.                 );
  2766.                 if (!$stmtInsert) {
  2767.                     throw new \RuntimeException('Prepare INSERT extraction_models: ' $clientMysqli->error);
  2768.                 }
  2769.                 $mode 'full_extract';
  2770.                 $stmtInsert->bind_param('ssssssi'$provider$mode$modelId$endpoint$apiKey$type$showConfidenceBadges);
  2771.                 if (!$stmtInsert->execute()) {
  2772.                     $stmtInsert->close();
  2773.                     throw new \RuntimeException('Execute INSERT extraction_models: ' $stmtInsert->error);
  2774.                 }
  2775.                 $localModelId = (int)$stmtInsert->insert_id;
  2776.                 $stmtInsert->close();
  2777.             }
  2778.             $stmtDelHeader $clientMysqli->prepare('DELETE FROM definitions_header WHERE model_id = ?');
  2779.             if (!$stmtDelHeader) {
  2780.                 throw new \RuntimeException('Prepare DELETE definitions_header: ' $clientMysqli->error);
  2781.             }
  2782.             $stmtDelHeader->bind_param('i'$localModelId);
  2783.             if (!$stmtDelHeader->execute()) {
  2784.                 $stmtDelHeader->close();
  2785.                 throw new \RuntimeException('Execute DELETE definitions_header: ' $stmtDelHeader->error);
  2786.             }
  2787.             $stmtDelHeader->close();
  2788.             $stmtDelLines $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
  2789.             if (!$stmtDelLines) {
  2790.                 throw new \RuntimeException('Prepare DELETE definitions_lines: ' $clientMysqli->error);
  2791.             }
  2792.             $stmtDelLines->bind_param('i'$localModelId);
  2793.             if (!$stmtDelLines->execute()) {
  2794.                 $stmtDelLines->close();
  2795.                 throw new \RuntimeException('Execute DELETE definitions_lines: ' $stmtDelLines->error);
  2796.             }
  2797.             $stmtDelLines->close();
  2798.             if (!empty($definitions['header'])) {
  2799.                 $stmtHeader $clientMysqli->prepare(
  2800.                     'INSERT INTO definitions_header
  2801.                     (model_id, field_key, label, value_type, order_index, visibility, order_index_table, visibility_table)
  2802.                     VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
  2803.                 );
  2804.                 if (!$stmtHeader) {
  2805.                     throw new \RuntimeException('Prepare INSERT definitions_header: ' $clientMysqli->error);
  2806.                 }
  2807.                 foreach ($definitions['header'] as $item) {
  2808.                     $fieldKey = (string)$item['field_key'];
  2809.                     $label = (string)$item['label'];
  2810.                     $valueType = (string)$item['value_type'];
  2811.                     $orderIndex = (int)$item['order_index'];
  2812.                     $visibility = (int)$item['visibility'];
  2813.                     $orderIndexTable = (int)$item['order_index_table'];
  2814.                     $visibilityTable = (int)$item['visibility_table'];
  2815.                     $stmtHeader->bind_param(
  2816.                         'isssiiii',
  2817.                         $localModelId,
  2818.                         $fieldKey,
  2819.                         $label,
  2820.                         $valueType,
  2821.                         $orderIndex,
  2822.                         $visibility,
  2823.                         $orderIndexTable,
  2824.                         $visibilityTable
  2825.                     );
  2826.                     if (!$stmtHeader->execute()) {
  2827.                         $stmtHeader->close();
  2828.                         throw new \RuntimeException('Execute INSERT definitions_header: ' $stmtHeader->error);
  2829.                     }
  2830.                 }
  2831.                 $stmtHeader->close();
  2832.             }
  2833.             if (!empty($definitions['lines'])) {
  2834.                 $stmtLines $clientMysqli->prepare(
  2835.                     'INSERT INTO definitions_lines
  2836.                     (model_id, field_key, label, value_type, order_index, visibility)
  2837.                     VALUES (?, ?, ?, ?, ?, ?)'
  2838.                 );
  2839.                 if (!$stmtLines) {
  2840.                     throw new \RuntimeException('Prepare INSERT definitions_lines: ' $clientMysqli->error);
  2841.                 }
  2842.                 foreach ($definitions['lines'] as $item) {
  2843.                     $fieldKey = (string)$item['field_key'];
  2844.                     $label = (string)$item['label'];
  2845.                     $valueType = (string)$item['value_type'];
  2846.                     $orderIndex = (int)$item['order_index'];
  2847.                     $visibility = (int)$item['visibility'];
  2848.                     $stmtLines->bind_param(
  2849.                         'isssii',
  2850.                         $localModelId,
  2851.                         $fieldKey,
  2852.                         $label,
  2853.                         $valueType,
  2854.                         $orderIndex,
  2855.                         $visibility
  2856.                     );
  2857.                     if (!$stmtLines->execute()) {
  2858.                         $stmtLines->close();
  2859.                         throw new \RuntimeException('Execute INSERT definitions_lines: ' $stmtLines->error);
  2860.                     }
  2861.                 }
  2862.                 $stmtLines->close();
  2863.             }
  2864.             $clientMysqli->commit();
  2865.             return $localModelId;
  2866.         } catch (\Throwable $e) {
  2867.             $clientMysqli->rollback();
  2868.             throw $e;
  2869.         }
  2870.     }
  2871.     private function registerModelInClientDb(\mysqli $clientMysqli, array $resourcestring $modelId): int
  2872.     {
  2873.         return $this->registerDiModelInClientDb($clientMysqli$resource$modelId);
  2874.     }
  2875.     private function normalizeAoaiFieldScope(string $scope): string
  2876.     {
  2877.         $scope strtolower(trim($scope));
  2878.         return $scope === 'lines' 'lines' 'header';
  2879.     }
  2880.     private function normalizeAoaiValueType(string $valueType): string
  2881.     {
  2882.         $valueType strtolower(trim($valueType));
  2883.         if ($valueType === 'number') {
  2884.             return 'number';
  2885.         }
  2886.         if ($valueType === 'date') {
  2887.             return 'date';
  2888.         }
  2889.         return 'string';
  2890.     }
  2891.     private function collectAoaiFieldsFromRequest(Request $request): array
  2892.     {
  2893.         $scopes $request->request->all('aoai_fields_scope');
  2894.         $keys $request->request->all('aoai_fields_key');
  2895.         $prompts $request->request->all('aoai_fields_prompt');
  2896.         $valueTypes $request->request->all('aoai_fields_type');
  2897.         if (!is_array($scopes)) {
  2898.             $scopes = [];
  2899.         }
  2900.         if (!is_array($keys)) {
  2901.             $keys = [];
  2902.         }
  2903.         if (!is_array($prompts)) {
  2904.             $prompts = [];
  2905.         }
  2906.         if (!is_array($valueTypes)) {
  2907.             $valueTypes = [];
  2908.         }
  2909.         $max max(count($scopes), count($keys), count($prompts), count($valueTypes));
  2910.         $fields = [];
  2911.         for ($i 0$i $max$i++) {
  2912.             $scope $this->normalizeAoaiFieldScope((string)($scopes[$i] ?? 'header'));
  2913.             $fieldKey trim((string)($keys[$i] ?? ''));
  2914.             $prompt trim((string)($prompts[$i] ?? ''));
  2915.             $valueType $this->normalizeAoaiValueType((string)($valueTypes[$i] ?? 'string'));
  2916.             if ($fieldKey === '' && $prompt === '') {
  2917.                 continue;
  2918.             }
  2919.             $fields[] = [
  2920.                 'scope' => $scope,
  2921.                 'field_key' => $fieldKey,
  2922.                 'prompt' => $prompt,
  2923.                 'value_type' => $valueType,
  2924.             ];
  2925.         }
  2926.         return $fields;
  2927.     }
  2928.     private function collectEnricherFieldsFromRequest(Request $request): array
  2929.     {
  2930.         $scopes $request->request->all('enricher_fields_scope');
  2931.         $keys $request->request->all('enricher_fields_key');
  2932.         $prompts $request->request->all('enricher_fields_prompt');
  2933.         $valueTypes $request->request->all('enricher_fields_type');
  2934.         if (!is_array($scopes)) {
  2935.             $scopes = [];
  2936.         }
  2937.         if (!is_array($keys)) {
  2938.             $keys = [];
  2939.         }
  2940.         if (!is_array($prompts)) {
  2941.             $prompts = [];
  2942.         }
  2943.         if (!is_array($valueTypes)) {
  2944.             $valueTypes = [];
  2945.         }
  2946.         $max max(count($scopes), count($keys), count($prompts), count($valueTypes));
  2947.         $fields = [];
  2948.         for ($i 0$i $max$i++) {
  2949.             $scope $this->normalizeAoaiFieldScope((string)($scopes[$i] ?? 'header'));
  2950.             $fieldKey trim((string)($keys[$i] ?? ''));
  2951.             $prompt trim((string)($prompts[$i] ?? ''));
  2952.             $valueType $this->normalizeAoaiValueType((string)($valueTypes[$i] ?? 'string'));
  2953.             if ($fieldKey === '' && $prompt === '') {
  2954.                 continue;
  2955.             }
  2956.             $fields[] = [
  2957.                 'scope' => $scope,
  2958.                 'field_key' => $fieldKey,
  2959.                 'prompt' => $prompt,
  2960.                 'value_type' => $valueType,
  2961.             ];
  2962.         }
  2963.         return $fields;
  2964.     }
  2965.     private function validateAoaiFields(array $fields): ?string
  2966.     {
  2967.         if (count($fields) === 0) {
  2968.             return 'Debes indicar al menos un campo para Azure OpenAI.';
  2969.         }
  2970.         $seen = [];
  2971.         foreach ($fields as $item) {
  2972.             $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  2973.             $fieldKey trim((string)($item['field_key'] ?? ''));
  2974.             $prompt trim((string)($item['prompt'] ?? ''));
  2975.             $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  2976.             if ($fieldKey === '' || $prompt === '') {
  2977.                 return 'Todos los campos de Azure OpenAI deben tener nombre y prompt.';
  2978.             }
  2979.             if (!in_array($valueType, ['string''number''date'], true)) {
  2980.                 return 'El tipo de dato permitido es string, number o date.';
  2981.             }
  2982.             $uniq $scope '|' strtolower($fieldKey);
  2983.             if (isset($seen[$uniq])) {
  2984.                 return 'No se permiten campos repetidos dentro del mismo alcance (cabecera o líneas).';
  2985.             }
  2986.             $seen[$uniq] = true;
  2987.         }
  2988.         return null;
  2989.     }
  2990.     private function registerAoaiModelInClientDb(\mysqli $clientMysqli, array $resource, array $aoaiFields): int
  2991.     {
  2992.         $extractorType $this->normalizeExtractorType((string)($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  2993.         if ($extractorType !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  2994.             throw new \RuntimeException('El recurso seleccionado no es de tipo Azure OpenAI.'400);
  2995.         }
  2996.         $validationError $this->validateAoaiFields($aoaiFields);
  2997.         if ($validationError !== null) {
  2998.             throw new \RuntimeException($validationError400);
  2999.         }
  3000.         $modelId trim((string)($resource['model_id'] ?? ''));
  3001.         $basePrompt trim((string)($resource['base_prompt'] ?? ''));
  3002.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  3003.         $apiKey trim((string)($resource['api_key'] ?? ''));
  3004.         if ($modelId === '' || $basePrompt === '' || $endpoint === '' || $apiKey === '') {
  3005.             throw new \RuntimeException('El recurso Azure OpenAI está incompleto.'400);
  3006.         }
  3007.         $provider 'azure_openai';
  3008.         $type 'custom';
  3009.         $showConfidenceBadges 0;
  3010.         $clientMysqli->begin_transaction();
  3011.         try {
  3012.             $existingId 0;
  3013.             $stmtSelect $clientMysqli->prepare(
  3014.                 "SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? AND mode = 'full_extract' LIMIT 1"
  3015.             );
  3016.             if (!$stmtSelect) {
  3017.                 throw new \RuntimeException('Prepare SELECT extraction_models AOAI: ' $clientMysqli->error);
  3018.             }
  3019.             $stmtSelect->bind_param('ss'$provider$modelId);
  3020.             if (!$stmtSelect->execute()) {
  3021.                 $stmtSelect->close();
  3022.                 throw new \RuntimeException('Execute SELECT extraction_models AOAI: ' $stmtSelect->error);
  3023.             }
  3024.             $result $stmtSelect->get_result();
  3025.             if ($result && ($row $result->fetch_assoc())) {
  3026.                 $existingId = (int)$row['id'];
  3027.             }
  3028.             $stmtSelect->close();
  3029.             if ($existingId 0) {
  3030.                 $stmtUpdate $clientMysqli->prepare(
  3031.                     'UPDATE extraction_models
  3032.                      SET endpoint = ?, api_key = ?, base_prompt = ?, type = ?, show_confidence_badges = ?
  3033.                      WHERE id = ?'
  3034.                 );
  3035.                 if (!$stmtUpdate) {
  3036.                     throw new \RuntimeException('Prepare UPDATE extraction_models AOAI: ' $clientMysqli->error);
  3037.                 }
  3038.                 $stmtUpdate->bind_param('ssssii'$endpoint$apiKey$basePrompt$type$showConfidenceBadges$existingId);
  3039.                 if (!$stmtUpdate->execute()) {
  3040.                     $stmtUpdate->close();
  3041.                     throw new \RuntimeException('Execute UPDATE extraction_models AOAI: ' $stmtUpdate->error);
  3042.                 }
  3043.                 $stmtUpdate->close();
  3044.                 $localModelId $existingId;
  3045.             } else {
  3046.                 $stmtInsert $clientMysqli->prepare(
  3047.                     'INSERT INTO extraction_models (provider, mode, model_id, endpoint, api_key, base_prompt, type, show_confidence_badges)
  3048.                      VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
  3049.                 );
  3050.                 if (!$stmtInsert) {
  3051.                     throw new \RuntimeException('Prepare INSERT extraction_models AOAI: ' $clientMysqli->error);
  3052.                 }
  3053.                 $mode 'full_extract';
  3054.                 $stmtInsert->bind_param('sssssssi'$provider$mode$modelId$endpoint$apiKey$basePrompt$type$showConfidenceBadges);
  3055.                 if (!$stmtInsert->execute()) {
  3056.                     $stmtInsert->close();
  3057.                     throw new \RuntimeException('Execute INSERT extraction_models AOAI: ' $stmtInsert->error);
  3058.                 }
  3059.                 $localModelId = (int)$stmtInsert->insert_id;
  3060.                 $stmtInsert->close();
  3061.             }
  3062.             $existingHeaderKeys = [];
  3063.             $existingLinesKeys = [];
  3064.             $stmtExistingHeader $clientMysqli->prepare(
  3065.                 'SELECT field_key FROM definitions_header WHERE model_id = ?'
  3066.             );
  3067.             if (!$stmtExistingHeader) {
  3068.                 throw new \RuntimeException('Prepare SELECT definitions_header AOAI: ' $clientMysqli->error);
  3069.             }
  3070.             $stmtExistingHeader->bind_param('i'$localModelId);
  3071.             if (!$stmtExistingHeader->execute()) {
  3072.                 $stmtExistingHeader->close();
  3073.                 throw new \RuntimeException('Execute SELECT definitions_header AOAI: ' $stmtExistingHeader->error);
  3074.             }
  3075.             $resExistingHeader $stmtExistingHeader->get_result();
  3076.             while ($resExistingHeader && ($row $resExistingHeader->fetch_assoc())) {
  3077.                 $existingHeaderKeys[(string)($row['field_key'] ?? '')] = true;
  3078.             }
  3079.             $stmtExistingHeader->close();
  3080.             $stmtExistingLines $clientMysqli->prepare(
  3081.                 'SELECT field_key FROM definitions_lines WHERE model_id = ?'
  3082.             );
  3083.             if (!$stmtExistingLines) {
  3084.                 throw new \RuntimeException('Prepare SELECT definitions_lines AOAI: ' $clientMysqli->error);
  3085.             }
  3086.             $stmtExistingLines->bind_param('i'$localModelId);
  3087.             if (!$stmtExistingLines->execute()) {
  3088.                 $stmtExistingLines->close();
  3089.                 throw new \RuntimeException('Execute SELECT definitions_lines AOAI: ' $stmtExistingLines->error);
  3090.             }
  3091.             $resExistingLines $stmtExistingLines->get_result();
  3092.             while ($resExistingLines && ($row $resExistingLines->fetch_assoc())) {
  3093.                 $existingLinesKeys[(string)($row['field_key'] ?? '')] = true;
  3094.             }
  3095.             $stmtExistingLines->close();
  3096.             $stmtInsertHeader $clientMysqli->prepare(
  3097.                 'INSERT INTO definitions_header
  3098.                 (model_id, field_key, label, prompt, value_type, order_index, visibility, order_index_table, visibility_table)
  3099.                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
  3100.             );
  3101.             if (!$stmtInsertHeader) {
  3102.                 throw new \RuntimeException('Prepare INSERT definitions_header AOAI: ' $clientMysqli->error);
  3103.             }
  3104.             $stmtUpdateHeader $clientMysqli->prepare(
  3105.                 'UPDATE definitions_header
  3106.                  SET prompt = ?, value_type = ?, order_index = ?, order_index_table = ?
  3107.                  WHERE model_id = ? AND field_key = ?'
  3108.             );
  3109.             if (!$stmtUpdateHeader) {
  3110.                 $stmtInsertHeader->close();
  3111.                 throw new \RuntimeException('Prepare UPDATE definitions_header AOAI: ' $clientMysqli->error);
  3112.             }
  3113.             $stmtInsertLines $clientMysqli->prepare(
  3114.                 'INSERT INTO definitions_lines
  3115.                 (model_id, field_key, label, prompt, value_type, order_index, visibility)
  3116.                 VALUES (?, ?, ?, ?, ?, ?, ?)'
  3117.             );
  3118.             if (!$stmtInsertLines) {
  3119.                 $stmtInsertHeader->close();
  3120.                 $stmtUpdateHeader->close();
  3121.                 throw new \RuntimeException('Prepare INSERT definitions_lines AOAI: ' $clientMysqli->error);
  3122.             }
  3123.             $stmtUpdateLines $clientMysqli->prepare(
  3124.                 'UPDATE definitions_lines
  3125.                  SET prompt = ?, value_type = ?, order_index = ?
  3126.                  WHERE model_id = ? AND field_key = ?'
  3127.             );
  3128.             if (!$stmtUpdateLines) {
  3129.                 $stmtInsertHeader->close();
  3130.                 $stmtUpdateHeader->close();
  3131.                 $stmtInsertLines->close();
  3132.                 throw new \RuntimeException('Prepare UPDATE definitions_lines AOAI: ' $clientMysqli->error);
  3133.             }
  3134.             $headerOrder 1;
  3135.             $lineOrder 1;
  3136.             $incomingHeaderKeys = [];
  3137.             $incomingLinesKeys = [];
  3138.             foreach ($aoaiFields as $item) {
  3139.                 $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  3140.                 $fieldKey trim((string)($item['field_key'] ?? ''));
  3141.                 $prompt trim((string)($item['prompt'] ?? ''));
  3142.                 $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  3143.                 $visibility 1;
  3144.                 if ($scope === 'lines') {
  3145.                     $orderIndex $lineOrder++;
  3146.                     $incomingLinesKeys[$fieldKey] = true;
  3147.                     if (isset($existingLinesKeys[$fieldKey])) {
  3148.                         $stmtUpdateLines->bind_param(
  3149.                             'ssiis',
  3150.                             $prompt,
  3151.                             $valueType,
  3152.                             $orderIndex,
  3153.                             $localModelId,
  3154.                             $fieldKey
  3155.                         );
  3156.                         if (!$stmtUpdateLines->execute()) {
  3157.                             throw new \RuntimeException('Execute UPDATE definitions_lines AOAI: ' $stmtUpdateLines->error);
  3158.                         }
  3159.                     } else {
  3160.                         $label $fieldKey;
  3161.                         $stmtInsertLines->bind_param(
  3162.                             'issssii',
  3163.                             $localModelId,
  3164.                             $fieldKey,
  3165.                             $label,
  3166.                             $prompt,
  3167.                             $valueType,
  3168.                             $orderIndex,
  3169.                             $visibility
  3170.                         );
  3171.                         if (!$stmtInsertLines->execute()) {
  3172.                             throw new \RuntimeException('Execute INSERT definitions_lines AOAI: ' $stmtInsertLines->error);
  3173.                         }
  3174.                     }
  3175.                 } else {
  3176.                     $orderIndex $headerOrder++;
  3177.                     $orderIndexTable $orderIndex;
  3178.                     $incomingHeaderKeys[$fieldKey] = true;
  3179.                     if (isset($existingHeaderKeys[$fieldKey])) {
  3180.                         $stmtUpdateHeader->bind_param(
  3181.                             'ssiiss',
  3182.                             $prompt,
  3183.                             $valueType,
  3184.                             $orderIndex,
  3185.                             $orderIndexTable,
  3186.                             $localModelId,
  3187.                             $fieldKey
  3188.                         );
  3189.                         if (!$stmtUpdateHeader->execute()) {
  3190.                             throw new \RuntimeException('Execute UPDATE definitions_header AOAI: ' $stmtUpdateHeader->error);
  3191.                         }
  3192.                     } else {
  3193.                         $label $fieldKey;
  3194.                         $visibilityTable 1;
  3195.                         $stmtInsertHeader->bind_param(
  3196.                             'issssiiii',
  3197.                             $localModelId,
  3198.                             $fieldKey,
  3199.                             $label,
  3200.                             $prompt,
  3201.                             $valueType,
  3202.                             $orderIndex,
  3203.                             $visibility,
  3204.                             $orderIndexTable,
  3205.                             $visibilityTable
  3206.                         );
  3207.                         if (!$stmtInsertHeader->execute()) {
  3208.                             throw new \RuntimeException('Execute INSERT definitions_header AOAI: ' $stmtInsertHeader->error);
  3209.                         }
  3210.                     }
  3211.                 }
  3212.             }
  3213.             $stmtInsertHeader->close();
  3214.             $stmtUpdateHeader->close();
  3215.             $stmtInsertLines->close();
  3216.             $stmtUpdateLines->close();
  3217.             $headerToDelete array_values(array_diff(array_keys($existingHeaderKeys), array_keys($incomingHeaderKeys)));
  3218.             if (count($headerToDelete) > 0) {
  3219.                 $ph implode(','array_fill(0count($headerToDelete), '?'));
  3220.                 $types 'i' str_repeat('s'count($headerToDelete));
  3221.                 $sql "DELETE FROM definitions_header WHERE model_id = ? AND field_key IN ($ph)";
  3222.                 $stmtDeleteHeader $clientMysqli->prepare($sql);
  3223.                 if (!$stmtDeleteHeader) {
  3224.                     throw new \RuntimeException('Prepare DELETE definitions_header AOAI selective: ' $clientMysqli->error);
  3225.                 }
  3226.                 $params array_merge([$localModelId], $headerToDelete);
  3227.                 $bind = [$types];
  3228.                 foreach ($params as $k => $v) {
  3229.                     $bind[] = &$params[$k];
  3230.                 }
  3231.                 call_user_func_array([$stmtDeleteHeader'bind_param'], $bind);
  3232.                 if (!$stmtDeleteHeader->execute()) {
  3233.                     $stmtDeleteHeader->close();
  3234.                     throw new \RuntimeException('Execute DELETE definitions_header AOAI selective: ' $stmtDeleteHeader->error);
  3235.                 }
  3236.                 $stmtDeleteHeader->close();
  3237.             }
  3238.             $linesToDelete array_values(array_diff(array_keys($existingLinesKeys), array_keys($incomingLinesKeys)));
  3239.             if (count($linesToDelete) > 0) {
  3240.                 $ph implode(','array_fill(0count($linesToDelete), '?'));
  3241.                 $types 'i' str_repeat('s'count($linesToDelete));
  3242.                 $sql "DELETE FROM definitions_lines WHERE model_id = ? AND field_key IN ($ph)";
  3243.                 $stmtDeleteLines $clientMysqli->prepare($sql);
  3244.                 if (!$stmtDeleteLines) {
  3245.                     throw new \RuntimeException('Prepare DELETE definitions_lines AOAI selective: ' $clientMysqli->error);
  3246.                 }
  3247.                 $params array_merge([$localModelId], $linesToDelete);
  3248.                 $bind = [$types];
  3249.                 foreach ($params as $k => $v) {
  3250.                     $bind[] = &$params[$k];
  3251.                 }
  3252.                 call_user_func_array([$stmtDeleteLines'bind_param'], $bind);
  3253.                 if (!$stmtDeleteLines->execute()) {
  3254.                     $stmtDeleteLines->close();
  3255.                     throw new \RuntimeException('Execute DELETE definitions_lines AOAI selective: ' $stmtDeleteLines->error);
  3256.                 }
  3257.                 $stmtDeleteLines->close();
  3258.             }
  3259.             $clientMysqli->commit();
  3260.             return $localModelId;
  3261.         } catch (\Throwable $e) {
  3262.             $clientMysqli->rollback();
  3263.             throw $e;
  3264.         }
  3265.     }
  3266.     private function loadEnricherForFullModel(\mysqli $mysqliint $fullModelIdEntityManagerInterface $em): array
  3267.     {
  3268.         if ($fullModelId <= 0) {
  3269.             return [
  3270.                 'enabled' => 0,
  3271.                 'enricher_model_id' => 0,
  3272.                 'aoai_resource_id' => 0,
  3273.                 'fields' => [],
  3274.             ];
  3275.         }
  3276.         $row null;
  3277.         $stmt $mysqli->prepare(
  3278.             "SELECT eme.enricher_model_id, eme.enabled
  3279.              FROM extraction_model_enrichers eme
  3280.              INNER JOIN extraction_models em ON em.id = eme.enricher_model_id
  3281.              WHERE eme.full_model_id = ?
  3282.                AND em.mode = 'field_enricher'
  3283.                AND em.provider IN ('azure_openai', 'azure-openai')
  3284.              ORDER BY eme.priority ASC, eme.id ASC
  3285.              LIMIT 1"
  3286.         );
  3287.         if ($stmt) {
  3288.             $stmt->bind_param('i'$fullModelId);
  3289.             if ($stmt->execute()) {
  3290.                 $res $stmt->get_result();
  3291.                 if ($res && ($found $res->fetch_assoc())) {
  3292.                     $row $found;
  3293.                 }
  3294.             }
  3295.             $stmt->close();
  3296.         }
  3297.         if (!$row) {
  3298.             return [
  3299.                 'enabled' => 0,
  3300.                 'enricher_model_id' => 0,
  3301.                 'aoai_resource_id' => 0,
  3302.                 'fields' => [],
  3303.             ];
  3304.         }
  3305.         $enricherModelId = (int)($row['enricher_model_id'] ?? 0);
  3306.         return [
  3307.             'enabled' => (int)($row['enabled'] ?? 0),
  3308.             'enricher_model_id' => $enricherModelId,
  3309.             'aoai_resource_id' => $this->resolveAoaiResourceIdForModel($mysqli$enricherModelId$em),
  3310.             'fields' => $this->loadAoaiFieldsForModel($mysqli$enricherModelId),
  3311.         ];
  3312.     }
  3313.     private function disableEnrichersForFullModel(\mysqli $mysqliint $fullModelId): void
  3314.     {
  3315.         if ($fullModelId <= 0) {
  3316.             return;
  3317.         }
  3318.         $stmt $mysqli->prepare('UPDATE extraction_model_enrichers SET enabled = 0 WHERE full_model_id = ?');
  3319.         if (!$stmt) {
  3320.             throw new \RuntimeException('Prepare UPDATE extraction_model_enrichers disable: ' $mysqli->error);
  3321.         }
  3322.         $stmt->bind_param('i'$fullModelId);
  3323.         if (!$stmt->execute()) {
  3324.             $stmt->close();
  3325.             throw new \RuntimeException('Execute UPDATE extraction_model_enrichers disable: ' $stmt->error);
  3326.         }
  3327.         $stmt->close();
  3328.     }
  3329.     private function registerAoaiEnricherInClientDb(\mysqli $clientMysqliint $fullModelId, array $resource, array $fieldsbool $enabled): int
  3330.     {
  3331.         if ($fullModelId <= 0) {
  3332.             throw new \RuntimeException('Debes seleccionar un modelo principal antes de configurar el enricher.'400);
  3333.         }
  3334.         $extractorType $this->normalizeExtractorType((string)($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  3335.         if ($extractorType !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3336.             throw new \RuntimeException('El recurso del enricher debe ser Azure OpenAI.'400);
  3337.         }
  3338.         $validationError $this->validateAoaiFields($fields);
  3339.         if ($validationError !== null) {
  3340.             throw new \RuntimeException($validationError400);
  3341.         }
  3342.         $modelId trim((string)($resource['model_id'] ?? ''));
  3343.         $basePrompt trim((string)($resource['base_prompt'] ?? ''));
  3344.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  3345.         $apiKey trim((string)($resource['api_key'] ?? ''));
  3346.         if ($modelId === '' || $basePrompt === '' || $endpoint === '' || $apiKey === '') {
  3347.             throw new \RuntimeException('El recurso Azure OpenAI del enricher esta incompleto.'400);
  3348.         }
  3349.         $provider 'azure_openai';
  3350.         $mode 'field_enricher';
  3351.         $type 'custom';
  3352.         $showConfidenceBadges 0;
  3353.         $enabledInt $enabled 0;
  3354.         $priority 1;
  3355.         $clientMysqli->begin_transaction();
  3356.         try {
  3357.             $enricherModelId 0;
  3358.             $stmtCurrent $clientMysqli->prepare(
  3359.                 "SELECT eme.enricher_model_id
  3360.                  FROM extraction_model_enrichers eme
  3361.                  INNER JOIN extraction_models em ON em.id = eme.enricher_model_id
  3362.                  WHERE eme.full_model_id = ?
  3363.                    AND em.mode = 'field_enricher'
  3364.                    AND em.provider IN ('azure_openai', 'azure-openai')
  3365.                  ORDER BY eme.priority ASC, eme.id ASC
  3366.                  LIMIT 1"
  3367.             );
  3368.             if (!$stmtCurrent) {
  3369.                 throw new \RuntimeException('Prepare SELECT current enricher: ' $clientMysqli->error);
  3370.             }
  3371.             $stmtCurrent->bind_param('i'$fullModelId);
  3372.             if (!$stmtCurrent->execute()) {
  3373.                 $stmtCurrent->close();
  3374.                 throw new \RuntimeException('Execute SELECT current enricher: ' $stmtCurrent->error);
  3375.             }
  3376.             $resCurrent $stmtCurrent->get_result();
  3377.             if ($resCurrent && ($current $resCurrent->fetch_assoc())) {
  3378.                 $enricherModelId = (int)($current['enricher_model_id'] ?? 0);
  3379.             }
  3380.             $stmtCurrent->close();
  3381.             if ($enricherModelId 0) {
  3382.                 $stmtUpdateModel $clientMysqli->prepare(
  3383.                     'UPDATE extraction_models
  3384.                      SET provider = ?, mode = ?, model_id = ?, endpoint = ?, api_key = ?, base_prompt = ?, type = ?, show_confidence_badges = ?
  3385.                      WHERE id = ?'
  3386.                 );
  3387.                 if (!$stmtUpdateModel) {
  3388.                     throw new \RuntimeException('Prepare UPDATE extraction_models enricher: ' $clientMysqli->error);
  3389.                 }
  3390.                 $stmtUpdateModel->bind_param(
  3391.                     'sssssssii',
  3392.                     $provider,
  3393.                     $mode,
  3394.                     $modelId,
  3395.                     $endpoint,
  3396.                     $apiKey,
  3397.                     $basePrompt,
  3398.                     $type,
  3399.                     $showConfidenceBadges,
  3400.                     $enricherModelId
  3401.                 );
  3402.                 if (!$stmtUpdateModel->execute()) {
  3403.                     $stmtUpdateModel->close();
  3404.                     throw new \RuntimeException('Execute UPDATE extraction_models enricher: ' $stmtUpdateModel->error);
  3405.                 }
  3406.                 $stmtUpdateModel->close();
  3407.             } else {
  3408.                 $stmtInsertModel $clientMysqli->prepare(
  3409.                     'INSERT INTO extraction_models (provider, mode, model_id, endpoint, api_key, base_prompt, type, show_confidence_badges)
  3410.                      VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
  3411.                 );
  3412.                 if (!$stmtInsertModel) {
  3413.                     throw new \RuntimeException('Prepare INSERT extraction_models enricher: ' $clientMysqli->error);
  3414.                 }
  3415.                 $stmtInsertModel->bind_param(
  3416.                     'sssssssi',
  3417.                     $provider,
  3418.                     $mode,
  3419.                     $modelId,
  3420.                     $endpoint,
  3421.                     $apiKey,
  3422.                     $basePrompt,
  3423.                     $type,
  3424.                     $showConfidenceBadges
  3425.                 );
  3426.                 if (!$stmtInsertModel->execute()) {
  3427.                     $stmtInsertModel->close();
  3428.                     throw new \RuntimeException('Execute INSERT extraction_models enricher: ' $stmtInsertModel->error);
  3429.                 }
  3430.                 $enricherModelId = (int)$stmtInsertModel->insert_id;
  3431.                 $stmtInsertModel->close();
  3432.             }
  3433.             $stmtDeleteOtherRelations $clientMysqli->prepare(
  3434.                 'DELETE FROM extraction_model_enrichers WHERE full_model_id = ? AND enricher_model_id <> ?'
  3435.             );
  3436.             if (!$stmtDeleteOtherRelations) {
  3437.                 throw new \RuntimeException('Prepare DELETE other extraction_model_enrichers: ' $clientMysqli->error);
  3438.             }
  3439.             $stmtDeleteOtherRelations->bind_param('ii'$fullModelId$enricherModelId);
  3440.             if (!$stmtDeleteOtherRelations->execute()) {
  3441.                 $stmtDeleteOtherRelations->close();
  3442.                 throw new \RuntimeException('Execute DELETE other extraction_model_enrichers: ' $stmtDeleteOtherRelations->error);
  3443.             }
  3444.             $stmtDeleteOtherRelations->close();
  3445.             $stmtRelation $clientMysqli->prepare(
  3446.                 'INSERT INTO extraction_model_enrichers (full_model_id, enricher_model_id, priority, enabled)
  3447.                  VALUES (?, ?, ?, ?)
  3448.                  ON DUPLICATE KEY UPDATE priority = VALUES(priority), enabled = VALUES(enabled)'
  3449.             );
  3450.             if (!$stmtRelation) {
  3451.                 throw new \RuntimeException('Prepare UPSERT extraction_model_enrichers: ' $clientMysqli->error);
  3452.             }
  3453.             $stmtRelation->bind_param('iiii'$fullModelId$enricherModelId$priority$enabledInt);
  3454.             if (!$stmtRelation->execute()) {
  3455.                 $stmtRelation->close();
  3456.                 throw new \RuntimeException('Execute UPSERT extraction_model_enrichers: ' $stmtRelation->error);
  3457.             }
  3458.             $stmtRelation->close();
  3459.             $stmtDelHeader $clientMysqli->prepare('DELETE FROM definitions_header WHERE model_id = ?');
  3460.             if (!$stmtDelHeader) {
  3461.                 throw new \RuntimeException('Prepare DELETE definitions_header enricher: ' $clientMysqli->error);
  3462.             }
  3463.             $stmtDelHeader->bind_param('i'$enricherModelId);
  3464.             if (!$stmtDelHeader->execute()) {
  3465.                 $stmtDelHeader->close();
  3466.                 throw new \RuntimeException('Execute DELETE definitions_header enricher: ' $stmtDelHeader->error);
  3467.             }
  3468.             $stmtDelHeader->close();
  3469.             $stmtDelLines $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
  3470.             if (!$stmtDelLines) {
  3471.                 throw new \RuntimeException('Prepare DELETE definitions_lines enricher: ' $clientMysqli->error);
  3472.             }
  3473.             $stmtDelLines->bind_param('i'$enricherModelId);
  3474.             if (!$stmtDelLines->execute()) {
  3475.                 $stmtDelLines->close();
  3476.                 throw new \RuntimeException('Execute DELETE definitions_lines enricher: ' $stmtDelLines->error);
  3477.             }
  3478.             $stmtDelLines->close();
  3479.             $this->insertAoaiDefinitionRows($clientMysqli$enricherModelId$fields);
  3480.             $this->syncEnricherFieldsToFullModel($clientMysqli$fullModelId$fields);
  3481.             $clientMysqli->commit();
  3482.             return $enricherModelId;
  3483.         } catch (\Throwable $e) {
  3484.             $clientMysqli->rollback();
  3485.             throw $e;
  3486.         }
  3487.     }
  3488.     private function insertAoaiDefinitionRows(\mysqli $mysqliint $modelId, array $fields): void
  3489.     {
  3490.         $stmtInsertHeader $mysqli->prepare(
  3491.             'INSERT INTO definitions_header
  3492.             (model_id, field_key, label, prompt, value_type, order_index, visibility, order_index_table, visibility_table)
  3493.             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
  3494.         );
  3495.         if (!$stmtInsertHeader) {
  3496.             throw new \RuntimeException('Prepare INSERT definitions_header enricher: ' $mysqli->error);
  3497.         }
  3498.         $stmtInsertLines $mysqli->prepare(
  3499.             'INSERT INTO definitions_lines
  3500.             (model_id, field_key, label, prompt, value_type, order_index, visibility)
  3501.             VALUES (?, ?, ?, ?, ?, ?, ?)'
  3502.         );
  3503.         if (!$stmtInsertLines) {
  3504.             $stmtInsertHeader->close();
  3505.             throw new \RuntimeException('Prepare INSERT definitions_lines enricher: ' $mysqli->error);
  3506.         }
  3507.         $headerOrder 1;
  3508.         $lineOrder 1;
  3509.         foreach ($fields as $item) {
  3510.             $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  3511.             $fieldKey trim((string)($item['field_key'] ?? ''));
  3512.             $prompt trim((string)($item['prompt'] ?? ''));
  3513.             $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  3514.             $label $fieldKey;
  3515.             $visibility 1;
  3516.             if ($scope === 'lines') {
  3517.                 $orderIndex $lineOrder++;
  3518.                 $stmtInsertLines->bind_param(
  3519.                     'issssii',
  3520.                     $modelId,
  3521.                     $fieldKey,
  3522.                     $label,
  3523.                     $prompt,
  3524.                     $valueType,
  3525.                     $orderIndex,
  3526.                     $visibility
  3527.                 );
  3528.                 if (!$stmtInsertLines->execute()) {
  3529.                     $stmtInsertLines->close();
  3530.                     $stmtInsertHeader->close();
  3531.                     throw new \RuntimeException('Execute INSERT definitions_lines enricher: ' $stmtInsertLines->error);
  3532.                 }
  3533.             } else {
  3534.                 $orderIndex $headerOrder++;
  3535.                 $orderIndexTable $orderIndex;
  3536.                 $visibilityTable 1;
  3537.                 $stmtInsertHeader->bind_param(
  3538.                     'issssiiii',
  3539.                     $modelId,
  3540.                     $fieldKey,
  3541.                     $label,
  3542.                     $prompt,
  3543.                     $valueType,
  3544.                     $orderIndex,
  3545.                     $visibility,
  3546.                     $orderIndexTable,
  3547.                     $visibilityTable
  3548.                 );
  3549.                 if (!$stmtInsertHeader->execute()) {
  3550.                     $stmtInsertLines->close();
  3551.                     $stmtInsertHeader->close();
  3552.                     throw new \RuntimeException('Execute INSERT definitions_header enricher: ' $stmtInsertHeader->error);
  3553.                 }
  3554.             }
  3555.         }
  3556.         $stmtInsertLines->close();
  3557.         $stmtInsertHeader->close();
  3558.     }
  3559.     private function syncEnricherFieldsToFullModel(\mysqli $mysqliint $fullModelId, array $fields): void
  3560.     {
  3561.         $stmtMaxHeader $mysqli->prepare('SELECT COALESCE(MAX(order_index), 0), COALESCE(MAX(order_index_table), 0) FROM definitions_header WHERE model_id = ?');
  3562.         if (!$stmtMaxHeader) {
  3563.             throw new \RuntimeException('Prepare SELECT max definitions_header full: ' $mysqli->error);
  3564.         }
  3565.         $stmtMaxHeader->bind_param('i'$fullModelId);
  3566.         if (!$stmtMaxHeader->execute()) {
  3567.             $stmtMaxHeader->close();
  3568.             throw new \RuntimeException('Execute SELECT max definitions_header full: ' $stmtMaxHeader->error);
  3569.         }
  3570.         $stmtMaxHeader->bind_result($headerOrder$headerTableOrder);
  3571.         $stmtMaxHeader->fetch();
  3572.         $stmtMaxHeader->close();
  3573.         $stmtMaxLines $mysqli->prepare('SELECT COALESCE(MAX(order_index), 0) FROM definitions_lines WHERE model_id = ?');
  3574.         if (!$stmtMaxLines) {
  3575.             throw new \RuntimeException('Prepare SELECT max definitions_lines full: ' $mysqli->error);
  3576.         }
  3577.         $stmtMaxLines->bind_param('i'$fullModelId);
  3578.         if (!$stmtMaxLines->execute()) {
  3579.             $stmtMaxLines->close();
  3580.             throw new \RuntimeException('Execute SELECT max definitions_lines full: ' $stmtMaxLines->error);
  3581.         }
  3582.         $stmtMaxLines->bind_result($lineOrder);
  3583.         $stmtMaxLines->fetch();
  3584.         $stmtMaxLines->close();
  3585.         $stmtExistsHeader $mysqli->prepare('SELECT id FROM definitions_header WHERE model_id = ? AND field_key = ? LIMIT 1');
  3586.         $stmtExistsLines $mysqli->prepare('SELECT id FROM definitions_lines WHERE model_id = ? AND field_key = ? LIMIT 1');
  3587.         $stmtInsertHeader $mysqli->prepare(
  3588.             'INSERT INTO definitions_header
  3589.             (model_id, field_key, label, prompt, value_type, order_index, visibility, order_index_table, visibility_table)
  3590.             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
  3591.         );
  3592.         $stmtInsertLines $mysqli->prepare(
  3593.             'INSERT INTO definitions_lines
  3594.             (model_id, field_key, label, prompt, value_type, order_index, visibility)
  3595.             VALUES (?, ?, ?, ?, ?, ?, ?)'
  3596.         );
  3597.         if (!$stmtExistsHeader || !$stmtExistsLines || !$stmtInsertHeader || !$stmtInsertLines) {
  3598.             foreach ([$stmtExistsHeader$stmtExistsLines$stmtInsertHeader$stmtInsertLines] as $stmt) {
  3599.                 if ($stmt) {
  3600.                     $stmt->close();
  3601.                 }
  3602.             }
  3603.             throw new \RuntimeException('Prepare sync definitions full model: ' $mysqli->error);
  3604.         }
  3605.         foreach ($fields as $item) {
  3606.             $scope $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
  3607.             $fieldKey trim((string)($item['field_key'] ?? ''));
  3608.             $prompt trim((string)($item['prompt'] ?? ''));
  3609.             $valueType $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
  3610.             $label $fieldKey;
  3611.             $visibility 1;
  3612.             if ($scope === 'lines') {
  3613.                 $stmtExistsLines->bind_param('is'$fullModelId$fieldKey);
  3614.                 if (!$stmtExistsLines->execute()) {
  3615.                     throw new \RuntimeException('Execute SELECT definitions_lines full: ' $stmtExistsLines->error);
  3616.                 }
  3617.                 $res $stmtExistsLines->get_result();
  3618.                 if ($res && $res->fetch_assoc()) {
  3619.                     $res->free();
  3620.                     continue;
  3621.                 }
  3622.                 if ($res) {
  3623.                     $res->free();
  3624.                 }
  3625.                 $lineOrder++;
  3626.                 $stmtInsertLines->bind_param(
  3627.                     'issssii',
  3628.                     $fullModelId,
  3629.                     $fieldKey,
  3630.                     $label,
  3631.                     $prompt,
  3632.                     $valueType,
  3633.                     $lineOrder,
  3634.                     $visibility
  3635.                 );
  3636.                 if (!$stmtInsertLines->execute()) {
  3637.                     throw new \RuntimeException('Execute INSERT definitions_lines full: ' $stmtInsertLines->error);
  3638.                 }
  3639.             } else {
  3640.                 $stmtExistsHeader->bind_param('is'$fullModelId$fieldKey);
  3641.                 if (!$stmtExistsHeader->execute()) {
  3642.                     throw new \RuntimeException('Execute SELECT definitions_header full: ' $stmtExistsHeader->error);
  3643.                 }
  3644.                 $res $stmtExistsHeader->get_result();
  3645.                 if ($res && $res->fetch_assoc()) {
  3646.                     $res->free();
  3647.                     continue;
  3648.                 }
  3649.                 if ($res) {
  3650.                     $res->free();
  3651.                 }
  3652.                 $headerOrder++;
  3653.                 $headerTableOrder++;
  3654.                 $visibilityTable 1;
  3655.                 $stmtInsertHeader->bind_param(
  3656.                     'issssiiii',
  3657.                     $fullModelId,
  3658.                     $fieldKey,
  3659.                     $label,
  3660.                     $prompt,
  3661.                     $valueType,
  3662.                     $headerOrder,
  3663.                     $visibility,
  3664.                     $headerTableOrder,
  3665.                     $visibilityTable
  3666.                 );
  3667.                 if (!$stmtInsertHeader->execute()) {
  3668.                     throw new \RuntimeException('Execute INSERT definitions_header full: ' $stmtInsertHeader->error);
  3669.                 }
  3670.             }
  3671.         }
  3672.         $stmtExistsHeader->close();
  3673.         $stmtExistsLines->close();
  3674.         $stmtInsertHeader->close();
  3675.         $stmtInsertLines->close();
  3676.     }
  3677.     public function Empresa(Request $reqEntityManagerInterface $em)
  3678.     {
  3679.         if (!$this->getUser() || !is_object($this->getUser())) {
  3680.             return $this->redirectToRoute('logout');
  3681.         }
  3682.         $id = (int)$req->get("id");
  3683.         if (!$id) {
  3684.             $this->addFlash('warning''Empresa no encontrada.');
  3685.             return $this->redirectToRoute("list");
  3686.         }
  3687.         $empresa $em->getRepository(Empresa::class)->find($id);
  3688.         if (!$empresa) {
  3689.             $this->addFlash('warning''Empresa no encontrada.');
  3690.             return $this->redirectToRoute("list");
  3691.         }
  3692.         $users $em->getRepository(Usuario::class)->findBy([
  3693.             "empresa" => $empresa->getId()
  3694.         ]);
  3695.         // Valores por defecto por si algo falla al conectar con la BD del cliente
  3696.         $activeUsers            null;
  3697.         $modulos                = [];
  3698.         $empresaLogo            null;
  3699.         $extractionModelLabel   null;
  3700.         $activeLicenseContract  = [];
  3701.         $licenseContractHistory = [];
  3702.         $visualActiveContractId null;
  3703.         $diskUsedBytes          null;
  3704.         $diskUsedGb             null;
  3705.         try {
  3706.             $cx $empresa->getConexionBD();
  3707.             if ($cx) {
  3708.                 $mysqli = @new \mysqli(
  3709.                     $cx->getDbUrl(),
  3710.                     $cx->getDbUser(),
  3711.                     $cx->getDbPassword(),
  3712.                     $cx->getDbName(),
  3713.                     (int)$cx->getDbPort()
  3714.                 );
  3715.                 if (!$mysqli->connect_error) {
  3716.                     // parámetros (modulos, límites, etc.)
  3717.                     [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  3718.                     // logo guardado en la BD del cliente
  3719.                     $empresaLogo $this->loadEmpresaLogo($mysqli);
  3720.                     $activeLicenseContract $this->licenseContractService->loadActiveContract($mysqli);
  3721.                     $licenseContractHistory $this->licenseContractService->loadContractHistory($mysqli);
  3722.                     $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($licenseContractHistory);
  3723.                     $diskUsedBytes $this->loadEmpresaDiskUsageBytes($mysqli);
  3724.                     $diskUsedGb = ($diskUsedBytes !== null)
  3725.                         ? round($diskUsedBytes 1024 1024 10242)
  3726.                         : null;
  3727.                     // Si tiene extracción y modelo seleccionado, buscamos el ID legible del modelo
  3728.                     if (
  3729.                         !empty($modulos['modulo_extraccion']) &&
  3730.                         !empty($modulos['extraction_model'])
  3731.                     ) {
  3732.                         try {
  3733.                             $stmt $mysqli->prepare('SELECT model_id FROM extraction_models WHERE id = ? LIMIT 1');
  3734.                             if ($stmt) {
  3735.                                 $modelParam = (int)$modulos['extraction_model'];
  3736.                                 $stmt->bind_param('i'$modelParam);
  3737.                                 if ($stmt->execute()) {
  3738.                                     $res $stmt->get_result();
  3739.                                     if ($res && ($row $res->fetch_assoc()) && isset($row['model_id'])) {
  3740.                                         $extractionModelLabel $row['model_id'];
  3741.                                     }
  3742.                                 }
  3743.                                 $stmt->close();
  3744.                             }
  3745.                         } catch (\Throwable $e) {
  3746.                             // Si falla, simplemente no mostramos el texto bonito del modelo
  3747.                             $extractionModelLabel null;
  3748.                         }
  3749.                     }
  3750.                     $mysqli->close();
  3751.                 }
  3752.             }
  3753.         } catch (\Throwable $e) {
  3754.             // Aquí podrías loguear el error si quieres, pero no rompemos la pantalla
  3755.         }
  3756.         return $this->render('empresa_detail.html.twig', [
  3757.             'empresa'              => $empresa,
  3758.             'users'                => $users,
  3759.             'activeUsers'          => $activeUsers,
  3760.             'modulos'              => $modulos,
  3761.             'empresaLogo'          => $empresaLogo,
  3762.             'extractionModelLabel' => $extractionModelLabel,
  3763.             'diskUsedGb' => $diskUsedGb,
  3764.             'activeLicenseContract' => $activeLicenseContract,
  3765.             'licenseContractHistory' => $licenseContractHistory,
  3766.             'visualActiveContractId' => $visualActiveContractId,
  3767.         ]);
  3768.     }
  3769.     public function deleteEmpresa(Request $requestEntityManagerInterface $em)
  3770.     {
  3771.         $id $request->get("id");
  3772.         $empresa $em->getRepository(Empresa::class)->find($id);
  3773.         $conexion $empresa->getConexionBD();
  3774.         $usuarios $em->getRepository(Usuario::class)->findBy(array("empresa" => $empresa->getId()));
  3775.         // Recoger avatar/firma de usuarios antes de eliminar la BD del cliente
  3776.         $mediaPaths = [];
  3777.         try {
  3778.             $mysqliMedia = @new \mysqli(
  3779.                 $conexion->getDbUrl(),
  3780.                 $conexion->getDbUser(),
  3781.                 $conexion->getDbPassword(),
  3782.                 $conexion->getDbName(),
  3783.                 (int)$conexion->getDbPort()
  3784.             );
  3785.             if (!$mysqliMedia->connect_error) {
  3786.                 $mediaPaths $this->getCompanyUserMediaPaths($mysqliMedia);
  3787.                 $mysqliMedia->close();
  3788.             } else {
  3789.                 error_log('No se pudo conectar a BD cliente para borrar media: ' $mysqliMedia->connect_error);
  3790.             }
  3791.         } catch (\Throwable $e) {
  3792.             error_log('Error borrando media de usuarios: ' $e->getMessage());
  3793.         }
  3794.         $hestiaApiUrl 'https://200.234.237.107:8083/api/';
  3795.         $owner 'docunecta'// o el dueño del hosting
  3796.         $postFields http_build_query([
  3797.             'user' => 'admin',
  3798.             'password' => 'i9iQiSmxb2EpvgLq',
  3799.             'returncode' => 'yes',
  3800.             'cmd' => 'v-delete-database',
  3801.             'arg1' => 'admin',
  3802.             'arg2' => $conexion->getDbName(),
  3803.         ]);
  3804.         $accessKeyId 'cWYbt9ShyFQ3yVRsUE8u';
  3805.         $secretKey 'e2M_5wk2_jUAlPorF7V8zfwo3_0ihu90WoLPMKwj';
  3806.         $headers = [
  3807.             'Authorization: Bearer ' $accessKeyId ':' $secretKey
  3808.         ];
  3809.         $ch curl_init();
  3810.         curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  3811.         curl_setopt($chCURLOPT_HTTPHEADER$headers);
  3812.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  3813.         curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  3814.         curl_setopt($chCURLOPT_POSTtrue);
  3815.         curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  3816.         curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  3817.         $response curl_exec($ch);
  3818.         $error curl_error($ch);
  3819.         curl_close($ch);
  3820.         if (($error || trim($response) !== '0') && trim($response) !== '3') {
  3821.             $this->addFlash('danger''Error al eliminar la base de datos en HestiaCP: ' . ($error ?: $response));
  3822.             return $this->redirectToRoute('list');
  3823.         }
  3824.         // Eliminar el servicio systemd asociado a la empresa
  3825.         $company_name $empresa->getId();
  3826.         $serviceName $company_name "-documanager.service";
  3827.         $servicePath "/etc/systemd/system/$serviceName";
  3828.         $cmds = [
  3829.             "sudo /bin/systemctl stop $serviceName",
  3830.             "sudo /bin/systemctl disable $serviceName",
  3831.             "sudo /bin/rm -f $servicePath",
  3832.             "sudo /bin/systemctl daemon-reload"
  3833.         ];
  3834.         $serviceErrors = [];
  3835.         foreach ($cmds as $cmd) {
  3836.             $output = @\shell_exec($cmd " 2>&1");
  3837.             if ($output !== null && trim($output) !== '') {
  3838.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  3839.             }
  3840.         }
  3841.         $azureService $company_name "-azuredi.service";
  3842.         $servicePathAzure "/etc/systemd/system/$azureService";
  3843.         $cmdsAzure = [
  3844.             "sudo /bin/systemctl stop $azureService",
  3845.             "sudo /bin/systemctl disable $azureService",
  3846.             "sudo /bin/rm -f $servicePathAzure",
  3847.             "sudo /bin/systemctl daemon-reload",
  3848.         ];
  3849.         foreach ($cmdsAzure as $cmd) {
  3850.             $output = @\shell_exec($cmd " 2>&1");
  3851.             if ($output) {
  3852.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  3853.             }
  3854.         }
  3855.         // Eliminar mail monitor service + timer (si existen)
  3856.         $this->disableMailMonitorService((int)$company_name);
  3857.         // Pedir a platform que elimine files/logs/media del cliente
  3858.         $this->callPlatformCleanup((int)$company_name$mediaPaths);
  3859.         //eliminamos usuarios
  3860.         foreach ($usuarios as $user) {
  3861.             $em->remove($user);
  3862.             $em->flush();
  3863.         }
  3864.         //eliminamos conexiĂłn
  3865.         $em->remove($conexion);
  3866.         $em->flush();
  3867.         //eliminamos empresa
  3868.         $em->remove($empresa);
  3869.         $em->flush();
  3870.         $msg 'Empresa y base de datos eliminadas correctamente.';
  3871.         if (count($serviceErrors) > 0) {
  3872.             $msg .= ' ' implode('<br>'$serviceErrors);
  3873.         }
  3874.         $this->addFlash('success'$msg);
  3875.         return $this->redirectToRoute('list');
  3876.     }
  3877.     public function editEmpresa(Request $requestEntityManagerInterface $em)
  3878.     {
  3879.         if (!$this->getUser() || !is_object($this->getUser())) {
  3880.             return $this->redirectToRoute('logout');
  3881.         }
  3882.         $id = (int)$request->get('id');
  3883.         $empresa $em->getRepository(Empresa::class)->find($id);
  3884.         if (!$empresa) {
  3885.             throw $this->createNotFoundException('Empresa no encontrada');
  3886.         }
  3887.         // 1) Conectar a la BD del cliente con las credenciales de la central
  3888.         $cx $empresa->getConexionBD();
  3889.         $mysqli = @new \mysqli(
  3890.             $cx->getDbUrl(),
  3891.             $cx->getDbUser(),
  3892.             $cx->getDbPassword(),
  3893.             $cx->getDbName(),
  3894.             (int)$cx->getDbPort()
  3895.         );
  3896.         if ($mysqli->connect_error) {
  3897.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  3898.             return $this->redirectToRoute('list');
  3899.         }
  3900.         $ocrPlusAvailable $this->isOcrPlusAvailable();
  3901.         $ocrV2Available $this->isOcrV2Available();
  3902.         $azureResources = [];
  3903.         $azureDiResources = [];
  3904.         $azureOpenAiResources = [];
  3905.         try {
  3906.             $azureResources $this->loadAzureResources($em);
  3907.             foreach ($azureResources as $resourceRow) {
  3908.                 if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3909.                     $azureOpenAiResources[] = $resourceRow;
  3910.                 } else {
  3911.                     $azureDiResources[] = $resourceRow;
  3912.                 }
  3913.             }
  3914.         } catch (\Throwable $e) {
  3915.             $this->addFlash('warning''No se pudo cargar el catalogo de recursos IA: ' $e->getMessage());
  3916.         }
  3917.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  3918.             // Estado previo de modulos (para detectar activacion de Mail Monitor)
  3919.             [$activeUsersPrev$modulosPrev] = $this->loadEmpresaParametros($mysqli);
  3920.             $prevMailMonitor = (int)($modulosPrev['modulo_mailMonitor'] ?? 0);
  3921.             $data $request->request->all();
  3922.             $maxThreads $this->clampDocuMaxThreads($data['maxThreads'] ?? null$empresa->getMaxThreads() ?: 4);
  3923.             $data['ocr_mode'] = $this->normalizeOcrMode($data['ocr_mode'] ?? 'base');
  3924.             if (!$ocrV2Available && $data['ocr_mode'] === 'v2_zxing') {
  3925.                 $data['ocr_mode'] = 'base';
  3926.             }
  3927.             if (!$ocrPlusAvailable && $data['ocr_mode'] === 'plus_glm') {
  3928.                 $data['ocr_mode'] = 'base';
  3929.             }
  3930.             // 2) Actualizar SOLO central
  3931.             $empresa->setName((string)($data['name'] ?? $empresa->getName()));
  3932.             $empresa->setMaxDiskQuota(
  3933.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== ''
  3934.                     ? (int)$data['maxDiskQuota']
  3935.                     : $empresa->getMaxDiskQuota()
  3936.             );
  3937.             $empresa->setMaxThreads($maxThreads);
  3938.             $em->persist($empresa);
  3939.             // ---- Normalización de POST (checkboxes / select) ----
  3940.             $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  3941.             $extractionModel 0;
  3942.             if (isset($data['extraction_model']) && $data['extraction_model'] !== '') {
  3943.                 $extractionModel = (int)$data['extraction_model'];
  3944.             }
  3945.             $data['extraction_model'] = $extractionModel;
  3946.             $data['extractor_type'] = $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
  3947.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  3948.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  3949.             $aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
  3950.             $aoaiFields $this->collectAoaiFieldsFromRequest($request);
  3951.             $enricherEnabled = isset($data['enricher_enabled']) && $toBool($data['enricher_enabled']);
  3952.             $enricherResourceId = (int)($data['enricher_resource_id'] ?? 0);
  3953.             $enricherFields $this->collectEnricherFieldsFromRequest($request);
  3954.             $data['modulo_extraccion']       = isset($data['modulo_extraccion'])       && $toBool($data['modulo_extraccion'])       ? 0;
  3955.             $data['modulo_etiquetas']        = isset($data['modulo_etiquetas'])        && $toBool($data['modulo_etiquetas'])        ? 0;
  3956.             $data['modulo_calendario']       = isset($data['modulo_calendario'])       && $toBool($data['modulo_calendario'])       ? 0;
  3957.             $data['modulo_calendarioExterno'] = isset($data['modulo_calendarioExterno']) && $toBool($data['modulo_calendarioExterno']) ? 0;
  3958.             $data['modulo_estados']          = isset($data['modulo_estados'])          && $toBool($data['modulo_estados'])          ? 0;
  3959.             $data['modulo_lineas']           = isset($data['modulo_lineas'])           && $toBool($data['modulo_lineas'])           ? 0;
  3960.             $data['modulo_conciliacion']     = isset($data['modulo_conciliacion'])     && $toBool($data['modulo_conciliacion'])     ? 0;
  3961.             $data['modulo_precios']          = isset($data['modulo_precios'])          && $toBool($data['modulo_precios'])          ? 0;
  3962.             $data['modulo_ubikos']           = isset($data['modulo_ubikos'])           && $toBool($data['modulo_ubikos'])           ? 0;
  3963.             $data['modulo_agora']            = isset($data['modulo_agora'])           && $toBool($data['modulo_agora'])             ? 0;
  3964.             $data['modulo_gstock']           = isset($data['modulo_gstock'])           && $toBool($data['modulo_gstock'])           ? 0;
  3965.             $data['modulo_expowin']          = isset($data['modulo_expowin'])          && $toBool($data['modulo_expowin'])          ? 0;
  3966.             $data['modulo_prinex']           = isset($data['modulo_prinex'])           && $toBool($data['modulo_prinex'])           ? 0;
  3967.             $data['modulo_mailMonitor']      = isset($data['modulo_mailMonitor'])      && $toBool($data['modulo_mailMonitor'])      ? 0;
  3968.             $data['modulo_busquedaNatural']  = isset($data['modulo_busquedaNatural'])  && $toBool($data['modulo_busquedaNatural'])  ? 0;
  3969.             $data['soloExtraccion']          = isset($data['soloExtraccion'])          && $toBool($data['soloExtraccion'])          ? 0;
  3970.             // Dependencias
  3971.             if (!$data['modulo_calendario']) {
  3972.                 $data['modulo_calendarioExterno'] = 0;
  3973.             }
  3974.             if (!$data['modulo_extraccion']) {
  3975.                 $data['modulo_lineas']   = 0;
  3976.                 $data['modulo_conciliacion'] = 0;
  3977.                 $data['modulo_precios']  = 0;
  3978.                 $data['modulo_ubikos']   = 0;
  3979.                 $data['extraction_model'] = 0;
  3980.                 $data['modulo_agora']    = 0;
  3981.                 $data['modulo_gstock']   = 0;
  3982.                 $data['modulo_expowin']  = 0;
  3983.                 $data['modulo_prinex']   = 0;
  3984.                 $data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
  3985.                 $azureResourceId 0;
  3986.                 $azureModelId '';
  3987.                 $aoaiResourceId 0;
  3988.                 $enricherEnabled false;
  3989.                 $enricherResourceId 0;
  3990.             }
  3991.             if (!$data['modulo_lineas']) {
  3992.                 $data['modulo_precios'] = 0;
  3993.             }
  3994.             // 3) Guardar en BD del cliente: parametros + license
  3995.             if ($data['modulo_extraccion'] === 1) {
  3996.                 if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  3997.                     if ($aoaiResourceId <= 0) {
  3998.                         $this->addFlash('danger''Para Azure OpenAI debes seleccionar un recurso IA.');
  3999.                         $mysqli->close();
  4000.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4001.                     }
  4002.                     $azureResource $this->loadAzureResourceById($em$aoaiResourceId);
  4003.                     if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  4004.                         $this->addFlash('danger''El recurso Azure OpenAI seleccionado no existe.');
  4005.                         $mysqli->close();
  4006.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4007.                     }
  4008.                     $validationError $this->validateAoaiFields($aoaiFields);
  4009.                     if ($validationError !== null) {
  4010.                         $this->addFlash('danger'$validationError);
  4011.                         $mysqli->close();
  4012.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4013.                     }
  4014.                     try {
  4015.                         $data['extraction_model'] = $this->registerAoaiModelInClientDb(
  4016.                             $mysqli,
  4017.                             $azureResource,
  4018.                             $aoaiFields
  4019.                         );
  4020.                         $this->addFlash('success''Modelo Azure OpenAI importado/actualizado correctamente.');
  4021.                     } catch (\Throwable $e) {
  4022.                         $this->addFlash('danger''No se pudo registrar el modelo IA: ' $e->getMessage());
  4023.                         $mysqli->close();
  4024.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4025.                     }
  4026.                 } else {
  4027.                     if (($azureResourceId && $azureModelId === '') || ($azureResourceId <= && $azureModelId !== '')) {
  4028.                         $this->addFlash('danger''Para importar desde recurso IA debes indicar recurso y modelo.');
  4029.                         $mysqli->close();
  4030.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4031.                     }
  4032.                     if ($azureResourceId && $azureModelId !== '') {
  4033.                         $azureResource $this->loadAzureResourceById($em$azureResourceId);
  4034.                         if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
  4035.                             $this->addFlash('danger''El recurso IA seleccionado no existe o no es de tipo Azure DI.');
  4036.                             $mysqli->close();
  4037.                             return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4038.                         }
  4039.                         try {
  4040.                             $data['extraction_model'] = $this->registerDiModelInClientDb(
  4041.                                 $mysqli,
  4042.                                 $azureResource,
  4043.                                 $azureModelId
  4044.                             );
  4045.                             $this->addFlash('success''Modelo IA importado/actualizado correctamente.');
  4046.                         } catch (\Throwable $e) {
  4047.                             $this->addFlash('danger''No se pudo registrar el modelo IA: ' $e->getMessage());
  4048.                             $mysqli->close();
  4049.                             return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4050.                         }
  4051.                     }
  4052.                     if ((int)$data['extraction_model'] <= 0) {
  4053.                         $this->addFlash('danger''Con extraccion activa debes seleccionar un modelo local o importar uno desde recurso IA.');
  4054.                         $mysqli->close();
  4055.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4056.                     }
  4057.                 }
  4058.                 if ($enricherEnabled) {
  4059.                     if ((int)$data['extraction_model'] <= 0) {
  4060.                         $this->addFlash('danger''Debes seleccionar un modelo principal antes de configurar el enricher.');
  4061.                         $mysqli->close();
  4062.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4063.                     }
  4064.                     if ($enricherResourceId <= 0) {
  4065.                         $this->addFlash('danger''Para activar el enricher debes seleccionar un recurso Azure OpenAI.');
  4066.                         $mysqli->close();
  4067.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4068.                     }
  4069.                     $enricherResource $this->loadAzureResourceById($em$enricherResourceId);
  4070.                     if (!$enricherResource || ($enricherResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
  4071.                         $this->addFlash('danger''El recurso Azure OpenAI del enricher no existe.');
  4072.                         $mysqli->close();
  4073.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4074.                     }
  4075.                     $validationError $this->validateAoaiFields($enricherFields);
  4076.                     if ($validationError !== null) {
  4077.                         $this->addFlash('danger'$validationError);
  4078.                         $mysqli->close();
  4079.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4080.                     }
  4081.                     try {
  4082.                         $previousExtractionModel = (int)($modulosPrev['extraction_model'] ?? 0);
  4083.                         if ($previousExtractionModel && $previousExtractionModel !== (int)$data['extraction_model']) {
  4084.                             $this->disableEnrichersForFullModel($mysqli$previousExtractionModel);
  4085.                         }
  4086.                         $this->registerAoaiEnricherInClientDb(
  4087.                             $mysqli,
  4088.                             (int)$data['extraction_model'],
  4089.                             $enricherResource,
  4090.                             $enricherFields,
  4091.                             true
  4092.                         );
  4093.                         $this->addFlash('success''Enricher Azure OpenAI importado/actualizado correctamente.');
  4094.                     } catch (\Throwable $e) {
  4095.                         $this->addFlash('danger''No se pudo registrar el enricher IA: ' $e->getMessage());
  4096.                         $mysqli->close();
  4097.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  4098.                     }
  4099.                 } else {
  4100.                     $modelToDisable = (int)($data['extraction_model'] ?: ($modulosPrev['extraction_model'] ?? 0));
  4101.                     if ($modelToDisable 0) {
  4102.                         try {
  4103.                             $this->disableEnrichersForFullModel($mysqli$modelToDisable);
  4104.                         } catch (\Throwable $e) {
  4105.                             $this->addFlash('warning''No se pudo desactivar el enricher: ' $e->getMessage());
  4106.                         }
  4107.                     }
  4108.                 }
  4109.             } elseif ((int)($modulosPrev['extraction_model'] ?? 0) > 0) {
  4110.                 try {
  4111.                     $this->disableEnrichersForFullModel($mysqli, (int)$modulosPrev['extraction_model']);
  4112.                 } catch (\Throwable $e) {
  4113.                     $this->addFlash('warning''No se pudo desactivar el enricher: ' $e->getMessage());
  4114.                 }
  4115.             }
  4116.             $this->updateEmpresaParametros($mysqli$data);
  4117.             if ((int)($data['modulo_gstock'] ?? 0) === && (int)($data['extraction_model'] ?? 0) > 0) {
  4118.                 try {
  4119.                     $this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
  4120.                 } catch (\Throwable $e) {
  4121.                     $this->addFlash('warning''No se pudo completar el automapeo de Gstock: ' $e->getMessage());
  4122.                 }
  4123.             }
  4124.             $this->updateLicense(
  4125.                 $mysqli,
  4126.                 $data,
  4127.                 $empresa->getName()
  4128.             );
  4129.             // Actualizar servicio OCR (hilos + binario segun modo OCR)
  4130.             $ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
  4131.             $ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
  4132.             $ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
  4133.             $ocrMode = ($data['ocr_mode'] ?? 'base');
  4134.             if ($ocrMode === 'plus_glm') {
  4135.                 $ocrBinary $ocrBinaryPlus;
  4136.             } elseif ($ocrMode === 'v2_zxing') {
  4137.                 $ocrBinary $ocrBinaryV2;
  4138.             } else {
  4139.                 $ocrBinary $ocrBinaryBase;
  4140.             }
  4141.             $filesPath = (string)($_ENV['FILES_PATH'] ?? '');
  4142.             if ($ocrBinary !== '' && $filesPath !== '') {
  4143.                 $companyId = (int)$empresa->getId();
  4144.                 $dbHost = (string)$cx->getDbUrl();
  4145.                 $dbName = (string)$cx->getDbName();
  4146.                 $dbUser = (string)$cx->getDbUser();
  4147.                 $dbPass = (string)$cx->getDbPassword();
  4148.                 $empresaName = (string)$empresa->getName();
  4149.                 $serviceContent = <<<EOT
  4150. [Unit]
  4151. Description={$empresaName} DocuManager OCR
  4152. Requires=mariadb.service
  4153. After=mariadb.service
  4154. [Service]
  4155. Type=simple
  4156. Environment="DOCU_MAX_THREADS=$maxThreads"
  4157. ExecStart=$ocrBinary {$dbHost}/{$dbName} {$dbUser} {$dbPass} {$filesPath}/{$companyId} NO
  4158. Restart=always
  4159. User=root
  4160. [Install]
  4161. WantedBy=multi-user.target
  4162. EOT;
  4163.                 $serviceName $companyId "-documanager.service";
  4164.                 $tmpServicePath "/tmp/$serviceName";
  4165.                 file_put_contents($tmpServicePath$serviceContent);
  4166.                 \chmod($tmpServicePath0644);
  4167.                 $cmds = [
  4168.                     "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  4169.                     "sudo /bin/systemctl daemon-reload",
  4170.                     "sudo /bin/systemctl restart $serviceName",
  4171.                 ];
  4172.                 $serviceErrors = [];
  4173.                 foreach ($cmds as $cmd) {
  4174.                     $output \shell_exec($cmd " 2>&1");
  4175.                     if ($output !== null && trim($output) !== '') {
  4176.                         error_log("CMD OUTPUT: $cmd\n$output");
  4177.                         $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  4178.                     }
  4179.                 }
  4180.                 if (count($serviceErrors) > 0) {
  4181.                     $this->addFlash('warning''Servicio OCR actualizado con avisos: ' implode(' | '$serviceErrors));
  4182.                 }
  4183.             } else {
  4184.                 if (($data['ocr_mode'] ?? 'base') === 'plus_glm') {
  4185.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_PLUS_BINARY o FILES_PATH.');
  4186.                 } elseif (($data['ocr_mode'] ?? 'base') === 'v2_zxing') {
  4187.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_BINARY_V2 o FILES_PATH.');
  4188.                 } else {
  4189.                     $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_BINARY o FILES_PATH.');
  4190.                 }
  4191.             }
  4192.             // Si se activa Mail Monitor y antes estaba desactivado, crear servicio/timer
  4193.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === && $filesPath !== '') {
  4194.                 $this->ensureMailMonitorService(
  4195.                     (int)$empresa->getId(),
  4196.                     (string)$cx->getDbUrl(),
  4197.                     (string)$cx->getDbPort(),
  4198.                     (string)$cx->getDbUser(),
  4199.                     (string)$cx->getDbPassword(),
  4200.                     (string)$cx->getDbName(),
  4201.                     (string)$filesPath
  4202.                 );
  4203.             }
  4204.             // Si se desactiva Mail Monitor, parar y eliminar service/timer
  4205.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === 0) {
  4206.                 $this->disableMailMonitorService((int)$empresa->getId());
  4207.             }
  4208.             $em->flush();
  4209.             $mysqli->close();
  4210.             // Mensaje de éxito
  4211.             $this->addFlash('success''Empresa editada correctamente.');
  4212.             return $this->redirectToRoute('app_empresa_show', ['id' => $id]);
  4213.         }
  4214.         // 4) GET: precargar desde BD del cliente
  4215.         [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  4216.         $license $this->loadLicense($mysqli);
  4217.         $extractorType self::EXTRACTOR_TYPE_AZURE_DI;
  4218.         $selectedAoaiResourceId 0;
  4219.         $aoaiFields = [];
  4220.         $enricher = [
  4221.             'enabled' => 0,
  4222.             'enricher_model_id' => 0,
  4223.             'aoai_resource_id' => 0,
  4224.             'fields' => [],
  4225.         ];
  4226.         if (!empty($modulos['extraction_model'])) {
  4227.             try {
  4228.                 $stmtCurrentModel $mysqli->prepare("SELECT provider FROM extraction_models WHERE id = ? AND mode = 'full_extract' LIMIT 1");
  4229.                 if ($stmtCurrentModel) {
  4230.                     $currentModelId = (int)$modulos['extraction_model'];
  4231.                     $stmtCurrentModel->bind_param('i'$currentModelId);
  4232.                     if ($stmtCurrentModel->execute()) {
  4233.                         $resCurrentModel $stmtCurrentModel->get_result();
  4234.                         if ($resCurrentModel && ($modelRow $resCurrentModel->fetch_assoc())) {
  4235.                             $provider strtolower(trim((string)($modelRow['provider'] ?? '')));
  4236.                             if ($provider === 'azure_openai' || $provider === 'azure-openai') {
  4237.                                 $extractorType self::EXTRACTOR_TYPE_AZURE_OPENAI;
  4238.                             }
  4239.                         }
  4240.                     }
  4241.                     $stmtCurrentModel->close();
  4242.                 }
  4243.             } catch (\Throwable $e) {
  4244.                 $extractorType self::EXTRACTOR_TYPE_AZURE_DI;
  4245.             }
  4246.         }
  4247.         if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI && !empty($modulos['extraction_model'])) {
  4248.             try {
  4249.                 $modelId = (int)$modulos['extraction_model'];
  4250.                 $selectedAoaiResourceId $this->resolveAoaiResourceIdForModel($mysqli$modelId$em);
  4251.                 $aoaiFields $this->loadAoaiFieldsForModel($mysqli$modelId);
  4252.             } catch (\Throwable $e) {
  4253.                 $aoaiFields = [];
  4254.             }
  4255.         }
  4256.         if (!empty($modulos['extraction_model'])) {
  4257.             try {
  4258.                 $enricher $this->loadEnricherForFullModel($mysqli, (int)$modulos['extraction_model'], $em);
  4259.             } catch (\Throwable $e) {
  4260.                 $enricher = [
  4261.                     'enabled' => 0,
  4262.                     'enricher_model_id' => 0,
  4263.                     'aoai_resource_id' => 0,
  4264.                     'fields' => [],
  4265.                 ];
  4266.             }
  4267.         }
  4268.         $extractionModels = [];
  4269.         try {
  4270.             $res $mysqli->query("SELECT id, model_id, provider FROM extraction_models WHERE mode = 'full_extract' AND provider IN ('azure-di', 'azure_openai', 'azure-openai') ORDER BY model_id");
  4271.             if ($res) {
  4272.                 while ($row $res->fetch_assoc()) {
  4273.                     $extractionModels[] = [
  4274.                         'id' => (int)$row['id'],
  4275.                         'model_id' => (string)$row['model_id'],
  4276.                         'provider' => (string)($row['provider'] ?? ''),
  4277.                     ];
  4278.                 }
  4279.                 $res->free();
  4280.             }
  4281.         } catch (\Throwable $e) {
  4282.             $extractionModels = [];
  4283.         }
  4284.         $mysqli->close();
  4285.         return $this->render('empresa/_edit.html.twig', [
  4286.             'empresa'      => $empresa,   // central: name + maxDiskQuota
  4287.             'id'           => $id,
  4288.             'activeUsers'  => $activeUsers// cliente
  4289.             'modulos'      => $modulos,     // cliente
  4290.             'license'      => $license,     // opcional
  4291.             'azure_resources' => $azureResources,
  4292.             'azure_di_resources' => $azureDiResources,
  4293.             'azure_openai_resources' => $azureOpenAiResources,
  4294.             'extraction_models' => $extractionModels,
  4295.             'form_data' => [
  4296.                 'extractor_type' => $extractorType,
  4297.                 'aoai_resource_id' => $selectedAoaiResourceId,
  4298.                 'ocr_mode' => $this->normalizeOcrMode($modulos['ocr_mode'] ?? 'base'),
  4299.             ],
  4300.             'aoai_fields' => $aoaiFields,
  4301.             'enricher' => $enricher,
  4302.             'ocr_plus_available' => $ocrPlusAvailable,
  4303.             'ocr_v2_available' => $ocrV2Available,
  4304.         ]);
  4305.     }
  4306.     public function empresaLicenseEdit(Request $requestEntityManagerInterface $em)
  4307.     {
  4308.         if (!$this->getUser() || !is_object($this->getUser())) {
  4309.             return $this->redirectToRoute('logout');
  4310.         }
  4311.         $id = (int)$request->get('id');
  4312.         $empresa $em->getRepository(Empresa::class)->find($id);
  4313.         if (!$empresa) {
  4314.             throw $this->createNotFoundException('Empresa no encontrada');
  4315.         }
  4316.         $cx $empresa->getConexionBD();
  4317.         $mysqli = @new \mysqli(
  4318.             $cx->getDbUrl(),
  4319.             $cx->getDbUser(),
  4320.             $cx->getDbPassword(),
  4321.             $cx->getDbName(),
  4322.             (int)$cx->getDbPort()
  4323.         );
  4324.         if ($mysqli->connect_error) {
  4325.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  4326.             return $this->redirectToRoute('list');
  4327.         }
  4328.         $editContractId max(0, (int)$request->query->get('edit_contract_id'0));
  4329.         $editLicenseFormData = [];
  4330.         if ($editContractId 0) {
  4331.             $editContract $this->licenseContractService->loadContractById($mysqli$editContractId);
  4332.             if (!empty($editContract)) {
  4333.                 $editLicenseFormData = [
  4334.                     'license_type' => (string)($editContract['type'] ?? 'page'),
  4335.                     'license_mode' => (string)($editContract['mode'] ?? 'monthly'),
  4336.                     'license_limit_mode' => (string)($editContract['limit_mode'] ?? 'block'),
  4337.                     'license_units_total' => (string)($editContract['units_total'] ?? '0'),
  4338.                     'license_units_used' => (string)($editContract['units_used'] ?? '0'),
  4339.                     'license_units_exceeded' => (string)($editContract['units_exceeded'] ?? '0'),
  4340.                     'license_start_date' => (string)($editContract['start_date'] ?? ''),
  4341.                     'license_end_date' => (string)($editContract['end_date'] ?? ''),
  4342.                 ];
  4343.             } else {
  4344.                 $editContractId 0;
  4345.                 $this->addFlash('warning''La licencia indicada no existe.');
  4346.             }
  4347.         }
  4348.         $response $this->renderEmpresaLicensePage($empresa$mysqli$editContractId $editContractId null$editLicenseFormData);
  4349.         $mysqli->close();
  4350.         return $response;
  4351.     }
  4352.     public function empresaLicenseUpdate(Request $requestEntityManagerInterface $em)
  4353.     {
  4354.         if (!$this->getUser() || !is_object($this->getUser())) {
  4355.             return $this->redirectToRoute('logout');
  4356.         }
  4357.         $id = (int)$request->get('id');
  4358.         $contractId = (int)$request->get('contractId');
  4359.         $empresa $em->getRepository(Empresa::class)->find($id);
  4360.         if (!$empresa) {
  4361.             throw $this->createNotFoundException('Empresa no encontrada');
  4362.         }
  4363.         $cx $empresa->getConexionBD();
  4364.         $mysqli = @new \mysqli(
  4365.             $cx->getDbUrl(),
  4366.             $cx->getDbUser(),
  4367.             $cx->getDbPassword(),
  4368.             $cx->getDbName(),
  4369.             (int)$cx->getDbPort()
  4370.         );
  4371.         if ($mysqli->connect_error) {
  4372.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  4373.             return $this->redirectToRoute('list');
  4374.         }
  4375.         $data $request->request->all();
  4376.         try {
  4377.             $normalizedData $this->licenseContractService->normalizePayload($datatrue);
  4378.             $this->licenseContractService->updateContract($mysqli$contractId$normalizedData);
  4379.             $this->addFlash('success''Licencia actualizada correctamente.');
  4380.             $mysqli->close();
  4381.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  4382.         } catch (\InvalidArgumentException $e) {
  4383.             $this->addFlash('danger'$e->getMessage());
  4384.         } catch (\Throwable $e) {
  4385.             $this->addFlash('danger''Error al actualizar la licencia: ' $e->getMessage());
  4386.         }
  4387.         $response $this->renderEmpresaLicensePage($empresa$mysqli$contractId$data);
  4388.         $mysqli->close();
  4389.         return $response;
  4390.     }
  4391.     public function empresaLicenseDelete(Request $requestEntityManagerInterface $em)
  4392.     {
  4393.         if (!$this->getUser() || !is_object($this->getUser())) {
  4394.             return $this->redirectToRoute('logout');
  4395.         }
  4396.         $id = (int)$request->get('id');
  4397.         $contractId = (int)$request->get('contractId');
  4398.         $empresa $em->getRepository(Empresa::class)->find($id);
  4399.         if (!$empresa) {
  4400.             throw $this->createNotFoundException('Empresa no encontrada');
  4401.         }
  4402.         $cx $empresa->getConexionBD();
  4403.         $mysqli = @new \mysqli(
  4404.             $cx->getDbUrl(),
  4405.             $cx->getDbUser(),
  4406.             $cx->getDbPassword(),
  4407.             $cx->getDbName(),
  4408.             (int)$cx->getDbPort()
  4409.         );
  4410.         if ($mysqli->connect_error) {
  4411.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  4412.             return $this->redirectToRoute('list');
  4413.         }
  4414.         try {
  4415.             $this->licenseContractService->deleteContract($mysqli$contractId);
  4416.             $this->addFlash('success''Licencia eliminada correctamente.');
  4417.         } catch (\Throwable $e) {
  4418.             $this->addFlash('danger''Error al eliminar la licencia: ' $e->getMessage());
  4419.         }
  4420.         $mysqli->close();
  4421.         return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  4422.     }
  4423.     public function empresaLicenseExport(Request $requestEntityManagerInterface $em)
  4424.     {
  4425.         if (!$this->getUser() || !is_object($this->getUser())) {
  4426.             return $this->redirectToRoute('logout');
  4427.         }
  4428.         $id = (int)$request->get('id');
  4429.         $empresa $em->getRepository(Empresa::class)->find($id);
  4430.         if (!$empresa) {
  4431.             throw $this->createNotFoundException('Empresa no encontrada');
  4432.         }
  4433.         try {
  4434.             $mysqli $this->openEmpresaMysqli($empresa);
  4435.         } catch (\Throwable $e) {
  4436.             $this->addFlash('danger'$e->getMessage());
  4437.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  4438.         }
  4439.         try {
  4440.             $rows $this->buildTenantLicenseExportRows(
  4441.                 $empresa,
  4442.                 $mysqli
  4443.             );
  4444.         } catch (\Throwable $e) {
  4445.             $mysqli->close();
  4446.             $this->addFlash('danger''Error al exportar licencias: ' $e->getMessage());
  4447.             return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  4448.         }
  4449.         $mysqli->close();
  4450.         return $this->createLicenseCsvResponse(
  4451.             sprintf('license-history-tenant-%d.csv'$id),
  4452.             $this->tenantLicenseExportColumns(),
  4453.             $rows
  4454.         );
  4455.     }
  4456.     public function empresaLicenseExportAll(Request $requestEntityManagerInterface $em)
  4457.     {
  4458.         if (!$this->getUser() || !is_object($this->getUser())) {
  4459.             return $this->redirectToRoute('logout');
  4460.         }
  4461.         try {
  4462.             $range $this->licenseContractService->validateExportRange(
  4463.                 $request->query->get('export_start_date'),
  4464.                 $request->query->get('export_end_date')
  4465.             );
  4466.         } catch (\InvalidArgumentException $e) {
  4467.             $this->addFlash('danger'$e->getMessage());
  4468.             return $this->redirectToRoute('list');
  4469.         }
  4470.         $rows = [];
  4471.         $empresas $em->getRepository(Empresa::class)->findAll();
  4472.         foreach ($empresas as $empresa) {
  4473.             try {
  4474.                 $mysqli $this->openEmpresaMysqli($empresa);
  4475.                 try {
  4476.                     $tenantRows $this->buildTenantLicenseSummaryRows(
  4477.                         $empresa,
  4478.                         $mysqli,
  4479.                         $range['start_date'],
  4480.                         $range['end_date']
  4481.                     );
  4482.                     foreach ($tenantRows as $row) {
  4483.                         $rows[] = $row;
  4484.                     }
  4485.                 } finally {
  4486.                     $mysqli->close();
  4487.                 }
  4488.             } catch (\Throwable $e) {
  4489.                 $rows[] = $this->createTenantConnectionErrorRow(
  4490.                     $empresa,
  4491.                     $range['start_date'],
  4492.                     $range['end_date']
  4493.                 );
  4494.             }
  4495.         }
  4496.         return $this->createLicenseCsvResponse(
  4497.             sprintf(
  4498.                 'license-summary-all-tenants-%s-to-%s.csv',
  4499.                 $range['start_date'],
  4500.                 $range['end_date']
  4501.             ),
  4502.             $this->licenseSummaryExportColumns(),
  4503.             $rows
  4504.         );
  4505.     }
  4506.     private function renderEmpresaLicensePage(Empresa $empresa\mysqli $mysqli, ?int $editContractId null, array $editLicenseFormData = [])
  4507.     {
  4508.         $activeLicenseContract $this->licenseContractService->loadActiveContract($mysqli);
  4509.         $licenseContractHistory $this->licenseContractService->loadContractHistory($mysqli);
  4510.         $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($licenseContractHistory);
  4511.         return $this->render('empresa/licenses.html.twig', [
  4512.             'empresa' => $empresa,
  4513.             'activeLicenseContract' => $activeLicenseContract,
  4514.             'licenseContractHistory' => $licenseContractHistory,
  4515.             'visualActiveContractId' => $visualActiveContractId,
  4516.             'editContractId' => $editContractId,
  4517.             'editLicenseFormData' => $editLicenseFormData,
  4518.         ]);
  4519.     }
  4520.     public function empresaLicenseCreate(Request $requestEntityManagerInterface $em)
  4521.     {
  4522.         if (!$this->getUser() || !is_object($this->getUser())) {
  4523.             return $this->redirectToRoute('logout');
  4524.         }
  4525.         $id = (int)$request->get('id');
  4526.         $empresa $em->getRepository(Empresa::class)->find($id);
  4527.         if (!$empresa) {
  4528.             throw $this->createNotFoundException('Empresa no encontrada');
  4529.         }
  4530.         $cx $empresa->getConexionBD();
  4531.         $mysqli = @new \mysqli(
  4532.             $cx->getDbUrl(),
  4533.             $cx->getDbUser(),
  4534.             $cx->getDbPassword(),
  4535.             $cx->getDbName(),
  4536.             (int)$cx->getDbPort()
  4537.         );
  4538.         if ($mysqli->connect_error) {
  4539.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  4540.             return $this->redirectToRoute('list');
  4541.         }
  4542.         $data $request->request->all();
  4543.         try {
  4544.             $normalizedData $this->licenseContractService->normalizePayload($datafalse);
  4545.             $this->licenseContractService->createNewContract($mysqli$normalizedData);
  4546.             $this->addFlash('success''Nueva licencia creada correctamente.');
  4547.         } catch (\InvalidArgumentException $e) {
  4548.             $this->addFlash('danger'$e->getMessage());
  4549.         } catch (\Throwable $e) {
  4550.             $this->addFlash('danger''Error al crear la licencia: ' $e->getMessage());
  4551.         }
  4552.         $mysqli->close();
  4553.         return $this->redirectToRoute('app_empresa_licenses', ['id' => $id]);
  4554.     }
  4555.     private function openEmpresaMysqli(Empresa $empresa): \mysqli
  4556.     {
  4557.         $cx $empresa->getConexionBD();
  4558.         if (!$cx) {
  4559.             throw new \RuntimeException('La empresa no tiene configurada una conexion de base de datos.');
  4560.         }
  4561.         $mysqli = @new \mysqli(
  4562.             $cx->getDbUrl(),
  4563.             $cx->getDbUser(),
  4564.             $cx->getDbPassword(),
  4565.             $cx->getDbName(),
  4566.             (int)$cx->getDbPort()
  4567.         );
  4568.         if ($mysqli->connect_error) {
  4569.             throw new \RuntimeException('No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  4570.         }
  4571.         return $mysqli;
  4572.     }
  4573.     private function tenantLicenseExportColumns(): array
  4574.     {
  4575.         return [
  4576.             'tenant_id',
  4577.             'tenant_name',
  4578.             'license_status',
  4579.             'contract_type',
  4580.             'contract_mode',
  4581.             'limit_mode',
  4582.             'units_total',
  4583.             'units_used',
  4584.             'units_exceeded',
  4585.             'contract_start_date',
  4586.             'contract_end_date',
  4587.         ];
  4588.     }
  4589.     private function licenseSummaryExportColumns(): array
  4590.     {
  4591.         return [
  4592.             'tenant_id',
  4593.             'tenant_name',
  4594.             'range_start',
  4595.             'range_end',
  4596.             'contract_type',
  4597.             'contract_mode',
  4598.             'limit_mode',
  4599.             'units_total',
  4600.             'units_used',
  4601.             'units_exceeded',
  4602.         ];
  4603.     }
  4604.     private function buildTenantLicenseExportRows(Empresa $empresa\mysqli $mysqli): array
  4605.     {
  4606.         $history $this->licenseContractService->loadContractHistory($mysqli);
  4607.         $visualActiveContractId $this->licenseContractService->resolveVisualActiveContractId($history);
  4608.         $rows = [];
  4609.         foreach ($history as $contract) {
  4610.             $rows[] = [
  4611.                 'tenant_id' => (string)$empresa->getId(),
  4612.                 'tenant_name' => (string)$empresa->getName(),
  4613.                 'license_status' => ((int)($contract['id'] ?? 0) === (int)$visualActiveContractId) ? 'Activa' 'Cerrada',
  4614.                 'contract_type' => (string)($contract['type'] ?? ''),
  4615.                 'contract_mode' => (string)($contract['mode'] ?? ''),
  4616.                 'limit_mode' => (string)($contract['limit_mode'] ?? ''),
  4617.                 'units_total' => (string)max(0, (int)($contract['units_total'] ?? 0)),
  4618.                 'units_used' => (string)max(0, (int)($contract['units_used'] ?? 0)),
  4619.                 'units_exceeded' => (string)max(0, (int)($contract['units_exceeded'] ?? 0)),
  4620.                 'contract_start_date' => (string)($contract['start_date'] ?? ''),
  4621.                 'contract_end_date' => (string)($contract['end_date'] ?? ''),
  4622.             ];
  4623.         }
  4624.         return $rows;
  4625.     }
  4626.     private function buildTenantLicenseSummaryRows(Empresa $empresa\mysqli $mysqlistring $rangeStartstring $rangeEnd): array
  4627.     {
  4628.         $contracts $this->licenseContractService->loadContractsStartingWithinRange($mysqli$rangeStart$rangeEnd);
  4629.         $aggregatedRows = [];
  4630.         foreach ($contracts as $contract) {
  4631.             $contractType trim((string)($contract['type'] ?? ''));
  4632.             $contractMode trim((string)($contract['mode'] ?? ''));
  4633.             $limitMode trim((string)($contract['limit_mode'] ?? ''));
  4634.             $groupKey implode('|', [
  4635.                 $empresa->getId(),
  4636.                 $contractType,
  4637.                 $contractMode,
  4638.                 $limitMode,
  4639.             ]);
  4640.             if (!isset($aggregatedRows[$groupKey])) {
  4641.                 $aggregatedRows[$groupKey] = [
  4642.                     'tenant_id' => (string)$empresa->getId(),
  4643.                     'tenant_name' => (string)$empresa->getName(),
  4644.                     'range_start' => $rangeStart,
  4645.                     'range_end' => $rangeEnd,
  4646.                     'contract_type' => $contractType,
  4647.                     'contract_mode' => $contractMode,
  4648.                     'limit_mode' => $limitMode,
  4649.                     'units_total' => 0,
  4650.                     'units_used' => 0,
  4651.                     'units_exceeded' => 0,
  4652.                 ];
  4653.             }
  4654.             $aggregatedRows[$groupKey]['units_total'] += max(0, (int)($contract['units_total'] ?? 0));
  4655.             $aggregatedRows[$groupKey]['units_used'] += max(0, (int)($contract['units_used'] ?? 0));
  4656.             $aggregatedRows[$groupKey]['units_exceeded'] += max(0, (int)($contract['units_exceeded'] ?? 0));
  4657.         }
  4658.         $rows = [];
  4659.         foreach ($aggregatedRows as $row) {
  4660.             $rows[] = [
  4661.                 'tenant_id' => (string)$empresa->getId(),
  4662.                 'tenant_name' => (string)$empresa->getName(),
  4663.                 'range_start' => $rangeStart,
  4664.                 'range_end' => $rangeEnd,
  4665.                 'contract_type' => (string)$row['contract_type'],
  4666.                 'contract_mode' => (string)$row['contract_mode'],
  4667.                 'limit_mode' => (string)$row['limit_mode'],
  4668.                 'units_total' => (string)$row['units_total'],
  4669.                 'units_used' => (string)$row['units_used'],
  4670.                 'units_exceeded' => (string)$row['units_exceeded'],
  4671.             ];
  4672.         }
  4673.         return $rows;
  4674.     }
  4675.     private function createTenantConnectionErrorRow(Empresa $empresastring $rangeStartstring $rangeEnd): array
  4676.     {
  4677.         return [
  4678.             'tenant_id' => (string)$empresa->getId(),
  4679.             'tenant_name' => (string)$empresa->getName(),
  4680.             'range_start' => $rangeStart,
  4681.             'range_end' => $rangeEnd,
  4682.             'contract_type' => '',
  4683.             'contract_mode' => '',
  4684.             'limit_mode' => '',
  4685.             'units_total' => '',
  4686.             'units_used' => '',
  4687.             'units_exceeded' => '',
  4688.         ];
  4689.     }
  4690.     private function createLicenseCsvResponse(string $filename, array $columns, array $rows): StreamedResponse
  4691.     {
  4692.         $response = new StreamedResponse(function () use ($columns$rows): void {
  4693.             $out fopen('php://output''wb');
  4694.             if ($out === false) {
  4695.                 throw new \RuntimeException('No se pudo abrir la salida CSV.');
  4696.             }
  4697.             fwrite($out"\xEF\xBB\xBF");
  4698.             fputcsv($out$columns';');
  4699.             foreach ($rows as $row) {
  4700.                 $csvRow = [];
  4701.                 foreach ($columns as $column) {
  4702.                     $csvRow[] = (string)($row[$column] ?? '');
  4703.                 }
  4704.                 fputcsv($out$csvRow';');
  4705.             }
  4706.             fclose($out);
  4707.         });
  4708.         $response->headers->set('Content-Type''text/csv; charset=UTF-8');
  4709.         $response->headers->set('Content-Disposition''attachment; filename="' $filename '"');
  4710.         return $response;
  4711.     }
  4712.     private function loadEmpresaParametros(\mysqli $mysqli): array
  4713.     {
  4714.         // claves que nos interesan en la tabla parametros
  4715.         $keys = [
  4716.             'activeUsers',
  4717.             'soloExtraccion',
  4718.             'modulo_etiquetas',
  4719.             'modulo_calendario',
  4720.             'modulo_calExt',
  4721.             'modulo_estados',
  4722.             'modulo_subida',
  4723.             'modulo_mailMonitor',
  4724.             'modulo_busquedaNatural',
  4725.             'modulo_extraccion',
  4726.             'modulo_lineas',
  4727.             'modulo_conciliacion',
  4728.             'modulo_precios',
  4729.             'modulo_ubikos',
  4730.             'modulo_agora',
  4731.             'modulo_gstock',
  4732.             'modulo_expowin',
  4733.             'modulo_prinex',
  4734.             'extraction_model',
  4735.             'ocr_mode',
  4736.             'tokensContratados',
  4737.             'tokensUsados',
  4738.         ];
  4739.         $placeholders implode(','array_fill(0count($keys), '?'));
  4740.         $sql "SELECT nombre, valor FROM parametros WHERE nombre IN ($placeholders)";
  4741.         $stmt $mysqli->prepare($sql);
  4742.         if (!$stmt) {
  4743.             throw new \RuntimeException('Prepare SELECT parametros: ' $mysqli->error);
  4744.         }
  4745.         // bind dinámico
  4746.         $types str_repeat('s'count($keys));
  4747.         $stmt->bind_param($types, ...$keys);
  4748.         if (!$stmt->execute()) {
  4749.             $stmt->close();
  4750.             throw new \RuntimeException('Execute SELECT parametros: ' $stmt->error);
  4751.         }
  4752.         $res $stmt->get_result();
  4753.         $map = [];
  4754.         while ($row $res->fetch_assoc()) {
  4755.             $map[$row['nombre']] = $row['valor'];
  4756.         }
  4757.         $stmt->close();
  4758.         // defaults seguros
  4759.         $activeUsers = isset($map['activeUsers']) ? (int)$map['activeUsers'] : 3;
  4760.         $flags = [
  4761.             'soloExtraccion'   => isset($map['soloExtraccion'])   ? (int)$map['soloExtraccion']   : 0,
  4762.             'modulo_etiquetas'  => isset($map['modulo_etiquetas'])  ? (int)$map['modulo_etiquetas']  : 0,
  4763.             'modulo_calendario' => isset($map['modulo_calendario']) ? (int)$map['modulo_calendario'] : 0,
  4764.             'modulo_calExt'     => isset($map['modulo_calExt'])     ? (int)$map['modulo_calExt']     : 0,
  4765.             'modulo_estados'    => isset($map['modulo_estados'])    ? (int)$map['modulo_estados']    : 0,
  4766.             'modulo_subida'     => isset($map['modulo_subida'])     ? (int)$map['modulo_subida']     : 0,
  4767.             'modulo_extraccion' => isset($map['modulo_extraccion']) ? (int)$map['modulo_extraccion'] : 0,
  4768.             'modulo_lineas'     => isset($map['modulo_lineas'])     ? (int)$map['modulo_lineas']     : 0,
  4769.             'modulo_conciliacion' => isset($map['modulo_conciliacion']) ? (int)$map['modulo_conciliacion'] : 0,
  4770.             'modulo_precios'    => isset($map['modulo_precios'])    ? (int)$map['modulo_precios']    : 0,
  4771.             'modulo_ubikos'     => isset($map['modulo_ubikos'])     ? (int)$map['modulo_ubikos']     : 0,
  4772.             'modulo_agora'      => isset($map['modulo_agora'])      ? (int)$map['modulo_agora']      : 0,
  4773.             'modulo_gstock'     => isset($map['modulo_gstock'])     ? (int)$map['modulo_gstock']     : 0,
  4774.             'modulo_expowin'    => isset($map['modulo_expowin'])    ? (int)$map['modulo_expowin']    : 0,
  4775.             'modulo_prinex'     => isset($map['modulo_prinex'])     ? (int)$map['modulo_prinex']     : 0,
  4776.             'modulo_mailMonitor' => isset($map['modulo_mailMonitor']) ? (int)$map['modulo_mailMonitor'] : 0,
  4777.             'modulo_busquedaNatural' => isset($map['modulo_busquedaNatural']) ? (int)$map['modulo_busquedaNatural'] : 0,
  4778.         ];
  4779.         // extraction_model: default 0 (sin modelo)
  4780.         $flags['extraction_model'] = isset($map['extraction_model']) && $map['extraction_model'] !== '' ? (int)$map['extraction_model'] : 0;
  4781.         $flags['ocr_mode'] = $this->normalizeOcrMode($map['ocr_mode'] ?? 'base');
  4782.         $flags['tokensContratados'] = isset($map['tokensContratados']) && $map['tokensContratados'] !== ''
  4783.             max(0, (int)$map['tokensContratados'])
  4784.             : 0;
  4785.         $flags['tokensUsados'] = isset($map['tokensUsados']) && $map['tokensUsados'] !== ''
  4786.             max(0, (int)$map['tokensUsados'])
  4787.             : 0;
  4788.         return [$activeUsers$flags];
  4789.     }
  4790.     private function updateEmpresaParametros(\mysqli $mysqli, array $data): void
  4791.     {
  4792.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  4793.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  4794.         $paramMap = [
  4795.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  4796.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  4797.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  4798.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  4799.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  4800.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  4801.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  4802.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  4803.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  4804.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  4805.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  4806.             'modulo_conciliacion' => $getFlag($data'modulo_conciliacion'),
  4807.             'modulo_precios'     => $getFlag($data'modulo_precios'),
  4808.             'modulo_ubikos'      => $getFlag($data'modulo_ubikos'),
  4809.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  4810.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  4811.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  4812.             'modulo_prinex'      => $getFlag($data'modulo_prinex'),
  4813.             'extraction_model'   => $getInt($data'extraction_model'0),
  4814.             'ocr_mode'           => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
  4815.             'tokensContratados'  => max(0$getInt($data'tokensContratados'0)),
  4816.         ];
  4817.         if ($paramMap['modulo_extraccion'] === 0) {
  4818.             $paramMap['modulo_lineas'] = 0;
  4819.             $paramMap['modulo_conciliacion'] = 0;
  4820.             $paramMap['modulo_precios'] = 0;
  4821.             $paramMap['modulo_ubikos'] = 0;
  4822.             $paramMap['extraction_model'] = 0;
  4823.             $paramMap['modulo_agora'] = 0;
  4824.             $paramMap['modulo_gstock'] = 0;
  4825.             $paramMap['modulo_expowin'] = 0;
  4826.             $paramMap['modulo_prinex'] = 0;
  4827.         }
  4828.         if ($paramMap['modulo_lineas'] === 0) {
  4829.             $paramMap['modulo_precios'] = 0;
  4830.         }
  4831.         if ($paramMap['modulo_calendario'] === 0) {
  4832.             $paramMap['modulo_calExt'] = 0;
  4833.         }
  4834.         $mysqli->begin_transaction();
  4835.         try {
  4836.             $stmt $mysqli->prepare("
  4837.                 INSERT INTO parametros (nombre, valor)
  4838.                 VALUES (?, ?)
  4839.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  4840.             ");
  4841.             if (!$stmt) {
  4842.                 throw new \RuntimeException('Prepare UPSERT parametros: ' $mysqli->error);
  4843.             }
  4844.             foreach ($paramMap as $nombre => $valor) {
  4845.                 $v = (string)$valor;
  4846.                 if (!$stmt->bind_param('ss'$nombre$v) || !$stmt->execute()) {
  4847.                     $stmt->close();
  4848.                     throw new \RuntimeException("Guardar parámetro {$nombre}{$stmt->error}");
  4849.                 }
  4850.             }
  4851.             $stmt->close();
  4852.             $mysqli->commit();
  4853.         } catch (\Throwable $e) {
  4854.             $mysqli->rollback();
  4855.             throw $e;
  4856.         }
  4857.     }
  4858.     private function loadLicense(\mysqli $mysqli): array
  4859.     {
  4860.         // Carga opcional para mostrar en la edición: capacityGb/users (u otros)
  4861.         $sql "SELECT client, capacityGb, users FROM license LIMIT 1";
  4862.         $res $mysqli->query($sql);
  4863.         if ($res && $row $res->fetch_assoc()) {
  4864.             return $row;
  4865.         }
  4866.         return [];
  4867.     }
  4868.     private function updateLicense(\mysqli $mysqli, array $datastring $clientName): void
  4869.     {
  4870.         $capacityGb  = isset($data['maxDiskQuota']) ? (int)$data['maxDiskQuota'] : 200;
  4871.         $activeUsers = isset($data['maxActiveUsers']) ? (int)$data['maxActiveUsers'] : 3;
  4872.         $stmt $mysqli->prepare("UPDATE license SET capacityGb = ?, users = ? WHERE client = ?");
  4873.         if (!$stmt) {
  4874.             throw new \RuntimeException('Prepare UPDATE license: ' $mysqli->error);
  4875.         }
  4876.         $stmt->bind_param('iis'$capacityGb$activeUsers$clientName);
  4877.         $stmt->execute();
  4878.         $stmt->close();
  4879.     }
  4880.     public function usersListEmpresa(Request $requestEntityManagerInterface $em)
  4881.     {
  4882.         $id $request->get("id");
  4883.         $users_empresa $em->getRepository(Usuario::class)->findBy(array("empresa" => $id));
  4884.         return $this->render('empresa/usersList.html.twig', array(
  4885.             'users' => $users_empresa,
  4886.             'id' => $id
  4887.         ));
  4888.     }
  4889.     private function loadEmpresaDiskUsageBytes(\mysqli $mysqli): ?int
  4890.     {
  4891.         // Si la tabla no existe o hay error, devolvemos null para no romper la vista
  4892.         $sql "SELECT COALESCE(SUM(size_bytes), 0) AS total_bytes FROM files";
  4893.         $res $mysqli->query($sql);
  4894.         if (!$res) {
  4895.             return null;
  4896.         }
  4897.         $row $res->fetch_assoc();
  4898.         $res->free();
  4899.         return isset($row['total_bytes']) ? (int)$row['total_bytes'] : 0;
  4900.     }
  4901.     // =======================================================================
  4902.     // VER DOCUMENTACION DE EMPRESAS
  4903.     // =======================================================================
  4904.     #[Route('/empresa/{id}/documentos'name'empresa_documentos'methods: ['GET'])]
  4905.     public function documentosEmpresa(Request $requestEntityManagerInterface $em)
  4906.     {
  4907.         if (!$this->getUser() || !is_object($this->getUser())) {
  4908.             return $this->redirectToRoute('logout');
  4909.         }
  4910.         $id = (int) $request->get('id');
  4911.         $empresa $em->getRepository(Empresa::class)->find($id);
  4912.         if (!$empresa) {
  4913.             $this->addFlash('warning''Empresa no encontrada.');
  4914.             return $this->redirectToRoute('list');
  4915.         }
  4916.         // Para el desplegable
  4917.         $empresas $em->getRepository(Empresa::class)->findAll();
  4918.         $baseUrl $this->getParameter('documanager_base_url');
  4919.         return $this->render('empresa/documentos.html.twig', [
  4920.             'empresaId' => $id,
  4921.             'empresas'  => $empresas,
  4922.             'documanagerBaseUrl' => $baseUrl,
  4923.         ]);
  4924.     }
  4925.     #[Route('/api/empresa/{id}/documentos'name'empresa_documentos_api'methods: ['GET'])]
  4926.     public function documentosEmpresaApi(Request $requestEntityManagerInterface $em): JsonResponse
  4927.     {
  4928.         if (!$this->getUser() || !is_object($this->getUser())) {
  4929.             return new JsonResponse(['error' => 'unauthorized'], 401);
  4930.         }
  4931.         $id = (int) $request->get('id');
  4932.         $empresa $em->getRepository(Empresa::class)->find($id);
  4933.         if (!$empresa) {
  4934.             return new JsonResponse(['error' => 'Empresa no encontrada'], 404);
  4935.         }
  4936.         $cx $empresa->getConexionBD();
  4937.         if (!$cx) {
  4938.             return new JsonResponse(['error' => 'La empresa no tiene conexión configurada'], 400);
  4939.         }
  4940.         $page    max(1, (int) $request->query->get('page'1));
  4941.         $perPage = (int) $request->query->get('per_page'50);
  4942.         $perPage min(max($perPage10), 200);
  4943.         $q       = (string) $request->query->get('q''');
  4944.         $offset  = ($page 1) * $perPage;
  4945.         $mysqli = @new \mysqli(
  4946.             $cx->getDbUrl(),
  4947.             $cx->getDbUser(),
  4948.             $cx->getDbPassword(),
  4949.             $cx->getDbName(),
  4950.             (int) $cx->getDbPort()
  4951.         );
  4952.         if ($mysqli->connect_error) {
  4953.             return new JsonResponse(['error' => 'Error conectando a la BD del cliente: ' $mysqli->connect_error], 500);
  4954.         }
  4955.         $mysqli->set_charset('utf8mb4');
  4956.         // --- TOTAL ---
  4957.         if ($q === '') {
  4958.             $sqlTotal "SELECT COUNT(*) AS c FROM files";
  4959.             $res $mysqli->query($sqlTotal);
  4960.             $row $res $res->fetch_assoc() : null;
  4961.             $total = (int)($row['c'] ?? 0);
  4962.         } else {
  4963.             $sqlTotal "SELECT COUNT(*) AS c
  4964.                          FROM files
  4965.                          WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?";
  4966.             $qLike '%' $q '%';
  4967.             $st $mysqli->prepare($sqlTotal);
  4968.             $st->bind_param('ssss'$qLike$qLike$qLike$qLike);
  4969.             $st->execute();
  4970.             $res $st->get_result();
  4971.             $row $res $res->fetch_assoc() : null;
  4972.             $total = (int)($row['c'] ?? 0);
  4973.             $st->close();
  4974.         }
  4975.         // --- ITEMS ---
  4976.         $items = [];
  4977.         if ($q === '') {
  4978.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  4979.                     FROM files
  4980.                     ORDER BY `date` DESC
  4981.                     LIMIT ? OFFSET ?";
  4982.             $st $mysqli->prepare($sql);
  4983.             $st->bind_param('ii'$perPage$offset);
  4984.         } else {
  4985.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  4986.                     FROM files
  4987.                     WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?
  4988.                     ORDER BY `date` DESC
  4989.                     LIMIT ? OFFSET ?";
  4990.             $qLike '%' $q '%';
  4991.             $st $mysqli->prepare($sql);
  4992.             $st->bind_param('ssssii'$qLike$qLike$qLike$qLike$perPage$offset);
  4993.         }
  4994.         $st->execute();
  4995.         $res $st->get_result();
  4996.         if ($res) {
  4997.             while ($r $res->fetch_assoc()) {
  4998.                 $items[] = $r;
  4999.             }
  5000.         }
  5001.         $st->close();
  5002.         $mysqli->close();
  5003.         return new JsonResponse([
  5004.             'company_id' => $id,
  5005.             'page'       => $page,
  5006.             'per_page'   => $perPage,
  5007.             'total'      => $total,
  5008.             'items'      => $items,
  5009.         ]);
  5010.     }
  5011. }