src/Controller/AdminController.php line 43

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