Compare commits
154
Commits
fb81ddf263
..
main
+119
-2
@@ -14,6 +14,10 @@ def get_connection():
|
|||||||
|
|
||||||
|
|
||||||
def _migrate(conn):
|
def _migrate(conn):
|
||||||
|
sync_cols = {r[1] for r in conn.execute("PRAGMA table_info(sync_wa_log)")}
|
||||||
|
if "detalle_json" not in sync_cols:
|
||||||
|
conn.execute("ALTER TABLE sync_wa_log ADD COLUMN detalle_json TEXT")
|
||||||
|
|
||||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(envios)")}
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(envios)")}
|
||||||
if "idrecepcion" not in cols:
|
if "idrecepcion" not in cols:
|
||||||
conn.execute("ALTER TABLE envios ADD COLUMN idrecepcion INTEGER")
|
conn.execute("ALTER TABLE envios ADD COLUMN idrecepcion INTEGER")
|
||||||
@@ -21,6 +25,104 @@ def _migrate(conn):
|
|||||||
conn.execute("ALTER TABLE envios ADD COLUMN contrato TEXT")
|
conn.execute("ALTER TABLE envios ADD COLUMN contrato TEXT")
|
||||||
if "mensaje_tns" not in cols:
|
if "mensaje_tns" not in cols:
|
||||||
conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT")
|
conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT")
|
||||||
|
if "cedula" not in cols:
|
||||||
|
conn.execute("ALTER TABLE envios ADD COLUMN cedula TEXT")
|
||||||
|
|
||||||
|
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
if "activity_log" not in tables:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE activity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
accion TEXT NOT NULL,
|
||||||
|
detalle TEXT NOT NULL DEFAULT '',
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
if "contratos" not in tables:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE contratos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
numero_contrato TEXT UNIQUE NOT NULL,
|
||||||
|
nit_empresa TEXT NOT NULL DEFAULT '',
|
||||||
|
tipo_usuario TEXT NOT NULL,
|
||||||
|
descripcion TEXT NOT NULL DEFAULT '',
|
||||||
|
excluir INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
contrato_cols = {r[1] for r in conn.execute("PRAGMA table_info(contratos)")}
|
||||||
|
if "excluir" not in contrato_cols:
|
||||||
|
conn.execute("ALTER TABLE contratos ADD COLUMN excluir INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "sin_contrato" not in contrato_cols:
|
||||||
|
conn.execute("ALTER TABLE contratos ADD COLUMN sin_contrato INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "excluir_ventas" not in contrato_cols:
|
||||||
|
conn.execute("ALTER TABLE contratos ADD COLUMN excluir_ventas INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if "cod_forma_pago" not in contrato_cols:
|
||||||
|
conn.execute("ALTER TABLE contratos ADD COLUMN cod_forma_pago TEXT NOT NULL DEFAULT ''")
|
||||||
|
# Ampliar CHECK constraint de queries para incluir 'ventas'
|
||||||
|
q_sql = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='queries'").fetchone()
|
||||||
|
if q_sql and "'ventas'" not in q_sql[0]:
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
conn.execute("""CREATE TABLE queries_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion','ventas')),
|
||||||
|
query_text TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)""")
|
||||||
|
conn.execute("INSERT INTO queries_new SELECT * FROM queries")
|
||||||
|
conn.execute("DROP TABLE queries")
|
||||||
|
conn.execute("ALTER TABLE queries_new RENAME TO queries")
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Ampliar CHECK constraint de envios para incluir 'ventas'
|
||||||
|
e_sql = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='envios'").fetchone()
|
||||||
|
if e_sql and "'ventas'" not in e_sql[0]:
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
conn.execute("""CREATE TABLE envios_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion','ventas')),
|
||||||
|
factura TEXT,
|
||||||
|
fecha_inicio TEXT,
|
||||||
|
fecha_fin TEXT,
|
||||||
|
pacientes_count INTEGER DEFAULT 0,
|
||||||
|
servicios_count INTEGER DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pendiente',
|
||||||
|
json_enviado TEXT,
|
||||||
|
respuesta_api TEXT,
|
||||||
|
codigo_cuv TEXT,
|
||||||
|
idrecepcion INTEGER,
|
||||||
|
contrato TEXT,
|
||||||
|
mensaje_tns TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)""")
|
||||||
|
conn.execute("INSERT INTO envios_new SELECT id,user_id,tipo,factura,fecha_inicio,fecha_fin,"
|
||||||
|
"pacientes_count,servicios_count,status,json_enviado,respuesta_api,codigo_cuv,"
|
||||||
|
"idrecepcion,contrato,mensaje_tns,created_at FROM envios")
|
||||||
|
conn.execute("DROP TABLE envios")
|
||||||
|
conn.execute("ALTER TABLE envios_new RENAME TO envios")
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "codigos_pago" not in tables:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE codigos_pago (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
codigo TEXT UNIQUE NOT NULL,
|
||||||
|
nombre TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
for codigo, nombre in [("CIAC", "Contado inmediato"), ("CR", "Crédito"), ("MU", "Mixto")]:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO codigos_pago (codigo, nombre) VALUES (?,?)", (codigo, nombre))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +146,7 @@ def init_db():
|
|||||||
CREATE TABLE IF NOT EXISTS queries (
|
CREATE TABLE IF NOT EXISTS queries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion')),
|
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion','ventas')),
|
||||||
query_text TEXT NOT NULL,
|
query_text TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -63,7 +165,7 @@ def init_db():
|
|||||||
CREATE TABLE IF NOT EXISTS envios (
|
CREATE TABLE IF NOT EXISTS envios (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER,
|
user_id INTEGER,
|
||||||
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion')),
|
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion','ventas')),
|
||||||
factura TEXT,
|
factura TEXT,
|
||||||
fecha_inicio TEXT,
|
fecha_inicio TEXT,
|
||||||
fecha_fin TEXT,
|
fecha_fin TEXT,
|
||||||
@@ -76,6 +178,21 @@ def init_db():
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_wa_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
origen TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
modo TEXT NOT NULL DEFAULT 'insertar',
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created INTEGER NOT NULL DEFAULT 0,
|
||||||
|
skipped INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errores INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errores_det TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_migrate(conn)
|
_migrate(conn)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
|||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from app.database import get_connection
|
from app.database import get_connection
|
||||||
from app.auth import hash_password, verify_password, create_token, get_current_user
|
from app.auth import hash_password, verify_password, create_token, get_current_user
|
||||||
|
from app.utils.activity import log_activity, get_ip
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -62,10 +63,12 @@ async def login(
|
|||||||
"SELECT * FROM users WHERE username = ?", (username,)
|
"SELECT * FROM users WHERE username = ?", (username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not user or not verify_password(password, user["password_hash"]):
|
if not user or not verify_password(password, user["password_hash"]):
|
||||||
|
log_activity(0, username, "login_fallido", f"Intento fallido desde {get_ip(request)}", get_ip(request))
|
||||||
return request.app.state.templates.TemplateResponse("login.html", {
|
return request.app.state.templates.TemplateResponse("login.html", {
|
||||||
"request": request, "error": "Usuario o contraseña incorrectos"
|
"request": request, "error": "Usuario o contraseña incorrectos"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
log_activity(user["id"], user["username"], "login", f"Inicio de sesión", get_ip(request))
|
||||||
token = create_token(user["id"], user["username"])
|
token = create_token(user["id"], user["username"])
|
||||||
resp = RedirectResponse("/dashboard", status_code=302)
|
resp = RedirectResponse("/dashboard", status_code=302)
|
||||||
resp.set_cookie(key="token", value=token, httponly=True)
|
resp.set_cookie(key="token", value=token, httponly=True)
|
||||||
|
|||||||
+820
-74
File diff suppressed because it is too large
Load Diff
@@ -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,323 @@
|
|||||||
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.utils.activity import log_activity, get_ip
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contratos", tags=["contratos"])
|
||||||
|
|
||||||
|
# (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir_rda, sin_contrato, excluir_ventas)
|
||||||
|
# excluir_ventas=0 → activo para Ventas/Crear | excluir_ventas=1 → bloqueado para ventas
|
||||||
|
_SEED = [
|
||||||
|
("001", "860078828", "11", "EPS SANITAS", 0, 0, 1),
|
||||||
|
("002", "830054904", "11", "EPS COMPENSAR", 0, 0, 1),
|
||||||
|
("003", "900278729", "12", "PARTICULAR", 0, 0, 1),
|
||||||
|
("004", "800106339", "11", "EPS NUEVA EPS", 0, 0, 1),
|
||||||
|
("005", "800153424", "11", "EPS SURA", 0, 0, 1),
|
||||||
|
("006", "805009741", "11", "EPS COOMEVA", 0, 0, 1),
|
||||||
|
("007", "860002183", "11", "EPS FAMISANAR", 0, 0, 1),
|
||||||
|
("008", "860002503", "11", "EPS CRUZ BLANCA", 0, 0, 1),
|
||||||
|
("009", "860027404", "11", "EPS COLSANITAS", 0, 0, 1),
|
||||||
|
("010", "860039988", "11", "EPS SALUD TOTAL", 0, 0, 1),
|
||||||
|
("011", "890903790", "11", "EPS SAVIA SALUD", 0, 0, 1),
|
||||||
|
("012", "900178724", "11", "EPS MUTUAL SER", 0, 0, 1),
|
||||||
|
("034", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
|
||||||
|
("035", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
|
||||||
|
("036", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
|
||||||
|
("20062026", "800182856", "01", "SUBSIDIADO", 0, 0, 1),
|
||||||
|
("CW225489", "899999068", "07", "POLIZA / SEGURO", 0, 0, 1),
|
||||||
|
# Contratos excluidos del envío RDA
|
||||||
|
("013", "", "11", "MEDILAVORO S.A.S", 0, 0, 1),
|
||||||
|
("014", "", "11", "LABORATORIO UROCLINICO",0, 0, 1),
|
||||||
|
("015", "", "11", "ANDRES AFANADOR VILLAMIZAR", 1, 0, 1),
|
||||||
|
("016", "", "11", "OMAR FERNANDO RIBERO GOMEZ", 1, 0, 1),
|
||||||
|
("017", "", "11", "CLAUDIA BELEN JULIO SEPULVEDA", 1, 0, 1),
|
||||||
|
("018", "", "11", "LABORATORIO MICROBIOLOGICO - MARGIE OJEDA", 1, 0, 1),
|
||||||
|
("019", "", "11", "LABORATORIO TOXICOLOGICO - MARTHA MORALES", 1, 0, 1),
|
||||||
|
("020", "", "11", "MARTHA LUCIA GALLARDO", 1, 0, 1),
|
||||||
|
("021", "", "11", "LABORATORIO VILMA OROZCO AYALA", 1, 0, 1),
|
||||||
|
("022", "", "11", "CLINICA SAN JOSE DE CUCUTA", 1, 0, 1),
|
||||||
|
("023", "", "11", "URONORTE S.A", 1, 0, 1),
|
||||||
|
("024", "", "11", "LABORATORIO CLINICO BIOLAB S.A.S", 1, 0, 1),
|
||||||
|
("025", "", "11", "JOEL LEONARDO CARRILLO CORREDOR", 1, 0, 1),
|
||||||
|
("026", "", "11", "ROLANDO IVAN PENARANDA DEVIA", 1, 0, 1),
|
||||||
|
("027", "", "11", "JOSE WILMER GARCIA CALDERON", 1, 0, 1),
|
||||||
|
("028", "", "11", "ONCOMEDICAL IPS S.A.S", 1, 0, 1),
|
||||||
|
("029", "", "11", "GENETIX S.A.S", 1, 0, 1),
|
||||||
|
("030", "", "11", "NORFETUS S.A.S", 1, 0, 1),
|
||||||
|
("031", "", "11", "TBTB GLOBAL LAB S.A.S", 1, 0, 1),
|
||||||
|
("032", "", "11", "COLGENES S.A.S", 1, 0, 1),
|
||||||
|
("033", "", "11", "IPS FIGURAS SPA CUCUTA S.A.S", 1, 0, 1),
|
||||||
|
("037", "", "11", "CLINICA URGENCIAS LA MERCED", 1, 0, 1),
|
||||||
|
("038", "", "11", "CLINICA COLSANITAS S.A.", 1, 0, 1),
|
||||||
|
("039", "", "11", "GOMEZ GIL JOSE JESUS", 1, 0, 1),
|
||||||
|
("040", "", "11", "MARTHA LILIANA SALGAR GALLEGO", 1, 1, 0),
|
||||||
|
("041", "", "11", "GENCELL PHARMA S.A.S", 1, 0, 1),
|
||||||
|
("042", "", "07", "AXA COLPATRIA HYC", 1, 0, 1),
|
||||||
|
("050", "", "12", "PARTICULAR COLEGAS", 0, 0, 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_defaults():
|
||||||
|
conn = get_connection()
|
||||||
|
for nc, nit, tu, desc, excluir, sin_contrato, excluir_ventas in _SEED:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT id FROM contratos WHERE numero_contrato = ?", (nc,)
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato, excluir_ventas) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(nc, nit, tu, desc, excluir, sin_contrato, excluir_ventas),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Solo actualiza datos descriptivos; los toggles (excluir/sin_contrato/excluir_ventas)
|
||||||
|
# se conservan tal como el usuario los dejó desde la UI
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contratos SET nit_empresa=?, tipo_usuario=?, descripcion=? WHERE numero_contrato=?",
|
||||||
|
(nit, tu, desc, nc),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def load_contrato_map() -> dict:
|
||||||
|
"""Devuelve {numero_contrato: tipo_usuario} incluyendo variante sin ceros a la izquierda."""
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT numero_contrato, tipo_usuario FROM contratos").fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
nc = r["numero_contrato"].strip()
|
||||||
|
tu = r["tipo_usuario"].strip()
|
||||||
|
result[nc] = tu
|
||||||
|
nc_s = nc.lstrip("0") or nc
|
||||||
|
if nc_s != nc:
|
||||||
|
result[nc_s] = tu
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_sin_contrato_set() -> set:
|
||||||
|
"""Devuelve set de numero_contrato marcados como sin_contrato=1 (se envían con numeroContrato: null)."""
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE sin_contrato=1").fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = set()
|
||||||
|
for r in rows:
|
||||||
|
nc = r["numero_contrato"].strip()
|
||||||
|
result.add(nc)
|
||||||
|
nc_s = nc.lstrip("0") or nc
|
||||||
|
if nc_s != nc:
|
||||||
|
result.add(nc_s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_excluded_set() -> set:
|
||||||
|
"""Devuelve set de numero_contrato marcados como excluir=1 (bloqueados para RDA)."""
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE excluir=1").fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = set()
|
||||||
|
for r in rows:
|
||||||
|
nc = r["numero_contrato"].strip()
|
||||||
|
result.add(nc)
|
||||||
|
nc_s = nc.lstrip("0") or nc
|
||||||
|
if nc_s != nc:
|
||||||
|
result.add(nc_s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_excluded_ventas_set() -> set:
|
||||||
|
"""Devuelve set de numero_contrato marcados como excluir_ventas=1 (bloqueados para Ventas)."""
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE excluir_ventas=1").fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = set()
|
||||||
|
for r in rows:
|
||||||
|
nc = r["numero_contrato"].strip()
|
||||||
|
result.add(nc)
|
||||||
|
nc_s = nc.lstrip("0") or nc
|
||||||
|
if nc_s != nc:
|
||||||
|
result.add(nc_s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_forma_pago_map() -> dict:
|
||||||
|
"""Devuelve {numero_contrato: cod_forma_pago} para contratos con forma de pago asignada."""
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT numero_contrato, cod_forma_pago FROM contratos WHERE cod_forma_pago != ''").fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
nc = r["numero_contrato"].strip()
|
||||||
|
result[nc] = r["cod_forma_pago"].strip()
|
||||||
|
nc_s = nc.lstrip("0") or nc
|
||||||
|
if nc_s != nc:
|
||||||
|
result[nc_s] = r["cod_forma_pago"].strip()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def contratos_page(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("SELECT * FROM contratos ORDER BY numero_contrato").fetchall()
|
||||||
|
codigos_pago = conn.execute("SELECT * FROM codigos_pago ORDER BY codigo").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return request.app.state.templates.TemplateResponse("contratos.html", {
|
||||||
|
"request": request, "user": user, "contratos": rows,
|
||||||
|
"codigos_pago": codigos_pago,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def contrato_create(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
numero_contrato: str = Form(...),
|
||||||
|
nit_empresa: str = Form(""),
|
||||||
|
tipo_usuario: str = Form(...),
|
||||||
|
descripcion: str = Form(""),
|
||||||
|
excluir: str = Form("0"),
|
||||||
|
sin_contrato: str = Form("0"),
|
||||||
|
excluir_ventas: str = Form("0"),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato, excluir_ventas) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_creado",
|
||||||
|
f"Contrato {numero_contrato.strip()} — {descripcion.strip()}", get_ip(request))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/update/{contrato_id}")
|
||||||
|
async def contrato_update(
|
||||||
|
contrato_id: int,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
numero_contrato: str = Form(...),
|
||||||
|
nit_empresa: str = Form(""),
|
||||||
|
tipo_usuario: str = Form(...),
|
||||||
|
descripcion: str = Form(""),
|
||||||
|
excluir: str = Form("0"),
|
||||||
|
sin_contrato: str = Form("0"),
|
||||||
|
excluir_ventas: str = Form("0"),
|
||||||
|
cod_forma_pago: str = Form(""),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=?, sin_contrato=?, excluir_ventas=?, cod_forma_pago=? WHERE id=?",
|
||||||
|
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas), cod_forma_pago.strip(), contrato_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_editado",
|
||||||
|
f"Contrato {numero_contrato.strip()} — {descripcion.strip()}", get_ip(request))
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle-excluir/{contrato_id}")
|
||||||
|
async def toggle_excluir(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contratos SET excluir = CASE WHEN excluir=1 THEN 0 ELSE 1 END WHERE id=?",
|
||||||
|
(contrato_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT numero_contrato, excluir FROM contratos WHERE id=?", (contrato_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
estado = "excluido" if row["excluir"] else "activado"
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_toggle",
|
||||||
|
f"Contrato {row['numero_contrato']} → {estado}", get_ip(request))
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle-sin-contrato/{contrato_id}")
|
||||||
|
async def toggle_sin_contrato(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contratos SET sin_contrato = CASE WHEN sin_contrato=1 THEN 0 ELSE 1 END WHERE id=?",
|
||||||
|
(contrato_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT numero_contrato, sin_contrato FROM contratos WHERE id=?", (contrato_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_toggle_sin_contrato",
|
||||||
|
f"Contrato {row['numero_contrato']} sin_contrato→{row['sin_contrato']}", get_ip(request))
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle-excluir-ventas/{contrato_id}")
|
||||||
|
async def toggle_excluir_ventas(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contratos SET excluir_ventas = CASE WHEN excluir_ventas=1 THEN 0 ELSE 1 END WHERE id=?",
|
||||||
|
(contrato_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT numero_contrato, excluir_ventas FROM contratos WHERE id=?", (contrato_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_toggle_ventas",
|
||||||
|
f"Contrato {row['numero_contrato']} excluir_ventas→{row['excluir_ventas']}", get_ip(request))
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-forma-pago/{contrato_id}")
|
||||||
|
async def set_forma_pago(
|
||||||
|
contrato_id: int, request: Request, user: dict = Depends(get_current_user),
|
||||||
|
cod_forma_pago: str = Form(""),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("UPDATE contratos SET cod_forma_pago=? WHERE id=?", (cod_forma_pago.strip(), contrato_id))
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT numero_contrato FROM contratos WHERE id=?", (contrato_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
log_activity(user["user_id"], user["username"], "contrato_forma_pago",
|
||||||
|
f"Contrato {row['numero_contrato']} forma_pago→{cod_forma_pago.strip() or '(auto)'}",
|
||||||
|
get_ip(request))
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codigos-pago/create")
|
||||||
|
async def codigo_pago_create(
|
||||||
|
request: Request, user: dict = Depends(get_current_user),
|
||||||
|
codigo: str = Form(...), nombre: str = Form(""),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("INSERT INTO codigos_pago (codigo, nombre) VALUES (?,?)",
|
||||||
|
(codigo.strip().upper(), nombre.strip()))
|
||||||
|
conn.commit()
|
||||||
|
log_activity(user["user_id"], user["username"], "codigo_pago_creado",
|
||||||
|
f"{codigo.strip().upper()} — {nombre.strip()}", get_ip(request))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codigos-pago/delete/{cp_id}")
|
||||||
|
async def codigo_pago_delete(cp_id: int, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("DELETE FROM codigos_pago WHERE id=?", (cp_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/delete/{contrato_id}")
|
||||||
|
async def contrato_delete(contrato_id: int, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("DELETE FROM contratos WHERE id = ?", (contrato_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse("/contratos", status_code=302)
|
||||||
+415
-16
@@ -1,5 +1,5 @@
|
|||||||
import traceback
|
import traceback
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from app.database import get_connection
|
from app.database import get_connection
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
@@ -8,6 +8,389 @@ from app.services.firebird_service import get_firebird_from_config
|
|||||||
router = APIRouter(prefix="/debug-fb", tags=["debug"])
|
router = APIRouter(prefix="/debug-fb", tags=["debug"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnosticos")
|
||||||
|
async def listar_diagnosticos(
|
||||||
|
q: str = Query(default="", description="Filtro por código o concepto"),
|
||||||
|
limit: int = Query(default=100, ge=1, le=5000),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Lista diagnósticos CIE-10 desde Firebird DIAGNOSTICO."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": msg})
|
||||||
|
|
||||||
|
# Estructura de la tabla
|
||||||
|
ok_c, _, cols = fb.execute_query(
|
||||||
|
"SELECT f.RDB$FIELD_NAME "
|
||||||
|
"FROM RDB$RELATION_FIELDS f "
|
||||||
|
"WHERE TRIM(f.RDB$RELATION_NAME) = 'DIAGNOSTICO' "
|
||||||
|
"ORDER BY f.RDB$FIELD_POSITION", None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Conteo total
|
||||||
|
ok_n, _, cnt = fb.execute_query(
|
||||||
|
"SELECT COUNT(*) AS TOTAL FROM DIAGNOSTICO", None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registros
|
||||||
|
if q:
|
||||||
|
q_up = q.upper()
|
||||||
|
ok_r, _, rows = fb.execute_query(
|
||||||
|
f"SELECT FIRST {limit} TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
|
||||||
|
f"FROM DIAGNOSTICO d "
|
||||||
|
f"WHERE UPPER(TRIM(d.COD_DIAG)) CONTAINING '{q_up}' "
|
||||||
|
f" OR UPPER(TRIM(d.CONCEPTO)) CONTAINING '{q_up}' "
|
||||||
|
f"ORDER BY d.COD_DIAG", None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok_r, _, rows = fb.execute_query(
|
||||||
|
f"SELECT FIRST {limit} TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
|
||||||
|
f"FROM DIAGNOSTICO d ORDER BY d.COD_DIAG", None
|
||||||
|
)
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"total": cnt[0]["TOTAL"] if ok_n and cnt else "?",
|
||||||
|
"columnas": [list(c.values())[0].strip() for c in cols] if ok_c and cols else [],
|
||||||
|
"limite": limit,
|
||||||
|
"filtro": q or None,
|
||||||
|
"rows": [{"cod": r["COD_DIAG"], "concepto": r["CONCEPTO"]} for r in (rows or [])],
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recepcion")
|
||||||
|
async def debug_recepcion(user: dict = Depends(get_current_user)):
|
||||||
|
"""Diagnóstico: columnas de RECEPCION, tabla EMPRESA y link a tarifa."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": msg})
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Todas las columnas de RECEPCION
|
||||||
|
ok_c, _, cols = fb.execute_query(
|
||||||
|
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL "
|
||||||
|
"FROM RDB$RELATION_FIELDS f "
|
||||||
|
"WHERE TRIM(f.RDB$RELATION_NAME) = 'RECEPCION' "
|
||||||
|
"ORDER BY f.RDB$FIELD_POSITION", None
|
||||||
|
)
|
||||||
|
result["columnas_recepcion"] = [r["COL"] for r in (cols or [])] if ok_c else []
|
||||||
|
|
||||||
|
# Tablas con EMPRESA o CONVENIO en el nombre
|
||||||
|
ok_e, _, tabs = fb.execute_query(
|
||||||
|
"SELECT TRIM(r.RDB$RELATION_NAME) AS TABLA FROM RDB$RELATIONS r "
|
||||||
|
"WHERE r.RDB$SYSTEM_FLAG = 0 "
|
||||||
|
" AND (UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'EMPRESA' "
|
||||||
|
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'CONVENIO') "
|
||||||
|
"ORDER BY TABLA", None
|
||||||
|
)
|
||||||
|
result["tablas_empresa_convenio"] = [r["TABLA"] for r in (tabs or [])] if ok_e else []
|
||||||
|
|
||||||
|
# Columnas + muestra de cada tabla encontrada
|
||||||
|
for t in result["tablas_empresa_convenio"][:4]:
|
||||||
|
ok_tc, _, tcols = fb.execute_query(
|
||||||
|
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL FROM RDB$RELATION_FIELDS f "
|
||||||
|
f"WHERE TRIM(f.RDB$RELATION_NAME) = '{t}' ORDER BY f.RDB$FIELD_POSITION", None
|
||||||
|
)
|
||||||
|
ok_tr, _, trows = fb.execute_query(f"SELECT FIRST 2 * FROM {t}", None)
|
||||||
|
result[f"tabla_{t}"] = {
|
||||||
|
"columnas": [c["COL"] for c in (tcols or [])] if ok_tc else [],
|
||||||
|
"muestra": list(trows or []) if ok_tr else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1 recepción completa para ver si tiene COD_TARIFA o NIT_EMPRESA
|
||||||
|
ok_r, _, rec = fb.execute_query(
|
||||||
|
"SELECT FIRST 1 r.* FROM RECEPCION r ORDER BY r.IDRECEPCION DESC", None
|
||||||
|
)
|
||||||
|
result["recepcion_completa"] = dict(rec[0]) if ok_r and rec else {}
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
return JSONResponse(result)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tarifas")
|
||||||
|
async def debug_tarifas(user: dict = Depends(get_current_user)):
|
||||||
|
"""Explora tablas de tarifas/precios en Firebird."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": msg})
|
||||||
|
|
||||||
|
# Todas las tablas que contengan TARIF, PRECIO, ARANCEL, CONVENIO, ISS en el nombre
|
||||||
|
ok_t, _, tablas = fb.execute_query(
|
||||||
|
"SELECT TRIM(r.RDB$RELATION_NAME) AS TABLA "
|
||||||
|
"FROM RDB$RELATIONS r "
|
||||||
|
"WHERE r.RDB$SYSTEM_FLAG = 0 "
|
||||||
|
" AND (UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'TARIF' "
|
||||||
|
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'PRECIO' "
|
||||||
|
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'ARANCEL' "
|
||||||
|
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'CONVENIO' "
|
||||||
|
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'VALOR') "
|
||||||
|
"ORDER BY TABLA", None
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {"tablas_encontradas": [r["TABLA"] for r in (tablas or [])] if ok_t else []}
|
||||||
|
|
||||||
|
# Para cada tabla encontrada: columnas + 3 filas de muestra
|
||||||
|
for t in result["tablas_encontradas"][:6]:
|
||||||
|
ok_c, _, cols = fb.execute_query(
|
||||||
|
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL "
|
||||||
|
"FROM RDB$RELATION_FIELDS f "
|
||||||
|
f"WHERE TRIM(f.RDB$RELATION_NAME) = '{t}' "
|
||||||
|
"ORDER BY f.RDB$FIELD_POSITION", None
|
||||||
|
)
|
||||||
|
ok_r, _, rows = fb.execute_query(
|
||||||
|
f"SELECT FIRST 3 * FROM {t}", None
|
||||||
|
)
|
||||||
|
ok_n, _, cnt = fb.execute_query(
|
||||||
|
f"SELECT COUNT(*) AS N FROM {t}", None
|
||||||
|
)
|
||||||
|
result[t] = {
|
||||||
|
"columnas": [c["COL"] for c in (cols or [])] if ok_c else [],
|
||||||
|
"total": cnt[0]["N"] if ok_n and cnt else "?",
|
||||||
|
"muestra": list(rows or []) if ok_r else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
return JSONResponse(result)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cmxc")
|
||||||
|
async def debug_cmxc(
|
||||||
|
fecha: str = Query(default="", description="YYYY-MM-DD, vacío = hoy"),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Consulta directa a Firebird: recepciones CMXC para la fecha dada."""
|
||||||
|
from datetime import date as date_cls
|
||||||
|
if not fecha:
|
||||||
|
fecha = date_cls.today().isoformat()
|
||||||
|
fecha_ini = f"{fecha} 00:00:00"
|
||||||
|
fecha_fin = f"{fecha} 23:59:59"
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": msg})
|
||||||
|
|
||||||
|
ok1, _, rows = fb.execute_query("""
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
|
||||||
|
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE, TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO, TRIM(e.NOMBRE) AS NOM_EMPRESA,
|
||||||
|
(SELECT COUNT(*) FROM RELACION rel WHERE rel.IDRECEPCION = r.IDRECEPCION) AS N_EXAMENES
|
||||||
|
FROM RECEPCION r
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
|
||||||
|
AND r.PREFIJO = 'CMXC'
|
||||||
|
ORDER BY r.IDRECEPCION
|
||||||
|
""", {"fi": fecha_ini, "ff": fecha_fin})
|
||||||
|
|
||||||
|
ok2, _, total = fb.execute_query("""
|
||||||
|
SELECT COUNT(*) AS N FROM RECEPCION r
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
|
||||||
|
""", {"fi": fecha_ini, "ff": fecha_fin})
|
||||||
|
|
||||||
|
# Correr el mismo _SQL_VENTAS que usa el preview
|
||||||
|
ok3, err3, rows_vta = fb.execute_query("""
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
|
||||||
|
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
AND r.PREFIJO = 'CMXC'
|
||||||
|
ORDER BY r.IDRECEPCION
|
||||||
|
""", {"fecha_ini": fecha_ini, "fecha_fin": fecha_fin})
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
# Estado de contratos en SQLite (excluir_ventas)
|
||||||
|
conn2 = get_connection()
|
||||||
|
contratos_excluidos_vta = [
|
||||||
|
r["numero_contrato"]
|
||||||
|
for r in conn2.execute(
|
||||||
|
"SELECT numero_contrato FROM contratos WHERE excluir_ventas=1 ORDER BY numero_contrato"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
contratos_activos_vta = [
|
||||||
|
r["numero_contrato"]
|
||||||
|
for r in conn2.execute(
|
||||||
|
"SELECT numero_contrato FROM contratos WHERE excluir_ventas=0 ORDER BY numero_contrato"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
conn2.close()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"fecha": fecha,
|
||||||
|
"total_recepcion_del_dia": total[0]["N"] if ok2 and total else "?",
|
||||||
|
"cmxc_count": len(rows or []),
|
||||||
|
"cmxc_rows": list(rows or []),
|
||||||
|
"sql_ventas_ok": ok3,
|
||||||
|
"sql_ventas_error": err3 if not ok3 else None,
|
||||||
|
"sql_ventas_count": len(rows_vta or []),
|
||||||
|
"sql_ventas_rows": list(rows_vta or [])[:10],
|
||||||
|
"contratos_excluidos_ventas": contratos_excluidos_vta,
|
||||||
|
"contratos_activos_ventas": contratos_activos_vta,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview-ventas")
|
||||||
|
async def debug_preview_ventas(
|
||||||
|
fecha: str = Query(default="", description="YYYY-MM-DD"),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Corre la lógica completa de ventas del preview y muestra cada paso."""
|
||||||
|
from datetime import date as date_cls
|
||||||
|
from collections import defaultdict
|
||||||
|
if not fecha:
|
||||||
|
fecha = date_cls.today().isoformat()
|
||||||
|
fecha_ini = f"{fecha} 00:00:00"
|
||||||
|
fecha_fin = f"{fecha} 23:59:59"
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
excluded_ventas = set(
|
||||||
|
r["numero_contrato"]
|
||||||
|
for r in conn.execute("SELECT numero_contrato FROM contratos WHERE excluir_ventas=1").fetchall()
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": msg})
|
||||||
|
|
||||||
|
ok_vta, err_vta, rows_vta = fb.execute_query("""
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
|
||||||
|
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
AND r.PREFIJO = 'CMXC'
|
||||||
|
ORDER BY r.IDRECEPCION
|
||||||
|
""", {"fecha_ini": fecha_ini, "fecha_fin": fecha_fin})
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
if not ok_vta:
|
||||||
|
return JSONResponse({"ok": False, "sql_error": err_vta})
|
||||||
|
|
||||||
|
# Agrupar por NUM_FACTURA
|
||||||
|
grupos: dict = defaultdict(list)
|
||||||
|
for row in (rows_vta or []):
|
||||||
|
key = str(row.get("NUM_FACTURA") or "")
|
||||||
|
grupos[key].append(dict(row))
|
||||||
|
|
||||||
|
pasos = []
|
||||||
|
grupos_finales = {}
|
||||||
|
for k, v in grupos.items():
|
||||||
|
paso = {"key": k, "filas": len(v),
|
||||||
|
"num_factura": v[0].get("NUM_FACTURA"),
|
||||||
|
"codcontrato": v[0].get("CODCONTRATO"),
|
||||||
|
"es_lista": isinstance(v, list),
|
||||||
|
"motivo_exclusion": None}
|
||||||
|
if not isinstance(v, list) or not v:
|
||||||
|
paso["motivo_exclusion"] = "no_es_lista_o_vacia"
|
||||||
|
elif int(v[0].get("NUM_FACTURA") or 0) == 0:
|
||||||
|
paso["motivo_exclusion"] = "num_factura_cero"
|
||||||
|
elif str(v[0].get("CODCONTRATO") or "").strip() in excluded_ventas:
|
||||||
|
paso["motivo_exclusion"] = f"contrato_excluido ({v[0].get('CODCONTRATO')})"
|
||||||
|
else:
|
||||||
|
grupos_finales[k] = v
|
||||||
|
pasos.append(paso)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"fecha": fecha,
|
||||||
|
"rows_vta_count": len(rows_vta or []),
|
||||||
|
"grupos_count": len(grupos),
|
||||||
|
"grupos_finales_count": len(grupos_finales),
|
||||||
|
"pasos": pasos,
|
||||||
|
"excluded_ventas_set": sorted(excluded_ventas),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/facturas")
|
||||||
|
async def debug_facturas(
|
||||||
|
desde: int = Query(..., description="NUM_FACTURA desde"),
|
||||||
|
hasta: int = Query(..., description="NUM_FACTURA hasta"),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Busca recepciones por rango de NUM_FACTURA en Firebird."""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": msg})
|
||||||
|
|
||||||
|
ok1, _, rows = fb.execute_query("""
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, TRIM(r.PREFIJO) AS PREFIJO, r.NUM_FACTURA,
|
||||||
|
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO,
|
||||||
|
TRIM(e.NOMBRE) AS NOM_EMPRESA,
|
||||||
|
(SELECT COUNT(*) FROM RELACION rel WHERE rel.IDRECEPCION = r.IDRECEPCION) AS N_EXAMENES
|
||||||
|
FROM RECEPCION r
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
WHERE r.NUM_FACTURA BETWEEN ? AND ?
|
||||||
|
ORDER BY r.NUM_FACTURA, r.IDRECEPCION
|
||||||
|
""", (desde, hasta))
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": ok1,
|
||||||
|
"desde": desde,
|
||||||
|
"hasta": hasta,
|
||||||
|
"count": len(rows or []),
|
||||||
|
"rows": list(rows or []),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cups")
|
@router.get("/cups")
|
||||||
async def find_cups(request: Request, user: dict = Depends(get_current_user)):
|
async def find_cups(request: Request, user: dict = Depends(get_current_user)):
|
||||||
try:
|
try:
|
||||||
@@ -21,23 +404,39 @@ async def find_cups(request: Request, user: dict = Depends(get_current_user)):
|
|||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# 1. Buscar todas las tablas que tengan campo con "CUP" en nombre
|
BUSCAR = "903967"
|
||||||
ok0, _, rows0 = fb.execute_query(
|
|
||||||
"SELECT TRIM(f.RDB$RELATION_NAME) AS t, TRIM(f.RDB$FIELD_NAME) AS c "
|
|
||||||
"FROM RDB$RELATION_FIELDS f WHERE f.RDB$FIELD_NAME LIKE '%CUP%' "
|
|
||||||
"AND f.RDB$SYSTEM_FLAG = 0 ORDER BY 1,2"
|
|
||||||
)
|
|
||||||
results["todos_campos_cup"] = list(rows0) if ok0 else []
|
|
||||||
|
|
||||||
# 2. Tabla PROTOCOLO - primera fila
|
# Tablas y sus campos de texto para buscar
|
||||||
ok1, err1, rows1 = fb.execute_query("SELECT FIRST 1 p.* FROM PROTOCOLO p")
|
tablas_candidatas = [
|
||||||
results["protocolo_fila"] = rows1[0] if (ok1 and rows1) else f"error: {err1}"
|
("PROTOCOLO", ["CODIGO", "NOMBRE"]),
|
||||||
|
("ITEM", ["COD_PROTOCOLO", "CUPS_DETALLE", "CODITEMINTERFACE"]),
|
||||||
|
("RELACION", ["COD_EXAMEN"]),
|
||||||
|
("RELACION_HIMS", ["CUPS_REF"]),
|
||||||
|
("ARTICULO", ["CODIGO", "CUPS"]),
|
||||||
|
("SERVICIO", ["CODIGO", "CUPS", "COD_CUPS"]),
|
||||||
|
("PROCEDIMIENTO", ["CODIGO", "CUPS"]),
|
||||||
|
]
|
||||||
|
|
||||||
# 3. PROTOCOLO para CH4
|
encontrado = {}
|
||||||
ok2, err2, rows2 = fb.execute_query(
|
for tabla, campos in tablas_candidatas:
|
||||||
"SELECT FIRST 1 p.* FROM PROTOCOLO p WHERE TRIM(p.CODIGO) = 'CH4'"
|
for campo in campos:
|
||||||
)
|
ok_t, _, rows_t = fb.execute_query(
|
||||||
results["protocolo_ch4"] = rows2[0] if (ok2 and rows2) else f"error: {err2}"
|
f"SELECT FIRST 3 t.* FROM {tabla} t "
|
||||||
|
f"WHERE TRIM(t.{campo}) = '{BUSCAR}'"
|
||||||
|
)
|
||||||
|
if ok_t and rows_t:
|
||||||
|
encontrado[f"{tabla}.{campo}"] = list(rows_t)
|
||||||
|
|
||||||
|
results["valor_903967_encontrado_en"] = encontrado if encontrado else "no encontrado en tablas comunes"
|
||||||
|
|
||||||
|
# Buscar en campos de tipo char/varchar de PROTOCOLO e ITEM que no conocemos
|
||||||
|
for tabla in ["PROTOCOLO", "ITEM"]:
|
||||||
|
ok_c, _, rows_c = fb.execute_query(
|
||||||
|
f"SELECT TRIM(f.RDB$FIELD_NAME) AS c FROM RDB$RELATION_FIELDS f "
|
||||||
|
f"WHERE TRIM(f.RDB$RELATION_NAME)='{tabla}' ORDER BY f.RDB$FIELD_POSITION"
|
||||||
|
)
|
||||||
|
if ok_c:
|
||||||
|
results[f"columnas_{tabla.lower()}"] = [r["c"] for r in rows_c]
|
||||||
|
|
||||||
fb.disconnect()
|
fb.disconnect()
|
||||||
return JSONResponse(results)
|
return JSONResponse(results)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from app.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/documentacion", tags=["docs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def docs_page(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
return request.app.state.templates.TemplateResponse("docs.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import httpx
|
||||||
|
import traceback as tb
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.scheduler import sync_recientes
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/envios", tags=["envios"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tns")
|
||||||
|
async def envios_tns(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
return request.app.state.templates.TemplateResponse("envios_tns.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/erp")
|
||||||
|
async def envios_erp(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("envios_erp.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
"wa_configurado": bool(wa_url and wa_key),
|
||||||
|
"wa_url": wa_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/erp/sync-now")
|
||||||
|
async def erp_sync_now(
|
||||||
|
ventana_min: int = Query(default=30, ge=1, le=1440),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
resultado = await sync_recientes(ventana_min=ventana_min)
|
||||||
|
return JSONResponse(resultado)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "error": str(e), "traceback": tb.format_exc()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/erp/sync-diagnosticos")
|
||||||
|
async def erp_sync_diagnosticos(
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
start_from: int = Query(default=0, ge=0, description="Índice desde el que reanudar"),
|
||||||
|
):
|
||||||
|
"""Migra los 12.422 diagnósticos CIE-10 de Firebird → WhatsApp en lotes de 200."""
|
||||||
|
try:
|
||||||
|
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({"ok": False, "error": "whatsapp_url o whatsapp_api_key no configurados"})
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(configs)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": f"Firebird: {msg}"})
|
||||||
|
|
||||||
|
ok_r, err_r, rows = fb.execute_query(
|
||||||
|
"SELECT TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
|
||||||
|
"FROM DIAGNOSTICO d ORDER BY d.COD_DIAG", None
|
||||||
|
)
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
if not ok_r:
|
||||||
|
return JSONResponse({"ok": False, "error": f"Query Firebird: {err_r}"})
|
||||||
|
|
||||||
|
diagnosticos = [{"cod": r["COD_DIAG"], "concepto": r["CONCEPTO"]} for r in (rows or [])]
|
||||||
|
total = len(diagnosticos)
|
||||||
|
batch_size = 200
|
||||||
|
url = f"{wa_url}/api/lab/ingest_diagnosticos.php"
|
||||||
|
headers = {"Content-Type": "application/json", "X-Lab-Key": wa_key}
|
||||||
|
|
||||||
|
insertados = 0
|
||||||
|
errores = []
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
for i in range(start_from, total, batch_size):
|
||||||
|
lote = diagnosticos[i:i + batch_size]
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, json={"rows": lote}, headers=headers)
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
raw = resp.text[:400].strip()
|
||||||
|
errores.append(f"Lote {i}-{i+len(lote)} HTTP {resp.status_code}: {raw!r}")
|
||||||
|
if i == 0:
|
||||||
|
break # Si el primer lote ya falla, no tiene sentido continuar
|
||||||
|
continue
|
||||||
|
if data.get("ok"):
|
||||||
|
insertados += data.get("insertados", len(lote))
|
||||||
|
else:
|
||||||
|
errores.append(f"Lote {i}-{i+len(lote)}: {data.get('error', '?')}")
|
||||||
|
if i == 0:
|
||||||
|
break
|
||||||
|
except Exception as ex:
|
||||||
|
errores.append(f"Lote {i}-{i+len(lote)}: {ex}")
|
||||||
|
if i == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": len(errores) == 0,
|
||||||
|
"total": total,
|
||||||
|
"insertados": insertados,
|
||||||
|
"lotes": (total + batch_size - 1) // batch_size,
|
||||||
|
"errores": errores,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "error": str(e), "traceback": tb.format_exc()})
|
||||||
+183
-2
@@ -1,10 +1,26 @@
|
|||||||
|
import json as json_lib
|
||||||
|
from datetime import datetime, date as date_cls
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from app.database import get_connection
|
from app.database import get_connection
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
|
from app.services.json_generator import _clean_times
|
||||||
|
from app.services.api_client import get_tns_token, TNS_BASE
|
||||||
|
from app.utils.activity import log_activity, get_ip
|
||||||
|
from app.routes.automation import _parse_tns_resp
|
||||||
|
|
||||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
def _limpiar_json_enviado(raw: str) -> str:
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return json_lib.dumps(_clean_times(json_lib.loads(raw)), ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
return raw
|
||||||
|
|
||||||
PAGE_SIZE = 50
|
PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +50,7 @@ async def logs_page(
|
|||||||
where.append("e.factura LIKE ?")
|
where.append("e.factura LIKE ?")
|
||||||
params.append(f"%{factura}%")
|
params.append(f"%{factura}%")
|
||||||
if paciente:
|
if paciente:
|
||||||
where.append("(CAST(e.idrecepcion AS TEXT) LIKE ? OR e.contrato LIKE ?)")
|
where.append("(e.cedula LIKE ? OR e.factura LIKE ?)")
|
||||||
params += [f"%{paciente}%", f"%{paciente}%"]
|
params += [f"%{paciente}%", f"%{paciente}%"]
|
||||||
if fecha_desde:
|
if fecha_desde:
|
||||||
where.append("DATE(e.created_at) >= ?")
|
where.append("DATE(e.created_at) >= ?")
|
||||||
@@ -76,6 +92,171 @@ async def logs_page(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actividad")
|
||||||
|
async def actividad_page(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
username: str = "",
|
||||||
|
accion: str = "",
|
||||||
|
fecha_desde: str = "",
|
||||||
|
fecha_hasta: str = "",
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
where = ["1=1"]
|
||||||
|
params = []
|
||||||
|
if username:
|
||||||
|
where.append("a.username = ?")
|
||||||
|
params.append(username)
|
||||||
|
if accion:
|
||||||
|
where.append("a.accion = ?")
|
||||||
|
params.append(accion)
|
||||||
|
if fecha_desde:
|
||||||
|
where.append("DATE(a.created_at) >= ?")
|
||||||
|
params.append(fecha_desde)
|
||||||
|
if fecha_hasta:
|
||||||
|
where.append("DATE(a.created_at) <= ?")
|
||||||
|
params.append(fecha_hasta)
|
||||||
|
w = " AND ".join(where)
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) FROM activity_log a WHERE {w}", params).fetchone()[0]
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT a.* FROM activity_log a
|
||||||
|
WHERE {w} ORDER BY a.created_at DESC LIMIT ? OFFSET ?
|
||||||
|
""", params + [PAGE_SIZE, offset]).fetchall()
|
||||||
|
usuarios = conn.execute("SELECT DISTINCT username FROM activity_log ORDER BY username").fetchall()
|
||||||
|
acciones = conn.execute("SELECT DISTINCT accion FROM activity_log ORDER BY accion").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return request.app.state.templates.TemplateResponse("actividad.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
"rows": rows, "total": total,
|
||||||
|
"usuarios": [u["username"] for u in usuarios],
|
||||||
|
"acciones": [a["accion"] for a in acciones],
|
||||||
|
"filtro_username": username, "filtro_accion": accion,
|
||||||
|
"filtro_fecha_desde": fecha_desde, "filtro_fecha_hasta": fecha_hasta,
|
||||||
|
"offset": offset, "page_size": PAGE_SIZE,
|
||||||
|
"has_more": (offset + PAGE_SIZE) < total,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reenviar/{envio_id}")
|
||||||
|
async def reenviar_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
row = conn.execute("SELECT * FROM envios WHERE id = ?", (envio_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return JSONResponse({"success": False, "message": "Registro no encontrado"})
|
||||||
|
if row["status"] in ("success", "warning"):
|
||||||
|
return JSONResponse({"success": False, "message": "No aplica reenvío para este registro"})
|
||||||
|
if not row["json_enviado"]:
|
||||||
|
return JSONResponse({"success": False, "message": "Sin JSON guardado para reenviar"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
rda_json = json_lib.loads(row["json_enviado"])
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"success": False, "message": "JSON inválido en registro"})
|
||||||
|
|
||||||
|
conn_cfg = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn_cfg.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn_cfg.close()
|
||||||
|
|
||||||
|
token, err = await get_tns_token(
|
||||||
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error login TNS: {err}"})
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
api_sucursal = cfg.get("api_sucursal", "00") or "00"
|
||||||
|
tipo = row["tipo"]
|
||||||
|
endpoint = (
|
||||||
|
f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
|
||||||
|
if tipo == "transaccion"
|
||||||
|
else f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
|
||||||
|
if tipo == "ventas"
|
||||||
|
else f"{TNS_BASE}/v2/tablas/Tercero/Crear"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_status = "error"
|
||||||
|
msg = ""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(endpoint, json=rda_json, headers=headers)
|
||||||
|
new_status, msg = _parse_tns_resp(r)
|
||||||
|
except Exception as ex:
|
||||||
|
msg = str(ex)
|
||||||
|
|
||||||
|
if new_status != "error":
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE envios SET status=?, mensaje_tns=?, respuesta_api=?, created_at=? WHERE id=?",
|
||||||
|
(new_status, msg[:500], msg[:5000], datetime.now().isoformat(), envio_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
log_activity(user["user_id"], user["username"], "reenvio_ok",
|
||||||
|
f"Envío #{envio_id} factura {row['factura']} reenvío {new_status}", get_ip(request))
|
||||||
|
|
||||||
|
return JSONResponse({"success": new_status != "error", "message": msg})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/resumen")
|
||||||
|
async def resumen_page(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
fecha: str = "",
|
||||||
|
tipo: str = "",
|
||||||
|
):
|
||||||
|
if not fecha:
|
||||||
|
fecha = date_cls.today().isoformat()
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
where_extra = ""
|
||||||
|
params_q: list = [fecha]
|
||||||
|
if tipo:
|
||||||
|
where_extra = "AND tipo = ?"
|
||||||
|
params_q.append(tipo)
|
||||||
|
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT
|
||||||
|
factura, tipo,
|
||||||
|
MAX(contrato) AS contrato,
|
||||||
|
MIN(cedula) AS cedula,
|
||||||
|
MAX(idrecepcion) AS idrecepcion,
|
||||||
|
COUNT(*) AS intentos,
|
||||||
|
SUM(CASE WHEN status != 'error' THEN 1 ELSE 0 END) AS exitos,
|
||||||
|
MIN(created_at) AS primer_envio,
|
||||||
|
MAX(created_at) AS ultimo_envio,
|
||||||
|
MAX(CASE WHEN status = 'error' THEN id END) AS ultimo_error_id,
|
||||||
|
MAX(CASE WHEN status != 'error' THEN id END) AS exitoso_id,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) > 0 THEN 'success'
|
||||||
|
WHEN SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) > 0 THEN 'warning'
|
||||||
|
ELSE 'error'
|
||||||
|
END AS estado_final
|
||||||
|
FROM envios
|
||||||
|
WHERE DATE(created_at) = ? {where_extra}
|
||||||
|
GROUP BY CASE WHEN tipo = 'terceros' THEN cedula ELSE factura END, tipo
|
||||||
|
ORDER BY estado_final, tipo, CASE WHEN tipo = 'terceros' THEN cedula ELSE factura END
|
||||||
|
""", params_q).fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
exitosos = sum(1 for r in rows if r["estado_final"] != "error")
|
||||||
|
errores = sum(1 for r in rows if r["estado_final"] == "error")
|
||||||
|
|
||||||
|
return request.app.state.templates.TemplateResponse("resumen.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
"fecha": fecha,
|
||||||
|
"filtro_tipo": tipo,
|
||||||
|
"rows": rows,
|
||||||
|
"total": total,
|
||||||
|
"exitosos": exitosos,
|
||||||
|
"errores": errores,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/detalle/{envio_id}")
|
@router.get("/detalle/{envio_id}")
|
||||||
async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
|
async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -95,7 +276,7 @@ async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(ge
|
|||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
"mensaje_tns": row["mensaje_tns"],
|
"mensaje_tns": row["mensaje_tns"],
|
||||||
"respuesta_api": row["respuesta_api"],
|
"respuesta_api": row["respuesta_api"],
|
||||||
"json_enviado": row["json_enviado"],
|
"json_enviado": _limpiar_json_enviado(row["json_enviado"]),
|
||||||
"fecha_inicio": row["fecha_inicio"],
|
"fecha_inicio": row["fecha_inicio"],
|
||||||
"fecha_fin": row["fecha_fin"],
|
"fecha_fin": row["fecha_fin"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
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, guardar_sync_log
|
||||||
|
|
||||||
|
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_EXAMENES_CEDULA = """
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
|
||||||
|
TRIM(ex.NOMBRE) AS NOM_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
|
||||||
|
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
|
||||||
|
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
|
||||||
|
r.VALORTOTAL
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
|
||||||
|
WHERE TRIM(p.DOCIDENT) = :cedula
|
||||||
|
AND CAST(r.FECHA_RECEPCION AS DATE) = CURRENT_DATE
|
||||||
|
ORDER BY r.IDRECEPCION DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
_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("/examenes")
|
||||||
|
async def examenes_paciente(request: Request, cedula: str = ""):
|
||||||
|
"""Server-to-server: exámenes registrados hoy (últimos 5 min) para una cédula."""
|
||||||
|
conn = get_connection()
|
||||||
|
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
api_key = request.headers.get("X-Lab-Key", "")
|
||||||
|
if not api_key or api_key != configs.get("whatsapp_api_key", ""):
|
||||||
|
return JSONResponse({"ok": False, "error": "No autorizado"}, status_code=401)
|
||||||
|
|
||||||
|
cedula = cedula.strip()
|
||||||
|
if not cedula:
|
||||||
|
return JSONResponse({"ok": False, "error": "cedula requerida"}, status_code=400)
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(configs)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"ok": False, "error": f"Error Firebird: {msg}"}, status_code=503)
|
||||||
|
|
||||||
|
ok_q, err_q, rows = fb.execute_query(_SQL_EXAMENES_CEDULA, {"cedula": cedula})
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
if not ok_q:
|
||||||
|
return JSONResponse({"ok": False, "error": f"Error consulta: {err_q}"}, status_code=500)
|
||||||
|
|
||||||
|
# Filtrar últimos 5 minutos por HORAINICIORECEPCION
|
||||||
|
ahora = datetime.now()
|
||||||
|
limite = ahora - timedelta(minutes=5)
|
||||||
|
|
||||||
|
def parse_hora(raw) -> datetime | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, datetime):
|
||||||
|
return ahora.replace(hour=raw.hour, minute=raw.minute, second=raw.second, microsecond=0)
|
||||||
|
s = str(raw)
|
||||||
|
try:
|
||||||
|
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
|
||||||
|
dt = datetime.fromisoformat(s.replace(" ", "T"))
|
||||||
|
return ahora.replace(hour=dt.hour, minute=dt.minute, second=dt.second, microsecond=0)
|
||||||
|
partes = s.split(":")
|
||||||
|
return ahora.replace(hour=int(partes[0]), minute=int(partes[1]),
|
||||||
|
second=int(partes[2].split(".")[0]), microsecond=0)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def safe_num(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fmt_hora_str(val) -> str:
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val.strftime("%H:%M:%S")
|
||||||
|
s = str(val)
|
||||||
|
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
|
||||||
|
return s[11:19]
|
||||||
|
return s[:8]
|
||||||
|
|
||||||
|
examenes = []
|
||||||
|
for row in rows:
|
||||||
|
hora_dt = parse_hora(row.get("HORAINICIORECEPCION"))
|
||||||
|
if hora_dt and hora_dt < limite:
|
||||||
|
continue
|
||||||
|
examenes.append({
|
||||||
|
"recepcion_id": row.get("IDRECEPCION"),
|
||||||
|
"hora": fmt_hora_str(row.get("HORAINICIORECEPCION")),
|
||||||
|
"cod_examen": (row.get("COD_EXAMEN") or "").strip(),
|
||||||
|
"nombre": (row.get("NOM_EXAMEN") or "").strip(),
|
||||||
|
"cups": (row.get("CUPS") or "").strip(),
|
||||||
|
"precio": safe_num(row.get("PRECIO")),
|
||||||
|
"diagnostico_cod": (row.get("DIAG_PPAL") or "").strip(),
|
||||||
|
"diagnostico_nombre": (row.get("DIAG_CONCEPTO") or "").strip(),
|
||||||
|
"medico_docidmedico": (row.get("DOCIDMEDICO") or "").strip(),
|
||||||
|
"nit_empresa": (row.get("NIT_EMPRESA") or "").strip(),
|
||||||
|
"valor_total": safe_num(row.get("VALORTOTAL")),
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "examenes": examenes})
|
||||||
|
|
||||||
|
|
||||||
|
@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, modo="insertar")
|
||||||
|
|
||||||
|
guardar_sync_log(resultado, user["user_id"], origen="manual", modo="insertar")
|
||||||
|
|
||||||
|
resultado["success"] = True
|
||||||
|
resultado["message"] = (
|
||||||
|
f"{resultado['total']} procesados — "
|
||||||
|
f"{resultado['created']} nuevos, "
|
||||||
|
f"{resultado['skipped']} ya existían, "
|
||||||
|
f"{resultado['errores']} errores."
|
||||||
|
)
|
||||||
|
return JSONResponse(resultado)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/historial")
|
||||||
|
async def historial(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT l.*, u.username
|
||||||
|
FROM sync_wa_log l
|
||||||
|
LEFT JOIN users u ON u.id = l.user_id
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return JSONResponse([dict(r) for r in rows])
|
||||||
@@ -47,8 +47,11 @@ WHERE p.DOCIDENT = :doc_num""",
|
|||||||
r.CLASEPROC,
|
r.CLASEPROC,
|
||||||
r.HORAINICIORECEPCION,
|
r.HORAINICIORECEPCION,
|
||||||
r.VALORTOTAL,
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
rel.COD_EXAMEN,
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
rel.PRECIO,
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
rel.FECHA_REPORTADO,
|
rel.FECHA_REPORTADO,
|
||||||
m.COD_ESPECIALIDAD,
|
m.COD_ESPECIALIDAD,
|
||||||
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
@@ -56,8 +59,10 @@ WHERE p.DOCIDENT = :doc_num""",
|
|||||||
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
FROM RECEPCION r
|
FROM RECEPCION r
|
||||||
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
WHERE r.NUM_FACTURA = :num_factura""",
|
WHERE r.NUM_FACTURA = :num_factura""",
|
||||||
"description": "Servicios de una recepción por número de factura"
|
"description": "Servicios de una recepción por número de factura"
|
||||||
},
|
},
|
||||||
@@ -78,8 +83,11 @@ WHERE r.NUM_FACTURA = :num_factura""",
|
|||||||
r.CLASEPROC,
|
r.CLASEPROC,
|
||||||
r.HORAINICIORECEPCION,
|
r.HORAINICIORECEPCION,
|
||||||
r.VALORTOTAL,
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
rel.COD_EXAMEN,
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
rel.PRECIO,
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
rel.FECHA_REPORTADO,
|
rel.FECHA_REPORTADO,
|
||||||
m.COD_ESPECIALIDAD,
|
m.COD_ESPECIALIDAD,
|
||||||
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
@@ -87,12 +95,149 @@ WHERE r.NUM_FACTURA = :num_factura""",
|
|||||||
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
FROM RECEPCION r
|
FROM RECEPCION r
|
||||||
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
ORDER BY r.IDRECEPCION""",
|
ORDER BY r.IDRECEPCION""",
|
||||||
"description": "Servicios en un rango de fechas (para RdaPaciente/Insertar)"
|
"description": "Servicios en un rango de fechas (para RdaPaciente/Insertar)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "RDA Pre-servicios por fecha",
|
||||||
|
"query_type": "transaccion",
|
||||||
|
"query_text": """SELECT
|
||||||
|
ps.ID_PS,
|
||||||
|
ps.PS_PREFIJO,
|
||||||
|
ps.PS_NUMERO,
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.DIAG_PPAL,
|
||||||
|
r.TIPOUSU,
|
||||||
|
r.TIPOUSUSISPRO,
|
||||||
|
r.AUTORIZACION,
|
||||||
|
r.CLASEPROC,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
rel.FECHA_REPORTADO,
|
||||||
|
m.COD_ESPECIALIDAD,
|
||||||
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
|
e.CODCONTRATO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
|
FROM PRESSERV_DIAN ps
|
||||||
|
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
|
||||||
|
ORDER BY ps.ID_PS, rel.COD_EXAMEN""",
|
||||||
|
"description": "Pre-servicios RCXC/SC en un rango de fechas (agrupa por ID_PS)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RDA Pre-servicios por número",
|
||||||
|
"query_type": "transaccion",
|
||||||
|
"query_text": """SELECT
|
||||||
|
ps.ID_PS,
|
||||||
|
ps.PS_PREFIJO,
|
||||||
|
ps.PS_NUMERO,
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.DIAG_PPAL,
|
||||||
|
r.TIPOUSU,
|
||||||
|
r.TIPOUSUSISPRO,
|
||||||
|
r.AUTORIZACION,
|
||||||
|
r.CLASEPROC,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
rel.FECHA_REPORTADO,
|
||||||
|
m.COD_ESPECIALIDAD,
|
||||||
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
|
e.CODCONTRATO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
|
FROM PRESSERV_DIAN ps
|
||||||
|
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE ps.PS_NUMERO = :ps_numero
|
||||||
|
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
|
||||||
|
ORDER BY ps.ID_PS, rel.COD_EXAMEN""",
|
||||||
|
"description": "Pre-servicio por número PS (buscar RCXC05291 → escribe solo el número)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Factura Venta por fecha",
|
||||||
|
"query_type": "ventas",
|
||||||
|
"query_text": """SELECT
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
ORDER BY r.IDRECEPCION""",
|
||||||
|
"description": "Facturas de venta en un rango de fechas (filtra por excluir_ventas en /contratos)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Factura Venta por número",
|
||||||
|
"query_type": "ventas",
|
||||||
|
"query_text": """SELECT
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.NUM_FACTURA = :num_factura""",
|
||||||
|
"description": "Factura de venta por número (filtra por excluir_ventas en /contratos)"
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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, guardar_sync_log
|
||||||
|
|
||||||
router = APIRouter(prefix="/terceros", tags=["terceros"])
|
router = APIRouter(prefix="/terceros", tags=["terceros"])
|
||||||
|
|
||||||
@@ -183,9 +184,35 @@ 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, modo="upsert")
|
||||||
|
wa_sync = {"ok": wa_result["ok"], "action": wa_result["action"]}
|
||||||
|
guardar_sync_log(
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"created": 1 if wa_result["action"] == "created" else 0,
|
||||||
|
"skipped": 1 if wa_result["action"] == "skipped" else 0,
|
||||||
|
"updated": 1 if wa_result["action"] == "updated" else 0,
|
||||||
|
"errores": 0 if wa_result["ok"] else 1,
|
||||||
|
"detalle": [] if wa_result["ok"] else [wa_result],
|
||||||
|
},
|
||||||
|
user["user_id"],
|
||||||
|
origen="tercero",
|
||||||
|
modo="upsert",
|
||||||
|
)
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
|
|||||||
+28
-10
@@ -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.routes.contratos import load_contrato_map, load_excluded_set, load_sin_contrato_set
|
||||||
|
|
||||||
router = APIRouter(prefix="/test-rda", tags=["test-rda"])
|
router = APIRouter(prefix="/test-rda", tags=["test-rda"])
|
||||||
|
|
||||||
@@ -20,18 +21,20 @@ _SQL_RDA_BY_WHERE = """
|
|||||||
SELECT
|
SELECT
|
||||||
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION, r.COD_PACIENTE,
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION, r.COD_PACIENTE,
|
||||||
r.NIT_EMPRESA, r.DIAG_PPAL, r.TIPOUSU, r.TIPOUSUSISPRO, r.AUTORIZACION, r.CLASEPROC,
|
r.NIT_EMPRESA, r.DIAG_PPAL, r.TIPOUSU, r.TIPOUSUSISPRO, r.AUTORIZACION, r.CLASEPROC,
|
||||||
r.HORAINICIORECEPCION, r.VALORTOTAL, rel.COD_EXAMEN,
|
r.HORAINICIORECEPCION, r.VALORTOTAL, r.VALORDESC, rel.COD_EXAMEN,
|
||||||
COALESCE(NULLIF(TRIM(it.CUPS_DETALLE), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
rel.PRECIO, rel.FECHA_REPORTADO,
|
rel.PRECIO, COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
rel.FECHA_REPORTADO,
|
||||||
m.COD_ESPECIALIDAD,
|
m.COD_ESPECIALIDAD,
|
||||||
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
e.CODCONTRATO,
|
e.CODCONTRATO,
|
||||||
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
FROM RECEPCION r
|
FROM RECEPCION r
|
||||||
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
LEFT JOIN ITEM it ON TRIM(it.COD_PROTOCOLO) = TRIM(rel.COD_EXAMEN)
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
AND r.NUM_FACTURA > 0
|
AND r.NUM_FACTURA > 0
|
||||||
AND e.CODCONTRATO IS NOT NULL
|
AND e.CODCONTRATO IS NOT NULL
|
||||||
@@ -159,15 +162,19 @@ async def get_candidatos(request: Request, user: dict = Depends(get_current_user
|
|||||||
if not ok2:
|
if not ok2:
|
||||||
return JSONResponse({"error": err2})
|
return JSONResponse({"error": err2})
|
||||||
|
|
||||||
|
excluded = load_excluded_set()
|
||||||
candidatos = []
|
candidatos = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
cod_c = str(row.get("CODCONTRATO") or "").strip()
|
||||||
|
if cod_c in excluded:
|
||||||
|
continue
|
||||||
candidatos.append({
|
candidatos.append({
|
||||||
"idrecepcion": row.get("IDRECEPCION"),
|
"idrecepcion": row.get("IDRECEPCION"),
|
||||||
"factura": row.get("NUM_FACTURA"),
|
"factura": row.get("NUM_FACTURA"),
|
||||||
"fecha": str(row.get("FECHA_RECEPCION", ""))[:10],
|
"fecha": str(row.get("FECHA_RECEPCION", ""))[:10],
|
||||||
"paciente": row.get("COD_PACIENTE", ""),
|
"paciente": row.get("COD_PACIENTE", ""),
|
||||||
"empresa": row.get("NIT_EMPRESA", ""),
|
"empresa": row.get("NIT_EMPRESA", ""),
|
||||||
"contrato": row.get("CODCONTRATO", ""),
|
"contrato": cod_c,
|
||||||
"examenes": row.get("EXAMENES", ""),
|
"examenes": row.get("EXAMENES", ""),
|
||||||
"total": row.get("TOTAL_EXAMENES", 0),
|
"total": row.get("TOTAL_EXAMENES", 0),
|
||||||
})
|
})
|
||||||
@@ -197,8 +204,13 @@ async def preview_rda(request: Request, user: dict = Depends(get_current_user),
|
|||||||
if not ok2 or not rows_rda:
|
if not ok2 or not rows_rda:
|
||||||
return JSONResponse({"error": err2 or "Sin registros"})
|
return JSONResponse({"error": err2 or "Sin registros"})
|
||||||
grupos = agrupar_por_recepcion(rows_rda)
|
grupos = agrupar_por_recepcion(rows_rda)
|
||||||
rda_json = generar_rda_paciente(list(grupos.values())[0], prof_def, esp_def, remis_def, prefijo_def)
|
grupo = list(grupos.values())[0]
|
||||||
return JSONResponse({"json": rda_json})
|
cod_c = str(grupo[0].get("CODCONTRATO") or "").strip()
|
||||||
|
excluido = cod_c in load_excluded_set()
|
||||||
|
rda_json = generar_rda_paciente(grupo, prof_def, esp_def, remis_def, prefijo_def,
|
||||||
|
contrato_map=load_contrato_map(),
|
||||||
|
sin_contrato_set=load_sin_contrato_set())
|
||||||
|
return JSONResponse({"json": rda_json, "excluido": excluido, "contrato": cod_c})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/enviar")
|
@router.post("/enviar")
|
||||||
@@ -250,6 +262,12 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
|
|||||||
fb.disconnect()
|
fb.disconnect()
|
||||||
return JSONResponse({"error": "Sin registros (¿NUM_FACTURA = 0 o contrato vacío?)"})
|
return JSONResponse({"error": "Sin registros (¿NUM_FACTURA = 0 o contrato vacío?)"})
|
||||||
|
|
||||||
|
# Bloquear si el contrato está excluido
|
||||||
|
cod_c_check = str(rows_rda[0].get("CODCONTRATO") or "").strip()
|
||||||
|
if cod_c_check in load_excluded_set():
|
||||||
|
fb.disconnect()
|
||||||
|
return JSONResponse({"error": f"Contrato {cod_c_check} está excluido del envío TNS"})
|
||||||
|
|
||||||
# Pacientes únicos
|
# Pacientes únicos
|
||||||
pacientes_unicos = {}
|
pacientes_unicos = {}
|
||||||
for row in rows_rda:
|
for row in rows_rda:
|
||||||
@@ -299,12 +317,14 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
|
|||||||
|
|
||||||
# Paso 2: enviar RDA
|
# Paso 2: enviar RDA
|
||||||
grupos = agrupar_por_recepcion(rows_rda)
|
grupos = agrupar_por_recepcion(rows_rda)
|
||||||
|
contrato_map = load_contrato_map()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
for id_rec, grupo_rows in grupos.items():
|
for id_rec, grupo_rows in grupos.items():
|
||||||
num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
||||||
numero_override = num_factura
|
numero_override = num_factura
|
||||||
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, numero_override)
|
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, numero_override,
|
||||||
|
contrato_map=contrato_map, sin_contrato_set=load_sin_contrato_set())
|
||||||
examenes = [r.get("COD_EXAMEN", "") for r in grupo_rows]
|
examenes = [r.get("COD_EXAMEN", "") for r in grupo_rows]
|
||||||
try:
|
try:
|
||||||
r = await client.post(endpoint_rda, json=rda_json, headers=headers)
|
r = await client.post(endpoint_rda, json=rda_json, headers=headers)
|
||||||
@@ -333,8 +353,6 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
|
|||||||
"respuesta_tns": raw_resp,
|
"respuesta_tns": raw_resp,
|
||||||
"json_enviado": rda_json,
|
"json_enviado": rda_json,
|
||||||
})
|
})
|
||||||
if ok_rda:
|
|
||||||
facturas_usadas_sesion.add(num_factura)
|
|
||||||
db_log = get_connection()
|
db_log = get_connection()
|
||||||
db_log.execute(
|
db_log.execute(
|
||||||
"INSERT OR REPLACE INTO rda_test_log (idrecepcion, factura, contrato, ok, mensaje) VALUES (?,?,?,?,?)",
|
"INSERT OR REPLACE INTO rda_test_log (idrecepcion, factura, contrato, ok, mensaje) VALUES (?,?,?,?,?)",
|
||||||
|
|||||||
+222
-48
@@ -6,8 +6,10 @@ from fastapi.responses import JSONResponse
|
|||||||
from app.database import get_connection
|
from app.database import get_connection
|
||||||
from app.auth import get_current_user
|
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_rda_paciente, agrupar_por_recepcion
|
from app.services.json_generator import generar_rda_paciente
|
||||||
from app.services.api_client import get_tns_token, TNS_BASE
|
from app.services.api_client import get_tns_token, TNS_BASE
|
||||||
|
from app.routes.contratos import load_contrato_map, load_excluded_set, load_sin_contrato_set
|
||||||
|
from app.utils.activity import log_activity, get_ip
|
||||||
|
|
||||||
router = APIRouter(prefix="/transaccion", tags=["transaccion"])
|
router = APIRouter(prefix="/transaccion", tags=["transaccion"])
|
||||||
|
|
||||||
@@ -25,23 +27,81 @@ def _query_rows(cfg, query_text, factura, fecha_inicio, fecha_fin):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return None, msg, None
|
return None, msg, None
|
||||||
nums = _re.findall(r'\d+', factura or "")
|
nums = _re.findall(r'\d+', factura or "")
|
||||||
num_val = int(nums[-1]) if nums else (factura or "")
|
num_val = int(nums[-1]) if nums else 0
|
||||||
prefix = _re.sub(r'[\d\s]', '', factura or "").strip().upper()
|
prefix = _re.sub(r'[\d\s]', '', factura or "").strip().upper()
|
||||||
params = {"fecha_ini": f"{fecha_inicio} 00:00:00", "fecha_fin": f"{fecha_fin} 23:59:59"}
|
|
||||||
|
# Solo agregar los parámetros que la query realmente usa
|
||||||
|
params = {}
|
||||||
|
if ":fecha_ini" in query_text:
|
||||||
|
params["fecha_ini"] = f"{fecha_inicio} 00:00:00"
|
||||||
|
if ":fecha_fin" in query_text:
|
||||||
|
params["fecha_fin"] = f"{fecha_fin} 23:59:59"
|
||||||
if ":num_factura" in query_text:
|
if ":num_factura" in query_text:
|
||||||
params["num_factura"] = num_val
|
params["num_factura"] = num_val
|
||||||
if ":prefijo" in query_text:
|
if ":prefijo" in query_text:
|
||||||
params["prefijo"] = prefix
|
params["prefijo"] = prefix
|
||||||
|
if ":ps_numero" in query_text:
|
||||||
|
params["ps_numero"] = num_val
|
||||||
|
|
||||||
ok2, err, rows = fb.execute_query(query_text, params)
|
ok2, err, rows = fb.execute_query(query_text, params)
|
||||||
fb.disconnect()
|
fb.disconnect()
|
||||||
if not ok2:
|
if not ok2:
|
||||||
return None, err, None
|
return None, err, None
|
||||||
# Si el usuario escribió prefijo (ej. "LHXC03726") y el query devuelve PREFIJO, filtramos
|
|
||||||
if prefix and rows and "PREFIJO" in rows[0]:
|
# Filtro Python por prefijo (PREFIJO o PS_PREFIJO)
|
||||||
rows = [r for r in rows if str(r.get("PREFIJO") or "").strip().upper() == prefix]
|
if prefix and rows:
|
||||||
|
if "PS_PREFIJO" in rows[0]:
|
||||||
|
rows = [r for r in rows if str(r.get("PS_PREFIJO") or "").strip().upper() == prefix]
|
||||||
|
elif "PREFIJO" in rows[0]:
|
||||||
|
rows = [r for r in rows if str(r.get("PREFIJO") or "").strip().upper() == prefix]
|
||||||
return rows, None, fb
|
return rows, None, fb
|
||||||
|
|
||||||
|
|
||||||
|
def _is_preserv(rows: list) -> bool:
|
||||||
|
return bool(rows) and "ID_PS" in rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _agrupar(rows: list) -> dict:
|
||||||
|
"""Agrupa por ID_PS (pre-servicios) o IDRECEPCION (regulares)."""
|
||||||
|
from collections import defaultdict
|
||||||
|
grupos = defaultdict(list)
|
||||||
|
key_field = "ID_PS" if _is_preserv(rows) else "IDRECEPCION"
|
||||||
|
for row in rows:
|
||||||
|
grupos[row.get(key_field)].append(dict(row))
|
||||||
|
return dict(grupos)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rda(grupo_rows: list, cfg: dict, contrato_map: dict, sin_contrato_set_val: set) -> tuple:
|
||||||
|
"""Devuelve (rda_json, numero_override, prefijo_override, factura_display)."""
|
||||||
|
prof_def = cfg.get("profesional_default", "")
|
||||||
|
esp_def = cfg.get("especialidad_default", "")
|
||||||
|
remis_def = cfg.get("remisionante_default", "00")
|
||||||
|
prefijo_def = cfg.get("prefijo_tns_default", "00")
|
||||||
|
|
||||||
|
if _is_preserv(grupo_rows):
|
||||||
|
ps_prefijo = str(grupo_rows[0].get("PS_PREFIJO") or "SC").strip()
|
||||||
|
ps_numero = str(grupo_rows[0].get("PS_NUMERO") or "").strip()
|
||||||
|
prefijo_override = "00" if ps_prefijo == "RCXC" else ps_prefijo
|
||||||
|
rda = generar_rda_paciente(
|
||||||
|
grupo_rows, prof_def, esp_def, remis_def, prefijo_def,
|
||||||
|
numero_override=ps_numero,
|
||||||
|
contrato_map=contrato_map,
|
||||||
|
prefijo_override=prefijo_override,
|
||||||
|
sin_contrato_set=sin_contrato_set_val,
|
||||||
|
)
|
||||||
|
factura_display = f"{ps_prefijo}-{ps_numero.zfill(5)}"
|
||||||
|
return rda, ps_numero, prefijo_override, factura_display
|
||||||
|
else:
|
||||||
|
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
||||||
|
rda = generar_rda_paciente(
|
||||||
|
grupo_rows, prof_def, esp_def, remis_def, prefijo_def,
|
||||||
|
numero_override=num_fac,
|
||||||
|
contrato_map=contrato_map,
|
||||||
|
sin_contrato_set=sin_contrato_set_val,
|
||||||
|
)
|
||||||
|
return rda, num_fac, "", str(grupo_rows[0].get("NUM_FACTURA", ""))
|
||||||
|
|
||||||
|
|
||||||
def _is_sent(idrecepcion: int) -> dict:
|
def _is_sent(idrecepcion: int) -> dict:
|
||||||
"""Returns the latest envio record for this idrecepcion, or None."""
|
"""Returns the latest envio record for this idrecepcion, or None."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -88,33 +148,40 @@ async def preview_transaccion(
|
|||||||
if not rows:
|
if not rows:
|
||||||
return JSONResponse({"success": False, "message": "Sin datos"})
|
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||||
|
|
||||||
grupos = agrupar_por_recepcion(rows)
|
excluded = load_excluded_set()
|
||||||
|
contrato_map = load_contrato_map()
|
||||||
|
sin_contrato_set_val = load_sin_contrato_set()
|
||||||
|
grupos_all = _agrupar(rows)
|
||||||
|
|
||||||
if contrato:
|
if contrato:
|
||||||
grupos = {k: v for k, v in grupos.items()
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
|
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
|
||||||
prof_def = cfg.get("profesional_default", "")
|
else:
|
||||||
esp_def = cfg.get("especialidad_default", "")
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
remis_def = cfg.get("remisionante_default", "00")
|
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
|
||||||
prefijo_def = cfg.get("prefijo_tns_default", "00")
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for id_rec, grupo_rows in grupos.items():
|
for key, grupo_rows in grupos.items():
|
||||||
enviado = _is_sent(id_rec)
|
enviado = _is_sent(key)
|
||||||
rda = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def)
|
cod_c = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||||
|
is_excluded = cod_c in excluded
|
||||||
|
rda, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
|
||||||
items.append({
|
items.append({
|
||||||
"idrecepcion": id_rec,
|
"idrecepcion": key,
|
||||||
"factura": grupo_rows[0].get("NUM_FACTURA", ""),
|
"factura": factura_display,
|
||||||
"paciente": grupo_rows[0].get("COD_PACIENTE", ""),
|
"paciente": grupo_rows[0].get("COD_PACIENTE", ""),
|
||||||
"contrato": grupo_rows[0].get("CODCONTRATO", ""),
|
"contrato": grupo_rows[0].get("CODCONTRATO", ""),
|
||||||
"examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows],
|
"examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows],
|
||||||
"valor": float(grupo_rows[0].get("VALORTOTAL") or 0),
|
"valor": float(grupo_rows[0].get("VALORTOTAL") or 0),
|
||||||
"enviado": enviado,
|
"enviado": enviado,
|
||||||
|
"excluido": is_excluded,
|
||||||
"json": rda,
|
"json": rda,
|
||||||
})
|
})
|
||||||
|
|
||||||
pendientes = sum(1 for i in items if not i["enviado"])
|
pendientes = sum(1 for i in items if not i["enviado"] and not i["excluido"])
|
||||||
enviados_ok = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "success")
|
enviados_ok = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "success")
|
||||||
enviados_err = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "error")
|
enviados_err = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "error")
|
||||||
|
excluidos = sum(1 for i in items if i["excluido"])
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -122,6 +189,7 @@ async def preview_transaccion(
|
|||||||
"pendientes": pendientes,
|
"pendientes": pendientes,
|
||||||
"enviados_ok": enviados_ok,
|
"enviados_ok": enviados_ok,
|
||||||
"enviados_err": enviados_err,
|
"enviados_err": enviados_err,
|
||||||
|
"excluidos": excluidos,
|
||||||
"items": items,
|
"items": items,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,10 +210,14 @@ async def send_one(
|
|||||||
if rows is None:
|
if rows is None:
|
||||||
return JSONResponse({"success": False, "message": err})
|
return JSONResponse({"success": False, "message": err})
|
||||||
|
|
||||||
grupos = agrupar_por_recepcion(rows)
|
grupos = _agrupar(rows)
|
||||||
grupo_rows = grupos.get(idrecepcion)
|
grupo_rows = grupos.get(idrecepcion)
|
||||||
if not grupo_rows:
|
if not grupo_rows:
|
||||||
return JSONResponse({"success": False, "message": f"IDRECEPCION {idrecepcion} no encontrado"})
|
return JSONResponse({"success": False, "message": f"Registro {idrecepcion} no encontrado"})
|
||||||
|
|
||||||
|
cod_c = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||||
|
if cod_c in load_excluded_set():
|
||||||
|
return JSONResponse({"success": False, "message": f"Contrato {cod_c} está excluido del envío TNS"})
|
||||||
|
|
||||||
token, token_err = await get_tns_token(
|
token, token_err = await get_tns_token(
|
||||||
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
@@ -157,15 +229,8 @@ async def send_one(
|
|||||||
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
||||||
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
|
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
|
||||||
|
|
||||||
num_factura_one = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
rda_json, numero_override_one, _, factura_display = _build_rda(
|
||||||
numero_override_one = num_factura_one
|
grupo_rows, cfg, load_contrato_map(), load_sin_contrato_set()
|
||||||
rda_json = generar_rda_paciente(
|
|
||||||
grupo_rows,
|
|
||||||
cfg.get("profesional_default", ""),
|
|
||||||
cfg.get("especialidad_default", ""),
|
|
||||||
cfg.get("remisionante_default", "00"),
|
|
||||||
cfg.get("prefijo_tns_default", "00"),
|
|
||||||
numero_override_one,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_resp = ""
|
raw_resp = ""
|
||||||
@@ -193,12 +258,12 @@ async def send_one(
|
|||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""", (
|
""", (
|
||||||
user["user_id"], "transaccion",
|
user["user_id"], "transaccion",
|
||||||
str(grupo_rows[0].get("NUM_FACTURA", idrecepcion)),
|
factura_display,
|
||||||
idrecepcion,
|
idrecepcion,
|
||||||
str(grupo_rows[0].get("CODCONTRATO", "")),
|
str(grupo_rows[0].get("CODCONTRATO", "")),
|
||||||
fecha_inicio, fecha_fin, 1, len(grupo_rows),
|
fecha_inicio, fecha_fin, 1, len(grupo_rows),
|
||||||
"success" if ok_rda else "error",
|
"success" if ok_rda else "error",
|
||||||
json_lib.dumps(rda_json, ensure_ascii=False)[:5000],
|
json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
|
||||||
raw_resp[:2000],
|
raw_resp[:2000],
|
||||||
msg_tns,
|
msg_tns,
|
||||||
datetime.now().isoformat(),
|
datetime.now().isoformat(),
|
||||||
@@ -206,6 +271,9 @@ async def send_one(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
log_activity(user["user_id"], user["username"], "rda_enviado",
|
||||||
|
f"Factura {factura_display} | Contrato {grupo_rows[0].get('CODCONTRATO','')} | {'OK' if ok_rda else 'ERROR: '+msg_tns[:80]}",
|
||||||
|
get_ip(request))
|
||||||
return JSONResponse({"success": ok_rda, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion})
|
return JSONResponse({"success": ok_rda, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion})
|
||||||
|
|
||||||
|
|
||||||
@@ -227,13 +295,22 @@ async def send_transaccion(
|
|||||||
if not rows:
|
if not rows:
|
||||||
return JSONResponse({"success": False, "message": "Sin datos"})
|
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||||
|
|
||||||
grupos = agrupar_por_recepcion(rows)
|
excluded = load_excluded_set()
|
||||||
|
contrato_map = load_contrato_map()
|
||||||
|
sin_contrato_set_val = load_sin_contrato_set()
|
||||||
|
grupos_all = _agrupar(rows)
|
||||||
|
|
||||||
if contrato:
|
if contrato:
|
||||||
grupos = {k: v for k, v in grupos.items()
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
|
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
|
||||||
|
else:
|
||||||
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
|
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
|
||||||
if solo_pendientes == "1":
|
if solo_pendientes == "1":
|
||||||
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
|
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
|
||||||
|
|
||||||
|
excluidos_count = len(grupos_all) - len(grupos)
|
||||||
|
|
||||||
token, token_err = await get_tns_token(
|
token, token_err = await get_tns_token(
|
||||||
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
)
|
)
|
||||||
@@ -246,17 +323,8 @@ async def send_transaccion(
|
|||||||
|
|
||||||
resultados = []
|
resultados = []
|
||||||
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||||
for id_rec, grupo_rows in grupos.items():
|
for key, grupo_rows in grupos.items():
|
||||||
num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
rda_json, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
|
||||||
numero_override = num_factura
|
|
||||||
rda_json = generar_rda_paciente(
|
|
||||||
grupo_rows,
|
|
||||||
cfg.get("profesional_default", ""),
|
|
||||||
cfg.get("especialidad_default", ""),
|
|
||||||
cfg.get("remisionante_default", "00"),
|
|
||||||
cfg.get("prefijo_tns_default", "00"),
|
|
||||||
numero_override,
|
|
||||||
)
|
|
||||||
raw_resp = ""
|
raw_resp = ""
|
||||||
ok_rda = False
|
ok_rda = False
|
||||||
msg_tns = ""
|
msg_tns = ""
|
||||||
@@ -281,24 +349,130 @@ async def send_transaccion(
|
|||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""", (
|
""", (
|
||||||
user["user_id"], "transaccion",
|
user["user_id"], "transaccion",
|
||||||
str(grupo_rows[0].get("NUM_FACTURA", id_rec)),
|
factura_display, key,
|
||||||
id_rec,
|
|
||||||
str(grupo_rows[0].get("CODCONTRATO", "")),
|
str(grupo_rows[0].get("CODCONTRATO", "")),
|
||||||
fecha_inicio, fecha_fin, 1, len(grupo_rows),
|
fecha_inicio, fecha_fin, 1, len(grupo_rows),
|
||||||
"success" if ok_rda else "error",
|
"success" if ok_rda else "error",
|
||||||
json_lib.dumps(rda_json, ensure_ascii=False)[:5000],
|
json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
|
||||||
raw_resp[:2000], msg_tns,
|
raw_resp[:2000], msg_tns,
|
||||||
datetime.now().isoformat(),
|
datetime.now().isoformat(),
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
resultados.append({"idrecepcion": id_rec, "success": ok_rda, "msg": msg_tns})
|
resultados.append({"idrecepcion": key, "success": ok_rda, "msg": msg_tns})
|
||||||
|
|
||||||
ok_count = sum(1 for r in resultados if r["success"])
|
ok_count = sum(1 for r in resultados if r["success"])
|
||||||
err_count = len(resultados) - ok_count
|
err_count = len(resultados) - ok_count
|
||||||
|
log_activity(user["user_id"], user["username"], "rda_masivo",
|
||||||
|
f"Enviados: {ok_count} OK, {err_count} errores, {excluidos_count} excluidos | {fecha_inicio} → {fecha_fin}",
|
||||||
|
get_ip(request))
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": err_count == 0,
|
"success": err_count == 0,
|
||||||
"total_enviados": ok_count,
|
"total_enviados": ok_count,
|
||||||
"total_errores": err_count,
|
"total_errores": err_count,
|
||||||
|
"total_excluidos": excluidos_count,
|
||||||
|
"resultados": resultados,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-direct")
|
||||||
|
async def send_direct(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
prefijo: str = Form(...),
|
||||||
|
numero: str = Form(...),
|
||||||
|
query_id: int = Form(...),
|
||||||
|
):
|
||||||
|
conn = get_connection()
|
||||||
|
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not q:
|
||||||
|
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
|
||||||
|
|
||||||
|
prefijo = prefijo.strip().upper()
|
||||||
|
numero = numero.strip()
|
||||||
|
factura_str = f"{prefijo}{numero}"
|
||||||
|
|
||||||
|
rows, err, _ = _query_rows(cfg, q["query_text"], factura_str, "2000-01-01", "2099-12-31")
|
||||||
|
if rows is None:
|
||||||
|
return JSONResponse({"success": False, "message": err})
|
||||||
|
if not rows:
|
||||||
|
return JSONResponse({"success": False, "message": f"No se encontró factura {factura_str} en Firebird"})
|
||||||
|
|
||||||
|
grupos = _agrupar(rows)
|
||||||
|
if not grupos:
|
||||||
|
return JSONResponse({"success": False, "message": "Sin datos agrupables"})
|
||||||
|
|
||||||
|
excluded = load_excluded_set()
|
||||||
|
primer_contrato = str(list(grupos.values())[0][0].get("CODCONTRATO") or "").strip()
|
||||||
|
if primer_contrato in excluded:
|
||||||
|
return JSONResponse({"success": False, "message": f"Contrato {primer_contrato} está excluido del envío TNS"})
|
||||||
|
|
||||||
|
token, token_err = await get_tns_token(
|
||||||
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||||
|
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
||||||
|
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
|
||||||
|
contrato_map = load_contrato_map()
|
||||||
|
sin_contrato_set_val = load_sin_contrato_set()
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||||
|
for key, grupo_rows in grupos.items():
|
||||||
|
rda_json, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
|
||||||
|
raw_resp = ""
|
||||||
|
ok_rda = False
|
||||||
|
msg_tns = ""
|
||||||
|
try:
|
||||||
|
r = await client.post(endpoint, json=rda_json, headers=headers)
|
||||||
|
raw_resp = r.text
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
ok_rda = bool(data.get("status") or (data.get("data") or {}).get("success", False))
|
||||||
|
msg_tns = ((data.get("data") or {}).get("response") or data.get("message") or raw_resp[:300])
|
||||||
|
except Exception:
|
||||||
|
ok_rda = r.status_code < 300
|
||||||
|
msg_tns = raw_resp[:300]
|
||||||
|
except Exception as ex:
|
||||||
|
msg_tns = str(ex)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO envios (user_id, tipo, factura, idrecepcion, contrato,
|
||||||
|
fecha_inicio, fecha_fin, pacientes_count, servicios_count,
|
||||||
|
status, json_enviado, respuesta_api, mensaje_tns, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
user["user_id"], "transaccion", factura_display, key,
|
||||||
|
str(grupo_rows[0].get("CODCONTRATO", "")),
|
||||||
|
"2000-01-01", "2099-12-31", 1, len(grupo_rows),
|
||||||
|
"success" if ok_rda else "error",
|
||||||
|
json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
|
||||||
|
raw_resp[:2000], msg_tns, datetime.now().isoformat(),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
resultados.append({
|
||||||
|
"idrecepcion": key,
|
||||||
|
"factura": factura_display,
|
||||||
|
"success": ok_rda,
|
||||||
|
"message": msg_tns,
|
||||||
|
"raw_tns": raw_resp[:1000],
|
||||||
|
"json": rda_json,
|
||||||
|
})
|
||||||
|
|
||||||
|
ok_count = sum(1 for r in resultados if r["success"])
|
||||||
|
log_activity(user["user_id"], user["username"], "rda_directo",
|
||||||
|
f"Factura {factura_str} | {'OK' if ok_count > 0 else 'ERROR'}",
|
||||||
|
get_ip(request))
|
||||||
|
return JSONResponse({
|
||||||
|
"success": ok_count > 0,
|
||||||
|
"total": len(resultados),
|
||||||
"resultados": resultados,
|
"resultados": resultados,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import json as json_lib
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
from app.services.json_generator import generar_factura_venta
|
||||||
|
from app.services.api_client import get_tns_token, TNS_BASE
|
||||||
|
from app.utils.activity import log_activity, get_ip
|
||||||
|
from app.routes.contratos import load_excluded_ventas_set, load_forma_pago_map
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ventas", tags=["ventas"])
|
||||||
|
|
||||||
|
_SQL_VENTAS = """
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL, r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO, COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO,
|
||||||
|
CAST(fd.FECHAFACT AS VARCHAR(30)) AS FECHAFACT,
|
||||||
|
fd.DIASVENC
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN FACTURA_DIAN fd ON fd.PREFIJO = r.PREFIJO AND fd.NUM_FACTURA = r.NUM_FACTURA
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE CAST(fd.FECHAFACT AS TIMESTAMP) BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
AND (fd.ANULADA IS NULL OR fd.ANULADA = 'F')
|
||||||
|
AND r.PREFIJO = 'CMXC'
|
||||||
|
ORDER BY r.NUM_FACTURA, r.IDRECEPCION
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SQL_VENTAS_BY_FACTURA = """
|
||||||
|
SELECT
|
||||||
|
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL, r.VALORDESC,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO, COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||||
|
TRIM(e.CODCONTRATO) AS CODCONTRATO,
|
||||||
|
CAST(fd.FECHAFACT AS VARCHAR(30)) AS FECHAFACT,
|
||||||
|
fd.DIASVENC
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN FACTURA_DIAN fd ON fd.PREFIJO = r.PREFIJO AND fd.NUM_FACTURA = r.NUM_FACTURA
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||||
|
WHERE r.PREFIJO = :prefijo AND r.NUM_FACTURA = :num_factura
|
||||||
|
ORDER BY r.IDRECEPCION
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _query_rows(cfg, fecha_inicio, fecha_fin):
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return None, msg
|
||||||
|
ok2, err, rows = fb.execute_query(_SQL_VENTAS, {
|
||||||
|
"fecha_ini": f"{fecha_inicio} 00:00:00",
|
||||||
|
"fecha_fin": f"{fecha_fin} 23:59:59",
|
||||||
|
})
|
||||||
|
fb.disconnect()
|
||||||
|
if not ok2:
|
||||||
|
return None, err
|
||||||
|
return rows, None
|
||||||
|
|
||||||
|
|
||||||
|
def _query_rows_by_factura(cfg, prefijo, num_factura):
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
return None, msg
|
||||||
|
ok2, err, rows = fb.execute_query(_SQL_VENTAS_BY_FACTURA, {
|
||||||
|
"prefijo": prefijo,
|
||||||
|
"num_factura": int(num_factura),
|
||||||
|
})
|
||||||
|
fb.disconnect()
|
||||||
|
if not ok2:
|
||||||
|
return None, err
|
||||||
|
return [dict(r) for r in rows], None
|
||||||
|
|
||||||
|
|
||||||
|
def _agrupar(rows):
|
||||||
|
"""Agrupa por PREFIJO+NUM_FACTURA para incluir todos los servicios de una factura."""
|
||||||
|
from collections import defaultdict
|
||||||
|
grupos = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
prefijo = str(row.get("PREFIJO") or "").strip()
|
||||||
|
num = str(row.get("NUM_FACTURA") or "").strip()
|
||||||
|
key = f"{prefijo}-{num}"
|
||||||
|
grupos[key].append(dict(row))
|
||||||
|
return dict(grupos)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_venta(grupo_rows, cfg, forma_pago_map=None):
|
||||||
|
prefijo_def = cfg.get("prefijo_tns_default", "00")
|
||||||
|
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
||||||
|
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||||
|
fp_override = (forma_pago_map or {}).get(contrato, "")
|
||||||
|
return generar_factura_venta(grupo_rows, default_vendedor="00",
|
||||||
|
default_prefijo=prefijo_def, numero_override=num_fac,
|
||||||
|
forma_pago_override=fp_override)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sent(factura_key):
|
||||||
|
conn = get_connection()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status, mensaje_tns, created_at FROM envios WHERE factura=? AND tipo='ventas' ORDER BY id DESC LIMIT 1",
|
||||||
|
(factura_key,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return {"status": row["status"], "mensaje": row["mensaje_tns"], "at": row["created_at"]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _guardar_envio(user_id, factura, contrato, json_data,
|
||||||
|
respuesta, ok, fecha_inicio, fecha_fin, servicios):
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO envios (user_id, tipo, factura, idrecepcion, contrato,
|
||||||
|
fecha_inicio, fecha_fin, pacientes_count, servicios_count,
|
||||||
|
status, json_enviado, respuesta_api, mensaje_tns, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
user_id, "ventas", factura, None, contrato,
|
||||||
|
fecha_inicio, fecha_fin, 1, servicios,
|
||||||
|
"success" if ok else "error",
|
||||||
|
json_lib.dumps(json_data, ensure_ascii=False)[:10000],
|
||||||
|
respuesta[:2000] if respuesta else "",
|
||||||
|
respuesta[:300] if respuesta else "",
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tns(r):
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
ok = bool(data.get("status") or (data.get("data") or {}).get("success", False))
|
||||||
|
msg = ((data.get("data") or {}).get("response") or data.get("message") or r.text[:300])
|
||||||
|
except Exception:
|
||||||
|
ok = r.status_code < 300
|
||||||
|
msg = r.text[:300]
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def ventas_page(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
return request.app.state.templates.TemplateResponse("ventas.html", {
|
||||||
|
"request": request, "user": user,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview_ventas(
|
||||||
|
request: Request, user: dict = Depends(get_current_user),
|
||||||
|
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
|
||||||
|
):
|
||||||
|
cfg = _cfg()
|
||||||
|
rows, err = _query_rows(cfg, fecha_inicio, fecha_fin)
|
||||||
|
if rows is None:
|
||||||
|
return JSONResponse({"success": False, "message": err})
|
||||||
|
if not rows:
|
||||||
|
return JSONResponse({"success": False, "message": "Sin datos para ese rango de fechas"})
|
||||||
|
|
||||||
|
excluded_ventas = load_excluded_ventas_set()
|
||||||
|
fp_map = load_forma_pago_map()
|
||||||
|
grupos_all = _agrupar(rows)
|
||||||
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
|
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for factura_key, grupo_rows in grupos.items():
|
||||||
|
enviado = _is_sent(factura_key)
|
||||||
|
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||||
|
items.append({
|
||||||
|
"factura_key": factura_key,
|
||||||
|
"factura": factura_key,
|
||||||
|
"paciente": grupo_rows[0].get("COD_PACIENTE", ""),
|
||||||
|
"contrato": str(grupo_rows[0].get("CODCONTRATO") or "").strip(),
|
||||||
|
"examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows],
|
||||||
|
"valor": float(grupo_rows[0].get("VALORTOTAL") or 0),
|
||||||
|
"enviado": enviado,
|
||||||
|
"json": venta_json,
|
||||||
|
})
|
||||||
|
|
||||||
|
pendientes = sum(1 for i in items if not i["enviado"])
|
||||||
|
enviados_ok = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "success")
|
||||||
|
enviados_err = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "error")
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"total": len(items),
|
||||||
|
"pendientes": pendientes,
|
||||||
|
"enviados_ok": enviados_ok,
|
||||||
|
"enviados_err": enviados_err,
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-one")
|
||||||
|
async def send_one(
|
||||||
|
request: Request, user: dict = Depends(get_current_user),
|
||||||
|
factura_key: str = Form(...),
|
||||||
|
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
|
||||||
|
):
|
||||||
|
import re as _re
|
||||||
|
cfg = _cfg()
|
||||||
|
|
||||||
|
parts = factura_key.split("-", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return JSONResponse({"success": False, "message": f"Clave de factura inválida: {factura_key}"})
|
||||||
|
prefijo, num_str = parts[0], parts[1]
|
||||||
|
nums = _re.findall(r'\d+', num_str)
|
||||||
|
if not nums:
|
||||||
|
return JSONResponse({"success": False, "message": f"Número de factura inválido: {factura_key}"})
|
||||||
|
|
||||||
|
grupo_rows, err = _query_rows_by_factura(cfg, prefijo, nums[-1])
|
||||||
|
if grupo_rows is None:
|
||||||
|
return JSONResponse({"success": False, "message": err})
|
||||||
|
if not grupo_rows:
|
||||||
|
return JSONResponse({"success": False, "message": f"Factura {factura_key} no encontrada"})
|
||||||
|
|
||||||
|
token, token_err = await get_tns_token(
|
||||||
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||||
|
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
||||||
|
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
|
||||||
|
fp_map = load_forma_pago_map()
|
||||||
|
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||||
|
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||||
|
|
||||||
|
raw_resp = ""
|
||||||
|
ok_v = False
|
||||||
|
msg_tns = ""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||||
|
r = await client.post(endpoint, json=venta_json, headers=headers)
|
||||||
|
raw_resp = r.text
|
||||||
|
ok_v, msg_tns = _parse_tns(r)
|
||||||
|
except Exception as ex:
|
||||||
|
msg_tns = str(ex)
|
||||||
|
|
||||||
|
_guardar_envio(user["user_id"], factura_key, contrato,
|
||||||
|
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
|
||||||
|
|
||||||
|
log_activity(user["user_id"], user["username"], "venta_enviada",
|
||||||
|
f"Factura {factura_key} | {'OK' if ok_v else 'ERROR: '+msg_tns[:80]}",
|
||||||
|
get_ip(request))
|
||||||
|
return JSONResponse({"success": ok_v, "message": msg_tns, "raw_tns": raw_resp, "factura_key": factura_key})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send")
|
||||||
|
async def send_ventas(
|
||||||
|
request: Request, user: dict = Depends(get_current_user),
|
||||||
|
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
|
||||||
|
solo_pendientes: str = Form("0"),
|
||||||
|
):
|
||||||
|
cfg = _cfg()
|
||||||
|
rows, err = _query_rows(cfg, fecha_inicio, fecha_fin)
|
||||||
|
if rows is None:
|
||||||
|
return JSONResponse({"success": False, "message": err})
|
||||||
|
if not rows:
|
||||||
|
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||||
|
|
||||||
|
excluded_ventas = load_excluded_ventas_set()
|
||||||
|
fp_map = load_forma_pago_map()
|
||||||
|
grupos_all = _agrupar(rows)
|
||||||
|
grupos = {k: v for k, v in grupos_all.items()
|
||||||
|
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
|
||||||
|
if solo_pendientes == "1":
|
||||||
|
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
|
||||||
|
|
||||||
|
token, token_err = await get_tns_token(
|
||||||
|
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||||
|
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
||||||
|
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||||
|
for factura_key, grupo_rows in grupos.items():
|
||||||
|
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||||
|
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||||
|
raw_resp = ""
|
||||||
|
ok_v = False
|
||||||
|
msg_tns = ""
|
||||||
|
try:
|
||||||
|
r = await client.post(endpoint, json=venta_json, headers=headers)
|
||||||
|
raw_resp = r.text
|
||||||
|
ok_v, msg_tns = _parse_tns(r)
|
||||||
|
except Exception as ex:
|
||||||
|
msg_tns = str(ex)
|
||||||
|
|
||||||
|
_guardar_envio(user["user_id"], factura_key, contrato,
|
||||||
|
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
|
||||||
|
resultados.append({"factura": factura_key, "success": ok_v, "msg": msg_tns})
|
||||||
|
|
||||||
|
ok_count = sum(1 for r in resultados if r["success"])
|
||||||
|
err_count = len(resultados) - ok_count
|
||||||
|
log_activity(user["user_id"], user["username"], "ventas_masivo",
|
||||||
|
f"Enviados: {ok_count} OK, {err_count} errores | {fecha_inicio} → {fecha_fin}",
|
||||||
|
get_ip(request))
|
||||||
|
return JSONResponse({
|
||||||
|
"success": err_count == 0,
|
||||||
|
"total_enviados": ok_count,
|
||||||
|
"total_errores": err_count,
|
||||||
|
"resultados": resultados,
|
||||||
|
})
|
||||||
@@ -30,7 +30,17 @@ class FirebirdService:
|
|||||||
self.conn = None
|
self.conn = None
|
||||||
|
|
||||||
def connect(self, host: str, port: int, database: str, user: str, password: str):
|
def connect(self, host: str, port: int, database: str, user: str, password: str):
|
||||||
|
import platform, os
|
||||||
try:
|
try:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
mac_lib = "/Library/Frameworks/Firebird.framework/Versions/A/Resources/lib/libfbclient.dylib"
|
||||||
|
if os.path.exists(mac_lib):
|
||||||
|
os.environ.setdefault("DYLD_LIBRARY_PATH",
|
||||||
|
"/Library/Frameworks/Firebird.framework/Versions/A/Resources/lib")
|
||||||
|
try:
|
||||||
|
fdb.load_api(mac_lib)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.conn = fdb.connect(
|
self.conn = fdb.connect(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
@@ -68,14 +78,15 @@ class FirebirdService:
|
|||||||
try:
|
try:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
if params:
|
if params:
|
||||||
# fdb no soporta :name — convertir a positional ?
|
if isinstance(params, (list, tuple)):
|
||||||
param_names = re.findall(r':([a-zA-Z_][a-zA-Z0-9_]*)', query)
|
# Parámetros posicionales directos (?)
|
||||||
if param_names:
|
cur.execute(query, params)
|
||||||
|
else:
|
||||||
|
# Parámetros nombrados (:name) → convertir a positional
|
||||||
|
param_names = re.findall(r':([a-zA-Z_][a-zA-Z0-9_]*)', query)
|
||||||
positional_sql = re.sub(r':[a-zA-Z_][a-zA-Z0-9_]*', '?', query)
|
positional_sql = re.sub(r':[a-zA-Z_][a-zA-Z0-9_]*', '?', query)
|
||||||
positional_vals = [params[n] for n in param_names]
|
positional_vals = [params[n] for n in param_names]
|
||||||
cur.execute(positional_sql, positional_vals)
|
cur.execute(positional_sql, positional_vals)
|
||||||
else:
|
|
||||||
cur.execute(query)
|
|
||||||
else:
|
else:
|
||||||
cur.execute(query)
|
cur.execute(query)
|
||||||
columns = [desc[0] for desc in cur.description] if cur.description else []
|
columns = [desc[0] for desc in cur.description] if cur.description else []
|
||||||
|
|||||||
+156
-43
@@ -1,7 +1,8 @@
|
|||||||
import re as _re
|
import re as _re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
# v1.5.0
|
||||||
|
|
||||||
|
|
||||||
# ── helpers de formato de fecha ──────────────────────────────────────────────
|
# ── helpers de formato de fecha ──────────────────────────────────────────────
|
||||||
@@ -58,7 +59,7 @@ def _fmt_datetime(val) -> str:
|
|||||||
_TIPO_DOC_MAP = {
|
_TIPO_DOC_MAP = {
|
||||||
"CC": "C", "TI": "T", "RC": "R", "CE": "E",
|
"CC": "C", "TI": "T", "RC": "R", "CE": "E",
|
||||||
"PA": "P", "AS": "A", "MS": "M", "NU": "U",
|
"PA": "P", "AS": "A", "MS": "M", "NU": "U",
|
||||||
"SC": "S", "PE": "PE", "PT": "PT", "SI": "A",
|
"SC": "S", "PE": "E", "PT": "E", "SI": "A",
|
||||||
"CN": "C", "DE": "E", "CD": "P",
|
"CN": "C", "DE": "E", "CD": "P",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ def generar_tercero_api(row: dict) -> dict:
|
|||||||
"sexo": sexo,
|
"sexo": sexo,
|
||||||
"identidadGenero": identidad_genero,
|
"identidadGenero": identidad_genero,
|
||||||
"zona": zona,
|
"zona": zona,
|
||||||
"etnia": str(row.get("CODETNIA") or "99").strip(),
|
"etnia": (lambda v: v if v and len(v) <= 2 else "99")(str(row.get("CODETNIA") or "").strip()),
|
||||||
|
"ciudadExp": cod_ciudad,
|
||||||
"antecedentes": "",
|
"antecedentes": "",
|
||||||
"cliente": "S",
|
"cliente": "S",
|
||||||
"enfermedadCronica": False,
|
"enfermedadCronica": False,
|
||||||
@@ -123,9 +125,19 @@ def agrupar_por_recepcion(rows: list) -> dict:
|
|||||||
return grupos
|
return grupos
|
||||||
|
|
||||||
|
|
||||||
|
def agrupar_por_factura(rows: list) -> dict:
|
||||||
|
"""Agrupa filas por NUM_FACTURA — para ventas CMXC donde una factura puede tener varias recepciones."""
|
||||||
|
grupos = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
key = str(row.get("NUM_FACTURA") or "")
|
||||||
|
grupos[key].append(dict(row))
|
||||||
|
return grupos
|
||||||
|
|
||||||
|
|
||||||
def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "",
|
def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "",
|
||||||
default_remisionante: str = "00", default_prefijo: str = "00",
|
default_remisionante: str = "00", default_prefijo: str = "00",
|
||||||
numero_override: str = "") -> dict:
|
numero_override: str = "", contrato_map: dict = None,
|
||||||
|
prefijo_override: str = "", sin_contrato_set: set = None) -> dict:
|
||||||
if not rows:
|
if not rows:
|
||||||
return {}
|
return {}
|
||||||
h = rows[0]
|
h = rows[0]
|
||||||
@@ -152,38 +164,52 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
|
|||||||
"codigoMaterial": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(),
|
"codigoMaterial": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(),
|
||||||
"codigoBodega": "00",
|
"codigoBodega": "00",
|
||||||
"cantidad": 1,
|
"cantidad": 1,
|
||||||
"tipoUnidad": "D",
|
|
||||||
"descuento": 0,
|
|
||||||
"porcentajeIva": 0,
|
|
||||||
"impConsumo": 0,
|
|
||||||
"observacion": "",
|
"observacion": "",
|
||||||
"profesional": default_profesional or None,
|
"profesional": default_profesional or None,
|
||||||
"especialidad": default_especialidad or None,
|
"especialidad": default_especialidad or None,
|
||||||
"profesionalRemisionante": cedula_medico or default_remisionante or None,
|
"profesionalRemisionante": cedula_medico or default_remisionante or None,
|
||||||
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip(),
|
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip() or "Z017",
|
||||||
"fechaHoraRealizacion": fecha_real or recepcion_dt,
|
"fechaHoraRealizacion": fecha_real or recepcion_dt,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Egreso = fechaHoraRealizacion del último examen en el detalle
|
# Egreso = fechaHoraRealizacion del último examen + 5 minutos
|
||||||
egreso = max(
|
_egreso_raw = max(
|
||||||
(d["fechaHoraRealizacion"] for d in detalle_pedido if d["fechaHoraRealizacion"]),
|
(d["fechaHoraRealizacion"] for d in detalle_pedido if d["fechaHoraRealizacion"]),
|
||||||
default=recepcion_dt,
|
default=recepcion_dt,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
_egreso_dt = datetime.strptime(_egreso_raw, "%d/%m/%Y %H:%M:%S") + timedelta(minutes=5)
|
||||||
|
egreso = _egreso_dt.strftime("%d/%m/%Y %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
egreso = _egreso_raw
|
||||||
|
|
||||||
nit_empresa = str(h.get("NIT_EMPRESA") or "").strip()
|
nit_empresa = str(h.get("NIT_EMPRESA") or "").strip()
|
||||||
cod_contrato_raw = str(h.get("CODCONTRATO") or "").strip()
|
cod_contrato_raw = str(h.get("CODCONTRATO") or "").strip()
|
||||||
# Particular: por NIT_EMPRESA "PART" o por contrato "12"/"012"
|
|
||||||
|
# Resolver tipo desde contrato_map primero (tiene prioridad sobre heurísticas)
|
||||||
|
_TIPOUSU_MAP = {"1": "11", "5": "07"} # EPS→11, Póliza→07
|
||||||
|
_nc_s = cod_contrato_raw.lstrip("0") or cod_contrato_raw
|
||||||
|
_map_tipo = ""
|
||||||
|
if contrato_map and cod_contrato_raw:
|
||||||
|
_map_tipo = contrato_map.get(cod_contrato_raw) or contrato_map.get(_nc_s) or ""
|
||||||
|
|
||||||
|
# es_particular: NIT "PART", o mapa dice "12", o heurística solo si no está en el mapa
|
||||||
es_particular = (
|
es_particular = (
|
||||||
nit_empresa.upper() in ("PART", "PARTICULAR", "", "0")
|
nit_empresa.upper() in ("PART", "PARTICULAR", "", "0")
|
||||||
or cod_contrato_raw.lstrip("0") == "12"
|
or (_map_tipo == "12")
|
||||||
|
or (not _map_tipo and cod_contrato_raw.lstrip("0") == "12")
|
||||||
)
|
)
|
||||||
cod_contrato = cod_contrato_raw or ("012" if es_particular else None)
|
if sin_contrato_set and cod_contrato_raw and cod_contrato_raw in sin_contrato_set:
|
||||||
|
cod_contrato = None
|
||||||
|
else:
|
||||||
|
cod_contrato = cod_contrato_raw or ("012" if es_particular else None)
|
||||||
cod_forma_pago = "CLIP" if es_particular else "INST"
|
cod_forma_pago = "CLIP" if es_particular else "INST"
|
||||||
autorizacion = None if es_particular else (str(h.get("AUTORIZACION") or "").strip() or None)
|
autorizacion = None if es_particular else (str(h.get("AUTORIZACION") or "").strip() or None)
|
||||||
|
|
||||||
# tipousuario: particular siempre "12"; para convenios usar TIPOUSUSISPRO o derivar
|
# tipousuario: prioridad 1→mapa contratos, 2→particular, 3→TIPOUSUSISPRO/TIPOUSU
|
||||||
_TIPOUSU_MAP = {"1": "11", "5": "07"} # EPS→11, Póliza→07
|
if _map_tipo:
|
||||||
if es_particular:
|
tipoususispro = _map_tipo
|
||||||
|
elif es_particular:
|
||||||
tipoususispro = "12"
|
tipoususispro = "12"
|
||||||
else:
|
else:
|
||||||
tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip()
|
tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip()
|
||||||
@@ -193,44 +219,141 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
|
|||||||
|
|
||||||
via_ingreso = "01"
|
via_ingreso = "01"
|
||||||
modalidad = "01"
|
modalidad = "01"
|
||||||
|
|
||||||
vigencia = 30
|
vigencia = 30
|
||||||
|
|
||||||
|
_total = float(h.get("VALORTOTAL") or 0)
|
||||||
|
_desc = float(h.get("VALORDESC") or 0)
|
||||||
|
descuento_pct = round(_desc * 100 / _total, 2) if _total > 0 else 0
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"codigoPrefijo": str(h.get("PREFIJO") or "").strip() or default_prefijo or "00",
|
"codigoPrefijo": prefijo_override or str(h.get("PREFIJO") or "").strip() or default_prefijo or "00",
|
||||||
"numero": numero_override if numero_override else (
|
"numero": (numero_override if numero_override else (
|
||||||
str(h.get("NUM_FACTURA") or "").strip() if (h.get("NUM_FACTURA") or 0) != 0 else ""
|
str(h.get("NUM_FACTURA") or "").strip() if (h.get("NUM_FACTURA") or 0) != 0 else ""
|
||||||
),
|
)).zfill(5) or "",
|
||||||
"fecha": fecha,
|
"fecha": fecha,
|
||||||
"codTercero": str(h.get("COD_PACIENTE") or "").strip(),
|
"codTercero": str(h.get("COD_PACIENTE") or "").strip(),
|
||||||
"codVendedor": "00",
|
"codVendedor": "00",
|
||||||
"codFormaPago": cod_forma_pago,
|
|
||||||
"codBanco": "00",
|
|
||||||
"codigoCentroCosto": "00",
|
"codigoCentroCosto": "00",
|
||||||
"tipoIngreso": str(h.get("CLASEPROC") or "1").strip() or "1",
|
"tipoIngreso": "1",
|
||||||
"fechaHoraIngreso": ingreso,
|
"fechaHoraIngreso": ingreso,
|
||||||
"fechaHoraEgreso": egreso,
|
"fechaHoraEgreso": egreso,
|
||||||
"modalidadAtencion": modalidad,
|
"modalidadAtencion": modalidad,
|
||||||
"numeroContrato": cod_contrato,
|
"numeroContrato": cod_contrato,
|
||||||
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip(),
|
"descuento": descuento_pct,
|
||||||
|
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip() or "Z017",
|
||||||
|
"discapacidad": "08",
|
||||||
"tipousuario": tipoususispro,
|
"tipousuario": tipoususispro,
|
||||||
"viaIngreso": via_ingreso,
|
"viaIngreso": via_ingreso,
|
||||||
"esTerapia": False,
|
"esTerapia": False,
|
||||||
"esProcedimiento": False,
|
"esProcedimiento": False,
|
||||||
"numeroAutorizacion": autorizacion,
|
"numeroAutorizacion": autorizacion,
|
||||||
"fechaAutorizacion": date.today().strftime("%d/%m/%Y"),
|
**({
|
||||||
"vigenciaAutorizacion": vigencia,
|
"fechaAutorizacion": date.today().strftime("%d/%m/%Y"),
|
||||||
|
"vigenciaAutorizacion": vigencia,
|
||||||
|
} if autorizacion else {}),
|
||||||
"detallePedido": detalle_pedido,
|
"detallePedido": detalle_pedido,
|
||||||
"detalleFormaPago": [{
|
|
||||||
"codigoFormaPago": cod_forma_pago,
|
|
||||||
"plazoDias": "0" if es_particular else "30",
|
|
||||||
"fechaVencimiento": fecha,
|
|
||||||
"valor": str(int(float(h.get("VALORTOTAL") or 0))),
|
|
||||||
}],
|
|
||||||
}
|
}
|
||||||
return _clean_times(result)
|
return _clean_times(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ventas/Crear ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fecha_add_dias(fecha_val, dias: int) -> str:
|
||||||
|
"""Suma `dias` a una fecha ISO (YYYY-MM-DD...) o date y retorna dd/mm/YYYY."""
|
||||||
|
try:
|
||||||
|
if hasattr(fecha_val, "strftime"):
|
||||||
|
d = fecha_val if isinstance(fecha_val, date) else fecha_val.date()
|
||||||
|
else:
|
||||||
|
d = datetime.strptime(str(fecha_val)[:10], "%Y-%m-%d").date()
|
||||||
|
return (d + timedelta(days=dias)).strftime("%d/%m/%Y")
|
||||||
|
except Exception:
|
||||||
|
return _fmt_fecha(fecha_val)
|
||||||
|
|
||||||
|
|
||||||
|
def generar_factura_venta(rows: list, default_vendedor: str = "00",
|
||||||
|
default_prefijo: str = "00", numero_override: str = "",
|
||||||
|
forma_pago_override: str = "") -> dict:
|
||||||
|
if not rows:
|
||||||
|
return {}
|
||||||
|
h = rows[0]
|
||||||
|
|
||||||
|
num_fac = (numero_override if numero_override else str(h.get("NUM_FACTURA") or "")).zfill(5)
|
||||||
|
fecha_raw = h.get("FECHAFACT") or h.get("FECHA_RECEPCION")
|
||||||
|
fecha = _fmt_fecha(fecha_raw)
|
||||||
|
|
||||||
|
detalle_pedido = []
|
||||||
|
total = 0.0
|
||||||
|
for row in rows:
|
||||||
|
precio = float(row.get("PRECIO_TARIFA") or row.get("PRECIO") or 0)
|
||||||
|
total += precio
|
||||||
|
detalle_pedido.append({
|
||||||
|
"codMat": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(),
|
||||||
|
"codBodega": "00",
|
||||||
|
"codTalla": "",
|
||||||
|
"codColor": "",
|
||||||
|
"cantidad": 1,
|
||||||
|
"tipoUnidad": "M",
|
||||||
|
"descuento": 0,
|
||||||
|
"descuentoValor": 0,
|
||||||
|
"centrosCostos": "00",
|
||||||
|
"porcIva": 0,
|
||||||
|
"valor": precio,
|
||||||
|
"impConsumo": 0,
|
||||||
|
"observacion": "",
|
||||||
|
"lote": "",
|
||||||
|
"fechaVenceLote": "",
|
||||||
|
"nroDocumento": "",
|
||||||
|
"itemsSerial": [],
|
||||||
|
"tipoSerial": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
nit_raw = str(h.get("NIT_EMPRESA") or "").strip()
|
||||||
|
cod_tercero = nit_raw if nit_raw else str(h.get("COD_PACIENTE") or "").strip()
|
||||||
|
|
||||||
|
prefijo_real = str(h.get("PREFIJO") or "").strip() or default_prefijo or "00"
|
||||||
|
|
||||||
|
diasvenc = int(h.get("DIASVENC") or 30)
|
||||||
|
cod_forma_pago = forma_pago_override.strip() if forma_pago_override else ("CIAC" if total == 0 else "CR")
|
||||||
|
if cod_forma_pago == "CIAC":
|
||||||
|
plazo_dias = 0
|
||||||
|
fecha_vence = fecha
|
||||||
|
else:
|
||||||
|
plazo_dias = diasvenc
|
||||||
|
fecha_vence = _fecha_add_dias(fecha_raw, diasvenc)
|
||||||
|
|
||||||
|
return _clean_times({
|
||||||
|
"codigoPrefijo": prefijo_real,
|
||||||
|
"numero": num_fac,
|
||||||
|
"numeroFactura": num_fac,
|
||||||
|
"sucursal": "00",
|
||||||
|
"fecha": fecha,
|
||||||
|
"kardexId": 0,
|
||||||
|
"codigoPedido": "",
|
||||||
|
"nombreCliente": "",
|
||||||
|
"codTercero": cod_tercero,
|
||||||
|
"codVendedor": default_vendedor or "00",
|
||||||
|
"codDespachar": "00",
|
||||||
|
"codFormaPago": cod_forma_pago,
|
||||||
|
"codBanco": "",
|
||||||
|
"fechaVence": fecha_vence,
|
||||||
|
"fechaEntrega": fecha,
|
||||||
|
"plazoDias": plazo_dias,
|
||||||
|
"observacion": "",
|
||||||
|
"latitud": "",
|
||||||
|
"longitud": "",
|
||||||
|
"motivo": "",
|
||||||
|
"numeroFacturaDevolucion": "",
|
||||||
|
"tipoOperacion": "",
|
||||||
|
"codigoCentroCosto": "00",
|
||||||
|
"codigoArea": "00",
|
||||||
|
"terminal": "00",
|
||||||
|
"detallePedido": detalle_pedido,
|
||||||
|
"detalleFormaPago": [],
|
||||||
|
"asentar": 0,
|
||||||
|
"detalleDescuentos": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── funciones legacy RIPS 2.0 (se mantienen) ─────────────────────────────────
|
# ── funciones legacy RIPS 2.0 (se mantienen) ─────────────────────────────────
|
||||||
|
|
||||||
def generar_terceros(row: dict) -> dict:
|
def generar_terceros(row: dict) -> dict:
|
||||||
@@ -280,16 +403,6 @@ def generar_procedimiento(row: dict, consecutivo: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def agrupar_por_factura(rows: list, factura_default: str = "") -> dict:
|
|
||||||
grupos = defaultdict(lambda: {"factura": "", "procedimientos": []})
|
|
||||||
for row in rows:
|
|
||||||
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
|
|
||||||
fact = row.get("num_factura", factura_default)
|
|
||||||
grupos[(fact, doc_key)]["factura"] = fact
|
|
||||||
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
|
|
||||||
return grupos
|
|
||||||
|
|
||||||
|
|
||||||
def generar_transaccion(
|
def generar_transaccion(
|
||||||
factura: str,
|
factura: str,
|
||||||
num_doc_obligado: str,
|
num_doc_obligado: str,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
Scheduler: sync automático de pacientes con recepción reciente → WhatsApp Lab.
|
||||||
|
Corre cada minuto y envía los pacientes con FECHA_RECEPCION en los últimos ventana_min minutos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
from app.services.whatsapp_sync import sync_todos, guardar_sync_log
|
||||||
|
|
||||||
|
# Filtra directamente en Firebird por FECHA_RECEPCION para no depender de HORAINICIORECEPCION
|
||||||
|
_SQL_RECIENTES = """
|
||||||
|
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,
|
||||||
|
r.HORAINICIORECEPCION
|
||||||
|
FROM PACIENTE p
|
||||||
|
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
|
||||||
|
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
|
||||||
|
WHERE r.FECHA_RECEPCION >= DATEADD(MINUTE, ?, CURRENT_TIMESTAMP)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SQL_EXAMENES_VENTANA = """
|
||||||
|
SELECT
|
||||||
|
TRIM(p.DOCIDENT) AS DOCIDENT,
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
|
||||||
|
TRIM(ex.NOMBRE) AS NOM_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
COALESCE(NULLIF(rel.PRECIO, 0), t.VALOR, 0) AS PRECIO,
|
||||||
|
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
|
||||||
|
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
|
||||||
|
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
|
||||||
|
TRIM(e.NOMBRE) AS NOM_EMPRESA,
|
||||||
|
r.VALORTOTAL
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
|
||||||
|
LEFT JOIN EMPRESA e ON TRIM(e.NIT) = TRIM(r.NIT_EMPRESA)
|
||||||
|
LEFT JOIN EMPRESA_SUB es ON TRIM(es.NIT_EMP) = TRIM(r.NIT_EMPRESA)
|
||||||
|
AND TRIM(es.SUBGRUPO) = TRIM(r.SUBGRUPO)
|
||||||
|
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN)
|
||||||
|
AND t.TARIFA = COALESCE(es.TARIFA, e.TARIFA)
|
||||||
|
WHERE r.FECHA_RECEPCION >= DATEADD(MINUTE, ?, CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY r.IDRECEPCION
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hora(val) -> datetime | None:
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
# Si ya es datetime (fdb devuelve objetos datetime)
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
ahora = datetime.now()
|
||||||
|
return ahora.replace(hour=val.hour, minute=val.minute, second=val.second, microsecond=0)
|
||||||
|
s = str(val)
|
||||||
|
try:
|
||||||
|
# "2026-07-16T15:20:12" o "2026-07-16 15:20:12"
|
||||||
|
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
|
||||||
|
dt = datetime.fromisoformat(s.replace(" ", "T"))
|
||||||
|
ahora = datetime.now()
|
||||||
|
return ahora.replace(hour=dt.hour, minute=dt.minute, second=dt.second, microsecond=0)
|
||||||
|
# "15:20:12"
|
||||||
|
partes = s.split(":")
|
||||||
|
ahora = datetime.now()
|
||||||
|
return ahora.replace(hour=int(partes[0]), minute=int(partes[1]),
|
||||||
|
second=int(partes[2].split(".")[0]), microsecond=0)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_hora_str(val) -> str:
|
||||||
|
"""Extrae HH:MM:SS de un datetime o string timestamp de Firebird."""
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val.strftime("%H:%M:%S")
|
||||||
|
s = str(val)
|
||||||
|
# "2026-07-16T15:20:12" o "2026-07-16 15:20:12"
|
||||||
|
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
|
||||||
|
return s[11:19]
|
||||||
|
return s[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_num(val):
|
||||||
|
"""Convierte Decimal/int/float a float, o None."""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_recientes(ventana_min: int = 2) -> dict:
|
||||||
|
"""Job que corre cada minuto: sincroniza pacientes con recepción en los últimos `ventana_min` min."""
|
||||||
|
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 {"ok": False, "error": "whatsapp_url o whatsapp_api_key no configurados"}
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(configs)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": f"Firebird: {msg}"}
|
||||||
|
|
||||||
|
ok_pac, err_pac, rows_pac = fb.execute_query(_SQL_RECIENTES, (-ventana_min,))
|
||||||
|
ok_ex, _, rows_ex = fb.execute_query(_SQL_EXAMENES_VENTANA, (-ventana_min,))
|
||||||
|
fb.disconnect()
|
||||||
|
|
||||||
|
if not ok_pac:
|
||||||
|
return {"ok": False, "error": f"Query pacientes: {err_pac}"}
|
||||||
|
|
||||||
|
recientes = rows_pac or []
|
||||||
|
total_hoy = len(recientes)
|
||||||
|
|
||||||
|
if not recientes:
|
||||||
|
return {"ok": True, "total": 0, "created": 0, "updated": 0,
|
||||||
|
"skipped": 0, "errores": 0, "total_hoy": 0,
|
||||||
|
"ventana_min": ventana_min, "mensaje": "Sin recepciones en la ventana de tiempo"}
|
||||||
|
|
||||||
|
examenes_map: dict[str, list] = {}
|
||||||
|
if ok_ex and rows_ex:
|
||||||
|
for ex in rows_ex:
|
||||||
|
doc = str(ex.get("DOCIDENT") or "").strip()
|
||||||
|
if not doc:
|
||||||
|
continue
|
||||||
|
examenes_map.setdefault(doc, []).append({
|
||||||
|
"cod_examen": (ex.get("COD_EXAMEN") or "").strip(),
|
||||||
|
"nombre": (ex.get("NOM_EXAMEN") or "").strip(),
|
||||||
|
"cups": (ex.get("CUPS") or "").strip(),
|
||||||
|
"precio": _safe_num(ex.get("PRECIO")),
|
||||||
|
"recepcion_id": ex.get("IDRECEPCION"),
|
||||||
|
"hora": _fmt_hora_str(ex.get("HORAINICIORECEPCION")),
|
||||||
|
"diagnostico_cod": (ex.get("DIAG_PPAL") or "").strip(),
|
||||||
|
"diagnostico_nombre": (ex.get("DIAG_CONCEPTO") or "").strip(),
|
||||||
|
"medico_docidmedico": (ex.get("DOCIDMEDICO") or "").strip(),
|
||||||
|
"nit_empresa": (ex.get("NIT_EMPRESA") or "").strip(),
|
||||||
|
"nom_empresa": (ex.get("NOM_EMPRESA") or "").strip(),
|
||||||
|
"valor_total": _safe_num(ex.get("VALORTOTAL")),
|
||||||
|
})
|
||||||
|
|
||||||
|
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
|
||||||
|
timeout = int(configs.get("api_timeout", 30))
|
||||||
|
resultado = await sync_todos(recientes, ingest_url, wa_key, timeout,
|
||||||
|
modo="upsert", examenes_map=examenes_map)
|
||||||
|
|
||||||
|
# Enriquecer detalle con exámenes, diagnóstico, empresa y valor para el log
|
||||||
|
for entry in resultado.get("detalle", []):
|
||||||
|
doc = entry.get("doc", "")
|
||||||
|
exams = examenes_map.get(doc, [])
|
||||||
|
if exams:
|
||||||
|
first = exams[0]
|
||||||
|
entry["diagnostico_cod"] = first.get("diagnostico_cod", "")
|
||||||
|
entry["diagnostico_nombre"] = first.get("diagnostico_nombre", "")
|
||||||
|
entry["nit_empresa"] = first.get("nit_empresa", "")
|
||||||
|
entry["nom_empresa"] = first.get("nom_empresa", "")
|
||||||
|
entry["valor_total"] = first.get("valor_total")
|
||||||
|
entry["examenes_det"] = [
|
||||||
|
{"cups": e.get("cups", ""), "nombre": e.get("nombre", ""), "precio": e.get("precio")}
|
||||||
|
for e in exams
|
||||||
|
]
|
||||||
|
|
||||||
|
if resultado["total"] > 0:
|
||||||
|
guardar_sync_log(resultado, None, origen="scheduler", modo="upsert")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"total": resultado["total"],
|
||||||
|
"created": resultado["created"],
|
||||||
|
"updated": resultado["updated"],
|
||||||
|
"skipped": resultado["skipped"],
|
||||||
|
"errores": resultado["errores"],
|
||||||
|
"total_hoy": total_hoy,
|
||||||
|
"ventana_min": ventana_min,
|
||||||
|
"detalle": resultado.get("detalle", []),
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Servicio de sincronización de pacientes RIPS → WhatsApp Lab.
|
||||||
|
Mapea campos Firebird al formato esperado por /api/lab/ingest_paciente.php.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def guardar_sync_log(
|
||||||
|
resultado: dict,
|
||||||
|
user_id,
|
||||||
|
origen: str = "manual",
|
||||||
|
modo: str = "insertar",
|
||||||
|
) -> None:
|
||||||
|
detalle_raw = resultado.get("detalle", [])
|
||||||
|
|
||||||
|
errores_det = None
|
||||||
|
errores = [d for d in detalle_raw if not d.get("ok")]
|
||||||
|
if errores:
|
||||||
|
errores_det = json.dumps(
|
||||||
|
[{"doc": e["doc"], "nombre": e["nombre"], "msg": e["message"]} for e in errores],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
detalle_json = None
|
||||||
|
if detalle_raw:
|
||||||
|
detalle_json = json.dumps(
|
||||||
|
[{
|
||||||
|
"doc": d["doc"],
|
||||||
|
"nombre": d["nombre"],
|
||||||
|
"action": d.get("action", ""),
|
||||||
|
"examenes_cnt": d.get("examenes_guardados", 0),
|
||||||
|
"diagnostico_cod": d.get("diagnostico_cod", ""),
|
||||||
|
"diagnostico_nombre": d.get("diagnostico_nombre", ""),
|
||||||
|
"nit_empresa": d.get("nit_empresa", ""),
|
||||||
|
"nom_empresa": d.get("nom_empresa", ""),
|
||||||
|
"valor_total": d.get("valor_total"),
|
||||||
|
"examenes_det": d.get("examenes_det", []),
|
||||||
|
} for d in detalle_raw],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO sync_wa_log
|
||||||
|
(user_id, origen, modo, total, created, skipped, updated, errores, errores_det, detalle_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
origen,
|
||||||
|
modo,
|
||||||
|
resultado.get("total", 0),
|
||||||
|
resultado.get("created", 0),
|
||||||
|
resultado.get("skipped", 0),
|
||||||
|
resultado.get("updated", 0),
|
||||||
|
resultado.get("errores", 0),
|
||||||
|
errores_det,
|
||||||
|
detalle_json,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_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": "lab",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_paciente(
|
||||||
|
row: dict,
|
||||||
|
url: str,
|
||||||
|
api_key: str,
|
||||||
|
client: Optional[httpx.AsyncClient] = None,
|
||||||
|
modo: str = "upsert",
|
||||||
|
examenes: Optional[list] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}."""
|
||||||
|
payload = mapear_paciente(row)
|
||||||
|
payload["modo"] = modo
|
||||||
|
if examenes:
|
||||||
|
payload["examenes"] = examenes
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as json_err:
|
||||||
|
raw = resp.text[:300].strip()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"action": "error",
|
||||||
|
"message": f"HTTP {resp.status_code} — respuesta no JSON: {raw!r}",
|
||||||
|
"doc": payload["numero_documento"],
|
||||||
|
"nombre": payload["nombre_completo"],
|
||||||
|
}
|
||||||
|
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, modo: str = "upsert",
|
||||||
|
examenes_map: Optional[dict] = None, concurrencia: int = 10) -> dict:
|
||||||
|
"""Envía una lista de filas de pacientes en paralelo. Retorna resumen {total, created, skipped, updated, errores}."""
|
||||||
|
resultado = {"total": len(rows), "created": 0, "skipped": 0, "updated": 0, "errores": 0, "detalle": []}
|
||||||
|
sem = asyncio.Semaphore(concurrencia)
|
||||||
|
|
||||||
|
async def _enviar(row: dict, client: httpx.AsyncClient) -> dict:
|
||||||
|
async with sem:
|
||||||
|
doc = str(row.get("DOCIDENT") or "").strip()
|
||||||
|
examenes = examenes_map.get(doc) if examenes_map else None
|
||||||
|
return await sync_paciente(row, url, api_key, client, modo, examenes)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
tasks = [_enviar(row, client) for row in rows]
|
||||||
|
resultados = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for r in resultados:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
resultado["errores"] += 1
|
||||||
|
resultado["detalle"].append({"ok": False, "action": "error", "message": str(r), "doc": "", "nombre": ""})
|
||||||
|
continue
|
||||||
|
if r["ok"]:
|
||||||
|
action = r["action"]
|
||||||
|
if action == "created":
|
||||||
|
resultado["created"] += 1
|
||||||
|
elif action == "skipped":
|
||||||
|
resultado["skipped"] += 1
|
||||||
|
else:
|
||||||
|
resultado["updated"] += 1
|
||||||
|
else:
|
||||||
|
resultado["errores"] += 1
|
||||||
|
resultado["detalle"].append(r)
|
||||||
|
|
||||||
|
return resultado
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Actividad{% endblock %}
|
||||||
|
{% block header %}Registro de Actividad{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex gap-2 mb-5">
|
||||||
|
<a href="/logs" class="px-4 py-2 rounded-lg text-sm font-medium bg-white border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i> Envíos TNS
|
||||||
|
</a>
|
||||||
|
<span class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">
|
||||||
|
<i class="fas fa-user-clock mr-1"></i> Actividad usuarios
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
|
||||||
|
<form method="get" action="/logs/actividad" class="p-4">
|
||||||
|
<input type="hidden" name="offset" value="0">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Usuario</label>
|
||||||
|
<select name="username" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{% for u in usuarios %}
|
||||||
|
<option value="{{ u }}" {{ 'selected' if filtro_username == u }}>{{ u }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Acción</label>
|
||||||
|
<select name="accion" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[150px]">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{% for a in acciones %}
|
||||||
|
<option value="{{ a }}" {{ 'selected' if filtro_accion == a }}>{{ a }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Desde</label>
|
||||||
|
<input type="date" name="fecha_desde" value="{{ filtro_fecha_desde }}"
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Hasta</label>
|
||||||
|
<input type="date" name="fecha_hasta" value="{{ filtro_fecha_hasta }}"
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
||||||
|
<i class="fas fa-search mr-1"></i> Filtrar
|
||||||
|
</button>
|
||||||
|
<a href="/logs/actividad" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Limpiar">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800"><i class="fas fa-list mr-2 text-gray-400"></i>Eventos</h3>
|
||||||
|
<span class="text-xs text-gray-400">{{ total }} registro(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Fecha/Hora</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Usuario</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Acción</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Detalle</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-2.5 text-xs text-gray-500 font-mono whitespace-nowrap">
|
||||||
|
{{ r.created_at[:16].replace('T',' ') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="font-medium text-gray-700">{{ r.username }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
{% set a = r.accion %}
|
||||||
|
{% if a == 'login' %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">Login</span>
|
||||||
|
{% elif a == 'login_fallido' %}
|
||||||
|
<span class="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-medium">Login fallido</span>
|
||||||
|
{% elif a == 'rda_enviado' %}
|
||||||
|
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">RDA enviado</span>
|
||||||
|
{% elif a == 'rda_masivo' %}
|
||||||
|
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">RDA masivo</span>
|
||||||
|
{% elif a == 'rda_directo' %}
|
||||||
|
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">RDA directo</span>
|
||||||
|
{% elif a == 'automation_run' %}
|
||||||
|
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded-full text-xs font-medium">Automatización</span>
|
||||||
|
{% elif a == 'contrato_creado' %}
|
||||||
|
<span class="px-2 py-0.5 bg-teal-100 text-teal-700 rounded-full text-xs font-medium">Contrato creado</span>
|
||||||
|
{% elif a == 'contrato_editado' %}
|
||||||
|
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">Contrato editado</span>
|
||||||
|
{% elif a == 'contrato_toggle' %}
|
||||||
|
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full text-xs font-medium">Contrato toggle</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">{{ a }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-xs text-gray-600 max-w-xs truncate" title="{{ r.detalle }}">
|
||||||
|
{{ r.detalle }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-xs text-gray-400 font-mono">{{ r.ip }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-4 py-10 text-center text-gray-400">
|
||||||
|
No hay eventos registrados aún
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if has_more or offset > 0 %}
|
||||||
|
<div class="px-6 py-3 border-t border-gray-100 flex justify-between items-center">
|
||||||
|
{% if offset > 0 %}
|
||||||
|
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ [offset - page_size, 0]|max }}"
|
||||||
|
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
|
||||||
|
<i class="fas fa-chevron-left mr-1"></i> Anterior
|
||||||
|
</a>
|
||||||
|
{% else %}<span></span>{% endif %}
|
||||||
|
<span class="text-xs text-gray-400">{{ offset + 1 }}–{{ [offset + page_size, total]|min }} de {{ total }}</span>
|
||||||
|
{% if has_more %}
|
||||||
|
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ offset + page_size }}"
|
||||||
|
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
|
||||||
|
Siguiente <i class="fas fa-chevron-right ml-1"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}<span></span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+539
-161
@@ -4,101 +4,154 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-4xl space-y-6">
|
<div class="max-w-4xl space-y-6">
|
||||||
|
|
||||||
<!-- Panel de control -->
|
<!-- ─── Panel de control ─────────────────────────────────────────────────── -->
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="font-semibold text-gray-800">
|
<h3 class="font-semibold text-gray-800">
|
||||||
<i class="fas fa-paper-plane mr-2 text-blue-500"></i>Envío automático por fecha
|
<i class="fas fa-paper-plane mr-2 text-blue-500"></i>Envío automático por fecha
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Selecciona una fecha para ver cuántos pacientes y RDA se enviarán a TNS.
|
Selecciona fecha y pasos activos para enviar a TNS.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-4">
|
||||||
<div class="flex items-end gap-4">
|
<!-- Inputs + Botones -->
|
||||||
<div class="flex-1">
|
<div class="flex items-end gap-3 flex-wrap">
|
||||||
|
<div class="flex-1 min-w-36">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha de atención</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha de atención</label>
|
||||||
<input type="date" id="fecha-input" value="{{ today }}"
|
<input type="date" id="fecha-input" value="{{ today }}"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button onclick="previewAutomation()" id="btn-preview"
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Convenio <span class="text-xs text-gray-400">(vacío = todos)</span></label>
|
class="px-5 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
|
||||||
<input type="text" id="contrato-input" placeholder="ej: 011"
|
|
||||||
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
<button onclick="previewAutomation()"
|
|
||||||
id="btn-preview"
|
|
||||||
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
|
|
||||||
<i class="fas fa-eye"></i> Vista previa
|
<i class="fas fa-eye"></i> Vista previa
|
||||||
</button>
|
</button>
|
||||||
<button onclick="abrirModalConfirmar()"
|
<button onclick="abrirModalConfirmar()" id="btn-run" disabled
|
||||||
id="btn-run"
|
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
<i class="fas fa-play"></i> Ejecutar
|
||||||
disabled>
|
</button>
|
||||||
<i class="fas fa-play"></i> Ejecutar envío
|
</div>
|
||||||
|
|
||||||
|
<!-- Paso chips -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 font-medium mr-1">Pasos:</span>
|
||||||
|
<button id="chip-terceros" onclick="togglePaso('terceros')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-blue-500 bg-blue-500 text-white">
|
||||||
|
<i class="fas fa-user"></i> Terceros
|
||||||
|
</button>
|
||||||
|
<button id="chip-rda" onclick="togglePaso('rda')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-purple-500 bg-purple-500 text-white">
|
||||||
|
<i class="fas fa-exchange-alt"></i> RDA Servicios
|
||||||
|
</button>
|
||||||
|
<button id="chip-preserv" onclick="togglePaso('preserv')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-orange-500 bg-orange-500 text-white">
|
||||||
|
<i class="fas fa-file-alt"></i> Pre-servicios
|
||||||
|
</button>
|
||||||
|
<button id="chip-ventas" onclick="togglePaso('ventas')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-emerald-500 bg-emerald-500 text-white">
|
||||||
|
<i class="fas fa-file-invoice-dollar"></i> Ventas
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vista previa de datos -->
|
<!-- ─── Vista previa ─────────────────────────────────────────────────────── -->
|
||||||
<div id="preview-section" class="hidden bg-white rounded-xl shadow-sm border border-gray-200">
|
<div id="preview-section" class="hidden bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-gray-800"><i class="fas fa-search mr-2 text-gray-500"></i>Resumen de la fecha</h3>
|
<h3 class="font-semibold text-gray-800"><i class="fas fa-search mr-2 text-gray-500"></i>Resumen de la fecha</h3>
|
||||||
<span id="preview-fecha" class="text-sm text-gray-500"></span>
|
<span id="preview-fecha" class="text-sm text-gray-500"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<!-- Stat cards -->
|
||||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
<div class="grid grid-cols-5 gap-4">
|
||||||
|
<div id="card-terceros" class="bg-blue-50 rounded-lg p-4 text-center transition-opacity">
|
||||||
<div id="cnt-pacientes" class="text-3xl font-bold text-blue-600">0</div>
|
<div id="cnt-pacientes" class="text-3xl font-bold text-blue-600">0</div>
|
||||||
<div class="text-sm text-blue-700 mt-1">Pacientes</div>
|
<div class="text-sm text-blue-700 mt-1">Pacientes</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
<div id="card-rda" class="bg-purple-50 rounded-lg p-4 text-center transition-opacity">
|
||||||
<div id="cnt-recepciones" class="text-3xl font-bold text-purple-600">0</div>
|
<div id="cnt-recepciones" class="text-3xl font-bold text-purple-600">0</div>
|
||||||
<div class="text-sm text-purple-700 mt-1">Recepciones</div>
|
<div class="text-sm text-purple-700 mt-1">Recepciones</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-green-50 rounded-lg p-4 text-center">
|
<div id="card-examenes" class="bg-green-50 rounded-lg p-4 text-center transition-opacity">
|
||||||
<div id="cnt-examenes" class="text-3xl font-bold text-green-600">0</div>
|
<div id="cnt-examenes" class="text-3xl font-bold text-green-600">0</div>
|
||||||
<div class="text-sm text-green-700 mt-1">Exámenes</div>
|
<div class="text-sm text-green-700 mt-1">Exámenes</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="card-preserv" class="bg-orange-50 rounded-lg p-4 text-center transition-opacity">
|
||||||
|
<div id="cnt-preservicios" class="text-3xl font-bold text-orange-600">0</div>
|
||||||
|
<div class="text-sm text-orange-700 mt-1">Pre-servicios</div>
|
||||||
|
<div class="text-xs text-orange-400">RCXC / SC</div>
|
||||||
|
</div>
|
||||||
|
<div id="card-ventas" class="bg-emerald-50 rounded-lg p-4 text-center transition-opacity">
|
||||||
|
<div id="cnt-ventas" class="text-3xl font-bold text-emerald-600">0</div>
|
||||||
|
<div class="text-sm text-emerald-700 mt-1">Ventas</div>
|
||||||
|
<div class="text-xs text-emerald-400">Ventas/Crear</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-gray-600 mb-2">Pacientes — clic para ver sus recepciones:</p>
|
<!-- Terceros / RDA -->
|
||||||
|
<div id="sec-terceros-rda">
|
||||||
|
<p class="text-xs font-medium text-gray-600 mb-2">Pacientes — clic para ver recepciones:</p>
|
||||||
<div id="preview-table" class="border border-gray-100 rounded-lg overflow-hidden text-sm"></div>
|
<div id="preview-table" class="border border-gray-100 rounded-lg overflow-hidden text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pre-servicios -->
|
||||||
|
<div id="preservicios-section" class="hidden">
|
||||||
|
<p class="text-xs font-medium text-orange-600 mb-2 mt-2">Pre-servicios RCXC / SC — clic para ver JSON:</p>
|
||||||
|
<div id="preview-ps-table" class="border border-orange-100 rounded-lg overflow-hidden text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ventas -->
|
||||||
|
<div id="ventas-section" class="hidden">
|
||||||
|
<p class="text-xs font-medium text-emerald-600 mb-2 mt-2">Facturas Venta — clic para ver JSON:</p>
|
||||||
|
<div id="preview-vta-table" class="border border-emerald-100 rounded-lg overflow-hidden text-sm"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progreso -->
|
<!-- ─── Resultados ────────────────────────────────────────────────────────── -->
|
||||||
<div id="progress-section" class="hidden space-y-4">
|
<div id="results-section" class="hidden space-y-3">
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<!-- Barra de resumen + filtros -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 px-5 py-4">
|
||||||
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">1</div>
|
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<span class="font-semibold text-gray-800">Terceros (pacientes)</span>
|
<!-- Chips por paso -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 shrink-0">RESULTADO</span>
|
||||||
|
<div id="res-summary" class="flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="flex items-center gap-1 bg-gray-100 rounded-lg p-1 shrink-0">
|
||||||
|
<button id="filter-btn-errores" onclick="setResultFilter('errores')"
|
||||||
|
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
|
||||||
|
<i class="fas fa-times-circle mr-1"></i>Errores <span id="filter-cnt-errores" class="font-bold"></span>
|
||||||
|
</button>
|
||||||
|
<button id="filter-btn-advertencias" onclick="setResultFilter('advertencias')"
|
||||||
|
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
|
||||||
|
<i class="fas fa-exclamation-circle mr-1"></i>Ya existe <span id="filter-cnt-advertencias" class="font-bold"></span>
|
||||||
|
</button>
|
||||||
|
<button id="filter-btn-enviados" onclick="setResultFilter('enviados')"
|
||||||
|
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
|
||||||
|
<i class="fas fa-check-circle mr-1"></i>Enviados <span id="filter-cnt-enviados" class="font-bold"></span>
|
||||||
|
</button>
|
||||||
|
<button id="filter-btn-todos" onclick="setResultFilter('todos')"
|
||||||
|
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
|
||||||
|
<i class="fas fa-list mr-1"></i>Todos <span id="filter-cnt-todos" class="font-bold"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="p1-badge" class="text-sm text-gray-400">Esperando...</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="p1-detalle" class="divide-y divide-gray-100 hidden"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<!-- Lista unificada -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white text-sm font-bold">2</div>
|
<div id="results-list" class="divide-y divide-gray-100"></div>
|
||||||
<span class="font-semibold text-gray-800">RDA Paciente (recepciones)</span>
|
|
||||||
</div>
|
|
||||||
<span id="p2-badge" class="text-sm text-gray-400">Esperando...</span>
|
|
||||||
</div>
|
|
||||||
<div id="p2-detalle" class="divide-y divide-gray-100 hidden"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal de confirmación -->
|
<!-- ─── Modal de confirmación ────────────────────────────────────────────── -->
|
||||||
<div id="modal-confirmar" class="fixed inset-0 z-50 hidden">
|
<div id="modal-confirmar" class="fixed inset-0 z-50 hidden">
|
||||||
<div class="absolute inset-0 bg-black/50" onclick="cerrarModalConfirmar()"></div>
|
<div class="absolute inset-0 bg-black/50" onclick="cerrarModalConfirmar()"></div>
|
||||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white rounded-xl shadow-2xl">
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white rounded-xl shadow-2xl">
|
||||||
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
|
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
|
||||||
<i class="fas fa-shield-alt text-blue-500"></i> Validar envío
|
<i class="fas fa-shield-alt text-blue-500"></i> Validar envío
|
||||||
@@ -107,39 +160,43 @@
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm text-gray-600">
|
||||||
Se enviará la siguiente información a <strong>TNS (api.tns.co)</strong> para la fecha
|
Se enviará a <strong>TNS</strong> para la fecha <strong id="modal-fecha" class="text-blue-600"></strong>:
|
||||||
<strong id="modal-fecha" class="text-blue-600"></strong>:
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="grid grid-cols-5 gap-3">
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div id="mc-terceros" class="bg-blue-50 rounded-lg p-3 text-center transition-opacity">
|
||||||
<div class="bg-blue-50 rounded-lg p-3 text-center">
|
|
||||||
<div id="modal-cnt-pac" class="text-2xl font-bold text-blue-600">0</div>
|
<div id="modal-cnt-pac" class="text-2xl font-bold text-blue-600">0</div>
|
||||||
<div class="text-xs text-blue-700">Terceros</div>
|
<div class="text-xs text-blue-700">Terceros</div>
|
||||||
<div class="text-xs text-blue-400">Tercero/Crear</div>
|
<div class="text-xs text-blue-400">Tercero/Crear</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-purple-50 rounded-lg p-3 text-center">
|
<div id="mc-rda" class="bg-purple-50 rounded-lg p-3 text-center transition-opacity">
|
||||||
<div id="modal-cnt-rec" class="text-2xl font-bold text-purple-600">0</div>
|
<div id="modal-cnt-rec" class="text-2xl font-bold text-purple-600">0</div>
|
||||||
<div class="text-xs text-purple-700">Recepciones</div>
|
<div class="text-xs text-purple-700">RDA</div>
|
||||||
<div class="text-xs text-purple-400">RdaPaciente/Insertar</div>
|
<div class="text-xs text-purple-400">RdaPaciente/Insertar</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-green-50 rounded-lg p-3 text-center">
|
<div id="mc-examenes" class="bg-green-50 rounded-lg p-3 text-center transition-opacity">
|
||||||
<div id="modal-cnt-exa" class="text-2xl font-bold text-green-600">0</div>
|
<div id="modal-cnt-exa" class="text-2xl font-bold text-green-600">0</div>
|
||||||
<div class="text-xs text-green-700">Exámenes</div>
|
<div class="text-xs text-green-700">Exámenes</div>
|
||||||
<div class="text-xs text-green-400">detallePedido</div>
|
<div class="text-xs text-green-400">detallePedido</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mc-preserv" class="bg-orange-50 rounded-lg p-3 text-center transition-opacity">
|
||||||
|
<div id="modal-cnt-ps" class="text-2xl font-bold text-orange-600">0</div>
|
||||||
|
<div class="text-xs text-orange-700">Pre-servicios</div>
|
||||||
|
<div class="text-xs text-orange-400">RCXC / SC</div>
|
||||||
|
</div>
|
||||||
|
<div id="mc-ventas" class="bg-emerald-50 rounded-lg p-3 text-center transition-opacity">
|
||||||
|
<div id="modal-cnt-vta" class="text-2xl font-bold text-emerald-600">0</div>
|
||||||
|
<div class="text-xs text-emerald-700">Ventas</div>
|
||||||
|
<div class="text-xs text-emerald-400">Ventas/Crear</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-700 flex items-start gap-2">
|
<div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-700 flex items-start gap-2">
|
||||||
<i class="fas fa-exclamation-triangle mt-0.5 shrink-0"></i>
|
<i class="fas fa-exclamation-triangle mt-0.5 shrink-0"></i>
|
||||||
<span>Esta acción enviará datos reales a TNS. Los registros que ya existan serán actualizados. No se puede deshacer.</span>
|
<span>Esta acción enviará datos reales a TNS. Los registros existentes serán actualizados. No se puede deshacer.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="max-h-36 overflow-y-auto border border-gray-100 rounded-lg" id="modal-lista-pac"></div>
|
||||||
<div class="max-h-40 overflow-y-auto border border-gray-100 rounded-lg" id="modal-lista-pac"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
|
<div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
|
||||||
<button onclick="cerrarModalConfirmar()"
|
<button onclick="cerrarModalConfirmar()"
|
||||||
class="px-5 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200">
|
class="px-5 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200">
|
||||||
@@ -154,23 +211,56 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let _previewData = null;
|
let _previewData = null;
|
||||||
|
let _rdaJsonStore = {};
|
||||||
|
let _resultAll = [];
|
||||||
|
let _activeFilter = 'errores';
|
||||||
|
|
||||||
|
// ── Estado de pasos ──────────────────────────────────────────────────────────
|
||||||
|
const _pasos = new Set(['terceros','rda','preserv','ventas']);
|
||||||
|
const _pasoMeta = {
|
||||||
|
terceros: { border:'border-blue-500', bg:'bg-blue-500', text:'text-blue-600' },
|
||||||
|
rda: { border:'border-purple-500', bg:'bg-purple-500', text:'text-purple-600' },
|
||||||
|
preserv: { border:'border-orange-500', bg:'bg-orange-500', text:'text-orange-600' },
|
||||||
|
ventas: { border:'border-emerald-500',bg:'bg-emerald-500', text:'text-emerald-600'},
|
||||||
|
};
|
||||||
|
|
||||||
|
function togglePaso(paso) {
|
||||||
|
if (_pasos.has(paso)) _pasos.delete(paso);
|
||||||
|
else _pasos.add(paso);
|
||||||
|
const m = _pasoMeta[paso];
|
||||||
|
const btn = document.getElementById('chip-' + paso);
|
||||||
|
if (_pasos.has(paso)) {
|
||||||
|
btn.className = `inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all ${m.border} ${m.bg} text-white`;
|
||||||
|
} else {
|
||||||
|
btn.className = `inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all ${m.border} bg-white ${m.text}`;
|
||||||
|
}
|
||||||
|
document.getElementById('preview-section').classList.add('hidden');
|
||||||
|
document.getElementById('results-section').classList.add('hidden');
|
||||||
|
document.getElementById('btn-run').disabled = true;
|
||||||
|
_previewData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vista previa ─────────────────────────────────────────────────────────────
|
||||||
async function previewAutomation() {
|
async function previewAutomation() {
|
||||||
const fecha = document.getElementById('fecha-input').value;
|
const fecha = document.getElementById('fecha-input').value;
|
||||||
if (!fecha) { showToast('Selecciona una fecha', 'error'); return; }
|
if (!fecha) { showToast('Selecciona una fecha', 'error'); return; }
|
||||||
|
if (_pasos.size === 0) { showToast('Activa al menos un paso', 'error'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById('btn-preview');
|
const btn = document.getElementById('btn-preview');
|
||||||
showLoading(btn);
|
showLoading(btn);
|
||||||
document.getElementById('preview-section').classList.add('hidden');
|
document.getElementById('preview-section').classList.add('hidden');
|
||||||
document.getElementById('progress-section').classList.add('hidden');
|
document.getElementById('results-section').classList.add('hidden');
|
||||||
document.getElementById('btn-run').disabled = true;
|
document.getElementById('btn-run').disabled = true;
|
||||||
_previewData = null;
|
_previewData = null;
|
||||||
|
_rdaJsonStore = {};
|
||||||
|
|
||||||
const contrato = document.getElementById('contrato-input').value.trim();
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('fecha', fecha);
|
form.append('fecha', fecha);
|
||||||
if (contrato) form.append('contrato', contrato);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/automation/preview', { method: 'POST', body: form, credentials: 'include' });
|
const resp = await fetch('/automation/preview', { method: 'POST', body: form, credentials: 'include' });
|
||||||
@@ -180,66 +270,168 @@ async function previewAutomation() {
|
|||||||
if (!data.success) { showToast(data.message || 'Error al consultar', 'error'); return; }
|
if (!data.success) { showToast(data.message || 'Error al consultar', 'error'); return; }
|
||||||
|
|
||||||
_previewData = data;
|
_previewData = data;
|
||||||
document.getElementById('cnt-pacientes').textContent = data.pacientes;
|
|
||||||
document.getElementById('cnt-recepciones').textContent = data.recepciones;
|
|
||||||
document.getElementById('cnt-examenes').textContent = data.examenes;
|
|
||||||
document.getElementById('preview-fecha').textContent = data.fecha;
|
|
||||||
|
|
||||||
const table = document.getElementById('preview-table');
|
// Stat cards
|
||||||
if (data.pacientes === 0) {
|
document.getElementById('cnt-pacientes').textContent = data.pacientes;
|
||||||
table.innerHTML = '<div class="p-4 text-gray-400 text-center">No hay pacientes para esta fecha</div>';
|
document.getElementById('cnt-recepciones').textContent = data.recepciones;
|
||||||
showToast('No hay datos para esa fecha', 'warning');
|
document.getElementById('cnt-examenes').textContent = data.examenes;
|
||||||
} else {
|
document.getElementById('cnt-preservicios').textContent= data.preservicios || 0;
|
||||||
table.innerHTML = data.pacientes_preview.map((p, i) => {
|
document.getElementById('cnt-ventas').textContent = data.ventas || 0;
|
||||||
const rdaRows = p.rda.map(r => `
|
document.getElementById('preview-fecha').textContent = data.fecha;
|
||||||
<div class="flex items-center gap-3 px-6 py-1.5 bg-blue-50 border-t border-blue-100 text-xs">
|
|
||||||
<span class="text-blue-400 w-4"><i class="fas fa-flask"></i></span>
|
document.getElementById('card-terceros').style.opacity = _pasos.has('terceros') ? '1' : '0.3';
|
||||||
<span class="font-mono text-blue-700 w-28">${r.factura}</span>
|
document.getElementById('card-rda').style.opacity = _pasos.has('rda') ? '1' : '0.3';
|
||||||
<span class="text-blue-600 w-24">${r.fecha}</span>
|
document.getElementById('card-examenes').style.opacity = (_pasos.has('rda')||_pasos.has('preserv')) ? '1' : '0.3';
|
||||||
<span class="text-blue-600 w-20">${r.examenes} examen(es)</span>
|
document.getElementById('card-preserv').style.opacity = _pasos.has('preserv') ? '1' : '0.3';
|
||||||
<span class="text-blue-600 flex-1">${r.diag}</span>
|
document.getElementById('card-ventas').style.opacity = _pasos.has('ventas') ? '1' : '0.3';
|
||||||
<span class="text-blue-700 font-medium">$${Number(r.valor).toLocaleString('es-CO')}</span>
|
|
||||||
</div>`).join('');
|
// Habilitar run solo si hay datos para al menos un paso activo
|
||||||
return `
|
const hasDatos = (
|
||||||
|
(_pasos.has('terceros') && data.pacientes > 0) ||
|
||||||
|
(_pasos.has('rda') && data.recepciones > 0) ||
|
||||||
|
(_pasos.has('preserv') && data.preservicios > 0) ||
|
||||||
|
(_pasos.has('ventas') && data.ventas > 0)
|
||||||
|
);
|
||||||
|
document.getElementById('btn-run').disabled = !hasDatos;
|
||||||
|
|
||||||
|
// Sección Terceros/RDA
|
||||||
|
const secTR = document.getElementById('sec-terceros-rda');
|
||||||
|
if (_pasos.has('terceros') || _pasos.has('rda')) {
|
||||||
|
secTR.classList.remove('hidden');
|
||||||
|
const table = document.getElementById('preview-table');
|
||||||
|
if (!data.pacientes_preview || data.pacientes_preview.length === 0) {
|
||||||
|
table.innerHTML = '<div class="p-4 text-gray-400 text-center">No hay pacientes para esta fecha</div>';
|
||||||
|
} else {
|
||||||
|
table.innerHTML = data.pacientes_preview.map((p, i) => {
|
||||||
|
const rdaRows = (p.rda || []).map((r, j) => {
|
||||||
|
const key = `rda-json-${i}-${j}`;
|
||||||
|
_rdaJsonStore[key] = r.json;
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 px-6 py-1.5 bg-blue-50 border-t border-blue-100 text-xs cursor-pointer hover:bg-blue-100 select-none"
|
||||||
|
onclick="toggleJsonBlock('${key}','rdachev-${i}-${j}')">
|
||||||
|
<i class="fas fa-chevron-right text-blue-300 text-xs transition-transform" id="rdachev-${i}-${j}"></i>
|
||||||
|
<span class="text-blue-400 w-4"><i class="fas fa-flask"></i></span>
|
||||||
|
<span class="font-mono text-blue-700 w-28">${escHtml(r.factura)}</span>
|
||||||
|
<span class="text-blue-600 w-24">${escHtml(r.fecha)}</span>
|
||||||
|
<span class="font-mono bg-blue-100 text-blue-800 px-1.5 py-0.5 rounded w-14 text-center">${escHtml(r.contrato||'—')}</span>
|
||||||
|
<span class="text-blue-600 w-20">${r.examenes} exam.</span>
|
||||||
|
<span class="text-blue-600 flex-1 truncate">${escHtml(r.diag||'')}</span>
|
||||||
|
<span class="text-blue-700 font-medium">$${Number(r.valor||0).toLocaleString('es-CO')}</span>
|
||||||
|
</div>
|
||||||
|
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-blue-100"></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer select-none"
|
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer select-none"
|
||||||
onclick="toggleRda('rda-${i}', this)">
|
onclick="toggleSubRow('rda-${i}','chev-${i}')">
|
||||||
<i class="fas fa-chevron-right text-gray-300 text-xs transition-transform duration-150" id="chev-${i}"></i>
|
<i class="fas fa-chevron-right text-gray-300 text-xs transition-transform" id="chev-${i}"></i>
|
||||||
<span class="text-gray-400 text-xs w-8">${p.tipo}</span>
|
<span class="text-gray-400 text-xs w-8">${escHtml(p.tipo)}</span>
|
||||||
<span class="text-gray-500 w-32 font-mono text-xs">${p.doc}</span>
|
<span class="text-gray-500 w-32 font-mono text-xs">${escHtml(p.doc)}</span>
|
||||||
<span class="text-gray-700 flex-1">${p.nombre}</span>
|
<span class="text-gray-700 flex-1">${escHtml(p.nombre)}</span>
|
||||||
<span class="text-xs ${p.rda.length > 0 ? 'text-blue-600' : 'text-gray-300'}">${p.rda.length} RDA</span>
|
<span class="text-xs ${p.rda&&p.rda.length>0?'text-blue-600':'text-gray-300'}">${(p.rda||[]).length} RDA</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="rda-${i}" class="hidden">${rdaRows || '<div class="px-6 py-2 text-xs text-gray-400 bg-gray-50">Sin recepciones</div>'}</div>
|
<div id="rda-${i}" class="hidden">${rdaRows||'<div class="px-6 py-2 text-xs text-gray-400 bg-gray-50">Sin recepciones</div>'}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
}
|
||||||
document.getElementById('btn-run').disabled = false;
|
} else {
|
||||||
showToast(`${data.pacientes} paciente(s) · ${data.recepciones} recepción(es)`, 'success');
|
secTR.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-servicios
|
||||||
|
const psSection = document.getElementById('preservicios-section');
|
||||||
|
if (_pasos.has('preserv') && data.preservicios_preview && data.preservicios_preview.length > 0) {
|
||||||
|
document.getElementById('preview-ps-table').innerHTML = data.preservicios_preview.map((ps, i) => {
|
||||||
|
const key = `ps-json-${i}`;
|
||||||
|
_rdaJsonStore[key] = ps.json;
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-orange-50 cursor-pointer select-none border-b border-orange-100"
|
||||||
|
onclick="toggleJsonBlock('${key}','pschev-${i}')">
|
||||||
|
<i class="fas fa-chevron-right text-orange-300 text-xs transition-transform" id="pschev-${i}"></i>
|
||||||
|
<span class="font-mono text-orange-700 font-medium w-32">${escHtml(ps.factura)}</span>
|
||||||
|
<span class="text-gray-500 w-24 text-xs">${escHtml(ps.fecha)}</span>
|
||||||
|
<span class="text-gray-600 flex-1 text-xs">Pac. ${escHtml(String(ps.paciente))}</span>
|
||||||
|
<span class="text-xs text-orange-500">${ps.examenes} examen(es)</span>
|
||||||
|
</div>
|
||||||
|
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-orange-100"></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
psSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
psSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ventas - errores de generación
|
||||||
|
if (data.ventas_gen_errors && data.ventas_gen_errors.length > 0) {
|
||||||
|
data.ventas_gen_errors.forEach(e => {
|
||||||
|
showToast(`Error generando venta ${e.factura}: ${e.error}`, 'error');
|
||||||
|
console.error('ventas_gen_error', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ventas
|
||||||
|
const vtaSection = document.getElementById('ventas-section');
|
||||||
|
if (_pasos.has('ventas') && data.ventas_preview && data.ventas_preview.length > 0) {
|
||||||
|
document.getElementById('preview-vta-table').innerHTML = data.ventas_preview.map((vta, i) => {
|
||||||
|
const key = `vta-json-${i}`;
|
||||||
|
_rdaJsonStore[key] = vta.json;
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-emerald-50 cursor-pointer select-none border-b border-emerald-100"
|
||||||
|
onclick="toggleJsonBlock('${key}','vtachev-${i}')">
|
||||||
|
<i class="fas fa-chevron-right text-emerald-300 text-xs transition-transform" id="vtachev-${i}"></i>
|
||||||
|
<span class="font-mono text-emerald-700 font-medium w-32">${escHtml(vta.factura)}</span>
|
||||||
|
<span class="text-gray-500 w-24 text-xs">${escHtml(vta.fecha)}</span>
|
||||||
|
<span class="font-mono bg-emerald-100 text-emerald-800 px-1.5 py-0.5 rounded text-xs w-14 text-center">${escHtml(vta.contrato||'—')}</span>
|
||||||
|
<span class="text-gray-600 flex-1 text-xs">Pac. ${escHtml(String(vta.paciente))}</span>
|
||||||
|
<span class="text-xs text-emerald-500">${vta.examenes} examen(es)</span>
|
||||||
|
</div>
|
||||||
|
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-emerald-100"></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
vtaSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
vtaSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('preview-section').classList.remove('hidden');
|
document.getElementById('preview-section').classList.remove('hidden');
|
||||||
} catch (e) {
|
|
||||||
|
if (!hasDatos) {
|
||||||
|
showToast('Sin datos para los pasos seleccionados en esa fecha', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast(`${data.pacientes} pac · ${data.recepciones} RDA · ${data.preservicios||0} PS · ${data.ventas||0} VTA`, 'success');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
hideLoading(btn, '<i class="fas fa-eye"></i> Vista previa');
|
hideLoading(btn, '<i class="fas fa-eye"></i> Vista previa');
|
||||||
showToast('Error de conexión', 'error');
|
showToast('Error de conexión', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Modal ────────────────────────────────────────────────────────────────────
|
||||||
function abrirModalConfirmar() {
|
function abrirModalConfirmar() {
|
||||||
if (!_previewData) return;
|
if (!_previewData) return;
|
||||||
const d = _previewData;
|
const d = _previewData;
|
||||||
document.getElementById('modal-fecha').textContent = d.fecha;
|
document.getElementById('modal-fecha').textContent = d.fecha;
|
||||||
document.getElementById('modal-cnt-pac').textContent = d.pacientes;
|
document.getElementById('mc-terceros').style.opacity = _pasos.has('terceros') ? '1' : '0.3';
|
||||||
document.getElementById('modal-cnt-rec').textContent = d.recepciones;
|
document.getElementById('mc-rda').style.opacity = _pasos.has('rda') ? '1' : '0.3';
|
||||||
document.getElementById('modal-cnt-exa').textContent = d.examenes;
|
document.getElementById('mc-examenes').style.opacity = (_pasos.has('rda')||_pasos.has('preserv')) ? '1' : '0.3';
|
||||||
|
document.getElementById('mc-preserv').style.opacity = _pasos.has('preserv') ? '1' : '0.3';
|
||||||
document.getElementById('modal-lista-pac').innerHTML = d.pacientes_preview.map(p => `
|
document.getElementById('mc-ventas').style.opacity = _pasos.has('ventas') ? '1' : '0.3';
|
||||||
|
document.getElementById('modal-cnt-pac').textContent = _pasos.has('terceros') ? d.pacientes : '—';
|
||||||
|
document.getElementById('modal-cnt-rec').textContent = _pasos.has('rda') ? d.recepciones : '—';
|
||||||
|
document.getElementById('modal-cnt-exa').textContent = (_pasos.has('rda')||_pasos.has('preserv')) ? d.examenes : '—';
|
||||||
|
document.getElementById('modal-cnt-ps').textContent = _pasos.has('preserv') ? (d.preservicios||0) : '—';
|
||||||
|
document.getElementById('modal-cnt-vta').textContent = _pasos.has('ventas') ? (d.ventas||0) : '—';
|
||||||
|
document.getElementById('modal-lista-pac').innerHTML = (d.pacientes_preview || []).map(p => `
|
||||||
<div class="flex items-center gap-3 px-3 py-1.5 border-b border-gray-50 text-xs last:border-0">
|
<div class="flex items-center gap-3 px-3 py-1.5 border-b border-gray-50 text-xs last:border-0">
|
||||||
<span class="text-gray-400 w-6">${p.tipo}</span>
|
<span class="text-gray-400 w-6">${escHtml(p.tipo)}</span>
|
||||||
<span class="font-mono text-gray-500 w-28">${p.doc}</span>
|
<span class="font-mono text-gray-500 w-28">${escHtml(p.doc)}</span>
|
||||||
<span class="text-gray-700 flex-1 truncate">${p.nombre}</span>
|
<span class="text-gray-700 flex-1 truncate">${escHtml(p.nombre)}</span>
|
||||||
<span class="text-blue-500">${p.rda.length} RDA</span>
|
<span class="text-blue-500">${(p.rda||[]).length} RDA</span>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
document.getElementById('modal-confirmar').classList.remove('hidden');
|
document.getElementById('modal-confirmar').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,92 +439,278 @@ function cerrarModalConfirmar() {
|
|||||||
document.getElementById('modal-confirmar').classList.add('hidden');
|
document.getElementById('modal-confirmar').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ejecutar envío ───────────────────────────────────────────────────────────
|
||||||
async function confirmarEnvio() {
|
async function confirmarEnvio() {
|
||||||
cerrarModalConfirmar();
|
cerrarModalConfirmar();
|
||||||
const fecha = document.getElementById('fecha-input').value;
|
const fecha = document.getElementById('fecha-input').value;
|
||||||
|
|
||||||
const btn = document.getElementById('btn-run');
|
const btn = document.getElementById('btn-run');
|
||||||
showLoading(btn);
|
showLoading(btn);
|
||||||
document.getElementById('btn-preview').disabled = true;
|
document.getElementById('btn-preview').disabled = true;
|
||||||
document.getElementById('progress-section').classList.remove('hidden');
|
document.getElementById('results-section').classList.add('hidden');
|
||||||
document.getElementById('p1-badge').textContent = 'Enviando...';
|
|
||||||
document.getElementById('p1-badge').className = 'text-sm text-blue-500';
|
|
||||||
document.getElementById('p2-badge').textContent = 'Esperando...';
|
|
||||||
document.getElementById('p2-badge').className = 'text-sm text-gray-400';
|
|
||||||
|
|
||||||
const contrato = document.getElementById('contrato-input').value.trim();
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('fecha', fecha);
|
form.append('fecha', fecha);
|
||||||
if (contrato) form.append('contrato', contrato);
|
form.append('pasos', [..._pasos].join(','));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/automation/run', { method: 'POST', body: form, credentials: 'include' });
|
const resp = await fetch('/automation/run', { method: 'POST', body: form, credentials: 'include' });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar envío');
|
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar');
|
||||||
document.getElementById('btn-preview').disabled = false;
|
document.getElementById('btn-preview').disabled = false;
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showToast(data.message || 'Error', 'error');
|
showToast(data.message || 'Error', 'error');
|
||||||
document.getElementById('p1-badge').textContent = 'Error';
|
|
||||||
document.getElementById('p1-badge').className = 'text-sm text-red-500';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
renderResults(data.resultado);
|
||||||
const r = data.resultado;
|
} catch(e) {
|
||||||
renderBadge('p1-badge', r.paso1_terceros.enviados, r.paso1_terceros.errores);
|
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar');
|
||||||
renderDetalle('p1-detalle', r.paso1_terceros.detalle.map(d => ({
|
|
||||||
col1: d.doc, col2: d.nombre,
|
|
||||||
col3: `${d.ok ? '✅' : '❌'} ${d.msg || ''}`, ok: d.ok,
|
|
||||||
})));
|
|
||||||
renderBadge('p2-badge', r.paso2_rda.enviados, r.paso2_rda.errores);
|
|
||||||
renderDetalle('p2-detalle', r.paso2_rda.detalle.map(d => ({
|
|
||||||
col1: `Fac. ${d.factura}`,
|
|
||||||
col2: `Pac. ${d.paciente} — ${d.examenes} examen(es)`,
|
|
||||||
col3: `${d.ok ? '✅' : '❌'} ${d.msg || ''}`, ok: d.ok,
|
|
||||||
})));
|
|
||||||
|
|
||||||
const totalErr = r.paso1_terceros.errores + r.paso2_rda.errores;
|
|
||||||
showToast(totalErr === 0 ? 'Envío completado sin errores' : `Completado con ${totalErr} error(es)`,
|
|
||||||
totalErr === 0 ? 'success' : 'warning');
|
|
||||||
} catch (e) {
|
|
||||||
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar envío');
|
|
||||||
document.getElementById('btn-preview').disabled = false;
|
document.getElementById('btn-preview').disabled = false;
|
||||||
showToast('Error de conexión', 'error');
|
showToast('Error de conexión', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRda(id, row) {
|
// ── Definiciones de pasos ────────────────────────────────────────────────────
|
||||||
|
const _STEP_DEFS = [
|
||||||
|
{ pasoKey:'terceros', resultKey:'paso1_terceros', label:'Terceros', num:1, color:'blue', tipo:'tercero', icon:'fa-user' },
|
||||||
|
{ pasoKey:'rda', resultKey:'paso2_rda', label:'RDA', num:2, color:'purple', tipo:'rda', icon:'fa-exchange-alt' },
|
||||||
|
{ pasoKey:'preserv', resultKey:'paso3_preserv', label:'Pre-servicios', num:3, color:'orange', tipo:'preserv', icon:'fa-file-alt' },
|
||||||
|
{ pasoKey:'ventas', resultKey:'paso4_ventas', label:'Ventas', num:4, color:'emerald',tipo:'venta', icon:'fa-file-invoice-dollar'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const _BADGE = {
|
||||||
|
blue: 'bg-blue-100 text-blue-700',
|
||||||
|
purple: 'bg-purple-100 text-purple-700',
|
||||||
|
orange: 'bg-orange-100 text-orange-700',
|
||||||
|
emerald:'bg-emerald-100 text-emerald-700',
|
||||||
|
};
|
||||||
|
const _CHIP = {
|
||||||
|
blue: 'bg-blue-100 text-blue-800',
|
||||||
|
purple: 'bg-purple-100 text-purple-800',
|
||||||
|
orange: 'bg-orange-100 text-orange-800',
|
||||||
|
emerald:'bg-emerald-100 text-emerald-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Renderizar resultados ────────────────────────────────────────────────────
|
||||||
|
function renderResults(r) {
|
||||||
|
const activePasos = r.pasos || _STEP_DEFS.map(s => s.pasoKey);
|
||||||
|
const activeSteps = _STEP_DEFS.filter(s => activePasos.includes(s.pasoKey));
|
||||||
|
|
||||||
|
// Summary chips
|
||||||
|
document.getElementById('res-summary').innerHTML = activeSteps.map(s => {
|
||||||
|
const d = r[s.resultKey];
|
||||||
|
const hasErr = d.errores > 0;
|
||||||
|
return `<span class="${_CHIP[s.color]} rounded-full px-3 py-1 text-xs font-semibold inline-flex items-center gap-1.5">
|
||||||
|
<i class="fas ${s.icon} text-xs opacity-70"></i> ${s.label}
|
||||||
|
<span class="text-green-700">✓${d.enviados}</span>
|
||||||
|
${hasErr ? `<span class="text-red-600 font-bold">✗${d.errores}</span>` : ''}
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Clasificar todos los items
|
||||||
|
_resultAll = [];
|
||||||
|
activeSteps.forEach(s => {
|
||||||
|
(r[s.resultKey].detalle || []).forEach((item, i) => {
|
||||||
|
const st = item.status || (item.ok ? 'success' : 'error');
|
||||||
|
_resultAll.push({ item, step: s, idx: `${s.pasoKey}-${i}`, status: st });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const errCount = _resultAll.filter(e => e.status === 'error').length;
|
||||||
|
const warnCount = _resultAll.filter(e => e.status === 'warning').length;
|
||||||
|
const okCount = _resultAll.filter(e => e.status === 'success').length;
|
||||||
|
|
||||||
|
document.getElementById('filter-cnt-errores').textContent = `(${errCount})`;
|
||||||
|
document.getElementById('filter-cnt-advertencias').textContent = `(${warnCount})`;
|
||||||
|
document.getElementById('filter-cnt-enviados').textContent = `(${okCount})`;
|
||||||
|
document.getElementById('filter-cnt-todos').textContent = `(${_resultAll.length})`;
|
||||||
|
|
||||||
|
const defaultFilter = errCount > 0 ? 'errores' : warnCount > 0 ? 'advertencias' : 'todos';
|
||||||
|
setResultFilter(defaultFilter);
|
||||||
|
|
||||||
|
document.getElementById('results-section').classList.remove('hidden');
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
errCount === 0 ? `Envío completado · ${okCount} OK · ${warnCount} ya existían` : `${errCount} fallo(s) · ${okCount} OK · ${warnCount} ya existían`,
|
||||||
|
errCount === 0 ? 'success' : 'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtro de resultados ─────────────────────────────────────────────────────
|
||||||
|
function setResultFilter(filter) {
|
||||||
|
_activeFilter = filter;
|
||||||
|
|
||||||
|
// Actualizar estilos de botones
|
||||||
|
const configs = {
|
||||||
|
errores: { active: 'bg-white shadow text-red-600', inactive: 'text-gray-500 hover:text-gray-700' },
|
||||||
|
advertencias: { active: 'bg-white shadow text-yellow-600', inactive: 'text-gray-500 hover:text-gray-700' },
|
||||||
|
enviados: { active: 'bg-white shadow text-green-600', inactive: 'text-gray-500 hover:text-gray-700' },
|
||||||
|
todos: { active: 'bg-white shadow text-gray-700', inactive: 'text-gray-500 hover:text-gray-700' },
|
||||||
|
};
|
||||||
|
['errores','advertencias','enviados','todos'].forEach(f => {
|
||||||
|
const btn = document.getElementById(`filter-btn-${f}`);
|
||||||
|
const base = 'px-3 py-1 text-xs font-medium rounded-md transition-all';
|
||||||
|
btn.className = `${base} ${f === filter ? configs[f].active : configs[f].inactive}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let items;
|
||||||
|
if (filter === 'errores') items = _resultAll.filter(e => e.status === 'error');
|
||||||
|
else if (filter === 'advertencias') items = _resultAll.filter(e => e.status === 'warning');
|
||||||
|
else if (filter === 'enviados') items = _resultAll.filter(e => e.status === 'success');
|
||||||
|
else items = _resultAll;
|
||||||
|
|
||||||
|
const listEl = document.getElementById('results-list');
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
const msg = filter === 'errores' ? 'Sin errores — todo enviado correctamente' :
|
||||||
|
filter === 'advertencias' ? 'Sin advertencias' :
|
||||||
|
filter === 'enviados' ? 'No hay registros enviados' : 'Sin resultados';
|
||||||
|
listEl.innerHTML = `<div class="p-8 text-center text-gray-400 text-sm"><i class="fas ${filter==='errores'?'fa-check-circle text-green-400':'fa-inbox'} text-2xl mb-2 block"></i>${msg}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = items.map(({ item, step: s, idx, status }) => {
|
||||||
|
let label1 = '', label2 = '', dtype = s.tipo, did = '';
|
||||||
|
|
||||||
|
if (s.tipo === 'tercero') {
|
||||||
|
label1 = item.doc || '—';
|
||||||
|
label2 = item.nombre || '—';
|
||||||
|
did = escHtml(String(item.codigo || ''));
|
||||||
|
} else {
|
||||||
|
label1 = item.factura || '—';
|
||||||
|
label2 = `Pac. ${item.paciente||'—'} · ${item.examenes||0} examen(es)`;
|
||||||
|
did = escHtml(String(item.idrecepcion || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = (item.msg || '').trim();
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
return `
|
||||||
|
<div class="px-5 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<span class="text-green-500 shrink-0 text-base"><i class="fas fa-check-circle"></i></span>
|
||||||
|
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0">
|
||||||
|
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-gray-800 text-xs w-28 shrink-0 truncate" title="${escHtml(label1)}">${escHtml(label1)}</span>
|
||||||
|
<span class="text-gray-600 text-xs flex-1 truncate">${escHtml(label2)}</span>
|
||||||
|
<span class="text-green-600 text-xs max-w-[220px] truncate shrink-0" title="${escHtml(msg)}">${escHtml(msg.slice(0,60))}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (status === 'warning') {
|
||||||
|
return `
|
||||||
|
<div class="px-5 py-3 flex items-center gap-3 bg-yellow-50 hover:bg-yellow-100 transition-colors border-l-4 border-yellow-400">
|
||||||
|
<span class="text-yellow-500 shrink-0 text-base"><i class="fas fa-exclamation-circle"></i></span>
|
||||||
|
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0">
|
||||||
|
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-gray-800 text-xs w-28 shrink-0 truncate" title="${escHtml(label1)}">${escHtml(label1)}</span>
|
||||||
|
<span class="text-gray-600 text-xs flex-1 truncate">${escHtml(label2)}</span>
|
||||||
|
<span class="text-yellow-700 text-xs max-w-[260px] truncate shrink-0" title="${escHtml(msg)}">${escHtml(msg.slice(0,80))}</span>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
<div data-resend-row class="px-5 py-3 flex items-start gap-3 bg-red-50 hover:bg-red-100 transition-colors border-l-4 border-red-400">
|
||||||
|
<span class="text-red-400 shrink-0 text-base mt-0.5 fail-status-icon"><i class="fas fa-times-circle"></i></span>
|
||||||
|
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0 mt-0.5">
|
||||||
|
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-mono text-gray-800 text-xs font-semibold">${escHtml(label1)}</span>
|
||||||
|
<span class="text-gray-600 text-xs truncate">${escHtml(label2)}</span>
|
||||||
|
</div>
|
||||||
|
${msg ? `<div class="mt-1 text-xs text-red-600 break-words fail-msg" title="${escHtml(msg)}">${escHtml(msg.slice(0,120))}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<button id="rbtn-${idx}"
|
||||||
|
data-tipo="${dtype}" data-id="${did}"
|
||||||
|
onclick="reenviarItem(this)"
|
||||||
|
class="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-gray-200 hover:bg-blue-50 hover:border-blue-300 text-xs text-gray-600 hover:text-blue-700 rounded-lg transition-colors font-medium mt-0.5">
|
||||||
|
<i class="fas fa-redo-alt text-xs"></i> Reenviar
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reenviar item individual ─────────────────────────────────────────────────
|
||||||
|
async function reenviarItem(btn) {
|
||||||
|
const tipo = btn.dataset.tipo;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const row = btn.closest('[data-resend-row]');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
let endpoint = '';
|
||||||
|
|
||||||
|
if (tipo === 'tercero') {
|
||||||
|
endpoint = '/automation/reenviar-tercero';
|
||||||
|
form.append('codigo', String(id));
|
||||||
|
} else if (tipo === 'rda') {
|
||||||
|
endpoint = '/automation/reenviar-rda';
|
||||||
|
form.append('idrecepcion', String(id));
|
||||||
|
form.append('tipo', 'rda');
|
||||||
|
} else if (tipo === 'preserv') {
|
||||||
|
endpoint = '/automation/reenviar-rda';
|
||||||
|
form.append('idrecepcion', String(id));
|
||||||
|
form.append('tipo', 'preserv');
|
||||||
|
} else {
|
||||||
|
endpoint = '/automation/reenviar-venta';
|
||||||
|
form.append('idrecepcion', String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(endpoint, { method: 'POST', body: form, credentials: 'include' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Marcar la fila como resuelta visualmente
|
||||||
|
row.classList.remove('bg-red-50','bg-red-100','border-l-4','border-red-400');
|
||||||
|
row.classList.add('bg-green-50','border-l-4','border-green-400');
|
||||||
|
row.removeAttribute('data-resend-row');
|
||||||
|
const icon = row.querySelector('.fail-status-icon');
|
||||||
|
if (icon) icon.innerHTML = '<i class="fas fa-check-circle text-green-500"></i>';
|
||||||
|
const msgEl = row.querySelector('.fail-msg');
|
||||||
|
if (msgEl) { msgEl.textContent = (data.message||'OK enviado').slice(0,120); msgEl.className = 'mt-1 text-xs text-green-700'; }
|
||||||
|
btn.innerHTML = '<i class="fas fa-check text-green-500 text-xs"></i> OK';
|
||||||
|
btn.className = 'shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 bg-green-50 border border-green-200 text-xs text-green-700 rounded-lg font-medium cursor-default mt-0.5';
|
||||||
|
showToast('Reenviado correctamente', 'success');
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
const msgEl = row.querySelector('.fail-msg');
|
||||||
|
if (msgEl) msgEl.textContent = (data.message||'Error').slice(0,120);
|
||||||
|
else {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'mt-1 text-xs text-red-600 break-words fail-msg';
|
||||||
|
div.textContent = (data.message||'Error').slice(0,120);
|
||||||
|
row.querySelector('.flex-1').appendChild(div);
|
||||||
|
}
|
||||||
|
showToast(data.message || 'Error al reenviar', 'error');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
showToast('Error de conexión', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers UI ───────────────────────────────────────────────────────────────
|
||||||
|
function toggleSubRow(id, chevId) {
|
||||||
const div = document.getElementById(id);
|
const div = document.getElementById(id);
|
||||||
const chev = row.querySelector('[id^="chev-"]');
|
const chev = document.getElementById(chevId);
|
||||||
const hidden = div.classList.contains('hidden');
|
const hidden = div.classList.contains('hidden');
|
||||||
div.classList.toggle('hidden', !hidden);
|
div.classList.toggle('hidden', !hidden);
|
||||||
if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : '';
|
if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBadge(id, enviados, errores) {
|
function toggleJsonBlock(id, chevId) {
|
||||||
const el = document.getElementById(id);
|
const div = document.getElementById(id);
|
||||||
if (errores === 0) {
|
const chev = document.getElementById(chevId);
|
||||||
el.textContent = `✅ ${enviados} enviados`;
|
const hidden = div.classList.contains('hidden');
|
||||||
el.className = 'text-sm text-green-600 font-medium';
|
if (hidden && _rdaJsonStore[id]) div.textContent = JSON.stringify(_rdaJsonStore[id], null, 2);
|
||||||
} else if (enviados > 0) {
|
div.classList.toggle('hidden', !hidden);
|
||||||
el.textContent = `⚠️ ${enviados} OK / ${errores} errores`;
|
if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : '';
|
||||||
el.className = 'text-sm text-yellow-600 font-medium';
|
|
||||||
} else {
|
|
||||||
el.textContent = `❌ ${errores} errores`;
|
|
||||||
el.className = 'text-sm text-red-600 font-medium';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDetalle(id, items) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!items || items.length === 0) return;
|
|
||||||
el.innerHTML = items.map(i => `
|
|
||||||
<div class="px-6 py-2 flex items-center gap-3 text-sm ${i.ok ? '' : 'bg-red-50'}">
|
|
||||||
<span class="text-gray-500 w-28 shrink-0">${i.col1}</span>
|
|
||||||
<span class="text-gray-700 flex-1 truncate">${i.col2}</span>
|
|
||||||
<span class="text-xs ${i.ok ? 'text-green-600' : 'text-red-600'} shrink-0 max-w-xs truncate">${i.col3}</span>
|
|
||||||
</div>`).join('');
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+80
-21
@@ -26,43 +26,99 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
margin: 1.5cm;
|
||||||
|
/* Elimina encabezado/pie del navegador en navegadores que lo soporten */
|
||||||
|
margin-top: 0.5cm;
|
||||||
|
margin-bottom: 0.5cm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
/* Ocultar sidebar y header */
|
||||||
|
.fixed { display: none !important; }
|
||||||
|
header { display: none !important; }
|
||||||
|
/* Contenido ocupa toda la hoja */
|
||||||
|
.pl-64 { padding-left: 0 !important; }
|
||||||
|
main { padding: 0 !important; }
|
||||||
|
/* Eliminar fondos de color */
|
||||||
|
* { background: white !important; color: black !important;
|
||||||
|
box-shadow: none !important; border-color: #ccc !important; }
|
||||||
|
/* Mantener colores de texto en código */
|
||||||
|
pre, code { background: #f5f5f5 !important; color: #1a1a1a !important; }
|
||||||
|
/* Evitar cortes en medio de secciones */
|
||||||
|
section { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
|
{% set embedded = request.query_params.get('embed') == '1' %}
|
||||||
|
{% set cur = request.url.path %}
|
||||||
<div class="min-h-full">
|
<div class="min-h-full">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
{% if not embedded %}
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30">
|
<div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30">
|
||||||
<div class="flex items-center h-16 px-6 border-b border-gray-700">
|
<div class="flex items-center h-16 px-6 border-b border-gray-700">
|
||||||
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
|
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
|
||||||
<span class="font-bold text-lg">RIPS Manager</span>
|
<span class="font-bold text-lg">RIPS Manager</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mt-4 px-3 space-y-1">␍
|
<nav class="mt-4 px-3 space-y-1 overflow-y-auto" style="max-height: calc(100vh - 130px)">
|
||||||
<a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
<a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
|
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
<a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
<i class="fas fa-cog w-5 mr-2"></i> Configuración
|
<i class="fas fa-cog w-5 mr-2"></i> Configuración
|
||||||
</a>
|
</a>
|
||||||
<a href="/queries" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/queries' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
<a href="/queries" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/queries' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL
|
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL
|
||||||
</a>
|
</a>
|
||||||
<hr class="my-3 border-gray-700">␍
|
<a href="/contratos" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/contratos' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Envíos</p>␍
|
<i class="fas fa-file-contract w-5 mr-2"></i> Contratos
|
||||||
<a href="/terceros" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/terceros' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
|
||||||
<i class="fas fa-user w-5 mr-2"></i> Terceros␍
|
|
||||||
</a>␍
|
|
||||||
<a href="/transaccion" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/transaccion' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
|
||||||
<i class="fas fa-exchange-alt w-5 mr-2"></i> Transacción RIPS␍
|
|
||||||
</a>␍
|
|
||||||
<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␍
|
|
||||||
</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 %}">
|
|
||||||
<i class="fas fa-flask w-5 mr-2"></i> Prueba RDA
|
|
||||||
</a>
|
</a>
|
||||||
<hr class="my-3 border-gray-700">
|
<hr class="my-3 border-gray-700">
|
||||||
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Módulos de Envío</p>
|
||||||
<i class="fas fa-history w-5 mr-2"></i> Historial␍
|
|
||||||
|
<!-- Módulo TNS -->
|
||||||
|
<a href="/envios/tns" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/tns' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-paper-plane w-5 mr-2"></i>
|
||||||
|
<span class="flex-1">TNS</span>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/tns' %}bg-blue-500 text-blue-100{% else %}bg-gray-700 text-gray-400{% endif %}">5</span>
|
||||||
|
</a>
|
||||||
|
{% if cur in ('/envios/tns', '/logs/resumen') %}
|
||||||
|
<div class="ml-4 border-l border-gray-700 pl-3">
|
||||||
|
<p class="text-xs text-gray-500 py-0.5">Terceros · Transacción · Ventas</p>
|
||||||
|
<p class="text-xs text-gray-500 py-0.5">Prueba RDA · Automatización</p>
|
||||||
|
<a href="/logs/resumen" class="flex items-center py-0.5 text-xs {% if cur == '/logs/resumen' %}text-blue-400{% else %}text-gray-500 hover:text-gray-300{% endif %}">
|
||||||
|
<i class="fas fa-calendar-check mr-1.5 text-[10px]"></i> Resumen Diario
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Módulo ERP Lab -->
|
||||||
|
<a href="/envios/erp" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/erp' %}bg-green-700 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-flask w-5 mr-2"></i>
|
||||||
|
<span class="flex-1">ERP Lab</span>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/erp' %}bg-green-500 text-green-100{% else %}bg-gray-700 text-gray-400{% endif %}">WA</span>
|
||||||
|
</a>
|
||||||
|
{% if cur == '/envios/erp' %}
|
||||||
|
<div class="ml-4 border-l border-gray-700 pl-3">
|
||||||
|
<p class="text-xs text-gray-500 py-0.5">Pacientes · Sync Automático</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="my-3 border-gray-700">
|
||||||
|
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-history w-5 mr-2"></i> Historial TNS
|
||||||
|
</a>
|
||||||
|
<a href="/logs/resumen" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/resumen' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-calendar-check w-5 mr-2"></i> Resumen Diario
|
||||||
|
</a>
|
||||||
|
<a href="/logs/actividad" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/actividad' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-user-clock w-5 mr-2"></i> Actividad
|
||||||
|
</a>
|
||||||
|
<hr class="my-3 border-gray-700">
|
||||||
|
<a href="/documentacion" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/documentacion' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-book w-5 mr-2"></i> Documentación
|
||||||
</a>
|
</a>
|
||||||
<a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700">
|
<a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700">
|
||||||
<i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir
|
<i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir
|
||||||
@@ -80,9 +136,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="pl-64">␍
|
<div class="{% if not embedded %}pl-64{% endif %}">
|
||||||
|
{% if not embedded %}
|
||||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||||
<div class="flex items-center justify-between h-16 px-8">
|
<div class="flex items-center justify-between h-16 px-8">
|
||||||
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1>
|
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1>
|
||||||
@@ -94,7 +152,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="p-8">␍
|
{% endif %}
|
||||||
|
<main class="{% if not embedded %}p-8{% else %}p-4{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,301 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Contratos{% endblock %}
|
||||||
|
{% block header %}Contratos{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-full space-y-6">
|
||||||
|
|
||||||
|
<!-- Agregar contrato -->
|
||||||
|
<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-plus-circle mr-2 text-blue-500"></i>Agregar Contrato</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="POST" action="/contratos/create" class="grid grid-cols-5 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">N° Contrato</label>
|
||||||
|
<input type="text" name="numero_contrato" required placeholder="Ej: 001"
|
||||||
|
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-xs font-medium text-gray-600 mb-1">NIT Empresa</label>
|
||||||
|
<input type="text" name="nit_empresa" placeholder="Ej: 860078828"
|
||||||
|
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-xs font-medium text-gray-600 mb-1">Tipo Usuario</label>
|
||||||
|
<select name="tipo_usuario" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="11">11 — Contributivo (EPS)</option>
|
||||||
|
<option value="12">12 — Particular</option>
|
||||||
|
<option value="07">07 — Póliza / Seguro</option>
|
||||||
|
<option value="01">01 — Subsidiado</option>
|
||||||
|
<option value="10">10 — No Afiliado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Descripción</label>
|
||||||
|
<input type="text" name="descripcion" placeholder="Ej: EPS SANITAS"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-gray-600 mb-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="excluir" value="1" class="rounded">
|
||||||
|
Excluir TNS
|
||||||
|
</label>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Agregar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Códigos de pago -->
|
||||||
|
<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-tags mr-2 text-indigo-500"></i>Códigos de Pago</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{% for cp in codigos_pago %}
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg text-sm">
|
||||||
|
<span class="font-mono font-semibold text-indigo-700">{{ cp.codigo }}</span>
|
||||||
|
<span class="text-gray-600">{{ cp.nombre }}</span>
|
||||||
|
<form method="POST" action="/contratos/codigos-pago/delete/{{ cp.id }}" class="inline"
|
||||||
|
onsubmit="return confirm('¿Eliminar código {{ cp.codigo }}?')">
|
||||||
|
<button type="submit" class="text-red-400 hover:text-red-600 ml-1">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/contratos/codigos-pago/create" class="flex items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Código</label>
|
||||||
|
<input type="text" name="codigo" required placeholder="Ej: CIAC" maxlength="10"
|
||||||
|
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm uppercase focus:ring-2 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre</label>
|
||||||
|
<input type="text" name="nombre" placeholder="Ej: Contado inmediato"
|
||||||
|
class="w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Agregar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de contratos -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-list mr-2 text-gray-500"></i>Contratos registrados
|
||||||
|
<span class="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{{ contratos|length }}</span>
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-400">El tipo de usuario se aplica automáticamente al generar RIPS por contrato</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">N° Contrato</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">NIT Empresa</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Tipo Usuario</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Descripción</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Excluir RDA</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Excluir Ventas</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Sin Contrato</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Forma Pago</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{% for c in contratos %}
|
||||||
|
<tr class="hover:bg-gray-50 {% if c.excluir %}bg-red-50{% endif %}" id="row-{{ c.id }}">
|
||||||
|
<td class="px-4 py-3 font-mono font-semibold {% if c.excluir %}text-red-500{% else %}text-gray-800{% endif %}">{{ c.numero_contrato }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600">{{ c.nit_empresa }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% set tu = c.tipo_usuario %}
|
||||||
|
{% if tu == '11' %}
|
||||||
|
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">11 — Contributivo</span>
|
||||||
|
{% elif tu == '12' %}
|
||||||
|
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full text-xs font-medium">12 — Particular</span>
|
||||||
|
{% elif tu == '07' %}
|
||||||
|
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">07 — Póliza</span>
|
||||||
|
{% elif tu == '01' %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">01 — Subsidiado</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">{{ tu }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500">{{ c.descripcion }}</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<form method="POST" action="/contratos/toggle-excluir/{{ c.id }}" class="inline">
|
||||||
|
{% if c.excluir %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded-full text-xs font-medium transition-colors">
|
||||||
|
<i class="fas fa-ban text-xs"></i> Excluido
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-700 hover:bg-green-200 rounded-full text-xs font-medium transition-colors">
|
||||||
|
<i class="fas fa-check text-xs"></i> Activo
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<form method="POST" action="/contratos/toggle-excluir-ventas/{{ c.id }}" class="inline">
|
||||||
|
{% if c.excluir_ventas %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded-full text-xs font-medium transition-colors">
|
||||||
|
<i class="fas fa-ban text-xs"></i> Excluido
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 rounded-full text-xs font-medium transition-colors">
|
||||||
|
<i class="fas fa-check text-xs"></i> Activo
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<form method="POST" action="/contratos/toggle-sin-contrato/{{ c.id }}" class="inline">
|
||||||
|
{% if c.sin_contrato %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-yellow-100 text-yellow-700 hover:bg-yellow-200 rounded-full text-xs font-medium transition-colors" title="numeroContrato: null en JSON">
|
||||||
|
<i class="fas fa-unlink text-xs"></i> Sin contrato
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-gray-100 text-gray-400 hover:bg-gray-200 rounded-full text-xs font-medium transition-colors">
|
||||||
|
<i class="fas fa-link text-xs"></i> Normal
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<form method="POST" action="/contratos/set-forma-pago/{{ c.id }}" class="inline">
|
||||||
|
<select name="cod_forma_pago" onchange="this.form.submit()"
|
||||||
|
class="text-xs border border-gray-200 rounded-lg px-2 py-1 focus:ring-2 focus:ring-indigo-400 bg-white {% if c.cod_forma_pago %}text-indigo-700 font-semibold{% else %}text-gray-400{% endif %}">
|
||||||
|
<option value="" {% if not c.cod_forma_pago %}selected{% endif %}>— auto —</option>
|
||||||
|
{% for cp in codigos_pago %}
|
||||||
|
<option value="{{ cp.codigo }}" {% if c.cod_forma_pago == cp.codigo %}selected{% endif %}>{{ cp.codigo }} — {{ cp.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}', {{ c.excluir }}, {{ c.sin_contrato }}, {{ c.excluir_ventas }}, '{{ c.cod_forma_pago }}')"
|
||||||
|
class="text-blue-600 hover:text-blue-800 mr-3 text-xs">
|
||||||
|
<i class="fas fa-edit"></i> Editar
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="/contratos/delete/{{ c.id }}" class="inline"
|
||||||
|
onsubmit="return confirm('¿Eliminar contrato {{ c.numero_contrato }}?')">
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-700 text-xs">
|
||||||
|
<i class="fas fa-trash"></i> Eliminar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">No hay contratos registrados</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal editar -->
|
||||||
|
<div id="edit-modal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4"><i class="fas fa-edit mr-2 text-blue-500"></i>Editar Contrato</h3>
|
||||||
|
<form method="POST" id="edit-form" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">N° Contrato</label>
|
||||||
|
<input type="text" name="numero_contrato" id="edit-nc" required
|
||||||
|
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-xs font-medium text-gray-600 mb-1">NIT Empresa</label>
|
||||||
|
<input type="text" name="nit_empresa" id="edit-nit"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo Usuario</label>
|
||||||
|
<select name="tipo_usuario" id="edit-tu"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="11">11 — Contributivo (EPS)</option>
|
||||||
|
<option value="12">12 — Particular</option>
|
||||||
|
<option value="07">07 — Póliza / Seguro</option>
|
||||||
|
<option value="01">01 — Subsidiado</option>
|
||||||
|
<option value="10">10 — No Afiliado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Descripción</label>
|
||||||
|
<input type="text" name="descripcion" id="edit-desc"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Forma de Pago (Ventas)</label>
|
||||||
|
<select name="cod_forma_pago" id="edit-forma-pago"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
|
||||||
|
<option value="">— automático (CIAC si total=0, CR si total>0) —</option>
|
||||||
|
{% for cp in codigos_pago %}
|
||||||
|
<option value="{{ cp.codigo }}">{{ cp.codigo }} — {{ cp.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="excluir" id="edit-excluir" value="1" class="rounded">
|
||||||
|
<span class="text-sm text-gray-700">Excluir RDA <span class="text-xs text-red-500">(bloquea transacción/automation)</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="excluir_ventas" id="edit-excluir-ventas" value="1" class="rounded">
|
||||||
|
<span class="text-sm text-gray-700">Excluir Ventas <span class="text-xs text-red-500">(bloquea /ventas)</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="sin_contrato" id="edit-sin-contrato" value="1" class="rounded">
|
||||||
|
<span class="text-sm text-gray-700">Sin contrato <span class="text-xs text-yellow-600">(numeroContrato: null)</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 pt-2">
|
||||||
|
<button type="button" onclick="closeEdit()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-save mr-1"></i> Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openEdit(id, nc, nit, tu, desc, excluir, sinContrato, excluirVentas, formaPago) {
|
||||||
|
document.getElementById('edit-form').action = '/contratos/update/' + id;
|
||||||
|
document.getElementById('edit-nc').value = nc;
|
||||||
|
document.getElementById('edit-nit').value = nit;
|
||||||
|
document.getElementById('edit-tu').value = tu;
|
||||||
|
document.getElementById('edit-desc').value = desc;
|
||||||
|
document.getElementById('edit-forma-pago').value = formaPago || '';
|
||||||
|
document.getElementById('edit-excluir').checked = excluir === 1;
|
||||||
|
document.getElementById('edit-sin-contrato').checked = sinContrato === 1;
|
||||||
|
document.getElementById('edit-excluir-ventas').checked = excluirVentas === 1;
|
||||||
|
document.getElementById('edit-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeEdit() {
|
||||||
|
document.getElementById('edit-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('edit-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeEdit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Documentación{% endblock %}
|
||||||
|
{% block header %}Documentación del Sistema{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex gap-6 max-w-full">
|
||||||
|
|
||||||
|
<!-- Sidebar de navegación -->
|
||||||
|
<aside class="w-64 flex-shrink-0">
|
||||||
|
<div class="sticky top-4 bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 bg-blue-700 text-white">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider">Contenido</p>
|
||||||
|
</div>
|
||||||
|
<nav class="p-3 space-y-0.5 text-sm max-h-[80vh] overflow-y-auto">
|
||||||
|
<p class="px-2 pt-2 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">General</p>
|
||||||
|
<a href="#objetivo" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-bullseye w-4 mr-2 text-xs"></i>Objetivo</a>
|
||||||
|
<a href="#arquitectura" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-sitemap w-4 mr-2 text-xs"></i>Arquitectura</a>
|
||||||
|
<a href="#acceso" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-key w-4 mr-2 text-xs"></i>Acceso y credenciales</a>
|
||||||
|
|
||||||
|
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Módulos</p>
|
||||||
|
<a href="#dashboard" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-chart-pie w-4 mr-2 text-xs"></i>Dashboard</a>
|
||||||
|
<a href="#configuracion" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-cog w-4 mr-2 text-xs"></i>Configuración</a>
|
||||||
|
<a href="#contratos" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-file-contract w-4 mr-2 text-xs"></i>Contratos</a>
|
||||||
|
<a href="#ventas" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-shopping-cart w-4 mr-2 text-xs"></i>Ventas CMXC</a>
|
||||||
|
<a href="#automation" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-robot w-4 mr-2 text-xs"></i>Automatización RDA</a>
|
||||||
|
<a href="#terceros" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-user-plus w-4 mr-2 text-xs"></i>Terceros</a>
|
||||||
|
<a href="#transaccion" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-exchange-alt w-4 mr-2 text-xs"></i>Transacción RDA</a>
|
||||||
|
<a href="#pacientes" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-users w-4 mr-2 text-xs"></i>Pacientes / Sync</a>
|
||||||
|
<a href="#queries" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-database w-4 mr-2 text-xs"></i>Consultas SQL</a>
|
||||||
|
<a href="#test-rda" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-vial w-4 mr-2 text-xs"></i>Prueba RDA</a>
|
||||||
|
|
||||||
|
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Historial</p>
|
||||||
|
<a href="#logs" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-history w-4 mr-2 text-xs"></i>Historial envíos</a>
|
||||||
|
<a href="#resumen" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-calendar-check w-4 mr-2 text-xs"></i>Resumen diario</a>
|
||||||
|
<a href="#actividad" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-user-clock w-4 mr-2 text-xs"></i>Actividad</a>
|
||||||
|
<a href="#erp" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-flask w-4 mr-2 text-xs"></i>ERP / WhatsApp</a>
|
||||||
|
|
||||||
|
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Referencia técnica</p>
|
||||||
|
<a href="#json-venta" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-code w-4 mr-2 text-xs"></i>JSON Venta</a>
|
||||||
|
<a href="#json-rda" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-code w-4 mr-2 text-xs"></i>JSON RDA</a>
|
||||||
|
<a href="#logica-pago" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-credit-card w-4 mr-2 text-xs"></i>Formas de pago</a>
|
||||||
|
<a href="#logica-contratos" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-sliders-h w-4 mr-2 text-xs"></i>Exclusiones</a>
|
||||||
|
<a href="#soporte" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-headset w-4 mr-2 text-xs"></i>Soporte</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Contenido principal -->
|
||||||
|
<div class="flex-1 space-y-8 min-w-0">
|
||||||
|
|
||||||
|
<!-- ── OBJETIVO ── -->
|
||||||
|
<section id="objetivo" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-bullseye text-blue-500"></i> Objetivo del sistema</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-gray-700 leading-relaxed">
|
||||||
|
Sistema web de integración de datos clínicos para el <strong>Laboratorio Clínico Ximena Caicedo</strong>, que permite el intercambio de información de ventas y recepciones (RDA) con el sistema <strong>TNS (Tecnología de Negocios en Salud)</strong>, de manera estructurada, segura y trazable.
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700 leading-relaxed mt-3">
|
||||||
|
Centraliza la gestión de contratos, el envío de facturas de venta, la generación de RDA de pacientes, la sincronización con WhatsApp ERP y el registro completo de toda la actividad del sistema.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-3 text-center"><i class="fas fa-paper-plane text-blue-500 text-lg mb-1"></i><p class="text-xs font-semibold text-blue-700">Envío TNS</p><p class="text-xs text-gray-500">Ventas y RDA</p></div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-3 text-center"><i class="fas fa-database text-green-500 text-lg mb-1"></i><p class="text-xs font-semibold text-green-700">Firebird</p><p class="text-xs text-gray-500">Base de datos clínica</p></div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-3 text-center"><i class="fas fa-shield-alt text-purple-500 text-lg mb-1"></i><p class="text-xs font-semibold text-purple-700">Trazabilidad</p><p class="text-xs text-gray-500">Log completo</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── ARQUITECTURA ── -->
|
||||||
|
<section id="arquitectura" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-sitemap text-blue-500"></i> Arquitectura técnica</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 mb-2">Stack tecnológico</p>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Componente</th><th class="px-3 py-2 text-left">Tecnología</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">Backend</td><td class="px-3 py-2">Python 3 + FastAPI (async)</td></tr>
|
||||||
|
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">Frontend</td><td class="px-3 py-2">Jinja2 + TailwindCSS + Font Awesome</td></tr>
|
||||||
|
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">BD local</td><td class="px-3 py-2">SQLite 3 (WAL mode)</td></tr>
|
||||||
|
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">BD clínica</td><td class="px-3 py-2">Firebird 2.5 — 192.168.0.125:3025</td></tr>
|
||||||
|
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">API externa</td><td class="px-3 py-2">TNS — REST JSON + Bearer Token</td></tr>
|
||||||
|
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">Servidor</td><td class="px-3 py-2">Windows — uvicorn :8080</td></tr>
|
||||||
|
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">Auth</td><td class="px-3 py-2">JWT + bcrypt</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 mb-2">Flujo de datos</p>
|
||||||
|
<ol class="space-y-2">
|
||||||
|
{% for step in [
|
||||||
|
('1','Navegador','Usuario accede y se autentica con JWT.','blue'),
|
||||||
|
('2','Firebird','Sistema consulta recepciones/facturas.','green'),
|
||||||
|
('3','Generador','Se construye el JSON según spec TNS.','purple'),
|
||||||
|
('4','TNS API','POST con Bearer Token al endpoint.','orange'),
|
||||||
|
('5','SQLite','Respuesta guardada (success/error).','gray'),
|
||||||
|
('6','Historial','Registro disponible para reenvío.','blue'),
|
||||||
|
] %}
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{{ step[0] }}</span>
|
||||||
|
<div><span class="text-xs font-semibold text-gray-700">{{ step[1] }}: </span><span class="text-xs text-gray-600">{{ step[2] }}</span></div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── ACCESO ── -->
|
||||||
|
<section id="acceso" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-key text-blue-500"></i> Acceso y credenciales</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<div class="flex items-center gap-3 mb-4 bg-blue-50 rounded-lg px-4 py-3">
|
||||||
|
<i class="fas fa-globe text-blue-500 text-lg"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">URL del sistema (red local)</p>
|
||||||
|
<p class="font-mono font-bold text-blue-700 text-sm">http://192.168.0.125:8080</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
|
<p class="text-sm font-semibold text-amber-700 mb-1"><i class="fas fa-exclamation-triangle mr-1"></i> Importante</p>
|
||||||
|
<p class="text-xs text-amber-700">Las credenciales de acceso son suministradas por el administrador del sistema.</p>
|
||||||
|
<p class="text-xs text-amber-600 mt-2">Nuevos usuarios: acceder a <span class="font-mono">/register</span> con sesión activa.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── DASHBOARD ── -->
|
||||||
|
<section id="dashboard" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-chart-pie text-blue-500"></i> Dashboard — Panel principal</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/dashboard</span> — Pantalla de inicio. Resumen en tiempo real del estado del sistema.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total de envíos exitosos acumulados (todos los tipos).</li>
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total de envíos con error.</li>
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Conteo de terceros registrados en TNS.</li>
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Conteo de transacciones RDA enviadas.</li>
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Número de facturas únicas procesadas.</li>
|
||||||
|
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Últimos 10 envíos con usuario, tipo, factura y estado.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── CONFIGURACIÓN ── -->
|
||||||
|
<section id="configuracion" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-cog text-blue-500"></i> Configuración</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/config</span> — Parámetros de conexión y credenciales del sistema.</p>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Parámetro</th><th class="px-3 py-2 text-left">Descripción</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{% for row in [
|
||||||
|
('firebird_host','IP del servidor Firebird (ej: 192.168.0.125)'),
|
||||||
|
('firebird_port','Puerto Firebird (ej: 3025)'),
|
||||||
|
('firebird_database','Ruta completa del archivo .FDB en el servidor'),
|
||||||
|
('firebird_user / password','Credenciales de acceso a Firebird (SYSDBA / masterkey)'),
|
||||||
|
('tns_empresa','NIT de la empresa en TNS'),
|
||||||
|
('tns_usuario / password','Credenciales de acceso a la API TNS'),
|
||||||
|
('api_sucursal','Código de sucursal para los endpoints TNS (ej: 81080)'),
|
||||||
|
('prefijo_tns_default','Prefijo por defecto para facturas (ej: 00)'),
|
||||||
|
('num_documento_obligado','NIT del obligado para RIPS'),
|
||||||
|
('profesional_default','Código del profesional por defecto en RDA'),
|
||||||
|
('api_timeout','Tiempo máximo de espera para llamadas TNS (segundos)'),
|
||||||
|
('whatsapp_url / api_key','URL y clave para integración con WhatsApp ERP'),
|
||||||
|
] %}
|
||||||
|
<tr class="{% if loop.index is odd %}bg-blue-50{% endif %} hover:bg-blue-100">
|
||||||
|
<td class="px-3 py-2 font-mono text-blue-800 font-medium">{{ row[0] }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-600">{{ row[1] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="mt-3 flex gap-3">
|
||||||
|
<div class="flex-1 bg-gray-50 rounded-lg p-3 text-xs"><i class="fas fa-plug text-blue-500 mr-1"></i> <strong>Probar conexión TNS</strong> — verifica credenciales y obtiene token JWT.</div>
|
||||||
|
<div class="flex-1 bg-gray-50 rounded-lg p-3 text-xs"><i class="fas fa-server text-green-500 mr-1"></i> <strong>Probar Firebird</strong> — verifica conectividad con la base de datos clínica.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── CONTRATOS ── -->
|
||||||
|
<section id="contratos" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-file-contract text-blue-500"></i> Contratos</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/contratos</span> — Gestión de convenios. Define cómo se procesa la información de cada aseguradora.</p>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden mb-4">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Campo</th><th class="px-3 py-2 text-left">Descripción</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{% for row in [
|
||||||
|
('N° Contrato','Código del convenio. Referenciado por CODCONTRATO en Firebird.'),
|
||||||
|
('NIT Empresa','NIT de la aseguradora o empresa del convenio.'),
|
||||||
|
('Tipo Usuario','Código RIPS: 11=Contributivo, 12=Particular, 07=Póliza, 01=Subsidiado.'),
|
||||||
|
('Descripción','Nombre descriptivo del convenio.'),
|
||||||
|
('Excluir RDA','Si activo, las recepciones de este contrato NO se envían en RDA.'),
|
||||||
|
('Excluir Ventas','Si activo, las facturas de este contrato NO aparecen en Ventas.'),
|
||||||
|
('Sin Contrato','JSON RDA se envía con numeroContrato: null.'),
|
||||||
|
('Forma de Pago','Código CIAC / CR / MU. Vacío = determinado automáticamente.'),
|
||||||
|
] %}
|
||||||
|
<tr class="{% if loop.index is odd %}bg-blue-50{% endif %}">
|
||||||
|
<td class="px-3 py-2 font-semibold text-gray-700">{{ row[0] }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-600">{{ row[1] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 mb-1"><i class="fas fa-tags mr-1"></i> Códigos de Pago</p>
|
||||||
|
<p class="text-xs text-indigo-700">Sección en la misma página para agregar/eliminar códigos (CIAC, CR, MU ya vienen por defecto). El select inline de cada fila asigna la forma de pago al contrato sin recargar la página.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── VENTAS ── -->
|
||||||
|
<section id="ventas" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-shopping-cart text-blue-500"></i> Ventas — Facturación CMXC</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/ventas</span> — Consulta y envío de facturas de venta al endpoint TNS <span class="font-mono bg-gray-100 px-1 rounded">/v2/facturacion/Ventas/Crear</span>. Solo procesa facturas con <strong>PREFIJO = CMXC</strong>.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Flujo de uso</p>
|
||||||
|
<ol class="space-y-1.5 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">1</span> Seleccionar rango de fechas (FECHAFACT).</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">2</span> Clic en "Cargar" → consulta Firebird y agrupa por factura.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">3</span> Tabla muestra: clave, paciente, contrato, exámenes, valor, estado.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">4</span> Botón "Enviar" individual o "Ver JSON" para inspeccionar.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">5</span> Botón "Enviar Todo" procesa todos los pendientes en lote.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros automáticos</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Solo facturas con PREFIJO = CMXC.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Excluye facturas anuladas (ANULADA = T).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Excluye contratos marcados "Excluir Ventas".</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Fecha aplicada sobre FECHAFACT, no FECHA_RECEPCION.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mt-3 mb-1">Agrupación</p>
|
||||||
|
<p class="text-xs text-gray-600">Una factura puede tener múltiples recepciones. Se agrupan por CMXC-{NUM} y se envían como un único JSON con todos los ítems en <span class="font-mono bg-gray-100 px-0.5">detallePedido</span>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── AUTOMATIZACIÓN RDA ── -->
|
||||||
|
<section id="automation" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-robot text-blue-500"></i> Automatización RDA</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/automation</span> — Envío masivo de recepciones RDA al endpoint <span class="font-mono bg-gray-100 px-1 rounded">/v2/rda/RdaPaciente/Insertar</span>. Procesa PREFIJO = LHXC, RCXC, SC.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Flujo de uso</p>
|
||||||
|
<ol class="space-y-1.5 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">1</span> Seleccionar rango de fechas y (opcional) contrato específico.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">2</span> "Cargar" → consulta Firebird con join a PACIENTE, RELACION, EXAMEN, MEDICO.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">3</span> Tabla muestra estado de cada recepción.</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">4</span> Envío individual o masivo "Enviar Todo Pendiente".</li>
|
||||||
|
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">5</span> "Solo pendientes" omite las ya enviadas exitosamente.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Comportamiento automático</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-user-check text-blue-400 mt-0.5"></i> Si el paciente no existe en TNS, lo registra automáticamente antes del RDA.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-ban text-red-400 mt-0.5"></i> Excluye recepciones con PS_NUM (muestras especiales).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-ban text-red-400 mt-0.5"></i> Excluye contratos marcados "Excluir RDA".</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-unlink text-yellow-500 mt-0.5"></i> Contratos "Sin Contrato" → numeroContrato: null en JSON.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-id-card text-purple-400 mt-0.5"></i> Tipo de usuario (tipousuario) tomado del mapa de contratos.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-comment text-green-500 mt-0.5"></i> Sincroniza el paciente en WhatsApp ERP (opcional).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── TERCEROS ── -->
|
||||||
|
<section id="terceros" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-user-plus text-blue-500"></i> Terceros</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/terceros</span> — Registro manual de pacientes en TNS via <span class="font-mono bg-gray-100 px-1 rounded">/v2/tablas/Tercero/Crear</span>.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Búsqueda de paciente en Firebird por número de documento.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista previa del JSON antes de enviar a TNS.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Envío individual con respuesta inmediata.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Listado de los últimos 20 terceros registrados.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Acceso a consultas SQL guardadas de tipo "terceros".</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización opcional del paciente en WhatsApp ERP.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── TRANSACCIÓN ── -->
|
||||||
|
<section id="transaccion" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-exchange-alt text-blue-500"></i> Transacción — RDA Manual</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/transaccion</span> — Envío manual de RDA para una factura o recepción específica usando una consulta SQL guardada.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Selector de consulta SQL (tipo "transaccion") guardada.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Campos de filtro: número de factura, rango de fechas.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista previa del JSON RDA generado antes de enviar.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Manejo de contratos: excluidos, sin contrato, tipo de usuario.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── PACIENTES ── -->
|
||||||
|
<section id="pacientes" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-users text-blue-500"></i> Pacientes / Sincronización</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/pacientes</span> — Sincronización masiva de pacientes con el sistema WhatsApp ERP del laboratorio.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Consulta todos los pacientes en Firebird por rango de fechas.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización masiva al WhatsApp ERP (insertar o actualizar).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Migración de diagnósticos CIE-10 (12.000+ registros en lotes de 200).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista de exámenes por cédula de paciente.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Log de cada sincronización: creados, actualizados, omitidos, errores.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── QUERIES ── -->
|
||||||
|
<section id="queries" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-database text-blue-500"></i> Consultas SQL (Queries)</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/queries</span> — Gestión de consultas SQL reutilizables que se ejecutan contra Firebird.</p>
|
||||||
|
<div class="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
<div class="border border-blue-200 rounded-lg p-3 bg-blue-50"><p class="text-xs font-bold text-blue-700 mb-1">terceros</p><p class="text-xs text-gray-600">Consultas para datos de pacientes (Tercero/Crear en TNS).</p></div>
|
||||||
|
<div class="border border-green-200 rounded-lg p-3 bg-green-50"><p class="text-xs font-bold text-green-700 mb-1">transaccion</p><p class="text-xs text-gray-600">Consultas para recepciones RDA (RdaPaciente/Insertar).</p></div>
|
||||||
|
<div class="border border-purple-200 rounded-lg p-3 bg-purple-50"><p class="text-xs font-bold text-purple-700 mb-1">ventas</p><p class="text-xs text-gray-600">Consultas históricas para facturas de venta.</p></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600">Las consultas se pueden crear, editar y eliminar. Se cargan como selector en los módulos Terceros y Transacción. Soportan parámetros <span class="font-mono bg-gray-100 px-0.5">:fecha_ini</span>, <span class="font-mono bg-gray-100 px-0.5">:fecha_fin</span>, <span class="font-mono bg-gray-100 px-0.5">:num_factura</span>, <span class="font-mono bg-gray-100 px-0.5">:doc_num</span>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── PRUEBA RDA ── -->
|
||||||
|
<section id="test-rda" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-vial text-blue-500"></i> Prueba RDA</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/test-rda</span> — Herramienta de diagnóstico para probar el envío de RDA por recepción o factura específica.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Búsqueda por IDRECEPCION o NUM_FACTURA específico.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Genera y muestra el JSON RDA completo antes de enviar.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Opción: enviar solo tercero, solo RDA, o ambos.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Muestra respuesta completa de TNS incluyendo mensajes de error detallados.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Ideal para diagnóstico de registros individuales con problemas.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── HISTORIAL ── -->
|
||||||
|
<section id="logs" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-history text-blue-500"></i> Historial de Envíos</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs</span> — Registro completo de todos los envíos al sistema TNS con filtros avanzados y reenvío.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros disponibles</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Tipo: terceros / transaccion / ventas.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Estado: success / error / warning.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Número de factura o cédula.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Rango de fechas. Paginación 50/pág.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Por cada registro</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-info-circle text-blue-400 mt-0.5"></i> Tipo, factura, contrato, usuario, estado.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-code text-blue-400 mt-0.5"></i> JSON completo enviado (formateado).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-server text-blue-400 mt-0.5"></i> Respuesta completa de TNS.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-redo text-green-500 mt-0.5"></i> Botón "Reenviar" para registros con error.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 bg-green-50 border border-green-200 rounded-lg p-3 text-xs text-green-800">
|
||||||
|
<i class="fas fa-redo mr-1"></i> <strong>Reenvío:</strong> obtiene el JSON guardado, hace nueva autenticación TNS y reenvía al endpoint correcto según el tipo. El registro se actualiza con el nuevo resultado.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── RESUMEN ── -->
|
||||||
|
<section id="resumen" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-calendar-check text-blue-500"></i> Resumen Diario</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs/resumen</span> — Vista consolidada de todos los envíos de un día, agrupados por factura/paciente.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total del día, exitosos y con error.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Estado final por registro (éxito si algún intento fue exitoso).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Número de intentos y hora del primer/último envío.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Filtro por tipo de envío.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── ACTIVIDAD ── -->
|
||||||
|
<section id="actividad" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-user-clock text-blue-500"></i> Registro de Actividad</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs/actividad</span> — Log de auditoría de todas las acciones de los usuarios del sistema.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Acciones registradas</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Creación, edición y eliminación de contratos.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Cambios en configuración del sistema.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Envíos individuales y masivos (terceros, RDA, ventas).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Reenvíos desde historial.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Cambios de forma de pago en contratos.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Sincronizaciones con WhatsApp ERP.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros</p>
|
||||||
|
<ul class="space-y-1 text-xs text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por usuario.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por tipo de acción.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por rango de fechas.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Paginación 50/pág.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── ERP ── -->
|
||||||
|
<section id="erp" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-flask text-blue-500"></i> ERP Lab / WhatsApp</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/envios/erp</span> — Integración con el sistema WhatsApp ERP del laboratorio.</p>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización de pacientes recientes (ventana configurable de minutos).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Migración masiva de diagnósticos CIE-10 (12.000+) en lotes de 200.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Indicador de estado de configuración (URL y API Key).</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Log por sincronización: total, creados, actualizados, omitidos, errores.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-sm text-gray-600 mt-3"><span class="font-mono bg-gray-100 px-1 rounded">/envios/tns</span> — Vista de envíos recientes al sistema TNS con estado consolidado.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── JSON VENTA ── -->
|
||||||
|
<section id="json-venta" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-code text-blue-500"></i> Referencia JSON — Factura de Venta</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-xs text-gray-500 mb-3 font-mono">POST {TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}</p>
|
||||||
|
<pre class="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-x-auto leading-relaxed">{
|
||||||
|
"codigoPrefijo": "CMXC",
|
||||||
|
"numero": "00309",
|
||||||
|
"numeroFactura": "00309",
|
||||||
|
"sucursal": "00",
|
||||||
|
"fecha": "15/07/2026",
|
||||||
|
"kardexId": 0,
|
||||||
|
"codigoPedido": "",
|
||||||
|
"nombreCliente": "",
|
||||||
|
"codTercero": "900123456-7", <span class="text-yellow-300">// NIT con dígito de verificación</span>
|
||||||
|
"codVendedor": "00",
|
||||||
|
"codDespachar": "00",
|
||||||
|
"codFormaPago": "CR", <span class="text-yellow-300">// CIAC | CR | MU</span>
|
||||||
|
"codBanco": "", <span class="text-yellow-300">// siempre vacío</span>
|
||||||
|
"fechaVence": "14/08/2026", <span class="text-yellow-300">// FECHAFACT + DIASVENC días</span>
|
||||||
|
"fechaEntrega": "15/07/2026",
|
||||||
|
"plazoDias": 30, <span class="text-yellow-300">// 0 si CIAC, DIASVENC si CR/MU</span>
|
||||||
|
"observacion": "",
|
||||||
|
"sucursal": "00",
|
||||||
|
"codigoCentroCosto": "00",
|
||||||
|
"codigoArea": "00",
|
||||||
|
"terminal": "00",
|
||||||
|
"detallePedido": [
|
||||||
|
{
|
||||||
|
"codMat": "90600", <span class="text-yellow-300">// CUPS del examen</span>
|
||||||
|
"codBodega": "00",
|
||||||
|
"cantidad": 1,
|
||||||
|
"tipoUnidad": "M",
|
||||||
|
"descuento": 0,
|
||||||
|
"descuentoValor": 0,
|
||||||
|
"centrosCostos": "00",
|
||||||
|
"porcIva": 0,
|
||||||
|
"valor": 7000,
|
||||||
|
"impConsumo": 0,
|
||||||
|
"observacion": "",
|
||||||
|
"lote": "",
|
||||||
|
"fechaVenceLote": "",
|
||||||
|
"nroDocumento": "",
|
||||||
|
"itemsSerial": [],
|
||||||
|
"tipoSerial": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"detalleFormaPago": [],
|
||||||
|
"asentar": 0,
|
||||||
|
"detalleDescuentos": []
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── JSON RDA ── -->
|
||||||
|
<section id="json-rda" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-code text-blue-500"></i> Referencia JSON — RDA Paciente</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-xs text-gray-500 mb-3 font-mono">POST {TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}</p>
|
||||||
|
<pre class="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-x-auto leading-relaxed">{
|
||||||
|
"codigoPrefijo": "LHXC",
|
||||||
|
"numero": "05103",
|
||||||
|
"fecha": "15/07/2026",
|
||||||
|
"codTercero": "1090512345", <span class="text-yellow-300">// código paciente en Firebird</span>
|
||||||
|
"codVendedor": "00",
|
||||||
|
"codigoCentroCosto": "00",
|
||||||
|
"tipoIngreso": "1",
|
||||||
|
"fechaHoraIngreso": "15/07/2026 08:30:00",
|
||||||
|
"fechaHoraEgreso": "15/07/2026 08:35:00",
|
||||||
|
"modalidadAtencion": "01",
|
||||||
|
"numeroContrato": "040", <span class="text-yellow-300">// null si sin_contrato=1</span>
|
||||||
|
"descuento": 0,
|
||||||
|
"diagnosticoprincipal": "Z017",
|
||||||
|
"discapacidad": "08",
|
||||||
|
"tipousuario": "11", <span class="text-yellow-300">// del mapa de contratos</span>
|
||||||
|
"viaIngreso": "01",
|
||||||
|
"esTerapia": false,
|
||||||
|
"esProcedimiento": false,
|
||||||
|
"numeroAutorizacion": null,
|
||||||
|
"detallePedido": [
|
||||||
|
{
|
||||||
|
"codigoMaterial": "90600", <span class="text-yellow-300">// CUPS del examen</span>
|
||||||
|
"codigoBodega": "00",
|
||||||
|
"cantidad": 1,
|
||||||
|
"observacion": "",
|
||||||
|
"profesional": "12345678",
|
||||||
|
"especialidad": "01",
|
||||||
|
"profesionalRemisionante": "00",
|
||||||
|
"diagnosticoprincipal": "Z017",
|
||||||
|
"fechaHoraRealizacion": "15/07/2026 08:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── LÓGICA FORMAS DE PAGO ── -->
|
||||||
|
<section id="logica-pago" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-credit-card text-blue-500"></i> Lógica de negocio — Formas de Pago</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-4">El sistema determina <code class="bg-gray-100 px-1 rounded text-xs">codFormaPago</code> con la siguiente prioridad:</p>
|
||||||
|
<div class="bg-blue-50 rounded-lg p-3 mb-4 text-sm">
|
||||||
|
<strong class="text-blue-800">Prioridad 1:</strong> Si el contrato tiene "Forma de Pago" asignada → se usa esa, ignorando el total.<br>
|
||||||
|
<strong class="text-blue-800">Prioridad 2:</strong> Si no tiene asignación → automático: <code class="bg-white px-1 rounded text-xs">CIAC</code> si total=$0, <code class="bg-white px-1 rounded text-xs">CR</code> si total>$0.
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Código</th><th class="px-3 py-2 text-left">Nombre</th><th class="px-3 py-2 text-left">plazoDias</th><th class="px-3 py-2 text-left">fechaVence</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold text-blue-700 font-mono">CIAC</td><td class="px-3 py-2">Contado inmediato</td><td class="px-3 py-2 font-mono">0</td><td class="px-3 py-2">= fecha de la factura</td></tr>
|
||||||
|
<tr><td class="px-3 py-2 font-bold text-blue-700 font-mono">CR</td><td class="px-3 py-2">Crédito</td><td class="px-3 py-2 font-mono">DIASVENC (30)</td><td class="px-3 py-2">FECHAFACT + 30 días</td></tr>
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold text-blue-700 font-mono">MU</td><td class="px-3 py-2">Mixto</td><td class="px-3 py-2 font-mono">DIASVENC (30)</td><td class="px-3 py-2">FECHAFACT + 30 días</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-gray-500 mt-2"><code class="bg-gray-100 px-1 rounded">codBanco</code> siempre se envía como cadena vacía <code class="bg-gray-100 px-1 rounded">""</code>. DIASVENC se lee de <code class="bg-gray-100 px-1 rounded">FACTURA_DIAN.DIASVENC</code> en Firebird.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── LÓGICA EXCLUSIONES ── -->
|
||||||
|
<section id="logica-contratos" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-sliders-h text-blue-500"></i> Lógica de negocio — Exclusiones y Prefijos</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 mb-2">Banderas de contrato</p>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Bandera</th><th class="px-3 py-2 text-left">Efecto</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-mono">excluir_rda=1</td><td class="px-3 py-2">NO aparece en RDA / Automatización.</td></tr>
|
||||||
|
<tr><td class="px-3 py-2 font-mono">excluir_ventas=1</td><td class="px-3 py-2">NO aparece en Ventas.</td></tr>
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-mono">sin_contrato=1</td><td class="px-3 py-2">JSON RDA con numeroContrato: null.</td></tr>
|
||||||
|
<tr><td class="px-3 py-2 font-mono">cod_forma_pago</td><td class="px-3 py-2">Override de forma de pago en venta.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 mb-2">Prefijos de factura</p>
|
||||||
|
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Prefijo</th><th class="px-3 py-2 text-left">Módulo</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold font-mono text-blue-700">CMXC</td><td class="px-3 py-2">Ventas — remisiones externas.</td></tr>
|
||||||
|
<tr><td class="px-3 py-2 font-bold font-mono text-green-700">LHXC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
|
||||||
|
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold font-mono text-green-700">RCXC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
|
||||||
|
<tr><td class="px-3 py-2 font-bold font-mono text-green-700">SC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── SOPORTE ── -->
|
||||||
|
<section id="soporte" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-headset text-blue-500"></i> Soporte técnico</h2>
|
||||||
|
<hr class="border-blue-100 mb-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<p class="text-sm font-bold text-blue-800 mb-2">U-SITE S.A.S. BIC</p>
|
||||||
|
<p class="text-xs text-gray-600 mb-1"><i class="fas fa-envelope mr-1 text-blue-400"></i> contacto@u-s.app</p>
|
||||||
|
<p class="text-xs text-gray-600 mb-1"><i class="fas fa-globe mr-1 text-blue-400"></i> https://u-s.app</p>
|
||||||
|
<p class="text-xs text-gray-600"><i class="fas fa-clock mr-1 text-blue-400"></i> Lunes a viernes — días hábiles</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm font-bold text-amber-800 mb-2"><i class="fas fa-search mr-1"></i> Antes de contactar soporte</p>
|
||||||
|
<ul class="space-y-1 text-xs text-amber-800">
|
||||||
|
<li>• Verificar red entre servidor y 192.168.0.125 (Firebird).</li>
|
||||||
|
<li>• Confirmar que uvicorn está corriendo en el puerto 8080.</li>
|
||||||
|
<li>• Probar credenciales TNS en <span class="font-mono">/config</span>.</li>
|
||||||
|
<li>• Revisar error específico en <span class="font-mono">/logs</span>.</li>
|
||||||
|
<li>• Consultar <span class="font-mono">/logs/actividad</span> para rastrear la operación.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 text-xs text-gray-600 text-center border border-gray-200">
|
||||||
|
<strong>RIPS Manager v1.0</strong> — Desarrollado por U-SITE S.A.S. BIC para Laboratorio Clínico Ximena Caicedo
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div><!-- /contenido -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Resaltar sección activa en sidebar al hacer scroll
|
||||||
|
const sections = document.querySelectorAll('section[id]');
|
||||||
|
const navLinks = document.querySelectorAll('.doc-link');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
let current = '';
|
||||||
|
sections.forEach(s => {
|
||||||
|
if (window.scrollY >= s.offsetTop - 120) current = s.id;
|
||||||
|
});
|
||||||
|
navLinks.forEach(a => {
|
||||||
|
a.classList.remove('bg-blue-100', 'text-blue-800', 'font-semibold');
|
||||||
|
if (a.getAttribute('href') === '#' + current) {
|
||||||
|
a.classList.add('bg-blue-100', 'text-blue-800', 'font-semibold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Smooth scroll
|
||||||
|
navLinks.forEach(a => {
|
||||||
|
a.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(a.getAttribute('href'));
|
||||||
|
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}ERP Lab{% endblock %}
|
||||||
|
{% block header %}<i class="fas fa-flask mr-2 text-green-400"></i> ERP Lab · Ximena Caicedo{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
|
||||||
|
|
||||||
|
<!-- Barra de sub-tabs -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
|
||||||
|
<div class="flex">
|
||||||
|
<button id="btn-pacientes" onclick="switchTab('pacientes')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-users text-xs"></i> Pacientes
|
||||||
|
</button>
|
||||||
|
<button id="btn-scheduler" onclick="switchTab('scheduler')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-sync-alt text-xs"></i> Sync Automático
|
||||||
|
<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-semibold">20 seg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paneles -->
|
||||||
|
<div class="flex-1 relative overflow-hidden bg-gray-50">
|
||||||
|
|
||||||
|
<!-- Tab: Pacientes (iframe) -->
|
||||||
|
<div id="panel-pacientes" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-pacientes" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Sync Automático (inline) -->
|
||||||
|
<div id="panel-scheduler" class="tab-panel absolute inset-0 hidden overflow-y-auto">
|
||||||
|
<div class="p-6 space-y-5 max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Estado del scheduler -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-robot mr-2 text-green-500"></i>Estado del Scheduler
|
||||||
|
</h3>
|
||||||
|
<span id="sched-badge" class="flex items-center gap-2 text-sm font-medium text-gray-400">
|
||||||
|
<span id="sched-dot" class="w-2 h-2 rounded-full bg-gray-300"></span>
|
||||||
|
<span id="sched-label">Verificando…</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="sched-live" class="px-5 pt-3 pb-2 text-xs text-gray-500 flex flex-wrap gap-x-6 gap-y-1 border-b border-gray-100"></div>
|
||||||
|
<div class="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fas fa-database text-blue-400 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700">Fuente</p>
|
||||||
|
<p class="text-xs text-gray-500">Firebird · DBLAB_XIMENA_FB25</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fas fa-filter text-purple-400 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700">Ventana de captura</p>
|
||||||
|
<p class="text-xs text-gray-500">Recepciones con HORAINICIORECEPCION en los últimos 2 minutos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fas fa-paper-plane text-green-400 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700">Destino</p>
|
||||||
|
<p class="text-xs text-gray-500">WhatsApp Lab · <code>ingest_paciente.php</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-4 border-t border-gray-100 pt-3">
|
||||||
|
<p class="text-xs text-gray-500 font-medium mb-2">Datos sincronizados por recepción:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">Paciente (upsert)</span>
|
||||||
|
<span class="text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded">Exámenes + CUPS</span>
|
||||||
|
<span class="text-xs bg-orange-50 text-orange-700 px-2 py-1 rounded">Diagnóstico CIE-10</span>
|
||||||
|
<span class="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">Médico ordenante</span>
|
||||||
|
<span class="text-xs bg-teal-50 text-teal-700 px-2 py-1 rounded">Empresa / EPS</span>
|
||||||
|
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded">Valor total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Migración diagnósticos CIE-10 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-stethoscope mr-2 text-orange-400"></i>Migración CIE-10
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-400">12.422 diagnósticos de Firebird → WhatsApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 flex flex-wrap items-center gap-4">
|
||||||
|
<p class="text-sm text-gray-500 flex-1">Pobla la tabla <code class="bg-gray-100 px-1 rounded">cie10_diagnosticos</code> en WhatsApp. Idempotente — se puede re-ejecutar. Si corta a la mitad, usa "Reanudar" para continuar desde donde quedó.</p>
|
||||||
|
<button id="btn-sync-diag" onclick="ejecutarSyncDiag()"
|
||||||
|
class="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-upload text-xs"></i> Migrar diagnósticos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sync-diag-result" class="hidden px-5 pb-4">
|
||||||
|
<div id="sync-diag-inner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prueba manual -->
|
||||||
|
<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-play-circle mr-2 text-blue-500"></i>Prueba manual
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 flex flex-wrap items-center gap-4">
|
||||||
|
<select id="sel-ventana"
|
||||||
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300">
|
||||||
|
<option value="2">Últimos 2 minutos (igual al scheduler)</option>
|
||||||
|
<option value="30" selected>Últimos 30 minutos</option>
|
||||||
|
<option value="120">Últimas 2 horas</option>
|
||||||
|
<option value="1440">Hoy completo</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-sync-now" onclick="ejecutarSyncNow()"
|
||||||
|
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-bolt text-xs"></i> Ejecutar ahora
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sync-now-result" class="hidden px-5 pb-5">
|
||||||
|
<div id="sync-now-inner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Historial de sync automático -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-history mr-2 text-gray-400"></i>Historial de sync automático
|
||||||
|
</h3>
|
||||||
|
<button onclick="cargarHistorialScheduler()"
|
||||||
|
class="text-sm text-blue-600 hover:underline flex items-center gap-1">
|
||||||
|
<i class="fas fa-refresh text-xs"></i> Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="scheduler-hist" class="p-4">
|
||||||
|
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _TAB_ACTIVE = 'border-green-500 text-green-700 bg-green-50';
|
||||||
|
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
|
||||||
|
|
||||||
|
let _schedulerLoaded = false;
|
||||||
|
let _histRefreshTimer = null;
|
||||||
|
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
|
||||||
|
b.classList.add(..._TAB_INACTIVE.split(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('panel-' + name).classList.remove('hidden');
|
||||||
|
const btn = document.getElementById('btn-' + name);
|
||||||
|
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
|
||||||
|
btn.classList.add(..._TAB_ACTIVE.split(' '));
|
||||||
|
|
||||||
|
if (name === 'pacientes') {
|
||||||
|
const iframe = document.getElementById('iframe-pacientes');
|
||||||
|
if (!iframe.src) iframe.src = '/pacientes?embed=1';
|
||||||
|
}
|
||||||
|
if (name === 'scheduler') {
|
||||||
|
if (!_schedulerLoaded) {
|
||||||
|
_schedulerLoaded = true;
|
||||||
|
cargarHistorialScheduler();
|
||||||
|
pollSchedulerStatus();
|
||||||
|
}
|
||||||
|
// Auto-refresh historial cada 60s mientras el tab está activo
|
||||||
|
clearInterval(_histRefreshTimer);
|
||||||
|
_histRefreshTimer = setInterval(cargarHistorialScheduler, 60000);
|
||||||
|
} else {
|
||||||
|
clearInterval(_histRefreshTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem('erp-tab', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _schedPollTimer = null;
|
||||||
|
async function pollSchedulerStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/scheduler/status');
|
||||||
|
const d = await r.json();
|
||||||
|
const dot = document.getElementById('sched-dot');
|
||||||
|
const label = document.getElementById('sched-label');
|
||||||
|
const live = document.getElementById('sched-live');
|
||||||
|
if (d.running && d.job_exists) {
|
||||||
|
dot.className = 'w-2 h-2 rounded-full bg-green-400 animate-pulse';
|
||||||
|
label.textContent = 'Activo — cada 20 segundos';
|
||||||
|
label.className = 'text-green-600';
|
||||||
|
} else {
|
||||||
|
dot.className = 'w-2 h-2 rounded-full bg-red-400';
|
||||||
|
label.textContent = d.running ? 'Sin tarea registrada' : 'DETENIDO';
|
||||||
|
label.className = 'text-red-600';
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (d.next_run) {
|
||||||
|
parts.push(`<span><i class="fas fa-clock mr-1 text-blue-400"></i>Próxima ejecución: <strong>${d.next_run}</strong></span>`);
|
||||||
|
}
|
||||||
|
if (d.ultimo_log) {
|
||||||
|
const ul = d.ultimo_log;
|
||||||
|
parts.push(`<span><i class="fas fa-history mr-1 text-purple-400"></i>Último sync scheduler: <strong>${ul.hora}</strong> · ${ul.total ?? 0} procesados, ${ul.errores ?? 0} errores</span>`);
|
||||||
|
}
|
||||||
|
live.innerHTML = parts.join('');
|
||||||
|
} catch(_) {}
|
||||||
|
_schedPollTimer = setTimeout(pollSchedulerStatus, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
|
||||||
|
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
|
||||||
|
|
||||||
|
async function ejecutarSyncDiag(startFrom = 0) {
|
||||||
|
const btn = document.getElementById('btn-sync-diag');
|
||||||
|
const resBox = document.getElementById('sync-diag-result');
|
||||||
|
const inner = document.getElementById('sync-diag-inner');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Migrando…';
|
||||||
|
resBox.classList.remove('hidden');
|
||||||
|
inner.innerHTML = `<p class="text-sm text-gray-400">Enviando diagnósticos en lotes de 200${startFrom ? ` (desde #${startFrom.toLocaleString()})` : ''}…</p>`;
|
||||||
|
try {
|
||||||
|
const url = `/envios/erp/sync-diagnosticos${startFrom ? `?start_from=${startFrom}` : ''}`;
|
||||||
|
const resp = await fetch(url, { method: 'POST' });
|
||||||
|
const d = await resp.json();
|
||||||
|
const errHTML = d.errores?.length
|
||||||
|
? `<ul class="mt-2 ml-4 list-disc text-xs space-y-0.5">${d.errores.map(e => `<li>${e}</li>`).join('')}</ul>`
|
||||||
|
: '';
|
||||||
|
if (d.ok) {
|
||||||
|
inner.innerHTML = `<div class="text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3">
|
||||||
|
<i class="fas fa-check-circle mr-1"></i>
|
||||||
|
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> diagnósticos migrados.
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const firstErr = d.errores?.[0] || '';
|
||||||
|
const resumeMatch = firstErr.match(/Lote (\d+)-/);
|
||||||
|
const resumeFrom = resumeMatch ? parseInt(resumeMatch[1]) : null;
|
||||||
|
inner.innerHTML = `<div class="text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-lg px-4 py-3">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> migrados — ${d.errores?.length || 0} lote(s) fallaron.
|
||||||
|
${errHTML}
|
||||||
|
${resumeFrom != null ? `<button onclick="ejecutarSyncDiag(${resumeFrom})" class="mt-3 flex items-center gap-1 bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium px-3 py-1.5 rounded-lg">
|
||||||
|
<i class="fas fa-play text-xs"></i> Reanudar desde #${resumeFrom.toLocaleString()}
|
||||||
|
</button>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-upload text-xs"></i> Migrar diagnósticos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDet(id) {
|
||||||
|
const row = document.getElementById(id);
|
||||||
|
if (row) row.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ejecutarSyncNow() {
|
||||||
|
const btn = document.getElementById('btn-sync-now');
|
||||||
|
const ventana = document.getElementById('sel-ventana').value;
|
||||||
|
const resBox = document.getElementById('sync-now-result');
|
||||||
|
const inner = document.getElementById('sync-now-inner');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Sincronizando…';
|
||||||
|
resBox.classList.remove('hidden');
|
||||||
|
inner.innerHTML = '<p class="text-sm text-gray-400">Ejecutando…</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/envios/erp/sync-now?ventana_min=${ventana}`, { method: 'POST' });
|
||||||
|
const d = await resp.json();
|
||||||
|
|
||||||
|
if (!d.ok) {
|
||||||
|
inner.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 text-red-600 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span>${d.error || 'Error desconocido'}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (d.total === 0) {
|
||||||
|
inner.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-sm">
|
||||||
|
<i class="fas fa-info-circle text-blue-400"></i>
|
||||||
|
<span>${d.mensaje || 'Sin recepciones en la ventana seleccionada.'} (Hoy: <strong>${d.total_hoy}</strong> recepción/es registradas)</span>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const detalles = (d.detalle || []).map(p => {
|
||||||
|
const a = p.action || (p.ok ? 'updated' : 'error');
|
||||||
|
const badge = a === 'created' ? '<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
|
||||||
|
: a === 'updated' ? '<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
|
||||||
|
: a === 'skipped' ? '<span class="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">Omitido</span>'
|
||||||
|
: `<span class="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded" title="${p.message || ''}">${a}</span>`;
|
||||||
|
return `<div class="flex items-center justify-between py-1 border-b border-gray-50 text-xs">
|
||||||
|
<span class="text-gray-700">${p.nombre || p.doc || '—'}</span>
|
||||||
|
${badge}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
inner.innerHTML = `
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mb-3">
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm">
|
||||||
|
<span class="text-green-700 font-semibold"><i class="fas fa-check-circle mr-1"></i>${d.total} procesados</span>
|
||||||
|
<span class="text-blue-600">${d.created} nuevos</span>
|
||||||
|
<span class="text-green-600">${d.updated} actualizados</span>
|
||||||
|
<span class="text-gray-400">${d.skipped} omitidos</span>
|
||||||
|
${d.errores > 0 ? `<span class="text-red-500 font-medium">${d.errores} errores</span>` : ''}
|
||||||
|
<span class="text-gray-400 ml-auto text-xs">Hoy: ${d.total_hoy} recepciones</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${detalles ? `<div class="max-h-48 overflow-y-auto pr-1">${detalles}</div>` : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-bolt text-xs"></i> Ejecutar ahora';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargarHistorialScheduler() {
|
||||||
|
const el = document.getElementById('scheduler-hist');
|
||||||
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/pacientes/historial');
|
||||||
|
const rows = await resp.json();
|
||||||
|
const sched = rows.filter(r => r.origen === 'scheduler');
|
||||||
|
if (!sched.length) {
|
||||||
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin ejecuciones automáticas registradas aún.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<th class="pb-2 pr-4 font-medium">Fecha</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium">Usuario</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right">Procesados</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
|
||||||
|
<th class="pb-2 font-medium text-right">Detalle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
${sched.map((r, i) => {
|
||||||
|
const dt = new Date(r.created_at.replace(' ', 'T') + 'Z');
|
||||||
|
const fecha = dt.toLocaleString('es-CO', {
|
||||||
|
timeZone: 'America/Bogota',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false
|
||||||
|
});
|
||||||
|
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
|
||||||
|
const pacientes = r.detalle_json ? JSON.parse(r.detalle_json) : [];
|
||||||
|
const rowId = 'hist-det-' + i;
|
||||||
|
|
||||||
|
const detHtml = pacientes.map((p, pi) => {
|
||||||
|
const badge = p.action === 'created' ? '<span class="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
|
||||||
|
: p.action === 'updated' ? '<span class="bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
|
||||||
|
: p.action === 'skipped' ? '<span class="bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">Omitido</span>'
|
||||||
|
: `<span class="bg-red-100 text-red-600 px-1.5 py-0.5 rounded">${p.action}</span>`;
|
||||||
|
|
||||||
|
const diag = p.diagnostico_cod
|
||||||
|
? `<span class="text-orange-600">${p.diagnostico_cod}</span>${p.diagnostico_nombre ? ' · ' + p.diagnostico_nombre : ''}`
|
||||||
|
: '<span class="text-gray-300">Sin diagnóstico</span>';
|
||||||
|
|
||||||
|
const empresa = (p.nom_empresa || p.nit_empresa)
|
||||||
|
? `<span class="text-teal-600">${p.nom_empresa || p.nit_empresa}</span>`
|
||||||
|
: '<span class="text-gray-300">Sin empresa</span>';
|
||||||
|
|
||||||
|
const valor = p.valor_total > 0
|
||||||
|
? `<span class="text-yellow-700">$${Number(p.valor_total).toLocaleString('es-CO')}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const exams = (p.examenes_det || []).map(e =>
|
||||||
|
`<tr class="border-b border-gray-50">
|
||||||
|
<td class="py-0.5 pr-3 font-mono text-purple-600">${e.cups || '—'}</td>
|
||||||
|
<td class="py-0.5 pr-3 text-gray-600">${e.nombre || '—'}</td>
|
||||||
|
<td class="py-0.5 text-right text-gray-500">${e.precio != null ? '$' + Number(e.precio).toLocaleString('es-CO') : '—'}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `<div class="mb-3 border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||||||
|
<span class="text-gray-400 font-mono text-xs">${p.doc}</span>
|
||||||
|
<span class="font-medium text-gray-700 flex-1">${p.nombre}</span>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-xs border-b border-gray-50">
|
||||||
|
<span>Dx: ${diag}</span>
|
||||||
|
<span>EPS: ${empresa}</span>
|
||||||
|
${valor ? `<span>Total: ${valor}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${exams ? `<table class="w-full text-xs px-3">
|
||||||
|
<thead><tr class="text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="py-1 pr-3 text-left font-medium px-3">CUPS</th>
|
||||||
|
<th class="py-1 pr-3 text-left font-medium">Examen</th>
|
||||||
|
<th class="py-1 text-right font-medium pr-3">Precio</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody class="px-3">${exams}</tbody>
|
||||||
|
</table>` : '<p class="text-xs text-gray-300 px-3 py-1">Sin exámenes registrados</p>'}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
|
||||||
|
<td class="py-2 pr-3 text-xs text-gray-400">${r.username || '—'}</td>
|
||||||
|
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
|
||||||
|
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
|
||||||
|
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
|
||||||
|
<td class="py-2 pr-3 text-right">
|
||||||
|
${r.errores > 0
|
||||||
|
? `<button onclick="toggleDet('${rowId}')" class="text-red-500 font-medium hover:underline">${r.errores}</button>`
|
||||||
|
: '<span class="text-gray-300">—</span>'}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 text-right">
|
||||||
|
${pacientes.length ? `
|
||||||
|
<button onclick="toggleDet('${rowId}')"
|
||||||
|
class="text-xs text-blue-500 hover:underline">Ver ${pacientes.length}</button>` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${(pacientes.length || errDet.length) ? `
|
||||||
|
<tr id="${rowId}" class="hidden">
|
||||||
|
<td colspan="7" class="px-3 pb-4 pt-2">
|
||||||
|
${errDet.length ? `
|
||||||
|
<div class="mb-3 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-xs text-red-700">
|
||||||
|
<p class="font-medium mb-1"><i class="fas fa-exclamation-circle mr-1"></i>${errDet.length} error(es):</p>
|
||||||
|
<ul class="list-disc ml-4 space-y-0.5">${errDet.map(e =>
|
||||||
|
typeof e === 'object'
|
||||||
|
? `<li><span class="font-mono text-red-400">${e.doc || ''}</span> ${e.nombre || ''} — ${e.msg || JSON.stringify(e)}</li>`
|
||||||
|
: `<li>${e}</li>`
|
||||||
|
).join('')}</ul>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="max-h-96 overflow-y-auto space-y-2 text-xs">
|
||||||
|
${detHtml}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(function init() {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
|
||||||
|
});
|
||||||
|
const saved = sessionStorage.getItem('erp-tab') || 'pacientes';
|
||||||
|
switchTab(saved);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Módulo TNS{% endblock %}
|
||||||
|
{% block header %}<i class="fas fa-paper-plane mr-2 text-blue-400"></i> Módulo TNS{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
|
||||||
|
|
||||||
|
<!-- Barra de sub-tabs -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
|
||||||
|
<div class="flex">
|
||||||
|
<button id="btn-terceros" onclick="switchTab('terceros', '/terceros?embed=1')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-user text-xs"></i> Terceros
|
||||||
|
</button>
|
||||||
|
<button id="btn-transaccion" onclick="switchTab('transaccion', '/transaccion?embed=1')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-exchange-alt text-xs"></i> Transacción RIPS
|
||||||
|
</button>
|
||||||
|
<button id="btn-ventas" onclick="switchTab('ventas', '/ventas?embed=1')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-file-invoice-dollar text-xs"></i> Facturas Venta
|
||||||
|
</button>
|
||||||
|
<button id="btn-rda" onclick="switchTab('rda', '/test-rda?embed=1')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-flask text-xs"></i> Prueba RDA
|
||||||
|
</button>
|
||||||
|
<button id="btn-automation" onclick="switchTab('automation', '/automation?embed=1')"
|
||||||
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
||||||
|
<i class="fas fa-robot text-xs"></i> Automatización
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paneles de iframe -->
|
||||||
|
<div class="flex-1 relative overflow-hidden bg-gray-50">
|
||||||
|
|
||||||
|
<div id="panel-terceros" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-terceros" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-transaccion" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-transaccion" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-ventas" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-ventas" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-rda" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-rda" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-automation" class="tab-panel absolute inset-0 hidden">
|
||||||
|
<iframe id="iframe-automation" class="w-full h-full border-0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _TAB_ACTIVE = 'border-blue-500 text-blue-600 bg-blue-50';
|
||||||
|
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
|
||||||
|
|
||||||
|
function switchTab(name, src) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
|
||||||
|
b.classList.add(..._TAB_INACTIVE.split(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = document.getElementById('panel-' + name);
|
||||||
|
const btn = document.getElementById('btn-' + name);
|
||||||
|
const iframe = document.getElementById('iframe-' + name);
|
||||||
|
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
|
||||||
|
btn.classList.add(..._TAB_ACTIVE.split(' '));
|
||||||
|
|
||||||
|
if (src && !iframe.src) {
|
||||||
|
iframe.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem('tns-tab', name);
|
||||||
|
sessionStorage.setItem('tns-src-' + name, src);
|
||||||
|
}
|
||||||
|
|
||||||
|
(function init() {
|
||||||
|
// Establecer clases base en todos los botones
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = sessionStorage.getItem('tns-tab') || 'terceros';
|
||||||
|
const srcMap = {
|
||||||
|
terceros: '/terceros?embed=1',
|
||||||
|
transaccion:'/transaccion?embed=1',
|
||||||
|
ventas: '/ventas?embed=1',
|
||||||
|
rda: '/test-rda?embed=1',
|
||||||
|
automation: '/automation?embed=1',
|
||||||
|
};
|
||||||
|
switchTab(saved, srcMap[saved] || srcMap.terceros);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+59
-18
@@ -10,11 +10,12 @@
|
|||||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
|
||||||
<span class="px-2 py-0.5 rounded text-xs font-medium
|
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||||
{% if s.tipo == 'transaccion' %}bg-purple-100 text-purple-700
|
{% if s.tipo == 'transaccion' %}bg-purple-100 text-purple-700
|
||||||
|
{% elif s.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
|
||||||
{% else %}bg-blue-100 text-blue-700{% endif %}">{{ s.tipo }}</span>
|
{% else %}bg-blue-100 text-blue-700{% endif %}">{{ s.tipo }}</span>
|
||||||
<span class="{% if s.status == 'success' %}text-green-600{% else %}text-red-600{% endif %} font-bold text-sm">
|
<span class="{% if s.status == 'success' %}text-green-600{% elif s.status == 'warning' %}text-yellow-600{% else %}text-red-600{% endif %} font-bold text-sm">
|
||||||
{{ s.cnt }}
|
{{ s.cnt }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-400 text-xs">{{ 'exitosos' if s.status == 'success' else 'errores' }}</span>
|
<span class="text-gray-400 text-xs">{{ 'enviados' if s.status == 'success' else ('ya existe' if s.status == 'warning' else 'errores') }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-2 text-gray-500 text-sm">
|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-2 text-gray-500 text-sm">
|
||||||
@@ -34,14 +35,16 @@
|
|||||||
<option value="">Todos los tipos</option>
|
<option value="">Todos los tipos</option>
|
||||||
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
|
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
|
||||||
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
|
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
|
||||||
|
<option value="ventas" {{ 'selected' if filtro_tipo == 'ventas' }}>Ventas</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">Estado</label>
|
<label class="block text-xs font-medium text-gray-500 mb-1">Estado</label>
|
||||||
<select name="status" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[120px]">
|
<select name="status" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[120px]">
|
||||||
<option value="">Todos</option>
|
<option value="">Todos</option>
|
||||||
<option value="success" {{ 'selected' if filtro_status == 'success' }}>Exitoso</option>
|
<option value="success" {{ 'selected' if filtro_status == 'success' }}>✓ Enviado</option>
|
||||||
<option value="error" {{ 'selected' if filtro_status == 'error' }}>Error</option>
|
<option value="warning" {{ 'selected' if filtro_status == 'warning' }}>! Ya existe</option>
|
||||||
|
<option value="error" {{ 'selected' if filtro_status == 'error' }}>✗ Error</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -60,8 +63,8 @@
|
|||||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-28">
|
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-28">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">IDRECEP / Contrato</label>
|
<label class="block text-xs font-medium text-gray-500 mb-1">Cédula paciente</label>
|
||||||
<input type="text" name="paciente" value="{{ filtro_paciente }}" placeholder="ID o contrato"
|
<input type="text" name="paciente" value="{{ filtro_paciente }}" placeholder="Nro. documento"
|
||||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-36">
|
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-36">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -107,11 +110,12 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for e in envios %}
|
{% for e in envios %}
|
||||||
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
|
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
|
||||||
{% if e.status == 'success' %}bg-green-50/25{% elif e.status == 'error' %}bg-red-50/25{% endif %}">
|
{% if e.status == 'success' %}bg-green-50/25{% elif e.status == 'warning' %}bg-yellow-50/40{% elif e.status == 'error' %}bg-red-50/25{% endif %}">
|
||||||
<td class="px-4 py-2.5 text-gray-400 font-mono">{{ e.id }}</td>
|
<td class="px-4 py-2.5 text-gray-400 font-mono">{{ e.id }}</td>
|
||||||
<td class="px-2 py-2.5">
|
<td class="px-2 py-2.5">
|
||||||
<span class="px-2 py-0.5 rounded text-xs font-medium
|
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||||
{% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700
|
{% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700
|
||||||
|
{% elif e.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
|
||||||
{% else %}bg-purple-100 text-purple-700{% endif %}">
|
{% else %}bg-purple-100 text-purple-700{% endif %}">
|
||||||
{{ e.tipo }}
|
{{ e.tipo }}
|
||||||
</span>
|
</span>
|
||||||
@@ -120,11 +124,13 @@
|
|||||||
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ e.idrecepcion or '-' }}</td>
|
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ e.idrecepcion or '-' }}</td>
|
||||||
<td class="px-2 py-2.5 text-gray-500">{{ e.contrato or '-' }}</td>
|
<td class="px-2 py-2.5 text-gray-500">{{ e.contrato or '-' }}</td>
|
||||||
<td class="px-2 py-2.5">
|
<td class="px-2 py-2.5">
|
||||||
<span class="px-2 py-0.5 rounded text-xs font-medium
|
{% if e.status == 'success' %}
|
||||||
{% if e.status == 'success' %}bg-green-100 text-green-700
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>
|
||||||
{% else %}bg-red-100 text-red-700{% endif %}">
|
{% elif e.status == 'warning' %}
|
||||||
{{ '✓ OK' if e.status == 'success' else '✗ Error' }}
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">! Ya existe</span>
|
||||||
</span>
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">✗ Error</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-2.5 text-gray-500">
|
<td class="px-2 py-2.5 text-gray-500">
|
||||||
{% if e.mensaje_tns %}
|
{% if e.mensaje_tns %}
|
||||||
@@ -135,11 +141,17 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ e.created_at[:16] if e.created_at else '-' }}</td>
|
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ e.created_at[:16] if e.created_at else '-' }}</td>
|
||||||
<td class="px-2 py-2.5 text-gray-400">{{ e.username or '-' }}</td>
|
<td class="px-2 py-2.5 text-gray-400">{{ e.username or '-' }}</td>
|
||||||
<td class="px-2 py-2.5">
|
<td class="px-2 py-2.5 flex items-center gap-1.5">
|
||||||
<button onclick="verDetalle({{ e.id }})"
|
<button onclick="verDetalle({{ e.id }})"
|
||||||
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle completo">
|
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
|
{% if e.status == 'error' and e.json_enviado %}
|
||||||
|
<button onclick="reenviar({{ e.id }}, this)"
|
||||||
|
class="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 rounded" title="Reenviar a TNS">
|
||||||
|
<i class="fas fa-redo text-xs"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -204,14 +216,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
async function reenviar(id, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/logs/reenviar/${id}`, {method: 'POST', credentials: 'include'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Reenvío exitoso', 'success');
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
row.classList.remove('bg-red-50/25');
|
||||||
|
row.classList.add('bg-green-50/25');
|
||||||
|
const badge = row.querySelector('td:nth-child(6) span');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = '✓ OK';
|
||||||
|
badge.className = 'px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700';
|
||||||
|
}
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
showToast(data.message || 'Error al reenviar', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Error de conexión', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function verDetalle(id) {
|
async function verDetalle(id) {
|
||||||
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
|
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.error) { showToast(data.error, 'error'); return; }
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
|
|
||||||
const estadoLabel = data.status === 'success' ? '✓ Exitoso' : '✗ Error';
|
const estadoLabel = data.status === 'success' ? '✓ Enviado' : data.status === 'warning' ? '! Ya existe' : '✗ Error';
|
||||||
document.getElementById('modal-titulo').innerHTML =
|
document.getElementById('modal-titulo').innerHTML =
|
||||||
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : 'text-red-600'}">${estadoLabel}</span>`;
|
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : data.status === 'warning' ? 'text-yellow-600' : 'text-red-600'}">${estadoLabel}</span>`;
|
||||||
|
|
||||||
const info = [
|
const info = [
|
||||||
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
|
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
|
||||||
@@ -266,8 +307,8 @@ function syntaxHighlight(obj) {
|
|||||||
return json
|
return json
|
||||||
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
||||||
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
||||||
.replace(/:(\s*)(\d+(?:\.\d+)?)/g,': $1<span class="text-orange-600">$2</span>')
|
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
|
||||||
.replace(/:(\s*)(null|true|false)/g,': $1<span class="text-purple-600">$2</span>');
|
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
{% 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>
|
||||||
|
|
||||||
|
<!-- Historial -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-history mr-2 text-gray-400"></i>Historial de sincronizaciones
|
||||||
|
</h3>
|
||||||
|
<button onclick="cargarHistorial()" class="text-sm text-blue-600 hover:underline">
|
||||||
|
<i class="fas fa-refresh mr-1"></i>Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="historialContainer" class="p-4">
|
||||||
|
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
|
||||||
|
</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-4 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-gray-400">${json.skipped ?? 0}</p>
|
||||||
|
<p class="text-gray-500">Ya existían</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
||||||
|
<p class="text-2xl font-bold text-red-500">${json.errores}</p>
|
||||||
|
<p class="text-gray-500">Errores</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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', automation: 'Automatización' };
|
||||||
|
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
|
||||||
|
|
||||||
|
async function cargarHistorial() {
|
||||||
|
const el = document.getElementById('historialContainer');
|
||||||
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/pacientes/historial');
|
||||||
|
const rows = await resp.json();
|
||||||
|
if (!rows.length) {
|
||||||
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin registros aún.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<th class="pb-2 pr-4 font-medium">Fecha</th>
|
||||||
|
<th class="pb-2 pr-4 font-medium">Origen</th>
|
||||||
|
<th class="pb-2 pr-4 font-medium">Modo</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right">Total</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-gray-400">Omitidos</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
|
||||||
|
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
|
||||||
|
<th class="pb-2 font-medium">Usuario</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
${rows.map(r => {
|
||||||
|
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
|
||||||
|
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
r.origen === 'manual' ? 'bg-blue-50 text-blue-700' :
|
||||||
|
r.origen === 'tercero' ? 'bg-orange-50 text-orange-700' :
|
||||||
|
'bg-purple-50 text-purple-700'
|
||||||
|
}">${_origenLabel[r.origen] || r.origen}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-500 text-xs">${_modoLabel[r.modo] || r.modo}</td>
|
||||||
|
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
|
||||||
|
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
|
||||||
|
<td class="py-2 pr-3 text-right text-gray-400">${r.skipped}</td>
|
||||||
|
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
|
||||||
|
<td class="py-2 pr-3 text-right ${r.errores > 0 ? 'text-red-500 font-medium' : 'text-gray-300'}">
|
||||||
|
${r.errores > 0 && errDet.length ? `
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer">${r.errores}</summary>
|
||||||
|
<div class="absolute z-10 bg-white border border-red-100 rounded-lg shadow-lg p-2 text-xs mt-1 max-w-xs">
|
||||||
|
${errDet.map(e => `<div class="text-red-600">${e.doc} — ${e.msg}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>` : r.errores || '—'}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 text-gray-400 text-xs">${r.username || '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar historial al abrir la página
|
||||||
|
cargarHistorial();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Resumen Diario{% endblock %}
|
||||||
|
{% block header %}Resumen Diario TNS{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-5">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
|
||||||
|
<i class="fas fa-layer-group text-gray-400 text-sm"></i>
|
||||||
|
<span class="font-bold text-sm text-gray-700">{{ total }}</span>
|
||||||
|
<span class="text-gray-400 text-xs">facturas procesadas</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
|
||||||
|
<i class="fas fa-check-circle text-green-500 text-sm"></i>
|
||||||
|
<span class="font-bold text-sm text-green-600">{{ exitosos }}</span>
|
||||||
|
<span class="text-gray-400 text-xs">exitosas</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
|
||||||
|
<i class="fas fa-times-circle text-red-500 text-sm"></i>
|
||||||
|
<span class="font-bold text-sm text-red-600">{{ errores }}</span>
|
||||||
|
<span class="text-gray-400 text-xs">error persistente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
|
||||||
|
<form method="get" action="/logs/resumen" class="p-4">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Fecha</label>
|
||||||
|
<input type="date" name="fecha" value="{{ fecha }}"
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Tipo</label>
|
||||||
|
<select name="tipo" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
|
||||||
|
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
|
||||||
|
<option value="ventas" {{ 'selected' if filtro_tipo == 'ventas' }}>Ventas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
||||||
|
<i class="fas fa-search mr-1"></i> Consultar
|
||||||
|
</button>
|
||||||
|
<a href="/logs/resumen" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Hoy">
|
||||||
|
<i class="fas fa-calendar-day"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{% if total %}{{ total }} registro{{ 's' if total != 1 }} — {{ fecha }}{% else %}Sin registros para {{ fecha }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Estado final: si hubo al menos un envío exitoso, se marca como exitoso
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-400 border-b border-gray-200 bg-gray-50/60">
|
||||||
|
<th class="px-4 py-3 font-medium">Tipo</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Factura</th>
|
||||||
|
<th class="px-2 py-3 font-medium">IDRECEP.</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Contrato</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Cédula</th>
|
||||||
|
<th class="px-2 py-3 font-medium text-center">Intentos</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Estado final</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Primer envío</th>
|
||||||
|
<th class="px-2 py-3 font-medium">Último envío</th>
|
||||||
|
<th class="px-2 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
|
||||||
|
{% if r.estado_final == 'success' %}bg-green-50/20
|
||||||
|
{% elif r.estado_final == 'warning' %}bg-yellow-50/30
|
||||||
|
{% else %}bg-red-50/20{% endif %}"
|
||||||
|
data-error-id="{{ r.ultimo_error_id or '' }}"
|
||||||
|
data-exitoso-id="{{ r.exitoso_id or '' }}">
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
{% if r.tipo == 'terceros' %}bg-blue-100 text-blue-700
|
||||||
|
{% elif r.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
|
||||||
|
{% else %}bg-purple-100 text-purple-700{% endif %}">
|
||||||
|
{{ r.tipo }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-700 font-medium">{{ '—' if r.tipo == 'terceros' else (r.factura or '—') }}</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.idrecepcion or '—' }}</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-500">{{ r.contrato or '—' }}</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.cedula or '—' }}</td>
|
||||||
|
<td class="px-2 py-2.5 text-center">
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-mono">{{ r.intentos }}</span>
|
||||||
|
{% if r.exitos > 0 and r.intentos > r.exitos %}
|
||||||
|
<span class="text-gray-400 text-[10px] ml-0.5">({{ r.exitos }} ok)</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2.5">
|
||||||
|
{% if r.estado_final == 'success' %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>
|
||||||
|
{% elif r.estado_final == 'warning' %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">! Ya existe</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">✗ Error persistente</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.primer_envio[:16] if r.primer_envio else '—' }}</td>
|
||||||
|
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.ultimo_envio[:16] if r.ultimo_envio else '—' }}</td>
|
||||||
|
<td class="px-2 py-2.5 flex items-center gap-1.5">
|
||||||
|
{% set ver_id = r.exitoso_id or r.ultimo_error_id %}
|
||||||
|
{% if ver_id %}
|
||||||
|
<button onclick="verDetalle({{ ver_id }})"
|
||||||
|
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.estado_final == 'error' and r.ultimo_error_id %}
|
||||||
|
<button onclick="reenviar({{ r.ultimo_error_id }}, this)"
|
||||||
|
class="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 rounded" title="Reenviar a TNS">
|
||||||
|
<i class="fas fa-redo text-xs"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-16 text-gray-400">
|
||||||
|
<i class="fas fa-calendar-times text-5xl mb-4 block opacity-30"></i>
|
||||||
|
<p class="text-sm">Sin envíos registrados para {{ fecha }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal detalle (idéntico al de logs.html) -->
|
||||||
|
<div id="modal-detalle" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="absolute inset-0 bg-black/60" onclick="closeModal()"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-3xl bg-white rounded-xl shadow-2xl max-h-[88vh] overflow-hidden flex flex-col">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center shrink-0">
|
||||||
|
<h3 id="modal-titulo" class="font-semibold text-gray-800 text-sm"></h3>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="modal-info" class="px-6 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 text-xs text-gray-500 flex flex-wrap gap-4"></div>
|
||||||
|
<div class="flex border-b border-gray-200 shrink-0 px-4">
|
||||||
|
<button onclick="showTab('t-json')" id="btn-t-json"
|
||||||
|
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-500 text-blue-600 -mb-px">
|
||||||
|
JSON enviado</button>
|
||||||
|
<button onclick="showTab('t-tns')" id="btn-t-tns"
|
||||||
|
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
|
||||||
|
Respuesta TNS</button>
|
||||||
|
<button onclick="showTab('t-msg')" id="btn-t-msg"
|
||||||
|
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
|
||||||
|
Mensaje</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto flex-1 p-5">
|
||||||
|
<pre id="t-json" class="tab-pane text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap"></pre>
|
||||||
|
<pre id="t-tns" class="tab-pane hidden text-xs font-mono bg-yellow-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-yellow-900"></pre>
|
||||||
|
<pre id="t-msg" class="tab-pane hidden text-xs font-mono bg-blue-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-blue-900"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function reenviar(id, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/logs/reenviar/${id}`, {method: 'POST', credentials: 'include'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Reenvío exitoso', 'success');
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
row.classList.remove('bg-red-50/20');
|
||||||
|
row.classList.add('bg-green-50/20');
|
||||||
|
const badge = row.querySelector('td:nth-child(7) span');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = '✓ Enviado';
|
||||||
|
badge.className = 'px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700';
|
||||||
|
}
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
showToast(data.message || 'Error al reenviar', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Error de conexión', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verDetalle(id) {
|
||||||
|
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
|
|
||||||
|
const estadoLabel = data.status === 'success' ? '✓ Enviado' : data.status === 'warning' ? '! Ya existe' : '✗ Error';
|
||||||
|
document.getElementById('modal-titulo').innerHTML =
|
||||||
|
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : data.status === 'warning' ? 'text-yellow-600' : 'text-red-600'}">${estadoLabel}</span>`;
|
||||||
|
|
||||||
|
const info = [
|
||||||
|
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
|
||||||
|
data.idrecepcion ? `<span><b>IDRECEPCION:</b> ${data.idrecepcion}</span>` : '',
|
||||||
|
data.contrato ? `<span><b>Contrato:</b> ${data.contrato}</span>` : '',
|
||||||
|
(data.fecha_inicio && data.fecha_fin) ? `<span><b>Período:</b> ${data.fecha_inicio} → ${data.fecha_fin}</span>` : '',
|
||||||
|
data.username ? `<span><b>Usuario:</b> ${data.username}</span>` : '',
|
||||||
|
data.created_at ? `<span><b>Enviado:</b> ${data.created_at.slice(0, 16)}</span>` : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
document.getElementById('modal-info').innerHTML = info;
|
||||||
|
|
||||||
|
const jsonPre = document.getElementById('t-json');
|
||||||
|
if (data.json_enviado) {
|
||||||
|
try { jsonPre.innerHTML = syntaxHighlight(JSON.parse(data.json_enviado)); }
|
||||||
|
catch(e) { jsonPre.textContent = data.json_enviado; }
|
||||||
|
} else {
|
||||||
|
jsonPre.textContent = '(sin JSON guardado)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tnsPre = document.getElementById('t-tns');
|
||||||
|
const raw = data.respuesta_api || '(sin respuesta)';
|
||||||
|
try { tnsPre.textContent = JSON.stringify(JSON.parse(raw), null, 2); }
|
||||||
|
catch(e) { tnsPre.textContent = raw; }
|
||||||
|
|
||||||
|
document.getElementById('t-msg').textContent = data.mensaje_tns || '(sin mensaje)';
|
||||||
|
|
||||||
|
showTab('t-json');
|
||||||
|
document.getElementById('modal-detalle').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.remove('border-blue-500','text-blue-600');
|
||||||
|
b.classList.add('border-transparent','text-gray-500');
|
||||||
|
});
|
||||||
|
document.getElementById(tabId).classList.remove('hidden');
|
||||||
|
const btn = document.getElementById('btn-' + tabId);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('border-blue-500','text-blue-600');
|
||||||
|
btn.classList.remove('border-transparent','text-gray-500');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-detalle').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntaxHighlight(obj) {
|
||||||
|
let json = JSON.stringify(obj, null, 2);
|
||||||
|
json = json.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return json
|
||||||
|
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
||||||
|
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
||||||
|
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
|
||||||
|
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -123,8 +123,8 @@ function syntaxHighlight(obj) {
|
|||||||
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
return json.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g, '<span class="text-blue-600">$1</span>')
|
return json.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g, '<span class="text-blue-600">$1</span>')
|
||||||
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g, ': $1<span class="text-green-600">$2</span>')
|
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g, ': $1<span class="text-green-600">$2</span>')
|
||||||
.replace(/:(\s*)(\d+)/g, ': $1<span class="text-orange-600">$2</span>')
|
.replace(/: (\d+)/g, ': <span class="text-orange-600">$1</span>')
|
||||||
.replace(/:(\s*)(null|true|false)/g, ': $1<span class="text-purple-600">$2</span>');
|
.replace(/: (null|true|false)/g, ': <span class="text-purple-600">$1</span>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -47,6 +47,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enviar RDA directo -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-indigo-200">
|
||||||
|
<div class="px-6 py-4 border-b border-indigo-100 flex items-center gap-2">
|
||||||
|
<i class="fas fa-bolt text-indigo-500"></i>
|
||||||
|
<h3 class="font-semibold text-gray-800">Enviar RDA directo</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Consulta SQL</label>
|
||||||
|
<select id="d-query" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
{% for q in queries %}
|
||||||
|
<option value="{{ q.id }}">{{ q.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo</label>
|
||||||
|
<input id="d-prefijo" type="text" placeholder="LHXC"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm uppercase"
|
||||||
|
style="text-transform:uppercase">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Número</label>
|
||||||
|
<input id="d-numero" type="text" placeholder="4358"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="enviarDirecto()" id="btn-directo"
|
||||||
|
class="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i> Enviar ahora
|
||||||
|
</button>
|
||||||
|
<div id="directo-result" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Resumen -->
|
<!-- Resumen -->
|
||||||
<div id="resumen-box" class="hidden bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
<div id="resumen-box" class="hidden bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
<div class="grid grid-cols-3 gap-2 text-center mb-4">
|
<div class="grid grid-cols-3 gap-2 text-center mb-4">
|
||||||
@@ -215,7 +252,7 @@ function filaHtml(item) {
|
|||||||
const at = (env.at || '').slice(0, 16).replace('T', ' ');
|
const at = (env.at || '').slice(0, 16).replace('T', ' ');
|
||||||
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700" title="Enviado ${at}">✓ Enviado</span>`;
|
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700" title="Enviado ${at}">✓ Enviado</span>`;
|
||||||
rowCls = 'bg-green-50/50 opacity-80';
|
rowCls = 'bg-green-50/50 opacity-80';
|
||||||
accionHtml = '';
|
accionHtml = `<button onclick="enviarUno(${id})" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
} else {
|
} else {
|
||||||
const tip = escAttr(env.mensaje || 'Error');
|
const tip = escAttr(env.mensaje || 'Error');
|
||||||
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${tip}">✗ Error</span>`;
|
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${tip}">✗ Error</span>`;
|
||||||
@@ -263,7 +300,7 @@ function marcarFila(id, ok, mensaje, rawTns) {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-green-50/50 opacity-80';
|
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-green-50/50 opacity-80';
|
||||||
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>`;
|
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>`;
|
||||||
if (btnWrap) btnWrap.innerHTML = '';
|
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno(${id})" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
} else {
|
} else {
|
||||||
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50';
|
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50';
|
||||||
const tip = escAttr(mensaje || 'Error');
|
const tip = escAttr(mensaje || 'Error');
|
||||||
@@ -351,6 +388,60 @@ function closeModal() {
|
|||||||
document.getElementById('modal-detalle').classList.add('hidden');
|
document.getElementById('modal-detalle').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enviarDirecto() {
|
||||||
|
const qid = document.getElementById('d-query').value;
|
||||||
|
const prefijo = document.getElementById('d-prefijo').value.trim().toUpperCase();
|
||||||
|
const numero = document.getElementById('d-numero').value.trim();
|
||||||
|
const resultBox = document.getElementById('directo-result');
|
||||||
|
if (!qid) { showToast('Selecciona una consulta SQL', 'warning'); return; }
|
||||||
|
if (!prefijo || !numero) { showToast('Ingresa Tipo y Número', 'warning'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-directo');
|
||||||
|
showLoading(btn);
|
||||||
|
resultBox.classList.add('hidden');
|
||||||
|
|
||||||
|
const fd = new URLSearchParams({ query_id: qid, prefijo, numero });
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/transaccion/send-direct', { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
result = await resp.json();
|
||||||
|
} catch(e) {
|
||||||
|
result = { success: false, message: String(e) };
|
||||||
|
}
|
||||||
|
hideLoading(btn, '<i class="fas fa-paper-plane mr-1"></i> Enviar ahora');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const r0 = (result.resultados || [])[0] || {};
|
||||||
|
resultBox.innerHTML = `
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-xs space-y-1">
|
||||||
|
<div class="font-semibold text-green-700"><i class="fas fa-check-circle mr-1"></i>Enviado correctamente</div>
|
||||||
|
<div class="text-gray-600">Factura: <strong>${escHtml(r0.factura||'')}</strong> — IDRECEP: ${r0.idrecepcion||''}</div>
|
||||||
|
<div class="text-gray-500">${escHtml(r0.message||'')}</div>
|
||||||
|
<button onclick="verDirectoJSON(this)" data-json='${JSON.stringify(r0.json||{}).replace(/'/g,"'")}'
|
||||||
|
class="mt-1 text-blue-600 underline">Ver JSON enviado</button>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const msgs = (result.resultados||[]).map(r => escHtml(r.message||'')).join('<br>') || escHtml(result.message||'Error');
|
||||||
|
resultBox.innerHTML = `
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs">
|
||||||
|
<div class="font-semibold text-red-700"><i class="fas fa-times-circle mr-1"></i>Error al enviar</div>
|
||||||
|
<div class="text-gray-600 mt-1">${msgs}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
resultBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verDirectoJSON(btn) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(btn.dataset.json);
|
||||||
|
document.getElementById('modal-titulo').innerHTML = '<i class="fas fa-code mr-2 text-indigo-500"></i>JSON — Envío directo';
|
||||||
|
document.getElementById('tab-json').innerHTML = syntaxHighlight(obj);
|
||||||
|
document.getElementById('tab-tns').textContent = '(usa ver resultado arriba)';
|
||||||
|
showTab('tab-json');
|
||||||
|
document.getElementById('modal-detalle').classList.remove('hidden');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
||||||
|
|
||||||
@@ -360,8 +451,8 @@ function syntaxHighlight(obj) {
|
|||||||
return json
|
return json
|
||||||
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
||||||
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
||||||
.replace(/:(\s*)(\d+(?:\.\d+)?)/g,': $1<span class="text-orange-600">$2</span>')
|
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
|
||||||
.replace(/:(\s*)(null|true|false)/g,': $1<span class="text-purple-600">$2</span>');
|
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Facturas Venta{% endblock %}
|
||||||
|
{% block header %}Envío de Facturas Venta{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<div class="lg:col-span-1 space-y-4">
|
||||||
|
<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-cog mr-2 text-emerald-500"></i>Parámetros</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Factura <span class="text-gray-400">(opcional)</span></label>
|
||||||
|
<input id="f-factura" type="text" placeholder="Ej: CMXC309"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Fecha inicio</label>
|
||||||
|
<input id="f-fi" type="date" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Fecha fin</label>
|
||||||
|
<input id="f-ff" type="date" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="cargarRegistros()" id="btn-cargar"
|
||||||
|
class="w-full px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
<i class="fas fa-search mr-1"></i> Cargar registros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen -->
|
||||||
|
<div id="resumen-box" class="hidden bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center mb-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div id="res-total" class="text-xl font-bold text-gray-700">0</div>
|
||||||
|
<div class="text-xs text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-3">
|
||||||
|
<div id="res-ok" class="text-xl font-bold text-green-600">0</div>
|
||||||
|
<div class="text-xs text-gray-500">Enviados</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-yellow-50 rounded-lg p-3">
|
||||||
|
<div id="res-pend" class="text-xl font-bold text-yellow-600">0</div>
|
||||||
|
<div class="text-xs text-gray-500">Pendientes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="res-err-wrap" class="hidden mb-3">
|
||||||
|
<div class="bg-red-50 rounded-lg p-3 text-center">
|
||||||
|
<div id="res-err" class="text-xl font-bold text-red-600">0</div>
|
||||||
|
<div class="text-xs text-gray-500">Con error</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="enviarTodosPendientes()" id="btn-enviar-pend"
|
||||||
|
class="flex-1 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-medium">
|
||||||
|
<i class="fas fa-paper-plane mr-1"></i> Enviar pendientes
|
||||||
|
</button>
|
||||||
|
<button onclick="cargarRegistros()"
|
||||||
|
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-xs" title="Actualizar">
|
||||||
|
<i class="fas fa-sync"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-800"><i class="fas fa-file-invoice-dollar mr-2 text-emerald-500"></i>Facturas</h3>
|
||||||
|
<span id="tabla-count" class="text-xs text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
<div id="tabla-wrap" class="p-6">
|
||||||
|
<div class="text-center py-12 text-gray-400 text-sm">
|
||||||
|
<i class="fas fa-file-invoice text-4xl block mb-3 opacity-30"></i>
|
||||||
|
Carga los registros para ver el estado de envío
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal JSON -->
|
||||||
|
<div id="modal-detalle" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="absolute inset-0 bg-black/60" onclick="closeModal()"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-3xl bg-white rounded-xl shadow-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center shrink-0">
|
||||||
|
<h3 id="modal-titulo" class="font-semibold text-gray-800"><i class="fas fa-code mr-2 text-emerald-500"></i>Detalle</h3>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex border-b border-gray-200 shrink-0 px-4">
|
||||||
|
<button onclick="showTab('tab-json')" id="t-json" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-emerald-500 text-emerald-600 -mb-px">JSON enviado</button>
|
||||||
|
<button onclick="showTab('tab-tns')" id="t-tns" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">Respuesta TNS</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto flex-1 p-5">
|
||||||
|
<pre id="tab-json" class="tab-pane text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap"></pre>
|
||||||
|
<pre id="tab-tns" class="tab-pane hidden text-xs font-mono bg-yellow-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-yellow-900"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allItems = [];
|
||||||
|
let formParams = {};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const today = new Date();
|
||||||
|
const ymd = d => d.toISOString().slice(0, 10);
|
||||||
|
document.getElementById('f-ff').value = ymd(today);
|
||||||
|
document.getElementById('f-fi').value = ymd(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
})();
|
||||||
|
|
||||||
|
function getParams() {
|
||||||
|
return {
|
||||||
|
factura: document.getElementById('f-factura').value,
|
||||||
|
fecha_inicio: document.getElementById('f-fi').value,
|
||||||
|
fecha_fin: document.getElementById('f-ff').value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargarRegistros() {
|
||||||
|
formParams = getParams();
|
||||||
|
if (!formParams.fecha_inicio || !formParams.fecha_fin) { showToast('Ingresa las fechas', 'warning'); return; }
|
||||||
|
|
||||||
|
document.getElementById('tabla-wrap').innerHTML = `
|
||||||
|
<div class="text-center py-12 text-gray-400 text-sm">
|
||||||
|
<i class="fas fa-spinner fa-spin text-3xl block mb-3"></i>Consultando Firebird...
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('resumen-box').classList.add('hidden');
|
||||||
|
|
||||||
|
const resp = await fetch('/ventas/preview', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
body: new URLSearchParams(formParams),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
document.getElementById('tabla-wrap').innerHTML = `
|
||||||
|
<div class="text-center py-10 text-red-500 text-sm">
|
||||||
|
<i class="fas fa-exclamation-circle text-3xl block mb-2"></i>
|
||||||
|
<strong>Error:</strong> ${escHtml(result.message)}
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems = result.items;
|
||||||
|
renderTabla();
|
||||||
|
actualizarResumen();
|
||||||
|
document.getElementById('resumen-box').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabla() {
|
||||||
|
if (!allItems.length) {
|
||||||
|
document.getElementById('tabla-wrap').innerHTML = '<div class="text-center py-10 text-gray-400 text-sm">Sin registros para los parámetros indicados</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('tabla-count').textContent = `${allItems.length} facturas`;
|
||||||
|
|
||||||
|
let html = `<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead><tr class="text-left text-gray-400 border-b border-gray-200">
|
||||||
|
<th class="pb-2 font-medium pr-3">Factura</th>
|
||||||
|
<th class="pb-2 font-medium pr-3">Paciente</th>
|
||||||
|
<th class="pb-2 font-medium pr-3">Contrato</th>
|
||||||
|
<th class="pb-2 font-medium pr-3">Exámenes</th>
|
||||||
|
<th class="pb-2 font-medium pr-3">Estado</th>
|
||||||
|
<th class="pb-2 font-medium"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
for (const item of allItems) {
|
||||||
|
html += filaHtml(item);
|
||||||
|
}
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('tabla-wrap').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filaHtml(item) {
|
||||||
|
const id = item.factura_key;
|
||||||
|
const sid = escAttr(id);
|
||||||
|
const env = item.enviado;
|
||||||
|
const examsStr = Array.isArray(item.examenes) ? item.examenes.join(', ') : (item.examenes || '');
|
||||||
|
const rowId = 'row-' + id.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
const estadoId = 'estado-' + id.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
const btnId = 'btnenv-' + id.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
|
||||||
|
let badgeHtml, rowCls, accionHtml;
|
||||||
|
if (!env) {
|
||||||
|
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">Pendiente</span>`;
|
||||||
|
rowCls = '';
|
||||||
|
accionHtml = `<button onclick="enviarUno('${sid}')" title="Enviar" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded text-xs"><i class="fas fa-paper-plane"></i></button>`;
|
||||||
|
} else if (env.status === 'success') {
|
||||||
|
const at = (env.at || '').slice(0, 16).replace('T', ' ');
|
||||||
|
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700" title="Enviado ${at}">✓ Enviado</span>`;
|
||||||
|
rowCls = 'bg-green-50/50 opacity-80';
|
||||||
|
accionHtml = `<button onclick="enviarUno('${sid}')" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
|
} else {
|
||||||
|
const tip = escAttr(env.mensaje || 'Error');
|
||||||
|
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${tip}">✗ Error</span>`;
|
||||||
|
rowCls = 'bg-red-50/50';
|
||||||
|
accionHtml = `<button onclick="enviarUno('${sid}')" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<tr id="${rowId}" class="border-b border-gray-50 hover:bg-gray-50/80 transition-colors ${rowCls}">
|
||||||
|
<td class="py-2 pr-3 font-mono text-gray-600">${escHtml(id)}</td>
|
||||||
|
<td class="py-2 pr-3 text-gray-600">${escHtml(String(item.paciente || '-'))}</td>
|
||||||
|
<td class="py-2 pr-3"><span class="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-xs font-mono">${escHtml(String(item.contrato || '-'))}</span></td>
|
||||||
|
<td class="py-2 pr-3 text-gray-500 max-w-[180px] truncate" title="${escAttr(examsStr)}">${escHtml(examsStr)}</td>
|
||||||
|
<td id="${estadoId}" class="py-2 pr-3">${badgeHtml}</td>
|
||||||
|
<td class="py-2 flex gap-1 items-center">
|
||||||
|
<button onclick="verDetalle('${sid}')" title="Ver JSON" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded text-xs"><i class="fas fa-code"></i></button>
|
||||||
|
<span id="${btnId}">${accionHtml}</span>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actualizarResumen() {
|
||||||
|
const total = allItems.length;
|
||||||
|
const ok = allItems.filter(i => i.enviado && i.enviado.status === 'success').length;
|
||||||
|
const pend = allItems.filter(i => !i.enviado).length;
|
||||||
|
const err = allItems.filter(i => i.enviado && i.enviado.status === 'error').length;
|
||||||
|
document.getElementById('res-total').textContent = total;
|
||||||
|
document.getElementById('res-ok').textContent = ok;
|
||||||
|
document.getElementById('res-pend').textContent = pend;
|
||||||
|
document.getElementById('res-err').textContent = err;
|
||||||
|
document.getElementById('res-err-wrap').classList.toggle('hidden', err === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeId(id) { return id.replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||||
|
|
||||||
|
function marcarFila(id, ok, mensaje, rawTns) {
|
||||||
|
const idx = allItems.findIndex(i => i.factura_key === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
allItems[idx].enviado = { status: ok ? 'success' : 'error', mensaje, at: new Date().toISOString() };
|
||||||
|
if (rawTns !== undefined) allItems[idx]._rawTns = rawTns;
|
||||||
|
}
|
||||||
|
const sid = escAttr(id);
|
||||||
|
const safe = _safeId(id);
|
||||||
|
const row = document.getElementById(`row-${safe}`);
|
||||||
|
const estadoCell = document.getElementById(`estado-${safe}`);
|
||||||
|
const btnWrap = document.getElementById(`btnenv-${safe}`);
|
||||||
|
if (!row || !estadoCell) return;
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-green-50/50 opacity-80';
|
||||||
|
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>`;
|
||||||
|
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno('${sid}')" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
|
} else {
|
||||||
|
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50';
|
||||||
|
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${escAttr(mensaje||'')}">✗ Error</span>`;
|
||||||
|
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno('${sid}')" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
|
||||||
|
}
|
||||||
|
actualizarResumen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enviarUno(id) {
|
||||||
|
const safe = _safeId(id);
|
||||||
|
const btnWrap = document.getElementById(`btnenv-${safe}`);
|
||||||
|
if (btnWrap) btnWrap.innerHTML = '<span class="px-2 py-1 text-gray-400 text-xs"><i class="fas fa-spinner fa-spin"></i></span>';
|
||||||
|
|
||||||
|
const fd = new URLSearchParams({ ...formParams, factura_key: id });
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/ventas/send-one', { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
result = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
result = { success: false, message: String(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
marcarFila(id, result.success, result.message, result.raw_tns);
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`Factura ${escHtml(id)}: enviada`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(`Factura ${escHtml(id)}: ${escHtml(result.message || 'Error')}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enviarTodosPendientes() {
|
||||||
|
const pendientes = allItems.filter(i => !i.enviado);
|
||||||
|
if (!pendientes.length) { showToast('No hay registros pendientes', 'info'); return; }
|
||||||
|
if (!confirm(`¿Enviar ${pendientes.length} facturas pendientes?`)) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-enviar-pend');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Enviando...';
|
||||||
|
|
||||||
|
let ok = 0, err = 0;
|
||||||
|
for (const item of pendientes) {
|
||||||
|
await enviarUno(item.factura_key);
|
||||||
|
if (allItems.find(i => i.factura_key === item.factura_key)?.enviado?.status === 'success') ok++; else err++;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane mr-1"></i> Enviar pendientes';
|
||||||
|
showToast(`Completado: ${ok} enviados, ${err} errores`, err === 0 ? 'success' : 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verDetalle(id) {
|
||||||
|
const item = allItems.find(i => i.factura_key === id);
|
||||||
|
if (!item) return;
|
||||||
|
document.getElementById('modal-titulo').innerHTML = `<i class="fas fa-code mr-2 text-emerald-500"></i>${escHtml(id)} — ${escHtml(item.paciente || '')} — Factura ${escHtml(item.factura || '-')}`;
|
||||||
|
document.getElementById('tab-json').innerHTML = syntaxHighlight(item.json || {});
|
||||||
|
const tnsPre = document.getElementById('tab-tns');
|
||||||
|
if (item.enviado) {
|
||||||
|
let tnsText = item._rawTns || item.enviado.mensaje || '(sin respuesta)';
|
||||||
|
try { tnsText = JSON.stringify(JSON.parse(tnsText), null, 2); } catch(e) {}
|
||||||
|
tnsPre.textContent = tnsText;
|
||||||
|
} else {
|
||||||
|
tnsPre.textContent = '(aún no enviado)';
|
||||||
|
}
|
||||||
|
showTab('tab-json');
|
||||||
|
document.getElementById('modal-detalle').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.remove('border-emerald-500', 'text-emerald-600');
|
||||||
|
b.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
document.getElementById(tabId).classList.remove('hidden');
|
||||||
|
const btnId = tabId === 'tab-json' ? 't-json' : 't-tns';
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
btn.classList.add('border-emerald-500', 'text-emerald-600');
|
||||||
|
btn.classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-detalle').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
||||||
|
|
||||||
|
function syntaxHighlight(obj) {
|
||||||
|
let json = JSON.stringify(obj, null, 2);
|
||||||
|
json = json.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return json
|
||||||
|
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
||||||
|
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
||||||
|
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
|
||||||
|
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from app.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def log_activity(user_id: int, username: str, accion: str, detalle: str = "", ip: str = ""):
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO activity_log (user_id, username, accion, detalle, ip) VALUES (?,?,?,?,?)",
|
||||||
|
(user_id, username, accion, detalle, ip),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_ip(request) -> str:
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Explora tablas de toma de muestras pendientes en Firebird.
|
||||||
|
Uso: python debug_muestras.py
|
||||||
|
"""
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
print("Error Firebird:", msg)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# 1. Buscar tablas que suenen a muestras/toma
|
||||||
|
print("=== Tablas con nombres relacionados a muestras ===")
|
||||||
|
_, _, tablas = fb.execute_query("""
|
||||||
|
SELECT TRIM(rdb$relation_name) AS tabla
|
||||||
|
FROM rdb$relations
|
||||||
|
WHERE rdb$system_flag = 0
|
||||||
|
AND (UPPER(rdb$relation_name) LIKE '%MUESTRA%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%TOMA%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%PENDIENTE%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%EXAMEN%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%RESULTADO%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%RELACION%'
|
||||||
|
OR UPPER(rdb$relation_name) LIKE '%ORDEN%')
|
||||||
|
ORDER BY 1
|
||||||
|
""", None)
|
||||||
|
for t in (tablas or []):
|
||||||
|
print(" ", t["TABLA"])
|
||||||
|
|
||||||
|
# 2. Ver columnas de RELACION (exámenes por recepción)
|
||||||
|
print("\n=== Columnas de RELACION ===")
|
||||||
|
_, _, cols = fb.execute_query("""
|
||||||
|
SELECT TRIM(rdb$field_name) AS campo
|
||||||
|
FROM rdb$relation_fields
|
||||||
|
WHERE rdb$relation_name = 'RELACION'
|
||||||
|
ORDER BY rdb$field_position
|
||||||
|
""", None)
|
||||||
|
for c in (cols or []):
|
||||||
|
print(" ", c["CAMPO"])
|
||||||
|
|
||||||
|
# 3. Ver si hay campo de estado/pendiente en RELACION
|
||||||
|
print("\n=== Muestra de RELACION (primeras 3 filas) ===")
|
||||||
|
_, _, sample = fb.execute_query("SELECT FIRST 3 * FROM RELACION", None)
|
||||||
|
for r in (sample or []):
|
||||||
|
print(" ", dict(r))
|
||||||
|
|
||||||
|
# 4. Si existe TOMA_MUESTRA o similar, ver su estructura
|
||||||
|
for tabla in ["TOMA_MUESTRA", "MUESTRAS", "TOMA", "MUESTRA"]:
|
||||||
|
ok_t, _, _ = fb.execute_query(f"SELECT FIRST 1 * FROM {tabla}", None)
|
||||||
|
if ok_t:
|
||||||
|
print(f"\n=== Tabla {tabla} existe — columnas ===")
|
||||||
|
_, _, tcols = fb.execute_query(f"""
|
||||||
|
SELECT TRIM(rdb$field_name) AS campo
|
||||||
|
FROM rdb$relation_fields
|
||||||
|
WHERE rdb$relation_name = '{tabla}'
|
||||||
|
ORDER BY rdb$field_position
|
||||||
|
""", None)
|
||||||
|
for c in (tcols or []):
|
||||||
|
print(" ", c["CAMPO"])
|
||||||
|
_, _, trows = fb.execute_query(f"SELECT FIRST 3 * FROM {tabla}", None)
|
||||||
|
for r in (trows or []):
|
||||||
|
print(" ", dict(r))
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
print("\nFin.")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Verifica si el paciente 88272630 aparece en las queries de automation para una fecha dada.
|
||||||
|
Uso: python debug_paciente.py 2026-07-22
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
|
||||||
|
fecha = sys.argv[1] if len(sys.argv) > 1 else "2026-07-22"
|
||||||
|
fecha_ini = f"{fecha} 00:00:00"
|
||||||
|
fecha_fin = f"{fecha} 23:59:59"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
print(f"Error Firebird: {msg}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print(f"=== Buscando paciente 88272630 para fecha {fecha} ===\n")
|
||||||
|
|
||||||
|
# 1. Query pacientes regulares
|
||||||
|
ok1, err1, rows = fb.execute_query("""
|
||||||
|
SELECT DISTINCT p.CODIGO, p.DOCIDENT, p.NOMBRES, p.APELLIDOS
|
||||||
|
FROM PACIENTE p
|
||||||
|
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
|
||||||
|
AND r.NUM_FACTURA > 0
|
||||||
|
AND r.PS_NUM IS NULL
|
||||||
|
AND TRIM(p.DOCIDENT) = '88272630'
|
||||||
|
""", {"fi": fecha_ini, "ff": fecha_fin})
|
||||||
|
print(f"_SQL_PACIENTES (regulares, PS_NUM IS NULL): {len(rows) if ok1 else 'ERROR: '+err1} resultados")
|
||||||
|
for r in (rows or []):
|
||||||
|
print(f" CODIGO={r['CODIGO']} DOC={r['DOCIDENT']} NOMBRE={r['NOMBRES']} {r['APELLIDOS']}")
|
||||||
|
|
||||||
|
# 2. Query pacientes EPS (via PRESSERV_DIAN)
|
||||||
|
ok2, err2, rows2 = fb.execute_query("""
|
||||||
|
SELECT DISTINCT p.CODIGO, p.DOCIDENT, p.NOMBRES, p.APELLIDOS
|
||||||
|
FROM PACIENTE p
|
||||||
|
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
|
||||||
|
JOIN PRESSERV_DIAN ps ON ps.ID_RECEP = r.IDRECEPCION
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
|
||||||
|
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
|
||||||
|
AND TRIM(p.DOCIDENT) = '88272630'
|
||||||
|
""", {"fi": fecha_ini, "ff": fecha_fin})
|
||||||
|
print(f"\n_SQL_PACIENTES_EPS (via PRESSERV_DIAN): {len(rows2) if ok2 else 'ERROR: '+err2} resultados")
|
||||||
|
for r in (rows2 or []):
|
||||||
|
print(f" CODIGO={r['CODIGO']} DOC={r['DOCIDENT']} NOMBRE={r['NOMBRES']} {r['APELLIDOS']}")
|
||||||
|
|
||||||
|
# 3. Query preserv
|
||||||
|
ok3, err3, rows3 = fb.execute_query("""
|
||||||
|
SELECT ps.ID_PS, ps.PS_NUMERO, ps.PS_PREFIJO, r.COD_PACIENTE, p.DOCIDENT
|
||||||
|
FROM PRESSERV_DIAN ps
|
||||||
|
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
|
||||||
|
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
|
||||||
|
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
|
||||||
|
AND TRIM(p.DOCIDENT) = '88272630'
|
||||||
|
""", {"fi": fecha_ini, "ff": fecha_fin})
|
||||||
|
print(f"\n_SQL_PRESERV (pre-servicios): {len(rows3) if ok3 else 'ERROR: '+err3} resultados")
|
||||||
|
for r in (rows3 or []):
|
||||||
|
print(f" ID_PS={r['ID_PS']} PREFIJO={r['PS_PREFIJO']} NUM={r['PS_NUMERO']} COD_PAC={r['COD_PACIENTE']}")
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
|
print("\nFin.")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from app.database import get_connection
|
||||||
|
from app.services.firebird_service import get_firebird_from_config
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
fb, ok, msg = get_firebird_from_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
print("Error Firebird:", msg)
|
||||||
|
else:
|
||||||
|
_, e, rows = fb.execute_query(
|
||||||
|
"SELECT DOCIDENT, TIPOIDENT FROM PACIENTE WHERE TRIM(DOCIDENT) = '13225821'", None
|
||||||
|
)
|
||||||
|
print("Resultado:", rows)
|
||||||
|
|
||||||
|
# También ver qué tipos de documento distintos existen en la BD
|
||||||
|
_, e2, tipos = fb.execute_query(
|
||||||
|
"SELECT DISTINCT TIPOIDENT, COUNT(*) AS CNT FROM PACIENTE GROUP BY TIPOIDENT ORDER BY CNT DESC", None
|
||||||
|
)
|
||||||
|
print("\nTodos los TIPOIDENT en PACIENTE:")
|
||||||
|
for t in (tipos or []):
|
||||||
|
print(" ", dict(t))
|
||||||
|
|
||||||
|
fb.disconnect()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
Q5 = """SELECT
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.DIAG_PPAL,
|
||||||
|
r.TIPOUSU,
|
||||||
|
r.TIPOUSUSISPRO,
|
||||||
|
r.AUTORIZACION,
|
||||||
|
r.CLASEPROC,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
rel.FECHA_REPORTADO,
|
||||||
|
m.COD_ESPECIALIDAD,
|
||||||
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
|
e.CODCONTRATO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
WHERE r.NUM_FACTURA = :num_factura"""
|
||||||
|
|
||||||
|
Q6 = """SELECT
|
||||||
|
r.IDRECEPCION,
|
||||||
|
r.PREFIJO,
|
||||||
|
r.NUM_FACTURA,
|
||||||
|
r.FECHA_RECEPCION,
|
||||||
|
r.COD_PACIENTE,
|
||||||
|
r.NIT_EMPRESA,
|
||||||
|
r.DIAG_PPAL,
|
||||||
|
r.TIPOUSU,
|
||||||
|
r.TIPOUSUSISPRO,
|
||||||
|
r.AUTORIZACION,
|
||||||
|
r.CLASEPROC,
|
||||||
|
r.HORAINICIORECEPCION,
|
||||||
|
r.VALORTOTAL,
|
||||||
|
rel.COD_EXAMEN,
|
||||||
|
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||||
|
rel.PRECIO,
|
||||||
|
rel.FECHA_REPORTADO,
|
||||||
|
m.COD_ESPECIALIDAD,
|
||||||
|
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
|
||||||
|
e.CODCONTRATO,
|
||||||
|
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
|
||||||
|
FROM RECEPCION r
|
||||||
|
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||||
|
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||||
|
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
|
||||||
|
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||||
|
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||||
|
ORDER BY r.IDRECEPCION"""
|
||||||
|
|
||||||
|
conn = sqlite3.connect("rips_manager.db")
|
||||||
|
conn.execute("UPDATE queries SET query_text=? WHERE id=5", (Q5,))
|
||||||
|
conn.execute("UPDATE queries SET query_text=? WHERE id=6", (Q6,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Queries actualizados OK")
|
||||||
@@ -8,11 +8,15 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.auth import decode_token
|
from app.auth import decode_token
|
||||||
|
from app.services.scheduler import sync_recientes
|
||||||
|
from app.services.whatsapp_sync import guardar_sync_log
|
||||||
|
|
||||||
app = FastAPI(title="RIPS Manager", version="1.0.0")
|
app = FastAPI(title="RIPS Manager", version="1.0.0")
|
||||||
|
_scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
templates = Jinja2Templates(
|
templates = Jinja2Templates(
|
||||||
directory=os.path.join(os.path.dirname(__file__), "app", "templates")
|
directory=os.path.join(os.path.dirname(__file__), "app", "templates")
|
||||||
@@ -56,8 +60,74 @@ async def startup():
|
|||||||
init_db()
|
init_db()
|
||||||
from app.routes.config import ensure_defaults as config_defaults
|
from app.routes.config import ensure_defaults as config_defaults
|
||||||
from app.routes.queries import ensure_defaults as query_defaults
|
from app.routes.queries import ensure_defaults as query_defaults
|
||||||
|
from app.routes.contratos import ensure_defaults as contratos_defaults
|
||||||
config_defaults()
|
config_defaults()
|
||||||
query_defaults()
|
query_defaults()
|
||||||
|
contratos_defaults()
|
||||||
|
async def _job_sync():
|
||||||
|
try:
|
||||||
|
resultado = await sync_recientes(ventana_min=60)
|
||||||
|
if not resultado.get("ok") and resultado.get("error"):
|
||||||
|
guardar_sync_log(
|
||||||
|
{"total": 0, "created": 0, "skipped": 0, "updated": 0, "errores": 1, "detalle": [
|
||||||
|
{"ok": False, "action": "error", "message": resultado["error"], "doc": "", "nombre": "scheduler-error"}
|
||||||
|
]},
|
||||||
|
None, origen="scheduler", modo="upsert"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
import traceback
|
||||||
|
guardar_sync_log(
|
||||||
|
{"total": 0, "created": 0, "skipped": 0, "updated": 0, "errores": 1, "detalle": [
|
||||||
|
{"ok": False, "action": "error", "message": str(exc), "doc": "", "nombre": "scheduler-exception"}
|
||||||
|
]},
|
||||||
|
None, origen="scheduler", modo="upsert"
|
||||||
|
)
|
||||||
|
|
||||||
|
_scheduler.add_job(_job_sync, "interval", seconds=20, id="wa_sync_recientes",
|
||||||
|
max_instances=1, coalesce=True)
|
||||||
|
_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown():
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/scheduler/status")
|
||||||
|
async def scheduler_status():
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from app.database import get_connection
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
COL = timezone(timedelta(hours=-5))
|
||||||
|
|
||||||
|
job = _scheduler.get_job("wa_sync_recientes")
|
||||||
|
next_run_str = None
|
||||||
|
if job and job.next_run_time:
|
||||||
|
next_run_str = job.next_run_time.astimezone(COL).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT created_at, total, errores FROM sync_wa_log "
|
||||||
|
"WHERE origen='scheduler' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
ultimo = None
|
||||||
|
if row:
|
||||||
|
from datetime import datetime
|
||||||
|
ts_utc = datetime.fromisoformat(row["created_at"].replace(" ", "T") + "+00:00")
|
||||||
|
ultimo = {
|
||||||
|
"hora": ts_utc.astimezone(COL).strftime("%H:%M:%S"),
|
||||||
|
"total": row["total"],
|
||||||
|
"errores": row["errores"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"running": _scheduler.running,
|
||||||
|
"job_exists": job is not None,
|
||||||
|
"next_run": next_run_str,
|
||||||
|
"ultimo_log": ultimo,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -65,18 +135,23 @@ 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, contratos, ventas, envios, docs
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(queries.router)
|
app.include_router(queries.router)
|
||||||
|
app.include_router(envios.router)
|
||||||
app.include_router(terceros.router)
|
app.include_router(terceros.router)
|
||||||
app.include_router(transaccion.router)
|
app.include_router(transaccion.router)
|
||||||
app.include_router(logs.router)
|
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)
|
||||||
|
app.include_router(contratos.router)
|
||||||
|
app.include_router(ventas.router)
|
||||||
|
app.include_router(docs.router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ bcrypt==4.2.1
|
|||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
fdb>=2.0.0
|
fdb>=2.0.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user