<?php
namespace App\Controller;
/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Empresa;
use App\Entity\Usuario;
use App\Entity\ConexionBD;
use App\Service\SuperuserProvisioningService;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Routing\Annotation\Route;
/**
* Description of AdminController
*
* @author joseangelparra
*/
class AdminController extends AbstractController
{
//put your code here
private const AZURE_DI_API_VERSION = '2024-11-30';
private const SUPERUSER_IS_ADMIN = 3;
private $params;
public function __construct(ParameterBagInterface $params)
{
$this->session = new Session();
$this->params = $params;
}
#[Route('/', name: 'login')]
public function Login(AuthenticationUtils $authenticationUtils)
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUSername();
return $this->render('base.html.twig', array(
'error' => $error,
'last_username' => $lastUsername
));
}
public function changePWD(UserPasswordHasherInterface $encoder, EntityManagerInterface $em)
{
$user = $em->getRepository(Usuario::class)->find(10);
$encoded = $encoder->hashPassword($user, 'docuManager2025');
$user->setPassword($encoded);
$em->persist($user);
$flush = $em->flush();
die();
}
public function checkUserExists(Request $request, EntityManagerInterface $em)
{
$email = trim((string)$request->request->get("user", ''));
$empresaId = (int)$request->request->get("empresa_id", 0);
$connectionId = (int)$request->request->get("connection", 0);
if ($email === '') {
return new JsonResponse(['exists' => false, 'message' => 'user_required'], 400);
}
$qb = $em->createQueryBuilder()
->select('u.id')
->from(Usuario::class, 'u')
->where('LOWER(u.email) = :email')
->setParameter('email', strtolower($email));
// Validar por ambito de empresa/conexion cuando se proporciona.
if ($empresaId > 0) {
$qb->andWhere('IDENTITY(u.empresa) = :empresaId')
->setParameter('empresaId', $empresaId);
} elseif ($connectionId > 0) {
$qb->andWhere('u.connection = :connectionId')
->setParameter('connectionId', $connectionId);
}
$existsScoped = !empty($qb->setMaxResults(1)->getQuery()->getArrayResult());
// Mantiene dato global para compatibilidad/diagnostico.
$existsGlobal = !empty($em->createQueryBuilder()
->select('u2.id')
->from(Usuario::class, 'u2')
->where('LOWER(u2.email) = :email')
->setParameter('email', strtolower($email))
->setMaxResults(1)
->getQuery()
->getArrayResult());
return new JsonResponse([
'exists' => $existsScoped,
'exists_scoped' => $existsScoped,
'exists_global' => $existsGlobal,
'scope' => ($empresaId > 0 ? 'empresa' : ($connectionId > 0 ? 'connection' : 'global')),
]);
}
private function azurePortForTenant(int $tenantId, int $base = 12000, int $max = 20999): int
{
$port = $base + $tenantId;
if ($port > $max) {
$range = max(1, $max - $base);
$port = $base + ($tenantId % $range);
}
return $port;
}
private function azureDiWorkdirFromEnv(): string
{
return rtrim((string)($_ENV['AZURE_DI_WORKDIR'] ?? ''), '/');
}
private function clampDocuMaxThreads($value, int $default = 4): int
{
if ($value === null || $value === '') {
return $default;
}
$threads = (int)$value;
if ($threads < 1) {
return 1;
}
if ($threads > 16) {
return 16;
}
return $threads;
}
private function ensureMailMonitorService(int $companyId, string $dbHost, string $dbPort, string $dbUser, string $dbPass, string $dbName, string $filesPath): void
{
$workdir = $_ENV['MAIL_IMPORTER_WORKDIR'] ?? '';
$script = $_ENV['MAIL_IMPORTER_SCRIPT'] ?? '';
$envFile = $_ENV['MAIL_IMPORTER_COMMON_ENV'] ?? '';
$missing = [];
if ($workdir === '') $missing[] = 'MAIL_IMPORTER_WORKDIR';
if ($script === '') $missing[] = 'MAIL_IMPORTER_SCRIPT';
if ($envFile === '') $missing[] = 'MAIL_IMPORTER_COMMON_ENV';
if (trim($filesPath) === '') $missing[] = 'FILES_PATH';
if (!empty($missing)) {
$msg = 'Mail Monitor no creado: faltan variables de entorno: ' . implode(', ', $missing);
$this->addFlash('warning', $msg);
error_log($msg);
return;
}
$serviceName = $companyId . "-mailMonitor.service";
$timerName = $companyId . "-mailMonitor.timer";
$filesRoot = rtrim($filesPath, '/') . '/' . $companyId;
$serviceContent = <<<EOT
[Unit]
Description=DocuManager Mail Monitor (empresa {$companyId})
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
WorkingDirectory={$workdir}
EnvironmentFile={$envFile}
Environment=MAIL_IMPORTER_DB_HOST={$dbHost}
Environment=MAIL_IMPORTER_DB_PORT={$dbPort}
Environment=MAIL_IMPORTER_DB_USER={$dbUser}
Environment=MAIL_IMPORTER_DB_PASS={$dbPass}
Environment=MAIL_IMPORTER_DB_NAME={$dbName}
Environment=MAIL_IMPORTER_FILES_ROOT={$filesRoot}
ExecStart=/usr/bin/python3 {$script} --once --log-level INFO
User=docunecta
Group=docunecta
[Install]
WantedBy=multi-user.target
EOT;
$timerContent = <<<EOT
[Unit]
Description=DocuManager Mail Monitor Timer (empresa {$companyId})
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
AccuracySec=1min
Persistent=true
[Install]
WantedBy=timers.target
EOT;
$tmpServicePath = "/tmp/{$serviceName}";
$tmpTimerPath = "/tmp/{$timerName}";
file_put_contents($tmpServicePath, $serviceContent);
file_put_contents($tmpTimerPath, $timerContent);
@chmod($tmpServicePath, 0644);
@chmod($tmpTimerPath, 0644);
$cmds = [
"sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
"sudo /bin/mv {$tmpTimerPath} /etc/systemd/system/{$timerName}",
"sudo /bin/systemctl daemon-reload",
"sudo /bin/systemctl enable --now {$timerName}",
];
foreach ($cmds as $cmd) {
$out = \shell_exec($cmd . " 2>&1");
if ($out !== null) {
error_log("MAIL-MONITOR CMD: $cmd\n$out");
}
}
}
private function disableMailMonitorService(int $companyId): void
{
$serviceName = $companyId . "-mailMonitor.service";
$timerName = $companyId . "-mailMonitor.timer";
$cmds = [
"sudo /bin/systemctl disable --now {$timerName}",
"sudo /bin/systemctl stop {$serviceName}",
"sudo /bin/rm -f /etc/systemd/system/{$serviceName}",
"sudo /bin/rm -f /etc/systemd/system/{$timerName}",
"sudo /bin/systemctl daemon-reload",
];
foreach ($cmds as $cmd) {
$out = \shell_exec($cmd . " 2>&1");
if ($out !== null) {
error_log("MAIL-MONITOR CMD: $cmd\n$out");
}
}
}
private function removeCompanyFilesDir(int $companyId): void
{
$base = $_ENV['FILES_PATH'] ?? '';
if (trim($base) === '') {
$msg = 'No se ha podido borrar carpeta de files: falta FILES_PATH en entorno.';
$this->addFlash('warning', $msg);
error_log($msg);
return;
}
$base = rtrim($base, '/');
$target = $base . '/' . $companyId;
if (!is_dir($target)) {
return;
}
$errors = [];
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($target, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
try {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@unlink($file->getPathname());
}
} catch (\Throwable $e) {
$errors[] = $e->getMessage();
}
}
@rmdir($target);
if (!empty($errors)) {
$msg = 'No se pudo borrar completamente la carpeta de files: ' . $target;
$this->addFlash('warning', $msg);
error_log($msg . ' | ' . implode(' | ', $errors));
}
}
private function removeCompanyLogsDir(int $companyId): void
{
$base = $_ENV['LOGS_ROOT'] ?? '';
if (trim($base) === '') {
$msg = 'No se ha podido borrar carpeta de logs: falta LOGS_ROOT en entorno.';
$this->addFlash('warning', $msg);
error_log($msg);
return;
}
$base = rtrim($base, '/');
$target = $base . '/' . $companyId;
if (!is_dir($target)) {
return;
}
$errors = [];
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($target, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
try {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@unlink($file->getPathname());
}
} catch (\Throwable $e) {
$errors[] = $e->getMessage();
}
}
@rmdir($target);
if (!empty($errors)) {
$msg = 'No se pudo borrar completamente la carpeta de logs: ' . $target;
$this->addFlash('warning', $msg);
error_log($msg . ' | ' . implode(' | ', $errors));
}
}
private function getCompanyUserMediaPaths(\mysqli $mysqli): array
{
$paths = [];
try {
$res = $mysqli->query("SELECT avatar, firma FROM users");
if ($res) {
while ($r = $res->fetch_assoc()) {
foreach (['avatar', 'firma'] as $col) {
$path = trim((string)($r[$col] ?? ''));
if ($path === '' || str_starts_with($path, 'http')) {
continue;
}
$paths[$path] = true;
}
}
$res->free();
}
} catch (\Throwable $e) {
error_log('Error leyendo usuarios para borrar media: ' . $e->getMessage());
}
return array_keys($paths);
}
private function callPlatformCleanup(int $companyId, array $mediaPaths = []): void
{
$url = $_ENV['PLATFORM_CLEANUP_URL'] ?? '';
$secret = $_ENV['PLATFORM_CLEANUP_SECRET'] ?? '';
if (trim($url) === '' || trim($secret) === '') {
$msg = 'Cleanup no ejecutado: faltan PLATFORM_CLEANUP_URL o PLATFORM_CLEANUP_SECRET.';
$this->addFlash('warning', $msg);
error_log($msg);
return;
}
$payload = [
'company_id' => $companyId,
'media_paths' => array_values($mediaPaths),
];
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($body === false) {
error_log('Cleanup: no se pudo serializar payload JSON.');
return;
}
$ts = time();
$sig = hash_hmac('sha256', $ts . "\n" . $body, $secret);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Docu-Timestamp: ' . $ts,
'X-Docu-Signature: ' . $sig,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error || $code < 200 || $code >= 300) {
$msg = 'Cleanup remoto fallo (' . $code . '): ' . ($error ?: (string)$response);
$this->addFlash('warning', $msg);
error_log($msg);
}
}
#[Route('/list', name: 'list')]
public function List(EntityManagerInterface $entityManager)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$empresas = $entityManager->getRepository(Empresa::class)->findAll();
return $this->render('listusers.html.twig', array(
'empresas' => $empresas,
));
}
public function superusersList(EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$rows = $em->getConnection()->fetchAllAssociative("
SELECT
LOWER(email) AS email_key,
MIN(email) AS email,
COUNT(*) AS empresas_count,
SUM(CASE WHEN status IN ('ENABLED', '1', 1) THEN 1 ELSE 0 END) AS enabled_count
FROM users
WHERE is_admin = :isAdmin
GROUP BY LOWER(email)
ORDER BY MIN(email) ASC
", ['isAdmin' => self::SUPERUSER_IS_ADMIN]);
return $this->render('superusers/list.html.twig', [
'superusers' => $rows,
]);
}
public function superusersNew(Request $request, EntityManagerInterface $em, SuperuserProvisioningService $service)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$empresas = $em->getRepository(Empresa::class)->findAll();
$formData = [
'email' => '',
'status' => 'ENABLED',
'empresas' => [],
];
if ($request->isMethod('POST')) {
$email = $this->normalizeSuperuserEmail((string)$request->request->get('email', ''));
$password = (string)$request->request->get('password', '');
$enabled = strtoupper((string)$request->request->get('status', 'ENABLED')) === 'ENABLED';
$empresaIds = $this->readEmpresaIdsFromRequest($request);
$formData['email'] = $email;
$formData['status'] = $enabled ? 'ENABLED' : 'DISABLED';
$formData['empresas'] = $empresaIds;
try {
$service->createSuperuser($email, $password, $empresaIds, $enabled);
$this->addFlash('success', 'Superusuario creado correctamente.');
return $this->redirectToRoute('superusers_list');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
}
return $this->render('superusers/form.html.twig', [
'title' => 'Crear superusuario',
'is_edit' => false,
'form' => $formData,
'empresas' => $empresas,
]);
}
public function superusersEdit(string $email, Request $request, EntityManagerInterface $em, SuperuserProvisioningService $service)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$email = $this->normalizeSuperuserEmail($email);
$existingRows = $em->getConnection()->fetchAllAssociative(
"SELECT empresa_id, status
FROM users
WHERE LOWER(email) = :email AND is_admin = :isAdmin",
['email' => $email, 'isAdmin' => self::SUPERUSER_IS_ADMIN]
);
if (count($existingRows) === 0) {
$this->addFlash('warning', 'Superusuario no encontrado.');
return $this->redirectToRoute('superusers_list');
}
$selectedEmpresaIds = [];
$hasEnabled = false;
foreach ($existingRows as $row) {
$selectedEmpresaIds[] = (int)$row['empresa_id'];
$hasEnabled = $hasEnabled || strtoupper((string)$row['status']) === 'ENABLED';
}
$selectedEmpresaIds = array_values(array_unique($selectedEmpresaIds));
sort($selectedEmpresaIds);
$empresas = $em->getRepository(Empresa::class)->findAll();
$formData = [
'email' => $email,
'status' => $hasEnabled ? 'ENABLED' : 'DISABLED',
'empresas' => $selectedEmpresaIds,
];
if ($request->isMethod('POST')) {
$newPassword = trim((string)$request->request->get('password', ''));
$enabled = strtoupper((string)$request->request->get('status', 'ENABLED')) === 'ENABLED';
$empresaIds = $this->readEmpresaIdsFromRequest($request);
$formData['status'] = $enabled ? 'ENABLED' : 'DISABLED';
$formData['empresas'] = $empresaIds;
try {
$service->updateSuperuser($email, ($newPassword === '' ? null : $newPassword), $empresaIds, $enabled);
$this->addFlash('success', 'Superusuario actualizado correctamente.');
return $this->redirectToRoute('superusers_list');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
}
return $this->render('superusers/form.html.twig', [
'title' => 'Editar superusuario',
'is_edit' => true,
'form' => $formData,
'empresas' => $empresas,
]);
}
public function superusersDeleteLink(string $email, int $empresaId, Request $request, EntityManagerInterface $em, SuperuserProvisioningService $service)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
if (!$request->isMethod('POST')) {
return $this->redirectToRoute('superusers_list');
}
$email = $this->normalizeSuperuserEmail($email);
$rows = $em->getConnection()->fetchAllAssociative(
"SELECT empresa_id, status
FROM users
WHERE LOWER(email) = :email AND is_admin = :isAdmin",
['email' => $email, 'isAdmin' => self::SUPERUSER_IS_ADMIN]
);
if (count($rows) === 0) {
$this->addFlash('warning', 'Superusuario no encontrado.');
return $this->redirectToRoute('superusers_list');
}
$targetEmpresaIds = [];
$enabled = false;
foreach ($rows as $row) {
$currentEmpresaId = (int)$row['empresa_id'];
if ($currentEmpresaId !== $empresaId) {
$targetEmpresaIds[] = $currentEmpresaId;
}
$enabled = $enabled || strtoupper((string)$row['status']) === 'ENABLED';
}
try {
$service->updateSuperuser($email, null, $targetEmpresaIds, $enabled);
$this->addFlash('success', 'Vinculación eliminada correctamente.');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
return $this->redirectToRoute('superusers_edit', ['email' => $email]);
}
public function superusersCheckEmail(Request $request, EntityManagerInterface $em): JsonResponse
{
$email = $this->normalizeSuperuserEmail((string)$request->request->get('email', ''));
$empresaIds = $this->readEmpresaIdsFromRequest($request);
if ($email === '') {
return new JsonResponse(['exists' => false, 'message' => 'email_required'], 400);
}
if (count($empresaIds) === 0) {
return new JsonResponse(['exists' => false, 'message' => 'no_empresas']);
}
$count = (int)$em->createQueryBuilder()
->select('COUNT(u.id)')
->from(Usuario::class, 'u')
->where('LOWER(u.email) = :email')
->andWhere('u.isAdmin = :isAdmin')
->andWhere('IDENTITY(u.empresa) IN (:empresaIds)')
->setParameter('email', $email)
->setParameter('isAdmin', self::SUPERUSER_IS_ADMIN)
->setParameter('empresaIds', $empresaIds)
->getQuery()
->getSingleScalarResult();
return new JsonResponse([
'exists' => $count > 0,
'count' => $count,
]);
}
private function normalizeSuperuserEmail(string $email): string
{
return strtolower(trim($email));
}
/**
* @return int[]
*/
private function readEmpresaIdsFromRequest(Request $request): array
{
$raw = $request->request->all('empresas');
if (!is_array($raw)) {
$raw = [];
}
$ids = [];
foreach ($raw as $empresaId) {
$id = (int)$empresaId;
if ($id > 0) {
$ids[] = $id;
}
}
$ids = array_values(array_unique($ids));
sort($ids);
return $ids;
}
public function azureResourcesList(EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$resources = [];
try {
$resources = $this->loadAzureDiResources($em);
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo cargar el listado de recursos IA: ' . $e->getMessage());
}
return $this->render('azure_resources/list.html.twig', [
'resources' => $resources,
]);
}
public function azureResourcesNew(Request $request, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$resource = [
'name' => '',
'endpoint' => '',
'api_key' => '',
];
if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
$resource['name'] = trim((string)$request->request->get('name', ''));
$resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint', ''));
$resource['api_key'] = trim((string)$request->request->get('api_key', ''));
if ($resource['name'] === '' || $resource['endpoint'] === '' || $resource['api_key'] === '') {
$this->addFlash('danger', 'Nombre, endpoint y api key son obligatorios.');
} else {
try {
$em->getConnection()->insert('azure_di_resources', [
'name' => $resource['name'],
'endpoint' => $resource['endpoint'],
'api_key' => $resource['api_key'],
]);
$this->addFlash('success', 'Recurso IA creado correctamente.');
return $this->redirectToRoute('azure_resources_list');
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo crear el recurso: ' . $e->getMessage());
}
}
}
return $this->render('azure_resources/new.html.twig', [
'resource' => $resource,
]);
}
public function azureResourcesEdit(Request $request, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$id = (int)$request->get('id');
$resource = $this->loadAzureDiResourceById($em, $id);
if (!$resource) {
$this->addFlash('warning', 'Recurso no encontrado.');
return $this->redirectToRoute('azure_resources_list');
}
if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
$resource['name'] = trim((string)$request->request->get('name', ''));
$resource['endpoint'] = $this->normalizeAzureEndpoint((string)$request->request->get('endpoint', ''));
$resource['api_key'] = trim((string)$request->request->get('api_key', ''));
if ($resource['name'] === '' || $resource['endpoint'] === '' || $resource['api_key'] === '') {
$this->addFlash('danger', 'Nombre, endpoint y api key son obligatorios.');
} else {
try {
$em->getConnection()->update('azure_di_resources', [
'name' => $resource['name'],
'endpoint' => $resource['endpoint'],
'api_key' => $resource['api_key'],
], [
'id' => $id,
]);
$this->addFlash('success', 'Recurso IA actualizado correctamente.');
return $this->redirectToRoute('azure_resources_list');
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo actualizar el recurso: ' . $e->getMessage());
}
}
}
return $this->render('azure_resources/edit.html.twig', [
'resource' => $resource,
]);
}
public function azureResourcesDelete(Request $request, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$id = (int)$request->get('id');
if ($id <= 0) {
$this->addFlash('warning', 'Identificador de recurso no valido.');
return $this->redirectToRoute('azure_resources_list');
}
try {
$em->getConnection()->delete('azure_di_resources', ['id' => $id]);
$this->addFlash('success', 'Recurso IA eliminado correctamente.');
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo eliminar el recurso: ' . $e->getMessage());
}
return $this->redirectToRoute('azure_resources_list');
}
public function azureApiResourceModels(Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !is_object($this->getUser())) {
return new JsonResponse(['error' => 'unauthorized'], 401);
}
$resource = $this->loadAzureDiResourceById($em, (int)$request->get('id'));
if (!$resource) {
return new JsonResponse(['error' => 'resource_not_found'], 404);
}
try {
$payload = $this->azureRequest(
(string)$resource['endpoint'],
(string)$resource['api_key'],
'/documentintelligence/documentModels'
);
} catch (\Throwable $e) {
$status = (int)$e->getCode();
if ($status < 400 || $status > 599) {
$status = 502;
}
return new JsonResponse(['error' => $e->getMessage()], $status);
}
$models = $payload['value'] ?? [];
if (!is_array($models)) {
$models = [];
}
$normalizedModels = [];
foreach ($models as $model) {
if (!is_array($model)) {
continue;
}
$normalizedModels[] = [
'modelId' => (string)($model['modelId'] ?? ''),
'description' => (string)($model['description'] ?? ''),
'createdDateTime' => (string)($model['createdDateTime'] ?? ''),
'expirationDateTime' => (string)($model['expirationDateTime'] ?? ''),
'type' => $this->classifyModelType($model),
];
}
usort($normalizedModels, static function (array $a, array $b): int {
return strcmp($a['modelId'] ?? '', $b['modelId'] ?? '');
});
return new JsonResponse([
'resource' => [
'id' => (int)$resource['id'],
'name' => (string)$resource['name'],
],
'models' => $normalizedModels,
]);
}
public function azureApiResourceModelDetail(Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !is_object($this->getUser())) {
return new JsonResponse(['error' => 'unauthorized'], 401);
}
$resource = $this->loadAzureDiResourceById($em, (int)$request->get('id'));
if (!$resource) {
return new JsonResponse(['error' => 'resource_not_found'], 404);
}
$modelId = trim((string)$request->get('modelId'));
if ($modelId === '') {
return new JsonResponse(['error' => 'model_id_required'], 400);
}
try {
$detail = $this->azureRequest(
(string)$resource['endpoint'],
(string)$resource['api_key'],
'/documentintelligence/documentModels/' . rawurlencode($modelId)
);
$mapped = $this->mapAzureSchemaToDefinitions($detail);
} catch (\Throwable $e) {
$status = (int)$e->getCode();
if ($status < 400 || $status > 599) {
$status = 502;
}
return new JsonResponse(['error' => $e->getMessage()], $status);
}
return new JsonResponse([
'model' => $detail,
'type' => $this->classifyModelType($detail),
'preview' => [
'header' => $mapped['header'],
'lines' => $mapped['lines'],
],
]);
}
private function loadAzureDiResources(EntityManagerInterface $em): array
{
$rows = $em->getConnection()->executeQuery(
'SELECT id, name, endpoint, api_key, created_at, updated_at FROM azure_di_resources ORDER BY name ASC'
)->fetchAllAssociative();
foreach ($rows as &$row) {
$row['id'] = (int)$row['id'];
}
return $rows;
}
private function loadAzureDiResourceById(EntityManagerInterface $em, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = $em->getConnection()->fetchAssociative(
'SELECT id, name, endpoint, api_key, created_at, updated_at
FROM azure_di_resources
WHERE id = :id',
['id' => $id]
);
if (!$row) {
return null;
}
$row['id'] = (int)$row['id'];
return $row;
}
private function normalizeAzureEndpoint(string $endpoint): string
{
$endpoint = trim($endpoint);
if ($endpoint === '') {
return '';
}
if (!preg_match('#^https?://#i', $endpoint)) {
$endpoint = 'https://' . $endpoint;
}
return rtrim($endpoint, '/');
}
private function azureRequest(string $endpoint, string $apiKey, string $path, array $query = []): array
{
$endpoint = $this->normalizeAzureEndpoint($endpoint);
if ($endpoint === '' || trim($apiKey) === '') {
throw new \RuntimeException('Recurso IA incompleto.', 400);
}
$query = array_merge(['api-version' => self::AZURE_DI_API_VERSION], $query);
$url = $endpoint . '/' . ltrim($path, '/') . '?' . http_build_query($query);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Ocp-Apim-Subscription-Key: ' . $apiKey,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($ch, CURLOPT_TIMEOUT, 25);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlError) {
throw new \RuntimeException('Error de red con recurso IA: ' . $curlError, 0);
}
$decoded = [];
if (is_string($response) && trim($response) !== '') {
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new \RuntimeException('Respuesta no valida del recurso IA.', 502);
}
}
if ($statusCode < 200 || $statusCode >= 300) {
$detail = $decoded['error']['message'] ?? $decoded['message'] ?? ('HTTP ' . $statusCode);
throw new \RuntimeException((string)$detail, $statusCode);
}
return $decoded;
}
private function classifyModelType(array $model): string
{
$expiration = trim((string)($model['expirationDateTime'] ?? ''));
return $expiration !== '' ? 'custom' : 'prebuilt';
}
public function addEmpresa(Request $req, EntityManagerInterface $em)
{
//dd(\shell_exec('whoami'));
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$azureResources = [];
try {
$azureResources = $this->loadAzureDiResources($em);
} catch (\Throwable $e) {
$this->addFlash('warning', 'No se pudo cargar el catalogo de recursos IA: ' . $e->getMessage());
}
if ($req->request->get("submit") != "") {
$data = $req->request->all();
$maxThreads = $this->clampDocuMaxThreads($data['maxThreads'] ?? null, 4);
$toBool = fn($v) => in_array(strtolower((string)$v), ['1', 'on', 'true', 'yes'], true);
$mailMonitorEnabled = isset($data['modulo_mailMonitor']) && $toBool($data['modulo_mailMonitor']);
$data['modulo_extraccion'] = isset($data['modulo_extraccion']) && $toBool($data['modulo_extraccion']) ? 1 : 0;
$data['modulo_expowin'] = isset($data['modulo_expowin']) && $toBool($data['modulo_expowin']) ? 1 : 0;
$azureResource = null;
$azureResourceId = (int)($data['azure_resource_id'] ?? 0);
$azureModelId = trim((string)($data['azure_model_id'] ?? ''));
if ($data['modulo_extraccion'] === 1) {
if ($azureResourceId <= 0 || $azureModelId === '') {
$this->addFlash('danger', 'Para activar extraccion debes seleccionar recurso y modelo IA.');
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
],
]);
}
$azureResource = $this->loadAzureDiResourceById($em, $azureResourceId);
if (!$azureResource) {
$this->addFlash('danger', 'El recurso IA seleccionado no existe.');
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
],
]);
}
try {
$this->azureRequest(
(string)$azureResource['endpoint'],
(string)$azureResource['api_key'],
'/documentintelligence/documentModels/' . rawurlencode($azureModelId)
);
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo validar el modelo IA: ' . $e->getMessage());
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
],
]);
}
} else {
$data['azure_resource_id'] = 0;
$data['azure_model_id'] = '';
$data['extraction_model'] = 0;
$data['modulo_expowin'] = 0;
}
// Crear nombre de base de datos y credenciales aleatorios
$dbName = 'doc_' . bin2hex(random_bytes(3));
$dbUser = 'doc_' . bin2hex(random_bytes(2));
$dbPass = bin2hex(random_bytes(8));
$dbHost = 'localhost';
$dbPort = '3306';
// Credenciales de HestiaCP desde variables de entorno
$hestiaApiUrl = $_ENV['HESTIA_API_URL'];
$hestiaApiUser = $_ENV['HESTIA_API_USER'];
$hestiaApiPass = $_ENV['HESTIA_API_PASS'];
$hestiaOwner = $_ENV['HESTIA_OWNER'];
$accessKeyId = $_ENV['HESTIA_ACCESS_KEY_ID'] ?? '';
$secretKey = $_ENV['HESTIA_SECRET_KEY'] ?? '';
// Variables para el script de bash
$ocrBinary = $_ENV['OCR_BINARY'];
$filesPath = $_ENV['FILES_PATH'];
$owner = $hestiaOwner; // o el dueńo del hosting
$postFields = http_build_query([
'user' => $hestiaApiUser,
'password' => $hestiaApiPass,
'returncode' => 'yes',
'cmd' => 'v-add-database',
'arg1' => $owner,
'arg2' => $dbName,
'arg3' => $dbUser,
'arg4' => $dbPass,
'arg5' => 'mysql'
]);
//dd($postFields);
$headers = [
'Authorization: Bearer ' . $accessKeyId . ':' . $secretKey
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $hestiaApiUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Solo si usas certificados autofirmados
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error || trim($response) !== '0') {
$this->addFlash('danger', 'Error al crear la base de datos en HestiaCP: ' . ($error ?: $response));
return $this->redirectToRoute('app_empresa_new');
}
//Añadir sql
$sqlFile = __DIR__ . '/../../db/db_base.sql'; // Ajusta la ruta si está en otro sitio
if (!file_exists($sqlFile)) {
$this->addFlash('danger', 'Archivo db_base.sql no encontrado.');
return $this->redirectToRoute('app_empresa_new');
}
$mysqli = new \mysqli($dbHost, "{$owner}_{$dbUser}", $dbPass, "{$owner}_{$dbName}", (int)$dbPort);
if ($mysqli->connect_error) {
$this->addFlash('danger', 'Error al conectar a la base de datos: ' . $mysqli->connect_error);
return $this->redirectToRoute('app_empresa_new');
}
$sql = file_get_contents($sqlFile);
// Eliminar lĂneas con DELIMITER
$sql = preg_replace('/DELIMITER\s+\$\$/', '', $sql);
$sql = preg_replace('/DELIMITER\s+;/', '', $sql);
// Separar por ';;' si los triggers usan ese delimitador (ajusta si es $$)
$statements = explode('$$', $sql);
foreach ($statements as $stmt) {
$stmt = trim($stmt);
if ($stmt) {
if (!$mysqli->multi_query($stmt)) {
$this->addFlash('danger', 'Error ejecutando SQL: ' . $mysqli->error);
return $this->redirectToRoute('app_empresa_new');
}
// Limpiar cualquier resultado intermedio
while ($mysqli->more_results() && $mysqli->next_result()) {
$mysqli->use_result();
}
}
}
$updateSql = "UPDATE users SET email = '" . $mysqli->real_escape_string((string)$data["user"]) . "' WHERE id = 1";
if (!$mysqli->query($updateSql)) {
$this->addFlash('danger', 'Error al actualizar usuario: ' . $mysqli->error);
return $this->redirectToRoute('app_empresa_new');
}
// Guardar parámetros (activeUsers + modulos_*) en la BD del cliente
$localExtractionModelId = 0;
if ($data['modulo_extraccion'] === 1) {
try {
$localExtractionModelId = $this->registerModelInClientDb(
$mysqli,
$azureResource ?? [],
$azureModelId
);
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo registrar el modelo en la BBDD cliente: ' . $e->getMessage());
$mysqli->close();
return $this->redirectToRoute('app_empresa_new');
}
}
$data['extraction_model'] = $localExtractionModelId;
// SUBIR LOGO PERSONALIZADO (SI LO HAY) ===
$customLogoFile = null;
try {
$customLogoFile = $this->uploadEmpresaLogo($req);
} catch (\Throwable $e) {
// Aquí decides si quieres que esto sea fatal o solo un aviso
$this->addFlash('warning', 'El logo personalizado no se pudo subir: ' . $e->getMessage());
// Si quieres abortar todo el proceso por fallo de logo, haz return+redirect aquí.
}
// Guardar logo en la BD del cliente
$this->saveEmpresaLogo($mysqli, $data, $customLogoFile);
// Actualizar/insertar license (capacityGb y users) en la BD del cliente
$this->upsertLicense(
$mysqli,
$data,
isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== '' ? (int)$data['maxDiskQuota'] : 200,
isset($data['maxActiveUsers']) && $data['maxActiveUsers'] !== '' ? (int)$data['maxActiveUsers'] : 3
);
// Guardar parametros (activeUsers + modulos_*) en la BD del cliente
$this->saveEmpresaParametros($mysqli, $data);
// Crear y persistir la empresa
$emp = new Empresa();
$emp->setName($data["name"]);
$emp->setMaxDiskQuota((int)$data["maxDiskQuota"]);
$emp->setMaxThreads($maxThreads);
$em->persist($emp);
$em->flush();
$conexionBD = new ConexionBD();
$conexionBD->setDbName($owner . "_" . $dbName);
$conexionBD->setDbUser($owner . "_" . $dbUser);
$conexionBD->setDbPassword($dbPass);
$conexionBD->setDbUrl($dbHost);
$conexionBD->setDbPort($dbPort);
$em->persist($conexionBD);
$em->flush();
$emp->setConexionBD($conexionBD);
$em->persist($emp);
$em->flush();
//crear usuario
$user = new \App\Entity\Usuario();
$user->setEmail($data["user"]);
$user->setEmpresa($emp);
$user->setPassword("dscsdcsno2234dwvw");
$user->setStatus(1);
$user->setIsAdmin(2);
$user->setConnection($conexionBD->getId());
$em->persist($user);
$em->flush();
//crear el script de bash
$company_name = $emp->getId();
// "DOCU_MAX_THREADS=" por defecto 4
// "NO" al final es para desactivar FTP
// Crear archivo .service
$empresaName = (string)($data['name'] ?? '');
$serviceContent = <<<EOT
[Unit]
Description={$empresaName} DocuManager OCR
Requires=mariadb.service
After=mariadb.service
[Service]
Type=simple
Environment="DOCU_MAX_THREADS=$maxThreads"
ExecStart=$ocrBinary localhost/{$owner}_{$dbName} {$owner}_{$dbUser} {$dbPass} {$filesPath}/{$company_name} NO
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOT;
// Guardar contenido temporal en un archivo dentro de /tmp
$serviceName = $company_name . "-documanager.service";
$tmpServicePath = "/tmp/$serviceName";
file_put_contents($tmpServicePath, $serviceContent);
\chmod($tmpServicePath, 0644);
// Mover el archivo y habilitar el servicio desde PHP con shell_exec
$commands = [
"sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
"sudo /bin/systemctl daemon-reload",
"sudo /bin/systemctl enable $serviceName",
"sudo /bin/systemctl start $serviceName",
];
$errors = [];
foreach ($commands as $cmd) {
$output = \shell_exec($cmd . " 2>&1");
if ($output !== null) {
// Puedes loguearlo si quieres para ver errores
error_log("CMD OUTPUT: $cmd\n$output");
$errors[] = "CMD OUTPUT: $cmd\n$output";
}
}
// === Crear servicio AZURE DI por tenant ===
$azureBasePort = 12000;
$tenantId = (int)$emp->getId();
$port = $this->azurePortForTenant($tenantId, $azureBasePort, 20999);
$serviceName = $tenantId . "-azuredi.service";
$workdir = $this->azureDiWorkdirFromEnv();
if ($workdir !== '') {
$logsDir = $workdir . "/logs/" . $tenantId;
// Asegura carpeta de logs
@mkdir($logsDir, 0775, true);
$serviceContent = <<<EOT
[Unit]
Description=DocuManager Azure DI (tenant {$tenantId})
After=network.target
[Service]
User=root
WorkingDirectory={$workdir}
Environment=APP_HOST=127.0.0.1
Environment=APP_PORT={$port}
Environment=LOG_DIR={$logsDir}
Environment=PYTHONUNBUFFERED=1
Environment=MAX_CONCURRENT=20
Environment="PATH=/opt/azure-di/.venv/bin:/usr/local/bin:/usr/bin"
EnvironmentFile=-{$workdir}/.env
ExecStart=/opt/azure-di/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port {$port} --proxy-headers --workers 2
Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOT;
$tmpServicePath = "/tmp/{$serviceName}";
file_put_contents($tmpServicePath, $serviceContent);
@chmod($tmpServicePath, 0644);
$cmds = [
"sudo /bin/mv {$tmpServicePath} /etc/systemd/system/{$serviceName}",
"sudo /bin/systemctl daemon-reload",
"sudo /bin/systemctl enable {$serviceName}",
"sudo /bin/systemctl start {$serviceName}",
];
foreach ($cmds as $cmd) {
$out = \shell_exec($cmd . " 2>&1");
if ($out !== null) {
error_log("AZURE-DI CMD: $cmd\n$out");
}
}
} else {
$this->addFlash('warning', 'No se pudo crear el servicio de extraccion IA: falta configuracion de entorno.');
}
// === Crear servicio/timer Mail Monitor si el modulo esta activo ===
if ($mailMonitorEnabled) {
$this->ensureMailMonitorService(
(int)$company_name,
(string)$dbHost,
(string)$dbPort,
(string)$owner . "_" . (string)$dbUser,
(string)$dbPass,
(string)$owner . "_" . (string)$dbName,
(string)$filesPath
);
}
if (count($errors) > 0) {
$this->addFlash('success', 'Empresa y base de datos creadas correctamente: ' . implode(" | ", $errors));
}
$this->addFlash('success', 'Empresa y base de datos creadas correctamente.');
return $this->redirectToRoute('list');
} else {
// Carga recursos Azure DI para el formulario de alta
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => 500,
],
]);
}
}
private function saveEmpresaParametros(\mysqli $mysqli, array $data): void
{
// Helpers
$getInt = fn(array $a, string $k, int $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
$getFlag = fn(array $a, string $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 1 : 0;
// Keys y valores tal y como quieres guardarlos
$paramMap = [
'activeUsers' => $getInt($data, 'maxActiveUsers', 3),
'soloExtraccion' => $getFlag($data, 'soloExtraccion'),
'modulo_etiquetas' => $getFlag($data, 'modulo_etiquetas'),
'modulo_calendario' => $getFlag($data, 'modulo_calendario'),
'modulo_calExt' => $getFlag($data, 'modulo_calendarioExterno'),
'modulo_estados' => $getFlag($data, 'modulo_estados'),
'modulo_subida' => $getFlag($data, 'modulo_subida'),
'modulo_mailMonitor' => $getFlag($data, 'modulo_mailMonitor'),
'modulo_busquedaNatural' => $getFlag($data, 'modulo_busquedaNatural'),
'modulo_extraccion' => $getFlag($data, 'modulo_extraccion'),
'modulo_lineas' => $getFlag($data, 'modulo_lineas'),
'modulo_agora' => $getFlag($data, 'modulo_agora'),
'modulo_gstock' => $getFlag($data, 'modulo_gstock'),
'modulo_expowin' => $getFlag($data, 'modulo_expowin'),
'limite_archivos' => $getInt($data, 'limite_archivos', 500),
'extraction_model' => $getInt($data, 'extraction_model', 0),
];
if ($paramMap['modulo_extraccion'] === 0) {
$paramMap['modulo_expowin'] = 0;
}
// RECOMENDADO en tu SQL base:
// ALTER TABLE parametros ADD UNIQUE KEY uniq_nombre (nombre);
$mysqli->begin_transaction();
try {
$stmt = $mysqli->prepare("
INSERT INTO parametros (nombre, valor)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)
");
if (!$stmt) {
throw new \RuntimeException('Prepare parametros: ' . $mysqli->error);
}
foreach ($paramMap as $nombre => $valor) {
// valor es TEXT en tu esquema: bindeamos como string
$v = (string)$valor;
$stmt->bind_param('ss', $nombre, $v);
if (!$stmt->execute()) {
throw new \RuntimeException("Guardar parámetro $nombre: " . $stmt->error);
}
}
$stmt->close();
$mysqli->commit();
} catch (\Throwable $e) {
$mysqli->rollback();
// Si NO puedes ańadir UNIQUE(nombre), usa fallback DELETE+INSERT:
// $this->saveParametrosFallback($mysqli, $paramMap);
throw $e;
}
}
private function saveEmpresaLogo(\mysqli $mysqli, array $data, ?string $customLogoFile = null): void
{
$this->logLogo('empresa_logo_save.log', '--- NUEVA LLAMADA saveEmpresaLogo ---');
$this->logLogo('empresa_logo_save.log', 'customLogoFile = ' . var_export($customLogoFile, true));
$this->logLogo('empresa_logo_save.log', 'data[empresa_vendor] = ' . var_export($data['empresa_vendor'] ?? null, true));
// 1) Decidir qué logo vamos a guardar
$logoFile = null;
// --- PRIORIDAD: LOGO PERSONALIZADO ---
if ($customLogoFile !== null && $customLogoFile !== '') {
$logoFile = $customLogoFile;
$this->logLogo('empresa_logo_save.log', 'Usando logo personalizado: ' . $logoFile);
} else {
// --- SI NO HAY PERSONALIZADO, USAMOS EL SELECT DE EMPRESA ---
if (!isset($data['empresa_vendor']) || $data['empresa_vendor'] === '') {
$this->logLogo('empresa_logo_save.log', 'No hay empresa_vendor y no hay logo custom. No hago nada.');
return;
}
$empresa = $data['empresa_vendor'];
$empresaKey = strtolower(trim((string)$empresa));
$logoMap = [
'docunecta' => 'DocuManager_transparente.png',
'docuindexa' => 'DocuIndexa.png',
];
$vendorLabelMap = [
'docunecta' => 'Docunecta',
'docuindexa' => 'Docuindexa',
];
if (!isset($logoMap[$empresaKey])) {
$this->logLogo('empresa_logo_save.log', "empresa_vendor $empresa no está en logoMap. No hago nada.");
return;
}
$logoFile = $logoMap[$empresaKey];
$vendorLabel = $vendorLabelMap[$empresaKey] ?? null;
$this->logLogo('empresa_logo_save.log', 'Usando logo por vendor: ' . $logoFile);
}
if ($logoFile === null || $logoFile === '') {
$this->logLogo('empresa_logo_save.log', 'logoFile está vacío. No hago nada.');
return;
}
$this->logLogo('empresa_logo_save.log', 'Voy a guardar en parametros.logo: ' . $logoFile);
$mysqli->begin_transaction();
try {
$stmt = $mysqli->prepare("
INSERT INTO parametros (nombre, valor)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)
");
if (!$stmt) {
$this->logLogo('empresa_logo_save.log', 'Error prepare: ' . $mysqli->error);
throw new \RuntimeException('Prepare logo: ' . $mysqli->error);
}
$paramName = 'logo';
$stmt->bind_param('ss', $paramName, $logoFile);
if (!$stmt->execute()) {
$this->logLogo('empresa_logo_save.log', 'Error execute: ' . $stmt->error);
throw new \RuntimeException('Guardar parámetro logo: ' . $stmt->error);
}
if (isset($vendorLabel) && $vendorLabel !== '') {
$paramName = 'vendor';
$stmt->bind_param('ss', $paramName, $vendorLabel);
if (!$stmt->execute()) {
$this->logLogo('empresa_logo_save.log', 'Error execute vendor: ' . $stmt->error);
throw new \RuntimeException('Guardar parámetro vendor: ' . $stmt->error);
}
}
$stmt->close();
$mysqli->commit();
$this->logLogo('empresa_logo_save.log', 'Logo guardado correctamente en BD.');
} catch (\Throwable $e) {
$mysqli->rollback();
$this->logLogo('empresa_logo_save.log', 'EXCEPCIÓN: ' . $e->getMessage());
throw $e;
}
}
private function uploadEmpresaLogo(Request $req): ?string
{
$this->logLogo('empresa_logo_upload.log', '--- NUEVA LLAMADA uploadEmpresaLogo ---');
// name del checkbox en el formulario (ajústalo si usas otro)
$useCustomLogo = $req->request->get('customLogoCheck');
$this->logLogo('empresa_logo_upload.log', 'customLogoCheck = ' . var_export($useCustomLogo, true));
// Si no marcaron "usar logo personalizado", no hacemos nada
if (!$useCustomLogo) {
$this->logLogo('empresa_logo_upload.log', 'No se ha marcado customLogoCheck. Salgo sin subir.');
return null;
}
/** @var UploadedFile|null $file */
$file = $req->files->get('logo_personalizado'); // name="logo_personalizado" en el input file
$this->logLogo('empresa_logo_upload.log', 'FILES[logo_personalizado] = ' . print_r($file, true));
if (!$file instanceof UploadedFile || !$file->isValid()) {
$this->logLogo('empresa_logo_upload.log', 'File no es UploadedFile válido. Salgo sin subir.');
return null;
}
// VALIDACIONES BÁSICAS
$maxSize = 2 * 1024 * 1024; // 2 MB por ejemplo
if ($file->getSize() > $maxSize) {
$this->logLogo('empresa_logo_upload.log', 'Tamańo demasiado grande: ' . $file->getSize());
throw new \RuntimeException('El logo personalizado es demasiado grande (máx 2MB).');
}
$mime = $file->getMimeType();
$allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
$this->logLogo('empresa_logo_upload.log', 'MIME = ' . $mime);
if (!in_array($mime, $allowedMimeTypes, true)) {
$this->logLogo('empresa_logo_upload.log', 'MIME no permitido.');
throw new \RuntimeException('Formato de logo no permitido. Usa PNG, JPG, WEBP o SVG.');
}
// Directorio destino según entorno (configurado en .env/.env.local)
$targetDir = $_ENV['APP_LOGO_DIR'] ?? null;
$this->logLogo('empresa_logo_upload.log', 'APP_LOGO_DIR = ' . var_export($targetDir, true));
if (!$targetDir) {
throw new \RuntimeException('APP_LOGO_DIR no está configurado en el entorno.');
}
if (!is_dir($targetDir)) {
$this->logLogo('empresa_logo_upload.log', "El directorio no existe: $targetDir");
throw new \RuntimeException("El directorio de logos no existe: $targetDir");
}
if (!is_writable($targetDir)) {
$this->logLogo('empresa_logo_upload.log', "El directorio no es escribible: $targetDir");
throw new \RuntimeException("El directorio de logos no es escribible: $targetDir");
}
// Nombre de archivo "seguro" y único
$ext = $file->guessExtension() ?: 'png';
$fileName = 'logo_empresa_' . bin2hex(random_bytes(6)) . '.' . $ext;
$this->logLogo('empresa_logo_upload.log', "Voy a mover archivo como: $fileName");
// Mover físicamente el archivo
$file->move($targetDir, $fileName);
$this->logLogo('empresa_logo_upload.log', "Fichero movido OK a $targetDir/$fileName");
// Devolvemos SOLO el nombre, que es lo que se guardará en parametros.logo
return $fileName;
}
private function logLogo(string $fileName, string $message): void
{
// Directorio de logs de Symfony (donde está dev.log/prod.log)
$logDir = $this->getParameter('kernel.logs_dir');
$fullPath = rtrim($logDir, '/') . '/' . $fileName;
$line = sprintf(
"[%s] %s\n",
date('Y-m-d H:i:s'),
$message
);
file_put_contents($fullPath, $line, FILE_APPEND);
}
private function loadEmpresaLogo(\mysqli $mysqli): ?string
{
$sql = "SELECT valor FROM parametros WHERE nombre = 'logo' LIMIT 1";
$res = $mysqli->query($sql);
if (!$res) {
return null;
}
if ($row = $res->fetch_assoc()) {
return $row['valor'] ?? null;
}
return null;
}
private function upsertLicense(\mysqli $mysqli, array $data, int $capacityGb = 200, int $activeUsers = 3): void
{
$clientName = (string)($data['name'] ?? '');
$licenseStr = 'Documanager 1.0';
$initialDate = date('Y-m-d');
$price = 0;
$emailSender = 'Documanager.es';
$emailFrom = 'no-reply@documanager.es';
$emailName = 'Documanager';
$smtpHost = 'documanager.es';
$smtpUser = 'no-reply@documanager.es';
$smtpPort = 587;
$smtpPass = 'Documanager1!';
$ins = $mysqli->prepare("
INSERT INTO license
(client, license, initialDate, capacityGb, users, price, emailSender, emailFrom, emailName, smtpHost, smtpUser, smtpPort, smtpPass)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
if (!$ins) {
throw new \RuntimeException('Prepare INSERT license: ' . $mysqli->error);
}
$ins->bind_param(
'sssiiisssssis',
$clientName,
$licenseStr,
$initialDate,
$capacityGb,
$activeUsers,
$price,
$emailSender,
$emailFrom,
$emailName,
$smtpHost,
$smtpUser,
$smtpPort,
$smtpPass
);
if (!$ins->execute()) {
$ins->close();
throw new \RuntimeException('Execute INSERT license: ' . $ins->error);
}
$ins->close();
}
private function mapAzureSchemaToDefinitions(array $modelDetail): array
{
$docTypes = $modelDetail['docTypes'] ?? [];
if (!is_array($docTypes) || $docTypes === []) {
return ['header' => [], 'lines' => []];
}
$modelId = (string)($modelDetail['modelId'] ?? '');
$docTypeKey = ($modelId !== '' && array_key_exists($modelId, $docTypes))
? $modelId
: array_key_first($docTypes);
if (!is_string($docTypeKey) || !isset($docTypes[$docTypeKey]) || !is_array($docTypes[$docTypeKey])) {
return ['header' => [], 'lines' => []];
}
$fieldSchema = $docTypes[$docTypeKey]['fieldSchema'] ?? [];
if (!is_array($fieldSchema)) {
return ['header' => [], 'lines' => []];
}
$rawHeader = [];
$rawLines = [];
foreach ($fieldSchema as $fieldKey => $fieldDef) {
if (!is_string($fieldKey) || !is_array($fieldDef)) {
continue;
}
$this->flattenFieldSchema($fieldKey, $fieldDef, $rawHeader, $rawLines);
}
$header = [];
$lines = [];
$seenHeader = [];
$seenLines = [];
foreach ($rawHeader as $item) {
$key = (string)($item['field_key'] ?? '');
if ($key === '' || isset($seenHeader[$key])) {
continue;
}
$seenHeader[$key] = true;
$header[] = [
'field_key' => $key,
'label' => $key,
'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
];
}
foreach ($rawLines as $item) {
$key = (string)($item['field_key'] ?? '');
if ($key === '' || isset($seenLines[$key])) {
continue;
}
$seenLines[$key] = true;
$lines[] = [
'field_key' => $key,
'label' => $key,
'value_type' => $this->mapAzureTypeToValueType((string)($item['azure_type'] ?? '')),
];
}
foreach ($header as $index => &$item) {
$order = $index + 1;
$item['order_index'] = $order;
$item['order_index_table'] = $order;
$item['visibility'] = 1;
$item['visibility_table'] = 1;
}
foreach ($lines as $index => &$item) {
$item['order_index'] = $index + 1;
$item['visibility'] = 1;
}
return ['header' => $header, 'lines' => $lines];
}
private function flattenFieldSchema(
string $fieldKey,
array $fieldDef,
array &$header,
array &$lines,
bool $insideItems = false,
string $parentPath = ''
): void {
$type = strtolower((string)($fieldDef['type'] ?? 'string'));
$currentPath = $parentPath !== '' ? $parentPath . '.' . $fieldKey : $fieldKey;
if ($insideItems) {
if ($type === 'object') {
$properties = $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
if (is_array($properties)) {
foreach ($properties as $childKey => $childDef) {
if (is_string($childKey) && is_array($childDef)) {
$this->flattenFieldSchema($childKey, $childDef, $header, $lines, true, $currentPath);
}
}
}
return;
}
if ($type === 'array') {
$itemsDef = $fieldDef['items'] ?? [];
$itemType = strtolower((string)($itemsDef['type'] ?? 'string'));
if ($itemType === 'object') {
$properties = $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
if (is_array($properties)) {
$arrayPath = $currentPath . '[*]';
foreach ($properties as $childKey => $childDef) {
if (is_string($childKey) && is_array($childDef)) {
$this->flattenFieldSchema($childKey, $childDef, $header, $lines, true, $arrayPath);
}
}
}
} else {
$lines[] = ['field_key' => $currentPath, 'azure_type' => $itemType];
}
return;
}
$lines[] = ['field_key' => $currentPath, 'azure_type' => $type];
return;
}
if ($type === 'object') {
$properties = $fieldDef['properties'] ?? $fieldDef['fields'] ?? [];
if (is_array($properties)) {
foreach ($properties as $childKey => $childDef) {
if (is_string($childKey) && is_array($childDef)) {
$this->flattenFieldSchema($childKey, $childDef, $header, $lines, false, $currentPath);
}
}
}
return;
}
if ($type === 'array') {
$itemsDef = $fieldDef['items'] ?? [];
$itemType = strtolower((string)($itemsDef['type'] ?? 'string'));
$isItems = strtolower($fieldKey) === 'items';
if ($itemType === 'object') {
$properties = $itemsDef['properties'] ?? $itemsDef['fields'] ?? [];
if (!is_array($properties)) {
return;
}
if ($isItems) {
foreach ($properties as $childKey => $childDef) {
if (is_string($childKey) && is_array($childDef)) {
$this->flattenFieldSchema($childKey, $childDef, $header, $lines, true, '');
}
}
} else {
$arrayPath = $currentPath . '[*]';
foreach ($properties as $childKey => $childDef) {
if (is_string($childKey) && is_array($childDef)) {
$this->flattenFieldSchema($childKey, $childDef, $header, $lines, false, $arrayPath);
}
}
}
return;
}
$header[] = ['field_key' => $currentPath, 'azure_type' => $itemType];
return;
}
$header[] = ['field_key' => $currentPath, 'azure_type' => $type];
}
private function mapAzureTypeToValueType(string $azureType): string
{
$azureType = strtolower(trim($azureType));
if ($azureType === 'date') {
return 'date';
}
if ($azureType === 'number' || $azureType === 'integer') {
return 'number';
}
return 'string';
}
private function registerModelInClientDb(\mysqli $clientMysqli, array $resource, string $modelId): int
{
$modelId = trim($modelId);
if ($modelId === '') {
throw new \RuntimeException('ModelId de recurso IA obligatorio.', 400);
}
$modelDetail = $this->azureRequest(
(string)($resource['endpoint'] ?? ''),
(string)($resource['api_key'] ?? ''),
'/documentintelligence/documentModels/' . rawurlencode($modelId)
);
$type = $this->classifyModelType($modelDetail);
$definitions = $this->mapAzureSchemaToDefinitions($modelDetail);
$endpoint = $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
$apiKey = (string)($resource['api_key'] ?? '');
$showConfidenceBadges = 1;
$clientMysqli->begin_transaction();
try {
$existingId = 0;
$stmtSelect = $clientMysqli->prepare(
"SELECT id FROM extraction_models WHERE provider = 'azure-di' AND model_id = ? LIMIT 1"
);
if (!$stmtSelect) {
throw new \RuntimeException('Prepare SELECT extraction_models: ' . $clientMysqli->error);
}
$stmtSelect->bind_param('s', $modelId);
if (!$stmtSelect->execute()) {
$stmtSelect->close();
throw new \RuntimeException('Execute SELECT extraction_models: ' . $stmtSelect->error);
}
$result = $stmtSelect->get_result();
if ($result && ($row = $result->fetch_assoc())) {
$existingId = (int)$row['id'];
}
$stmtSelect->close();
if ($existingId > 0) {
$stmtUpdate = $clientMysqli->prepare(
'UPDATE extraction_models
SET endpoint = ?, api_key = ?, type = ?, show_confidence_badges = ?
WHERE id = ?'
);
if (!$stmtUpdate) {
throw new \RuntimeException('Prepare UPDATE extraction_models: ' . $clientMysqli->error);
}
$stmtUpdate->bind_param('sssii', $endpoint, $apiKey, $type, $showConfidenceBadges, $existingId);
if (!$stmtUpdate->execute()) {
$stmtUpdate->close();
throw new \RuntimeException('Execute UPDATE extraction_models: ' . $stmtUpdate->error);
}
$stmtUpdate->close();
$localModelId = $existingId;
} else {
$provider = 'azure-di';
$stmtInsert = $clientMysqli->prepare(
'INSERT INTO extraction_models (provider, model_id, endpoint, api_key, type, show_confidence_badges)
VALUES (?, ?, ?, ?, ?, ?)'
);
if (!$stmtInsert) {
throw new \RuntimeException('Prepare INSERT extraction_models: ' . $clientMysqli->error);
}
$stmtInsert->bind_param('sssssi', $provider, $modelId, $endpoint, $apiKey, $type, $showConfidenceBadges);
if (!$stmtInsert->execute()) {
$stmtInsert->close();
throw new \RuntimeException('Execute INSERT extraction_models: ' . $stmtInsert->error);
}
$localModelId = (int)$stmtInsert->insert_id;
$stmtInsert->close();
}
$stmtDelHeader = $clientMysqli->prepare('DELETE FROM definitions_header WHERE model_id = ?');
if (!$stmtDelHeader) {
throw new \RuntimeException('Prepare DELETE definitions_header: ' . $clientMysqli->error);
}
$stmtDelHeader->bind_param('i', $localModelId);
if (!$stmtDelHeader->execute()) {
$stmtDelHeader->close();
throw new \RuntimeException('Execute DELETE definitions_header: ' . $stmtDelHeader->error);
}
$stmtDelHeader->close();
$stmtDelLines = $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
if (!$stmtDelLines) {
throw new \RuntimeException('Prepare DELETE definitions_lines: ' . $clientMysqli->error);
}
$stmtDelLines->bind_param('i', $localModelId);
if (!$stmtDelLines->execute()) {
$stmtDelLines->close();
throw new \RuntimeException('Execute DELETE definitions_lines: ' . $stmtDelLines->error);
}
$stmtDelLines->close();
if (!empty($definitions['header'])) {
$stmtHeader = $clientMysqli->prepare(
'INSERT INTO definitions_header
(model_id, field_key, label, value_type, order_index, visibility, order_index_table, visibility_table)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
if (!$stmtHeader) {
throw new \RuntimeException('Prepare INSERT definitions_header: ' . $clientMysqli->error);
}
foreach ($definitions['header'] as $item) {
$fieldKey = (string)$item['field_key'];
$label = (string)$item['label'];
$valueType = (string)$item['value_type'];
$orderIndex = (int)$item['order_index'];
$visibility = (int)$item['visibility'];
$orderIndexTable = (int)$item['order_index_table'];
$visibilityTable = (int)$item['visibility_table'];
$stmtHeader->bind_param(
'isssiiii',
$localModelId,
$fieldKey,
$label,
$valueType,
$orderIndex,
$visibility,
$orderIndexTable,
$visibilityTable
);
if (!$stmtHeader->execute()) {
$stmtHeader->close();
throw new \RuntimeException('Execute INSERT definitions_header: ' . $stmtHeader->error);
}
}
$stmtHeader->close();
}
if (!empty($definitions['lines'])) {
$stmtLines = $clientMysqli->prepare(
'INSERT INTO definitions_lines
(model_id, field_key, label, value_type, order_index, visibility)
VALUES (?, ?, ?, ?, ?, ?)'
);
if (!$stmtLines) {
throw new \RuntimeException('Prepare INSERT definitions_lines: ' . $clientMysqli->error);
}
foreach ($definitions['lines'] as $item) {
$fieldKey = (string)$item['field_key'];
$label = (string)$item['label'];
$valueType = (string)$item['value_type'];
$orderIndex = (int)$item['order_index'];
$visibility = (int)$item['visibility'];
$stmtLines->bind_param(
'isssii',
$localModelId,
$fieldKey,
$label,
$valueType,
$orderIndex,
$visibility
);
if (!$stmtLines->execute()) {
$stmtLines->close();
throw new \RuntimeException('Execute INSERT definitions_lines: ' . $stmtLines->error);
}
}
$stmtLines->close();
}
$clientMysqli->commit();
return $localModelId;
} catch (\Throwable $e) {
$clientMysqli->rollback();
throw $e;
}
}
public function Empresa(Request $req, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$id = (int)$req->get("id");
if (!$id) {
$this->addFlash('warning', 'Empresa no encontrada.');
return $this->redirectToRoute("list");
}
$empresa = $em->getRepository(Empresa::class)->find($id);
if (!$empresa) {
$this->addFlash('warning', 'Empresa no encontrada.');
return $this->redirectToRoute("list");
}
$users = $em->getRepository(Usuario::class)->findBy([
"empresa" => $empresa->getId()
]);
// Valores por defecto por si algo falla al conectar con la BD del cliente
$activeUsers = null;
$modulos = [];
$empresaLogo = null;
$extractionModelLabel = null;
$diskUsedBytes = null;
$diskUsedGb = null;
try {
$cx = $empresa->getConexionBD();
if ($cx) {
$mysqli = @new \mysqli(
$cx->getDbUrl(),
$cx->getDbUser(),
$cx->getDbPassword(),
$cx->getDbName(),
(int)$cx->getDbPort()
);
if (!$mysqli->connect_error) {
// parámetros (modulos, límites, etc.)
[$activeUsers, $modulos] = $this->loadEmpresaParametros($mysqli);
// logo guardado en la BD del cliente
$empresaLogo = $this->loadEmpresaLogo($mysqli);
$diskUsedBytes = $this->loadEmpresaDiskUsageBytes($mysqli);
$diskUsedGb = ($diskUsedBytes !== null)
? round($diskUsedBytes / 1024 / 1024 / 1024, 2)
: null;
// Si tiene extracción y modelo seleccionado, buscamos el ID legible del modelo
if (
!empty($modulos['modulo_extraccion']) &&
!empty($modulos['extraction_model'])
) {
try {
$stmt = $mysqli->prepare('SELECT model_id FROM extraction_models WHERE id = ? LIMIT 1');
if ($stmt) {
$modelParam = (int)$modulos['extraction_model'];
$stmt->bind_param('i', $modelParam);
if ($stmt->execute()) {
$res = $stmt->get_result();
if ($res && ($row = $res->fetch_assoc()) && isset($row['model_id'])) {
$extractionModelLabel = $row['model_id'];
}
}
$stmt->close();
}
} catch (\Throwable $e) {
// Si falla, simplemente no mostramos el texto bonito del modelo
$extractionModelLabel = null;
}
}
$mysqli->close();
}
}
} catch (\Throwable $e) {
// Aquí podrías loguear el error si quieres, pero no rompemos la pantalla
}
return $this->render('empresa_detail.html.twig', [
'empresa' => $empresa,
'users' => $users,
'activeUsers' => $activeUsers,
'modulos' => $modulos,
'empresaLogo' => $empresaLogo,
'extractionModelLabel' => $extractionModelLabel,
'diskUsedGb' => $diskUsedGb,
]);
}
public function deleteEmpresa(Request $request, EntityManagerInterface $em)
{
$id = $request->get("id");
$empresa = $em->getRepository(Empresa::class)->find($id);
$conexion = $empresa->getConexionBD();
$usuarios = $em->getRepository(Usuario::class)->findBy(array("empresa" => $empresa->getId()));
// Recoger avatar/firma de usuarios antes de eliminar la BD del cliente
$mediaPaths = [];
try {
$mysqliMedia = @new \mysqli(
$conexion->getDbUrl(),
$conexion->getDbUser(),
$conexion->getDbPassword(),
$conexion->getDbName(),
(int)$conexion->getDbPort()
);
if (!$mysqliMedia->connect_error) {
$mediaPaths = $this->getCompanyUserMediaPaths($mysqliMedia);
$mysqliMedia->close();
} else {
error_log('No se pudo conectar a BD cliente para borrar media: ' . $mysqliMedia->connect_error);
}
} catch (\Throwable $e) {
error_log('Error borrando media de usuarios: ' . $e->getMessage());
}
$hestiaApiUrl = 'https://200.234.237.107:8083/api/';
$owner = 'docunecta'; // o el dueño del hosting
$postFields = http_build_query([
'user' => 'admin',
'password' => 'i9iQiSmxb2EpvgLq',
'returncode' => 'yes',
'cmd' => 'v-delete-database',
'arg1' => 'admin',
'arg2' => $conexion->getDbName(),
]);
$accessKeyId = 'cWYbt9ShyFQ3yVRsUE8u';
$secretKey = 'e2M_5wk2_jUAlPorF7V8zfwo3_0ihu90WoLPMKwj';
$headers = [
'Authorization: Bearer ' . $accessKeyId . ':' . $secretKey
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $hestiaApiUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Solo si usas certificados autofirmados
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if (($error || trim($response) !== '0') && trim($response) !== '3') {
$this->addFlash('danger', 'Error al eliminar la base de datos en HestiaCP: ' . ($error ?: $response));
return $this->redirectToRoute('list');
}
// Eliminar el servicio systemd asociado a la empresa
$company_name = $empresa->getId();
$serviceName = $company_name . "-documanager.service";
$servicePath = "/etc/systemd/system/$serviceName";
$cmds = [
"sudo /bin/systemctl stop $serviceName",
"sudo /bin/systemctl disable $serviceName",
"sudo /bin/rm -f $servicePath",
"sudo /bin/systemctl daemon-reload"
];
$serviceErrors = [];
foreach ($cmds as $cmd) {
$output = @\shell_exec($cmd . " 2>&1");
if ($output !== null && trim($output) !== '') {
$serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
}
}
$azureService = $company_name . "-azuredi.service";
$servicePathAzure = "/etc/systemd/system/$azureService";
$cmdsAzure = [
"sudo /bin/systemctl stop $azureService",
"sudo /bin/systemctl disable $azureService",
"sudo /bin/rm -f $servicePathAzure",
"sudo /bin/systemctl daemon-reload",
];
foreach ($cmdsAzure as $cmd) {
$output = @\shell_exec($cmd . " 2>&1");
if ($output) {
$serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
}
}
// Eliminar mail monitor service + timer (si existen)
$this->disableMailMonitorService((int)$company_name);
// Pedir a platform que elimine files/logs/media del cliente
$this->callPlatformCleanup((int)$company_name, $mediaPaths);
//eliminamos usuarios
foreach ($usuarios as $user) {
$em->remove($user);
$em->flush();
}
//eliminamos conexiĂłn
$em->remove($conexion);
$em->flush();
//eliminamos empresa
$em->remove($empresa);
$em->flush();
$msg = 'Empresa y base de datos eliminadas correctamente.';
if (count($serviceErrors) > 0) {
$msg .= ' ' . implode('<br>', $serviceErrors);
}
$this->addFlash('success', $msg);
return $this->redirectToRoute('list');
}
public function editEmpresa(Request $request, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$id = (int)$request->get('id');
$empresa = $em->getRepository(Empresa::class)->find($id);
if (!$empresa) {
throw $this->createNotFoundException('Empresa no encontrada');
}
// 1) Conectar a la BD del cliente con las credenciales de la central
$cx = $empresa->getConexionBD();
$mysqli = @new \mysqli(
$cx->getDbUrl(),
$cx->getDbUser(),
$cx->getDbPassword(),
$cx->getDbName(),
(int)$cx->getDbPort()
);
if ($mysqli->connect_error) {
$this->addFlash('danger', 'No se puede conectar a la BD del cliente: ' . $mysqli->connect_error);
return $this->redirectToRoute('list');
}
$azureResources = [];
try {
$azureResources = $this->loadAzureDiResources($em);
} catch (\Throwable $e) {
$this->addFlash('warning', 'No se pudo cargar el catalogo de recursos IA: ' . $e->getMessage());
}
if ($request->isMethod('POST') && $request->request->get('submit') !== null) {
// Estado previo de modulos (para detectar activacion de Mail Monitor)
[$activeUsersPrev, $modulosPrev] = $this->loadEmpresaParametros($mysqli);
$prevMailMonitor = (int)($modulosPrev['modulo_mailMonitor'] ?? 0);
$data = $request->request->all();
$maxThreads = $this->clampDocuMaxThreads($data['maxThreads'] ?? null, $empresa->getMaxThreads() ?: 4);
// 2) Actualizar SOLO central
$empresa->setName((string)($data['name'] ?? $empresa->getName()));
$empresa->setMaxDiskQuota(
isset($data['maxDiskQuota']) && $data['maxDiskQuota'] !== ''
? (int)$data['maxDiskQuota']
: $empresa->getMaxDiskQuota()
);
$empresa->setMaxThreads($maxThreads);
$em->persist($empresa);
// ---- Normalización de POST (checkboxes / select) ----
$toBool = fn($v) => in_array(strtolower((string)$v), ['1', 'on', 'true', 'yes'], true);
$extractionModel = 0;
if (isset($data['extraction_model']) && $data['extraction_model'] !== '') {
$extractionModel = (int)$data['extraction_model'];
}
$data['extraction_model'] = $extractionModel;
$azureResourceId = (int)($data['azure_resource_id'] ?? 0);
$azureModelId = trim((string)($data['azure_model_id'] ?? ''));
$data['modulo_extraccion'] = isset($data['modulo_extraccion']) && $toBool($data['modulo_extraccion']) ? 1 : 0;
$data['modulo_etiquetas'] = isset($data['modulo_etiquetas']) && $toBool($data['modulo_etiquetas']) ? 1 : 0;
$data['modulo_calendario'] = isset($data['modulo_calendario']) && $toBool($data['modulo_calendario']) ? 1 : 0;
$data['modulo_calendarioExterno'] = isset($data['modulo_calendarioExterno']) && $toBool($data['modulo_calendarioExterno']) ? 1 : 0;
$data['modulo_estados'] = isset($data['modulo_estados']) && $toBool($data['modulo_estados']) ? 1 : 0;
$data['modulo_lineas'] = isset($data['modulo_lineas']) && $toBool($data['modulo_lineas']) ? 1 : 0;
$data['modulo_agora'] = isset($data['modulo_agora']) && $toBool($data['modulo_agora']) ? 1 : 0;
$data['modulo_gstock'] = isset($data['modulo_gstock']) && $toBool($data['modulo_gstock']) ? 1 : 0;
$data['modulo_expowin'] = isset($data['modulo_expowin']) && $toBool($data['modulo_expowin']) ? 1 : 0;
$data['modulo_mailMonitor'] = isset($data['modulo_mailMonitor']) && $toBool($data['modulo_mailMonitor']) ? 1 : 0;
$data['modulo_busquedaNatural'] = isset($data['modulo_busquedaNatural']) && $toBool($data['modulo_busquedaNatural']) ? 1 : 0;
$data['soloExtraccion'] = isset($data['soloExtraccion']) && $toBool($data['soloExtraccion']) ? 1 : 0;
// Dependencias
if (!$data['modulo_calendario']) {
$data['modulo_calendarioExterno'] = 0;
}
if (!$data['modulo_extraccion']) {
$data['modulo_lineas'] = 0;
$data['extraction_model'] = 0;
$data['modulo_agora'] = 0;
$data['modulo_gstock'] = 0;
$data['modulo_expowin'] = 0;
$azureResourceId = 0;
$azureModelId = '';
}
// 3) Guardar en BD del cliente: parametros + license
if ($data['modulo_extraccion'] === 1) {
if (($azureResourceId > 0 && $azureModelId === '') || ($azureResourceId <= 0 && $azureModelId !== '')) {
$this->addFlash('danger', 'Para importar desde recurso IA debes indicar recurso y modelo.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
if ($azureResourceId > 0 && $azureModelId !== '') {
$azureResource = $this->loadAzureDiResourceById($em, $azureResourceId);
if (!$azureResource) {
$this->addFlash('danger', 'El recurso IA seleccionado no existe.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
try {
$data['extraction_model'] = $this->registerModelInClientDb(
$mysqli,
$azureResource,
$azureModelId
);
$this->addFlash('success', 'Modelo IA importado/actualizado correctamente.');
} catch (\Throwable $e) {
$this->addFlash('danger', 'No se pudo registrar el modelo IA: ' . $e->getMessage());
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
}
if ((int)$data['extraction_model'] <= 0) {
$this->addFlash('danger', 'Con extraccion activa debes seleccionar un modelo local o importar uno desde recurso IA.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
}
$this->updateEmpresaParametros($mysqli, $data);
$this->updateLicense(
$mysqli,
$data,
$empresa->getName()
);
// Actualizar servicio OCR (hilos)
$ocrBinary = $_ENV['OCR_BINARY'] ?? '';
$filesPath = $_ENV['FILES_PATH'] ?? '';
if ($ocrBinary !== '' && $filesPath !== '') {
$companyId = (int)$empresa->getId();
$dbHost = (string)$cx->getDbUrl();
$dbName = (string)$cx->getDbName();
$dbUser = (string)$cx->getDbUser();
$dbPass = (string)$cx->getDbPassword();
$empresaName = (string)$empresa->getName();
$serviceContent = <<<EOT
[Unit]
Description={$empresaName} DocuManager OCR
Requires=mariadb.service
After=mariadb.service
[Service]
Type=simple
Environment="DOCU_MAX_THREADS=$maxThreads"
ExecStart=$ocrBinary {$dbHost}/{$dbName} {$dbUser} {$dbPass} {$filesPath}/{$companyId} NO
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOT;
$serviceName = $companyId . "-documanager.service";
$tmpServicePath = "/tmp/$serviceName";
file_put_contents($tmpServicePath, $serviceContent);
\chmod($tmpServicePath, 0644);
$cmds = [
"sudo /bin/mv /tmp/$serviceName /etc/systemd/system/$serviceName",
"sudo /bin/systemctl daemon-reload",
"sudo /bin/systemctl restart $serviceName",
];
$serviceErrors = [];
foreach ($cmds as $cmd) {
$output = \shell_exec($cmd . " 2>&1");
if ($output !== null && trim($output) !== '') {
error_log("CMD OUTPUT: $cmd\n$output");
$serviceErrors[] = "CMD OUTPUT: $cmd\n$output";
}
}
if (count($serviceErrors) > 0) {
$this->addFlash('warning', 'Servicio OCR actualizado con avisos: ' . implode(' | ', $serviceErrors));
}
} else {
$this->addFlash('warning', 'No se pudo actualizar el servicio OCR: faltan OCR_BINARY o FILES_PATH.');
}
// Si se activa Mail Monitor y antes estaba desactivado, crear servicio/timer
if ($prevMailMonitor === 0 && (int)$data['modulo_mailMonitor'] === 1 && $filesPath !== '') {
$this->ensureMailMonitorService(
(int)$empresa->getId(),
(string)$cx->getDbUrl(),
(string)$cx->getDbPort(),
(string)$cx->getDbUser(),
(string)$cx->getDbPassword(),
(string)$cx->getDbName(),
(string)$filesPath
);
}
// Si se desactiva Mail Monitor, parar y eliminar service/timer
if ($prevMailMonitor === 1 && (int)$data['modulo_mailMonitor'] === 0) {
$this->disableMailMonitorService((int)$empresa->getId());
}
$em->flush();
$mysqli->close();
// Mensaje de éxito
$this->addFlash('success', 'Empresa editada correctamente.');
return $this->redirectToRoute('app_empresa_show', ['id' => $id]);
}
// 4) GET: precargar desde BD del cliente
[$activeUsers, $modulos] = $this->loadEmpresaParametros($mysqli);
$license = $this->loadLicense($mysqli); // por si quieres mostrarlo
$extractionModels = [];
try {
$res = $mysqli->query('SELECT id, model_id FROM extraction_models ORDER BY model_id');
if ($res) {
while ($row = $res->fetch_assoc()) {
$extractionModels[] = [
'id' => (int)$row['id'],
'model_id' => (string)$row['model_id'],
];
}
$res->free();
}
} catch (\Throwable $e) {
$extractionModels = [];
}
$mysqli->close();
return $this->render('empresa/_edit.html.twig', [
'empresa' => $empresa, // central: name + maxDiskQuota
'id' => $id,
'activeUsers' => $activeUsers, // cliente
'modulos' => $modulos, // cliente
'license' => $license, // opcional
'azure_resources' => $azureResources,
'extraction_models' => $extractionModels,
]);
}
private function loadEmpresaParametros(\mysqli $mysqli): array
{
// claves que nos interesan en la tabla parametros
$keys = [
'activeUsers',
'soloExtraccion',
'modulo_etiquetas',
'modulo_calendario',
'modulo_calExt',
'modulo_estados',
'modulo_subida',
'modulo_mailMonitor',
'modulo_busquedaNatural',
'modulo_extraccion',
'modulo_lineas',
'modulo_agora',
'modulo_gstock',
'modulo_expowin',
'limite_archivos',
'extraction_model',
];
$placeholders = implode(',', array_fill(0, count($keys), '?'));
$sql = "SELECT nombre, valor FROM parametros WHERE nombre IN ($placeholders)";
$stmt = $mysqli->prepare($sql);
if (!$stmt) {
throw new \RuntimeException('Prepare SELECT parametros: ' . $mysqli->error);
}
// bind dinámico
$types = str_repeat('s', count($keys));
$stmt->bind_param($types, ...$keys);
if (!$stmt->execute()) {
$stmt->close();
throw new \RuntimeException('Execute SELECT parametros: ' . $stmt->error);
}
$res = $stmt->get_result();
$map = [];
while ($row = $res->fetch_assoc()) {
$map[$row['nombre']] = $row['valor'];
}
$stmt->close();
// defaults seguros
$activeUsers = isset($map['activeUsers']) ? (int)$map['activeUsers'] : 3;
$flags = [
'soloExtraccion' => isset($map['soloExtraccion']) ? (int)$map['soloExtraccion'] : 0,
'modulo_etiquetas' => isset($map['modulo_etiquetas']) ? (int)$map['modulo_etiquetas'] : 0,
'modulo_calendario' => isset($map['modulo_calendario']) ? (int)$map['modulo_calendario'] : 0,
'modulo_calExt' => isset($map['modulo_calExt']) ? (int)$map['modulo_calExt'] : 0,
'modulo_estados' => isset($map['modulo_estados']) ? (int)$map['modulo_estados'] : 0,
'modulo_subida' => isset($map['modulo_subida']) ? (int)$map['modulo_subida'] : 0,
'modulo_extraccion' => isset($map['modulo_extraccion']) ? (int)$map['modulo_extraccion'] : 0,
'modulo_lineas' => isset($map['modulo_lineas']) ? (int)$map['modulo_lineas'] : 0,
'modulo_agora' => isset($map['modulo_agora']) ? (int)$map['modulo_agora'] : 0,
'modulo_gstock' => isset($map['modulo_gstock']) ? (int)$map['modulo_gstock'] : 0,
'modulo_expowin' => isset($map['modulo_expowin']) ? (int)$map['modulo_expowin'] : 0,
'modulo_mailMonitor' => isset($map['modulo_mailMonitor']) ? (int)$map['modulo_mailMonitor'] : 0,
'modulo_busquedaNatural' => isset($map['modulo_busquedaNatural']) ? (int)$map['modulo_busquedaNatural'] : 0,
];
$limiteArchivos = isset($map['limite_archivos']) && $map['limite_archivos'] !== ''
? (int)$map['limite_archivos']
: 500;
// Lo ańadimos a $flags para no cambiar la firma del return
$flags['limite_archivos'] = $limiteArchivos;
// extraction_model: default 0 (sin modelo)
$flags['extraction_model'] = isset($map['extraction_model']) && $map['extraction_model'] !== '' ? (int)$map['extraction_model'] : 0;
return [$activeUsers, $flags];
}
private function updateEmpresaParametros(\mysqli $mysqli, array $data): void
{
$toBool = fn($v) => in_array(strtolower((string)$v), ['1', 'on', 'true', 'yes'], true);
$getInt = fn(array $a, string $k, int $d) => (isset($a[$k]) && $a[$k] !== '') ? (int)$a[$k] : $d;
$getFlag = fn(array $a, string $k) => (isset($a[$k]) && (int)$a[$k] === 1) ? 1 : 0;
$paramMap = [
'activeUsers' => $getInt($data, 'maxActiveUsers', 3),
'soloExtraccion' => $getFlag($data, 'soloExtraccion'),
'modulo_etiquetas' => $getFlag($data, 'modulo_etiquetas'),
'modulo_calendario' => $getFlag($data, 'modulo_calendario'),
'modulo_calExt' => $getFlag($data, 'modulo_calendarioExterno'),
'modulo_estados' => $getFlag($data, 'modulo_estados'),
'modulo_subida' => $getFlag($data, 'modulo_subida'),
'modulo_mailMonitor' => $getFlag($data, 'modulo_mailMonitor'),
'modulo_busquedaNatural' => $getFlag($data, 'modulo_busquedaNatural'),
'modulo_extraccion' => $getFlag($data, 'modulo_extraccion'),
'modulo_lineas' => $getFlag($data, 'modulo_lineas'),
'modulo_agora' => $getFlag($data, 'modulo_agora'),
'modulo_gstock' => $getFlag($data, 'modulo_gstock'),
'modulo_expowin' => $getFlag($data, 'modulo_expowin'),
'limite_archivos' => $getInt($data, 'limite_archivos', 500),
'extraction_model' => $getInt($data, 'extraction_model', 0),
];
if ($paramMap['modulo_extraccion'] === 0) {
$paramMap['modulo_lineas'] = 0;
$paramMap['extraction_model'] = 0;
$paramMap['modulo_agora'] = 0;
$paramMap['modulo_gstock'] = 0;
$paramMap['modulo_expowin'] = 0;
}
if ($paramMap['modulo_calendario'] === 0) {
$paramMap['modulo_calExt'] = 0;
}
$mysqli->begin_transaction();
try {
$stmt = $mysqli->prepare("
INSERT INTO parametros (nombre, valor)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)
");
if (!$stmt) {
throw new \RuntimeException('Prepare UPSERT parametros: ' . $mysqli->error);
}
foreach ($paramMap as $nombre => $valor) {
$v = (string)$valor;
if (!$stmt->bind_param('ss', $nombre, $v) || !$stmt->execute()) {
$stmt->close();
throw new \RuntimeException("Guardar parámetro {$nombre}: {$stmt->error}");
}
}
$stmt->close();
$mysqli->commit();
} catch (\Throwable $e) {
$mysqli->rollback();
throw $e;
}
}
private function loadLicense(\mysqli $mysqli): array
{
// Carga opcional para mostrar en la edición: capacityGb/users (u otros)
$sql = "SELECT client, capacityGb, users FROM license LIMIT 1";
$res = $mysqli->query($sql);
if ($res && $row = $res->fetch_assoc()) {
return $row;
}
return [];
}
private function updateLicense(\mysqli $mysqli, array $data, string $clientName): void
{
$capacityGb = isset($data['maxDiskQuota']) ? (int)$data['maxDiskQuota'] : 200;
$activeUsers = isset($data['maxActiveUsers']) ? (int)$data['maxActiveUsers'] : 3;
$stmt = $mysqli->prepare("UPDATE license SET capacityGb = ?, users = ? WHERE client = ?");
if (!$stmt) {
throw new \RuntimeException('Prepare UPDATE license: ' . $mysqli->error);
}
$stmt->bind_param('iis', $capacityGb, $activeUsers, $clientName);
$stmt->execute();
$stmt->close();
}
public function usersListEmpresa(Request $request, EntityManagerInterface $em)
{
$id = $request->get("id");
$users_empresa = $em->getRepository(Usuario::class)->findBy(array("empresa" => $id));
return $this->render('empresa/usersList.html.twig', array(
'users' => $users_empresa,
'id' => $id
));
}
private function loadEmpresaDiskUsageBytes(\mysqli $mysqli): ?int
{
// Si la tabla no existe o hay error, devolvemos null para no romper la vista
$sql = "SELECT COALESCE(SUM(size_bytes), 0) AS total_bytes FROM files";
$res = $mysqli->query($sql);
if (!$res) {
return null;
}
$row = $res->fetch_assoc();
$res->free();
return isset($row['total_bytes']) ? (int)$row['total_bytes'] : 0;
}
// =======================================================================
// VER DOCUMENTACION DE EMPRESAS
// =======================================================================
#[Route('/empresa/{id}/documentos', name: 'empresa_documentos', methods: ['GET'])]
public function documentosEmpresa(Request $request, EntityManagerInterface $em)
{
if (!$this->getUser() || !is_object($this->getUser())) {
return $this->redirectToRoute('logout');
}
$id = (int) $request->get('id');
$empresa = $em->getRepository(Empresa::class)->find($id);
if (!$empresa) {
$this->addFlash('warning', 'Empresa no encontrada.');
return $this->redirectToRoute('list');
}
// Para el desplegable
$empresas = $em->getRepository(Empresa::class)->findAll();
$baseUrl = $this->getParameter('documanager_base_url');
return $this->render('empresa/documentos.html.twig', [
'empresaId' => $id,
'empresas' => $empresas,
'documanagerBaseUrl' => $baseUrl,
]);
}
#[Route('/api/empresa/{id}/documentos', name: 'empresa_documentos_api', methods: ['GET'])]
public function documentosEmpresaApi(Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !is_object($this->getUser())) {
return new JsonResponse(['error' => 'unauthorized'], 401);
}
$id = (int) $request->get('id');
$empresa = $em->getRepository(Empresa::class)->find($id);
if (!$empresa) {
return new JsonResponse(['error' => 'Empresa no encontrada'], 404);
}
$cx = $empresa->getConexionBD();
if (!$cx) {
return new JsonResponse(['error' => 'La empresa no tiene conexión configurada'], 400);
}
$page = max(1, (int) $request->query->get('page', 1));
$perPage = (int) $request->query->get('per_page', 50);
$perPage = min(max($perPage, 10), 200);
$q = (string) $request->query->get('q', '');
$offset = ($page - 1) * $perPage;
$mysqli = @new \mysqli(
$cx->getDbUrl(),
$cx->getDbUser(),
$cx->getDbPassword(),
$cx->getDbName(),
(int) $cx->getDbPort()
);
if ($mysqli->connect_error) {
return new JsonResponse(['error' => 'Error conectando a la BD del cliente: ' . $mysqli->connect_error], 500);
}
$mysqli->set_charset('utf8mb4');
// --- TOTAL ---
if ($q === '') {
$sqlTotal = "SELECT COUNT(*) AS c FROM files";
$res = $mysqli->query($sqlTotal);
$row = $res ? $res->fetch_assoc() : null;
$total = (int)($row['c'] ?? 0);
} else {
$sqlTotal = "SELECT COUNT(*) AS c
FROM files
WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?";
$qLike = '%' . $q . '%';
$st = $mysqli->prepare($sqlTotal);
$st->bind_param('ssss', $qLike, $qLike, $qLike, $qLike);
$st->execute();
$res = $st->get_result();
$row = $res ? $res->fetch_assoc() : null;
$total = (int)($row['c'] ?? 0);
$st->close();
}
// --- ITEMS ---
$items = [];
if ($q === '') {
$sql = "SELECT name, size_bytes, path, `date`, status, control, tag, notes
FROM files
ORDER BY `date` DESC
LIMIT ? OFFSET ?";
$st = $mysqli->prepare($sql);
$st->bind_param('ii', $perPage, $offset);
} else {
$sql = "SELECT name, size_bytes, path, `date`, status, control, tag, notes
FROM files
WHERE name LIKE ? OR path LIKE ? OR tag LIKE ? OR notes LIKE ?
ORDER BY `date` DESC
LIMIT ? OFFSET ?";
$qLike = '%' . $q . '%';
$st = $mysqli->prepare($sql);
$st->bind_param('ssssii', $qLike, $qLike, $qLike, $qLike, $perPage, $offset);
}
$st->execute();
$res = $st->get_result();
if ($res) {
while ($r = $res->fetch_assoc()) {
$items[] = $r;
}
}
$st->close();
$mysqli->close();
return new JsonResponse([
'company_id' => $id,
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'items' => $items,
]);
}
}