{"repo": "carlosarriaga83/geriapp", "files": {"v9/cron/notificaciones.php": {"6513d14f8165a09333b7833f0c67f0fe1d438330": {"t": 1785746148.0294363, "n": 45316, "h": "b1e64282819057d8", "c": "> /var/log/geriapp_cron.log 2>&1\n */\n\n$isCli = php_sapi_name() === 'cli';\n$isHttp = !$isCli;\n\nif ($isHttp) {\n header('Content-Type: text/html; charset=utf-8');\n} else {\n header('Content-Type: text/plain; charset=utf-8');\n}\n\n// CLI only (unless CRON_ALLOW_HTTP is defined)\nif ($isHttp && !defined('CRON_ALLOW_HTTP')) {\n http_response_code(403);\n exit('CLI only');\n}\n\n// Buffered output — collect all output, then format at end\nob_start();\n\nrequire_once __DIR__ . '/../conf/config.php';\nrequire_once __DIR__ . '/../db/Database.php';\nrequire_once __DIR__ . '/../db/models.php';\nrequire_once __DIR__ . '/../includes/EncryptionMap.php';\n\n$logPrefix = date('Y-m-d H:i:s') . ' [notif-cron] ';\n$summary = ['inicio' => date('Y-m-d H:i:s'), 'instituciones' => 0, 'inst_sin_canales' => 0, 'residentes' => 0, 'notif_enviadas' => 0, 'notif_error' => 0, 'notif_dedup' => 0, 'reportes_ia' => 0, 'detalle' => []];\n\n// WhatsApp API throttle: minimum 7 seconds between sends to avoid rate-limit errors\n$_waLastSendTime = 0;\nfunction _waThrottle(): void {\n global $_waLastSendTime, $logPrefix;\n if ($_waLastSendTime > 0) {\n $elapsed = hrtime(true) - $_waLastSendTime;\n $remaining = 7_000_000_000 - $elapsed; // 7 seconds in nanoseconds\n if ($remaining > 0) {\n $secs = intdiv((int)$remaining, 1_000_000_000);\n $nanos = (int)($remaining % 1_000_000_000);\n $waitLabel = number_format($remaining / 1_000_000_000, 2);\n echo $logPrefix . \" WA throttle: esperando {$waitLabel}s...\\n\";\n time_nanosleep($secs, $nanos);\n }\n }\n}\nfunction _waMarkSent(): void {\n global $_waLastSendTime;\n $_waLastSendTime = hrtime(true);\n}\n\n// Get all active institutions\ntry {\n $central = Database::getMaster();\n $instituciones = $central->query(\"SELECT id, nombre FROM instituciones WHERE estado = 'activa'\")->fetchAll(PDO::FETCH_ASSOC);\n} catch (Throwable $e) {\n echo $logPrefix . \"ERROR: No se pudo conectar a la base central: \" . $e->getMessage() . \"\\n\";\n exit(1);\n}\n\nforeach ($instituciones as $inst) {\n $instId = (int) $inst['id'];\n $summary['instituciones']++;\n $instSummary = ['nombre' => $inst['nombre'], 'id' => $instId, 'enviados' => 0, 'errores' => 0, 'dedup' => 0, 'residentes' => 0];\n echo $logPrefix . \"Procesando institución: {$inst['nombre']} (ID: $instId)\\n\";\n\n try {\n $db = Database::getTenant($instId);\n } catch (Throwable $e) {\n echo $logPrefix . \" ERROR conexión tenant: \" . $e->getMessage() . \"\\n\";\n continue;\n }\n\n // Ensure notificaciones_log table exists\n $db->exec(\"CREATE TABLE IF NOT EXISTS notificaciones_log (\n id INT AUTO_INCREMENT PRIMARY KEY,\n institucion_id INT NOT NULL,\n residente_id INT,\n destinatario VARCHAR(255),\n canal VARCHAR(20) NOT NULL DEFAULT 'whatsapp',\n tipo VARCHAR(50),\n mensaje TEXT,\n estado VARCHAR(20) NOT NULL DEFAULT 'enviado',\n error_detalle TEXT,\n fecha DATETIME DEFAULT CURRENT_TIMESTAMP,\n INDEX(institucion_id, residente_id, fecha)\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\");\n\n // Check if this institution has WA/email configured\n $config = Configuracion::getByInstitucion($instId);\n $waActivo = !empty($config['wa_activo']);\n $smtpHost = !empty($config['smtp_host']);\n $instName = $config['inst_nombre'] ?? $inst['nombre'] ?? 'GeriApp';\n\n // Use institution timezone for hour comparison (server may be UTC)\n $cfgTz = $config['timezone'] ?? '';\n if (empty($cfgTz)) $cfgTz = 'America/Mexico_City';\n $instTz = new DateTimeZone($cfgTz);\n $nowInst = new DateTime('now', $instTz);\n $currentHour = $nowInst->format('H:i');\n // Today's date in institution timezone (for dedup instead of MySQL CURDATE which uses server TZ)\n $todayInst = $nowInst->format('Y-m-d');\n // Convert institution's start-of-day to UTC for dedup queries against MySQL CURRENT_TIMESTAMP (server TZ)\n $todayStartUtc = (new DateTime($todayInst . ' 00:00:00', $instTz))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');\n echo $logPrefix . \" Timezone configurado: {$cfgTz} | Hora local: {$currentHour} | Fecha local: {$todayInst} | Dedup desde (UTC): {$todayStartUtc}\\n\";\n\n if (!$waActivo && !$smtpHost) {\n echo $logPrefix . \" Sin canales configurados, saltando.\\n\";\n $summary['inst_sin_canales']++;\n $instSummary['nota'] = 'Sin canales configurados';\n $summary['detalle'][] = $instSummary;\n continue;\n }\n\n $wa = null;\n $mailer = null;\n if ($waActivo) {\n try { $wa = WaSenderAPI::fromConfig($instId); } catch (Throwable $e) {\n echo $logPrefix . \" WARN: WA no disponible: \" . $e->getMessage() . \"\\n\";\n }\n }\n if ($smtpHost) {\n try { $mailer = Mailer::fromConfig($instId); } catch (Throwable $e) {\n echo $logPrefix . \" WARN: Email no disponible: \" . $e->getMessage() . \"\\n\";\n }\n }\n\n // Get all active residents with contacts\n $residentes = $db->prepare(\"SELECT id, nombre, apellidos, contacto_nombre, contacto_telefono, contacto_email, contactos_json FROM residentes WHERE institucion_id = ? AND estado = 'activo'\");\n $residentes->execute([$instId]);\n $residentes = $residentes->fetchAll(PDO::FETCH_ASSOC);\n // Decrypt PII columns (consolidated phase) so contact phone/email/JSON\n // are usable as destinatario instead of being persisted as ciphertext\n // into notificaciones_log.\n $residentes = array_map(fn($r) => EncryptionMap::decryptRow('residentes', $r), $residentes);\n\n foreach ($residentes as $res) {\n $resId = (int) $res['id'];\n $resName = trim(($res['nombre'] ?? '') . ' ' . ($res['apellidos'] ?? ''));\n $instSummary['residentes']++;\n $summary['residentes']++;\n\n // Build contacts array (new format: all contacts in contactos_json)\n $contacts = [];\n if (!empty($res['contactos_json'])) {\n $parsed = json_decode($res['contactos_json'], true);\n if (is_array($parsed) && count($parsed)) {\n // New format: first entry has notif_* keys\n if (isset($parsed[0]['notif_stock']) || isset($parsed[0]['notif_wa'])) {\n $contacts = $parsed;\n } else {\n // Old format: JSON has only extra contacts\n if (!empty($res['contacto_nombre'])) {\n $contacts[] = [\n 'nombre' => $res['contacto_nombre'],\n 'telefono' => $res['contacto_telefono'] ?? '',\n 'email' => $res['contacto_email'] ?? '',\n 'notif_stock' => 1,\n 'notif_wa' => 1,\n ];\n }\n $contacts = array_merge($contacts, $parsed);\n }\n }\n }\n // Fallback: build from flat fields\n if (empty($contacts) && !empty($res['contacto_nombre'])) {\n $contacts[] = [\n 'nombre' => $res['contacto_nombre'],\n 'telefono' => $res['contacto_telefono'] ?? '',\n 'email' => $res['contacto_email'] ?? '',\n 'notif_stock' => 1,\n 'notif_wa' => 1,\n ];\n }\n\n // Check stock alerts (medicamentos)\n $lowStockItems = [];\n try {\n $stInv = $db->prepare(\"SELECT nombre, stock_actual, stock_minimo FROM inventario_items WHERE institucion_id = ? AND tipo = 'medicamento' AND activo = 1 AND stock_actual <= COALESCE(stock_minimo, 5) AND stock_actual >= 0\");\n $stInv->execute([$instId]);\n $lowStockItems = array_map(fn($r) => EncryptionMap::decryptRow('inventario_items', $r), $stInv->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) { /* inventario table may not exist */ }\n\n // Check stock alerts (pañales)\n $lowStockPanales = [];\n try {\n $stPan = $db->prepare(\"SELECT nombre, stock_actual, stock_minimo FROM inventario_items WHERE institucion_id = ? AND tipo = 'panal' AND activo = 1 AND stock_actual <= COALESCE(stock_minimo, 5) AND stock_actual >= 0\");\n $stPan->execute([$instId]);\n $lowStockPanales = array_map(fn($r) => EncryptionMap::decryptRow('inventario_items', $r), $stPan->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) { /* inventario table may not exist */ }\n\n // Get yesterday's care records for daily report (in institution timezone)\n $yesterdayInst = (clone $nowInst)->modify('-1 day');\n $yesterday = $yesterdayInst->format('Y-m-d');\n $careRecords = [];\n try {\n $stCare = $db->prepare(\n \"SELECT cr.categoria, cr.hora, cr.datos, cr.observaciones, u.nombre AS usuario_nombre\n FROM cuidados_registros cr\n LEFT JOIN usuarios u ON u.id = cr.usuario_id\n WHERE cr.institucion_id = ? AND cr.residente_id = ? AND cr.fecha = ?\n ORDER BY cr.hora ASC\"\n );\n $stCare->execute([$instId, $resId, $yesterday]);\n $careRecords = array_map(fn($r) => EncryptionMap::decryptRow('cuidados_registros', $r), $stCare->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) {}\n\n // ── Active medical note (notas_medico estado='vigente') ──────────\n // Used by the AI prompt to contextualize the analysis with the\n // most recent doctor's note (PHI = Protected Health Information).\n $activeNote = null;\n try {\n $stNm = $db->prepare(\n \"SELECT contenido, creado_at FROM notas_medico\n WHERE institucion_id = ? AND residente_id = ? AND estado = 'vigente'\n ORDER BY creado_at DESC LIMIT 1\"\n );\n $stNm->execute([$instId, $resId]);\n $rowNm = $stNm->fetch(PDO::FETCH_ASSOC);\n if ($rowNm) {\n $rowNm = EncryptionMap::decryptRow('notas_medico', $rowNm);\n $activeNote = $rowNm;\n }\n } catch (Throwable $e) {}\n\n // ── Hours since last Heces / Orina (eliminacion alerts) ──────────\n // Thresholds (in hours) for surfacing as alerts to the AI.\n $hecesAlertThr = 12; // > 12h sin heces → MONITOREO\n $hecesCritThr = 24; // > 24h sin heces → ALERTA crítica\n $orinaAlertThr = 8; // > 8h sin orina → MONITOREO\n $orinaCritThr = 12; // > 12h sin orina → ALERTA crítica\n $eliminacionAlerts = ['heces' => null, 'orina' => null];\n try {\n $nowTs = $nowInst->getTimestamp();\n foreach (['Heces' => 'heces', 'Orina' => 'orina'] as $sub => $key) {\n $stEl = $db->prepare(\n \"SELECT fecha, hora FROM cuidados_registros\n WHERE institucion_id = ? AND residente_id = ?\n AND categoria = 'eliminacion' AND subtipo = ?\n ORDER BY fecha DESC, hora DESC LIMIT 1\"\n );\n $stEl->execute([$instId, $resId, $sub]);\n $rowEl = $stEl->fetch(PDO::FETCH_ASSOC);\n if ($rowEl) {\n try {\n $dtEl = new DateTime($rowEl['fecha'] . ' ' . ($rowEl['hora'] ?: '00:00'), $instTz);\n $hoursSince = max(0, (int)floor(($nowTs - $dtEl->getTimestamp()) / 3600));\n $eliminacionAlerts[$key] = [\n 'horas' => $hoursSince,\n 'ultimo_fecha' => $rowEl['fecha'],\n 'ultimo_hora' => $rowEl['hora'],\n ];\n } catch (Throwable $e) {}\n } else {\n $eliminacionAlerts[$key] = ['horas' => null, 'ultimo_fecha' => null, 'ultimo_hora' => null];\n }\n }\n } catch (Throwable $e) {}\n $eliminacionAlerts['_thresholds'] = [\n 'heces_alert' => $hecesAlertThr, 'heces_crit' => $hecesCritThr,\n 'orina_alert' => $orinaAlertThr, 'orina_crit' => $orinaCritThr,\n ];\n\n // ── Stock & other per-contact notifications ──────────────────────\n foreach ($contacts as $c) {\n if (empty($c['nombre'])) continue;\n\n // Stock bajo — medicamentos\n if (!empty($c['notif_stock']) && count($lowStockItems) > 0) {\n $agotados = array_filter($lowStockItems, fn($i) => (int)$i['stock_actual'] === 0);\n $criticos = array_filter($lowStockItems, fn($i) => (int)$i['stock_actual'] > 0);\n\n $stockMsg = \"⚠️ *REPORTE DE STOCK BAJO*\\n\";\n $stockMsg .= \"Paciente: *{$resName}*\\n\";\n\n if (!empty($agotados)) {\n $stockMsg .= \"\\n🔴 *AGOTADOS (0 unidades)*\\n\";\n foreach ($agotados as $item) {\n $stockMsg .= \"• {$item['nombre']}\\n\";\n }\n }\n if (!empty($criticos)) {\n $stockMsg .= \"\\n🟠 *STOCK CRÍTICO (Próximos a agotarse)*\\n\";\n foreach ($criticos as $item) {\n $stockMsg .= \"• {$item['nombre']}: {$item['stock_actual']} uds\\n\";\n }\n }\n $stockMsg .= \"\\n> Por favor, coordine la reposición a la brevedad.\";\n\n $dest = $c['telefono'] ?: $c['email'];\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = 'stock_bajo_auto' AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $todayStartUtc]);\n if (!$chk->fetch()) {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, 'stock_bajo_auto', 'Alerta: Stock bajo de medicamentos', $stockMsg, $wa, $mailer, $instName);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n\n // Stock bajo — pañales\n if (!empty($c['notif_stock']) && count($lowStockPanales) > 0) {\n $agotadosP = array_filter($lowStockPanales, fn($i) => (int)$i['stock_actual'] === 0);\n $criticosP = array_filter($lowStockPanales, fn($i) => (int)$i['stock_actual'] > 0);\n\n $panalMsg = \"⚠️ *REPORTE DE STOCK BAJO — PAÑALES*\\n\";\n $panalMsg .= \"Paciente: *{$resName}*\\n\";\n\n if (!empty($agotadosP)) {\n $panalMsg .= \"\\n🔴 *AGOTADOS (0 unidades)*\\n\";\n foreach ($agotadosP as $item) {\n $panalMsg .= \"• {$item['nombre']}\\n\";\n }\n }\n if (!empty($criticosP)) {\n $panalMsg .= \"\\n🟠 *STOCK CRÍTICO (Próximos a agotarse)*\\n\";\n foreach ($criticosP as $item) {\n $panalMsg .= \"• {$item['nombre']}: {$item['stock_actual']} uds\\n\";\n }\n }\n $panalMsg .= \"\\n> Por favor, envíe más a la brevedad.\";\n\n $dest = $c['telefono'] ?: $c['email'];\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = 'stock_panales_auto' AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $todayStartUtc]);\n if (!$chk->fetch()) {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, 'stock_panales_auto', 'Alerta: Stock bajo de pañales', $panalMsg, $wa, $mailer, $instName);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n }\n\n // ── Reporte diario (IA) — generate once, distribute to all ───────\n // Collect eligible contacts for this hour\n $reportRecipients = [];\n // Current time in minutes since midnight (institution timezone)\n $nowMinutes = (int)$nowInst->format('H') * 60 + (int)$nowInst->format('i');\n foreach ($contacts as $c) {\n if (empty($c['nombre']) || empty($c['notif_reporte'])) continue;\n // Parse schedule hours (supports legacy single string or JSON array)\n $rawHora = $c['notif_reporte_hora'] ?? '08:00';\n $horasArr = [];\n if (is_string($rawHora) && str_starts_with($rawHora, '[')) {\n $decoded = json_decode($rawHora, true);\n if (is_array($decoded)) $horasArr = $decoded;\n }\n if (empty($horasArr)) $horasArr = [$rawHora];\n // Check if current time is within ±10 min of any scheduled time\n $hourMatches = false;\n $matchedHH = null;\n foreach ($horasArr as $h) {\n $parts = explode(':', $h);\n $schedMinutes = (int)($parts[0] ?? 0) * 60 + (int)($parts[1] ?? 0);\n if (abs($nowMinutes - $schedMinutes) <= 10) {\n $hourMatches = true;\n $matchedHH = str_pad((int)($parts[0] ?? 0), 2, '0', STR_PAD_LEFT) . str_pad((int)($parts[1] ?? 0), 2, '0', STR_PAD_LEFT);\n break;\n }\n }\n if (!$hourMatches) continue;\n $dest = $c['telefono'] ?: ($c['email'] ?? '');\n if (!$dest) continue;\n // Dedup check: use scheduled HHMM as tipo to allow multiple reports per day at different hours\n $dedupTipo = 'reporte_dia_auto_' . $matchedHH;\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = ? AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $dedupTipo, $todayStartUtc]);\n if (!$chk->fetch()) {\n $reportRecipients[] = ['contact' => $c, 'dedup_tipo' => $dedupTipo];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n\n if (!empty($reportRecipients)) {\n // Generate AI report once\n $reportData = _buildReportData($resName, $yesterday, $careRecords, $activeNote, $eliminacionAlerts);\n $reportMsg = _generateAIReport($config, $reportData, $resName, $yesterday);\n $summary['reportes_ia']++;\n\n // Generate PDF if any recipient wants it\n $wantsPdf = false;\n foreach ($reportRecipients as $r) {\n if (!empty($r['contact']['notif_reporte_pdf'])) { $wantsPdf = true; break; }\n }\n $pdfData = null;\n if ($wantsPdf) {\n $appUrl = $config['app_url'] ?? '';\n if ($appUrl) {\n $pdfData = _generateReportPdf($reportMsg, $resName, $yesterday, $instName, $appUrl);\n } else {\n echo $logPrefix . \" WARN: app_url no configurada, no se puede generar PDF con URL pública.\\n\";\n }\n }\n\n // Distribute to each recipient (throttle handled globally inside _cronSendNotif)\n foreach ($reportRecipients as $r) {\n $c = $r['contact'];\n $dedupTipo = $r['dedup_tipo'];\n // Only pass PDF data if this contact has the PDF switch enabled\n $cPdfUrl = (!empty($c['notif_reporte_pdf']) && $pdfData) ? $pdfData['url'] : '';\n $cPdfPath = (!empty($c['notif_reporte_pdf']) && $pdfData) ? $pdfData['path'] : '';\n try {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, $dedupTipo, 'Reporte del día', $reportMsg, $wa, $mailer, $instName, $cPdfUrl, $cPdfPath);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } catch (Throwable $e) {\n echo $logPrefix . \" ERROR envío reporte a \" . ($c['telefono'] ?? $c['email'] ?? '?') . \": \" . $e->getMessage() . \"\\n\";\n $instSummary['errores']++; $summary['notif_error']++;\n }\n }\n }\n }\n $summary['detalle'][] = $instSummary;\n}\n\n// ── Resumen de ejecución ─────────────────────────────────────────────────\n$summary['fin'] = date('Y-m-d H:i:s');\necho \"\\n\";\necho \"════════════════════════════════════════\\n\";\necho \" RESUMEN DE EJECUCIÓN DEL CRON\\n\";\necho \"════════════════════════════════════════\\n\";\necho \" Inicio: {$summary['inicio']}\\n\";\necho \" Fin: {$summary['fin']}\\n\";\necho \" Instituciones: {$summary['instituciones']}\\n\";\necho \" Sin canales: {$summary['inst_sin_canales']}\\n\";\necho \" Residentes: {$summary['residentes']}\\n\";\necho \" Reportes IA gen.: {$summary['reportes_ia']}\\n\";\necho \" Notif. enviadas: {$summary['notif_enviadas']}\\n\";\necho \" Notif. con error: {$summary['notif_error']}\\n\";\necho \" Notif. duplicadas: {$summary['notif_dedup']}\\n\";\necho \"────────────────────────────────────────\\n\";\nforeach ($summary['detalle'] as $d) {\n echo \" [{$d['id']}] {$d['nombre']}\\n\";\n if (!empty($d['nota'])) { echo \" {$d['nota']}\\n\"; continue; }\n echo \" Residentes: {$d['residentes']} | Enviados: {$d['enviados']} | Errores: {$d['errores']} | Dedup: {$d['dedup']}\\n\";\n}\necho \"════════════════════════════════════════\\n\";\n\n// ── Flush output ─────────────────────────────────────────────────────────\n$output = ob_get_clean();\nif ($isHttp) {\n $safe = htmlspecialchars($output, ENT_QUOTES, 'UTF-8');\n echo \"
GeriApp Cron\";\n echo \"\";\n echo \"{$safe}\";\n echo \"\";\n} else {\n echo $output;\n}\n\n// ════════════════════════════════════════════════════════════════════════════\n// Helper functions\n// ════════════════════════════════════════════════════════════════════════════\n\n/**\n * Build a detailed plain-text report from care records.\n *\n * Includes (when available):\n * - The active medical note (notas_medico estado='vigente') so the AI can\n * contextualize today's analysis against the doctor's current plan.\n * - Hours since the last 'Heces' / 'Orina' eliminación events so the AI can\n * surface intestinal/urinary alerts when retention exceeds safe thresholds.\n *\n * §5 PHIPA/NOM — All inputs already decrypted by EncryptionMap upstream.\n */\nfunction _buildReportData(\n string $resName,\n string $fecha,\n array $records,\n ?array $activeNote = null,\n ?array $eliminacionAlerts = null\n): string {\n $catLabels = [\n 'sueno' => 'Sueño', 'alimentacion' => 'Alimentación', 'medicacion' => 'Medicación',\n 'higiene' => 'Higiene', 'terapia' => 'Terapia', 'movilidad' => 'Movilidad',\n 'eliminacion' => 'Eliminación', 'comportamiento' => 'Comportamiento',\n 'signos_vitales' => 'Signos vitales'\n ];\n\n $text = \"Residente: {$resName}\\nFecha: {$fecha}\\n\\n\";\n\n // ── Active medical note (vigente) ───────────────────────────────\n if (is_array($activeNote) && !empty($activeNote['contenido'])) {\n // Strip HTML tags so the prompt stays plain text and within budget.\n $contenido = trim(html_entity_decode(strip_tags((string)$activeNote['contenido']), ENT_QUOTES, 'UTF-8'));\n $contenido = preg_replace(\"/[ \\t]+/u\", ' ', $contenido);\n $contenido = preg_replace(\"/\\n{3,}/u\", \"\\n\\n\", $contenido);\n // Cap to ~2000 chars to keep the prompt efficient\n if (mb_strlen($contenido) > 2000) {\n $contenido = mb_substr($contenido, 0, 2000) . '… [truncado]';\n }\n $created = $activeNote['creado_at'] ?? '';\n $text .= \"NOTA MÉDICA VIGENTE\";\n if ($created) $text .= \" (registrada {$created})\";\n $text .= \":\\n{$contenido}\\n\\n\";\n }\n\n // ── Eliminación alerts (heces / orina) ──────────────────────────\n if (is_array($eliminacionAlerts)) {\n $thr = $eliminacionAlerts['_thresholds'] ?? [\n 'heces_alert' => 12, 'heces_crit' => 24,\n 'orina_alert' => 8, 'orina_crit' => 12,\n ];\n $alertLines = [];\n foreach (['heces' => 'Heces', 'orina' => 'Orina'] as $k => $label) {\n $info = $eliminacionAlerts[$k] ?? null;\n if (!is_array($info)) continue;\n $hrs = $info['horas'] ?? null;\n $aThr = $thr[$k . '_alert'] ?? null;\n $cThr = $thr[$k . '_crit'] ?? null;\n if ($hrs === null) {\n $alertLines[] = \"- {$label}: SIN registros previos en la base de datos.\";\n continue;\n }\n $when = trim(($info['ultimo_fecha'] ?? '') . ' ' . ($info['ultimo_hora'] ?? ''));\n $level = '';\n if ($cThr !== null && $hrs >= $cThr) $level = 'CRÍTICA';\n elseif ($aThr !== null && $hrs >= $aThr) $level = 'monitoreo';\n if ($level !== '') {\n $alertLines[] = \"- {$label}: {$hrs}h sin registro (último: {$when}) — alerta \" . $level . \".\";\n } else {\n $alertLines[] = \"- {$label}: {$hrs}h desde último registro (último: {$when}) — dentro de rango.\";\n }\n }\n if (!empty($alertLines)) {\n $text .= \"ALERTAS DE ELIMINACIÓN (umbrales heces ≥{$thr['heces_alert']}h/≥{$thr['heces_crit']}h, orina ≥{$thr['orina_alert']}h/≥{$thr['orina_crit']}h):\\n\"\n . implode(\"\\n\", $alertLines) . \"\\n\\n\";\n }\n }\n\n if (empty($records)) {\n $text .= \"No se registraron cuidados en este día.\\n\";\n return $text;\n }\n\n // Stats summary\n $counts = [];\n foreach ($records as $r) {\n $cat = $r['categoria'];\n $counts[$cat] = ($counts[$cat] ?? 0) + 1;\n }\n $text .= \"Estadísticas:\\n\";\n foreach ($counts as $cat => $total) {\n $label = $catLabels[$cat] ?? $cat;\n $text .= \"- {$label}: {$total} registro(s)\\n\";\n }\n $text .= \"\\n\";\n\n // Detailed records by category\n $byCategory = [];\n foreach ($records as $r) {\n $byCategory[$r['categoria']][] = $r;\n }\n\n foreach ($byCategory as $cat => $recs) {\n $label = $catLabels[$cat] ?? $cat;\n $text .= \"{$label}:\\n\";\n foreach ($recs as $r) {\n $hora = $r['hora'] ?? '';\n $datos = is_string($r['datos']) ? json_decode($r['datos'], true) : ($r['datos'] ?: []);\n $obs = $r['observaciones'] ?? '';\n $parts = [];\n if ($hora) $parts[] = $hora;\n // Extract key data fields\n if (is_array($datos)) {\n foreach ($datos as $k => $v) {\n if ($v === null || $v === '' || $k === 'foto_path') continue;\n if (is_bool($v)) $v = $v ? 'Sí' : 'No';\n if (is_array($v)) {\n // Sub-objetos (ej. signos vitales: presion=>['sis'=>120,'dia'=>80])\n // se aplanan como \"k1=v1, k2=v2\"\n $flat = [];\n foreach ($v as $sk => $sv) {\n if ($sv === null || $sv === '') continue;\n if (is_array($sv)) $sv = json_encode($sv, JSON_UNESCAPED_UNICODE);\n elseif (is_bool($sv)) $sv = $sv ? 'Sí' : 'No';\n $flat[] = \"{$sk}={$sv}\";\n }\n $v = implode(', ', $flat);\n }\n $parts[] = str_replace('_', ' ', $k) . \": {$v}\";\n }\n }\n if ($obs) $parts[] = \"Obs: {$obs}\";\n if ($r['usuario_nombre'] ?? false) $parts[] = \"Por: {$r['usuario_nombre']}\";\n $text .= \" - \" . implode(' | ', $parts) . \"\\n\";\n }\n $text .= \"\\n\";\n }\n\n return $text;\n}\n\n/**\n * Call the AI provider to interpret report data. Falls back to plain text.\n */\nfunction _generateAIReport(array $config, string $reportData, string $resName, string $fecha): string\n{\n global $logPrefix;\n\n $provider = $config['ia_proveedor'] ?? 'openai';\n $apiKey = $config['ia_api_key'] ?? '';\n $model = $config['ia_modelo'] ?? '';\n $maxPalabras = max(50, min(2000, (int)($config['ia_max_palabras'] ?? 400)));\n\n $fechaObj = DateTime::createFromFormat('Y-m-d', $fecha);\n $fechaDisplay = $fechaObj ? strtoupper($fechaObj->format('d')) . ' ' . strtoupper([\n 1=>'ENERO',2=>'FEBRERO',3=>'MARZO',4=>'ABRIL',5=>'MAYO',6=>'JUNIO',\n 7=>'JULIO',8=>'AGOSTO',9=>'SEPTIEMBRE',10=>'OCTUBRE',11=>'NOVIEMBRE',12=>'DICIEMBRE'\n ][(int)$fechaObj->format('n')]) . ' ' . $fechaObj->format('Y') : $fecha;\n\n // If no AI key configured, send the raw detailed report\n if (empty($apiKey)) {\n echo $logPrefix . \" IA no configurada, enviando reporte de datos en texto plano.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n $systemPrompt = $config['ia_prompt'] ?? '';\n if (empty($systemPrompt)) {\n $systemPrompt = \"Eres un asistente de salud geriátrica profesional. Analizas datos de cuidados de adultos mayores en una residencia.\";\n }\n\n $userPrompt = \"Analiza este reporte de cuidados de un adulto mayor en una residencia geriátrica.\\n\\n\"\n . \"Instrucciones ESTRICTAS de formato (este mensaje se envía por WhatsApp):\\n\"\n . \"- Máximo {$maxPalabras} palabras.\\n\"\n . \"- NO uses formato de carta (sin saludo, sin despedida, sin firma).\\n\"\n . \"- NO uses encabezados markdown (# ni ##). Usa emojis + *negritas* para secciones.\\n\"\n . \"- Formato WhatsApp: *negritas*, _itálicas_, listas con • (viñeta).\\n\"\n . \"- Responde en español.\\n\\n\"\n . \"Estructura OBLIGATORIA del reporte (usa EXACTAMENTE estas secciones, omite las que no apliquen):\\n\\n\"\n . \"🔴 *ALERTAS PRIORITARIAS* _(Requieren atención)_\\n\"\n . \"Solo si hay situaciones que necesiten intervención médica o familiar inmediata.\\n\"\n . \"Cada alerta en un párrafo breve indicando QUÉ pasó y POR QUÉ es relevante.\\n\\n\"\n . \"⚠️ *MONITOREO* _(Observaciones)_\\n\"\n . \"Situaciones que no son urgentes pero merecen seguimiento.\\n\\n\"\n . \"✅ *ESTADO GENERAL* _(Estable)_\\n\"\n . \"Resumen breve de lo que estuvo bien: medicación, alimentación, higiene, etc.\\n\\n\"\n . \"💡 *RECOMENDACIONES*\\n\"\n . \"Acciones concretas sugeridas para el familiar o el equipo médico.\\n\\n\"\n . \"IMPORTANTE:\\n\"\n . \"- Si el reporte incluye 'NOTA MÉDICA VIGENTE', úsala como contexto clínico de base: contrasta lo registrado hoy contra el plan/diagnóstico/indicaciones del médico, y resalta cualquier desviación o cumplimiento.\\n\"\n . \"- Si el reporte incluye 'ALERTAS DE ELIMINACIÓN' con nivel 'CRÍTICA' (heces ≥24h u orina ≥12h), inclúyelas SIEMPRE en 🔴 ALERTAS PRIORITARIAS. Si el nivel es 'monitoreo' (heces ≥12h u orina ≥8h), inclúyelas en ⚠️ MONITOREO.\\n\"\n . \"- Presta especial atención a las observaciones de los cuidadores (campo 'Obs:') ya que contienen información clínica valiosa.\\n\"\n . \"- Identifica patrones, tendencias o anomalías.\\n\"\n . \"- Sé directo, empático y usa lenguaje sencillo (es para familiares, no médicos).\\n\"\n . \"- No repitas la misma información en varias secciones.\\n\\n\"\n . \"Datos del reporte:\\n{$reportData}\";\n\n // Call AI provider\n $endpoints = [\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'gemini' => 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model ?: 'gemini-2.0-flash') . ':generateContent?key=' . urlencode($apiKey),\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n ];\n $url = $endpoints[$provider] ?? $endpoints['openai'];\n\n if ($provider === 'gemini') {\n $payload = json_encode([\n 'system_instruction' => ['parts' => [['text' => $systemPrompt]]],\n 'contents' => [['parts' => [['text' => $userPrompt]]]],\n 'generationConfig' => ['maxOutputTokens' => 2048, 'temperature' => 0.7],\n ]);\n $headers = ['Content-Type: application/json'];\n } else {\n $payload = json_encode([\n 'model' => $model ?: ($provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini'),\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => $userPrompt],\n ],\n 'max_tokens' => 2048,\n 'temperature' => 0.7,\n ]);\n $headers = [\n 'Content-Type: application/json',\n 'Authorization: Bearer ' . $apiKey,\n ];\n }\n\n $ch = curl_init($url);\n curl_setopt_array($ch, [\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => $payload,\n CURLOPT_HTTPHEADER => $headers,\n CURLOPT_TIMEOUT => 120,\n ]);\n $response = curl_exec($ch);\n $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n $curlErr = curl_error($ch);\n curl_close($ch);\n\n if ($curlErr || $httpCode < 200 || $httpCode >= 300) {\n echo $logPrefix . \" ERROR IA ({$provider}): \" . ($curlErr ?: \"HTTP {$httpCode}\") . \"\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n $result = json_decode($response, true);\n if ($provider === 'gemini') {\n $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';\n } else {\n $text = $result['choices'][0]['message']['content'] ?? '';\n }\n\n if (empty($text)) {\n echo $logPrefix . \" WARN: IA no generó respuesta, enviando datos planos.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n echo $logPrefix . \" IA ({$provider}): reporte generado OK.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $text;\n}\n\n/**\n * Send a notification to a contact via their preferred channels.\n * Returns ['ok' => int, 'err' => int] with counts of successful/failed sends.\n * $pdfUrl: optional public URL to a PDF document to attach via WaSender.\n * $pdfPath: optional local file path to attach to email.\n */\nfunction _cronSendNotif(PDO $db, int $instId, int $resId, array $c, string $tipo, string $subject, string $msg, ?WaSenderAPI $wa, ?Mailer $mailer, string $instName = 'GeriApp', string $pdfUrl = '', string $pdfPath = ''): array\n{\n global $logPrefix;\n $ok = 0; $err = 0;\n // WhatsApp\n if (!empty($c['notif_wa']) && !empty($c['telefono']) && $wa) {\n $estado = 'enviado';\n $error = null;\n // Convert markdown bold (**text**) to WhatsApp bold (*text*)\n $waMsg = preg_replace('/\\*\\*(.+?)\\*\\*/', '*$1*', $msg);\n _waThrottle();\n $result = $wa->sendText($c['telefono'], $waMsg);\n _waMarkSent();\n if (empty($result['ok'])) {\n $estado = 'error';\n $error = $result['error'] ?? 'Error desconocido de WA API';\n $err++;\n } else { $ok++; }\n $encMsg = EncryptionMap::encryptRow('notificaciones_log', ['mensaje' => $msg])['mensaje'] ?? $msg;\n $st = $db->prepare(\"INSERT INTO notificaciones_log (institucion_id, residente_id, destinatario, canal, tipo, mensaje, estado, error_detalle) VALUES (?, ?, ?, 'whatsapp', ?, ?, ?, ?)\");\n $st->execute([$instId, $resId, $c['telefono'], $tipo, $encMsg, $estado, $error]);\n EncryptionMap::dualWriteEnc($db, 'notificaciones_log', (int)$db->lastInsertId(), ['mensaje' => $msg]);\n echo $logPrefix . \" WA → {$c['telefono']}: {$estado}\" . ($error ? \" ({$error})\" : '') . \"\\n\";\n\n // Send PDF/HTML document if available\n if ($pdfUrl && empty($error)) {\n _waThrottle(); // Enforce 7s gap before sending document\n $ext = pathinfo(parse_url($pdfUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'pdf';\n $docFilename = \"reporte_diario.{$ext}\";\n $pdfResult = $wa->sendDocument($c['telefono'], $pdfUrl, '📋 Reporte diario', $docFilename);\n _waMarkSent();\n if (empty($pdfResult['ok'])) {\n echo $logPrefix . \" WA DOC → {$c['telefono']}: error (\" . ($pdfResult['error'] ?? '?') . \")\\n\";\n } else {\n echo $logPrefix . \" WA DOC → {$c['telefono']}: enviado\\n\";\n }\n }\n }\n // Email\n if (!empty($c['notif_email']) && !empty($c['email']) && $mailer) {\n $estado = 'enviado';\n $error = null;\n $htmlBody = _buildEmailHtml($subject, $msg, $tipo, $instName);\n try {\n if ($pdfPath && file_exists($pdfPath)) {\n $mailer->addAttachment($pdfPath, 'reporte_diario.pdf');\n }\n $mailer->send($c['email'], $subject, $htmlBody, $msg);\n $ok++;\n } catch (Throwable $e) {\n $estado = 'error';\n $error = $e->getMessage();\n $err++;\n }\n $encMsgEmail = EncryptionMap::encryptRow('notificaciones_log', ['mensaje' => $msg])['mensaje'] ?? $msg;\n $st = $db->prepare(\"INSERT INTO notificaciones_log (institucion_id, residente_id, destinatario, canal, tipo, mensaje, estado, error_detalle) VALUES (?, ?, ?, 'email', ?, ?, ?, ?)\");\n $st->execute([$instId, $resId, $c['email'], $tipo, $encMsgEmail, $estado, $error]);\n EncryptionMap::dualWriteEnc($db, 'notificaciones_log', (int)$db->lastInsertId(), ['mensaje' => $msg]);\n echo $logPrefix . \" Email → {$c['email']}: {$estado}\\n\";\n }\n return ['ok' => $ok, 'err' => $err];\n}\n\n/**\n * Generate a styled HTML/PDF report file.\n * Tries dompdf if the library is available; otherwise saves as .pdf-compatible HTML.\n * Returns ['path' => local file path, 'url' => public URL] or null on failure.\n */\nfunction _generateReportPdf(string $reportMsg, string $resName, string $fecha, string $instName, string $appUrl): ?array\n{\n global $logPrefix;\n\n $uploadsDir = __DIR__ . '/../uploads/reportes';\n if (!is_dir($uploadsDir)) {\n mkdir($uploadsDir, 0755, true);\n }\n\n // Clean old reports (older than 7 days)\n foreach (glob($uploadsDir . '/*.{pdf,html}', GLOB_BRACE) as $old) {\n if (filemtime($old) < time() - 7 * 86400) @unlink($old);\n }\n\n $safeRes = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($resName, 0, 30));\n $filename = \"reporte_{$safeRes}_{$fecha}_\" . substr(md5(uniqid('', true)), 0, 8);\n\n // Build styled HTML content\n $msgSafe = htmlspecialchars($reportMsg, ENT_QUOTES, 'UTF-8');\n // Convert WhatsApp formatting to HTML\n $msgHtml = preg_replace('/\\*(.+?)\\*/', '$1', $msgSafe);\n $msgHtml = preg_replace('/_(.+?)_/', '$1', $msgHtml);\n $msgHtml = nl2br($msgHtml);\n // Convert bullet points\n $msgHtml = preg_replace('/^• /m', '• ', $msgHtml);\n\n $instSafe = htmlspecialchars($instName, ENT_QUOTES, 'UTF-8');\n $resSafe = htmlspecialchars($resName, ENT_QUOTES, 'UTF-8');\n $year = date('Y');\n\n $html = <<\n\n\n\nReporte Diario - {$resSafe}\n\n\n\n\n{$msgHtml}
\n\n\n\nEPDF;\n\n // Try dompdf if available\n $dompdfAutoload = __DIR__ . '/../vendor/autoload.php';\n if (file_exists($dompdfAutoload)) {\n try {\n require_once $dompdfAutoload;\n if (class_exists('Dompdf\\\\Dompdf')) {\n $dompdf = new \\Dompdf\\Dompdf(['isRemoteEnabled' => false]);\n $dompdf->loadHtml($html);\n $dompdf->setPaper('A4', 'portrait');\n $dompdf->render();\n $pdfPath = $uploadsDir . '/' . $filename . '.pdf';\n file_put_contents($pdfPath, $dompdf->output());\n $publicUrl = rtrim($appUrl, '/') . '/uploads/reportes/' . $filename . '.pdf';\n echo $logPrefix . \" PDF generado (dompdf): {$filename}.pdf\\n\";\n return ['path' => $pdfPath, 'url' => $publicUrl];\n }\n } catch (Throwable $e) {\n echo $logPrefix . \" WARN dompdf: \" . $e->getMessage() . \"\\n\";\n }\n }\n\n // Fallback: save as HTML document (opens in any browser/device)\n $htmlPath = $uploadsDir . '/' . $filename . '.html';\n file_put_contents($htmlPath, $html);\n $publicUrl = rtrim($appUrl, '/') . '/uploads/reportes/' . $filename . '.html';\n echo $logPrefix . \" Reporte HTML generado (sin dompdf): {$filename}.html\\n\";\n return ['path' => $htmlPath, 'url' => $publicUrl];\n}\n\n/**\n * Build a styled HTML email body.\n */\nfunction _buildEmailHtml(string $subject, string $message, string $tipo, string $instName = 'GeriApp'): string\n{\n $accentColors = [\n 'stock_bajo_auto' => '#e67e22',\n 'stock_panales_auto' => '#e67e22',\n 'signos_vitales' => '#e74c3c',\n 'incidente' => '#e74c3c',\n 'reporte_dia_auto' => '#1e3a6e',\n 'medicacion' => '#27ae60',\n 'alimentacion' => '#27ae60',\n 'animo' => '#8e44ad',\n ];\n $accent = $accentColors[$tipo] ?? '#1e3a6e';\n $instNameSafe = htmlspecialchars($instName, ENT_QUOTES, 'UTF-8');\n $subjectSafe = htmlspecialchars($subject, ENT_QUOTES, 'UTF-8');\n $messageHtml = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'));\n $year = date('Y');\n\n return <<\n\n\n\n\n\n\n | \n | \n {$instNameSafe} \n {$subjectSafe} \n | \n | \n | \n {$messageHtml} \n | \n | \n {$instNameSafe} · GeriApp · © {$year} \n | \n \n |
\n
\n\n\nEHTML;\n}\n"}, "4d09f620fa12ceab376f468963e44ec6d29adb70": {"t": 1785760178.9270463, "n": 45316, "h": "b1e64282819057d8", "c": "> /var/log/geriapp_cron.log 2>&1\n */\n\n$isCli = php_sapi_name() === 'cli';\n$isHttp = !$isCli;\n\nif ($isHttp) {\n header('Content-Type: text/html; charset=utf-8');\n} else {\n header('Content-Type: text/plain; charset=utf-8');\n}\n\n// CLI only (unless CRON_ALLOW_HTTP is defined)\nif ($isHttp && !defined('CRON_ALLOW_HTTP')) {\n http_response_code(403);\n exit('CLI only');\n}\n\n// Buffered output — collect all output, then format at end\nob_start();\n\nrequire_once __DIR__ . '/../conf/config.php';\nrequire_once __DIR__ . '/../db/Database.php';\nrequire_once __DIR__ . '/../db/models.php';\nrequire_once __DIR__ . '/../includes/EncryptionMap.php';\n\n$logPrefix = date('Y-m-d H:i:s') . ' [notif-cron] ';\n$summary = ['inicio' => date('Y-m-d H:i:s'), 'instituciones' => 0, 'inst_sin_canales' => 0, 'residentes' => 0, 'notif_enviadas' => 0, 'notif_error' => 0, 'notif_dedup' => 0, 'reportes_ia' => 0, 'detalle' => []];\n\n// WhatsApp API throttle: minimum 7 seconds between sends to avoid rate-limit errors\n$_waLastSendTime = 0;\nfunction _waThrottle(): void {\n global $_waLastSendTime, $logPrefix;\n if ($_waLastSendTime > 0) {\n $elapsed = hrtime(true) - $_waLastSendTime;\n $remaining = 7_000_000_000 - $elapsed; // 7 seconds in nanoseconds\n if ($remaining > 0) {\n $secs = intdiv((int)$remaining, 1_000_000_000);\n $nanos = (int)($remaining % 1_000_000_000);\n $waitLabel = number_format($remaining / 1_000_000_000, 2);\n echo $logPrefix . \" WA throttle: esperando {$waitLabel}s...\\n\";\n time_nanosleep($secs, $nanos);\n }\n }\n}\nfunction _waMarkSent(): void {\n global $_waLastSendTime;\n $_waLastSendTime = hrtime(true);\n}\n\n// Get all active institutions\ntry {\n $central = Database::getMaster();\n $instituciones = $central->query(\"SELECT id, nombre FROM instituciones WHERE estado = 'activa'\")->fetchAll(PDO::FETCH_ASSOC);\n} catch (Throwable $e) {\n echo $logPrefix . \"ERROR: No se pudo conectar a la base central: \" . $e->getMessage() . \"\\n\";\n exit(1);\n}\n\nforeach ($instituciones as $inst) {\n $instId = (int) $inst['id'];\n $summary['instituciones']++;\n $instSummary = ['nombre' => $inst['nombre'], 'id' => $instId, 'enviados' => 0, 'errores' => 0, 'dedup' => 0, 'residentes' => 0];\n echo $logPrefix . \"Procesando institución: {$inst['nombre']} (ID: $instId)\\n\";\n\n try {\n $db = Database::getTenant($instId);\n } catch (Throwable $e) {\n echo $logPrefix . \" ERROR conexión tenant: \" . $e->getMessage() . \"\\n\";\n continue;\n }\n\n // Ensure notificaciones_log table exists\n $db->exec(\"CREATE TABLE IF NOT EXISTS notificaciones_log (\n id INT AUTO_INCREMENT PRIMARY KEY,\n institucion_id INT NOT NULL,\n residente_id INT,\n destinatario VARCHAR(255),\n canal VARCHAR(20) NOT NULL DEFAULT 'whatsapp',\n tipo VARCHAR(50),\n mensaje TEXT,\n estado VARCHAR(20) NOT NULL DEFAULT 'enviado',\n error_detalle TEXT,\n fecha DATETIME DEFAULT CURRENT_TIMESTAMP,\n INDEX(institucion_id, residente_id, fecha)\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\");\n\n // Check if this institution has WA/email configured\n $config = Configuracion::getByInstitucion($instId);\n $waActivo = !empty($config['wa_activo']);\n $smtpHost = !empty($config['smtp_host']);\n $instName = $config['inst_nombre'] ?? $inst['nombre'] ?? 'GeriApp';\n\n // Use institution timezone for hour comparison (server may be UTC)\n $cfgTz = $config['timezone'] ?? '';\n if (empty($cfgTz)) $cfgTz = 'America/Mexico_City';\n $instTz = new DateTimeZone($cfgTz);\n $nowInst = new DateTime('now', $instTz);\n $currentHour = $nowInst->format('H:i');\n // Today's date in institution timezone (for dedup instead of MySQL CURDATE which uses server TZ)\n $todayInst = $nowInst->format('Y-m-d');\n // Convert institution's start-of-day to UTC for dedup queries against MySQL CURRENT_TIMESTAMP (server TZ)\n $todayStartUtc = (new DateTime($todayInst . ' 00:00:00', $instTz))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');\n echo $logPrefix . \" Timezone configurado: {$cfgTz} | Hora local: {$currentHour} | Fecha local: {$todayInst} | Dedup desde (UTC): {$todayStartUtc}\\n\";\n\n if (!$waActivo && !$smtpHost) {\n echo $logPrefix . \" Sin canales configurados, saltando.\\n\";\n $summary['inst_sin_canales']++;\n $instSummary['nota'] = 'Sin canales configurados';\n $summary['detalle'][] = $instSummary;\n continue;\n }\n\n $wa = null;\n $mailer = null;\n if ($waActivo) {\n try { $wa = WaSenderAPI::fromConfig($instId); } catch (Throwable $e) {\n echo $logPrefix . \" WARN: WA no disponible: \" . $e->getMessage() . \"\\n\";\n }\n }\n if ($smtpHost) {\n try { $mailer = Mailer::fromConfig($instId); } catch (Throwable $e) {\n echo $logPrefix . \" WARN: Email no disponible: \" . $e->getMessage() . \"\\n\";\n }\n }\n\n // Get all active residents with contacts\n $residentes = $db->prepare(\"SELECT id, nombre, apellidos, contacto_nombre, contacto_telefono, contacto_email, contactos_json FROM residentes WHERE institucion_id = ? AND estado = 'activo'\");\n $residentes->execute([$instId]);\n $residentes = $residentes->fetchAll(PDO::FETCH_ASSOC);\n // Decrypt PII columns (consolidated phase) so contact phone/email/JSON\n // are usable as destinatario instead of being persisted as ciphertext\n // into notificaciones_log.\n $residentes = array_map(fn($r) => EncryptionMap::decryptRow('residentes', $r), $residentes);\n\n foreach ($residentes as $res) {\n $resId = (int) $res['id'];\n $resName = trim(($res['nombre'] ?? '') . ' ' . ($res['apellidos'] ?? ''));\n $instSummary['residentes']++;\n $summary['residentes']++;\n\n // Build contacts array (new format: all contacts in contactos_json)\n $contacts = [];\n if (!empty($res['contactos_json'])) {\n $parsed = json_decode($res['contactos_json'], true);\n if (is_array($parsed) && count($parsed)) {\n // New format: first entry has notif_* keys\n if (isset($parsed[0]['notif_stock']) || isset($parsed[0]['notif_wa'])) {\n $contacts = $parsed;\n } else {\n // Old format: JSON has only extra contacts\n if (!empty($res['contacto_nombre'])) {\n $contacts[] = [\n 'nombre' => $res['contacto_nombre'],\n 'telefono' => $res['contacto_telefono'] ?? '',\n 'email' => $res['contacto_email'] ?? '',\n 'notif_stock' => 1,\n 'notif_wa' => 1,\n ];\n }\n $contacts = array_merge($contacts, $parsed);\n }\n }\n }\n // Fallback: build from flat fields\n if (empty($contacts) && !empty($res['contacto_nombre'])) {\n $contacts[] = [\n 'nombre' => $res['contacto_nombre'],\n 'telefono' => $res['contacto_telefono'] ?? '',\n 'email' => $res['contacto_email'] ?? '',\n 'notif_stock' => 1,\n 'notif_wa' => 1,\n ];\n }\n\n // Check stock alerts (medicamentos)\n $lowStockItems = [];\n try {\n $stInv = $db->prepare(\"SELECT nombre, stock_actual, stock_minimo FROM inventario_items WHERE institucion_id = ? AND tipo = 'medicamento' AND activo = 1 AND stock_actual <= COALESCE(stock_minimo, 5) AND stock_actual >= 0\");\n $stInv->execute([$instId]);\n $lowStockItems = array_map(fn($r) => EncryptionMap::decryptRow('inventario_items', $r), $stInv->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) { /* inventario table may not exist */ }\n\n // Check stock alerts (pañales)\n $lowStockPanales = [];\n try {\n $stPan = $db->prepare(\"SELECT nombre, stock_actual, stock_minimo FROM inventario_items WHERE institucion_id = ? AND tipo = 'panal' AND activo = 1 AND stock_actual <= COALESCE(stock_minimo, 5) AND stock_actual >= 0\");\n $stPan->execute([$instId]);\n $lowStockPanales = array_map(fn($r) => EncryptionMap::decryptRow('inventario_items', $r), $stPan->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) { /* inventario table may not exist */ }\n\n // Get yesterday's care records for daily report (in institution timezone)\n $yesterdayInst = (clone $nowInst)->modify('-1 day');\n $yesterday = $yesterdayInst->format('Y-m-d');\n $careRecords = [];\n try {\n $stCare = $db->prepare(\n \"SELECT cr.categoria, cr.hora, cr.datos, cr.observaciones, u.nombre AS usuario_nombre\n FROM cuidados_registros cr\n LEFT JOIN usuarios u ON u.id = cr.usuario_id\n WHERE cr.institucion_id = ? AND cr.residente_id = ? AND cr.fecha = ?\n ORDER BY cr.hora ASC\"\n );\n $stCare->execute([$instId, $resId, $yesterday]);\n $careRecords = array_map(fn($r) => EncryptionMap::decryptRow('cuidados_registros', $r), $stCare->fetchAll(PDO::FETCH_ASSOC));\n } catch (Throwable $e) {}\n\n // ── Active medical note (notas_medico estado='vigente') ──────────\n // Used by the AI prompt to contextualize the analysis with the\n // most recent doctor's note (PHI = Protected Health Information).\n $activeNote = null;\n try {\n $stNm = $db->prepare(\n \"SELECT contenido, creado_at FROM notas_medico\n WHERE institucion_id = ? AND residente_id = ? AND estado = 'vigente'\n ORDER BY creado_at DESC LIMIT 1\"\n );\n $stNm->execute([$instId, $resId]);\n $rowNm = $stNm->fetch(PDO::FETCH_ASSOC);\n if ($rowNm) {\n $rowNm = EncryptionMap::decryptRow('notas_medico', $rowNm);\n $activeNote = $rowNm;\n }\n } catch (Throwable $e) {}\n\n // ── Hours since last Heces / Orina (eliminacion alerts) ──────────\n // Thresholds (in hours) for surfacing as alerts to the AI.\n $hecesAlertThr = 12; // > 12h sin heces → MONITOREO\n $hecesCritThr = 24; // > 24h sin heces → ALERTA crítica\n $orinaAlertThr = 8; // > 8h sin orina → MONITOREO\n $orinaCritThr = 12; // > 12h sin orina → ALERTA crítica\n $eliminacionAlerts = ['heces' => null, 'orina' => null];\n try {\n $nowTs = $nowInst->getTimestamp();\n foreach (['Heces' => 'heces', 'Orina' => 'orina'] as $sub => $key) {\n $stEl = $db->prepare(\n \"SELECT fecha, hora FROM cuidados_registros\n WHERE institucion_id = ? AND residente_id = ?\n AND categoria = 'eliminacion' AND subtipo = ?\n ORDER BY fecha DESC, hora DESC LIMIT 1\"\n );\n $stEl->execute([$instId, $resId, $sub]);\n $rowEl = $stEl->fetch(PDO::FETCH_ASSOC);\n if ($rowEl) {\n try {\n $dtEl = new DateTime($rowEl['fecha'] . ' ' . ($rowEl['hora'] ?: '00:00'), $instTz);\n $hoursSince = max(0, (int)floor(($nowTs - $dtEl->getTimestamp()) / 3600));\n $eliminacionAlerts[$key] = [\n 'horas' => $hoursSince,\n 'ultimo_fecha' => $rowEl['fecha'],\n 'ultimo_hora' => $rowEl['hora'],\n ];\n } catch (Throwable $e) {}\n } else {\n $eliminacionAlerts[$key] = ['horas' => null, 'ultimo_fecha' => null, 'ultimo_hora' => null];\n }\n }\n } catch (Throwable $e) {}\n $eliminacionAlerts['_thresholds'] = [\n 'heces_alert' => $hecesAlertThr, 'heces_crit' => $hecesCritThr,\n 'orina_alert' => $orinaAlertThr, 'orina_crit' => $orinaCritThr,\n ];\n\n // ── Stock & other per-contact notifications ──────────────────────\n foreach ($contacts as $c) {\n if (empty($c['nombre'])) continue;\n\n // Stock bajo — medicamentos\n if (!empty($c['notif_stock']) && count($lowStockItems) > 0) {\n $agotados = array_filter($lowStockItems, fn($i) => (int)$i['stock_actual'] === 0);\n $criticos = array_filter($lowStockItems, fn($i) => (int)$i['stock_actual'] > 0);\n\n $stockMsg = \"⚠️ *REPORTE DE STOCK BAJO*\\n\";\n $stockMsg .= \"Paciente: *{$resName}*\\n\";\n\n if (!empty($agotados)) {\n $stockMsg .= \"\\n🔴 *AGOTADOS (0 unidades)*\\n\";\n foreach ($agotados as $item) {\n $stockMsg .= \"• {$item['nombre']}\\n\";\n }\n }\n if (!empty($criticos)) {\n $stockMsg .= \"\\n🟠 *STOCK CRÍTICO (Próximos a agotarse)*\\n\";\n foreach ($criticos as $item) {\n $stockMsg .= \"• {$item['nombre']}: {$item['stock_actual']} uds\\n\";\n }\n }\n $stockMsg .= \"\\n> Por favor, coordine la reposición a la brevedad.\";\n\n $dest = $c['telefono'] ?: $c['email'];\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = 'stock_bajo_auto' AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $todayStartUtc]);\n if (!$chk->fetch()) {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, 'stock_bajo_auto', 'Alerta: Stock bajo de medicamentos', $stockMsg, $wa, $mailer, $instName);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n\n // Stock bajo — pañales\n if (!empty($c['notif_stock']) && count($lowStockPanales) > 0) {\n $agotadosP = array_filter($lowStockPanales, fn($i) => (int)$i['stock_actual'] === 0);\n $criticosP = array_filter($lowStockPanales, fn($i) => (int)$i['stock_actual'] > 0);\n\n $panalMsg = \"⚠️ *REPORTE DE STOCK BAJO — PAÑALES*\\n\";\n $panalMsg .= \"Paciente: *{$resName}*\\n\";\n\n if (!empty($agotadosP)) {\n $panalMsg .= \"\\n🔴 *AGOTADOS (0 unidades)*\\n\";\n foreach ($agotadosP as $item) {\n $panalMsg .= \"• {$item['nombre']}\\n\";\n }\n }\n if (!empty($criticosP)) {\n $panalMsg .= \"\\n🟠 *STOCK CRÍTICO (Próximos a agotarse)*\\n\";\n foreach ($criticosP as $item) {\n $panalMsg .= \"• {$item['nombre']}: {$item['stock_actual']} uds\\n\";\n }\n }\n $panalMsg .= \"\\n> Por favor, envíe más a la brevedad.\";\n\n $dest = $c['telefono'] ?: $c['email'];\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = 'stock_panales_auto' AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $todayStartUtc]);\n if (!$chk->fetch()) {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, 'stock_panales_auto', 'Alerta: Stock bajo de pañales', $panalMsg, $wa, $mailer, $instName);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n }\n\n // ── Reporte diario (IA) — generate once, distribute to all ───────\n // Collect eligible contacts for this hour\n $reportRecipients = [];\n // Current time in minutes since midnight (institution timezone)\n $nowMinutes = (int)$nowInst->format('H') * 60 + (int)$nowInst->format('i');\n foreach ($contacts as $c) {\n if (empty($c['nombre']) || empty($c['notif_reporte'])) continue;\n // Parse schedule hours (supports legacy single string or JSON array)\n $rawHora = $c['notif_reporte_hora'] ?? '08:00';\n $horasArr = [];\n if (is_string($rawHora) && str_starts_with($rawHora, '[')) {\n $decoded = json_decode($rawHora, true);\n if (is_array($decoded)) $horasArr = $decoded;\n }\n if (empty($horasArr)) $horasArr = [$rawHora];\n // Check if current time is within ±10 min of any scheduled time\n $hourMatches = false;\n $matchedHH = null;\n foreach ($horasArr as $h) {\n $parts = explode(':', $h);\n $schedMinutes = (int)($parts[0] ?? 0) * 60 + (int)($parts[1] ?? 0);\n if (abs($nowMinutes - $schedMinutes) <= 10) {\n $hourMatches = true;\n $matchedHH = str_pad((int)($parts[0] ?? 0), 2, '0', STR_PAD_LEFT) . str_pad((int)($parts[1] ?? 0), 2, '0', STR_PAD_LEFT);\n break;\n }\n }\n if (!$hourMatches) continue;\n $dest = $c['telefono'] ?: ($c['email'] ?? '');\n if (!$dest) continue;\n // Dedup check: use scheduled HHMM as tipo to allow multiple reports per day at different hours\n $dedupTipo = 'reporte_dia_auto_' . $matchedHH;\n $chk = $db->prepare(\"SELECT id FROM notificaciones_log WHERE institucion_id = ? AND residente_id = ? AND destinatario = ? AND tipo = ? AND fecha >= ?\");\n $chk->execute([$instId, $resId, $dest, $dedupTipo, $todayStartUtc]);\n if (!$chk->fetch()) {\n $reportRecipients[] = ['contact' => $c, 'dedup_tipo' => $dedupTipo];\n } else { $summary['notif_dedup']++; $instSummary['dedup']++; }\n }\n\n if (!empty($reportRecipients)) {\n // Generate AI report once\n $reportData = _buildReportData($resName, $yesterday, $careRecords, $activeNote, $eliminacionAlerts);\n $reportMsg = _generateAIReport($config, $reportData, $resName, $yesterday);\n $summary['reportes_ia']++;\n\n // Generate PDF if any recipient wants it\n $wantsPdf = false;\n foreach ($reportRecipients as $r) {\n if (!empty($r['contact']['notif_reporte_pdf'])) { $wantsPdf = true; break; }\n }\n $pdfData = null;\n if ($wantsPdf) {\n $appUrl = $config['app_url'] ?? '';\n if ($appUrl) {\n $pdfData = _generateReportPdf($reportMsg, $resName, $yesterday, $instName, $appUrl);\n } else {\n echo $logPrefix . \" WARN: app_url no configurada, no se puede generar PDF con URL pública.\\n\";\n }\n }\n\n // Distribute to each recipient (throttle handled globally inside _cronSendNotif)\n foreach ($reportRecipients as $r) {\n $c = $r['contact'];\n $dedupTipo = $r['dedup_tipo'];\n // Only pass PDF data if this contact has the PDF switch enabled\n $cPdfUrl = (!empty($c['notif_reporte_pdf']) && $pdfData) ? $pdfData['url'] : '';\n $cPdfPath = (!empty($c['notif_reporte_pdf']) && $pdfData) ? $pdfData['path'] : '';\n try {\n $sendResult = _cronSendNotif($db, $instId, $resId, $c, $dedupTipo, 'Reporte del día', $reportMsg, $wa, $mailer, $instName, $cPdfUrl, $cPdfPath);\n $instSummary['enviados'] += $sendResult['ok']; $instSummary['errores'] += $sendResult['err'];\n $summary['notif_enviadas'] += $sendResult['ok']; $summary['notif_error'] += $sendResult['err'];\n } catch (Throwable $e) {\n echo $logPrefix . \" ERROR envío reporte a \" . ($c['telefono'] ?? $c['email'] ?? '?') . \": \" . $e->getMessage() . \"\\n\";\n $instSummary['errores']++; $summary['notif_error']++;\n }\n }\n }\n }\n $summary['detalle'][] = $instSummary;\n}\n\n// ── Resumen de ejecución ─────────────────────────────────────────────────\n$summary['fin'] = date('Y-m-d H:i:s');\necho \"\\n\";\necho \"════════════════════════════════════════\\n\";\necho \" RESUMEN DE EJECUCIÓN DEL CRON\\n\";\necho \"════════════════════════════════════════\\n\";\necho \" Inicio: {$summary['inicio']}\\n\";\necho \" Fin: {$summary['fin']}\\n\";\necho \" Instituciones: {$summary['instituciones']}\\n\";\necho \" Sin canales: {$summary['inst_sin_canales']}\\n\";\necho \" Residentes: {$summary['residentes']}\\n\";\necho \" Reportes IA gen.: {$summary['reportes_ia']}\\n\";\necho \" Notif. enviadas: {$summary['notif_enviadas']}\\n\";\necho \" Notif. con error: {$summary['notif_error']}\\n\";\necho \" Notif. duplicadas: {$summary['notif_dedup']}\\n\";\necho \"────────────────────────────────────────\\n\";\nforeach ($summary['detalle'] as $d) {\n echo \" [{$d['id']}] {$d['nombre']}\\n\";\n if (!empty($d['nota'])) { echo \" {$d['nota']}\\n\"; continue; }\n echo \" Residentes: {$d['residentes']} | Enviados: {$d['enviados']} | Errores: {$d['errores']} | Dedup: {$d['dedup']}\\n\";\n}\necho \"════════════════════════════════════════\\n\";\n\n// ── Flush output ─────────────────────────────────────────────────────────\n$output = ob_get_clean();\nif ($isHttp) {\n $safe = htmlspecialchars($output, ENT_QUOTES, 'UTF-8');\n echo \"GeriApp Cron\";\n echo \"\";\n echo \"{$safe}\";\n echo \"\";\n} else {\n echo $output;\n}\n\n// ════════════════════════════════════════════════════════════════════════════\n// Helper functions\n// ════════════════════════════════════════════════════════════════════════════\n\n/**\n * Build a detailed plain-text report from care records.\n *\n * Includes (when available):\n * - The active medical note (notas_medico estado='vigente') so the AI can\n * contextualize today's analysis against the doctor's current plan.\n * - Hours since the last 'Heces' / 'Orina' eliminación events so the AI can\n * surface intestinal/urinary alerts when retention exceeds safe thresholds.\n *\n * §5 PHIPA/NOM — All inputs already decrypted by EncryptionMap upstream.\n */\nfunction _buildReportData(\n string $resName,\n string $fecha,\n array $records,\n ?array $activeNote = null,\n ?array $eliminacionAlerts = null\n): string {\n $catLabels = [\n 'sueno' => 'Sueño', 'alimentacion' => 'Alimentación', 'medicacion' => 'Medicación',\n 'higiene' => 'Higiene', 'terapia' => 'Terapia', 'movilidad' => 'Movilidad',\n 'eliminacion' => 'Eliminación', 'comportamiento' => 'Comportamiento',\n 'signos_vitales' => 'Signos vitales'\n ];\n\n $text = \"Residente: {$resName}\\nFecha: {$fecha}\\n\\n\";\n\n // ── Active medical note (vigente) ───────────────────────────────\n if (is_array($activeNote) && !empty($activeNote['contenido'])) {\n // Strip HTML tags so the prompt stays plain text and within budget.\n $contenido = trim(html_entity_decode(strip_tags((string)$activeNote['contenido']), ENT_QUOTES, 'UTF-8'));\n $contenido = preg_replace(\"/[ \\t]+/u\", ' ', $contenido);\n $contenido = preg_replace(\"/\\n{3,}/u\", \"\\n\\n\", $contenido);\n // Cap to ~2000 chars to keep the prompt efficient\n if (mb_strlen($contenido) > 2000) {\n $contenido = mb_substr($contenido, 0, 2000) . '… [truncado]';\n }\n $created = $activeNote['creado_at'] ?? '';\n $text .= \"NOTA MÉDICA VIGENTE\";\n if ($created) $text .= \" (registrada {$created})\";\n $text .= \":\\n{$contenido}\\n\\n\";\n }\n\n // ── Eliminación alerts (heces / orina) ──────────────────────────\n if (is_array($eliminacionAlerts)) {\n $thr = $eliminacionAlerts['_thresholds'] ?? [\n 'heces_alert' => 12, 'heces_crit' => 24,\n 'orina_alert' => 8, 'orina_crit' => 12,\n ];\n $alertLines = [];\n foreach (['heces' => 'Heces', 'orina' => 'Orina'] as $k => $label) {\n $info = $eliminacionAlerts[$k] ?? null;\n if (!is_array($info)) continue;\n $hrs = $info['horas'] ?? null;\n $aThr = $thr[$k . '_alert'] ?? null;\n $cThr = $thr[$k . '_crit'] ?? null;\n if ($hrs === null) {\n $alertLines[] = \"- {$label}: SIN registros previos en la base de datos.\";\n continue;\n }\n $when = trim(($info['ultimo_fecha'] ?? '') . ' ' . ($info['ultimo_hora'] ?? ''));\n $level = '';\n if ($cThr !== null && $hrs >= $cThr) $level = 'CRÍTICA';\n elseif ($aThr !== null && $hrs >= $aThr) $level = 'monitoreo';\n if ($level !== '') {\n $alertLines[] = \"- {$label}: {$hrs}h sin registro (último: {$when}) — alerta \" . $level . \".\";\n } else {\n $alertLines[] = \"- {$label}: {$hrs}h desde último registro (último: {$when}) — dentro de rango.\";\n }\n }\n if (!empty($alertLines)) {\n $text .= \"ALERTAS DE ELIMINACIÓN (umbrales heces ≥{$thr['heces_alert']}h/≥{$thr['heces_crit']}h, orina ≥{$thr['orina_alert']}h/≥{$thr['orina_crit']}h):\\n\"\n . implode(\"\\n\", $alertLines) . \"\\n\\n\";\n }\n }\n\n if (empty($records)) {\n $text .= \"No se registraron cuidados en este día.\\n\";\n return $text;\n }\n\n // Stats summary\n $counts = [];\n foreach ($records as $r) {\n $cat = $r['categoria'];\n $counts[$cat] = ($counts[$cat] ?? 0) + 1;\n }\n $text .= \"Estadísticas:\\n\";\n foreach ($counts as $cat => $total) {\n $label = $catLabels[$cat] ?? $cat;\n $text .= \"- {$label}: {$total} registro(s)\\n\";\n }\n $text .= \"\\n\";\n\n // Detailed records by category\n $byCategory = [];\n foreach ($records as $r) {\n $byCategory[$r['categoria']][] = $r;\n }\n\n foreach ($byCategory as $cat => $recs) {\n $label = $catLabels[$cat] ?? $cat;\n $text .= \"{$label}:\\n\";\n foreach ($recs as $r) {\n $hora = $r['hora'] ?? '';\n $datos = is_string($r['datos']) ? json_decode($r['datos'], true) : ($r['datos'] ?: []);\n $obs = $r['observaciones'] ?? '';\n $parts = [];\n if ($hora) $parts[] = $hora;\n // Extract key data fields\n if (is_array($datos)) {\n foreach ($datos as $k => $v) {\n if ($v === null || $v === '' || $k === 'foto_path') continue;\n if (is_bool($v)) $v = $v ? 'Sí' : 'No';\n if (is_array($v)) {\n // Sub-objetos (ej. signos vitales: presion=>['sis'=>120,'dia'=>80])\n // se aplanan como \"k1=v1, k2=v2\"\n $flat = [];\n foreach ($v as $sk => $sv) {\n if ($sv === null || $sv === '') continue;\n if (is_array($sv)) $sv = json_encode($sv, JSON_UNESCAPED_UNICODE);\n elseif (is_bool($sv)) $sv = $sv ? 'Sí' : 'No';\n $flat[] = \"{$sk}={$sv}\";\n }\n $v = implode(', ', $flat);\n }\n $parts[] = str_replace('_', ' ', $k) . \": {$v}\";\n }\n }\n if ($obs) $parts[] = \"Obs: {$obs}\";\n if ($r['usuario_nombre'] ?? false) $parts[] = \"Por: {$r['usuario_nombre']}\";\n $text .= \" - \" . implode(' | ', $parts) . \"\\n\";\n }\n $text .= \"\\n\";\n }\n\n return $text;\n}\n\n/**\n * Call the AI provider to interpret report data. Falls back to plain text.\n */\nfunction _generateAIReport(array $config, string $reportData, string $resName, string $fecha): string\n{\n global $logPrefix;\n\n $provider = $config['ia_proveedor'] ?? 'openai';\n $apiKey = $config['ia_api_key'] ?? '';\n $model = $config['ia_modelo'] ?? '';\n $maxPalabras = max(50, min(2000, (int)($config['ia_max_palabras'] ?? 400)));\n\n $fechaObj = DateTime::createFromFormat('Y-m-d', $fecha);\n $fechaDisplay = $fechaObj ? strtoupper($fechaObj->format('d')) . ' ' . strtoupper([\n 1=>'ENERO',2=>'FEBRERO',3=>'MARZO',4=>'ABRIL',5=>'MAYO',6=>'JUNIO',\n 7=>'JULIO',8=>'AGOSTO',9=>'SEPTIEMBRE',10=>'OCTUBRE',11=>'NOVIEMBRE',12=>'DICIEMBRE'\n ][(int)$fechaObj->format('n')]) . ' ' . $fechaObj->format('Y') : $fecha;\n\n // If no AI key configured, send the raw detailed report\n if (empty($apiKey)) {\n echo $logPrefix . \" IA no configurada, enviando reporte de datos en texto plano.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n $systemPrompt = $config['ia_prompt'] ?? '';\n if (empty($systemPrompt)) {\n $systemPrompt = \"Eres un asistente de salud geriátrica profesional. Analizas datos de cuidados de adultos mayores en una residencia.\";\n }\n\n $userPrompt = \"Analiza este reporte de cuidados de un adulto mayor en una residencia geriátrica.\\n\\n\"\n . \"Instrucciones ESTRICTAS de formato (este mensaje se envía por WhatsApp):\\n\"\n . \"- Máximo {$maxPalabras} palabras.\\n\"\n . \"- NO uses formato de carta (sin saludo, sin despedida, sin firma).\\n\"\n . \"- NO uses encabezados markdown (# ni ##). Usa emojis + *negritas* para secciones.\\n\"\n . \"- Formato WhatsApp: *negritas*, _itálicas_, listas con • (viñeta).\\n\"\n . \"- Responde en español.\\n\\n\"\n . \"Estructura OBLIGATORIA del reporte (usa EXACTAMENTE estas secciones, omite las que no apliquen):\\n\\n\"\n . \"🔴 *ALERTAS PRIORITARIAS* _(Requieren atención)_\\n\"\n . \"Solo si hay situaciones que necesiten intervención médica o familiar inmediata.\\n\"\n . \"Cada alerta en un párrafo breve indicando QUÉ pasó y POR QUÉ es relevante.\\n\\n\"\n . \"⚠️ *MONITOREO* _(Observaciones)_\\n\"\n . \"Situaciones que no son urgentes pero merecen seguimiento.\\n\\n\"\n . \"✅ *ESTADO GENERAL* _(Estable)_\\n\"\n . \"Resumen breve de lo que estuvo bien: medicación, alimentación, higiene, etc.\\n\\n\"\n . \"💡 *RECOMENDACIONES*\\n\"\n . \"Acciones concretas sugeridas para el familiar o el equipo médico.\\n\\n\"\n . \"IMPORTANTE:\\n\"\n . \"- Si el reporte incluye 'NOTA MÉDICA VIGENTE', úsala como contexto clínico de base: contrasta lo registrado hoy contra el plan/diagnóstico/indicaciones del médico, y resalta cualquier desviación o cumplimiento.\\n\"\n . \"- Si el reporte incluye 'ALERTAS DE ELIMINACIÓN' con nivel 'CRÍTICA' (heces ≥24h u orina ≥12h), inclúyelas SIEMPRE en 🔴 ALERTAS PRIORITARIAS. Si el nivel es 'monitoreo' (heces ≥12h u orina ≥8h), inclúyelas en ⚠️ MONITOREO.\\n\"\n . \"- Presta especial atención a las observaciones de los cuidadores (campo 'Obs:') ya que contienen información clínica valiosa.\\n\"\n . \"- Identifica patrones, tendencias o anomalías.\\n\"\n . \"- Sé directo, empático y usa lenguaje sencillo (es para familiares, no médicos).\\n\"\n . \"- No repitas la misma información en varias secciones.\\n\\n\"\n . \"Datos del reporte:\\n{$reportData}\";\n\n // Call AI provider\n $endpoints = [\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'gemini' => 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model ?: 'gemini-2.0-flash') . ':generateContent?key=' . urlencode($apiKey),\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n ];\n $url = $endpoints[$provider] ?? $endpoints['openai'];\n\n if ($provider === 'gemini') {\n $payload = json_encode([\n 'system_instruction' => ['parts' => [['text' => $systemPrompt]]],\n 'contents' => [['parts' => [['text' => $userPrompt]]]],\n 'generationConfig' => ['maxOutputTokens' => 2048, 'temperature' => 0.7],\n ]);\n $headers = ['Content-Type: application/json'];\n } else {\n $payload = json_encode([\n 'model' => $model ?: ($provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini'),\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => $userPrompt],\n ],\n 'max_tokens' => 2048,\n 'temperature' => 0.7,\n ]);\n $headers = [\n 'Content-Type: application/json',\n 'Authorization: Bearer ' . $apiKey,\n ];\n }\n\n $ch = curl_init($url);\n curl_setopt_array($ch, [\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => $payload,\n CURLOPT_HTTPHEADER => $headers,\n CURLOPT_TIMEOUT => 120,\n ]);\n $response = curl_exec($ch);\n $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n $curlErr = curl_error($ch);\n curl_close($ch);\n\n if ($curlErr || $httpCode < 200 || $httpCode >= 300) {\n echo $logPrefix . \" ERROR IA ({$provider}): \" . ($curlErr ?: \"HTTP {$httpCode}\") . \"\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n $result = json_decode($response, true);\n if ($provider === 'gemini') {\n $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';\n } else {\n $text = $result['choices'][0]['message']['content'] ?? '';\n }\n\n if (empty($text)) {\n echo $logPrefix . \" WARN: IA no generó respuesta, enviando datos planos.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $reportData;\n }\n\n echo $logPrefix . \" IA ({$provider}): reporte generado OK.\\n\";\n return \"📋 *REPORTE DIARIO — {$fechaDisplay}*\\nPaciente: *{$resName}*\\n\\n\" . $text;\n}\n\n/**\n * Send a notification to a contact via their preferred channels.\n * Returns ['ok' => int, 'err' => int] with counts of successful/failed sends.\n * $pdfUrl: optional public URL to a PDF document to attach via WaSender.\n * $pdfPath: optional local file path to attach to email.\n */\nfunction _cronSendNotif(PDO $db, int $instId, int $resId, array $c, string $tipo, string $subject, string $msg, ?WaSenderAPI $wa, ?Mailer $mailer, string $instName = 'GeriApp', string $pdfUrl = '', string $pdfPath = ''): array\n{\n global $logPrefix;\n $ok = 0; $err = 0;\n // WhatsApp\n if (!empty($c['notif_wa']) && !empty($c['telefono']) && $wa) {\n $estado = 'enviado';\n $error = null;\n // Convert markdown bold (**text**) to WhatsApp bold (*text*)\n $waMsg = preg_replace('/\\*\\*(.+?)\\*\\*/', '*$1*', $msg);\n _waThrottle();\n $result = $wa->sendText($c['telefono'], $waMsg);\n _waMarkSent();\n if (empty($result['ok'])) {\n $estado = 'error';\n $error = $result['error'] ?? 'Error desconocido de WA API';\n $err++;\n } else { $ok++; }\n $encMsg = EncryptionMap::encryptRow('notificaciones_log', ['mensaje' => $msg])['mensaje'] ?? $msg;\n $st = $db->prepare(\"INSERT INTO notificaciones_log (institucion_id, residente_id, destinatario, canal, tipo, mensaje, estado, error_detalle) VALUES (?, ?, ?, 'whatsapp', ?, ?, ?, ?)\");\n $st->execute([$instId, $resId, $c['telefono'], $tipo, $encMsg, $estado, $error]);\n EncryptionMap::dualWriteEnc($db, 'notificaciones_log', (int)$db->lastInsertId(), ['mensaje' => $msg]);\n echo $logPrefix . \" WA → {$c['telefono']}: {$estado}\" . ($error ? \" ({$error})\" : '') . \"\\n\";\n\n // Send PDF/HTML document if available\n if ($pdfUrl && empty($error)) {\n _waThrottle(); // Enforce 7s gap before sending document\n $ext = pathinfo(parse_url($pdfUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'pdf';\n $docFilename = \"reporte_diario.{$ext}\";\n $pdfResult = $wa->sendDocument($c['telefono'], $pdfUrl, '📋 Reporte diario', $docFilename);\n _waMarkSent();\n if (empty($pdfResult['ok'])) {\n echo $logPrefix . \" WA DOC → {$c['telefono']}: error (\" . ($pdfResult['error'] ?? '?') . \")\\n\";\n } else {\n echo $logPrefix . \" WA DOC → {$c['telefono']}: enviado\\n\";\n }\n }\n }\n // Email\n if (!empty($c['notif_email']) && !empty($c['email']) && $mailer) {\n $estado = 'enviado';\n $error = null;\n $htmlBody = _buildEmailHtml($subject, $msg, $tipo, $instName);\n try {\n if ($pdfPath && file_exists($pdfPath)) {\n $mailer->addAttachment($pdfPath, 'reporte_diario.pdf');\n }\n $mailer->send($c['email'], $subject, $htmlBody, $msg);\n $ok++;\n } catch (Throwable $e) {\n $estado = 'error';\n $error = $e->getMessage();\n $err++;\n }\n $encMsgEmail = EncryptionMap::encryptRow('notificaciones_log', ['mensaje' => $msg])['mensaje'] ?? $msg;\n $st = $db->prepare(\"INSERT INTO notificaciones_log (institucion_id, residente_id, destinatario, canal, tipo, mensaje, estado, error_detalle) VALUES (?, ?, ?, 'email', ?, ?, ?, ?)\");\n $st->execute([$instId, $resId, $c['email'], $tipo, $encMsgEmail, $estado, $error]);\n EncryptionMap::dualWriteEnc($db, 'notificaciones_log', (int)$db->lastInsertId(), ['mensaje' => $msg]);\n echo $logPrefix . \" Email → {$c['email']}: {$estado}\\n\";\n }\n return ['ok' => $ok, 'err' => $err];\n}\n\n/**\n * Generate a styled HTML/PDF report file.\n * Tries dompdf if the library is available; otherwise saves as .pdf-compatible HTML.\n * Returns ['path' => local file path, 'url' => public URL] or null on failure.\n */\nfunction _generateReportPdf(string $reportMsg, string $resName, string $fecha, string $instName, string $appUrl): ?array\n{\n global $logPrefix;\n\n $uploadsDir = __DIR__ . '/../uploads/reportes';\n if (!is_dir($uploadsDir)) {\n mkdir($uploadsDir, 0755, true);\n }\n\n // Clean old reports (older than 7 days)\n foreach (glob($uploadsDir . '/*.{pdf,html}', GLOB_BRACE) as $old) {\n if (filemtime($old) < time() - 7 * 86400) @unlink($old);\n }\n\n $safeRes = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($resName, 0, 30));\n $filename = \"reporte_{$safeRes}_{$fecha}_\" . substr(md5(uniqid('', true)), 0, 8);\n\n // Build styled HTML content\n $msgSafe = htmlspecialchars($reportMsg, ENT_QUOTES, 'UTF-8');\n // Convert WhatsApp formatting to HTML\n $msgHtml = preg_replace('/\\*(.+?)\\*/', '$1', $msgSafe);\n $msgHtml = preg_replace('/_(.+?)_/', '$1', $msgHtml);\n $msgHtml = nl2br($msgHtml);\n // Convert bullet points\n $msgHtml = preg_replace('/^• /m', '• ', $msgHtml);\n\n $instSafe = htmlspecialchars($instName, ENT_QUOTES, 'UTF-8');\n $resSafe = htmlspecialchars($resName, ENT_QUOTES, 'UTF-8');\n $year = date('Y');\n\n $html = <<\n\n\n\nReporte Diario - {$resSafe}\n\n\n\n\n{$msgHtml}
\n\n\n\nEPDF;\n\n // Try dompdf if available\n $dompdfAutoload = __DIR__ . '/../vendor/autoload.php';\n if (file_exists($dompdfAutoload)) {\n try {\n require_once $dompdfAutoload;\n if (class_exists('Dompdf\\\\Dompdf')) {\n $dompdf = new \\Dompdf\\Dompdf(['isRemoteEnabled' => false]);\n $dompdf->loadHtml($html);\n $dompdf->setPaper('A4', 'portrait');\n $dompdf->render();\n $pdfPath = $uploadsDir . '/' . $filename . '.pdf';\n file_put_contents($pdfPath, $dompdf->output());\n $publicUrl = rtrim($appUrl, '/') . '/uploads/reportes/' . $filename . '.pdf';\n echo $logPrefix . \" PDF generado (dompdf): {$filename}.pdf\\n\";\n return ['path' => $pdfPath, 'url' => $publicUrl];\n }\n } catch (Throwable $e) {\n echo $logPrefix . \" WARN dompdf: \" . $e->getMessage() . \"\\n\";\n }\n }\n\n // Fallback: save as HTML document (opens in any browser/device)\n $htmlPath = $uploadsDir . '/' . $filename . '.html';\n file_put_contents($htmlPath, $html);\n $publicUrl = rtrim($appUrl, '/') . '/uploads/reportes/' . $filename . '.html';\n echo $logPrefix . \" Reporte HTML generado (sin dompdf): {$filename}.html\\n\";\n return ['path' => $htmlPath, 'url' => $publicUrl];\n}\n\n/**\n * Build a styled HTML email body.\n */\nfunction _buildEmailHtml(string $subject, string $message, string $tipo, string $instName = 'GeriApp'): string\n{\n $accentColors = [\n 'stock_bajo_auto' => '#e67e22',\n 'stock_panales_auto' => '#e67e22',\n 'signos_vitales' => '#e74c3c',\n 'incidente' => '#e74c3c',\n 'reporte_dia_auto' => '#1e3a6e',\n 'medicacion' => '#27ae60',\n 'alimentacion' => '#27ae60',\n 'animo' => '#8e44ad',\n ];\n $accent = $accentColors[$tipo] ?? '#1e3a6e';\n $instNameSafe = htmlspecialchars($instName, ENT_QUOTES, 'UTF-8');\n $subjectSafe = htmlspecialchars($subject, ENT_QUOTES, 'UTF-8');\n $messageHtml = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'));\n $year = date('Y');\n\n return <<\n\n\n\n\n\n\n | \n | \n {$instNameSafe} \n {$subjectSafe} \n | \n | \n | \n {$messageHtml} \n | \n | \n {$instNameSafe} · GeriApp · © {$year} \n | \n \n |
\n
\n\n\nEHTML;\n}\n"}}, "v9/api/reportes.php": {"6513d14f8165a09333b7833f0c67f0fe1d438330": {"t": 1785746154.428364, "n": 11500, "h": "6926fb86168f7f12", "c": " Inteligencia Artificial.', 422);\n\n $maxPalabras = (int)($cfg['ia_max_palabras'] ?? 400);\n if ($maxPalabras < 50) $maxPalabras = 50;\n if ($maxPalabras > 2000) $maxPalabras = 2000;\n\n // ── Server-side enrichment: prepend active medical note + heces/orina alerts ─\n // The client only sends raw care records. To improve clinical analysis we add\n // (a) the latest 'vigente' notas_medico and (b) hours-since-last Heces/Orina,\n // both fetched here from the tenant DB so they cannot be tampered with.\n if ($residenteId > 0) {\n try { api_assert_residente($residenteId); } catch (\\Throwable $e) { $residenteId = 0; }\n }\n if ($residenteId > 0) {\n $extraText = '';\n try {\n $tdb = Database::getTenant($instId);\n\n // Active medical note\n $stNm = $tdb->prepare(\n \"SELECT contenido, creado_at FROM notas_medico\n WHERE institucion_id = ? AND residente_id = ? AND estado = 'vigente'\n ORDER BY creado_at DESC LIMIT 1\"\n );\n $stNm->execute([$instId, $residenteId]);\n $rowNm = $stNm->fetch(PDO::FETCH_ASSOC);\n if ($rowNm) {\n $rowNm = EncryptionMap::decryptRow('notas_medico', $rowNm);\n $contenido = trim(html_entity_decode(strip_tags((string)($rowNm['contenido'] ?? '')), ENT_QUOTES, 'UTF-8'));\n $contenido = preg_replace(\"/[ \\t]+/u\", ' ', $contenido);\n $contenido = preg_replace(\"/\\n{3,}/u\", \"\\n\\n\", $contenido);\n if (mb_strlen($contenido) > 2000) $contenido = mb_substr($contenido, 0, 2000) . '… [truncado]';\n if ($contenido !== '') {\n $extraText .= \"NOTA MÉDICA VIGENTE\";\n if (!empty($rowNm['creado_at'])) $extraText .= \" (registrada {$rowNm['creado_at']})\";\n $extraText .= \":\\n{$contenido}\\n\\n\";\n }\n }\n\n // Hours since last Heces / Orina\n $tzCfg = $cfg['general_timezone'] ?? ($cfg['timezone'] ?? date_default_timezone_get());\n try { $tz = new DateTimeZone($tzCfg); } catch (\\Throwable $e) { $tz = new DateTimeZone(date_default_timezone_get()); }\n $nowTs = (new DateTime('now', $tz))->getTimestamp();\n $thr = ['heces_alert'=>12,'heces_crit'=>24,'orina_alert'=>8,'orina_crit'=>12];\n $alertLines = [];\n foreach (['Heces' => 'Heces', 'Orina' => 'Orina'] as $sub => $label) {\n $stEl = $tdb->prepare(\n \"SELECT fecha, hora FROM cuidados_registros\n WHERE institucion_id = ? AND residente_id = ?\n AND categoria = 'eliminacion' AND subtipo = ?\n ORDER BY fecha DESC, hora DESC LIMIT 1\"\n );\n $stEl->execute([$instId, $residenteId, $sub]);\n $rowEl = $stEl->fetch(PDO::FETCH_ASSOC);\n $key = strtolower($sub);\n if (!$rowEl) {\n $alertLines[] = \"- {$label}: SIN registros previos en la base de datos.\";\n continue;\n }\n try {\n $dtEl = new DateTime($rowEl['fecha'] . ' ' . ($rowEl['hora'] ?: '00:00'), $tz);\n $hrs = max(0, (int)floor(($nowTs - $dtEl->getTimestamp()) / 3600));\n $when = $rowEl['fecha'] . ' ' . $rowEl['hora'];\n $level = '';\n if ($hrs >= $thr[$key . '_crit']) $level = 'CRÍTICA';\n elseif ($hrs >= $thr[$key . '_alert']) $level = 'monitoreo';\n if ($level !== '') {\n $alertLines[] = \"- {$label}: {$hrs}h sin registro (último: {$when}) — alerta {$level}.\";\n } else {\n $alertLines[] = \"- {$label}: {$hrs}h desde último registro (último: {$when}) — dentro de rango.\";\n }\n } catch (\\Throwable $e) {}\n }\n if (!empty($alertLines)) {\n $extraText .= \"ALERTAS DE ELIMINACIÓN (umbrales heces ≥{$thr['heces_alert']}h/≥{$thr['heces_crit']}h, orina ≥{$thr['orina_alert']}h/≥{$thr['orina_crit']}h):\\n\"\n . implode(\"\\n\", $alertLines) . \"\\n\\n\";\n }\n } catch (\\Throwable $e) { /* enrichment is best-effort */ }\n\n if ($extraText !== '') {\n $reportData = $extraText . $reportData;\n }\n }\n\n // Build prompt per role\n $roleLabels = [\n 'medico' => 'médico geriatra',\n 'enfermero' => 'cuidador/a especialista en geriatría',\n 'familiar' => 'familiar del residente (lenguaje sencillo y empático)',\n ];\n $roleLabel = $roleLabels[$role];\n\n $systemPrompt = $cfg['ia_prompt'] ?? '';\n if (empty($systemPrompt)) {\n $systemPrompt = \"Eres un asistente de salud geriátrica profesional. Analizas datos de cuidados de adultos mayores en una residencia.\";\n }\n\n $userPrompt = \"Analiza este reporte de cuidados como {$roleLabel}.\\n\\n\"\n . \"Instrucciones de formato y contenido:\\n\"\n . \"- NO uses formato de carta (sin saludo, sin despedida, sin firma).\\n\"\n . \"- Sé directo y conciso: máximo {$maxPalabras} palabras.\\n\"\n . \"- NO uses encabezados markdown (# ni ## ni ###). En su lugar usa texto en negritas (**título**) para los nombres de sección.\\n\"\n . \"- Usa viñetas (-) y numerales (1.) para organizar la información.\\n\"\n . \"- Incluye estas secciones: Resumen General, Hallazgos Clave, Alertas (si las hay), Recomendaciones.\\n\"\n . \"- Si el reporte incluye 'NOTA MÉDICA VIGENTE', úsala como contexto clínico de base: contrasta lo observado contra el plan/diagnóstico/indicaciones del médico, y resalta desviaciones o cumplimiento.\\n\"\n . \"- Si el reporte incluye 'ALERTAS DE ELIMINACIÓN' con nivel 'CRÍTICA' (heces ≥24h u orina ≥12h), repórtalas SIEMPRE en la sección de Alertas. Si el nivel es 'monitoreo' (heces ≥12h u orina ≥8h), repórtalas como observación a vigilar.\\n\"\n . \"- Presta especial atención a las observaciones de los cuidadores (campo 'Obs:') ya que contienen información clínica relevante.\\n\"\n . \"- Identifica patrones, tendencias o anomalías en los datos.\\n\"\n . \"- Usa un tono profesional adecuado para un/a {$roleLabel}.\\n\"\n . \"- Responde en español.\\n\\n\"\n . \"Datos del reporte:\\n{$reportData}\";\n\n // Call AI provider\n $endpoints = [\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'gemini' => 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model ?: 'gemini-2.0-flash') . ':generateContent?key=' . urlencode($apiKey),\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n ];\n\n $url = $endpoints[$provider] ?? $endpoints['openai'];\n\n if ($provider === 'gemini') {\n $payload = json_encode([\n 'system_instruction' => ['parts' => [['text' => $systemPrompt]]],\n 'contents' => [['parts' => [['text' => $userPrompt]]]],\n 'generationConfig' => ['maxOutputTokens' => 2048, 'temperature' => 0.7],\n ]);\n $headers = ['Content-Type: application/json'];\n } else {\n $payload = json_encode([\n 'model' => $model ?: ($provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini'),\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => $userPrompt],\n ],\n 'max_tokens' => 2048,\n 'temperature' => 0.7,\n ]);\n $headers = [\n 'Content-Type: application/json',\n 'Authorization: Bearer ' . $apiKey,\n ];\n }\n\n $ch = curl_init($url);\n curl_setopt_array($ch, [\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => $payload,\n CURLOPT_HTTPHEADER => $headers,\n CURLOPT_TIMEOUT => 60,\n ]);\n $response = curl_exec($ch);\n $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n $curlErr = curl_error($ch);\n curl_close($ch);\n\n if ($curlErr) api_error('Error de conexión: ' . $curlErr, 502);\n if ($httpCode < 200 || $httpCode >= 300) {\n $errBody = json_decode($response, true);\n $errMsg = $errBody['error']['message'] ?? ($errBody['error']['status'] ?? \"HTTP $httpCode\");\n api_error('Error del proveedor IA: ' . $errMsg, 502);\n }\n\n $result = json_decode($response, true);\n\n // Extract text based on provider\n if ($provider === 'gemini') {\n $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';\n } else {\n $text = $result['choices'][0]['message']['content'] ?? '';\n }\n\n if (empty($text)) api_error('La IA no generó una respuesta', 502);\n\n api_ok(['interpretation' => $text]);\n}\n\n// ════════════════════════════════════════════════════════════════════════════\n// GET — Datos agregados para reporte\n// ════════════════════════════════════════════════════════════════════════════\nif ($method !== 'GET') api_error('Método no permitido', 405);\n\n$instId = api_inst_id();\n$resId = api_int('residente_id');\n$desde = trim($_GET['desde'] ?? '');\n$hasta = trim($_GET['hasta'] ?? '');\n\nif ($resId <= 0) api_error('residente_id requerido', 422);\nif (!$desde) api_error('desde requerido', 422);\nif (!$hasta) api_error('hasta requerido', 422);\n\n// Verificar que el residente pertenece a la institución\n$residente = Residente::getById($resId, $instId);\nif (!$residente) api_error('Residente no encontrado', 404);\n\n// Familiares solo pueden ver su residente vinculado\nif (($_SESSION['user_rol'] ?? '') === 'familiar' && empty($_SESSION['sa_impersonating'])) {\n $famResId = (int)($_SESSION['user_residente_id'] ?? 0);\n if ($famResId > 0 && $famResId !== $resId) api_error('Acceso denegado', 403);\n}\n\n$datos = Reporte::getResumenCompleto($instId, $resId, $desde, $hasta);\n\napi_ok(array_merge(['residente' => $residente], $datos));\n"}, "4d09f620fa12ceab376f468963e44ec6d29adb70": {"t": 1785760178.601167, "n": 11500, "h": "6926fb86168f7f12", "c": " Inteligencia Artificial.', 422);\n\n $maxPalabras = (int)($cfg['ia_max_palabras'] ?? 400);\n if ($maxPalabras < 50) $maxPalabras = 50;\n if ($maxPalabras > 2000) $maxPalabras = 2000;\n\n // ── Server-side enrichment: prepend active medical note + heces/orina alerts ─\n // The client only sends raw care records. To improve clinical analysis we add\n // (a) the latest 'vigente' notas_medico and (b) hours-since-last Heces/Orina,\n // both fetched here from the tenant DB so they cannot be tampered with.\n if ($residenteId > 0) {\n try { api_assert_residente($residenteId); } catch (\\Throwable $e) { $residenteId = 0; }\n }\n if ($residenteId > 0) {\n $extraText = '';\n try {\n $tdb = Database::getTenant($instId);\n\n // Active medical note\n $stNm = $tdb->prepare(\n \"SELECT contenido, creado_at FROM notas_medico\n WHERE institucion_id = ? AND residente_id = ? AND estado = 'vigente'\n ORDER BY creado_at DESC LIMIT 1\"\n );\n $stNm->execute([$instId, $residenteId]);\n $rowNm = $stNm->fetch(PDO::FETCH_ASSOC);\n if ($rowNm) {\n $rowNm = EncryptionMap::decryptRow('notas_medico', $rowNm);\n $contenido = trim(html_entity_decode(strip_tags((string)($rowNm['contenido'] ?? '')), ENT_QUOTES, 'UTF-8'));\n $contenido = preg_replace(\"/[ \\t]+/u\", ' ', $contenido);\n $contenido = preg_replace(\"/\\n{3,}/u\", \"\\n\\n\", $contenido);\n if (mb_strlen($contenido) > 2000) $contenido = mb_substr($contenido, 0, 2000) . '… [truncado]';\n if ($contenido !== '') {\n $extraText .= \"NOTA MÉDICA VIGENTE\";\n if (!empty($rowNm['creado_at'])) $extraText .= \" (registrada {$rowNm['creado_at']})\";\n $extraText .= \":\\n{$contenido}\\n\\n\";\n }\n }\n\n // Hours since last Heces / Orina\n $tzCfg = $cfg['general_timezone'] ?? ($cfg['timezone'] ?? date_default_timezone_get());\n try { $tz = new DateTimeZone($tzCfg); } catch (\\Throwable $e) { $tz = new DateTimeZone(date_default_timezone_get()); }\n $nowTs = (new DateTime('now', $tz))->getTimestamp();\n $thr = ['heces_alert'=>12,'heces_crit'=>24,'orina_alert'=>8,'orina_crit'=>12];\n $alertLines = [];\n foreach (['Heces' => 'Heces', 'Orina' => 'Orina'] as $sub => $label) {\n $stEl = $tdb->prepare(\n \"SELECT fecha, hora FROM cuidados_registros\n WHERE institucion_id = ? AND residente_id = ?\n AND categoria = 'eliminacion' AND subtipo = ?\n ORDER BY fecha DESC, hora DESC LIMIT 1\"\n );\n $stEl->execute([$instId, $residenteId, $sub]);\n $rowEl = $stEl->fetch(PDO::FETCH_ASSOC);\n $key = strtolower($sub);\n if (!$rowEl) {\n $alertLines[] = \"- {$label}: SIN registros previos en la base de datos.\";\n continue;\n }\n try {\n $dtEl = new DateTime($rowEl['fecha'] . ' ' . ($rowEl['hora'] ?: '00:00'), $tz);\n $hrs = max(0, (int)floor(($nowTs - $dtEl->getTimestamp()) / 3600));\n $when = $rowEl['fecha'] . ' ' . $rowEl['hora'];\n $level = '';\n if ($hrs >= $thr[$key . '_crit']) $level = 'CRÍTICA';\n elseif ($hrs >= $thr[$key . '_alert']) $level = 'monitoreo';\n if ($level !== '') {\n $alertLines[] = \"- {$label}: {$hrs}h sin registro (último: {$when}) — alerta {$level}.\";\n } else {\n $alertLines[] = \"- {$label}: {$hrs}h desde último registro (último: {$when}) — dentro de rango.\";\n }\n } catch (\\Throwable $e) {}\n }\n if (!empty($alertLines)) {\n $extraText .= \"ALERTAS DE ELIMINACIÓN (umbrales heces ≥{$thr['heces_alert']}h/≥{$thr['heces_crit']}h, orina ≥{$thr['orina_alert']}h/≥{$thr['orina_crit']}h):\\n\"\n . implode(\"\\n\", $alertLines) . \"\\n\\n\";\n }\n } catch (\\Throwable $e) { /* enrichment is best-effort */ }\n\n if ($extraText !== '') {\n $reportData = $extraText . $reportData;\n }\n }\n\n // Build prompt per role\n $roleLabels = [\n 'medico' => 'médico geriatra',\n 'enfermero' => 'cuidador/a especialista en geriatría',\n 'familiar' => 'familiar del residente (lenguaje sencillo y empático)',\n ];\n $roleLabel = $roleLabels[$role];\n\n $systemPrompt = $cfg['ia_prompt'] ?? '';\n if (empty($systemPrompt)) {\n $systemPrompt = \"Eres un asistente de salud geriátrica profesional. Analizas datos de cuidados de adultos mayores en una residencia.\";\n }\n\n $userPrompt = \"Analiza este reporte de cuidados como {$roleLabel}.\\n\\n\"\n . \"Instrucciones de formato y contenido:\\n\"\n . \"- NO uses formato de carta (sin saludo, sin despedida, sin firma).\\n\"\n . \"- Sé directo y conciso: máximo {$maxPalabras} palabras.\\n\"\n . \"- NO uses encabezados markdown (# ni ## ni ###). En su lugar usa texto en negritas (**título**) para los nombres de sección.\\n\"\n . \"- Usa viñetas (-) y numerales (1.) para organizar la información.\\n\"\n . \"- Incluye estas secciones: Resumen General, Hallazgos Clave, Alertas (si las hay), Recomendaciones.\\n\"\n . \"- Si el reporte incluye 'NOTA MÉDICA VIGENTE', úsala como contexto clínico de base: contrasta lo observado contra el plan/diagnóstico/indicaciones del médico, y resalta desviaciones o cumplimiento.\\n\"\n . \"- Si el reporte incluye 'ALERTAS DE ELIMINACIÓN' con nivel 'CRÍTICA' (heces ≥24h u orina ≥12h), repórtalas SIEMPRE en la sección de Alertas. Si el nivel es 'monitoreo' (heces ≥12h u orina ≥8h), repórtalas como observación a vigilar.\\n\"\n . \"- Presta especial atención a las observaciones de los cuidadores (campo 'Obs:') ya que contienen información clínica relevante.\\n\"\n . \"- Identifica patrones, tendencias o anomalías en los datos.\\n\"\n . \"- Usa un tono profesional adecuado para un/a {$roleLabel}.\\n\"\n . \"- Responde en español.\\n\\n\"\n . \"Datos del reporte:\\n{$reportData}\";\n\n // Call AI provider\n $endpoints = [\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'gemini' => 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model ?: 'gemini-2.0-flash') . ':generateContent?key=' . urlencode($apiKey),\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n ];\n\n $url = $endpoints[$provider] ?? $endpoints['openai'];\n\n if ($provider === 'gemini') {\n $payload = json_encode([\n 'system_instruction' => ['parts' => [['text' => $systemPrompt]]],\n 'contents' => [['parts' => [['text' => $userPrompt]]]],\n 'generationConfig' => ['maxOutputTokens' => 2048, 'temperature' => 0.7],\n ]);\n $headers = ['Content-Type: application/json'];\n } else {\n $payload = json_encode([\n 'model' => $model ?: ($provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini'),\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => $userPrompt],\n ],\n 'max_tokens' => 2048,\n 'temperature' => 0.7,\n ]);\n $headers = [\n 'Content-Type: application/json',\n 'Authorization: Bearer ' . $apiKey,\n ];\n }\n\n $ch = curl_init($url);\n curl_setopt_array($ch, [\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => $payload,\n CURLOPT_HTTPHEADER => $headers,\n CURLOPT_TIMEOUT => 60,\n ]);\n $response = curl_exec($ch);\n $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n $curlErr = curl_error($ch);\n curl_close($ch);\n\n if ($curlErr) api_error('Error de conexión: ' . $curlErr, 502);\n if ($httpCode < 200 || $httpCode >= 300) {\n $errBody = json_decode($response, true);\n $errMsg = $errBody['error']['message'] ?? ($errBody['error']['status'] ?? \"HTTP $httpCode\");\n api_error('Error del proveedor IA: ' . $errMsg, 502);\n }\n\n $result = json_decode($response, true);\n\n // Extract text based on provider\n if ($provider === 'gemini') {\n $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';\n } else {\n $text = $result['choices'][0]['message']['content'] ?? '';\n }\n\n if (empty($text)) api_error('La IA no generó una respuesta', 502);\n\n api_ok(['interpretation' => $text]);\n}\n\n// ════════════════════════════════════════════════════════════════════════════\n// GET — Datos agregados para reporte\n// ════════════════════════════════════════════════════════════════════════════\nif ($method !== 'GET') api_error('Método no permitido', 405);\n\n$instId = api_inst_id();\n$resId = api_int('residente_id');\n$desde = trim($_GET['desde'] ?? '');\n$hasta = trim($_GET['hasta'] ?? '');\n\nif ($resId <= 0) api_error('residente_id requerido', 422);\nif (!$desde) api_error('desde requerido', 422);\nif (!$hasta) api_error('hasta requerido', 422);\n\n// Verificar que el residente pertenece a la institución\n$residente = Residente::getById($resId, $instId);\nif (!$residente) api_error('Residente no encontrado', 404);\n\n// Familiares solo pueden ver su residente vinculado\nif (($_SESSION['user_rol'] ?? '') === 'familiar' && empty($_SESSION['sa_impersonating'])) {\n $famResId = (int)($_SESSION['user_residente_id'] ?? 0);\n if ($famResId > 0 && $famResId !== $resId) api_error('Acceso denegado', 403);\n}\n\n$datos = Reporte::getResumenCompleto($instId, $resId, $desde, $hasta);\n\napi_ok(array_merge(['residente' => $residente], $datos));\n"}}}}