src/Controller/AdminController.php line 36

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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\Session\Session;
  10. use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  13. use Symfony\Component\HttpFoundation\Request;
  14. use Doctrine\ORM\EntityManagerInterface;
  15. use App\Entity\Empresa;
  16. use App\Entity\Usuario;
  17. use App\Entity\ConexionBD;
  18. use App\Service\SuperuserProvisioningService;
  19. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  20. use Symfony\Component\HttpFoundation\File\UploadedFile;
  21. use Symfony\Component\Routing\Annotation\Route;
  22. /**
  23.  * Description of AdminController
  24.  *
  25.  * @author joseangelparra
  26.  */
  27. class AdminController extends AbstractController
  28. {
  29.     //put your code here
  30.     private const AZURE_DI_API_VERSION '2024-11-30';
  31.     private const SUPERUSER_IS_ADMIN 3;
  32.     private $params;
  33.     public function __construct(ParameterBagInterface $params)
  34.     {
  35.         $this->session = new Session();
  36.         $this->params $params;
  37.     }
  38.     #[Route('/'name'login')]
  39.     public function Login(AuthenticationUtils $authenticationUtils)
  40.     {
  41.         $error $authenticationUtils->getLastAuthenticationError();
  42.         $lastUsername $authenticationUtils->getLastUSername();
  43.         return $this->render('base.html.twig', array(
  44.             'error' => $error,
  45.             'last_username' => $lastUsername
  46.         ));
  47.     }
  48.     public function changePWD(UserPasswordHasherInterface $encoderEntityManagerInterface $em)
  49.     {
  50.         $user $em->getRepository(Usuario::class)->find(10);
  51.         $encoded $encoder->hashPassword($user'docuManager2025');
  52.         $user->setPassword($encoded);
  53.         $em->persist($user);
  54.         $flush $em->flush();
  55.         die();
  56.     }
  57.     public function checkUserExists(Request $requestEntityManagerInterface $em)
  58.     {
  59.         $email trim((string)$request->request->get("user"''));
  60.         $empresaId = (int)$request->request->get("empresa_id"0);
  61.         $connectionId = (int)$request->request->get("connection"0);
  62.         if ($email === '') {
  63.             return new JsonResponse(['exists' => false'message' => 'user_required'], 400);
  64.         }
  65.         $qb $em->createQueryBuilder()
  66.             ->select('u.id')
  67.             ->from(Usuario::class, 'u')
  68.             ->where('LOWER(u.email) = :email')
  69.             ->setParameter('email'strtolower($email));
  70.         // Validar por ambito de empresa/conexion cuando se proporciona.
  71.         if ($empresaId 0) {
  72.             $qb->andWhere('IDENTITY(u.empresa) = :empresaId')
  73.                 ->setParameter('empresaId'$empresaId);
  74.         } elseif ($connectionId 0) {
  75.             $qb->andWhere('u.connection = :connectionId')
  76.                 ->setParameter('connectionId'$connectionId);
  77.         }
  78.         $existsScoped = !empty($qb->setMaxResults(1)->getQuery()->getArrayResult());
  79.         // Mantiene dato global para compatibilidad/diagnostico.
  80.         $existsGlobal = !empty($em->createQueryBuilder()
  81.             ->select('u2.id')
  82.             ->from(Usuario::class, 'u2')
  83.             ->where('LOWER(u2.email) = :email')
  84.             ->setParameter('email'strtolower($email))
  85.             ->setMaxResults(1)
  86.             ->getQuery()
  87.             ->getArrayResult());
  88.         return new JsonResponse([
  89.             'exists' => $existsScoped,
  90.             'exists_scoped' => $existsScoped,
  91.             'exists_global' => $existsGlobal,
  92.             'scope' => ($empresaId 'empresa' : ($connectionId 'connection' 'global')),
  93.         ]);
  94.     }
  95.     private function azurePortForTenant(int $tenantIdint $base 12000int $max 20999): int
  96.     {
  97.         $port $base $tenantId;
  98.         if ($port $max) {
  99.             $range max(1$max $base);
  100.             $port  $base + ($tenantId $range);
  101.         }
  102.         return $port;
  103.     }
  104.     private function azureDiWorkdirFromEnv(): string
  105.     {
  106.         return rtrim((string)($_ENV['AZURE_DI_WORKDIR'] ?? ''), '/');
  107.     }
  108.     private function clampDocuMaxThreads($valueint $default 4): int
  109.     {
  110.         if ($value === null || $value === '') {
  111.             return $default;
  112.         }
  113.         $threads = (int)$value;
  114.         if ($threads 1) {
  115.             return 1;
  116.         }
  117.         if ($threads 16) {
  118.             return 16;
  119.         }
  120.         return $threads;
  121.     }
  122.     private function ensureMailMonitorService(int $companyIdstring $dbHoststring $dbPortstring $dbUserstring $dbPassstring $dbNamestring $filesPath): void
  123.     {
  124.         $workdir $_ENV['MAIL_IMPORTER_WORKDIR'] ?? '';
  125.         $script  $_ENV['MAIL_IMPORTER_SCRIPT'] ?? '';
  126.         $envFile $_ENV['MAIL_IMPORTER_COMMON_ENV'] ?? '';
  127.         $missing = [];
  128.         if ($workdir === ''$missing[] = 'MAIL_IMPORTER_WORKDIR';
  129.         if ($script === ''$missing[] = 'MAIL_IMPORTER_SCRIPT';
  130.         if ($envFile === ''$missing[] = 'MAIL_IMPORTER_COMMON_ENV';
  131.         if (trim($filesPath) === ''$missing[] = 'FILES_PATH';
  132.         if (!empty($missing)) {
  133.             $msg 'Mail Monitor no creado: faltan variables de entorno: ' implode(', '$missing);
  134.             $this->addFlash('warning'$msg);
  135.             error_log($msg);
  136.             return;
  137.         }
  138.         $serviceName $companyId "-mailMonitor.service";
  139.         $timerName   $companyId "-mailMonitor.timer";
  140.         $filesRoot   rtrim($filesPath'/') . '/' $companyId;
  141.         $serviceContent = <<<EOT
  142. [Unit]
  143. Description=DocuManager Mail Monitor (empresa {$companyId})
  144. Wants=network-online.target
  145. After=network-online.target
  146. [Service]
  147. Type=oneshot
  148. WorkingDirectory={$workdir}
  149. EnvironmentFile={$envFile}
  150. Environment=MAIL_IMPORTER_DB_HOST={$dbHost}
  151. Environment=MAIL_IMPORTER_DB_PORT={$dbPort}
  152. Environment=MAIL_IMPORTER_DB_USER={$dbUser}
  153. Environment=MAIL_IMPORTER_DB_PASS={$dbPass}
  154. Environment=MAIL_IMPORTER_DB_NAME={$dbName}
  155. Environment=MAIL_IMPORTER_FILES_ROOT={$filesRoot}
  156. ExecStart=/usr/bin/python3 {$script} --once --log-level INFO
  157. User=docunecta
  158. Group=docunecta
  159. [Install]
  160. WantedBy=multi-user.target
  161. EOT;
  162.         $timerContent = <<<EOT
  163. [Unit]
  164. Description=DocuManager Mail Monitor Timer (empresa {$companyId})
  165. [Timer]
  166. OnBootSec=2min
  167. OnUnitActiveSec=15min
  168. AccuracySec=1min
  169. Persistent=true
  170. [Install]
  171. WantedBy=timers.target
  172. EOT;
  173.         $tmpServicePath "/tmp/{$serviceName}";
  174.         $tmpTimerPath   "/tmp/{$timerName}";
  175.         file_put_contents($tmpServicePath$serviceContent);
  176.         file_put_contents($tmpTimerPath$timerContent);
  177.         @chmod($tmpServicePath0644);
  178.         @chmod($tmpTimerPath0644);
  179.         $cmds = [
  180.             "sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
  181.             "sudo /bin/mv {$tmpTimerPath} /etc/systemd/system/{$timerName}",
  182.             "sudo /bin/systemctl daemon-reload",
  183.             "sudo /bin/systemctl enable --now {$timerName}",
  184.         ];
  185.         foreach ($cmds as $cmd) {
  186.             $out \shell_exec($cmd " 2>&1");
  187.             if ($out !== null) {
  188.                 error_log("MAIL-MONITOR CMD: $cmd\n$out");
  189.             }
  190.         }
  191.     }
  192.     private function disableMailMonitorService(int $companyId): void
  193.     {
  194.         $serviceName $companyId "-mailMonitor.service";
  195.         $timerName   $companyId "-mailMonitor.timer";
  196.         $cmds = [
  197.             "sudo /bin/systemctl disable --now {$timerName}",
  198.             "sudo /bin/systemctl stop {$serviceName}",
  199.             "sudo /bin/rm -f /etc/systemd/system/{$serviceName}",
  200.             "sudo /bin/rm -f /etc/systemd/system/{$timerName}",
  201.             "sudo /bin/systemctl daemon-reload",
  202.         ];
  203.         foreach ($cmds as $cmd) {
  204.             $out \shell_exec($cmd " 2>&1");
  205.             if ($out !== null) {
  206.                 error_log("MAIL-MONITOR CMD: $cmd\n$out");
  207.             }
  208.         }
  209.     }
  210.     private function removeCompanyFilesDir(int $companyId): void
  211.     {
  212.         $base $_ENV['FILES_PATH'] ?? '';
  213.         if (trim($base) === '') {
  214.             $msg 'No se ha podido borrar carpeta de files: falta FILES_PATH en entorno.';
  215.             $this->addFlash('warning'$msg);
  216.             error_log($msg);
  217.             return;
  218.         }
  219.         $base rtrim($base'/');
  220.         $target $base '/' $companyId;
  221.         if (!is_dir($target)) {
  222.             return;
  223.         }
  224.         $errors = [];
  225.         $it = new \RecursiveIteratorIterator(
  226.             new \RecursiveDirectoryIterator($target\FilesystemIterator::SKIP_DOTS),
  227.             \RecursiveIteratorIterator::CHILD_FIRST
  228.         );
  229.         foreach ($it as $file) {
  230.             try {
  231.                 if ($file->isDir()) {
  232.                     @rmdir($file->getPathname());
  233.                 } else {
  234.                     @unlink($file->getPathname());
  235.                 }
  236.             } catch (\Throwable $e) {
  237.                 $errors[] = $e->getMessage();
  238.             }
  239.         }
  240.         @rmdir($target);
  241.         if (!empty($errors)) {
  242.             $msg 'No se pudo borrar completamente la carpeta de files: ' $target;
  243.             $this->addFlash('warning'$msg);
  244.             error_log($msg ' | ' implode(' | '$errors));
  245.         }
  246.     }
  247.     private function removeCompanyLogsDir(int $companyId): void
  248.     {
  249.         $base $_ENV['LOGS_ROOT'] ?? '';
  250.         if (trim($base) === '') {
  251.             $msg 'No se ha podido borrar carpeta de logs: falta LOGS_ROOT en entorno.';
  252.             $this->addFlash('warning'$msg);
  253.             error_log($msg);
  254.             return;
  255.         }
  256.         $base rtrim($base'/');
  257.         $target $base '/' $companyId;
  258.         if (!is_dir($target)) {
  259.             return;
  260.         }
  261.         $errors = [];
  262.         $it = new \RecursiveIteratorIterator(
  263.             new \RecursiveDirectoryIterator($target\FilesystemIterator::SKIP_DOTS),
  264.             \RecursiveIteratorIterator::CHILD_FIRST
  265.         );
  266.         foreach ($it as $file) {
  267.             try {
  268.                 if ($file->isDir()) {
  269.                     @rmdir($file->getPathname());
  270.                 } else {
  271.                     @unlink($file->getPathname());
  272.                 }
  273.             } catch (\Throwable $e) {
  274.                 $errors[] = $e->getMessage();
  275.             }
  276.         }
  277.         @rmdir($target);
  278.         if (!empty($errors)) {
  279.             $msg 'No se pudo borrar completamente la carpeta de logs: ' $target;
  280.             $this->addFlash('warning'$msg);
  281.             error_log($msg ' | ' implode(' | '$errors));
  282.         }
  283.     }
  284.     private function getCompanyUserMediaPaths(\mysqli $mysqli): array
  285.     {
  286.         $paths = [];
  287.         try {
  288.             $res $mysqli->query("SELECT avatar, firma FROM users");
  289.             if ($res) {
  290.                 while ($r $res->fetch_assoc()) {
  291.                     foreach (['avatar''firma'] as $col) {
  292.                         $path trim((string)($r[$col] ?? ''));
  293.                         if ($path === '' || str_starts_with($path'http')) {
  294.                             continue;
  295.                         }
  296.                         $paths[$path] = true;
  297.                     }
  298.                 }
  299.                 $res->free();
  300.             }
  301.         } catch (\Throwable $e) {
  302.             error_log('Error leyendo usuarios para borrar media: ' $e->getMessage());
  303.         }
  304.         return array_keys($paths);
  305.     }
  306.     private function callPlatformCleanup(int $companyId, array $mediaPaths = []): void
  307.     {
  308.         $url $_ENV['PLATFORM_CLEANUP_URL'] ?? '';
  309.         $secret $_ENV['PLATFORM_CLEANUP_SECRET'] ?? '';
  310.         if (trim($url) === '' || trim($secret) === '') {
  311.             $msg 'Cleanup no ejecutado: faltan PLATFORM_CLEANUP_URL o PLATFORM_CLEANUP_SECRET.';
  312.             $this->addFlash('warning'$msg);
  313.             error_log($msg);
  314.             return;
  315.         }
  316.         $payload = [
  317.             'company_id' => $companyId,
  318.             'media_paths' => array_values($mediaPaths),
  319.         ];
  320.         $body json_encode($payloadJSON_UNESCAPED_SLASHES);
  321.         if ($body === false) {
  322.             error_log('Cleanup: no se pudo serializar payload JSON.');
  323.             return;
  324.         }
  325.         $ts time();
  326.         $sig hash_hmac('sha256'$ts "\n" $body$secret);
  327.         $ch curl_init();
  328.         curl_setopt($chCURLOPT_URL$url);
  329.         curl_setopt($chCURLOPT_POSTtrue);
  330.         curl_setopt($chCURLOPT_POSTFIELDS$body);
  331.         curl_setopt($chCURLOPT_HTTPHEADER, [
  332.             'Content-Type: application/json',
  333.             'X-Docu-Timestamp: ' $ts,
  334.             'X-Docu-Signature: ' $sig,
  335.         ]);
  336.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  337.         curl_setopt($chCURLOPT_TIMEOUT8);
  338.         curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse);
  339.         curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  340.         $response curl_exec($ch);
  341.         $error curl_error($ch);
  342.         $code curl_getinfo($chCURLINFO_HTTP_CODE);
  343.         curl_close($ch);
  344.         if ($error || $code 200 || $code >= 300) {
  345.             $msg 'Cleanup remoto fallo (' $code '): ' . ($error ?: (string)$response);
  346.             $this->addFlash('warning'$msg);
  347.             error_log($msg);
  348.         }
  349.     }
  350.     #[Route('/list'name'list')]
  351.     public function List(EntityManagerInterface $entityManager)
  352.     {
  353.         if (!$this->getUser() || !is_object($this->getUser())) {
  354.             return $this->redirectToRoute('logout');
  355.         }
  356.         $empresas $entityManager->getRepository(Empresa::class)->findAll();
  357.         return $this->render('listusers.html.twig', array(
  358.             'empresas' => $empresas,
  359.         ));
  360.     }
  361.     public function superusersList(EntityManagerInterface $em)
  362.     {
  363.         if (!$this->getUser() || !is_object($this->getUser())) {
  364.             return $this->redirectToRoute('logout');
  365.         }
  366.         $rows $em->getConnection()->fetchAllAssociative("
  367.             SELECT
  368.                 LOWER(email) AS email_key,
  369.                 MIN(email) AS email,
  370.                 COUNT(*) AS empresas_count,
  371.                 SUM(CASE WHEN status IN ('ENABLED', '1', 1) THEN 1 ELSE 0 END) AS enabled_count
  372.             FROM users
  373.             WHERE is_admin = :isAdmin
  374.             GROUP BY LOWER(email)
  375.             ORDER BY MIN(email) ASC
  376.         ", ['isAdmin' => self::SUPERUSER_IS_ADMIN]);
  377.         return $this->render('superusers/list.html.twig', [
  378.             'superusers' => $rows,
  379.         ]);
  380.     }
  381.     public function superusersNew(Request $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  382.     {
  383.         if (!$this->getUser() || !is_object($this->getUser())) {
  384.             return $this->redirectToRoute('logout');
  385.         }
  386.         $empresas $em->getRepository(Empresa::class)->findAll();
  387.         $formData = [
  388.             'email' => '',
  389.             'status' => 'ENABLED',
  390.             'empresas' => [],
  391.         ];
  392.         if ($request->isMethod('POST')) {
  393.             $email $this->normalizeSuperuserEmail((string)$request->request->get('email'''));
  394.             $password = (string)$request->request->get('password''');
  395.             $enabled strtoupper((string)$request->request->get('status''ENABLED')) === 'ENABLED';
  396.             $empresaIds $this->readEmpresaIdsFromRequest($request);
  397.             $formData['email'] = $email;
  398.             $formData['status'] = $enabled 'ENABLED' 'DISABLED';
  399.             $formData['empresas'] = $empresaIds;
  400.             try {
  401.                 $service->createSuperuser($email$password$empresaIds$enabled);
  402.                 $this->addFlash('success''Superusuario creado correctamente.');
  403.                 return $this->redirectToRoute('superusers_list');
  404.             } catch (\Throwable $e) {
  405.                 $this->addFlash('danger'$e->getMessage());
  406.             }
  407.         }
  408.         return $this->render('superusers/form.html.twig', [
  409.             'title' => 'Crear superusuario',
  410.             'is_edit' => false,
  411.             'form' => $formData,
  412.             'empresas' => $empresas,
  413.         ]);
  414.     }
  415.     public function superusersEdit(string $emailRequest $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  416.     {
  417.         if (!$this->getUser() || !is_object($this->getUser())) {
  418.             return $this->redirectToRoute('logout');
  419.         }
  420.         $email $this->normalizeSuperuserEmail($email);
  421.         $existingRows $em->getConnection()->fetchAllAssociative(
  422.             "SELECT empresa_id, status
  423.              FROM users
  424.              WHERE LOWER(email) = :email AND is_admin = :isAdmin",
  425.             ['email' => $email'isAdmin' => self::SUPERUSER_IS_ADMIN]
  426.         );
  427.         if (count($existingRows) === 0) {
  428.             $this->addFlash('warning''Superusuario no encontrado.');
  429.             return $this->redirectToRoute('superusers_list');
  430.         }
  431.         $selectedEmpresaIds = [];
  432.         $hasEnabled false;
  433.         foreach ($existingRows as $row) {
  434.             $selectedEmpresaIds[] = (int)$row['empresa_id'];
  435.             $hasEnabled $hasEnabled || strtoupper((string)$row['status']) === 'ENABLED';
  436.         }
  437.         $selectedEmpresaIds array_values(array_unique($selectedEmpresaIds));
  438.         sort($selectedEmpresaIds);
  439.         $empresas $em->getRepository(Empresa::class)->findAll();
  440.         $formData = [
  441.             'email' => $email,
  442.             'status' => $hasEnabled 'ENABLED' 'DISABLED',
  443.             'empresas' => $selectedEmpresaIds,
  444.         ];
  445.         if ($request->isMethod('POST')) {
  446.             $newPassword trim((string)$request->request->get('password'''));
  447.             $enabled strtoupper((string)$request->request->get('status''ENABLED')) === 'ENABLED';
  448.             $empresaIds $this->readEmpresaIdsFromRequest($request);
  449.             $formData['status'] = $enabled 'ENABLED' 'DISABLED';
  450.             $formData['empresas'] = $empresaIds;
  451.             try {
  452.                 $service->updateSuperuser($email, ($newPassword === '' null $newPassword), $empresaIds$enabled);
  453.                 $this->addFlash('success''Superusuario actualizado correctamente.');
  454.                 return $this->redirectToRoute('superusers_list');
  455.             } catch (\Throwable $e) {
  456.                 $this->addFlash('danger'$e->getMessage());
  457.             }
  458.         }
  459.         return $this->render('superusers/form.html.twig', [
  460.             'title' => 'Editar superusuario',
  461.             'is_edit' => true,
  462.             'form' => $formData,
  463.             'empresas' => $empresas,
  464.         ]);
  465.     }
  466.     public function superusersDeleteLink(string $emailint $empresaIdRequest $requestEntityManagerInterface $emSuperuserProvisioningService $service)
  467.     {
  468.         if (!$this->getUser() || !is_object($this->getUser())) {
  469.             return $this->redirectToRoute('logout');
  470.         }
  471.         if (!$request->isMethod('POST')) {
  472.             return $this->redirectToRoute('superusers_list');
  473.         }
  474.         $email $this->normalizeSuperuserEmail($email);
  475.         $rows $em->getConnection()->fetchAllAssociative(
  476.             "SELECT empresa_id, status
  477.              FROM users
  478.              WHERE LOWER(email) = :email AND is_admin = :isAdmin",
  479.             ['email' => $email'isAdmin' => self::SUPERUSER_IS_ADMIN]
  480.         );
  481.         if (count($rows) === 0) {
  482.             $this->addFlash('warning''Superusuario no encontrado.');
  483.             return $this->redirectToRoute('superusers_list');
  484.         }
  485.         $targetEmpresaIds = [];
  486.         $enabled false;
  487.         foreach ($rows as $row) {
  488.             $currentEmpresaId = (int)$row['empresa_id'];
  489.             if ($currentEmpresaId !== $empresaId) {
  490.                 $targetEmpresaIds[] = $currentEmpresaId;
  491.             }
  492.             $enabled $enabled || strtoupper((string)$row['status']) === 'ENABLED';
  493.         }
  494.         try {
  495.             $service->updateSuperuser($emailnull$targetEmpresaIds$enabled);
  496.             $this->addFlash('success''Vinculación eliminada correctamente.');
  497.         } catch (\Throwable $e) {
  498.             $this->addFlash('danger'$e->getMessage());
  499.         }
  500.         return $this->redirectToRoute('superusers_edit', ['email' => $email]);
  501.     }
  502.     public function superusersCheckEmail(Request $requestEntityManagerInterface $em): JsonResponse
  503.     {
  504.         $email $this->normalizeSuperuserEmail((string)$request->request->get('email'''));
  505.         $empresaIds $this->readEmpresaIdsFromRequest($request);
  506.         if ($email === '') {
  507.             return new JsonResponse(['exists' => false'message' => 'email_required'], 400);
  508.         }
  509.         if (count($empresaIds) === 0) {
  510.             return new JsonResponse(['exists' => false'message' => 'no_empresas']);
  511.         }
  512.         $count = (int)$em->createQueryBuilder()
  513.             ->select('COUNT(u.id)')
  514.             ->from(Usuario::class, 'u')
  515.             ->where('LOWER(u.email) = :email')
  516.             ->andWhere('u.isAdmin = :isAdmin')
  517.             ->andWhere('IDENTITY(u.empresa) IN (:empresaIds)')
  518.             ->setParameter('email'$email)
  519.             ->setParameter('isAdmin'self::SUPERUSER_IS_ADMIN)
  520.             ->setParameter('empresaIds'$empresaIds)
  521.             ->getQuery()
  522.             ->getSingleScalarResult();
  523.         return new JsonResponse([
  524.             'exists' => $count 0,
  525.             'count' => $count,
  526.         ]);
  527.     }
  528.     private function normalizeSuperuserEmail(string $email): string
  529.     {
  530.         return strtolower(trim($email));
  531.     }
  532.     /**
  533.      * @return int[]
  534.      */
  535.     private function readEmpresaIdsFromRequest(Request $request): array
  536.     {
  537.         $raw $request->request->all('empresas');
  538.         if (!is_array($raw)) {
  539.             $raw = [];
  540.         }
  541.         $ids = [];
  542.         foreach ($raw as $empresaId) {
  543.             $id = (int)$empresaId;
  544.             if ($id 0) {
  545.                 $ids[] = $id;
  546.             }
  547.         }
  548.         $ids array_values(array_unique($ids));
  549.         sort($ids);
  550.         return $ids;
  551.     }
  552.     public function azureResourcesList(EntityManagerInterface $em)
  553.     {
  554.         if (!$this->getUser() || !is_object($this->getUser())) {
  555.             return $this->redirectToRoute('logout');
  556.         }
  557.         $resources = [];
  558.         try {
  559.             $resources $this->loadAzureDiResources($em);
  560.         } catch (\Throwable $e) {
  561.             $this->addFlash('danger''No se pudo cargar el listado de recursos IA: ' $e->getMessage());
  562.         }
  563.         return $this->render('azure_resources/list.html.twig', [
  564.             'resources' => $resources,
  565.         ]);
  566.     }
  567.     public function azureResourcesNew(Request $requestEntityManagerInterface $em)
  568.     {
  569.         if (!$this->getUser() || !is_object($this->getUser())) {
  570.             return $this->redirectToRoute('logout');
  571.         }
  572.         $resource = [
  573.             'name' => '',
  574.             'endpoint' => '',
  575.             'api_key' => '',
  576.         ];
  577.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  578.             $resource['name'] = trim((string)$request->request->get('name'''));
  579.             $resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint'''));
  580.             $resource['api_key'] = trim((string)$request->request->get('api_key'''));
  581.             if ($resource['name'] === '' || $resource['endpoint'] === '' || $resource['api_key'] === '') {
  582.                 $this->addFlash('danger''Nombre, endpoint y api key son obligatorios.');
  583.             } else {
  584.                 try {
  585.                     $em->getConnection()->insert('azure_di_resources', [
  586.                         'name' => $resource['name'],
  587.                         'endpoint' => $resource['endpoint'],
  588.                         'api_key' => $resource['api_key'],
  589.                     ]);
  590.                     $this->addFlash('success''Recurso IA creado correctamente.');
  591.                     return $this->redirectToRoute('azure_resources_list');
  592.                 } catch (\Throwable $e) {
  593.                     $this->addFlash('danger''No se pudo crear el recurso: ' $e->getMessage());
  594.                 }
  595.             }
  596.         }
  597.         return $this->render('azure_resources/new.html.twig', [
  598.             'resource' => $resource,
  599.         ]);
  600.     }
  601.     public function azureResourcesEdit(Request $requestEntityManagerInterface $em)
  602.     {
  603.         if (!$this->getUser() || !is_object($this->getUser())) {
  604.             return $this->redirectToRoute('logout');
  605.         }
  606.         $id = (int)$request->get('id');
  607.         $resource $this->loadAzureDiResourceById($em$id);
  608.         if (!$resource) {
  609.             $this->addFlash('warning''Recurso no encontrado.');
  610.             return $this->redirectToRoute('azure_resources_list');
  611.         }
  612.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  613.             $resource['name'] = trim((string)$request->request->get('name'''));
  614.             $resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint'''));
  615.             $resource['api_key'] = trim((string)$request->request->get('api_key'''));
  616.             if ($resource['name'] === '' || $resource['endpoint'] === '' || $resource['api_key'] === '') {
  617.                 $this->addFlash('danger''Nombre, endpoint y api key son obligatorios.');
  618.             } else {
  619.                 try {
  620.                     $em->getConnection()->update('azure_di_resources', [
  621.                         'name' => $resource['name'],
  622.                         'endpoint' => $resource['endpoint'],
  623.                         'api_key' => $resource['api_key'],
  624.                     ], [
  625.                         'id' => $id,
  626.                     ]);
  627.                     $this->addFlash('success''Recurso IA actualizado correctamente.');
  628.                     return $this->redirectToRoute('azure_resources_list');
  629.                 } catch (\Throwable $e) {
  630.                     $this->addFlash('danger''No se pudo actualizar el recurso: ' $e->getMessage());
  631.                 }
  632.             }
  633.         }
  634.         return $this->render('azure_resources/edit.html.twig', [
  635.             'resource' => $resource,
  636.         ]);
  637.     }
  638.     public function azureResourcesDelete(Request $requestEntityManagerInterface $em)
  639.     {
  640.         if (!$this->getUser() || !is_object($this->getUser())) {
  641.             return $this->redirectToRoute('logout');
  642.         }
  643.         $id = (int)$request->get('id');
  644.         if ($id <= 0) {
  645.             $this->addFlash('warning''Identificador de recurso no valido.');
  646.             return $this->redirectToRoute('azure_resources_list');
  647.         }
  648.         try {
  649.             $em->getConnection()->delete('azure_di_resources', ['id' => $id]);
  650.             $this->addFlash('success''Recurso IA eliminado correctamente.');
  651.         } catch (\Throwable $e) {
  652.             $this->addFlash('danger''No se pudo eliminar el recurso: ' $e->getMessage());
  653.         }
  654.         return $this->redirectToRoute('azure_resources_list');
  655.     }
  656.     public function azureApiResourceModels(Request $requestEntityManagerInterface $em): JsonResponse
  657.     {
  658.         if (!$this->getUser() || !is_object($this->getUser())) {
  659.             return new JsonResponse(['error' => 'unauthorized'], 401);
  660.         }
  661.         $resource $this->loadAzureDiResourceById($em, (int)$request->get('id'));
  662.         if (!$resource) {
  663.             return new JsonResponse(['error' => 'resource_not_found'], 404);
  664.         }
  665.         try {
  666.             $payload $this->azureRequest(
  667.                 (string)$resource['endpoint'],
  668.                 (string)$resource['api_key'],
  669.                 '/documentintelligence/documentModels'
  670.             );
  671.         } catch (\Throwable $e) {
  672.             $status = (int)$e->getCode();
  673.             if ($status 400 || $status 599) {
  674.                 $status 502;
  675.             }
  676.             return new JsonResponse(['error' => $e->getMessage()], $status);
  677.         }
  678.         $models $payload['value'] ?? [];
  679.         if (!is_array($models)) {
  680.             $models = [];
  681.         }
  682.         $normalizedModels = [];
  683.         foreach ($models as $model) {
  684.             if (!is_array($model)) {
  685.                 continue;
  686.             }
  687.             $normalizedModels[] = [
  688.                 'modelId' => (string)($model['modelId'] ?? ''),
  689.                 'description' => (string)($model['description'] ?? ''),
  690.                 'createdDateTime' => (string)($model['createdDateTime'] ?? ''),
  691.                 'expirationDateTime' => (string)($model['expirationDateTime'] ?? ''),
  692.                 'type' => $this->classifyModelType($model),
  693.             ];
  694.         }
  695.         usort($normalizedModels, static function (array $a, array $b): int {
  696.             return strcmp($a['modelId'] ?? ''$b['modelId'] ?? '');
  697.         });
  698.         return new JsonResponse([
  699.             'resource' => [
  700.                 'id' => (int)$resource['id'],
  701.                 'name' => (string)$resource['name'],
  702.             ],
  703.             'models' => $normalizedModels,
  704.         ]);
  705.     }
  706.     public function azureApiResourceModelDetail(Request $requestEntityManagerInterface $em): JsonResponse
  707.     {
  708.         if (!$this->getUser() || !is_object($this->getUser())) {
  709.             return new JsonResponse(['error' => 'unauthorized'], 401);
  710.         }
  711.         $resource $this->loadAzureDiResourceById($em, (int)$request->get('id'));
  712.         if (!$resource) {
  713.             return new JsonResponse(['error' => 'resource_not_found'], 404);
  714.         }
  715.         $modelId trim((string)$request->get('modelId'));
  716.         if ($modelId === '') {
  717.             return new JsonResponse(['error' => 'model_id_required'], 400);
  718.         }
  719.         try {
  720.             $detail $this->azureRequest(
  721.                 (string)$resource['endpoint'],
  722.                 (string)$resource['api_key'],
  723.                 '/documentintelligence/documentModels/' rawurlencode($modelId)
  724.             );
  725.             $mapped $this->mapAzureSchemaToDefinitions($detail);
  726.         } catch (\Throwable $e) {
  727.             $status = (int)$e->getCode();
  728.             if ($status 400 || $status 599) {
  729.                 $status 502;
  730.             }
  731.             return new JsonResponse(['error' => $e->getMessage()], $status);
  732.         }
  733.         return new JsonResponse([
  734.             'model' => $detail,
  735.             'type' => $this->classifyModelType($detail),
  736.             'preview' => [
  737.                 'header' => $mapped['header'],
  738.                 'lines' => $mapped['lines'],
  739.             ],
  740.         ]);
  741.     }
  742.     private function loadAzureDiResources(EntityManagerInterface $em): array
  743.     {
  744.         $rows $em->getConnection()->executeQuery(
  745.             'SELECT id, name, endpoint, api_key, created_at, updated_at FROM azure_di_resources ORDER BY name ASC'
  746.         )->fetchAllAssociative();
  747.         foreach ($rows as &$row) {
  748.             $row['id'] = (int)$row['id'];
  749.         }
  750.         return $rows;
  751.     }
  752.     private function loadAzureDiResourceById(EntityManagerInterface $emint $id): ?array
  753.     {
  754.         if ($id <= 0) {
  755.             return null;
  756.         }
  757.         $row $em->getConnection()->fetchAssociative(
  758.             'SELECT id, name, endpoint, api_key, created_at, updated_at
  759.              FROM azure_di_resources
  760.              WHERE id = :id',
  761.             ['id' => $id]
  762.         );
  763.         if (!$row) {
  764.             return null;
  765.         }
  766.         $row['id'] = (int)$row['id'];
  767.         return $row;
  768.     }
  769.     private function normalizeAzureEndpoint(string $endpoint): string
  770.     {
  771.         $endpoint trim($endpoint);
  772.         if ($endpoint === '') {
  773.             return '';
  774.         }
  775.         if (!preg_match('#^https?://#i'$endpoint)) {
  776.             $endpoint 'https://' $endpoint;
  777.         }
  778.         return rtrim($endpoint'/');
  779.     }
  780.     private function azureRequest(string $endpointstring $apiKeystring $path, array $query = []): array
  781.     {
  782.         $endpoint $this->normalizeAzureEndpoint($endpoint);
  783.         if ($endpoint === '' || trim($apiKey) === '') {
  784.             throw new \RuntimeException('Recurso IA incompleto.'400);
  785.         }
  786.         $query array_merge(['api-version' => self::AZURE_DI_API_VERSION], $query);
  787.         $url $endpoint '/' ltrim($path'/') . '?' http_build_query($query);
  788.         $ch curl_init();
  789.         curl_setopt($chCURLOPT_URL$url);
  790.         curl_setopt($chCURLOPT_HTTPHEADER, [
  791.             'Accept: application/json',
  792.             'Ocp-Apim-Subscription-Key: ' $apiKey,
  793.         ]);
  794.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  795.         curl_setopt($chCURLOPT_CONNECTTIMEOUT8);
  796.         curl_setopt($chCURLOPT_TIMEOUT25);
  797.         $response curl_exec($ch);
  798.         $curlError curl_error($ch);
  799.         $statusCode = (int)curl_getinfo($chCURLINFO_HTTP_CODE);
  800.         curl_close($ch);
  801.         if ($curlError) {
  802.             throw new \RuntimeException('Error de red con recurso IA: ' $curlError0);
  803.         }
  804.         $decoded = [];
  805.         if (is_string($response) && trim($response) !== '') {
  806.             $decoded json_decode($responsetrue);
  807.             if (!is_array($decoded)) {
  808.                 throw new \RuntimeException('Respuesta no valida del recurso IA.'502);
  809.             }
  810.         }
  811.         if ($statusCode 200 || $statusCode >= 300) {
  812.             $detail $decoded['error']['message'] ?? $decoded['message'] ?? ('HTTP ' $statusCode);
  813.             throw new \RuntimeException((string)$detail$statusCode);
  814.         }
  815.         return $decoded;
  816.     }
  817.     private function classifyModelType(array $model): string
  818.     {
  819.         $expiration trim((string)($model['expirationDateTime'] ?? ''));
  820.         return $expiration !== '' 'custom' 'prebuilt';
  821.     }
  822.     public function addEmpresa(Request $reqEntityManagerInterface $em)
  823.     {
  824.         //dd(\shell_exec('whoami'));
  825.         if (!$this->getUser() || !is_object($this->getUser())) {
  826.             return $this->redirectToRoute('logout');
  827.         }
  828.         $azureResources = [];
  829.         try {
  830.             $azureResources $this->loadAzureDiResources($em);
  831.         } catch (\Throwable $e) {
  832.             $this->addFlash('warning''No se pudo cargar el catalogo de recursos IA: ' $e->getMessage());
  833.         }
  834.         if ($req->request->get("submit") != "") {
  835.             $data $req->request->all();
  836.             $maxThreads $this->clampDocuMaxThreads($data['maxThreads'] ?? null4);
  837.             $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  838.             $mailMonitorEnabled = isset($data['modulo_mailMonitor']) && $toBool($data['modulo_mailMonitor']);
  839.             $data['modulo_extraccion'] = isset($data['modulo_extraccion']) && $toBool($data['modulo_extraccion']) ? 0;
  840.             $data['modulo_expowin'] = isset($data['modulo_expowin']) && $toBool($data['modulo_expowin']) ? 0;
  841.             $azureResource null;
  842.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  843.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  844.             if ($data['modulo_extraccion'] === 1) {
  845.                 if ($azureResourceId <= || $azureModelId === '') {
  846.                     $this->addFlash('danger''Para activar extraccion debes seleccionar recurso y modelo IA.');
  847.                     return $this->render('empresa/_add.html.twig', [
  848.                         'azure_resources' => $azureResources,
  849.                         'modulos' => [
  850.                             'extraction_model' => 0,
  851.                             'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
  852.                         ],
  853.                     ]);
  854.                 }
  855.                 $azureResource $this->loadAzureDiResourceById($em$azureResourceId);
  856.                 if (!$azureResource) {
  857.                     $this->addFlash('danger''El recurso IA seleccionado no existe.');
  858.                     return $this->render('empresa/_add.html.twig', [
  859.                         'azure_resources' => $azureResources,
  860.                         'modulos' => [
  861.                             'extraction_model' => 0,
  862.                             'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
  863.                         ],
  864.                     ]);
  865.                 }
  866.                 try {
  867.                     $this->azureRequest(
  868.                         (string)$azureResource['endpoint'],
  869.                         (string)$azureResource['api_key'],
  870.                         '/documentintelligence/documentModels/' rawurlencode($azureModelId)
  871.                     );
  872.                 } catch (\Throwable $e) {
  873.                     $this->addFlash('danger''No se pudo validar el modelo IA: ' $e->getMessage());
  874.                     return $this->render('empresa/_add.html.twig', [
  875.                         'azure_resources' => $azureResources,
  876.                         'modulos' => [
  877.                             'extraction_model' => 0,
  878.                             'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
  879.                         ],
  880.                     ]);
  881.                 }
  882.             } else {
  883.                 $data['azure_resource_id'] = 0;
  884.                 $data['azure_model_id'] = '';
  885.                 $data['extraction_model'] = 0;
  886.                 $data['modulo_expowin'] = 0;
  887.             }
  888.             // Crear nombre de base de datos y credenciales aleatorios
  889.             $dbName 'doc_' bin2hex(random_bytes(3));
  890.             $dbUser 'doc_' bin2hex(random_bytes(2));
  891.             $dbPass bin2hex(random_bytes(8));
  892.             $dbHost 'localhost';
  893.             $dbPort '3306';
  894.             // Credenciales de HestiaCP desde variables de entorno
  895.             $hestiaApiUrl $_ENV['HESTIA_API_URL'];
  896.             $hestiaApiUser $_ENV['HESTIA_API_USER'];
  897.             $hestiaApiPass $_ENV['HESTIA_API_PASS'];
  898.             $hestiaOwner   $_ENV['HESTIA_OWNER'];
  899.             $accessKeyId   $_ENV['HESTIA_ACCESS_KEY_ID'] ?? '';
  900.             $secretKey     $_ENV['HESTIA_SECRET_KEY'] ?? '';
  901.             // Variables para el script de bash
  902.             $ocrBinary  $_ENV['OCR_BINARY'];
  903.             $filesPath  $_ENV['FILES_PATH'];
  904.             $owner $hestiaOwner// o el dueńo del hosting
  905.             $postFields http_build_query([
  906.                 'user' => $hestiaApiUser,
  907.                 'password' => $hestiaApiPass,
  908.                 'returncode' => 'yes',
  909.                 'cmd' => 'v-add-database',
  910.                 'arg1' => $owner,
  911.                 'arg2' => $dbName,
  912.                 'arg3' => $dbUser,
  913.                 'arg4' => $dbPass,
  914.                 'arg5' => 'mysql'
  915.             ]);
  916.             //dd($postFields);            
  917.             $headers = [
  918.                 'Authorization: Bearer ' $accessKeyId ':' $secretKey
  919.             ];
  920.             $ch curl_init();
  921.             curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  922.             curl_setopt($chCURLOPT_HTTPHEADER$headers);
  923.             curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  924.             curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  925.             curl_setopt($chCURLOPT_POSTtrue);
  926.             curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  927.             curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  928.             $response curl_exec($ch);
  929.             $error curl_error($ch);
  930.             curl_close($ch);
  931.             if ($error || trim($response) !== '0') {
  932.                 $this->addFlash('danger''Error al crear la base de datos en HestiaCP: ' . ($error ?: $response));
  933.                 return $this->redirectToRoute('app_empresa_new');
  934.             }
  935.             //Añadir sql
  936.             $sqlFile __DIR__ '/../../db/db_base.sql'// Ajusta la ruta si está en otro sitio
  937.             if (!file_exists($sqlFile)) {
  938.                 $this->addFlash('danger''Archivo db_base.sql no encontrado.');
  939.                 return $this->redirectToRoute('app_empresa_new');
  940.             }
  941.             $mysqli = new \mysqli($dbHost"{$owner}_{$dbUser}"$dbPass"{$owner}_{$dbName}", (int)$dbPort);
  942.             if ($mysqli->connect_error) {
  943.                 $this->addFlash('danger''Error al conectar a la base de datos: ' $mysqli->connect_error);
  944.                 return $this->redirectToRoute('app_empresa_new');
  945.             }
  946.             $sql file_get_contents($sqlFile);
  947.             // Eliminar lĂ­neas con DELIMITER
  948.             $sql preg_replace('/DELIMITER\s+\$\$/'''$sql);
  949.             $sql preg_replace('/DELIMITER\s+;/'''$sql);
  950.             // Separar por ';;' si los triggers usan ese delimitador (ajusta si es $$)
  951.             $statements explode('$$'$sql);
  952.             foreach ($statements as $stmt) {
  953.                 $stmt trim($stmt);
  954.                 if ($stmt) {
  955.                     if (!$mysqli->multi_query($stmt)) {
  956.                         $this->addFlash('danger''Error ejecutando SQL: ' $mysqli->error);
  957.                         return $this->redirectToRoute('app_empresa_new');
  958.                     }
  959.                     // Limpiar cualquier resultado intermedio
  960.                     while ($mysqli->more_results() && $mysqli->next_result()) {
  961.                         $mysqli->use_result();
  962.                     }
  963.                 }
  964.             }
  965.             $updateSql "UPDATE users SET email = '" $mysqli->real_escape_string((string)$data["user"]) . "' WHERE id = 1";
  966.             if (!$mysqli->query($updateSql)) {
  967.                 $this->addFlash('danger''Error al actualizar usuario: ' $mysqli->error);
  968.                 return $this->redirectToRoute('app_empresa_new');
  969.             }
  970.             // Guardar parámetros (activeUsers + modulos_*) en la BD del cliente
  971.             $localExtractionModelId 0;
  972.             if ($data['modulo_extraccion'] === 1) {
  973.                 try {
  974.                     $localExtractionModelId $this->registerModelInClientDb(
  975.                         $mysqli,
  976.                         $azureResource ?? [],
  977.                         $azureModelId
  978.                     );
  979.                 } catch (\Throwable $e) {
  980.                     $this->addFlash('danger''No se pudo registrar el modelo en la BBDD cliente: ' $e->getMessage());
  981.                     $mysqli->close();
  982.                     return $this->redirectToRoute('app_empresa_new');
  983.                 }
  984.             }
  985.             $data['extraction_model'] = $localExtractionModelId;
  986.             // SUBIR LOGO PERSONALIZADO (SI LO HAY) ===
  987.             $customLogoFile null;
  988.             try {
  989.                 $customLogoFile $this->uploadEmpresaLogo($req);
  990.             } catch (\Throwable $e) {
  991.                 // Aquí decides si quieres que esto sea fatal o solo un aviso
  992.                 $this->addFlash('warning''El logo personalizado no se pudo subir: ' $e->getMessage());
  993.                 // Si quieres abortar todo el proceso por fallo de logo, haz return+redirect aquí.
  994.             }
  995.             // Guardar logo en la BD del cliente
  996.             $this->saveEmpresaLogo($mysqli$data$customLogoFile);
  997.             // Actualizar/insertar license (capacityGb y users) en la BD del cliente
  998.             $this->upsertLicense(
  999.                 $mysqli,
  1000.                 $data,
  1001.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== '' ? (int)$data['maxDiskQuota'] : 200,
  1002.                 isset($data['maxActiveUsers']) && $data['maxActiveUsers'] !== '' ? (int)$data['maxActiveUsers'] : 3
  1003.             );
  1004.             // Guardar parametros (activeUsers + modulos_*) en la BD del cliente
  1005.             $this->saveEmpresaParametros($mysqli$data);
  1006.             // Crear y persistir la empresa
  1007.             $emp = new Empresa();
  1008.             $emp->setName($data["name"]);
  1009.             $emp->setMaxDiskQuota((int)$data["maxDiskQuota"]);
  1010.             $emp->setMaxThreads($maxThreads);
  1011.             $em->persist($emp);
  1012.             $em->flush();
  1013.             $conexionBD = new ConexionBD();
  1014.             $conexionBD->setDbName($owner "_" $dbName);
  1015.             $conexionBD->setDbUser($owner "_" $dbUser);
  1016.             $conexionBD->setDbPassword($dbPass);
  1017.             $conexionBD->setDbUrl($dbHost);
  1018.             $conexionBD->setDbPort($dbPort);
  1019.             $em->persist($conexionBD);
  1020.             $em->flush();
  1021.             $emp->setConexionBD($conexionBD);
  1022.             $em->persist($emp);
  1023.             $em->flush();
  1024.             //crear usuario
  1025.             $user = new \App\Entity\Usuario();
  1026.             $user->setEmail($data["user"]);
  1027.             $user->setEmpresa($emp);
  1028.             $user->setPassword("dscsdcsno2234dwvw");
  1029.             $user->setStatus(1);
  1030.             $user->setIsAdmin(2);
  1031.             $user->setConnection($conexionBD->getId());
  1032.             $em->persist($user);
  1033.             $em->flush();
  1034.             //crear el script de bash
  1035.             $company_name $emp->getId();
  1036.             // "DOCU_MAX_THREADS=" por defecto 4
  1037.             // "NO" al final es para desactivar FTP
  1038.             // Crear archivo .service
  1039.             $empresaName = (string)($data['name'] ?? '');
  1040.             $serviceContent = <<<EOT
  1041. [Unit]
  1042. Description={$empresaName} DocuManager OCR
  1043. Requires=mariadb.service
  1044. After=mariadb.service
  1045. [Service]
  1046. Type=simple
  1047. Environment="DOCU_MAX_THREADS=$maxThreads"
  1048. ExecStart=$ocrBinary localhost/{$owner}_{$dbName} {$owner}_{$dbUser} {$dbPass} {$filesPath}/{$company_name} NO
  1049. Restart=always
  1050. User=root
  1051. [Install]
  1052. WantedBy=multi-user.target
  1053. EOT;
  1054.             // Guardar contenido temporal en un archivo dentro de /tmp
  1055.             $serviceName $company_name "-documanager.service";
  1056.             $tmpServicePath "/tmp/$serviceName";
  1057.             file_put_contents($tmpServicePath$serviceContent);
  1058.             \chmod($tmpServicePath0644);
  1059.             // Mover el archivo y habilitar el servicio desde PHP con shell_exec
  1060.             $commands = [
  1061.                 "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  1062.                 "sudo /bin/systemctl daemon-reload",
  1063.                 "sudo /bin/systemctl enable $serviceName",
  1064.                 "sudo /bin/systemctl start $serviceName",
  1065.             ];
  1066.             $errors = [];
  1067.             foreach ($commands as $cmd) {
  1068.                 $output \shell_exec($cmd " 2>&1");
  1069.                 if ($output !== null) {
  1070.                     // Puedes loguearlo si quieres para ver errores
  1071.                     error_log("CMD OUTPUT: $cmd\n$output");
  1072.                     $errors[] = "CMD OUTPUT: $cmd\n$output";
  1073.                 }
  1074.             }
  1075.             // === Crear servicio AZURE DI por tenant ===
  1076.             $azureBasePort 12000;
  1077.             $tenantId      = (int)$emp->getId();
  1078.             $port          $this->azurePortForTenant($tenantId$azureBasePort20999);
  1079.             $serviceName   $tenantId "-azuredi.service";
  1080.             $workdir       $this->azureDiWorkdirFromEnv();
  1081.             if ($workdir !== '') {
  1082.                 $logsDir $workdir "/logs/" $tenantId;
  1083.                 // Asegura carpeta de logs
  1084.                 @mkdir($logsDir0775true);
  1085.                 $serviceContent = <<<EOT
  1086.                 [Unit]
  1087.                 Description=DocuManager Azure DI (tenant {$tenantId})
  1088.                 After=network.target
  1089.                 [Service]
  1090.                 User=root
  1091.                 WorkingDirectory={$workdir}
  1092.                 Environment=APP_HOST=127.0.0.1
  1093.                 Environment=APP_PORT={$port}
  1094.                 Environment=LOG_DIR={$logsDir}
  1095.                 Environment=PYTHONUNBUFFERED=1
  1096.                 Environment=MAX_CONCURRENT=20
  1097.                 Environment="PATH=/opt/azure-di/.venv/bin:/usr/local/bin:/usr/bin"
  1098.                 EnvironmentFile=-{$workdir}/.env
  1099.                 ExecStart=/opt/azure-di/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port {$port} --proxy-headers --workers 2
  1100.                 Restart=always
  1101.                 RestartSec=2
  1102.                 StandardOutput=journal
  1103.                 StandardError=journal
  1104.                 [Install]
  1105.                 WantedBy=multi-user.target
  1106.                 EOT;
  1107.                 $tmpServicePath "/tmp/{$serviceName}";
  1108.                 file_put_contents($tmpServicePath$serviceContent);
  1109.                 @chmod($tmpServicePath0644);
  1110.                 $cmds = [
  1111.                     "sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
  1112.                     "sudo /bin/systemctl daemon-reload",
  1113.                     "sudo /bin/systemctl enable {$serviceName}",
  1114.                     "sudo /bin/systemctl start {$serviceName}",
  1115.                 ];
  1116.                 foreach ($cmds as $cmd) {
  1117.                     $out \shell_exec($cmd " 2>&1");
  1118.                     if ($out !== null) {
  1119.                         error_log("AZURE-DI CMD: $cmd\n$out");
  1120.                     }
  1121.                 }
  1122.             } else {
  1123.                 $this->addFlash('warning''No se pudo crear el servicio de extraccion IA: falta configuracion de entorno.');
  1124.             }
  1125.             // === Crear servicio/timer Mail Monitor si el modulo esta activo ===
  1126.             if ($mailMonitorEnabled) {
  1127.                 $this->ensureMailMonitorService(
  1128.                     (int)$company_name,
  1129.                     (string)$dbHost,
  1130.                     (string)$dbPort,
  1131.                     (string)$owner "_" . (string)$dbUser,
  1132.                     (string)$dbPass,
  1133.                     (string)$owner "_" . (string)$dbName,
  1134.                     (string)$filesPath
  1135.                 );
  1136.             }
  1137.             if (count($errors) > 0) {
  1138.                 $this->addFlash('success''Empresa y base de datos creadas correctamente: ' implode(" | "$errors));
  1139.             }
  1140.             $this->addFlash('success''Empresa y base de datos creadas correctamente.');
  1141.             return $this->redirectToRoute('list');
  1142.         } else {
  1143.             // Carga recursos Azure DI para el formulario de alta
  1144.             return $this->render('empresa/_add.html.twig', [
  1145.                 'azure_resources' => $azureResources,
  1146.                 'modulos' => [
  1147.                     'extraction_model' => 0,
  1148.                     'limite_archivos' => 500,
  1149.                 ],
  1150.             ]);
  1151.         }
  1152.     }
  1153.     private function saveEmpresaParametros(\mysqli $mysqli, array $data): void
  1154.     {
  1155.         // Helpers
  1156.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  1157.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  1158.         // Keys y valores tal y como quieres guardarlos
  1159.         $paramMap = [
  1160.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  1161.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  1162.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  1163.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  1164.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  1165.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  1166.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  1167.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  1168.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  1169.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  1170.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  1171.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  1172.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  1173.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  1174.             'limite_archivos'  => $getInt($data'limite_archivos'500),
  1175.             'extraction_model'  => $getInt($data'extraction_model'0),
  1176.         ];
  1177.         if ($paramMap['modulo_extraccion'] === 0) {
  1178.             $paramMap['modulo_expowin'] = 0;
  1179.         }
  1180.         // RECOMENDADO en tu SQL base:
  1181.         // ALTER TABLE parametros ADD UNIQUE KEY uniq_nombre (nombre);
  1182.         $mysqli->begin_transaction();
  1183.         try {
  1184.             $stmt $mysqli->prepare("
  1185.                 INSERT INTO parametros (nombre, valor)
  1186.                 VALUES (?, ?)
  1187.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  1188.             ");
  1189.             if (!$stmt) {
  1190.                 throw new \RuntimeException('Prepare parametros: ' $mysqli->error);
  1191.             }
  1192.             foreach ($paramMap as $nombre => $valor) {
  1193.                 // valor es TEXT en tu esquema: bindeamos como string
  1194.                 $v = (string)$valor;
  1195.                 $stmt->bind_param('ss'$nombre$v);
  1196.                 if (!$stmt->execute()) {
  1197.                     throw new \RuntimeException("Guardar parámetro $nombre: " $stmt->error);
  1198.                 }
  1199.             }
  1200.             $stmt->close();
  1201.             $mysqli->commit();
  1202.         } catch (\Throwable $e) {
  1203.             $mysqli->rollback();
  1204.             // Si NO puedes ańadir UNIQUE(nombre), usa fallback DELETE+INSERT:
  1205.             // $this->saveParametrosFallback($mysqli, $paramMap);
  1206.             throw $e;
  1207.         }
  1208.     }
  1209.     private function saveEmpresaLogo(\mysqli $mysqli, array $data, ?string $customLogoFile null): void
  1210.     {
  1211.         $this->logLogo('empresa_logo_save.log''--- NUEVA LLAMADA saveEmpresaLogo ---');
  1212.         $this->logLogo('empresa_logo_save.log''customLogoFile = ' var_export($customLogoFiletrue));
  1213.         $this->logLogo('empresa_logo_save.log''data[empresa_vendor] = ' var_export($data['empresa_vendor'] ?? nulltrue));
  1214.         // 1) Decidir qué logo vamos a guardar
  1215.         $logoFile null;
  1216.         // --- PRIORIDAD: LOGO PERSONALIZADO ---
  1217.         if ($customLogoFile !== null && $customLogoFile !== '') {
  1218.             $logoFile $customLogoFile;
  1219.             $this->logLogo('empresa_logo_save.log''Usando logo personalizado: ' $logoFile);
  1220.         } else {
  1221.             // --- SI NO HAY PERSONALIZADO, USAMOS EL SELECT DE EMPRESA ---
  1222.             if (!isset($data['empresa_vendor']) || $data['empresa_vendor'] === '') {
  1223.                 $this->logLogo('empresa_logo_save.log''No hay empresa_vendor y no hay logo custom. No hago nada.');
  1224.                 return;
  1225.             }
  1226.             $empresa $data['empresa_vendor'];
  1227.             $empresaKey strtolower(trim((string)$empresa));
  1228.             $logoMap = [
  1229.                 'docunecta'  => 'DocuManager_transparente.png',
  1230.                 'docuindexa' => 'DocuIndexa.png',
  1231.             ];
  1232.             $vendorLabelMap = [
  1233.                 'docunecta'  => 'Docunecta',
  1234.                 'docuindexa' => 'Docuindexa',
  1235.             ];
  1236.             if (!isset($logoMap[$empresaKey])) {
  1237.                 $this->logLogo('empresa_logo_save.log'"empresa_vendor $empresa no está en logoMap. No hago nada.");
  1238.                 return;
  1239.             }
  1240.             $logoFile $logoMap[$empresaKey];
  1241.             $vendorLabel $vendorLabelMap[$empresaKey] ?? null;
  1242.             $this->logLogo('empresa_logo_save.log''Usando logo por vendor: ' $logoFile);
  1243.         }
  1244.         if ($logoFile === null || $logoFile === '') {
  1245.             $this->logLogo('empresa_logo_save.log''logoFile está vacío. No hago nada.');
  1246.             return;
  1247.         }
  1248.         $this->logLogo('empresa_logo_save.log''Voy a guardar en parametros.logo: ' $logoFile);
  1249.         $mysqli->begin_transaction();
  1250.         try {
  1251.             $stmt $mysqli->prepare("
  1252.                 INSERT INTO parametros (nombre, valor)
  1253.                 VALUES (?, ?)
  1254.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  1255.             ");
  1256.             if (!$stmt) {
  1257.                 $this->logLogo('empresa_logo_save.log''Error prepare: ' $mysqli->error);
  1258.                 throw new \RuntimeException('Prepare logo: ' $mysqli->error);
  1259.             }
  1260.             $paramName 'logo';
  1261.             $stmt->bind_param('ss'$paramName$logoFile);
  1262.             if (!$stmt->execute()) {
  1263.                 $this->logLogo('empresa_logo_save.log''Error execute: ' $stmt->error);
  1264.                 throw new \RuntimeException('Guardar parámetro logo: ' $stmt->error);
  1265.             }
  1266.             if (isset($vendorLabel) && $vendorLabel !== '') {
  1267.                 $paramName 'vendor';
  1268.                 $stmt->bind_param('ss'$paramName$vendorLabel);
  1269.                 if (!$stmt->execute()) {
  1270.                     $this->logLogo('empresa_logo_save.log''Error execute vendor: ' $stmt->error);
  1271.                     throw new \RuntimeException('Guardar parámetro vendor: ' $stmt->error);
  1272.                 }
  1273.             }
  1274.             $stmt->close();
  1275.             $mysqli->commit();
  1276.             $this->logLogo('empresa_logo_save.log''Logo guardado correctamente en BD.');
  1277.         } catch (\Throwable $e) {
  1278.             $mysqli->rollback();
  1279.             $this->logLogo('empresa_logo_save.log''EXCEPCIÓN: ' $e->getMessage());
  1280.             throw $e;
  1281.         }
  1282.     }
  1283.     private function uploadEmpresaLogo(Request $req): ?string
  1284.     {
  1285.         $this->logLogo('empresa_logo_upload.log''--- NUEVA LLAMADA uploadEmpresaLogo ---');
  1286.         // name del checkbox en el formulario (ajústalo si usas otro)
  1287.         $useCustomLogo $req->request->get('customLogoCheck');
  1288.         $this->logLogo('empresa_logo_upload.log''customLogoCheck = ' var_export($useCustomLogotrue));
  1289.         // Si no marcaron "usar logo personalizado", no hacemos nada
  1290.         if (!$useCustomLogo) {
  1291.             $this->logLogo('empresa_logo_upload.log''No se ha marcado customLogoCheck. Salgo sin subir.');
  1292.             return null;
  1293.         }
  1294.         /** @var UploadedFile|null $file */
  1295.         $file $req->files->get('logo_personalizado'); // name="logo_personalizado" en el input file
  1296.         $this->logLogo('empresa_logo_upload.log''FILES[logo_personalizado] = ' print_r($filetrue));
  1297.         if (!$file instanceof UploadedFile || !$file->isValid()) {
  1298.             $this->logLogo('empresa_logo_upload.log''File no es UploadedFile válido. Salgo sin subir.');
  1299.             return null;
  1300.         }
  1301.         // VALIDACIONES BÁSICAS
  1302.         $maxSize 1024 1024// 2 MB por ejemplo
  1303.         if ($file->getSize() > $maxSize) {
  1304.             $this->logLogo('empresa_logo_upload.log''Tamańo demasiado grande: ' $file->getSize());
  1305.             throw new \RuntimeException('El logo personalizado es demasiado grande (máx 2MB).');
  1306.         }
  1307.         $mime $file->getMimeType();
  1308.         $allowedMimeTypes = ['image/png''image/jpeg''image/webp''image/svg+xml'];
  1309.         $this->logLogo('empresa_logo_upload.log''MIME = ' $mime);
  1310.         if (!in_array($mime$allowedMimeTypestrue)) {
  1311.             $this->logLogo('empresa_logo_upload.log''MIME no permitido.');
  1312.             throw new \RuntimeException('Formato de logo no permitido. Usa PNG, JPG, WEBP o SVG.');
  1313.         }
  1314.         // Directorio destino según entorno (configurado en .env/.env.local)
  1315.         $targetDir $_ENV['APP_LOGO_DIR'] ?? null;
  1316.         $this->logLogo('empresa_logo_upload.log''APP_LOGO_DIR = ' var_export($targetDirtrue));
  1317.         if (!$targetDir) {
  1318.             throw new \RuntimeException('APP_LOGO_DIR no está configurado en el entorno.');
  1319.         }
  1320.         if (!is_dir($targetDir)) {
  1321.             $this->logLogo('empresa_logo_upload.log'"El directorio no existe: $targetDir");
  1322.             throw new \RuntimeException("El directorio de logos no existe: $targetDir");
  1323.         }
  1324.         if (!is_writable($targetDir)) {
  1325.             $this->logLogo('empresa_logo_upload.log'"El directorio no es escribible: $targetDir");
  1326.             throw new \RuntimeException("El directorio de logos no es escribible: $targetDir");
  1327.         }
  1328.         // Nombre de archivo "seguro" y único
  1329.         $ext $file->guessExtension() ?: 'png';
  1330.         $fileName 'logo_empresa_' bin2hex(random_bytes(6)) . '.' $ext;
  1331.         $this->logLogo('empresa_logo_upload.log'"Voy a mover archivo como: $fileName");
  1332.         // Mover físicamente el archivo
  1333.         $file->move($targetDir$fileName);
  1334.         $this->logLogo('empresa_logo_upload.log'"Fichero movido OK a $targetDir/$fileName");
  1335.         // Devolvemos SOLO el nombre, que es lo que se guardará en parametros.logo
  1336.         return $fileName;
  1337.     }
  1338.     private function logLogo(string $fileNamestring $message): void
  1339.     {
  1340.         // Directorio de logs de Symfony (donde está dev.log/prod.log)
  1341.         $logDir $this->getParameter('kernel.logs_dir');
  1342.         $fullPath rtrim($logDir'/') . '/' $fileName;
  1343.         $line sprintf(
  1344.             "[%s] %s\n",
  1345.             date('Y-m-d H:i:s'),
  1346.             $message
  1347.         );
  1348.         file_put_contents($fullPath$lineFILE_APPEND);
  1349.     }
  1350.     private function loadEmpresaLogo(\mysqli $mysqli): ?string
  1351.     {
  1352.         $sql "SELECT valor FROM parametros WHERE nombre = 'logo' LIMIT 1";
  1353.         $res $mysqli->query($sql);
  1354.         if (!$res) {
  1355.             return null;
  1356.         }
  1357.         if ($row $res->fetch_assoc()) {
  1358.             return $row['valor'] ?? null;
  1359.         }
  1360.         return null;
  1361.     }
  1362.     private function upsertLicense(\mysqli $mysqli, array $dataint $capacityGb 200int $activeUsers 3): void
  1363.     {
  1364.         $clientName = (string)($data['name'] ?? '');
  1365.         $licenseStr  'Documanager 1.0';
  1366.         $initialDate date('Y-m-d');
  1367.         $price       0;
  1368.         $emailSender 'Documanager.es';
  1369.         $emailFrom   'no-reply@documanager.es';
  1370.         $emailName   'Documanager';
  1371.         $smtpHost    'documanager.es';
  1372.         $smtpUser    'no-reply@documanager.es';
  1373.         $smtpPort    587;
  1374.         $smtpPass    'Documanager1!';
  1375.         $ins $mysqli->prepare("
  1376.             INSERT INTO license
  1377.                 (client, license, initialDate, capacityGb, users, price, emailSender, emailFrom, emailName, smtpHost, smtpUser, smtpPort, smtpPass)
  1378.             VALUES
  1379.                 (?,      ?,       ?,          ?,          ?,     ?,     ?,           ?,         ?,         ?,        ?,        ?,        ?)
  1380.         ");
  1381.         if (!$ins) {
  1382.             throw new \RuntimeException('Prepare INSERT license: ' $mysqli->error);
  1383.         }
  1384.         $ins->bind_param(
  1385.             'sssiiisssssis',
  1386.             $clientName,
  1387.             $licenseStr,
  1388.             $initialDate,
  1389.             $capacityGb,
  1390.             $activeUsers,
  1391.             $price,
  1392.             $emailSender,
  1393.             $emailFrom,
  1394.             $emailName,
  1395.             $smtpHost,
  1396.             $smtpUser,
  1397.             $smtpPort,
  1398.             $smtpPass
  1399.         );
  1400.         if (!$ins->execute()) {
  1401.             $ins->close();
  1402.             throw new \RuntimeException('Execute INSERT license: ' $ins->error);
  1403.         }
  1404.         $ins->close();
  1405.     }
  1406.     private function mapAzureSchemaToDefinitions(array $modelDetail): array
  1407.     {
  1408.         $docTypes $modelDetail['docTypes'] ?? [];
  1409.         if (!is_array($docTypes) || $docTypes === []) {
  1410.             return ['header' => [], 'lines' => []];
  1411.         }
  1412.         $modelId = (string)($modelDetail['modelId'] ?? '');
  1413.         $docTypeKey = ($modelId !== '' && array_key_exists($modelId$docTypes))
  1414.             ? $modelId
  1415.             array_key_first($docTypes);
  1416.         if (!is_string($docTypeKey) || !isset($docTypes[$docTypeKey]) || !is_array($docTypes[$docTypeKey])) {
  1417.             return ['header' => [], 'lines' => []];
  1418.         }
  1419.         $fieldSchema $docTypes[$docTypeKey]['fieldSchema'] ?? [];
  1420.         if (!is_array($fieldSchema)) {
  1421.             return ['header' => [], 'lines' => []];
  1422.         }
  1423.         $rawHeader = [];
  1424.         $rawLines = [];
  1425.         foreach ($fieldSchema as $fieldKey => $fieldDef) {
  1426.             if (!is_string($fieldKey) || !is_array($fieldDef)) {
  1427.                 continue;
  1428.             }
  1429.             $this->flattenFieldSchema($fieldKey$fieldDef$rawHeader$rawLines);
  1430.         }
  1431.         $header = [];
  1432.         $lines = [];
  1433.         $seenHeader = [];
  1434.         $seenLines = [];
  1435.         foreach ($rawHeader as $item) {
  1436.             $key = (string)($item['field_key'] ?? '');
  1437.             if ($key === '' || isset($seenHeader[$key])) {
  1438.                 continue;
  1439.             }
  1440.             $seenHeader[$key] = true;
  1441.             $header[] = [
  1442.                 'field_key' => $key,
  1443.                 'label' => $key,
  1444.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  1445.             ];
  1446.         }
  1447.         foreach ($rawLines as $item) {
  1448.             $key = (string)($item['field_key'] ?? '');
  1449.             if ($key === '' || isset($seenLines[$key])) {
  1450.                 continue;
  1451.             }
  1452.             $seenLines[$key] = true;
  1453.             $lines[] = [
  1454.                 'field_key' => $key,
  1455.                 'label' => $key,
  1456.                 'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
  1457.             ];
  1458.         }
  1459.         foreach ($header as $index => &$item) {
  1460.             $order $index 1;
  1461.             $item['order_index'] = $order;
  1462.             $item['order_index_table'] = $order;
  1463.             $item['visibility'] = 1;
  1464.             $item['visibility_table'] = 1;
  1465.         }
  1466.         foreach ($lines as $index => &$item) {
  1467.             $item['order_index'] = $index 1;
  1468.             $item['visibility'] = 1;
  1469.         }
  1470.         return ['header' => $header'lines' => $lines];
  1471.     }
  1472.     private function flattenFieldSchema(
  1473.         string $fieldKey,
  1474.         array $fieldDef,
  1475.         array &$header,
  1476.         array &$lines,
  1477.         bool $insideItems false,
  1478.         string $parentPath ''
  1479.     ): void {
  1480.         $type strtolower((string)($fieldDef['type'] ?? 'string'));
  1481.         $currentPath $parentPath !== '' $parentPath '.' $fieldKey $fieldKey;
  1482.         if ($insideItems) {
  1483.             if ($type === 'object') {
  1484.                 $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  1485.                 if (is_array($properties)) {
  1486.                     foreach ($properties as $childKey => $childDef) {
  1487.                         if (is_string($childKey) && is_array($childDef)) {
  1488.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue$currentPath);
  1489.                         }
  1490.                     }
  1491.                 }
  1492.                 return;
  1493.             }
  1494.             if ($type === 'array') {
  1495.                 $itemsDef $fieldDef['items'] ?? [];
  1496.                 $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  1497.                 if ($itemType === 'object') {
  1498.                     $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  1499.                     if (is_array($properties)) {
  1500.                         $arrayPath $currentPath '[*]';
  1501.                         foreach ($properties as $childKey => $childDef) {
  1502.                             if (is_string($childKey) && is_array($childDef)) {
  1503.                                 $this->flattenFieldSchema($childKey$childDef$header$linestrue$arrayPath);
  1504.                             }
  1505.                         }
  1506.                     }
  1507.                 } else {
  1508.                     $lines[] = ['field_key' => $currentPath'azure_type' => $itemType];
  1509.                 }
  1510.                 return;
  1511.             }
  1512.             $lines[] = ['field_key' => $currentPath'azure_type' => $type];
  1513.             return;
  1514.         }
  1515.         if ($type === 'object') {
  1516.             $properties $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
  1517.             if (is_array($properties)) {
  1518.                 foreach ($properties as $childKey => $childDef) {
  1519.                     if (is_string($childKey) && is_array($childDef)) {
  1520.                         $this->flattenFieldSchema($childKey$childDef$header$linesfalse$currentPath);
  1521.                     }
  1522.                 }
  1523.             }
  1524.             return;
  1525.         }
  1526.         if ($type === 'array') {
  1527.             $itemsDef $fieldDef['items'] ?? [];
  1528.             $itemType strtolower((string)($itemsDef['type'] ?? 'string'));
  1529.             $isItems strtolower($fieldKey) === 'items';
  1530.             if ($itemType === 'object') {
  1531.                 $properties $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
  1532.                 if (!is_array($properties)) {
  1533.                     return;
  1534.                 }
  1535.                 if ($isItems) {
  1536.                     foreach ($properties as $childKey => $childDef) {
  1537.                         if (is_string($childKey) && is_array($childDef)) {
  1538.                             $this->flattenFieldSchema($childKey$childDef$header$linestrue'');
  1539.                         }
  1540.                     }
  1541.                 } else {
  1542.                     $arrayPath $currentPath '[*]';
  1543.                     foreach ($properties as $childKey => $childDef) {
  1544.                         if (is_string($childKey) && is_array($childDef)) {
  1545.                             $this->flattenFieldSchema($childKey$childDef$header$linesfalse$arrayPath);
  1546.                         }
  1547.                     }
  1548.                 }
  1549.                 return;
  1550.             }
  1551.             $header[] = ['field_key' => $currentPath'azure_type' => $itemType];
  1552.             return;
  1553.         }
  1554.         $header[] = ['field_key' => $currentPath'azure_type' => $type];
  1555.     }
  1556.     private function mapAzureTypeToValueType(string $azureType): string
  1557.     {
  1558.         $azureType strtolower(trim($azureType));
  1559.         if ($azureType === 'date') {
  1560.             return 'date';
  1561.         }
  1562.         if ($azureType === 'number' || $azureType === 'integer') {
  1563.             return 'number';
  1564.         }
  1565.         return 'string';
  1566.     }
  1567.     private function registerModelInClientDb(\mysqli $clientMysqli, array $resourcestring $modelId): int
  1568.     {
  1569.         $modelId trim($modelId);
  1570.         if ($modelId === '') {
  1571.             throw new \RuntimeException('ModelId de recurso IA obligatorio.'400);
  1572.         }
  1573.         $modelDetail $this->azureRequest(
  1574.             (string)($resource['endpoint'] ?? ''),
  1575.             (string)($resource['api_key'] ?? ''),
  1576.             '/documentintelligence/documentModels/' rawurlencode($modelId)
  1577.         );
  1578.         $type $this->classifyModelType($modelDetail);
  1579.         $definitions $this->mapAzureSchemaToDefinitions($modelDetail);
  1580.         $endpoint $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
  1581.         $apiKey = (string)($resource['api_key'] ?? '');
  1582.         $showConfidenceBadges 1;
  1583.         $clientMysqli->begin_transaction();
  1584.         try {
  1585.             $existingId 0;
  1586.             $stmtSelect $clientMysqli->prepare(
  1587.                 "SELECT id FROM extraction_models WHERE provider = 'azure-di' AND model_id = ? LIMIT 1"
  1588.             );
  1589.             if (!$stmtSelect) {
  1590.                 throw new \RuntimeException('Prepare SELECT extraction_models: ' $clientMysqli->error);
  1591.             }
  1592.             $stmtSelect->bind_param('s'$modelId);
  1593.             if (!$stmtSelect->execute()) {
  1594.                 $stmtSelect->close();
  1595.                 throw new \RuntimeException('Execute SELECT extraction_models: ' $stmtSelect->error);
  1596.             }
  1597.             $result $stmtSelect->get_result();
  1598.             if ($result && ($row $result->fetch_assoc())) {
  1599.                 $existingId = (int)$row['id'];
  1600.             }
  1601.             $stmtSelect->close();
  1602.             if ($existingId 0) {
  1603.                 $stmtUpdate $clientMysqli->prepare(
  1604.                     'UPDATE extraction_models
  1605.                      SET endpoint = ?, api_key = ?, type = ?, show_confidence_badges = ?
  1606.                      WHERE id = ?'
  1607.                 );
  1608.                 if (!$stmtUpdate) {
  1609.                     throw new \RuntimeException('Prepare UPDATE extraction_models: ' $clientMysqli->error);
  1610.                 }
  1611.                 $stmtUpdate->bind_param('sssii'$endpoint$apiKey$type$showConfidenceBadges$existingId);
  1612.                 if (!$stmtUpdate->execute()) {
  1613.                     $stmtUpdate->close();
  1614.                     throw new \RuntimeException('Execute UPDATE extraction_models: ' $stmtUpdate->error);
  1615.                 }
  1616.                 $stmtUpdate->close();
  1617.                 $localModelId $existingId;
  1618.             } else {
  1619.                 $provider 'azure-di';
  1620.                 $stmtInsert $clientMysqli->prepare(
  1621.                     'INSERT INTO extraction_models (provider, model_id, endpoint, api_key, type, show_confidence_badges)
  1622.                      VALUES (?, ?, ?, ?, ?, ?)'
  1623.                 );
  1624.                 if (!$stmtInsert) {
  1625.                     throw new \RuntimeException('Prepare INSERT extraction_models: ' $clientMysqli->error);
  1626.                 }
  1627.                 $stmtInsert->bind_param('sssssi'$provider$modelId$endpoint$apiKey$type$showConfidenceBadges);
  1628.                 if (!$stmtInsert->execute()) {
  1629.                     $stmtInsert->close();
  1630.                     throw new \RuntimeException('Execute INSERT extraction_models: ' $stmtInsert->error);
  1631.                 }
  1632.                 $localModelId = (int)$stmtInsert->insert_id;
  1633.                 $stmtInsert->close();
  1634.             }
  1635.             $stmtDelHeader $clientMysqli->prepare('DELETE FROM definitions_header WHERE model_id = ?');
  1636.             if (!$stmtDelHeader) {
  1637.                 throw new \RuntimeException('Prepare DELETE definitions_header: ' $clientMysqli->error);
  1638.             }
  1639.             $stmtDelHeader->bind_param('i'$localModelId);
  1640.             if (!$stmtDelHeader->execute()) {
  1641.                 $stmtDelHeader->close();
  1642.                 throw new \RuntimeException('Execute DELETE definitions_header: ' $stmtDelHeader->error);
  1643.             }
  1644.             $stmtDelHeader->close();
  1645.             $stmtDelLines $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
  1646.             if (!$stmtDelLines) {
  1647.                 throw new \RuntimeException('Prepare DELETE definitions_lines: ' $clientMysqli->error);
  1648.             }
  1649.             $stmtDelLines->bind_param('i'$localModelId);
  1650.             if (!$stmtDelLines->execute()) {
  1651.                 $stmtDelLines->close();
  1652.                 throw new \RuntimeException('Execute DELETE definitions_lines: ' $stmtDelLines->error);
  1653.             }
  1654.             $stmtDelLines->close();
  1655.             if (!empty($definitions['header'])) {
  1656.                 $stmtHeader $clientMysqli->prepare(
  1657.                     'INSERT INTO definitions_header
  1658.                     (model_id, field_key, label, value_type, order_index, visibility, order_index_table, visibility_table)
  1659.                     VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
  1660.                 );
  1661.                 if (!$stmtHeader) {
  1662.                     throw new \RuntimeException('Prepare INSERT definitions_header: ' $clientMysqli->error);
  1663.                 }
  1664.                 foreach ($definitions['header'] as $item) {
  1665.                     $fieldKey = (string)$item['field_key'];
  1666.                     $label = (string)$item['label'];
  1667.                     $valueType = (string)$item['value_type'];
  1668.                     $orderIndex = (int)$item['order_index'];
  1669.                     $visibility = (int)$item['visibility'];
  1670.                     $orderIndexTable = (int)$item['order_index_table'];
  1671.                     $visibilityTable = (int)$item['visibility_table'];
  1672.                     $stmtHeader->bind_param(
  1673.                         'isssiiii',
  1674.                         $localModelId,
  1675.                         $fieldKey,
  1676.                         $label,
  1677.                         $valueType,
  1678.                         $orderIndex,
  1679.                         $visibility,
  1680.                         $orderIndexTable,
  1681.                         $visibilityTable
  1682.                     );
  1683.                     if (!$stmtHeader->execute()) {
  1684.                         $stmtHeader->close();
  1685.                         throw new \RuntimeException('Execute INSERT definitions_header: ' $stmtHeader->error);
  1686.                     }
  1687.                 }
  1688.                 $stmtHeader->close();
  1689.             }
  1690.             if (!empty($definitions['lines'])) {
  1691.                 $stmtLines $clientMysqli->prepare(
  1692.                     'INSERT INTO definitions_lines
  1693.                     (model_id, field_key, label, value_type, order_index, visibility)
  1694.                     VALUES (?, ?, ?, ?, ?, ?)'
  1695.                 );
  1696.                 if (!$stmtLines) {
  1697.                     throw new \RuntimeException('Prepare INSERT definitions_lines: ' $clientMysqli->error);
  1698.                 }
  1699.                 foreach ($definitions['lines'] as $item) {
  1700.                     $fieldKey = (string)$item['field_key'];
  1701.                     $label = (string)$item['label'];
  1702.                     $valueType = (string)$item['value_type'];
  1703.                     $orderIndex = (int)$item['order_index'];
  1704.                     $visibility = (int)$item['visibility'];
  1705.                     $stmtLines->bind_param(
  1706.                         'isssii',
  1707.                         $localModelId,
  1708.                         $fieldKey,
  1709.                         $label,
  1710.                         $valueType,
  1711.                         $orderIndex,
  1712.                         $visibility
  1713.                     );
  1714.                     if (!$stmtLines->execute()) {
  1715.                         $stmtLines->close();
  1716.                         throw new \RuntimeException('Execute INSERT definitions_lines: ' $stmtLines->error);
  1717.                     }
  1718.                 }
  1719.                 $stmtLines->close();
  1720.             }
  1721.             $clientMysqli->commit();
  1722.             return $localModelId;
  1723.         } catch (\Throwable $e) {
  1724.             $clientMysqli->rollback();
  1725.             throw $e;
  1726.         }
  1727.     }
  1728.     public function Empresa(Request $reqEntityManagerInterface $em)
  1729.     {
  1730.         if (!$this->getUser() || !is_object($this->getUser())) {
  1731.             return $this->redirectToRoute('logout');
  1732.         }
  1733.         $id = (int)$req->get("id");
  1734.         if (!$id) {
  1735.             $this->addFlash('warning''Empresa no encontrada.');
  1736.             return $this->redirectToRoute("list");
  1737.         }
  1738.         $empresa $em->getRepository(Empresa::class)->find($id);
  1739.         if (!$empresa) {
  1740.             $this->addFlash('warning''Empresa no encontrada.');
  1741.             return $this->redirectToRoute("list");
  1742.         }
  1743.         $users $em->getRepository(Usuario::class)->findBy([
  1744.             "empresa" => $empresa->getId()
  1745.         ]);
  1746.         // Valores por defecto por si algo falla al conectar con la BD del cliente
  1747.         $activeUsers            null;
  1748.         $modulos                = [];
  1749.         $empresaLogo            null;
  1750.         $extractionModelLabel   null;
  1751.         $diskUsedBytes          null;
  1752.         $diskUsedGb             null;
  1753.         try {
  1754.             $cx $empresa->getConexionBD();
  1755.             if ($cx) {
  1756.                 $mysqli = @new \mysqli(
  1757.                     $cx->getDbUrl(),
  1758.                     $cx->getDbUser(),
  1759.                     $cx->getDbPassword(),
  1760.                     $cx->getDbName(),
  1761.                     (int)$cx->getDbPort()
  1762.                 );
  1763.                 if (!$mysqli->connect_error) {
  1764.                     // parámetros (modulos, límites, etc.)
  1765.                     [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  1766.                     // logo guardado en la BD del cliente
  1767.                     $empresaLogo $this->loadEmpresaLogo($mysqli);
  1768.                     $diskUsedBytes $this->loadEmpresaDiskUsageBytes($mysqli);
  1769.                     $diskUsedGb = ($diskUsedBytes !== null)
  1770.                         ? round($diskUsedBytes 1024 1024 10242)
  1771.                         : null;
  1772.                     // Si tiene extracción y modelo seleccionado, buscamos el ID legible del modelo
  1773.                     if (
  1774.                         !empty($modulos['modulo_extraccion']) &&
  1775.                         !empty($modulos['extraction_model'])
  1776.                     ) {
  1777.                         try {
  1778.                             $stmt $mysqli->prepare('SELECT model_id FROM extraction_models WHERE id = ? LIMIT 1');
  1779.                             if ($stmt) {
  1780.                                 $modelParam = (int)$modulos['extraction_model'];
  1781.                                 $stmt->bind_param('i'$modelParam);
  1782.                                 if ($stmt->execute()) {
  1783.                                     $res $stmt->get_result();
  1784.                                     if ($res && ($row $res->fetch_assoc()) && isset($row['model_id'])) {
  1785.                                         $extractionModelLabel $row['model_id'];
  1786.                                     }
  1787.                                 }
  1788.                                 $stmt->close();
  1789.                             }
  1790.                         } catch (\Throwable $e) {
  1791.                             // Si falla, simplemente no mostramos el texto bonito del modelo
  1792.                             $extractionModelLabel null;
  1793.                         }
  1794.                     }
  1795.                     $mysqli->close();
  1796.                 }
  1797.             }
  1798.         } catch (\Throwable $e) {
  1799.             // Aquí podrías loguear el error si quieres, pero no rompemos la pantalla
  1800.         }
  1801.         return $this->render('empresa_detail.html.twig', [
  1802.             'empresa'              => $empresa,
  1803.             'users'                => $users,
  1804.             'activeUsers'          => $activeUsers,
  1805.             'modulos'              => $modulos,
  1806.             'empresaLogo'          => $empresaLogo,
  1807.             'extractionModelLabel' => $extractionModelLabel,
  1808.             'diskUsedGb' => $diskUsedGb,
  1809.         ]);
  1810.     }
  1811.     public function deleteEmpresa(Request $requestEntityManagerInterface $em)
  1812.     {
  1813.         $id $request->get("id");
  1814.         $empresa $em->getRepository(Empresa::class)->find($id);
  1815.         $conexion $empresa->getConexionBD();
  1816.         $usuarios $em->getRepository(Usuario::class)->findBy(array("empresa" => $empresa->getId()));
  1817.         // Recoger avatar/firma de usuarios antes de eliminar la BD del cliente
  1818.         $mediaPaths = [];
  1819.         try {
  1820.             $mysqliMedia = @new \mysqli(
  1821.                 $conexion->getDbUrl(),
  1822.                 $conexion->getDbUser(),
  1823.                 $conexion->getDbPassword(),
  1824.                 $conexion->getDbName(),
  1825.                 (int)$conexion->getDbPort()
  1826.             );
  1827.             if (!$mysqliMedia->connect_error) {
  1828.                 $mediaPaths $this->getCompanyUserMediaPaths($mysqliMedia);
  1829.                 $mysqliMedia->close();
  1830.             } else {
  1831.                 error_log('No se pudo conectar a BD cliente para borrar media: ' $mysqliMedia->connect_error);
  1832.             }
  1833.         } catch (\Throwable $e) {
  1834.             error_log('Error borrando media de usuarios: ' $e->getMessage());
  1835.         }
  1836.         $hestiaApiUrl 'https://200.234.237.107:8083/api/';
  1837.         $owner 'docunecta'// o el dueño del hosting
  1838.         $postFields http_build_query([
  1839.             'user' => 'admin',
  1840.             'password' => 'i9iQiSmxb2EpvgLq',
  1841.             'returncode' => 'yes',
  1842.             'cmd' => 'v-delete-database',
  1843.             'arg1' => 'admin',
  1844.             'arg2' => $conexion->getDbName(),
  1845.         ]);
  1846.         $accessKeyId 'cWYbt9ShyFQ3yVRsUE8u';
  1847.         $secretKey 'e2M_5wk2_jUAlPorF7V8zfwo3_0ihu90WoLPMKwj';
  1848.         $headers = [
  1849.             'Authorization: Bearer ' $accessKeyId ':' $secretKey
  1850.         ];
  1851.         $ch curl_init();
  1852.         curl_setopt($chCURLOPT_URL$hestiaApiUrl);
  1853.         curl_setopt($chCURLOPT_HTTPHEADER$headers);
  1854.         curl_setopt($chCURLOPT_RETURNTRANSFERtrue);
  1855.         curl_setopt($chCURLOPT_SSL_VERIFYHOSTfalse);
  1856.         curl_setopt($chCURLOPT_POSTtrue);
  1857.         curl_setopt($chCURLOPT_POSTFIELDS$postFields);
  1858.         curl_setopt($chCURLOPT_SSL_VERIFYPEERfalse); // Solo si usas certificados autofirmados
  1859.         $response curl_exec($ch);
  1860.         $error curl_error($ch);
  1861.         curl_close($ch);
  1862.         if (($error || trim($response) !== '0') && trim($response) !== '3') {
  1863.             $this->addFlash('danger''Error al eliminar la base de datos en HestiaCP: ' . ($error ?: $response));
  1864.             return $this->redirectToRoute('list');
  1865.         }
  1866.         // Eliminar el servicio systemd asociado a la empresa
  1867.         $company_name $empresa->getId();
  1868.         $serviceName $company_name "-documanager.service";
  1869.         $servicePath "/etc/systemd/system/$serviceName";
  1870.         $cmds = [
  1871.             "sudo /bin/systemctl stop $serviceName",
  1872.             "sudo /bin/systemctl disable $serviceName",
  1873.             "sudo /bin/rm -f $servicePath",
  1874.             "sudo /bin/systemctl daemon-reload"
  1875.         ];
  1876.         $serviceErrors = [];
  1877.         foreach ($cmds as $cmd) {
  1878.             $output = @\shell_exec($cmd " 2>&1");
  1879.             if ($output !== null && trim($output) !== '') {
  1880.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  1881.             }
  1882.         }
  1883.         $azureService $company_name "-azuredi.service";
  1884.         $servicePathAzure "/etc/systemd/system/$azureService";
  1885.         $cmdsAzure = [
  1886.             "sudo /bin/systemctl stop $azureService",
  1887.             "sudo /bin/systemctl disable $azureService",
  1888.             "sudo /bin/rm -f $servicePathAzure",
  1889.             "sudo /bin/systemctl daemon-reload",
  1890.         ];
  1891.         foreach ($cmdsAzure as $cmd) {
  1892.             $output = @\shell_exec($cmd " 2>&1");
  1893.             if ($output) {
  1894.                 $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  1895.             }
  1896.         }
  1897.         // Eliminar mail monitor service + timer (si existen)
  1898.         $this->disableMailMonitorService((int)$company_name);
  1899.         // Pedir a platform que elimine files/logs/media del cliente
  1900.         $this->callPlatformCleanup((int)$company_name$mediaPaths);
  1901.         //eliminamos usuarios
  1902.         foreach ($usuarios as $user) {
  1903.             $em->remove($user);
  1904.             $em->flush();
  1905.         }
  1906.         //eliminamos conexiĂłn
  1907.         $em->remove($conexion);
  1908.         $em->flush();
  1909.         //eliminamos empresa
  1910.         $em->remove($empresa);
  1911.         $em->flush();
  1912.         $msg 'Empresa y base de datos eliminadas correctamente.';
  1913.         if (count($serviceErrors) > 0) {
  1914.             $msg .= ' ' implode('<br>'$serviceErrors);
  1915.         }
  1916.         $this->addFlash('success'$msg);
  1917.         return $this->redirectToRoute('list');
  1918.     }
  1919.     public function editEmpresa(Request $requestEntityManagerInterface $em)
  1920.     {
  1921.         if (!$this->getUser() || !is_object($this->getUser())) {
  1922.             return $this->redirectToRoute('logout');
  1923.         }
  1924.         $id = (int)$request->get('id');
  1925.         $empresa $em->getRepository(Empresa::class)->find($id);
  1926.         if (!$empresa) {
  1927.             throw $this->createNotFoundException('Empresa no encontrada');
  1928.         }
  1929.         // 1) Conectar a la BD del cliente con las credenciales de la central
  1930.         $cx $empresa->getConexionBD();
  1931.         $mysqli = @new \mysqli(
  1932.             $cx->getDbUrl(),
  1933.             $cx->getDbUser(),
  1934.             $cx->getDbPassword(),
  1935.             $cx->getDbName(),
  1936.             (int)$cx->getDbPort()
  1937.         );
  1938.         if ($mysqli->connect_error) {
  1939.             $this->addFlash('danger''No se puede conectar a la BD del cliente: ' $mysqli->connect_error);
  1940.             return $this->redirectToRoute('list');
  1941.         }
  1942.         $azureResources = [];
  1943.         try {
  1944.             $azureResources $this->loadAzureDiResources($em);
  1945.         } catch (\Throwable $e) {
  1946.             $this->addFlash('warning''No se pudo cargar el catalogo de recursos IA: ' $e->getMessage());
  1947.         }
  1948.         if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
  1949.             // Estado previo de modulos (para detectar activacion de Mail Monitor)
  1950.             [$activeUsersPrev$modulosPrev] = $this->loadEmpresaParametros($mysqli);
  1951.             $prevMailMonitor = (int)($modulosPrev['modulo_mailMonitor'] ?? 0);
  1952.             $data $request->request->all();
  1953.             $maxThreads $this->clampDocuMaxThreads($data['maxThreads'] ?? null$empresa->getMaxThreads() ?: 4);
  1954.             // 2) Actualizar SOLO central
  1955.             $empresa->setName((string)($data['name'] ?? $empresa->getName()));
  1956.             $empresa->setMaxDiskQuota(
  1957.                 isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== ''
  1958.                     ? (int)$data['maxDiskQuota']
  1959.                     : $empresa->getMaxDiskQuota()
  1960.             );
  1961.             $empresa->setMaxThreads($maxThreads);
  1962.             $em->persist($empresa);
  1963.             // ---- Normalización de POST (checkboxes / select) ----
  1964.             $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  1965.             $extractionModel 0;
  1966.             if (isset($data['extraction_model']) && $data['extraction_model'] !== '') {
  1967.                 $extractionModel = (int)$data['extraction_model'];
  1968.             }
  1969.             $data['extraction_model'] = $extractionModel;
  1970.             $azureResourceId = (int)($data['azure_resource_id'] ?? 0);
  1971.             $azureModelId trim((string)($data['azure_model_id'] ?? ''));
  1972.             $data['modulo_extraccion']       = isset($data['modulo_extraccion'])       && $toBool($data['modulo_extraccion'])       ? 0;
  1973.             $data['modulo_etiquetas']        = isset($data['modulo_etiquetas'])        && $toBool($data['modulo_etiquetas'])        ? 0;
  1974.             $data['modulo_calendario']       = isset($data['modulo_calendario'])       && $toBool($data['modulo_calendario'])       ? 0;
  1975.             $data['modulo_calendarioExterno'] = isset($data['modulo_calendarioExterno']) && $toBool($data['modulo_calendarioExterno']) ? 0;
  1976.             $data['modulo_estados']          = isset($data['modulo_estados'])          && $toBool($data['modulo_estados'])          ? 0;
  1977.             $data['modulo_lineas']           = isset($data['modulo_lineas'])           && $toBool($data['modulo_lineas'])           ? 0;
  1978.             $data['modulo_agora']            = isset($data['modulo_agora'])           && $toBool($data['modulo_agora'])             ? 0;
  1979.             $data['modulo_gstock']           = isset($data['modulo_gstock'])           && $toBool($data['modulo_gstock'])           ? 0;
  1980.             $data['modulo_expowin']          = isset($data['modulo_expowin'])          && $toBool($data['modulo_expowin'])          ? 0;
  1981.             $data['modulo_mailMonitor']      = isset($data['modulo_mailMonitor'])      && $toBool($data['modulo_mailMonitor'])      ? 0;
  1982.             $data['modulo_busquedaNatural']  = isset($data['modulo_busquedaNatural'])  && $toBool($data['modulo_busquedaNatural'])  ? 0;
  1983.             $data['soloExtraccion']          = isset($data['soloExtraccion'])          && $toBool($data['soloExtraccion'])          ? 0;
  1984.             // Dependencias
  1985.             if (!$data['modulo_calendario']) {
  1986.                 $data['modulo_calendarioExterno'] = 0;
  1987.             }
  1988.             if (!$data['modulo_extraccion']) {
  1989.                 $data['modulo_lineas']   = 0;
  1990.                 $data['extraction_model'] = 0;
  1991.                 $data['modulo_agora']    = 0;
  1992.                 $data['modulo_gstock']   = 0;
  1993.                 $data['modulo_expowin']  = 0;
  1994.                 $azureResourceId 0;
  1995.                 $azureModelId '';
  1996.             }
  1997.             // 3) Guardar en BD del cliente: parametros + license
  1998.             if ($data['modulo_extraccion'] === 1) {
  1999.                 if (($azureResourceId && $azureModelId === '') || ($azureResourceId <= && $azureModelId !== '')) {
  2000.                     $this->addFlash('danger''Para importar desde recurso IA debes indicar recurso y modelo.');
  2001.                     $mysqli->close();
  2002.                     return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  2003.                 }
  2004.                 if ($azureResourceId && $azureModelId !== '') {
  2005.                     $azureResource $this->loadAzureDiResourceById($em$azureResourceId);
  2006.                     if (!$azureResource) {
  2007.                         $this->addFlash('danger''El recurso IA seleccionado no existe.');
  2008.                         $mysqli->close();
  2009.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  2010.                     }
  2011.                     try {
  2012.                         $data['extraction_model'] = $this->registerModelInClientDb(
  2013.                             $mysqli,
  2014.                             $azureResource,
  2015.                             $azureModelId
  2016.                         );
  2017.                         $this->addFlash('success''Modelo IA importado/actualizado correctamente.');
  2018.                     } catch (\Throwable $e) {
  2019.                         $this->addFlash('danger''No se pudo registrar el modelo IA: ' $e->getMessage());
  2020.                         $mysqli->close();
  2021.                         return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  2022.                     }
  2023.                 }
  2024.                 if ((int)$data['extraction_model'] <= 0) {
  2025.                     $this->addFlash('danger''Con extraccion activa debes seleccionar un modelo local o importar uno desde recurso IA.');
  2026.                     $mysqli->close();
  2027.                     return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
  2028.                 }
  2029.             }
  2030.             $this->updateEmpresaParametros($mysqli$data);
  2031.             $this->updateLicense(
  2032.                 $mysqli,
  2033.                 $data,
  2034.                 $empresa->getName()
  2035.             );
  2036.             // Actualizar servicio OCR (hilos)
  2037.             $ocrBinary $_ENV['OCR_BINARY'] ?? '';
  2038.             $filesPath $_ENV['FILES_PATH'] ?? '';
  2039.             if ($ocrBinary !== '' && $filesPath !== '') {
  2040.                 $companyId = (int)$empresa->getId();
  2041.                 $dbHost = (string)$cx->getDbUrl();
  2042.                 $dbName = (string)$cx->getDbName();
  2043.                 $dbUser = (string)$cx->getDbUser();
  2044.                 $dbPass = (string)$cx->getDbPassword();
  2045.                 $empresaName = (string)$empresa->getName();
  2046.                 $serviceContent = <<<EOT
  2047. [Unit]
  2048. Description={$empresaName} DocuManager OCR
  2049. Requires=mariadb.service
  2050. After=mariadb.service
  2051. [Service]
  2052. Type=simple
  2053. Environment="DOCU_MAX_THREADS=$maxThreads"
  2054. ExecStart=$ocrBinary {$dbHost}/{$dbName} {$dbUser} {$dbPass} {$filesPath}/{$companyId} NO
  2055. Restart=always
  2056. User=root
  2057. [Install]
  2058. WantedBy=multi-user.target
  2059. EOT;
  2060.                 $serviceName $companyId "-documanager.service";
  2061.                 $tmpServicePath "/tmp/$serviceName";
  2062.                 file_put_contents($tmpServicePath$serviceContent);
  2063.                 \chmod($tmpServicePath0644);
  2064.                 $cmds = [
  2065.                     "sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
  2066.                     "sudo /bin/systemctl daemon-reload",
  2067.                     "sudo /bin/systemctl restart $serviceName",
  2068.                 ];
  2069.                 $serviceErrors = [];
  2070.                 foreach ($cmds as $cmd) {
  2071.                     $output \shell_exec($cmd " 2>&1");
  2072.                     if ($output !== null && trim($output) !== '') {
  2073.                         error_log("CMD OUTPUT: $cmd\n$output");
  2074.                         $serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
  2075.                     }
  2076.                 }
  2077.                 if (count($serviceErrors) > 0) {
  2078.                     $this->addFlash('warning''Servicio OCR actualizado con avisos: ' implode(' | '$serviceErrors));
  2079.                 }
  2080.             } else {
  2081.                 $this->addFlash('warning''No se pudo actualizar el servicio OCR: faltan OCR_BINARY o FILES_PATH.');
  2082.             }
  2083.             // Si se activa Mail Monitor y antes estaba desactivado, crear servicio/timer
  2084.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === && $filesPath !== '') {
  2085.                 $this->ensureMailMonitorService(
  2086.                     (int)$empresa->getId(),
  2087.                     (string)$cx->getDbUrl(),
  2088.                     (string)$cx->getDbPort(),
  2089.                     (string)$cx->getDbUser(),
  2090.                     (string)$cx->getDbPassword(),
  2091.                     (string)$cx->getDbName(),
  2092.                     (string)$filesPath
  2093.                 );
  2094.             }
  2095.             // Si se desactiva Mail Monitor, parar y eliminar service/timer
  2096.             if ($prevMailMonitor === && (int)$data['modulo_mailMonitor'] === 0) {
  2097.                 $this->disableMailMonitorService((int)$empresa->getId());
  2098.             }
  2099.             $em->flush();
  2100.             $mysqli->close();
  2101.             // Mensaje de éxito
  2102.             $this->addFlash('success''Empresa editada correctamente.');
  2103.             return $this->redirectToRoute('app_empresa_show', ['id' => $id]);
  2104.         }
  2105.         // 4) GET: precargar desde BD del cliente
  2106.         [$activeUsers$modulos] = $this->loadEmpresaParametros($mysqli);
  2107.         $license $this->loadLicense($mysqli); // por si quieres mostrarlo
  2108.         $extractionModels = [];
  2109.         try {
  2110.             $res $mysqli->query('SELECT id, model_id FROM extraction_models ORDER BY model_id');
  2111.             if ($res) {
  2112.                 while ($row $res->fetch_assoc()) {
  2113.                     $extractionModels[] = [
  2114.                         'id' => (int)$row['id'],
  2115.                         'model_id' => (string)$row['model_id'],
  2116.                     ];
  2117.                 }
  2118.                 $res->free();
  2119.             }
  2120.         } catch (\Throwable $e) {
  2121.             $extractionModels = [];
  2122.         }
  2123.         $mysqli->close();
  2124.         return $this->render('empresa/_edit.html.twig', [
  2125.             'empresa'      => $empresa,   // central: name + maxDiskQuota
  2126.             'id'           => $id,
  2127.             'activeUsers'  => $activeUsers// cliente
  2128.             'modulos'      => $modulos,     // cliente
  2129.             'license'      => $license,     // opcional
  2130.             'azure_resources' => $azureResources,
  2131.             'extraction_models' => $extractionModels,
  2132.         ]);
  2133.     }
  2134.     private function loadEmpresaParametros(\mysqli $mysqli): array
  2135.     {
  2136.         // claves que nos interesan en la tabla parametros
  2137.         $keys = [
  2138.             'activeUsers',
  2139.             'soloExtraccion',
  2140.             'modulo_etiquetas',
  2141.             'modulo_calendario',
  2142.             'modulo_calExt',
  2143.             'modulo_estados',
  2144.             'modulo_subida',
  2145.             'modulo_mailMonitor',
  2146.             'modulo_busquedaNatural',
  2147.             'modulo_extraccion',
  2148.             'modulo_lineas',
  2149.             'modulo_agora',
  2150.             'modulo_gstock',
  2151.             'modulo_expowin',
  2152.             'limite_archivos',
  2153.             'extraction_model',
  2154.         ];
  2155.         $placeholders implode(','array_fill(0count($keys), '?'));
  2156.         $sql "SELECT nombre, valor FROM parametros WHERE nombre IN ($placeholders)";
  2157.         $stmt $mysqli->prepare($sql);
  2158.         if (!$stmt) {
  2159.             throw new \RuntimeException('Prepare SELECT parametros: ' $mysqli->error);
  2160.         }
  2161.         // bind dinámico
  2162.         $types str_repeat('s'count($keys));
  2163.         $stmt->bind_param($types, ...$keys);
  2164.         if (!$stmt->execute()) {
  2165.             $stmt->close();
  2166.             throw new \RuntimeException('Execute SELECT parametros: ' $stmt->error);
  2167.         }
  2168.         $res $stmt->get_result();
  2169.         $map = [];
  2170.         while ($row $res->fetch_assoc()) {
  2171.             $map[$row['nombre']] = $row['valor'];
  2172.         }
  2173.         $stmt->close();
  2174.         // defaults seguros
  2175.         $activeUsers = isset($map['activeUsers']) ? (int)$map['activeUsers'] : 3;
  2176.         $flags = [
  2177.             'soloExtraccion'   => isset($map['soloExtraccion'])   ? (int)$map['soloExtraccion']   : 0,
  2178.             'modulo_etiquetas'  => isset($map['modulo_etiquetas'])  ? (int)$map['modulo_etiquetas']  : 0,
  2179.             'modulo_calendario' => isset($map['modulo_calendario']) ? (int)$map['modulo_calendario'] : 0,
  2180.             'modulo_calExt'     => isset($map['modulo_calExt'])     ? (int)$map['modulo_calExt']     : 0,
  2181.             'modulo_estados'    => isset($map['modulo_estados'])    ? (int)$map['modulo_estados']    : 0,
  2182.             'modulo_subida'     => isset($map['modulo_subida'])     ? (int)$map['modulo_subida']     : 0,
  2183.             'modulo_extraccion' => isset($map['modulo_extraccion']) ? (int)$map['modulo_extraccion'] : 0,
  2184.             'modulo_lineas'     => isset($map['modulo_lineas'])     ? (int)$map['modulo_lineas']     : 0,
  2185.             'modulo_agora'      => isset($map['modulo_agora'])      ? (int)$map['modulo_agora']      : 0,
  2186.             'modulo_gstock'     => isset($map['modulo_gstock'])     ? (int)$map['modulo_gstock']     : 0,
  2187.             'modulo_expowin'    => isset($map['modulo_expowin'])    ? (int)$map['modulo_expowin']    : 0,
  2188.             'modulo_mailMonitor' => isset($map['modulo_mailMonitor']) ? (int)$map['modulo_mailMonitor'] : 0,
  2189.             'modulo_busquedaNatural' => isset($map['modulo_busquedaNatural']) ? (int)$map['modulo_busquedaNatural'] : 0,
  2190.         ];
  2191.         $limiteArchivos = isset($map['limite_archivos']) && $map['limite_archivos'] !== ''
  2192.             ? (int)$map['limite_archivos']
  2193.             : 500;
  2194.         // Lo ańadimos a $flags para no cambiar la firma del return
  2195.         $flags['limite_archivos'] = $limiteArchivos;
  2196.         // extraction_model: default 0 (sin modelo)
  2197.         $flags['extraction_model'] = isset($map['extraction_model']) && $map['extraction_model'] !== '' ? (int)$map['extraction_model'] : 0;
  2198.         return [$activeUsers$flags];
  2199.     }
  2200.     private function updateEmpresaParametros(\mysqli $mysqli, array $data): void
  2201.     {
  2202.         $toBool = fn($v) => in_array(strtolower((string)$v), ['1''on''true''yes'], true);
  2203.         $getInt  = fn(array $astring $kint $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
  2204.         $getFlag = fn(array $astring $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 0;
  2205.         $paramMap = [
  2206.             'activeUsers'        => $getInt($data'maxActiveUsers'3),
  2207.             'soloExtraccion'     => $getFlag($data'soloExtraccion'),
  2208.             'modulo_etiquetas'   => $getFlag($data'modulo_etiquetas'),
  2209.             'modulo_calendario'  => $getFlag($data'modulo_calendario'),
  2210.             'modulo_calExt'      => $getFlag($data'modulo_calendarioExterno'),
  2211.             'modulo_estados'     => $getFlag($data'modulo_estados'),
  2212.             'modulo_subida'      => $getFlag($data'modulo_subida'),
  2213.             'modulo_mailMonitor' => $getFlag($data'modulo_mailMonitor'),
  2214.             'modulo_busquedaNatural' => $getFlag($data'modulo_busquedaNatural'),
  2215.             'modulo_extraccion'  => $getFlag($data'modulo_extraccion'),
  2216.             'modulo_lineas'      => $getFlag($data'modulo_lineas'),
  2217.             'modulo_agora'       => $getFlag($data'modulo_agora'),
  2218.             'modulo_gstock'      => $getFlag($data'modulo_gstock'),
  2219.             'modulo_expowin'     => $getFlag($data'modulo_expowin'),
  2220.             'limite_archivos'    => $getInt($data'limite_archivos'500),
  2221.             'extraction_model'   => $getInt($data'extraction_model'0),
  2222.         ];
  2223.         if ($paramMap['modulo_extraccion'] === 0) {
  2224.             $paramMap['modulo_lineas'] = 0;
  2225.             $paramMap['extraction_model'] = 0;
  2226.             $paramMap['modulo_agora'] = 0;
  2227.             $paramMap['modulo_gstock'] = 0;
  2228.             $paramMap['modulo_expowin'] = 0;
  2229.         }
  2230.         if ($paramMap['modulo_calendario'] === 0) {
  2231.             $paramMap['modulo_calExt'] = 0;
  2232.         }
  2233.         $mysqli->begin_transaction();
  2234.         try {
  2235.             $stmt $mysqli->prepare("
  2236.                 INSERT INTO parametros (nombre, valor)
  2237.                 VALUES (?, ?)
  2238.                 ON DUPLICATE KEY UPDATE valor = VALUES(valor)
  2239.             ");
  2240.             if (!$stmt) {
  2241.                 throw new \RuntimeException('Prepare UPSERT parametros: ' $mysqli->error);
  2242.             }
  2243.             foreach ($paramMap as $nombre => $valor) {
  2244.                 $v = (string)$valor;
  2245.                 if (!$stmt->bind_param('ss'$nombre$v) || !$stmt->execute()) {
  2246.                     $stmt->close();
  2247.                     throw new \RuntimeException("Guardar parámetro {$nombre}{$stmt->error}");
  2248.                 }
  2249.             }
  2250.             $stmt->close();
  2251.             $mysqli->commit();
  2252.         } catch (\Throwable $e) {
  2253.             $mysqli->rollback();
  2254.             throw $e;
  2255.         }
  2256.     }
  2257.     private function loadLicense(\mysqli $mysqli): array
  2258.     {
  2259.         // Carga opcional para mostrar en la edición: capacityGb/users (u otros)
  2260.         $sql "SELECT client, capacityGb, users FROM license LIMIT 1";
  2261.         $res $mysqli->query($sql);
  2262.         if ($res && $row $res->fetch_assoc()) {
  2263.             return $row;
  2264.         }
  2265.         return [];
  2266.     }
  2267.     private function updateLicense(\mysqli $mysqli, array $datastring $clientName): void
  2268.     {
  2269.         $capacityGb  = isset($data['maxDiskQuota']) ? (int)$data['maxDiskQuota'] : 200;
  2270.         $activeUsers = isset($data['maxActiveUsers']) ? (int)$data['maxActiveUsers'] : 3;
  2271.         $stmt $mysqli->prepare("UPDATE license SET capacityGb = ?, users = ? WHERE client = ?");
  2272.         if (!$stmt) {
  2273.             throw new \RuntimeException('Prepare UPDATE license: ' $mysqli->error);
  2274.         }
  2275.         $stmt->bind_param('iis'$capacityGb$activeUsers$clientName);
  2276.         $stmt->execute();
  2277.         $stmt->close();
  2278.     }
  2279.     public function usersListEmpresa(Request $requestEntityManagerInterface $em)
  2280.     {
  2281.         $id $request->get("id");
  2282.         $users_empresa $em->getRepository(Usuario::class)->findBy(array("empresa" => $id));
  2283.         return $this->render('empresa/usersList.html.twig', array(
  2284.             'users' => $users_empresa,
  2285.             'id' => $id
  2286.         ));
  2287.     }
  2288.     private function loadEmpresaDiskUsageBytes(\mysqli $mysqli): ?int
  2289.     {
  2290.         // Si la tabla no existe o hay error, devolvemos null para no romper la vista
  2291.         $sql "SELECT COALESCE(SUM(size_bytes), 0) AS total_bytes FROM files";
  2292.         $res $mysqli->query($sql);
  2293.         if (!$res) {
  2294.             return null;
  2295.         }
  2296.         $row $res->fetch_assoc();
  2297.         $res->free();
  2298.         return isset($row['total_bytes']) ? (int)$row['total_bytes'] : 0;
  2299.     }
  2300.     // =======================================================================
  2301.     // VER DOCUMENTACION DE EMPRESAS
  2302.     // =======================================================================
  2303.     #[Route('/empresa/{id}/documentos'name'empresa_documentos'methods: ['GET'])]
  2304.     public function documentosEmpresa(Request $requestEntityManagerInterface $em)
  2305.     {
  2306.         if (!$this->getUser() || !is_object($this->getUser())) {
  2307.             return $this->redirectToRoute('logout');
  2308.         }
  2309.         $id = (int) $request->get('id');
  2310.         $empresa $em->getRepository(Empresa::class)->find($id);
  2311.         if (!$empresa) {
  2312.             $this->addFlash('warning''Empresa no encontrada.');
  2313.             return $this->redirectToRoute('list');
  2314.         }
  2315.         // Para el desplegable
  2316.         $empresas $em->getRepository(Empresa::class)->findAll();
  2317.         $baseUrl $this->getParameter('documanager_base_url');
  2318.         return $this->render('empresa/documentos.html.twig', [
  2319.             'empresaId' => $id,
  2320.             'empresas'  => $empresas,
  2321.             'documanagerBaseUrl' => $baseUrl,
  2322.         ]);
  2323.     }
  2324.     #[Route('/api/empresa/{id}/documentos'name'empresa_documentos_api'methods: ['GET'])]
  2325.     public function documentosEmpresaApi(Request $requestEntityManagerInterface $em): JsonResponse
  2326.     {
  2327.         if (!$this->getUser() || !is_object($this->getUser())) {
  2328.             return new JsonResponse(['error' => 'unauthorized'], 401);
  2329.         }
  2330.         $id = (int) $request->get('id');
  2331.         $empresa $em->getRepository(Empresa::class)->find($id);
  2332.         if (!$empresa) {
  2333.             return new JsonResponse(['error' => 'Empresa no encontrada'], 404);
  2334.         }
  2335.         $cx $empresa->getConexionBD();
  2336.         if (!$cx) {
  2337.             return new JsonResponse(['error' => 'La empresa no tiene conexión configurada'], 400);
  2338.         }
  2339.         $page    max(1, (int) $request->query->get('page'1));
  2340.         $perPage = (int) $request->query->get('per_page'50);
  2341.         $perPage min(max($perPage10), 200);
  2342.         $q       = (string) $request->query->get('q''');
  2343.         $offset  = ($page 1) * $perPage;
  2344.         $mysqli = @new \mysqli(
  2345.             $cx->getDbUrl(),
  2346.             $cx->getDbUser(),
  2347.             $cx->getDbPassword(),
  2348.             $cx->getDbName(),
  2349.             (int) $cx->getDbPort()
  2350.         );
  2351.         if ($mysqli->connect_error) {
  2352.             return new JsonResponse(['error' => 'Error conectando a la BD del cliente: ' $mysqli->connect_error], 500);
  2353.         }
  2354.         $mysqli->set_charset('utf8mb4');
  2355.         // --- TOTAL ---
  2356.         if ($q === '') {
  2357.             $sqlTotal "SELECT COUNT(*) AS c FROM files";
  2358.             $res $mysqli->query($sqlTotal);
  2359.             $row $res $res->fetch_assoc() : null;
  2360.             $total = (int)($row['c'] ?? 0);
  2361.         } else {
  2362.             $sqlTotal "SELECT COUNT(*) AS c
  2363.                          FROM files
  2364.                          WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?";
  2365.             $qLike '%' $q '%';
  2366.             $st $mysqli->prepare($sqlTotal);
  2367.             $st->bind_param('ssss'$qLike$qLike$qLike$qLike);
  2368.             $st->execute();
  2369.             $res $st->get_result();
  2370.             $row $res $res->fetch_assoc() : null;
  2371.             $total = (int)($row['c'] ?? 0);
  2372.             $st->close();
  2373.         }
  2374.         // --- ITEMS ---
  2375.         $items = [];
  2376.         if ($q === '') {
  2377.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  2378.                     FROM files
  2379.                     ORDER BY `date` DESC
  2380.                     LIMIT ? OFFSET ?";
  2381.             $st $mysqli->prepare($sql);
  2382.             $st->bind_param('ii'$perPage$offset);
  2383.         } else {
  2384.             $sql "SELECT name, size_bytes, path, `date`, status, control, tag, notes
  2385.                     FROM files
  2386.                     WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?
  2387.                     ORDER BY `date` DESC
  2388.                     LIMIT ? OFFSET ?";
  2389.             $qLike '%' $q '%';
  2390.             $st $mysqli->prepare($sql);
  2391.             $st->bind_param('ssssii'$qLike$qLike$qLike$qLike$perPage$offset);
  2392.         }
  2393.         $st->execute();
  2394.         $res $st->get_result();
  2395.         if ($res) {
  2396.             while ($r $res->fetch_assoc()) {
  2397.                 $items[] = $r;
  2398.             }
  2399.         }
  2400.         $st->close();
  2401.         $mysqli->close();
  2402.         return new JsonResponse([
  2403.             'company_id' => $id,
  2404.             'page'       => $page,
  2405.             'per_page'   => $perPage,
  2406.             'total'      => $total,
  2407.             'items'      => $items,
  2408.         ]);
  2409.     }
  2410. }