feat(pacientes): sync de pacientes Firebird → WhatsApp Lab
- Nuevo servicio whatsapp_sync.py: mapeo de campos RIPS→lab_pacientes y cliente HTTP para /api/lab/ingest_paciente.php - Nueva ruta /pacientes con sync masiva por rango de fechas o todos - Hook en /terceros/send y /automation/run: sync silencioso a WhatsApp después de cada envío a TNS - Config: campos whatsapp_url y whatsapp_api_key + botón probar conexión Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b4288ac899
commit
e7c6bc794a
@@ -13,6 +13,7 @@ from app.services.json_generator import (
|
|||||||
agrupar_por_recepcion,
|
agrupar_por_recepcion,
|
||||||
)
|
)
|
||||||
from app.services.api_client import get_tns_token, TNS_BASE
|
from app.services.api_client import get_tns_token, TNS_BASE
|
||||||
|
from app.services.whatsapp_sync import sync_paciente
|
||||||
|
|
||||||
router = APIRouter(prefix="/automation", tags=["automation"])
|
router = APIRouter(prefix="/automation", tags=["automation"])
|
||||||
|
|
||||||
@@ -259,6 +260,18 @@ async def run_automation(
|
|||||||
|
|
||||||
_guardar_envio(user["user_id"], "terceros", fecha, tercero_json, msg, ok)
|
_guardar_envio(user["user_id"], "terceros", fecha, tercero_json, msg, ok)
|
||||||
|
|
||||||
|
# ── Sync paralelo a WhatsApp Lab (silencioso) ─────────────────────────────
|
||||||
|
wa_url = configs.get("whatsapp_url", "").rstrip("/")
|
||||||
|
wa_key = configs.get("whatsapp_api_key", "")
|
||||||
|
if wa_url and wa_key and rows_pac:
|
||||||
|
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as wa_client:
|
||||||
|
for row in rows_pac:
|
||||||
|
await sync_paciente(row, ingest_url, wa_key, wa_client)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# ── PASO 2: Enviar RDA Paciente ───────────────────────────────────────────
|
# ── PASO 2: Enviar RDA Paciente ───────────────────────────────────────────
|
||||||
grupos = agrupar_por_recepcion(rows_rda)
|
grupos = agrupar_por_recepcion(rows_rda)
|
||||||
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal or '00'}"
|
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal or '00'}"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ DEFAULT_KEYS = [
|
|||||||
("especialidad_default", ""),
|
("especialidad_default", ""),
|
||||||
("remisionante_default", "00"),
|
("remisionante_default", "00"),
|
||||||
("prefijo_tns_default", "00"),
|
("prefijo_tns_default", "00"),
|
||||||
|
("whatsapp_url", ""),
|
||||||
|
("whatsapp_api_key", "rips-lab-sync-2026"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
from app.services.whatsapp_sync import sync_todos
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pacientes", tags=["pacientes"])
|
||||||
|
|
||||||
|
# Trae todos los pacientes distintos en un rango de fechas de recepción.
|
||||||
|
# Reutiliza la misma query de automation para no duplicar lógica.
|
||||||
|
_SQL_TODOS = """
|
||||||
|
SELECT DISTINCT
|
||||||
|
p.CODIGO,
|
||||||
|
p.TIPOIDENT,
|
||||||
|
p.DOCIDENT,
|
||||||
|
p.NOMBRES,
|
||||||
|
p.APELLIDOS,
|
||||||
|
p.DIRECCION,
|
||||||
|
p.CIUDAD AS COD_CIUDAD,
|
||||||
|
c.NOMBRE AS NOM_CIUDAD,
|
||||||
|
p.TELEFONOS,
|
||||||
|
p.EMAIL,
|
||||||
|
p.F_NACIMIENTO,
|
||||||
|
p.SEXO,
|
||||||
|
p.TIPORES,
|
||||||
|
p.CODETNIA
|
||||||
|
FROM PACIENTE p
|
||||||
|
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
|
||||||
|
LEFT JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
|
||||||
|
WHERE (:fecha_ini IS NULL OR r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin)
|
||||||
|
AND r.NUM_FACTURA > 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SQL_SIN_FILTRO = """
|
||||||
|
SELECT DISTINCT
|
||||||
|
p.CODIGO,
|
||||||
|
p.TIPOIDENT,
|
||||||
|
p.DOCIDENT,
|
||||||
|
p.NOMBRES,
|
||||||
|
p.APELLIDOS,
|
||||||
|
p.DIRECCION,
|
||||||
|
p.CIUDAD AS COD_CIUDAD,
|
||||||
|
c.NOMBRE AS NOM_CIUDAD,
|
||||||
|
p.TELEFONOS,
|
||||||
|
p.EMAIL,
|
||||||
|
p.F_NACIMIENTO,
|
||||||
|
p.SEXO,
|
||||||
|
p.TIPORES,
|
||||||
|
p.CODETNIA
|
||||||
|
FROM PACIENTE p
|
||||||
|
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
|
||||||
|
WHERE p.DOCIDENT IS NOT NULL AND p.NOMBRES IS NOT NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def pacientes_page(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
wa_url = configs.get("whatsapp_url", "")
|
||||||
|
wa_key = configs.get("whatsapp_api_key", "")
|
||||||
|
return request.app.state.templates.TemplateResponse("pacientes.html", {
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"wa_configurado": bool(wa_url and wa_key),
|
||||||
|
"wa_url": wa_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-all")
|
||||||
|
async def sync_all(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
fecha_ini: str = Form(""),
|
||||||
|
fecha_fin: str = Form(""),
|
||||||
|
todos: str = Form(""),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
wa_url = configs.get("whatsapp_url", "").rstrip("/")
|
||||||
|
wa_key = configs.get("whatsapp_api_key", "")
|
||||||
|
|
||||||
|
if not wa_url or not wa_key:
|
||||||
|
return JSONResponse({
|
||||||
|
"success": False,
|
||||||
|
"message": "Configura la URL y API Key de WhatsApp Lab antes de sincronizar.",
|
||||||
|
})
|
||||||
|
|
||||||
|
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(configs)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
|
||||||
|
|
||||||
|
if todos == "1" or (not fecha_ini and not fecha_fin):
|
||||||
|
ok_q, err_q, rows = fb.execute_query(_SQL_SIN_FILTRO, None)
|
||||||
|
else:
|
||||||
|
if not fecha_ini or not fecha_fin:
|
||||||
|
fb.disconnect()
|
||||||
|
return JSONResponse({"success": False, "message": "Indica fecha inicio y fecha fin."})
|
||||||
|
params = {
|
||||||
|
"fecha_ini": f"{fecha_ini} 00:00:00",
|
||||||
|
"fecha_fin": f"{fecha_fin} 23:59:59",
|
||||||
|
}
|
||||||
|
ok_q, err_q, rows = fb.execute_query(_SQL_TODOS, params)
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
if not ok_q:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error al consultar Firebird: {err_q}"})
|
||||||
|
if not rows:
|
||||||
|
return JSONResponse({"success": False, "message": "No se encontraron pacientes con esos criterios."})
|
||||||
|
|
||||||
|
timeout = int(configs.get("api_timeout", 30))
|
||||||
|
resultado = await sync_todos(rows, ingest_url, wa_key, timeout)
|
||||||
|
resultado["success"] = True
|
||||||
|
resultado["message"] = (
|
||||||
|
f"{resultado['total']} pacientes procesados — "
|
||||||
|
f"{resultado['created']} creados, "
|
||||||
|
f"{resultado['updated']} actualizados, "
|
||||||
|
f"{resultado['errores']} errores."
|
||||||
|
)
|
||||||
|
return JSONResponse(resultado)
|
||||||
@@ -8,6 +8,7 @@ from app.auth import get_current_user
|
|||||||
from app.services.firebird_service import get_firebird_from_config
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
from app.services.json_generator import generar_tercero_api
|
from app.services.json_generator import generar_tercero_api
|
||||||
from app.services.api_client import get_tns_token, TNS_BASE
|
from app.services.api_client import get_tns_token, TNS_BASE
|
||||||
|
from app.services.whatsapp_sync import sync_paciente
|
||||||
|
|
||||||
router = APIRouter(prefix="/terceros", tags=["terceros"])
|
router = APIRouter(prefix="/terceros", tags=["terceros"])
|
||||||
|
|
||||||
@@ -183,9 +184,22 @@ async def send_terceros(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ── Sync silencioso a WhatsApp Lab ───────────────────────────────────────
|
||||||
|
wa_url = configs.get("whatsapp_url", "").rstrip("/")
|
||||||
|
wa_key = configs.get("whatsapp_api_key", "")
|
||||||
|
wa_sync = None
|
||||||
|
if wa_url and wa_key and rows:
|
||||||
|
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
|
||||||
|
try:
|
||||||
|
wa_result = await sync_paciente(rows[0], ingest_url, wa_key)
|
||||||
|
wa_sync = {"ok": wa_result["ok"], "action": wa_result["action"]}
|
||||||
|
except Exception:
|
||||||
|
wa_sync = {"ok": False, "action": "error"}
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": resp_ok,
|
"success": resp_ok,
|
||||||
"status_code": resp_code,
|
"status_code": resp_code,
|
||||||
"message": "Envío exitoso" if resp_ok else f"Error: {resp_text}",
|
"message": "Envío exitoso" if resp_ok else f"Error: {resp_text}",
|
||||||
"cuv": resp_text[:200] if resp_ok else None,
|
"cuv": resp_text[:200] if resp_ok else None,
|
||||||
|
"wa_sync": wa_sync,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Servicio de sincronización de pacientes RIPS → WhatsApp Lab.
|
||||||
|
Mapea campos Firebird al formato esperado por /api/lab/ingest_paciente.php.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_TIPO_DOC_MAP = {
|
||||||
|
"CC": "CC", "TI": "TI", "RC": "RC", "CE": "CE",
|
||||||
|
"PA": "PA", "NIT": "NIT", "MS": "MS", "AS": "CC",
|
||||||
|
"SI": "CC", "CN": "CC", "DE": "CE", "CD": "PA",
|
||||||
|
"PE": "CE", "PT": "PA",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SEXO_MAP = {"M": "M", "F": "F", "H": "M"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_fecha_iso(val) -> Optional[str]:
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
if hasattr(val, "strftime"):
|
||||||
|
return val.strftime("%Y-%m-%d")
|
||||||
|
s = str(val)[:10]
|
||||||
|
if re.match(r"\d{4}-\d{2}-\d{2}", s):
|
||||||
|
return s
|
||||||
|
# dd/mm/yyyy
|
||||||
|
parts = s.split("/")
|
||||||
|
if len(parts) == 3:
|
||||||
|
return f"{parts[2]}-{parts[1]}-{parts[0]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mapear_paciente(row: dict) -> dict:
|
||||||
|
"""Convierte una fila de Firebird PACIENTE al body de ingest_paciente.php."""
|
||||||
|
nombres = (row.get("NOMBRES") or "").strip()
|
||||||
|
apellidos = (row.get("APELLIDOS") or "").strip()
|
||||||
|
nombre_completo = f"{nombres} {apellidos}".strip().upper()
|
||||||
|
|
||||||
|
tipo_raw = str(row.get("TIPOIDENT") or "CC").strip()
|
||||||
|
tipo_doc = _TIPO_DOC_MAP.get(tipo_raw, "CC")
|
||||||
|
|
||||||
|
sexo_raw = str(row.get("SEXO") or "M").strip().upper()
|
||||||
|
genero = _SEXO_MAP.get(sexo_raw, "M")
|
||||||
|
|
||||||
|
email = (row.get("EMAIL") or "").strip().lower()
|
||||||
|
if not email or "@sinregistro" in email:
|
||||||
|
email = ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nombre_completo": nombre_completo,
|
||||||
|
"numero_documento": str(row.get("DOCIDENT") or "").strip(),
|
||||||
|
"tipo_documento": tipo_doc,
|
||||||
|
"telefono": str(row.get("TELEFONOS") or "").strip(),
|
||||||
|
"email": email,
|
||||||
|
"fecha_nacimiento": _fmt_fecha_iso(row.get("F_NACIMIENTO")),
|
||||||
|
"genero": genero,
|
||||||
|
"direccion": (row.get("DIRECCION") or "").strip(),
|
||||||
|
"ciudad": (row.get("NOM_CIUDAD") or "").strip(),
|
||||||
|
"origen": "rips",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_paciente(row: dict, url: str, api_key: str, client: Optional[httpx.AsyncClient] = None) -> dict:
|
||||||
|
"""Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}."""
|
||||||
|
payload = mapear_paciente(row)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Lab-Key": api_key,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if client:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
resp = await c.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
"ok": data.get("ok", False),
|
||||||
|
"action": data.get("action", ""),
|
||||||
|
"message": data.get("message") or data.get("error", ""),
|
||||||
|
"doc": payload["numero_documento"],
|
||||||
|
"nombre": payload["nombre_completo"],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"action": "error",
|
||||||
|
"message": str(e),
|
||||||
|
"doc": payload.get("numero_documento", ""),
|
||||||
|
"nombre": payload.get("nombre_completo", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_todos(rows: list, url: str, api_key: str, timeout: int = 30) -> dict:
|
||||||
|
"""Envía una lista de filas de pacientes. Retorna resumen {total, created, updated, errores}."""
|
||||||
|
resultado = {"total": len(rows), "created": 0, "updated": 0, "errores": 0, "detalle": []}
|
||||||
|
headers = {"Content-Type": "application/json", "X-Lab-Key": api_key}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
for row in rows:
|
||||||
|
r = await sync_paciente(row, url, api_key, client)
|
||||||
|
if r["ok"]:
|
||||||
|
if r["action"] == "created":
|
||||||
|
resultado["created"] += 1
|
||||||
|
else:
|
||||||
|
resultado["updated"] += 1
|
||||||
|
else:
|
||||||
|
resultado["errores"] += 1
|
||||||
|
resultado["detalle"].append(r)
|
||||||
|
|
||||||
|
return resultado
|
||||||
@@ -57,6 +57,9 @@
|
|||||||
<a href="/automation" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/automation' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
<a href="/automation" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/automation' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
||||||
<i class="fas fa-robot w-5 mr-2"></i> Automatización␍
|
<i class="fas fa-robot w-5 mr-2"></i> Automatización␍
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/pacientes" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/pacientes' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-users w-5 mr-2"></i> Pacientes <span class="ml-auto text-xs bg-green-500 text-white px-1.5 rounded-full">WA</span>
|
||||||
|
</a>
|
||||||
<a href="/test-rda" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/test-rda' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
<a href="/test-rda" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/test-rda' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
<i class="fas fa-flask w-5 mr-2"></i> Prueba RDA
|
<i class="fas fa-flask w-5 mr-2"></i> Prueba RDA
|
||||||
</a>␍
|
</a>␍
|
||||||
|
|||||||
@@ -154,6 +154,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-200">
|
||||||
|
|
||||||
|
<h4 class="font-medium text-gray-800">
|
||||||
|
<i class="fab fa-whatsapp mr-2 text-green-500"></i>WhatsApp Lab — Ingesta de Pacientes
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 -mt-2">
|
||||||
|
URL base del sistema WhatsApp Lab. Los pacientes de RIPS se sincronizarán a
|
||||||
|
<code class="bg-gray-100 px-1 rounded">/api/lab/ingest_paciente.php</code>.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">URL WhatsApp Lab</label>
|
||||||
|
<input type="text" name="config_whatsapp_url"
|
||||||
|
value="{{ configs|selectattr('key', 'equalto', 'whatsapp_url')|map(attribute='value')|first|default('') }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
placeholder="Ej: https://lab.ximena.com.co">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">API Key (X-Lab-Key)</label>
|
||||||
|
<input type="text" name="config_whatsapp_api_key"
|
||||||
|
value="{{ configs|selectattr('key', 'equalto', 'whatsapp_api_key')|map(attribute='value')|first|default('rips-lab-sync-2026') }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
placeholder="rips-lab-sync-2026">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" onclick="testWhatsapp()"
|
||||||
|
class="px-4 py-2 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 text-sm font-medium">
|
||||||
|
<i class="fab fa-whatsapp mr-1"></i> Probar conexión
|
||||||
|
</button>
|
||||||
|
<span id="wa-status" class="text-sm ml-3"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
|
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
|
||||||
<i class="fas fa-save mr-2"></i> Guardar Configuración
|
<i class="fas fa-save mr-2"></i> Guardar Configuración
|
||||||
@@ -202,5 +235,33 @@ async function testFirebird() {
|
|||||||
? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Conexión exitosa</span>'
|
? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Conexión exitosa</span>'
|
||||||
: '<span class="text-red-600"><i class="fas fa-times-circle"></i> ' + result.message + '</span>';
|
: '<span class="text-red-600"><i class="fas fa-times-circle"></i> ' + result.message + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testWhatsapp() {
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const status = document.getElementById('wa-status');
|
||||||
|
status.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Probando…';
|
||||||
|
|
||||||
|
const url = form.querySelector('[name="config_whatsapp_url"]').value.trim().replace(/\/$/, '');
|
||||||
|
const apiKey = form.querySelector('[name="config_whatsapp_api_key"]').value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
status.innerHTML = '<span class="text-red-600">Ingresa la URL primero</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${url}/api/lab/ingest_paciente.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Lab-Key': apiKey },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
// 422 = llegó al endpoint pero sin datos = conexión OK
|
||||||
|
status.innerHTML = (resp.status === 200 || resp.status === 422 || resp.status === 400)
|
||||||
|
? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Endpoint alcanzable</span>'
|
||||||
|
: `<span class="text-red-600"><i class="fas fa-times-circle"></i> HTTP ${resp.status}</span>`;
|
||||||
|
} catch (e) {
|
||||||
|
status.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle"></i> ${e.message}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Pacientes — Sync WhatsApp{% endblock %}
|
||||||
|
{% block header %}Pacientes — Sincronizar con WhatsApp Lab{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl space-y-6">
|
||||||
|
|
||||||
|
{% if not wa_configurado %}
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-yellow-800">WhatsApp Lab no está configurado</p>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1">
|
||||||
|
Ve a <a href="/config" class="underline">Configuración</a> y completa la
|
||||||
|
<strong>URL de WhatsApp Lab</strong> y la <strong>API Key</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Sync masiva -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-sync-alt mr-2 text-green-500"></i>Sincronizar pacientes a WhatsApp Lab
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Destino: <code class="bg-gray-100 px-1 rounded text-xs">{{ wa_url or "—" }}/api/lab/ingest_paciente.php</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
|
<form id="syncForm" class="space-y-4">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="checkbox" id="todosCheck" name="todos" value="1"
|
||||||
|
class="w-4 h-4 text-blue-600 rounded border-gray-300">
|
||||||
|
<label for="todosCheck" class="text-sm font-medium text-gray-700">
|
||||||
|
Sincronizar <strong>todos</strong> los pacientes (sin filtro de fecha)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fechaFields" class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
|
||||||
|
<input type="date" id="fecha_ini" name="fecha_ini"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
|
||||||
|
<input type="date" id="fecha_fin" name="fecha_fin"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onclick="runSync()"
|
||||||
|
id="btnSync"
|
||||||
|
{% if not wa_configurado %}disabled{% endif %}
|
||||||
|
class="px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm">
|
||||||
|
<i class="fas fa-upload mr-2"></i> Iniciar sincronización
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Progreso -->
|
||||||
|
<div id="syncProgress" class="hidden">
|
||||||
|
<div class="flex items-center gap-3 text-sm text-gray-600">
|
||||||
|
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
||||||
|
<span>Sincronizando pacientes con WhatsApp Lab…</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Esto puede tomar varios segundos según la cantidad de pacientes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultado -->
|
||||||
|
<div id="syncResult" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info sobre la API -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-info-circle mr-2 text-blue-400"></i>Cómo funciona
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 text-sm text-gray-600 space-y-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</span>
|
||||||
|
<p>Se consultan los pacientes en Firebird (<code class="bg-gray-100 px-1 rounded">PACIENTE</code>)
|
||||||
|
filtrados por rango de fecha de recepción (o todos si marcas la opción).</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</span>
|
||||||
|
<p>Cada paciente se envía a WhatsApp Lab via <code class="bg-gray-100 px-1 rounded">POST /api/lab/ingest_paciente.php</code>
|
||||||
|
con el header <code class="bg-gray-100 px-1 rounded">X-Lab-Key</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</span>
|
||||||
|
<p>El endpoint hace <strong>UPSERT</strong>: si el paciente ya existe por número de documento lo actualiza,
|
||||||
|
si no existe lo crea en <code class="bg-gray-100 px-1 rounded">lab_pacientes</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="w-6 h-6 bg-green-100 text-green-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">✓</span>
|
||||||
|
<p>La sync también se dispara <strong>automáticamente</strong> al enviar un Tercero individual
|
||||||
|
o al ejecutar la Automatización RIPS.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('todosCheck').addEventListener('change', function () {
|
||||||
|
document.getElementById('fechaFields').style.display = this.checked ? 'none' : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runSync() {
|
||||||
|
const btn = document.getElementById('btnSync');
|
||||||
|
const prog = document.getElementById('syncProgress');
|
||||||
|
const result = document.getElementById('syncResult');
|
||||||
|
const form = document.getElementById('syncForm');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
prog.classList.remove('hidden');
|
||||||
|
result.classList.add('hidden');
|
||||||
|
result.innerHTML = '';
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/pacientes/sync-all', { method: 'POST', body: data });
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
prog.classList.add('hidden');
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (json.success) {
|
||||||
|
const errColor = json.errores > 0 ? 'text-red-600' : 'text-gray-500';
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||||
|
<p class="font-medium text-green-800"><i class="fas fa-check-circle mr-2"></i>${json.message}</p>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-center text-sm">
|
||||||
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
||||||
|
<p class="text-2xl font-bold text-gray-800">${json.total}</p>
|
||||||
|
<p class="text-gray-500">Total</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
||||||
|
<p class="text-2xl font-bold text-blue-600">${json.created}</p>
|
||||||
|
<p class="text-gray-500">Nuevos</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
||||||
|
<p class="text-2xl font-bold text-green-600">${json.updated}</p>
|
||||||
|
<p class="text-gray-500">Actualizados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${json.errores > 0 ? `<p class="text-sm text-red-600"><i class="fas fa-exclamation-triangle mr-1"></i>${json.errores} errores — revisa los detalles abajo.</p>` : ''}
|
||||||
|
${renderDetalle(json.detalle || [])}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p class="font-medium text-red-800"><i class="fas fa-times-circle mr-2"></i>${json.message}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
prog.classList.add('hidden');
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
result.innerHTML = `<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">Error de conexión: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetalle(detalle) {
|
||||||
|
const errores = detalle.filter(d => !d.ok);
|
||||||
|
if (!errores.length) return '';
|
||||||
|
return `
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="text-sm text-red-600 cursor-pointer">Ver ${errores.length} errores</summary>
|
||||||
|
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
${errores.map(d => `
|
||||||
|
<div class="flex gap-2 text-xs text-red-700 bg-red-50 rounded px-2 py-1">
|
||||||
|
<span class="font-mono">${d.doc || '—'}</span>
|
||||||
|
<span class="text-gray-500">${d.nombre || ''}</span>
|
||||||
|
<span class="ml-auto">${d.message || ''}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -65,7 +65,7 @@ async def root():
|
|||||||
return RedirectResponse(url="/dashboard")
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
|
||||||
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb
|
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
@@ -77,6 +77,7 @@ app.include_router(logs.router)
|
|||||||
app.include_router(automation.router)
|
app.include_router(automation.router)
|
||||||
app.include_router(test_rda.router)
|
app.include_router(test_rda.router)
|
||||||
app.include_router(debug_fb.router)
|
app.include_router(debug_fb.router)
|
||||||
|
app.include_router(pacientes.router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user