<?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 App\Config\GstockAutoMappings;
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 EXTRACTOR_TYPE_AZURE_DI = 'azure-di';
private const EXTRACTOR_TYPE_AZURE_OPENAI = 'azure-openai';
private const GLOBAL_IS_ADMIN_SUPERADMIN = 3;
private const GLOBAL_IS_ADMIN_SUPERUSER = 4;
private const GLOBAL_IS_ADMINS = [self::GLOBAL_IS_ADMIN_SUPERADMIN, self::GLOBAL_IS_ADMIN_SUPERUSER];
private const PROFILE_SUPERUSER = 0;
private const PROFILE_SUPERADMIN = 1;
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 azureDiPhpBaseUrl(Request $request): string
{
$host = strtolower(trim((string)$request->getHost()));
if ($host !== '' && preg_match('/^(console|newdev)\.(.+)$/', $host, $matches) === 1) {
$mappedSubdomain = $matches[1] === 'console' ? 'app' : 'platform';
return 'https://' . $mappedSubdomain . '.' . $matches[2] . '/newdocu/public';
}
return rtrim(trim((string)($_ENV['AZURE_DI_DOCU_PHP_BASE_URL'] ?? '')), '/');
}
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 normalizeOcrMode($value): string
{
$mode = strtolower(trim((string)$value));
if ($mode === 'plus_glm') {
return 'plus_glm';
}
if ($mode === 'v2_zxing') {
return 'v2_zxing';
}
return 'base';
}
private function isOcrV2Available(): bool
{
$forced = $_ENV['OCR_V2_AVAILABLE'] ?? null;
if ($forced !== null && $forced !== '') {
return in_array(strtolower((string)$forced), ['1', 'true', 'yes', 'on'], true);
}
$ocrV2Dir = (string)($_ENV['OCR_V2_DIR'] ?? '/home/docunecta/ocr/documanager_ocr+zxing');
if ($ocrV2Dir === '') {
return false;
}
if ($this->isPathAllowedByOpenBaseDir($ocrV2Dir)) {
return @is_dir($ocrV2Dir);
}
$sudoResult = $this->checkDirExistsWithSudo($ocrV2Dir);
return $sudoResult ?? false;
}
private function isOcrPlusAvailable(): bool
{
$forced = $_ENV['OCR_PLUS_AVAILABLE'] ?? null;
if ($forced !== null && $forced !== '') {
return in_array(strtolower((string)$forced), ['1', 'true', 'yes', 'on'], true);
}
$ocrPlusDir = (string)($_ENV['OCR_PLUS_DIR'] ?? '/home/docunecta/ocr/TEST_documanager_ocr+glm+zxing');
if ($ocrPlusDir === '') {
return false;
}
if ($this->isPathAllowedByOpenBaseDir($ocrPlusDir)) {
return @is_dir($ocrPlusDir);
}
$sudoResult = $this->checkDirExistsWithSudo($ocrPlusDir);
return $sudoResult ?? false;
}
private function isPathAllowedByOpenBaseDir(string $path): bool
{
$openBaseDir = (string)ini_get('open_basedir');
if ($openBaseDir === '') {
return true;
}
$normalizedPath = rtrim(str_replace('\\', '/', $path), '/') . '/';
$allowedParts = array_filter(array_map('trim', explode(PATH_SEPARATOR, $openBaseDir)));
foreach ($allowedParts as $allowed) {
$normalizedAllowed = rtrim(str_replace('\\', '/', (string)$allowed), '/') . '/';
if (str_starts_with($normalizedPath, $normalizedAllowed)) {
return true;
}
}
return false;
}
private function checkDirExistsWithSudo(string $path): ?bool
{
$cmd = 'sudo -n /usr/bin/test -d ' . escapeshellarg($path) . ' && echo 1 || echo 0';
$out = @shell_exec($cmd . ' 2>/dev/null');
if ($out === null) {
return null;
}
$out = trim($out);
if ($out === '1') {
return true;
}
if ($out === '0') {
return false;
}
return null;
}
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 IN (" . self::GLOBAL_IS_ADMIN_SUPERADMIN . ", " . self::GLOBAL_IS_ADMIN_SUPERUSER . ")
GROUP BY LOWER(email)
ORDER BY MIN(email) ASC
");
foreach ($rows as &$row) {
$email = $this->normalizeSuperuserEmail((string)($row['email'] ?? ''));
$links = $em->getConnection()->fetchAllAssociative(
"SELECT empresa_id, is_admin
FROM users
WHERE LOWER(email) = :email AND is_admin IN (" . self::GLOBAL_IS_ADMIN_SUPERADMIN . ", " . self::GLOBAL_IS_ADMIN_SUPERUSER . ")
ORDER BY empresa_id ASC",
['email' => $email]
);
$empresaIds = [];
foreach ($links as $link) {
$empresaIds[] = (int)($link['empresa_id'] ?? 0);
}
$empresaIds = array_values(array_unique(array_filter($empresaIds)));
$account = $this->resolveGlobalAccountType($em, $email, $empresaIds, $links);
$row['account_type'] = $account['key'];
$row['account_type_label'] = $account['label'];
}
unset($row);
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' => [],
'account_type' => 'superadmin',
];
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);
$targetProfile = $this->targetProfileFromRequest($request);
$formData['email'] = $email;
$formData['status'] = $enabled ? 'ENABLED' : 'DISABLED';
$formData['empresas'] = $empresaIds;
$formData['account_type'] = $this->accountTypeFromProfile($targetProfile);
try {
$service->createSuperuser($email, $password, $empresaIds, $enabled, $targetProfile);
$this->addFlash('success', 'Usuario global creado correctamente.');
return $this->redirectToRoute('superusers_list');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
}
return $this->render('superusers/form.html.twig', [
'title' => 'Crear usuario global',
'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, is_admin
FROM users
WHERE LOWER(email) = :email AND is_admin IN (" . self::GLOBAL_IS_ADMIN_SUPERADMIN . ", " . self::GLOBAL_IS_ADMIN_SUPERUSER . ")",
['email' => $email]
);
if (count($existingRows) === 0) {
$this->addFlash('warning', 'Usuario global no encontrado.');
return $this->redirectToRoute('superusers_list');
}
$selectedEmpresaIds = [];
$hasEnabled = false;
$isAdminCandidates = [];
foreach ($existingRows as $row) {
$empresaId = (int)$row['empresa_id'];
$selectedEmpresaIds[] = $empresaId;
$hasEnabled = $hasEnabled || strtoupper((string)$row['status']) === 'ENABLED';
$isAdminCandidates[] = (int)($row['is_admin'] ?? 0);
}
$selectedEmpresaIds = array_values(array_unique($selectedEmpresaIds));
sort($selectedEmpresaIds);
$isAdminCandidates = array_values(array_unique($isAdminCandidates));
$resolvedProfile = count($isAdminCandidates) === 1
? $this->profileFromIsAdmin((int)$isAdminCandidates[0])
: self::PROFILE_SUPERADMIN;
$accountType = $this->accountTypeFromProfile($resolvedProfile);
$empresas = $em->getRepository(Empresa::class)->findAll();
$formData = [
'email' => $email,
'status' => $hasEnabled ? 'ENABLED' : 'DISABLED',
'empresas' => $selectedEmpresaIds,
'account_type' => $accountType,
];
if ($request->isMethod('POST')) {
$newPassword = trim((string)$request->request->get('password', ''));
$enabled = strtoupper((string)$request->request->get('status', 'ENABLED')) === 'ENABLED';
$empresaIds = $this->readEmpresaIdsFromRequest($request);
$targetProfile = $this->targetProfileFromRequest($request);
$formData['status'] = $enabled ? 'ENABLED' : 'DISABLED';
$formData['empresas'] = $empresaIds;
$formData['account_type'] = $this->accountTypeFromProfile($targetProfile);
try {
$service->updateSuperuser($email, ($newPassword === '' ? null : $newPassword), $empresaIds, $enabled, $targetProfile);
$this->addFlash('success', 'Usuario global actualizado correctamente.');
return $this->redirectToRoute('superusers_list');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
}
return $this->render('superusers/form.html.twig', [
'title' => 'Editar usuario global',
'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, is_admin
FROM users
WHERE LOWER(email) = :email AND is_admin IN (" . self::GLOBAL_IS_ADMIN_SUPERADMIN . ", " . self::GLOBAL_IS_ADMIN_SUPERUSER . ")",
['email' => $email]
);
if (count($rows) === 0) {
$this->addFlash('warning', 'Usuario global no encontrado.');
return $this->redirectToRoute('superusers_list');
}
$targetEmpresaIds = [];
$enabled = false;
$isAdminCandidates = [];
foreach ($rows as $row) {
$currentEmpresaId = (int)$row['empresa_id'];
if ($currentEmpresaId !== $empresaId) {
$targetEmpresaIds[] = $currentEmpresaId;
}
$enabled = $enabled || strtoupper((string)$row['status']) === 'ENABLED';
$isAdminCandidates[] = (int)($row['is_admin'] ?? 0);
}
$isAdminCandidates = array_values(array_unique($isAdminCandidates));
$resolvedProfile = count($isAdminCandidates) === 1
? $this->profileFromIsAdmin((int)$isAdminCandidates[0])
: self::PROFILE_SUPERADMIN;
try {
$service->updateSuperuser($email, null, $targetEmpresaIds, $enabled, $resolvedProfile);
$this->addFlash('success', 'Vinculación eliminada correctamente.');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
return $this->redirectToRoute('superusers_edit', ['email' => $email]);
}
public function superusersDeleteGlobal(string $email, Request $request, 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);
try {
$service->deleteSuperuser($email);
$this->addFlash('success', 'Usuario global eliminado de todas las empresas.');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
return $this->redirectToRoute('superusers_list');
}
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 IN (:isAdmins)')
->andWhere('IDENTITY(u.empresa) IN (:empresaIds)')
->setParameter('email', $email)
->setParameter('isAdmins', self::GLOBAL_IS_ADMINS)
->setParameter('empresaIds', $empresaIds)
->getQuery()
->getSingleScalarResult();
return new JsonResponse([
'exists' => $count > 0,
'count' => $count,
]);
}
private function normalizeSuperuserEmail(string $email): string
{
return strtolower(trim($email));
}
private function targetProfileFromRequest(Request $request): int
{
$accountType = strtolower(trim((string)$request->request->get('account_type', 'superadmin')));
return $this->profileFromAccountType($accountType);
}
private function profileFromAccountType(string $accountType): int
{
return $accountType === 'superuser'
? self::PROFILE_SUPERUSER
: self::PROFILE_SUPERADMIN;
}
private function accountTypeFromProfile(int $profile): string
{
return $profile === self::PROFILE_SUPERUSER ? 'superuser' : 'superadmin';
}
private function accountTypeLabelFromProfile(int $profile): string
{
return $profile === self::PROFILE_SUPERUSER ? 'Superusuario' : 'Superadministrador';
}
private function profileFromIsAdmin(int $isAdmin): int
{
return $isAdmin === self::GLOBAL_IS_ADMIN_SUPERUSER
? self::PROFILE_SUPERUSER
: self::PROFILE_SUPERADMIN;
}
private function resolveGlobalAccountType(EntityManagerInterface $em, string $email, array $empresaIds, array $linkRows = []): array
{
if ($email === '' || count($empresaIds) === 0) {
return ['key' => 'indeterminate', 'label' => 'Indeterminado'];
}
$isAdmins = [];
if (!empty($linkRows)) {
foreach ($linkRows as $row) {
$isAdmin = (int)($row['is_admin'] ?? 0);
if (in_array($isAdmin, self::GLOBAL_IS_ADMINS, true)) {
$isAdmins[] = $isAdmin;
}
}
}
if (empty($isAdmins)) {
$linkRows = $em->getConnection()->fetchAllAssociative(
"SELECT is_admin
FROM users
WHERE LOWER(email) = :email AND is_admin IN (" . self::GLOBAL_IS_ADMIN_SUPERADMIN . ", " . self::GLOBAL_IS_ADMIN_SUPERUSER . ")",
['email' => $email]
);
foreach ($linkRows as $row) {
$isAdmin = (int)($row['is_admin'] ?? 0);
if (in_array($isAdmin, self::GLOBAL_IS_ADMINS, true)) {
$isAdmins[] = $isAdmin;
}
}
}
$isAdmins = array_values(array_unique($isAdmins));
if (count($isAdmins) !== 1) {
return ['key' => 'indeterminate', 'label' => 'Indeterminado'];
}
$profile = $this->profileFromIsAdmin((int)$isAdmins[0]);
return [
'key' => $this->accountTypeFromProfile($profile),
'label' => $this->accountTypeLabelFromProfile($profile),
];
}
/**
* @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->loadAzureResources($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' => '',
'extractor_type' => self::EXTRACTOR_TYPE_AZURE_DI,
'model_id' => '',
'base_prompt' => '',
];
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', ''));
$resource['extractor_type'] = $this->normalizeExtractorType((string)$request->request->get('extractor_type', self::EXTRACTOR_TYPE_AZURE_DI));
$resource['model_id'] = trim((string)$request->request->get('model_id', ''));
$resource['base_prompt'] = trim((string)$request->request->get('base_prompt', ''));
if (
$resource['name'] === '' ||
$resource['endpoint'] === '' ||
$resource['api_key'] === ''
) {
$this->addFlash('danger', 'Nombre, endpoint y api key son obligatorios.');
} elseif (
$resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_OPENAI &&
($resource['model_id'] === '' || $resource['base_prompt'] === '')
) {
$this->addFlash('danger', 'Para recursos Azure OpenAI debes indicar model_id y prompt general.');
} else {
if ($resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_DI) {
$resource['model_id'] = '';
$resource['base_prompt'] = '';
}
try {
$em->getConnection()->insert('azure_di_resources', [
'name' => $resource['name'],
'endpoint' => $resource['endpoint'],
'api_key' => $resource['api_key'],
'extractor_type' => $resource['extractor_type'],
'model_id' => $resource['model_id'],
'base_prompt' => $resource['base_prompt'],
]);
$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->loadAzureResourceById($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', ''));
$resource['extractor_type'] = $this->normalizeExtractorType((string)$request->request->get('extractor_type', self::EXTRACTOR_TYPE_AZURE_DI));
$resource['model_id'] = trim((string)$request->request->get('model_id', ''));
$resource['base_prompt'] = trim((string)$request->request->get('base_prompt', ''));
if (
$resource['name'] === '' ||
$resource['endpoint'] === '' ||
$resource['api_key'] === ''
) {
$this->addFlash('danger', 'Nombre, endpoint y api key son obligatorios.');
} elseif (
$resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_OPENAI &&
($resource['model_id'] === '' || $resource['base_prompt'] === '')
) {
$this->addFlash('danger', 'Para recursos Azure OpenAI debes indicar model_id y prompt general.');
} else {
if ($resource['extractor_type'] === self::EXTRACTOR_TYPE_AZURE_DI) {
$resource['model_id'] = '';
$resource['base_prompt'] = '';
}
try {
$em->getConnection()->update('azure_di_resources', [
'name' => $resource['name'],
'endpoint' => $resource['endpoint'],
'api_key' => $resource['api_key'],
'extractor_type' => $resource['extractor_type'],
'model_id' => $resource['model_id'],
'base_prompt' => $resource['base_prompt'],
], [
'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->loadAzureResourceById($em, (int)$request->get('id'));
if (!$resource) {
return new JsonResponse(['error' => 'resource_not_found'], 404);
}
if (($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
return new JsonResponse(['error' => 'resource_type_not_supported_for_di_models'], 400);
}
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->loadAzureResourceById($em, (int)$request->get('id'));
if (!$resource) {
return new JsonResponse(['error' => 'resource_not_found'], 404);
}
if (($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
return new JsonResponse(['error' => 'resource_type_not_supported_for_di_models'], 400);
}
$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 normalizeExtractorType(string $value): string
{
$value = strtolower(trim($value));
return $value === self::EXTRACTOR_TYPE_AZURE_OPENAI
? self::EXTRACTOR_TYPE_AZURE_OPENAI
: self::EXTRACTOR_TYPE_AZURE_DI;
}
private function loadAzureResources(EntityManagerInterface $em, ?string $extractorType = null): array
{
$rows = [];
$sql = 'SELECT id, name, endpoint, api_key, extractor_type, model_id, base_prompt, created_at, updated_at
FROM azure_di_resources';
$params = [];
if ($extractorType !== null && $extractorType !== '') {
$sql .= ' WHERE extractor_type = :extractor_type';
$params['extractor_type'] = $this->normalizeExtractorType($extractorType);
}
$sql .= ' ORDER BY name ASC';
try {
$rows = $em->getConnection()->executeQuery($sql, $params)->fetchAllAssociative();
} catch (\Throwable $e) {
// Compatibilidad temporal con esquemas antiguos sin columnas nuevas.
$legacyRows = $em->getConnection()->executeQuery(
'SELECT id, name, endpoint, api_key, created_at, updated_at FROM azure_di_resources ORDER BY name ASC'
)->fetchAllAssociative();
foreach ($legacyRows as &$legacy) {
$legacy['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
$legacy['model_id'] = '';
$legacy['base_prompt'] = '';
}
$rows = $legacyRows;
}
foreach ($rows as &$row) {
$row['id'] = (int)$row['id'];
$row['extractor_type'] = $this->normalizeExtractorType((string)($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
$row['model_id'] = (string)($row['model_id'] ?? '');
$row['base_prompt'] = (string)($row['base_prompt'] ?? '');
}
return $rows;
}
private function loadAzureResourceById(EntityManagerInterface $em, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = null;
try {
$row = $em->getConnection()->fetchAssociative(
'SELECT id, name, endpoint, api_key, extractor_type, model_id, base_prompt, created_at, updated_at
FROM azure_di_resources
WHERE id = :id',
['id' => $id]
);
} catch (\Throwable $e) {
$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) {
$row['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
$row['model_id'] = '';
$row['base_prompt'] = '';
}
}
if (!$row) {
return null;
}
$row['id'] = (int)$row['id'];
$row['extractor_type'] = $this->normalizeExtractorType((string)($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
$row['model_id'] = (string)($row['model_id'] ?? '');
$row['base_prompt'] = (string)($row['base_prompt'] ?? '');
return $row;
}
private function loadAzureDiResources(EntityManagerInterface $em): array
{
return $this->loadAzureResources($em, self::EXTRACTOR_TYPE_AZURE_DI);
}
private function loadAzureDiResourceById(EntityManagerInterface $em, int $id): ?array
{
$row = $this->loadAzureResourceById($em, $id);
if (!$row) {
return null;
}
return ($row['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_DI
? $row
: null;
}
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 = [];
$azureDiResources = [];
$azureOpenAiResources = [];
try {
$azureResources = $this->loadAzureResources($em);
foreach ($azureResources as $resourceRow) {
if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
$azureOpenAiResources[] = $resourceRow;
} else {
$azureDiResources[] = $resourceRow;
}
}
} catch (\Throwable $e) {
$this->addFlash('warning', 'No se pudo cargar el catalogo de recursos IA: ' . $e->getMessage());
}
$ocrPlusAvailable = $this->isOcrPlusAvailable();
$ocrV2Available = $this->isOcrV2Available();
if ($req->request->get("submit") != "") {
$data = $req->request->all();
$maxThreads = $this->clampDocuMaxThreads($data['maxThreads'] ?? null, 4);
$data['ocr_mode'] = $this->normalizeOcrMode($data['ocr_mode'] ?? 'base');
if (!$ocrV2Available && $data['ocr_mode'] === 'v2_zxing') {
$data['ocr_mode'] = 'base';
}
if (!$ocrPlusAvailable && $data['ocr_mode'] === 'plus_glm') {
$data['ocr_mode'] = 'base';
}
$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;
$data['modulo_prinex'] = isset($data['modulo_prinex']) && $toBool($data['modulo_prinex']) ? 1 : 0;
$data['tipo_conteo'] = (isset($data['tipo_conteo']) && (int)$data['tipo_conteo'] === 0) ? 0 : 1;
$data['limite_archivos'] = (isset($data['limite_archivos']) && $data['limite_archivos'] !== '') ? (int)$data['limite_archivos'] : 500;
$data['limite_paginas'] = (isset($data['limite_paginas']) && $data['limite_paginas'] !== '') ? (int)$data['limite_paginas'] : 1000;
if ($data['tipo_conteo'] === 0) {
$data['limite_archivos'] = max(1, (int)$data['limite_archivos']);
$data['limite_paginas'] = 0;
} else {
$data['limite_paginas'] = max(1, (int)$data['limite_paginas']);
$data['limite_archivos'] = 0;
}
$extractorType = $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
$data['extractor_type'] = $extractorType;
$azureResource = null;
$azureResourceId = (int)($data['azure_resource_id'] ?? 0);
$azureModelId = trim((string)($data['azure_model_id'] ?? ''));
$aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
$aoaiFields = $this->collectAoaiFieldsFromRequest($req);
$renderAddWithData = function () use ($azureResources, $azureDiResources, $azureOpenAiResources, $data, $aoaiFields) {
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'azure_di_resources' => $azureDiResources,
'azure_openai_resources' => $azureOpenAiResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => isset($data['limite_archivos']) ? (int)$data['limite_archivos'] : 500,
'limite_paginas' => isset($data['limite_paginas']) ? (int)$data['limite_paginas'] : 1000,
'tipo_conteo' => isset($data['tipo_conteo']) ? (int)$data['tipo_conteo'] : 1,
],
'form_data' => $data,
'aoai_fields' => $aoaiFields,
'ocr_plus_available' => $ocrPlusAvailable,
'ocr_v2_available' => $ocrV2Available,
]);
};
if ($data['modulo_extraccion'] === 1) {
if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
if ($aoaiResourceId <= 0) {
$this->addFlash('danger', 'Para Azure OpenAI debes seleccionar un recurso IA.');
return $renderAddWithData();
}
$azureResource = $this->loadAzureResourceById($em, $aoaiResourceId);
if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
$this->addFlash('danger', 'El recurso Azure OpenAI seleccionado no existe.');
return $renderAddWithData();
}
$validationError = $this->validateAoaiFields($aoaiFields);
if ($validationError !== null) {
$this->addFlash('danger', $validationError);
return $renderAddWithData();
}
} else {
if ($azureResourceId <= 0 || $azureModelId === '') {
$this->addFlash('danger', 'Para activar extraccion debes seleccionar recurso y modelo IA.');
return $renderAddWithData();
}
$azureResource = $this->loadAzureResourceById($em, $azureResourceId);
if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
$this->addFlash('danger', 'El recurso IA seleccionado no existe o no es de tipo Azure DI.');
return $renderAddWithData();
}
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 $renderAddWithData();
}
}
} else {
$data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
$data['azure_resource_id'] = 0;
$data['azure_model_id'] = '';
$data['aoai_resource_id'] = 0;
$data['extraction_model'] = 0;
$data['modulo_expowin'] = 0;
$data['modulo_prinex'] = 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
$ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
$ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
$ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
$ocrMode = ($data['ocr_mode'] ?? 'base');
if ($ocrMode === 'plus_glm') {
$ocrBinary = $ocrBinaryPlus;
} elseif ($ocrMode === 'v2_zxing') {
$ocrBinary = $ocrBinaryV2;
} else {
$ocrBinary = $ocrBinaryBase;
}
$filesPath = $_ENV['FILES_PATH'];
if (($data['ocr_mode'] ?? 'base') === 'plus_glm' && trim($ocrBinaryPlus) === '') {
$this->addFlash('danger', 'OCR+ seleccionado pero falta configurar OCR_PLUS_BINARY en .env.local.');
return $renderAddWithData();
}
if (($data['ocr_mode'] ?? 'base') === 'v2_zxing' && trim($ocrBinaryV2) === '') {
$this->addFlash('danger', 'OCR v2 seleccionado pero falta configurar OCR_BINARY_V2 en .env.local.');
return $renderAddWithData();
}
if (($data['ocr_mode'] ?? 'base') === 'base' && trim($ocrBinaryBase) === '') {
$this->addFlash('danger', 'OCR Base seleccionado pero falta configurar OCR_BINARY en .env.local.');
return $renderAddWithData();
}
if (trim((string)$filesPath) === '') {
$this->addFlash('danger', 'Falta configurar FILES_PATH en .env.local.');
return $renderAddWithData();
}
$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 {
if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
$localExtractionModelId = $this->registerAoaiModelInClientDb(
$mysqli,
$azureResource ?? [],
$aoaiFields
);
} else {
$localExtractionModelId = $this->registerDiModelInClientDb(
$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);
if ((int)($data['modulo_gstock'] ?? 0) === 1 && (int)($data['extraction_model'] ?? 0) > 0) {
try {
$this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
} catch (\Throwable $e) {
$this->addFlash('warning', 'No se pudo completar el automapeo de Gstock: ' . $e->getMessage());
}
}
// 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=docunecta
[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();
$docuPhpBaseUrl = $this->azureDiPhpBaseUrl($req);
if ($workdir !== '' && $docuPhpBaseUrl !== '') {
$logsDir = $workdir . "/logs/" . $tenantId;
// Asegura carpeta de logs
@mkdir($logsDir, 0775, true);
$serviceContent = <<<EOT
[Unit]
Description=DocuManager Azure DI {$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
# ---- Scheduler interno de extracción ----
Environment=EXTRACT_TICK_ENABLED=1
Environment=EXTRACT_TICK_INTERVAL_SEC=10
Environment=DOCU_TENANT_ID={$tenantId}
Environment=EXTRACT_TICK_LIMIT=30
Environment=EXTRACT_TICK_TIMEOUT_SEC=25
Environment=EXTRACT_TICK_LOCK_FILE=/tmp/azure_di_extract_tick_{$tenantId}.lock
# Ajustar URL base pública/interna de PHP
Environment=DOCU_PHP_BASE_URL={$docuPhpBaseUrl}
# Debe coincidir con EXTRACT_INTERNAL_TOKEN del lado PHP
Environment=EXTRACT_INTERNAL_TOKEN=8c7e7a1b4d0f6e2a9c1d3f5b7a8e0c2d4f6a1b3c5d7e9f0a2c4e6b8d0f1a3c5
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 {
$missing = [];
if ($workdir === '') $missing[] = 'AZURE_DI_WORKDIR';
if ($docuPhpBaseUrl === '') $missing[] = 'AZURE_DI_DOCU_PHP_BASE_URL';
$this->addFlash('warning', 'No se pudo crear el servicio de extraccion IA: faltan variables de entorno: ' . implode(', ', $missing));
}
// === 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 IA para el formulario de alta
return $this->render('empresa/_add.html.twig', [
'azure_resources' => $azureResources,
'azure_di_resources' => $azureDiResources,
'azure_openai_resources' => $azureOpenAiResources,
'modulos' => [
'extraction_model' => 0,
'limite_archivos' => 500,
'limite_paginas' => 1000,
'tipo_conteo' => 1,
],
'form_data' => [
'extractor_type' => self::EXTRACTOR_TYPE_AZURE_DI,
'ocr_mode' => 'base',
],
'aoai_fields' => [],
'ocr_plus_available' => $ocrPlusAvailable,
'ocr_v2_available' => $ocrV2Available,
]);
}
}
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'),
'modulo_prinex' => $getFlag($data, 'modulo_prinex'),
'tipo_conteo' => $getInt($data, 'tipo_conteo', 1) === 1 ? 1 : 0,
'archivos_subidos' => 0,
'limite_archivos' => $getInt($data, 'limite_archivos', 500),
'paginas_subidas' => 0,
'limite_paginas' => $getInt($data, 'limite_paginas', 1000),
'extraction_model' => $getInt($data, 'extraction_model', 0),
'ocr_mode' => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
'tokensContratados' => max(0, $getInt($data, 'tokensContratados', 0)),
];
if ($paramMap['modulo_extraccion'] === 0) {
$paramMap['modulo_expowin'] = 0;
$paramMap['modulo_prinex'] = 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 applyGstockAutoMappings(\mysqli $mysqli, int $modelId): void
{
if ($modelId <= 0) {
return;
}
$available = [
'header' => [],
'line' => [],
];
$stmtHeader = $mysqli->prepare('SELECT field_key FROM definitions_header WHERE model_id = ?');
if (!$stmtHeader) {
throw new \RuntimeException('Prepare SELECT definitions_header para automapeo Gstock: ' . $mysqli->error);
}
$stmtHeader->bind_param('i', $modelId);
if (!$stmtHeader->execute()) {
$stmtHeader->close();
throw new \RuntimeException('Execute SELECT definitions_header para automapeo Gstock: ' . $stmtHeader->error);
}
$resHeader = $stmtHeader->get_result();
while ($resHeader && ($row = $resHeader->fetch_assoc())) {
$fieldKey = (string)($row['field_key'] ?? '');
if ($fieldKey !== '') {
$available['header'][$fieldKey] = true;
}
}
$stmtHeader->close();
$stmtLines = $mysqli->prepare('SELECT field_key FROM definitions_lines WHERE model_id = ?');
if (!$stmtLines) {
throw new \RuntimeException('Prepare SELECT definitions_lines para automapeo Gstock: ' . $mysqli->error);
}
$stmtLines->bind_param('i', $modelId);
if (!$stmtLines->execute()) {
$stmtLines->close();
throw new \RuntimeException('Execute SELECT definitions_lines para automapeo Gstock: ' . $stmtLines->error);
}
$resLines = $stmtLines->get_result();
while ($resLines && ($row = $resLines->fetch_assoc())) {
$fieldKey = (string)($row['field_key'] ?? '');
if ($fieldKey !== '') {
$available['line'][$fieldKey] = true;
}
}
$stmtLines->close();
$existing = [];
$stmtExisting = $mysqli->prepare('SELECT type, source, destination FROM gstock_mapping WHERE model_id = ?');
if (!$stmtExisting) {
throw new \RuntimeException('Prepare SELECT gstock_mapping para automapeo: ' . $mysqli->error);
}
$stmtExisting->bind_param('i', $modelId);
if (!$stmtExisting->execute()) {
$stmtExisting->close();
throw new \RuntimeException('Execute SELECT gstock_mapping para automapeo: ' . $stmtExisting->error);
}
$resExisting = $stmtExisting->get_result();
while ($resExisting && ($row = $resExisting->fetch_assoc())) {
$type = (string)($row['type'] ?? '');
$source = (string)($row['source'] ?? '');
$destination = (string)($row['destination'] ?? '');
if ($type !== '' && $source !== '' && $destination !== '') {
$existing[$type . '|' . $source . '|' . $destination] = true;
}
}
$stmtExisting->close();
$mysqli->begin_transaction();
try {
$stmtInsert = $mysqli->prepare(
'INSERT INTO gstock_mapping (model_id, type, source, destination) VALUES (?, ?, ?, ?)'
);
if (!$stmtInsert) {
throw new \RuntimeException('Prepare INSERT gstock_mapping para automapeo: ' . $mysqli->error);
}
foreach (GstockAutoMappings::MAPPINGS as $type => $pairs) {
foreach ($pairs as $pair) {
$source = (string)($pair['source'] ?? '');
$destination = (string)($pair['destination'] ?? '');
if ($source === '' || $destination === '') {
continue;
}
if ($type === 'header' && !isset($available['header'][$source])) {
continue;
}
if ($type === 'line' && !isset($available['line'][$source])) {
continue;
}
$key = $type . '|' . $source . '|' . $destination;
if (isset($existing[$key])) {
continue;
}
$stmtInsert->bind_param('isss', $modelId, $type, $source, $destination);
if (!$stmtInsert->execute()) {
$stmtInsert->close();
throw new \RuntimeException('Execute INSERT gstock_mapping para automapeo: ' . $stmtInsert->error);
}
$existing[$key] = true;
}
}
$stmtInsert->close();
$mysqli->commit();
} catch (\Throwable $e) {
$mysqli->rollback();
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';
$ins = $mysqli->prepare("
INSERT INTO license
(client, license, initialDate, capacityGb, users, price, emailSender, emailFrom, emailName)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)
");
if (!$ins) {
throw new \RuntimeException('Prepare INSERT license: ' . $mysqli->error);
}
$ins->bind_param(
'sssiiisss',
$clientName,
$licenseStr,
$initialDate,
$capacityGb,
$activeUsers,
$price,
$emailSender,
$emailFrom,
$emailName
);
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 registerDiModelInClientDb(\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 = 0;
$clientMysqli->begin_transaction();
try {
$existingId = 0;
$stmtSelect = $clientMysqli->prepare(
"SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? LIMIT 1"
);
if (!$stmtSelect) {
throw new \RuntimeException('Prepare SELECT extraction_models: ' . $clientMysqli->error);
}
$provider = self::EXTRACTOR_TYPE_AZURE_DI;
$stmtSelect->bind_param('ss', $provider, $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 = self::EXTRACTOR_TYPE_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;
}
}
private function registerModelInClientDb(\mysqli $clientMysqli, array $resource, string $modelId): int
{
return $this->registerDiModelInClientDb($clientMysqli, $resource, $modelId);
}
private function normalizeAoaiFieldScope(string $scope): string
{
$scope = strtolower(trim($scope));
return $scope === 'lines' ? 'lines' : 'header';
}
private function normalizeAoaiValueType(string $valueType): string
{
$valueType = strtolower(trim($valueType));
if ($valueType === 'number') {
return 'number';
}
if ($valueType === 'date') {
return 'date';
}
return 'string';
}
private function collectAoaiFieldsFromRequest(Request $request): array
{
$scopes = $request->request->all('aoai_fields_scope');
$keys = $request->request->all('aoai_fields_key');
$prompts = $request->request->all('aoai_fields_prompt');
$valueTypes = $request->request->all('aoai_fields_type');
if (!is_array($scopes)) {
$scopes = [];
}
if (!is_array($keys)) {
$keys = [];
}
if (!is_array($prompts)) {
$prompts = [];
}
if (!is_array($valueTypes)) {
$valueTypes = [];
}
$max = max(count($scopes), count($keys), count($prompts), count($valueTypes));
$fields = [];
for ($i = 0; $i < $max; $i++) {
$scope = $this->normalizeAoaiFieldScope((string)($scopes[$i] ?? 'header'));
$fieldKey = trim((string)($keys[$i] ?? ''));
$prompt = trim((string)($prompts[$i] ?? ''));
$valueType = $this->normalizeAoaiValueType((string)($valueTypes[$i] ?? 'string'));
if ($fieldKey === '' && $prompt === '') {
continue;
}
$fields[] = [
'scope' => $scope,
'field_key' => $fieldKey,
'prompt' => $prompt,
'value_type' => $valueType,
];
}
return $fields;
}
private function validateAoaiFields(array $fields): ?string
{
if (count($fields) === 0) {
return 'Debes indicar al menos un campo para Azure OpenAI.';
}
$seen = [];
foreach ($fields as $item) {
$scope = $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
$fieldKey = trim((string)($item['field_key'] ?? ''));
$prompt = trim((string)($item['prompt'] ?? ''));
$valueType = $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
if ($fieldKey === '' || $prompt === '') {
return 'Todos los campos de Azure OpenAI deben tener nombre y prompt.';
}
if (!in_array($valueType, ['string', 'number', 'date'], true)) {
return 'El tipo de dato permitido es string, number o date.';
}
$uniq = $scope . '|' . strtolower($fieldKey);
if (isset($seen[$uniq])) {
return 'No se permiten campos repetidos dentro del mismo alcance (cabecera o líneas).';
}
$seen[$uniq] = true;
}
return null;
}
private function registerAoaiModelInClientDb(\mysqli $clientMysqli, array $resource, array $aoaiFields): int
{
$extractorType = $this->normalizeExtractorType((string)($resource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
if ($extractorType !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
throw new \RuntimeException('El recurso seleccionado no es de tipo Azure OpenAI.', 400);
}
$validationError = $this->validateAoaiFields($aoaiFields);
if ($validationError !== null) {
throw new \RuntimeException($validationError, 400);
}
$modelId = trim((string)($resource['model_id'] ?? ''));
$basePrompt = trim((string)($resource['base_prompt'] ?? ''));
$endpoint = $this->normalizeAzureEndpoint((string)($resource['endpoint'] ?? ''));
$apiKey = trim((string)($resource['api_key'] ?? ''));
if ($modelId === '' || $basePrompt === '' || $endpoint === '' || $apiKey === '') {
throw new \RuntimeException('El recurso Azure OpenAI está incompleto.', 400);
}
$provider = 'azure_openai';
$type = 'custom';
$showConfidenceBadges = 0;
$clientMysqli->begin_transaction();
try {
$existingId = 0;
$stmtSelect = $clientMysqli->prepare(
'SELECT id FROM extraction_models WHERE provider = ? AND model_id = ? LIMIT 1'
);
if (!$stmtSelect) {
throw new \RuntimeException('Prepare SELECT extraction_models AOAI: ' . $clientMysqli->error);
}
$stmtSelect->bind_param('ss', $provider, $modelId);
if (!$stmtSelect->execute()) {
$stmtSelect->close();
throw new \RuntimeException('Execute SELECT extraction_models AOAI: ' . $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 = ?, base_prompt = ?, type = ?, show_confidence_badges = ?
WHERE id = ?'
);
if (!$stmtUpdate) {
throw new \RuntimeException('Prepare UPDATE extraction_models AOAI: ' . $clientMysqli->error);
}
$stmtUpdate->bind_param('ssssii', $endpoint, $apiKey, $basePrompt, $type, $showConfidenceBadges, $existingId);
if (!$stmtUpdate->execute()) {
$stmtUpdate->close();
throw new \RuntimeException('Execute UPDATE extraction_models AOAI: ' . $stmtUpdate->error);
}
$stmtUpdate->close();
$localModelId = $existingId;
} else {
$stmtInsert = $clientMysqli->prepare(
'INSERT INTO extraction_models (provider, model_id, endpoint, api_key, base_prompt, type, show_confidence_badges)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
if (!$stmtInsert) {
throw new \RuntimeException('Prepare INSERT extraction_models AOAI: ' . $clientMysqli->error);
}
$stmtInsert->bind_param('ssssssi', $provider, $modelId, $endpoint, $apiKey, $basePrompt, $type, $showConfidenceBadges);
if (!$stmtInsert->execute()) {
$stmtInsert->close();
throw new \RuntimeException('Execute INSERT extraction_models AOAI: ' . $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 AOAI: ' . $clientMysqli->error);
}
$stmtDelHeader->bind_param('i', $localModelId);
if (!$stmtDelHeader->execute()) {
$stmtDelHeader->close();
throw new \RuntimeException('Execute DELETE definitions_header AOAI: ' . $stmtDelHeader->error);
}
$stmtDelHeader->close();
$stmtDelLines = $clientMysqli->prepare('DELETE FROM definitions_lines WHERE model_id = ?');
if (!$stmtDelLines) {
throw new \RuntimeException('Prepare DELETE definitions_lines AOAI: ' . $clientMysqli->error);
}
$stmtDelLines->bind_param('i', $localModelId);
if (!$stmtDelLines->execute()) {
$stmtDelLines->close();
throw new \RuntimeException('Execute DELETE definitions_lines AOAI: ' . $stmtDelLines->error);
}
$stmtDelLines->close();
$stmtHeader = $clientMysqli->prepare(
'INSERT INTO definitions_header
(model_id, field_key, label, prompt, value_type, order_index, visibility, order_index_table, visibility_table)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
if (!$stmtHeader) {
throw new \RuntimeException('Prepare INSERT definitions_header AOAI: ' . $clientMysqli->error);
}
$stmtLines = $clientMysqli->prepare(
'INSERT INTO definitions_lines
(model_id, field_key, label, prompt, value_type, order_index, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
if (!$stmtLines) {
$stmtHeader->close();
throw new \RuntimeException('Prepare INSERT definitions_lines AOAI: ' . $clientMysqli->error);
}
$headerOrder = 1;
$lineOrder = 1;
foreach ($aoaiFields as $item) {
$scope = $this->normalizeAoaiFieldScope((string)($item['scope'] ?? 'header'));
$fieldKey = trim((string)($item['field_key'] ?? ''));
$prompt = trim((string)($item['prompt'] ?? ''));
$label = $fieldKey;
$valueType = $this->normalizeAoaiValueType((string)($item['value_type'] ?? 'string'));
$visibility = 1;
if ($scope === 'lines') {
$orderIndex = $lineOrder++;
$stmtLines->bind_param(
'issssii',
$localModelId,
$fieldKey,
$label,
$prompt,
$valueType,
$orderIndex,
$visibility
);
if (!$stmtLines->execute()) {
throw new \RuntimeException('Execute INSERT definitions_lines AOAI: ' . $stmtLines->error);
}
} else {
$orderIndex = $headerOrder++;
$orderIndexTable = $orderIndex;
$visibilityTable = 1;
$stmtHeader->bind_param(
'issssiiii',
$localModelId,
$fieldKey,
$label,
$prompt,
$valueType,
$orderIndex,
$visibility,
$orderIndexTable,
$visibilityTable
);
if (!$stmtHeader->execute()) {
throw new \RuntimeException('Execute INSERT definitions_header AOAI: ' . $stmtHeader->error);
}
}
}
$stmtHeader->close();
$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');
}
$ocrPlusAvailable = $this->isOcrPlusAvailable();
$ocrV2Available = $this->isOcrV2Available();
$azureResources = [];
$azureDiResources = [];
$azureOpenAiResources = [];
try {
$azureResources = $this->loadAzureResources($em);
foreach ($azureResources as $resourceRow) {
if (($resourceRow['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
$azureOpenAiResources[] = $resourceRow;
} else {
$azureDiResources[] = $resourceRow;
}
}
} 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);
$data['ocr_mode'] = $this->normalizeOcrMode($data['ocr_mode'] ?? 'base');
if (!$ocrV2Available && $data['ocr_mode'] === 'v2_zxing') {
$data['ocr_mode'] = 'base';
}
if (!$ocrPlusAvailable && $data['ocr_mode'] === 'plus_glm') {
$data['ocr_mode'] = 'base';
}
// 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;
$data['extractor_type'] = $this->normalizeExtractorType((string)($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI));
$azureResourceId = (int)($data['azure_resource_id'] ?? 0);
$azureModelId = trim((string)($data['azure_model_id'] ?? ''));
$aoaiResourceId = (int)($data['aoai_resource_id'] ?? 0);
$aoaiFields = $this->collectAoaiFieldsFromRequest($request);
$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_prinex'] = isset($data['modulo_prinex']) && $toBool($data['modulo_prinex']) ? 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;
$data['tipo_conteo'] = (isset($data['tipo_conteo']) && (int)$data['tipo_conteo'] === 0) ? 0 : 1;
$data['limite_archivos'] = (isset($data['limite_archivos']) && $data['limite_archivos'] !== '') ? (int)$data['limite_archivos'] : 500;
$data['limite_paginas'] = (isset($data['limite_paginas']) && $data['limite_paginas'] !== '') ? (int)$data['limite_paginas'] : 1000;
if ($data['tipo_conteo'] === 0) {
$data['limite_archivos'] = max(1, (int)$data['limite_archivos']);
$data['limite_paginas'] = 0;
} else {
$data['limite_paginas'] = max(1, (int)$data['limite_paginas']);
$data['limite_archivos'] = 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;
$data['modulo_prinex'] = 0;
$data['extractor_type'] = self::EXTRACTOR_TYPE_AZURE_DI;
$azureResourceId = 0;
$azureModelId = '';
$aoaiResourceId = 0;
}
// 3) Guardar en BD del cliente: parametros + license
if ($data['modulo_extraccion'] === 1) {
if (($data['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) === self::EXTRACTOR_TYPE_AZURE_OPENAI) {
if ($aoaiResourceId <= 0) {
$this->addFlash('danger', 'Para Azure OpenAI debes seleccionar un recurso IA.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
$azureResource = $this->loadAzureResourceById($em, $aoaiResourceId);
if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_OPENAI) {
$this->addFlash('danger', 'El recurso Azure OpenAI seleccionado no existe.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
$validationError = $this->validateAoaiFields($aoaiFields);
if ($validationError !== null) {
$this->addFlash('danger', $validationError);
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
try {
$data['extraction_model'] = $this->registerAoaiModelInClientDb(
$mysqli,
$azureResource,
$aoaiFields
);
$this->addFlash('success', 'Modelo Azure OpenAI 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]);
}
} else {
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->loadAzureResourceById($em, $azureResourceId);
if (!$azureResource || ($azureResource['extractor_type'] ?? self::EXTRACTOR_TYPE_AZURE_DI) !== self::EXTRACTOR_TYPE_AZURE_DI) {
$this->addFlash('danger', 'El recurso IA seleccionado no existe o no es de tipo Azure DI.');
$mysqli->close();
return $this->redirectToRoute('app_edit_empresa', ['id' => $id]);
}
try {
$data['extraction_model'] = $this->registerDiModelInClientDb(
$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);
if ((int)($data['modulo_gstock'] ?? 0) === 1 && (int)($data['extraction_model'] ?? 0) > 0) {
try {
$this->applyGstockAutoMappings($mysqli, (int)$data['extraction_model']);
} catch (\Throwable $e) {
$this->addFlash('warning', 'No se pudo completar el automapeo de Gstock: ' . $e->getMessage());
}
}
$this->updateLicense(
$mysqli,
$data,
$empresa->getName()
);
// Actualizar servicio OCR (hilos + binario segun modo OCR)
$ocrBinaryBase = (string)($_ENV['OCR_BINARY'] ?? '');
$ocrBinaryV2 = (string)($_ENV['OCR_BINARY_V2'] ?? '');
$ocrBinaryPlus = (string)($_ENV['OCR_PLUS_BINARY'] ?? '');
$ocrMode = ($data['ocr_mode'] ?? 'base');
if ($ocrMode === 'plus_glm') {
$ocrBinary = $ocrBinaryPlus;
} elseif ($ocrMode === 'v2_zxing') {
$ocrBinary = $ocrBinaryV2;
} else {
$ocrBinary = $ocrBinaryBase;
}
$filesPath = (string)($_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 {
if (($data['ocr_mode'] ?? 'base') === 'plus_glm') {
$this->addFlash('warning', 'No se pudo actualizar el servicio OCR: faltan OCR_PLUS_BINARY o FILES_PATH.');
} elseif (($data['ocr_mode'] ?? 'base') === 'v2_zxing') {
$this->addFlash('warning', 'No se pudo actualizar el servicio OCR: faltan OCR_BINARY_V2 o FILES_PATH.');
} 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
$extractorType = self::EXTRACTOR_TYPE_AZURE_DI;
$selectedAoaiResourceId = 0;
$aoaiFields = [];
if (!empty($modulos['extraction_model'])) {
try {
$stmtCurrentModel = $mysqli->prepare('SELECT provider FROM extraction_models WHERE id = ? LIMIT 1');
if ($stmtCurrentModel) {
$currentModelId = (int)$modulos['extraction_model'];
$stmtCurrentModel->bind_param('i', $currentModelId);
if ($stmtCurrentModel->execute()) {
$resCurrentModel = $stmtCurrentModel->get_result();
if ($resCurrentModel && ($modelRow = $resCurrentModel->fetch_assoc())) {
$provider = strtolower(trim((string)($modelRow['provider'] ?? '')));
if ($provider === 'azure_openai') {
$extractorType = self::EXTRACTOR_TYPE_AZURE_OPENAI;
}
}
}
$stmtCurrentModel->close();
}
} catch (\Throwable $e) {
$extractorType = self::EXTRACTOR_TYPE_AZURE_DI;
}
}
if ($extractorType === self::EXTRACTOR_TYPE_AZURE_OPENAI && !empty($modulos['extraction_model'])) {
try {
$modelId = (int)$modulos['extraction_model'];
$currentModelId = '';
$currentEndpoint = '';
$stmtModelMeta = $mysqli->prepare(
"SELECT model_id, endpoint FROM extraction_models WHERE id = ? AND provider = 'azure_openai' LIMIT 1"
);
if ($stmtModelMeta) {
$stmtModelMeta->bind_param('i', $modelId);
if ($stmtModelMeta->execute()) {
$resMeta = $stmtModelMeta->get_result();
if ($resMeta && ($metaRow = $resMeta->fetch_assoc())) {
$currentModelId = trim((string)($metaRow['model_id'] ?? ''));
$currentEndpoint = $this->normalizeAzureEndpoint((string)($metaRow['endpoint'] ?? ''));
}
}
$stmtModelMeta->close();
}
if ($currentModelId !== '' && $currentEndpoint !== '') {
foreach ($azureOpenAiResources as $resourceRow) {
$resourceModelId = trim((string)($resourceRow['model_id'] ?? ''));
$resourceEndpoint = $this->normalizeAzureEndpoint((string)($resourceRow['endpoint'] ?? ''));
if ($resourceModelId === $currentModelId && $resourceEndpoint === $currentEndpoint) {
$selectedAoaiResourceId = (int)($resourceRow['id'] ?? 0);
break;
}
}
}
$stmtHeader = $mysqli->prepare(
'SELECT field_key, prompt, value_type FROM definitions_header WHERE model_id = ? ORDER BY order_index ASC, id ASC'
);
if ($stmtHeader) {
$stmtHeader->bind_param('i', $modelId);
if ($stmtHeader->execute()) {
$resHeader = $stmtHeader->get_result();
while ($resHeader && ($row = $resHeader->fetch_assoc())) {
$aoaiFields[] = [
'scope' => 'header',
'field_key' => (string)($row['field_key'] ?? ''),
'prompt' => (string)($row['prompt'] ?? ''),
'value_type' => $this->normalizeAoaiValueType((string)($row['value_type'] ?? 'string')),
];
}
}
$stmtHeader->close();
}
$stmtLines = $mysqli->prepare(
'SELECT field_key, prompt, value_type FROM definitions_lines WHERE model_id = ? ORDER BY order_index ASC, id ASC'
);
if ($stmtLines) {
$stmtLines->bind_param('i', $modelId);
if ($stmtLines->execute()) {
$resLines = $stmtLines->get_result();
while ($resLines && ($row = $resLines->fetch_assoc())) {
$aoaiFields[] = [
'scope' => 'lines',
'field_key' => (string)($row['field_key'] ?? ''),
'prompt' => (string)($row['prompt'] ?? ''),
'value_type' => $this->normalizeAoaiValueType((string)($row['value_type'] ?? 'string')),
];
}
}
$stmtLines->close();
}
} catch (\Throwable $e) {
$aoaiFields = [];
}
}
$extractionModels = [];
try {
$res = $mysqli->query("SELECT id, model_id, provider FROM extraction_models WHERE provider = 'azure-di' ORDER BY model_id");
if ($res) {
while ($row = $res->fetch_assoc()) {
$extractionModels[] = [
'id' => (int)$row['id'],
'model_id' => (string)$row['model_id'],
'provider' => (string)($row['provider'] ?? ''),
];
}
$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,
'azure_di_resources' => $azureDiResources,
'azure_openai_resources' => $azureOpenAiResources,
'extraction_models' => $extractionModels,
'form_data' => [
'extractor_type' => $extractorType,
'aoai_resource_id' => $selectedAoaiResourceId,
'ocr_mode' => $this->normalizeOcrMode($modulos['ocr_mode'] ?? 'base'),
],
'aoai_fields' => $aoaiFields,
'ocr_plus_available' => $ocrPlusAvailable,
'ocr_v2_available' => $ocrV2Available,
]);
}
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',
'modulo_prinex',
'tipo_conteo',
'archivos_subidos',
'limite_archivos',
'paginas_subidas',
'limite_paginas',
'extraction_model',
'ocr_mode',
'tokensContratados',
'tokensUsados',
];
$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_prinex' => isset($map['modulo_prinex']) ? (int)$map['modulo_prinex'] : 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;
$flags['tipo_conteo'] = isset($map['tipo_conteo']) ? (((int)$map['tipo_conteo'] === 0) ? 0 : 1) : 1;
$flags['archivos_subidos'] = isset($map['archivos_subidos']) && $map['archivos_subidos'] !== ''
? (int)$map['archivos_subidos']
: 0;
$flags['paginas_subidas'] = isset($map['paginas_subidas']) && $map['paginas_subidas'] !== ''
? (int)$map['paginas_subidas']
: 0;
$flags['limite_paginas'] = isset($map['limite_paginas']) && $map['limite_paginas'] !== ''
? (int)$map['limite_paginas']
: 1000;
// extraction_model: default 0 (sin modelo)
$flags['extraction_model'] = isset($map['extraction_model']) && $map['extraction_model'] !== '' ? (int)$map['extraction_model'] : 0;
$flags['ocr_mode'] = $this->normalizeOcrMode($map['ocr_mode'] ?? 'base');
$flags['tokensContratados'] = isset($map['tokensContratados']) && $map['tokensContratados'] !== ''
? max(0, (int)$map['tokensContratados'])
: 0;
$flags['tokensUsados'] = isset($map['tokensUsados']) && $map['tokensUsados'] !== ''
? max(0, (int)$map['tokensUsados'])
: 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;
$counterDefaults = [
'archivos_subidos' => 0,
'paginas_subidas' => 0,
];
$counterRes = $mysqli->query("SELECT nombre, valor FROM parametros WHERE nombre IN ('archivos_subidos','paginas_subidas')");
if ($counterRes) {
while ($row = $counterRes->fetch_assoc()) {
$nombre = (string)($row['nombre'] ?? '');
if (array_key_exists($nombre, $counterDefaults)) {
$counterDefaults[$nombre] = (int)($row['valor'] ?? 0);
}
}
$counterRes->free();
}
$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'),
'modulo_prinex' => $getFlag($data, 'modulo_prinex'),
'tipo_conteo' => $getInt($data, 'tipo_conteo', 1) === 1 ? 1 : 0,
'archivos_subidos' => $getInt($data, 'archivos_subidos', $counterDefaults['archivos_subidos']),
'limite_archivos' => $getInt($data, 'limite_archivos', 500),
'paginas_subidas' => $getInt($data, 'paginas_subidas', $counterDefaults['paginas_subidas']),
'limite_paginas' => $getInt($data, 'limite_paginas', 1000),
'extraction_model' => $getInt($data, 'extraction_model', 0),
'ocr_mode' => $this->normalizeOcrMode($data['ocr_mode'] ?? 'base'),
'tokensContratados' => max(0, $getInt($data, 'tokensContratados', 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;
$paramMap['modulo_prinex'] = 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,
]);
}
}