This commit is contained in:
Lizandro Guarnizo
2026-06-18 12:25:44 -05:00
parent 65ff7995e8
commit 408ea50d21
12 changed files with 1042 additions and 1100 deletions
+9 -16
View File
@@ -1,10 +1,13 @@
import os
import bcrypt
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET_KEY = "rips-manager-secret-key-change-in-production"
# ponytail: fallback inseguro — set RIPS_SECRET_KEY en producción
SECRET_KEY = os.environ.get("RIPS_SECRET_KEY", "rips-manager-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 12
@@ -23,12 +26,11 @@ def create_token(user_id: int, username: str) -> str:
payload = {
"user_id": user_id,
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
from typing import Optional
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@@ -37,23 +39,14 @@ def decode_token(token: str) -> Optional[dict]:
return None
from fastapi import Request
def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
if hasattr(request.state, "user") and request.state.user:
return request.state.user
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return payload
-13
View File
@@ -39,19 +39,6 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
step TEXT,
status TEXT NOT NULL CHECK(status IN ('success','error')),
payload TEXT,
response TEXT,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS envios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
-18
View File
@@ -13,26 +13,8 @@ class UserLogin(BaseModel):
password: str
class ConfigUpdate(BaseModel):
key: str
value: str
class QueryCreate(BaseModel):
name: str
query_type: str
query_text: str
description: Optional[str] = None
class QueryUpdate(BaseModel):
name: Optional[str] = None
query_text: Optional[str] = None
description: Optional[str] = None
class SendRequest(BaseModel):
tipo: str
fecha_inicio: str
fecha_fin: str
factura: Optional[str] = None
+77 -87
View File
@@ -1,9 +1,12 @@
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 FirebirdService
from app.services.json_generator import generar_terceros, generar_transaccion
from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_terceros, generar_transaccion, agrupar_por_factura
router = APIRouter(prefix="/automation", tags=["automation"])
@@ -31,11 +34,6 @@ async def run_automation(
fecha_fin: str = Form(...),
factura: str = Form(""),
):
import json as json_lib
import httpx
from datetime import datetime
from collections import defaultdict
conn = get_connection()
q_terceros = conn.execute("SELECT * FROM queries WHERE id = ?", (query_terceros_id,)).fetchone()
q_trans = conn.execute("SELECT * FROM queries WHERE id = ?", (query_transaccion_id,)).fetchone()
@@ -45,15 +43,8 @@ async def run_automation(
if not q_terceros or not q_trans:
return JSONResponse({"success": False, "message": "Consultas no encontradas"})
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
fb, fb_ok, fb_msg = get_firebird_from_config(configs)
if not fb_ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
api_url = configs.get("api_url", "")
@@ -74,7 +65,10 @@ async def run_automation(
if ":doc_num" in q_terceros["query_text"]:
params["doc_num"] = ""
success, error, rows = fb.execute_query(q_terceros["query_text"], params if ":fecha_ini" in q_terceros["query_text"] else None)
success, error, rows = fb.execute_query(
q_terceros["query_text"],
params if ":fecha_ini" in q_terceros["query_text"] else None
)
if not success:
resultado["paso1_terceros"] = {"status": "error", "message": error}
@@ -85,40 +79,44 @@ async def run_automation(
terceros_errores = 0
pacientes_enviados = []
for row in rows:
tercero_json = generar_terceros(row)
doc_id = tercero_json["numDocumentoIdentificacion"]
if doc_id in pacientes_enviados:
continue
pacientes_enviados.append(doc_id)
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
for row in rows:
tercero_json = generar_terceros(row)
doc_id = tercero_json["numDocumentoIdentificacion"]
if doc_id in pacientes_enviados:
continue
pacientes_enviados.append(doc_id)
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
resp_ok = False
resp_text = ""
try:
if api_method == "POST":
resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers)
else:
resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers)
if resp.is_success:
terceros_enviados += 1
else:
resp_ok = resp.is_success
resp_text = resp.text[:1000]
if resp_ok:
terceros_enviados += 1
else:
terceros_errores += 1
except Exception as e:
terceros_errores += 1
except Exception as e:
terceros_errores += 1
resp_text = str(e)
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "terceros", factura or "AUTO",
"success" if resp.is_success else "error",
json_lib.dumps(tercero_json, indent=2, ensure_ascii=False),
resp.text[:1000] if resp.is_success else str(e),
datetime.now().isoformat(),
))
conn.commit()
conn.close()
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "terceros", factura or "AUTO",
"success" if resp_ok else "error",
json_lib.dumps(tercero_json, indent=2, ensure_ascii=False),
resp_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
resultado["paso1_terceros"] = {
"status": "success" if terceros_errores == 0 else "partial",
@@ -140,59 +138,51 @@ async def run_automation(
elif not rows:
resultado["paso2_transaccion"] = {"status": "error", "message": "No hay servicios para enviar"}
else:
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
grupos = agrupar_por_factura(rows, factura)
trans_enviados = 0
trans_errores = 0
for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans_json = generar_transaccion(
fact,
configs.get("num_documento_obligado", ""),
paciente_data,
grupo["procedimientos"],
)
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans_json = generar_transaccion(
fact, configs.get("num_documento_obligado", ""),
paciente_data, grupo["procedimientos"],
)
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
status_ok = False
resp_text = ""
try:
if api_method == "POST":
resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers)
else:
resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers)
status_ok = resp.is_success
resp_text = resp.text[:1000]
except Exception as e:
resp_text = str(e)
status_ok = resp.is_success
resp_text = resp.text[:1000]
except Exception as e:
status_ok = False
resp_text = str(e)
if status_ok:
trans_enviados += 1
else:
trans_errores += 1
if status_ok:
trans_enviados += 1
else:
trans_errores += 1
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "transaccion", fact,
fecha_inicio, fecha_fin,
1, len(grupo["procedimientos"]),
"success" if status_ok else "error",
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
resp_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "transaccion", fact,
fecha_inicio, fecha_fin,
1, len(grupo["procedimientos"]),
"success" if status_ok else "error",
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
resp_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
resultado["paso2_transaccion"] = {
"status": "success" if trans_errores == 0 else "partial",
-1
View File
@@ -32,7 +32,6 @@ def ensure_defaults():
@router.get("")
async def config_page(request: Request, user: dict = Depends(get_current_user)):
ensure_defaults()
conn = get_connection()
configs = conn.execute("SELECT * FROM config ORDER BY key").fetchall()
conn.close()
+324 -325
View File
@@ -1,325 +1,324 @@
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.models import QueryCreate
router = APIRouter(prefix="/queries", tags=["queries"])
QUERY_DEFAULTS = [
{
"name": "Terceros - Datos del paciente",
"query_type": "terceros",
"query_text": """SELECT␍␍␍␍
p.TIPO_DOCUMENTO as tipo_documento,␍␍␍␍
p.NUMERO_DOCUMENTO as numero_documento,␍␍␍␍
p.PRIMER_NOMBRE as primer_nombre,␍␍␍␍
p.SEGUNDO_NOMBRE as segundo_nombre,␍␍␍␍
p.PRIMER_APELLIDO as primer_apellido,␍␍␍␍
p.SEGUNDO_APELLIDO as segundo_apellido,␍␍␍␍
p.FECHA_NACIMIENTO as fecha_nacimiento,␍␍␍␍
p.SEXO as cod_sexo,␍␍␍␍
p.COD_ENTIDAD as cod_entidad,␍␍␍␍
p.TIPO_USUARIO as tipo_usuario,␍␍␍␍
p.COD_MUNICIPIO as cod_municipio,␍␍␍␍
p.ZONA as cod_zona,␍␍␍␍
p.DIRECCION as direccion␍␍␍␍
FROM USUAHOS p␍␍␍␍
WHERE p.NUMERO_DOCUMENTO = :doc_num""",
"description": "Consulta datos maestros del paciente por documento"
},
{
"name": "Procedimientos por factura",
"query_type": "transaccion",
"query_text": """SELECT␍␍␍␍
s.CODIGO_CUP as cod_procedimiento,␍␍␍␍
s.FECHA_ATENCION as fecha_atencion,␍␍␍␍
s.COD_DIAGNOSTICO as cod_diagnostico,␍␍␍␍
s.FINALIDAD as finalidad,␍␍␍␍
s.VIA_INGRESO as via_ingreso,␍␍␍␍
s.MODALIDAD as modalidad,␍␍␍␍
s.GRUPO_SERVICIO as grupo_servicio,␍␍␍␍
s.COD_SERVICIO as cod_servicio,␍␍␍␍
s.COD_PRESTADOR as cod_prestador,␍␍␍␍
s.TIPO_DOC_PROFESIONAL as tipo_doc_profesional,␍␍␍␍
s.NUM_DOC_PROFESIONAL as num_doc_profesional,␍␍␍␍
s.VR_SERVICIO as vr_servicio,␍␍␍␍
s.VALOR_PAGO_MODERADOR as valor_pago_moderador,␍␍␍␍
s.CONCEPTO_RECAUDO as concepto_recaudo,␍␍␍␍
s.NUM_AUTORIZACION as num_autorizacion␍␍␍␍
FROM SERVICIOS s␍␍␍␍
WHERE s.NUM_FACTURA = :factura␍␍␍␍
AND s.FECHA_ATENCION BETWEEN :fecha_ini AND :fecha_fin""",
"description": "Consulta procedimientos por factura y rango de fechas"
},
{
"name": "Procedimientos por fecha",
"query_type": "transaccion",
"query_text": """SELECT␍␍␍␍
s.FACTURA as num_factura,␍␍␍␍
s.CODIGO_CUP as cod_procedimiento,␍␍␍␍
s.FECHA_ATENCION as fecha_atencion,␍␍␍␍
s.COD_DIAGNOSTICO as cod_diagnostico,␍␍␍␍
s.FINALIDAD as finalidad,␍␍␍␍
s.VIA_INGRESO as via_ingreso,␍␍␍␍
s.MODALIDAD as modalidad,␍␍␍␍
s.GRUPO_SERVICIO as grupo_servicio,␍␍␍␍
s.COD_SERVICIO as cod_servicio,␍␍␍␍
s.COD_PRESTADOR as cod_prestador,␍␍␍␍
s.TIPO_DOC_PROFESIONAL as tipo_doc_profesional,␍␍␍␍
s.NUM_DOC_PROFESIONAL as num_doc_profesional,␍␍␍␍
s.VR_SERVICIO as vr_servicio,␍␍␍␍
p.TIPO_DOCUMENTO as tipo_doc_paciente,␍␍␍␍
p.NUMERO_DOCUMENTO as num_doc_paciente␍␍␍␍
FROM SERVICIOS s␍␍␍␍
JOIN USUAHOS p ON s.COD_PACIENTE = p.COD_PACIENTE␍␍␍␍
WHERE s.FECHA_ATENCION BETWEEN :fecha_ini AND :fecha_fin""",
"description": "Consulta todos los procedimientos en rango de fechas"
},
]
def ensure_defaults():
conn = get_connection()
for q in QUERY_DEFAULTS:
exists = conn.execute(
"SELECT id FROM queries WHERE name = ?", (q["name"],)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO queries (name, query_type, query_text, description) VALUES (?, ?, ?, ?)",
(q["name"], q["query_type"], q["query_text"], q["description"]),
)
conn.commit()
conn.close()
@router.get("")
async def queries_page(request: Request, user: dict = Depends(get_current_user)):
ensure_defaults()
conn = get_connection()
queries = conn.execute("SELECT * FROM queries ORDER BY query_type, name").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("queries.html", {
"request": request, "user": user, "queries": queries
})
@router.post("/create")
async def query_create(
request: Request,
user: dict = Depends(get_current_user),
name: str = Form(...),
query_type: str = Form(...),
query_text: str = Form(...),
description: str = Form(""),
):
conn = get_connection()
conn.execute(
"INSERT INTO queries (name, query_type, query_text, description) VALUES (?, ?, ?, ?)",
(name, query_type, query_text, description),
)
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/update/{query_id}")
async def query_update(
query_id: int,
request: Request,
user: dict = Depends(get_current_user),
name: str = Form(...),
query_text: str = Form(...),
description: str = Form(""),
):
conn = get_connection()
conn.execute(
"UPDATE queries SET name = ?, query_text = ?, description = ? WHERE id = ?",
(name, query_text, description, query_id),
)
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/run-sql")
async def run_sql(
request: Request,
sql: str = Form(...),
user: dict = Depends(get_current_user),
):
from app.services.firebird_service import FirebirdService
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"})
success, fb_err, rows = fb.execute_query(sql)
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err})
limit = rows[:200] if rows else []
return JSONResponse({
"rows": limit,
"total": len(rows),
"columns": list(limit[0].keys()) if limit else []
})
@router.post("/delete/{query_id}")
async def query_delete(query_id: int, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute("DELETE FROM queries WHERE id = ?", (query_id,))
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/esquema")
async def obtener_esquema(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, tablas = fb.execute_query("""␍␍␍
SELECT RDB$RELATION_NAME as nombre␍␍␍
FROM RDB$RELATIONS␍␍␍
WHERE RDB$SYSTEM_FLAG = 0␍␍␍
AND RDB$RELATION_NAME NOT LIKE 'RDB$%'␍␍␍
ORDER BY 1␍␍␍
""", {})
if not success:
fb.disconnect()
return JSONResponse({"error": fb_err}, status_code=400)
resultado = []
for t in tablas:
nombre = t["NOMBRE"].strip()
ok, err, cols = fb.execute_query(f"""␍␍
SELECT␍␍
rf.RDB$FIELD_NAME as COLUMN_NAME,␍␍
f.RDB$FIELD_TYPE as FIELD_TYPE,␍␍
f.RDB$FIELD_LENGTH as FIELD_LENGTH␍␍
FROM RDB$RELATION_FIELDS rf␍␍
JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME␍␍
WHERE rf.RDB$RELATION_NAME = '{nombre}'␍␍
ORDER BY rf.RDB$FIELD_POSITION␍␍
""")
columnas = []
if ok:
for c in cols:
tiponum = c["FIELD_TYPE"]
tipos = {7: "SMALLINT", 8: "INTEGER", 10: "FLOAT", 12: "DATE", 13: "TIME",
14: "CHAR", 16: "BIGINT", 27: "DOUBLE", 35: "TIMESTAMP", 37: "VARCHAR",
40: "BLOB", 45: "BLOB_ID", 261: "BLOB"}
columnas.append({
"nombre": c["COLUMN_NAME"].strip(),
"tipo": tipos.get(tiponum, f"UNKNOWN({tiponum})"),
"longitud": c["FIELD_LENGTH"]
})
resultado.append({"tabla": nombre, "columnas": columnas})
fb.disconnect()
return JSONResponse({"tablas": resultado})
@router.post("/tablas")
async def listar_tablas(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, rows = fb.execute_query("""␍␍␍␍
SELECT RDB$RELATION_NAME as nombre␍␍␍␍
FROM RDB$RELATIONS␍␍␍␍
WHERE RDB$SYSTEM_FLAG = 0␍␍␍␍
AND RDB$RELATION_NAME NOT LIKE 'RDB$%'␍␍␍␍
ORDER BY 1␍␍␍␍
""", {})
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err}, status_code=400)
return JSONResponse({
"tablas": [r["NOMBRE"].strip() for r in rows]
})
@router.post("/test")
async def query_test(
request: Request,
query_id: int = Form(...),
user: dict = Depends(get_current_user),
):
from app.services.firebird_service import FirebirdService
conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
if not q:
return JSONResponse({"error": "Consulta no encontrada"}, status_code=404)
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, rows = fb.execute_query(q["query_text"], {})
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err}, status_code=400)
limit = rows[:20] if rows else []
return JSONResponse({
"rows": limit,
"total": len(rows),
"columns": list(limit[0].keys()) if limit else []
})
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.models import QueryCreate
router = APIRouter(prefix="/queries", tags=["queries"])
QUERY_DEFAULTS = [
{
"name": "Terceros - Datos del paciente",
"query_type": "terceros",
"query_text": """SELECT
p.TIPO_DOCUMENTO as tipo_documento,
p.NUMERO_DOCUMENTO as numero_documento,
p.PRIMER_NOMBRE as primer_nombre,
p.SEGUNDO_NOMBRE as segundo_nombre,
p.PRIMER_APELLIDO as primer_apellido,
p.SEGUNDO_APELLIDO as segundo_apellido,
p.FECHA_NACIMIENTO as fecha_nacimiento,
p.SEXO as cod_sexo,
p.COD_ENTIDAD as cod_entidad,
p.TIPO_USUARIO as tipo_usuario,
p.COD_MUNICIPIO as cod_municipio,
p.ZONA as cod_zona,
p.DIRECCION as direccion
FROM USUAHOS p
WHERE p.NUMERO_DOCUMENTO = :doc_num""",
"description": "Consulta datos maestros del paciente por documento"
},
{
"name": "Procedimientos por factura",
"query_type": "transaccion",
"query_text": """SELECT
s.CODIGO_CUP as cod_procedimiento,
s.FECHA_ATENCION as fecha_atencion,
s.COD_DIAGNOSTICO as cod_diagnostico,
s.FINALIDAD as finalidad,
s.VIA_INGRESO as via_ingreso,
s.MODALIDAD as modalidad,
s.GRUPO_SERVICIO as grupo_servicio,
s.COD_SERVICIO as cod_servicio,
s.COD_PRESTADOR as cod_prestador,
s.TIPO_DOC_PROFESIONAL as tipo_doc_profesional,
s.NUM_DOC_PROFESIONAL as num_doc_profesional,
s.VR_SERVICIO as vr_servicio,
s.VALOR_PAGO_MODERADOR as valor_pago_moderador,
s.CONCEPTO_RECAUDO as concepto_recaudo,
s.NUM_AUTORIZACION as num_autorizacion
FROM SERVICIOS s
WHERE s.NUM_FACTURA = :factura
AND s.FECHA_ATENCION BETWEEN :fecha_ini AND :fecha_fin""",
"description": "Consulta procedimientos por factura y rango de fechas"
},
{
"name": "Procedimientos por fecha",
"query_type": "transaccion",
"query_text": """SELECT
s.FACTURA as num_factura,
s.CODIGO_CUP as cod_procedimiento,
s.FECHA_ATENCION as fecha_atencion,
s.COD_DIAGNOSTICO as cod_diagnostico,
s.FINALIDAD as finalidad,
s.VIA_INGRESO as via_ingreso,
s.MODALIDAD as modalidad,
s.GRUPO_SERVICIO as grupo_servicio,
s.COD_SERVICIO as cod_servicio,
s.COD_PRESTADOR as cod_prestador,
s.TIPO_DOC_PROFESIONAL as tipo_doc_profesional,
s.NUM_DOC_PROFESIONAL as num_doc_profesional,
s.VR_SERVICIO as vr_servicio,
p.TIPO_DOCUMENTO as tipo_doc_paciente,
p.NUMERO_DOCUMENTO as num_doc_paciente
FROM SERVICIOS s
JOIN USUAHOS p ON s.COD_PACIENTE = p.COD_PACIENTE
WHERE s.FECHA_ATENCION BETWEEN :fecha_ini AND :fecha_fin""",
"description": "Consulta todos los procedimientos en rango de fechas"
},
]
def ensure_defaults():
conn = get_connection()
for q in QUERY_DEFAULTS:
exists = conn.execute(
"SELECT id FROM queries WHERE name = ?", (q["name"],)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO queries (name, query_type, query_text, description) VALUES (?, ?, ?, ?)",
(q["name"], q["query_type"], q["query_text"], q["description"]),
)
conn.commit()
conn.close()
@router.get("")
async def queries_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
queries = conn.execute("SELECT * FROM queries ORDER BY query_type, name").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("queries.html", {
"request": request, "user": user, "queries": queries
})
@router.post("/create")
async def query_create(
request: Request,
user: dict = Depends(get_current_user),
name: str = Form(...),
query_type: str = Form(...),
query_text: str = Form(...),
description: str = Form(""),
):
conn = get_connection()
conn.execute(
"INSERT INTO queries (name, query_type, query_text, description) VALUES (?, ?, ?, ?)",
(name, query_type, query_text, description),
)
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/update/{query_id}")
async def query_update(
query_id: int,
request: Request,
user: dict = Depends(get_current_user),
name: str = Form(...),
query_text: str = Form(...),
description: str = Form(""),
):
conn = get_connection()
conn.execute(
"UPDATE queries SET name = ?, query_text = ?, description = ? WHERE id = ?",
(name, query_text, description, query_id),
)
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/run-sql")
async def run_sql(
request: Request,
sql: str = Form(...),
user: dict = Depends(get_current_user),
):
from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"})
success, fb_err, rows = fb.execute_query(sql)
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err})
limit = rows[:200] if rows else []
return JSONResponse({
"rows": limit,
"total": len(rows),
"columns": list(limit[0].keys()) if limit else []
})
@router.post("/delete/{query_id}")
async def query_delete(query_id: int, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute("DELETE FROM queries WHERE id = ?", (query_id,))
conn.commit()
conn.close()
return RedirectResponse("/queries", status_code=302)
@router.post("/esquema")
async def obtener_esquema(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, tablas = fb.execute_query("""
SELECT RDB$RELATION_NAME as nombre
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0
AND RDB$RELATION_NAME NOT LIKE 'RDB$%'
ORDER BY 1
""", {})
if not success:
fb.disconnect()
return JSONResponse({"error": fb_err}, status_code=400)
resultado = []
for t in tablas:
nombre = t["NOMBRE"].strip()
ok, err, cols = fb.execute_query("""
SELECT
rf.RDB$FIELD_NAME as COLUMN_NAME,
f.RDB$FIELD_TYPE as FIELD_TYPE,
f.RDB$FIELD_LENGTH as FIELD_LENGTH
FROM RDB$RELATION_FIELDS rf
JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
WHERE rf.RDB$RELATION_NAME = :nombre
ORDER BY rf.RDB$FIELD_POSITION
""", {"nombre": nombre})
columnas = []
if ok:
for c in cols:
tiponum = c["FIELD_TYPE"]
tipos = {7: "SMALLINT", 8: "INTEGER", 10: "FLOAT", 12: "DATE", 13: "TIME",
14: "CHAR", 16: "BIGINT", 27: "DOUBLE", 35: "TIMESTAMP", 37: "VARCHAR",
40: "BLOB", 45: "BLOB_ID", 261: "BLOB"}
columnas.append({
"nombre": c["COLUMN_NAME"].strip(),
"tipo": tipos.get(tiponum, f"UNKNOWN({tiponum})"),
"longitud": c["FIELD_LENGTH"]
})
resultado.append({"tabla": nombre, "columnas": columnas})
fb.disconnect()
return JSONResponse({"tablas": resultado})
@router.post("/tablas")
async def listar_tablas(user: dict = Depends(get_current_user)):
from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, rows = fb.execute_query("""
SELECT RDB$RELATION_NAME as nombre
FROM RDB$RELATIONS
WHERE RDB$SYSTEM_FLAG = 0
AND RDB$RELATION_NAME NOT LIKE 'RDB$%'
ORDER BY 1
""", {})
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err}, status_code=400)
return JSONResponse({
"tablas": [r["NOMBRE"].strip() for r in rows]
})
@router.post("/test")
async def query_test(
request: Request,
query_id: int = Form(...),
user: dict = Depends(get_current_user),
):
from app.services.firebird_service import FirebirdService, get_firebird_from_config
conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
if not q:
return JSONResponse({"error": "Consulta no encontrada"}, status_code=404)
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"error": f"Error Firebird: {fb_msg}"}, status_code=400)
success, fb_err, rows = fb.execute_query(q["query_text"], {})
fb.disconnect()
if not success:
return JSONResponse({"error": fb_err}, status_code=400)
limit = rows[:20] if rows else []
return JSONResponse({
"rows": limit,
"total": len(rows),
"columns": list(limit[0].keys()) if limit else []
})
+33 -43
View File
@@ -1,13 +1,23 @@
import json
import httpx
from datetime import datetime
from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import JSONResponse
from app.database import get_connection
from app.auth import get_current_user
from app.services.firebird_service import FirebirdService
from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_terceros
router = APIRouter(prefix="/terceros", tags=["terceros"])
def _load_configs() -> dict:
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
return configs
@router.get("")
async def terceros_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
@@ -41,6 +51,7 @@ async def test_connection(
fb_user: str = Form(...),
fb_password: str = Form(...),
):
from app.services.firebird_service import FirebirdService
fb = FirebirdService()
success, msg = fb.connect(host, port, database, fb_user, fb_password)
if success:
@@ -63,16 +74,9 @@ async def preview_query(
if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
params = {}
if ":doc_num" in q["query_text"] and doc_num:
@@ -89,9 +93,7 @@ async def preview_query(
if not success:
return JSONResponse({"success": False, "message": error})
json_result = None
if rows:
json_result = generar_terceros(rows[0])
json_result = generar_terceros(rows[0]) if rows else None
return JSONResponse({
"success": True,
@@ -109,10 +111,6 @@ async def send_terceros(
query_id: int = Form(...),
doc_num: str = Form(""),
):
import json
import httpx
from datetime import datetime
conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
@@ -121,16 +119,9 @@ async def send_terceros(
if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
params = {}
if ":doc_num" in q["query_text"]:
@@ -141,51 +132,50 @@ async def send_terceros(
if not success:
return JSONResponse({"success": False, "message": error})
if not rows:
return JSONResponse({"success": False, "message": "No se encontraron datos"})
# Generar JSON terceros
tercero_json = generar_terceros(rows[0])
# Enviar a API
api_url = configs.get("api_url", "")
api_key = configs.get("api_key", "")
api_method = configs.get("api_method", "POST")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp_ok = False
resp_text = ""
resp_code = 0
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
if api_method == "POST":
resp = await client.post(api_url + "/terceros", json=tercero_json, headers=headers)
else:
resp = await client.put(api_url + "/terceros", json=tercero_json, headers=headers)
result = resp.status_code, resp.is_success, resp.text
resp_ok = resp.is_success
resp_text = resp.text
resp_code = resp.status_code
except Exception as e:
result = (0, False, str(e))
resp_text = str(e)
# Guardar log
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "terceros",
"success" if result[1] else "error",
"success" if resp_ok else "error",
json.dumps(tercero_json, indent=2, ensure_ascii=False),
str(result[2])[:1000],
resp_text[:1000],
datetime.now().isoformat(),
))
conn.commit()
conn.close()
return JSONResponse({
"success": result[1],
"status_code": result[0],
"message": "Envío exitoso" if result[1] else f"Error: {result[2]}",
"cuv": result[2][:200] if result[1] else None,
"success": resp_ok,
"status_code": resp_code,
"message": "Envío exitoso" if resp_ok else f"Error: {resp_text}",
"cuv": resp_text[:200] if resp_ok else None,
})
+52 -90
View File
@@ -1,9 +1,12 @@
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 FirebirdService
from app.services.json_generator import generar_terceros, generar_transaccion
from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_transaccion, agrupar_por_factura
router = APIRouter(prefix="/transaccion", tags=["transaccion"])
@@ -45,16 +48,9 @@ async def preview_transaccion(
if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura:
@@ -66,26 +62,14 @@ async def preview_transaccion(
if not success:
return JSONResponse({"success": False, "message": error})
# Agrupar por paciente y factura
from collections import defaultdict
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
grupos = agrupar_por_factura(rows, factura)
json_result = []
for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans = generar_transaccion(
fact,
configs.get("num_documento_obligado", ""),
paciente_data,
grupo["procedimientos"],
)
json_result.append(trans)
json_result.append(generar_transaccion(
fact, configs.get("num_documento_obligado", ""),
paciente_data, grupo["procedimientos"],
))
return JSONResponse({
"success": True,
@@ -107,11 +91,6 @@ async def send_transaccion(
fecha_inicio: str = Form(...),
fecha_fin: str = Form(...),
):
import json as json_lib
import httpx
from datetime import datetime
from collections import defaultdict
conn = get_connection()
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
@@ -120,16 +99,9 @@ async def send_transaccion(
if not q:
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
fb = FirebirdService()
fb_success, fb_msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
if not fb_success:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura:
@@ -143,14 +115,7 @@ async def send_transaccion(
if not rows:
return JSONResponse({"success": False, "message": "No se encontraron datos"})
# Agrupar
grupos = defaultdict(lambda: {"factura": "", "procedimientos": [], "paciente": {}})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
grupos = agrupar_por_factura(rows, factura)
api_url = configs.get("api_url", "")
api_key = configs.get("api_key", "")
api_method = configs.get("api_method", "POST")
@@ -162,52 +127,49 @@ async def send_transaccion(
total_errores = 0
resultados = []
for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans_json = generar_transaccion(
fact,
configs.get("num_documento_obligado", ""),
paciente_data,
grupo["procedimientos"],
)
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
for (fact, doc_key), grupo in grupos.items():
paciente_data = {"tipoDocumentoIdentificacion": doc_key[0], "numDocumentoIdentificacion": doc_key[1]}
trans_json = generar_transaccion(
fact, configs.get("num_documento_obligado", ""),
paciente_data, grupo["procedimientos"],
)
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
status_ok = False
response_text = ""
try:
if api_method == "POST":
resp = await client.post(api_url + "/transaccion", json=trans_json, headers=headers)
else:
resp = await client.put(api_url + "/transaccion", json=trans_json, headers=headers)
status_ok = resp.is_success
response_text = resp.text[:1000]
except Exception as e:
response_text = str(e)
status_ok = resp.is_success
response_text = resp.text[:1000]
except Exception as e:
status_ok = False
response_text = str(e)
if status_ok:
total_enviados += 1
else:
total_errores += 1
if status_ok:
total_enviados += 1
else:
total_errores += 1
resultados.append({"factura": fact, "success": status_ok})
resultados.append({"factura": fact, "success": status_ok})
# Guardar log
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "transaccion", fact,
fecha_inicio, fecha_fin,
1, len(grupo["procedimientos"]),
"success" if status_ok else "error",
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
response_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
conn = get_connection()
conn.execute("""
INSERT INTO envios (user_id, tipo, factura, fecha_inicio, fecha_fin,
pacientes_count, servicios_count, status, json_enviado, respuesta_api, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user["user_id"], "transaccion", fact,
fecha_inicio, fecha_fin,
1, len(grupo["procedimientos"]),
"success" if status_ok else "error",
json_lib.dumps(trans_json, indent=2, ensure_ascii=False)[:5000],
response_text,
datetime.now().isoformat(),
))
conn.commit()
conn.close()
return JSONResponse({
"success": total_errores == 0,
+13 -1
View File
@@ -44,7 +44,7 @@ class FirebirdService:
return False, "No hay conexión activa", []
try:
cur = self.conn.cursor()
if params:
if params is not None:
cur.execute(query, params)
else:
cur.execute(query)
@@ -53,3 +53,15 @@ class FirebirdService:
return True, "", [dict(zip(columns, row)) for row in rows]
except Exception as e:
return False, str(e), []
def get_firebird_from_config(configs: dict) -> tuple:
fb = FirebirdService()
ok, msg = fb.connect(
configs.get("firebird_host", "localhost"),
int(configs.get("firebird_port", 3050)),
configs.get("firebird_database", ""),
configs.get("firebird_user", "SYSDBA"),
configs.get("firebird_password", "masterkey"),
)
return fb, ok, msg
+11
View File
@@ -1,3 +1,4 @@
from collections import defaultdict
from datetime import datetime
from typing import Optional
@@ -49,6 +50,16 @@ 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(
factura: str,
num_doc_obligado: str,
+434 -403
View File
@@ -1,403 +1,434 @@
{% extends "base.html" %}
{% block title %}Consultas SQL{% endblock %}
{% block header %}Consultas SQL{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3">
<div class="flex space-x-1 border-b border-gray-200 mb-4">
<button onclick="cambiarTab('probador')" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600" id="tab-probador"><i class="fas fa-play mr-1"></i> Probador</button>
<button onclick="cambiarTab('constructor')" class="tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent" id="tab-constructor"><i class="fas fa-wrench mr-1"></i> Constructor</button>
<button onclick="cambiarTab('editor')" class="tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent" id="tab-editor"><i class="fas fa-code mr-1"></i> Editor</button>
</div>
<div id="panel-probador">
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-terminal mr-2 text-green-500"></i>Escribí tu SQL y ejecutalo</h3>
<div class="flex space-x-2">
<button onclick="ejecutarProbador()" class="px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-play mr-1"></i> Ejecutar</button>
<button onclick="limpiarProbador()" class="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 text-xs rounded-lg"><i class="fas fa-eraser mr-1"></i> Limpiar</button>
</div>
</div>
<div>
<textarea id="probador-sql" rows="6" class="w-full px-4 py-3 text-sm font-mono border-0 focus:ring-0" placeholder="SELECT * FROM ..."></textarea>
</div>
<div id="probador-resultados" class="border-t border-gray-200 overflow-x-auto max-h-96 overflow-y-auto"></div>
<div id="probador-info" class="px-4 py-2 text-xs text-gray-500 border-t border-gray-200"></div>
</div>
</div>
<div id="panel-constructor" class="hidden">
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-wrench mr-2 text-blue-500"></i>Constructor Visual</h3>
<button onclick="cargarEsquema()" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-sync mr-1"></i> Cargar esquema</button>
</div>
<div class="p-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div>
<h4 class="text-xs font-semibold text-gray-700 mb-2">Tablas</h4>
<div id="builder-tablas" class="space-y-1 max-h-64 overflow-y-auto border rounded p-2 bg-gray-50 text-xs">
<p class="text-gray-400 italic">Presiona "Cargar esquema"</p>
</div>
</div>
<div>
<h4 class="text-xs font-semibold text-gray-700 mb-2">Columnas</h4>
<div id="builder-columnas" class="border rounded p-2 bg-gray-50 min-h-[100px] text-xs">
<p class="text-gray-400 italic">Seleccioná una tabla</p>
</div>
<h4 class="text-xs font-semibold text-gray-700 mt-3 mb-2">Filtros</h4>
<div id="builder-where" class="space-y-1 text-xs"></div>
<button onclick="agregarFiltro()" class="mt-1 text-xs text-blue-600 hover:text-blue-800"><i class="fas fa-plus mr-1"></i> Agregar filtro</button>
</div>
<div>
<h4 class="text-xs font-semibold text-gray-700 mb-2">SQL</h4>
<textarea id="builder-sql" rows="4" readonly class="w-full px-2 py-1 border rounded text-xs font-mono bg-gray-100"></textarea>
<p id="builder-params" class="text-xs text-blue-600 mt-1"></p>
<button onclick="ejecutarBuilder()" class="mt-2 px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg"><i class="fas fa-play mr-1"></i> Ejecutar</button>
<button onclick="usarSqlEditor()" class="mt-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded-lg ml-1"><i class="fas fa-arrow-right mr-1"></i> Enviar al Editor</button>
</div>
</div>
<div id="builder-resultados" class="mt-3 border-t border-gray-200 pt-3 overflow-x-auto max-h-60 overflow-y-auto"></div>
</div>
</div>
</div>
<div id="panel-editor" class="hidden">
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-save mr-2 text-blue-500"></i>Guardar consulta</h3>
</div>
<div class="p-4">
<form method="POST" action="/queries/create" class="space-y-3">
<input type="hidden" name="query_id" id="query_id" value="">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" name="name" id="q_name" required class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" placeholder="Nombre">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Tipo</label>
<select name="query_type" id="q_type" class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">SQL</label>
<textarea name="query_text" id="q_text" rows="8" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono" placeholder="PEGA ACA LA SQL YA PROBADA"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
<input type="text" name="description" id="q_desc" class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm" placeholder="Descripción">
</div>
<div class="flex space-x-2">
<button type="submit" class="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"><i class="fas fa-save mr-1"></i> Guardar</button>
<button type="button" onclick="cancelEdit()" class="px-4 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 hidden" id="btn-cancel"><i class="fas fa-times mr-1"></i> Cancelar</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"><i class="fas fa-list mr-2 text-blue-500"></i>Consultas</h3>
</div>
<div class="p-3 space-y-2 max-h-[calc(100vh-200px)] overflow-y-auto">
{% for q in queries %}
<div class="p-2 rounded-lg border border-gray-200 text-xs">
<div onclick="cargarConsulta({{ q.id }}, '{{ q.name }}', '{{ q.query_type }}', `{{ q.query_text|e }}`)" class="cursor-pointer">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-800 truncate">{{ q.name }}</span>
<span class="px-1.5 py-0.5 rounded text-xs {% if q.query_type == 'terceros' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">{{ q.query_type[:4] }}</span>
</div>
<p class="text-gray-500 mt-0.5 truncate">{{ q.description or 'Sin descripción' }}</p>
</div>
<div class="flex space-x-2 mt-1">
<button onclick="event.stopPropagation();ejecutarConsultaId({{ q.id }})" class="text-green-600 hover:text-green-800"><i class="fas fa-play"></i></button>
<button onclick="event.stopPropagation();cargarConsulta({{ q.id }}, '{{ q.name }}', '{{ q.query_type }}', `{{ q.query_text|e }}`)" class="text-blue-600 hover:text-blue-800"><i class="fas fa-edit"></i></button>
<form method="POST" action="/queries/delete/{{ q.id }}" class="inline" onsubmit="return confirm('Eliminar?')">
<button type="submit" class="text-red-500 hover:text-red-700"><i class="fas fa-trash"></i></button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
let sqlEditor = null;
let esquema = [];
let tablasSeleccionadas = {};
let filtros = [];
let aliasCounter = 0;
function cambiarTab(tab) {
['probador','constructor','editor'].forEach(t => {
document.getElementById('panel-'+t).classList.toggle('hidden', t !== tab);
const btn = document.getElementById('tab-'+t);
if (t === tab) {
btn.className = 'tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600';
} else {
btn.className = 'tab-btn px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent';
}
});
if (tab === 'constructor' && !esquema.length) cargarEsquema();
}
// ───── PROBADOR ─────
function ejecutarProbador() {
const sql = document.getElementById('probador-sql').value.trim();
if (!sql) { showToast('Escribí una consulta SQL', 'warning'); return; }
ejecutarSql(sql, 'probador-resultados', 'probador-info');
}
function limpiarProbador() {
document.getElementById('probador-sql').value = '';
document.getElementById('probador-resultados').innerHTML = '';
document.getElementById('probador-info').innerHTML = '';
}
async function ejecutarSql(sql, outputId, infoId) {
const outputDiv = document.getElementById(outputId);
const infoDiv = document.getElementById(infoId);
outputDiv.innerHTML = '<div class="p-4 text-center"><i class="fas fa-spinner fa-spin mr-2"></i>Ejecutando...</div>';
if (infoDiv) infoDiv.innerHTML = '';
try {
const form = new FormData();
form.append('sql', sql);
const resp = await fetch('/queries/run-sql', { method: 'POST', body: form });
const data = await resp.json();
if (data.error) {
outputDiv.innerHTML = `<div class="p-4 text-red-600 text-sm"><i class="fas fa-exclamation-circle mr-1"></i> ${data.error}</div>`;
return;
}
let html = '<table class="w-full text-xs border-collapse"><thead><tr class="bg-gray-100">';
data.columns.forEach(c => { html += '<th class="p-2 border text-left font-semibold whitespace-nowrap">' + c + '</th>'; });
html += '</tr></thead><tbody>';
data.rows.slice(0, 200).forEach(r => {
html += '<tr class="hover:bg-gray-50">';
data.columns.forEach(c => { html += '<td class="p-2 border whitespace-nowrap">' + (r[c] ?? '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table>';
outputDiv.innerHTML = html;
if (infoDiv) infoDiv.innerHTML = `${data.total} registros (mostrando ${Math.min(data.total, 200)})`;
if (data.total > 200) infoDiv.innerHTML += ' <span class="text-yellow-600">- Resultado truncado a 200 filas</span>';
} catch(e) {
outputDiv.innerHTML = `<div class="p-4 text-red-600 text-sm">Error: ${e.message}</div>`;
}
}
// ───── CONSTRUCTOR ─────
async function cargarEsquema() {
const btn = document.querySelector('#panel-constructor .bg-green-600');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Cargando...';
try {
const resp = await fetch('/queries/esquema', { method: 'POST' });
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
esquema = data.tablas;
renderizarTablas();
} catch(e) { showToast('Error: ' + e.message, 'error'); }
finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-sync mr-1"></i> Cargar esquema'; }
}
function renderizarTablas() {
const div = document.getElementById('builder-tablas');
div.innerHTML = '';
esquema.forEach(t => {
const id = 'tab-' + t.tabla.replace(/[^a-zA-Z0-9]/g, '_');
const checked = tablasSeleccionadas[t.tabla] ? 'checked' : '';
const card = document.createElement('div');
card.className = 'border rounded p-1.5 ' + (checked ? 'border-blue-300 bg-blue-50' : 'border-gray-200 bg-white');
card.innerHTML = `
<label class="flex items-center space-x-1 cursor-pointer">
<input type="checkbox" ${checked} onchange="toggleTabla('${t.tabla}', this.checked)" class="rounded">
<span class="font-medium text-xs">${t.tabla}</span>
<span class="text-gray-400 text-xs">(${t.columnas.length})</span>
</label>
<div id="${id}" class="ml-4 ${checked ? '' : 'hidden'}">${renderizarColumnas(t.tabla, t.columnas)}</div>`;
div.appendChild(card);
});
actualizarBuilder();
}
function renderizarColumnas(tabla, columnas) {
let html = '';
columnas.forEach(c => {
const sel = tablasSeleccionadas[tabla]?.columnas?.[c.nombre] ? 'checked' : '';
html += `<label class="flex items-center space-x-1 py-0.5 cursor-pointer hover:bg-blue-100 px-1 rounded text-xs">
<input type="checkbox" ${sel} onchange="toggleColumna('${tabla}','${c.nombre}',this.checked)" class="rounded">
<span>${c.nombre} <span class="text-gray-400">(${c.tipo}${c.longitud ? ','+c.longitud : ''})</span></span>
</label>`;
});
return html;
}
function toggleTabla(tabla, checked) {
if (checked) tablasSeleccionadas[tabla] = { alias: 't' + (++aliasCounter), columnas: {} };
else delete tablasSeleccionadas[tabla];
renderizarTablas(); actualizarBuilder();
}
function toggleColumna(tabla, columna, checked) {
if (!tablasSeleccionadas[tabla]) return;
if (checked) tablasSeleccionadas[tabla].columnas[columna] = true;
else delete tablasSeleccionadas[tabla].columnas[columna];
renderizarTablas(); actualizarBuilder();
}
function agregarFiltro() {
filtros.push({ campo: '', operador: '=', parametro: '', condicion: 'AND' });
renderizarFiltros(); actualizarBuilder();
}
function renderizarFiltros() {
const div = document.getElementById('builder-where');
div.innerHTML = '';
if (!filtros.length) { div.innerHTML = '<p class="text-gray-400 italic">Sin filtros</p>'; return; }
filtros.forEach((f, i) => {
const opts = [];
Object.entries(tablasSeleccionadas).forEach(([t, info]) => {
const tInfo = esquema.find(e => e.tabla === t);
if (tInfo) tInfo.columnas.forEach(c => {
opts.push({ value: `${t}.${c.nombre}`, label: `${info.alias}.${c.nombre}` });
});
});
const row = document.createElement('div');
row.className = 'flex items-center space-x-1 text-xs';
row.innerHTML = `
<select onchange="actualizarFiltro(${i},'campo',this.value)" class="border rounded px-1 py-0.5 text-xs max-w-[100px]">
<option value="">--campo--</option>
${opts.map(o => `<option value="${o.value}" ${f.campo===o.value?'selected':''}>${o.label}</option>`).join('')}
</select>
<select onchange="actualizarFiltro(${i},'operador',this.value)" class="border rounded px-1 py-0.5 text-xs w-14">
${['=','>','<','>=','<=','<>','LIKE','BETWEEN'].map(o => `<option ${f.operador===o?'selected':''}>${o}</option>`).join('')}
</select>
<input type="text" value="${f.parametro}" onchange="actualizarFiltro(${i},'parametro',this.value)" placeholder=":param" class="border rounded px-1 py-0.5 text-xs font-mono w-20">
<button onclick="filtros.splice(${i},1);renderizarFiltros();actualizarBuilder();" class="text-red-500 hover:text-red-700"><i class="fas fa-times"></i></button>`;
div.appendChild(row);
if (i < filtros.length - 1) {
const sep = document.createElement('div');
sep.className = 'ml-1';
sep.innerHTML = `<select onchange="actualizarFiltro(${i+1},'condicion',this.value)" class="border rounded px-1 py-0.5 text-xs w-14">
<option value="AND" ${filtros[i+1]?.condicion==='AND'?'selected':''}>AND</option>
<option value="OR" ${filtros[i+1]?.condicion==='OR'?'selected':''}>OR</option>
</select>`;
div.appendChild(sep);
}
});
}
function actualizarFiltro(i, prop, val) {
if (!filtros[i]) filtros[i] = { campo: '', operador: '=', parametro: '', condicion: 'AND' };
filtros[i][prop] = val;
actualizarBuilder();
}
function generarSql() {
const tabs = Object.entries(tablasSeleccionadas);
if (!tabs.length) return { sql: '-- Seleccioná al menos una tabla', params: [] };
const selects = [], froms = [], allParams = new Set();
tabs.forEach(([tabla, info]) => {
froms.push({ tabla, alias: info.alias });
Object.keys(info.columnas).forEach(c => selects.push(`${info.alias}.${c}`));
});
if (!selects.length) return { sql: '-- Seleccioná al menos una columna', params: [] };
let sql = 'SELECT\n ' + selects.join(',\n ') + '\nFROM ';
sql += froms.map((f, i) => i === 0 ? `${f.tabla} ${f.alias}` : `JOIN ${f.tabla} ${f.alias} ON 1=1`).join('\n');
const wheres = [];
filtros.forEach(f => {
if (f.campo && f.parametro) {
const [t, c] = f.campo.split('.');
const info = tablasSeleccionadas[t];
if (info) { wheres.push(`${info.alias}.${c} ${f.operador} ${f.parametro}`); if (f.parametro.startsWith(':')) allParams.add(f.parametro); }
}
});
if (wheres.length) sql += '\nWHERE ' + wheres.join(' ');
sql += '\nROWS 200';
return { sql, params: Array.from(allParams) };
}
function actualizarBuilder() {
const { sql, params } = generarSql();
document.getElementById('builder-sql').value = sql;
document.getElementById('builder-params').textContent = params.length ? 'Params: ' + params.join(', ') : '';
const tabs = Object.entries(tablasSeleccionadas);
const div = document.getElementById('builder-columnas');
if (!tabs.length) { div.innerHTML = '<p class="text-gray-400 italic">Seleccioná una tabla</p>'; return; }
let html = '';
tabs.forEach(([tabla, info]) => {
const cols = Object.keys(info.columnas);
if (cols.length) html += `<div class="mb-0.5 text-xs"><span class="font-medium">${info.alias}.</span> ${cols.map(c => `<code class="bg-blue-100 px-1 rounded">${c}</code>`).join(' ')}</div>`;
});
div.innerHTML = html || '<p class="text-gray-400 italic">Seleccioná columnas</p>';
}
function ejecutarBuilder() {
const sql = document.getElementById('builder-sql').value;
if (!sql || sql.startsWith('--')) { showToast('No hay SQL generado', 'warning'); return; }
ejecutarSql(sql, 'builder-resultados', null);
}
function usarSqlEditor() {
const sql = document.getElementById('builder-sql').value;
if (!sql || sql.startsWith('--')) { showToast('No hay SQL generado', 'warning'); return; }
document.getElementById('q_text').value = sql;
document.getElementById('probador-sql').value = sql;
cambiarTab('probador');
showToast('SQL enviado al Probador');
}
// ───── CONSULTAS GUARDADAS ─────
function cargarConsulta(id, name, type, text) {
document.getElementById('query_id').value = id;
document.getElementById('q_name').value = name;
document.getElementById('q_type').value = type;
document.getElementById('q_text').value = text;
document.getElementById('q_desc').value = '';
document.getElementById('probador-sql').value = text;
document.getElementById('btn-cancel').classList.remove('hidden');
document.querySelector('#panel-editor form').action = '/queries/update/' + id;
cambiarTab('probador');
}
function cancelEdit() {
document.getElementById('query_id').value = '';
document.getElementById('q_name').value = '';
document.getElementById('q_text').value = '';
document.getElementById('q_desc').value = '';
document.getElementById('btn-cancel').classList.add('hidden');
document.querySelector('#panel-editor form').action = '/queries/create';
}
async function ejecutarConsultaId(id) {
const form = new FormData(); form.append('sql', '');
const resp = await fetch('/queries/test', { method: 'POST', body: (()=>{const f=new FormData();f.append('query_id',id);return f;})() });
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
let html = '<table class="w-full text-xs border-collapse"><thead><tr class="bg-gray-100">';
data.columns.forEach(c => { html += '<th class="p-2 border text-left font-semibold">' + c + '</th>'; });
html += '</tr></thead><tbody>';
data.rows.slice(0,200).forEach(r => {
html += '<tr class="hover:bg-gray-50">';
data.columns.forEach(c => { html += '<td class="p-2 border whitespace-nowrap">' + (r[c] ?? '') + '</td>'; });
html += '</tr>';
});
html += '</tbody></table>';
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50';
modal.innerHTML = '<div class="bg-white rounded-xl shadow-xl max-w-5xl w-full mx-4 max-h-[80vh] overflow-auto p-4"><div class="flex justify-between items-center mb-3"><h3 class="font-semibold text-sm">Resultado</h3><button onclick="this.closest(\'.fixed\').remove()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-lg"></i></button></div>' + html + '<p class="text-xs text-gray-500 mt-2">Total: ' + data.total + ' registros (mostrando ' + Math.min(data.total, 200) + ')</p></div>';
document.body.appendChild(modal);
}
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}SQL Workbench{% endblock %}
{% block header %}SQL Workbench{% endblock %}
{% block content %}
<style>
.CodeMirror { height: 180px; font-size: 13px; }
.cm-s-default .cm-keyword { color: #0000ff; font-weight: bold; }
.schema-col:hover { background: #eff6ff; }
.schema-table-row:hover { background: #f3f4f6; }
#result-table table { border-collapse: collapse; width: 100%; }
#result-table th { position: sticky; top: 0; background: #f9fafb; z-index: 1; }
#result-table td, #result-table th { padding: 4px 10px; border-bottom: 1px solid #e5e7eb; white-space: nowrap; font-size: 12px; text-align: left; }
#result-table tr:hover td { background: #eff6ff; }
#result-table tr:nth-child(even) td { background: #fafafa; }
#result-table tr:nth-child(even):hover td { background: #eff6ff; }
</style>
<div class="flex gap-3" style="height: calc(100vh - 148px);">
<!-- ── LEFT PANEL ─────────────────────────────────────────────── -->
<div class="flex flex-col gap-3" style="width: 240px; flex-shrink: 0;">
<!-- Schema Browser -->
<div class="bg-white rounded-xl border border-gray-200 flex flex-col" style="flex: 1; min-height: 0;">
<div class="px-3 py-2 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Esquema</span>
<button id="btn-conectar" onclick="cargarEsquema()"
class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<i class="fas fa-plug"></i> Conectar
</button>
</div>
<div id="schema-search-wrap" class="hidden px-2 pt-2 flex-shrink-0">
<input id="schema-search" type="text" placeholder="Buscar tabla..."
oninput="filtrarTablas(this.value)"
class="w-full text-xs border border-gray-200 rounded px-2 py-1 focus:outline-none focus:border-blue-400">
</div>
<div id="schema-tree" class="flex-1 overflow-y-auto p-2 text-xs">
<p class="text-gray-400 italic text-center mt-4">Presioná Conectar para<br>cargar el esquema Firebird</p>
</div>
</div>
<!-- Saved Queries -->
<div class="bg-white rounded-xl border border-gray-200 flex flex-col" style="max-height: 38%; min-height: 120px;">
<div class="px-3 py-2 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Consultas</span>
<button onclick="abrirModalNueva()"
class="text-xs text-blue-600 hover:text-blue-800"><i class="fas fa-plus"></i></button>
</div>
<div class="overflow-y-auto flex-1">
{% for q in queries %}
<div class="group flex items-center px-3 py-1.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
onclick="cargarConsulta({{ q.query_text | tojson }}, {{ q.name | tojson }}, {{ q.query_type | tojson }})">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs px-1 rounded {% if q.query_type == 'terceros' %}bg-blue-100 text-blue-600{% else %}bg-purple-100 text-purple-600{% endif %}">
{{ q.query_type[:4] }}
</span>
<span class="text-xs text-gray-800 truncate">{{ q.name }}</span>
</div>
</div>
<div class="hidden group-hover:flex items-center gap-1 ml-1 flex-shrink-0">
<button onclick="event.stopPropagation();editarConsulta({{ q.id }}, {{ q.name | tojson }}, {{ q.query_type | tojson }}, {{ q.query_text | tojson }}, {{ (q.description or '') | tojson }})"
class="text-blue-400 hover:text-blue-600 p-0.5"><i class="fas fa-edit text-xs"></i></button>
<form method="POST" action="/queries/delete/{{ q.id }}" onsubmit="return confirm('¿Eliminar consulta?')" class="inline">
<button type="submit" class="text-red-400 hover:text-red-600 p-0.5"><i class="fas fa-trash text-xs"></i></button>
</form>
</div>
</div>
{% else %}
<p class="text-xs text-gray-400 italic p-3 text-center">Sin consultas guardadas</p>
{% endfor %}
</div>
</div>
</div>
<!-- ── RIGHT PANEL ────────────────────────────────────────────── -->
<div class="flex-1 flex flex-col gap-3 min-w-0">
<!-- Toolbar -->
<div class="bg-white rounded-xl border border-gray-200 px-4 py-2.5 flex items-center gap-2 flex-shrink-0">
<button onclick="ejecutar()" id="btn-run"
class="flex items-center gap-2 px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg font-medium">
<i class="fas fa-play text-xs"></i> Ejecutar
</button>
<span class="text-gray-200 select-none">|</span>
<input type="text" id="q-name" placeholder="Nombre de la consulta"
class="text-sm border border-gray-200 rounded-lg px-3 py-1.5 w-44 focus:outline-none focus:border-blue-400">
<select id="q-type" class="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-400">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
<button onclick="guardar()"
class="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
<i class="fas fa-save text-xs"></i> Guardar
</button>
<button id="btn-cancel" onclick="cancelarEdicion()"
class="hidden items-center gap-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-lg">
<i class="fas fa-times text-xs"></i> Cancelar
</button>
<span class="text-xs text-gray-400 ml-1 hidden" id="editing-label"></span>
<div class="ml-auto text-xs" id="status-bar"></div>
</div>
<!-- Editor -->
<div class="bg-white rounded-xl border border-gray-200 flex-shrink-0">
<div class="flex items-center justify-between px-3 py-1.5 border-b border-gray-100">
<span class="text-xs text-gray-500 font-mono" id="editor-label">SQL</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">Ctrl+Enter para ejecutar · Ctrl+Space autocomplete</span>
<button onclick="limpiar()" class="text-xs text-gray-400 hover:text-gray-600"><i class="fas fa-eraser"></i></button>
</div>
</div>
<textarea id="sql-editor" class="hidden"></textarea>
</div>
<!-- Results -->
<div class="bg-white rounded-xl border border-gray-200 flex-1 flex flex-col min-h-0">
<div class="px-4 py-2 border-b border-gray-100 flex items-center justify-between flex-shrink-0">
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">Resultados</span>
<span id="result-info" class="text-xs text-gray-400"></span>
</div>
<div id="result-table" class="flex-1 overflow-auto">
<div class="flex flex-col items-center justify-center h-full text-gray-300 select-none">
<i class="fas fa-table text-4xl mb-3"></i>
<p class="text-sm">Ejecutá una consulta para ver resultados</p>
</div>
</div>
</div>
</div>
</div>
<!-- ── MODAL GUARDAR ──────────────────────────────────────────────── -->
<div id="modal-save" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 p-6">
<h3 class="font-semibold text-gray-800 mb-4 text-base" id="modal-title">Guardar consulta</h3>
<form id="form-save" method="POST" action="/queries/create" class="space-y-3">
<input type="text" name="name" id="form-name" required placeholder="Nombre *"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
<div class="grid grid-cols-2 gap-3">
<select name="query_type" id="form-type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
<option value="terceros">Terceros</option>
<option value="transaccion">Transacción</option>
</select>
<input type="text" name="description" id="form-desc" placeholder="Descripción"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-400">
</div>
<textarea name="query_text" id="form-sql" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:outline-none focus:border-blue-400"></textarea>
<div class="flex justify-end gap-2 pt-1">
<button type="button" onclick="cerrarModal()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">Cancelar</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Guardar</button>
</div>
</form>
</div>
</div>
<script>
let editor, esquema = [], editandoId = null;
// ── INIT CODEMIRROR ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
editor = CodeMirror.fromTextArea(document.getElementById('sql-editor'), {
mode: 'text/x-sql',
lineNumbers: true,
autofocus: true,
tabSize: 4,
indentWithTabs: false,
lineWrapping: true,
extraKeys: {
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
},
hintOptions: { tables: {} }
});
editor.setSize(null, 180);
editor.on('keydown', (cm, e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
ejecutar();
}
});
});
// ── SCHEMA BROWSER ───────────────────────────────────────────────────
async function cargarEsquema() {
const btn = document.getElementById('btn-conectar');
const tree = document.getElementById('schema-tree');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
btn.disabled = true;
tree.innerHTML = '<p class="text-gray-400 italic text-center mt-4">Conectando...</p>';
try {
const resp = await fetch('/queries/esquema', { method: 'POST' });
const data = await resp.json();
if (data.error) {
tree.innerHTML = `<div class="p-2 text-red-500 text-xs">${data.error}</div>`;
showToast(data.error, 'error');
return;
}
esquema = data.tablas;
// feed schema into CodeMirror autocomplete
const tablesHint = {};
esquema.forEach(t => { tablesHint[t.tabla] = t.columnas.map(c => c.nombre); });
editor.setOption('hintOptions', { tables: tablesHint });
document.getElementById('schema-search-wrap').classList.remove('hidden');
renderSchemaTree(esquema);
btn.innerHTML = '<i class="fas fa-sync mr-1"></i>Refrescar';
showToast(`${esquema.length} tablas cargadas`);
} catch(e) {
tree.innerHTML = `<div class="p-2 text-red-500 text-xs">Error: ${e.message}</div>`;
showToast('Error: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
function filtrarTablas(q) {
const filtradas = q.trim()
? esquema.filter(t => t.tabla.toLowerCase().includes(q.toLowerCase()))
: esquema;
renderSchemaTree(filtradas);
}
function renderSchemaTree(tablas) {
const tree = document.getElementById('schema-tree');
if (!tablas.length) {
tree.innerHTML = '<p class="text-gray-400 italic text-center mt-4 text-xs">Sin resultados</p>';
return;
}
tree.innerHTML = '';
tablas.forEach(t => {
const key = t.tabla.replace(/\W/g, '_');
const wrap = document.createElement('div');
wrap.className = 'mb-0.5';
wrap.innerHTML = `
<div class="schema-table-row flex items-center gap-1 px-2 py-1 rounded cursor-pointer group select-none"
onclick="toggleCols('${key}')">
<i id="chev-${key}" class="fas fa-chevron-right text-gray-300 text-xs transition-transform duration-150" style="width:10px"></i>
<i class="fas fa-table text-blue-400 text-xs"></i>
<span class="font-semibold text-gray-700 flex-1 text-xs">${t.tabla}</span>
<span class="text-gray-300 text-xs">${t.columnas.length}</span>
<button onclick="event.stopPropagation();selectAll('${t.tabla}',${JSON.stringify(t.columnas.map(c=>c.nombre))})"
title="SELECT *" class="hidden group-hover:inline text-blue-400 hover:text-blue-600 text-xs ml-0.5">
<i class="fas fa-code"></i>
</button>
</div>
<div id="cols-${key}" class="hidden ml-4 border-l border-gray-200 pl-2">
${t.columnas.map(c => `
<div class="schema-col flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs"
onclick="insertarTexto('${c.nombre}')" title="Insertar ${c.nombre}">
<i class="fas fa-columns text-gray-300" style="font-size:9px;width:10px"></i>
<span class="text-gray-700 flex-1">${c.nombre}</span>
<span class="text-gray-400" style="font-size:10px">${c.tipo}</span>
</div>`).join('')}
</div>`;
tree.appendChild(wrap);
});
}
function toggleCols(key) {
const div = document.getElementById('cols-' + key);
const chev = document.getElementById('chev-' + key);
const open = !div.classList.contains('hidden');
div.classList.toggle('hidden', open);
chev.style.transform = open ? '' : 'rotate(90deg)';
}
function insertarTexto(texto) {
if (!editor) return;
editor.replaceSelection(texto);
editor.focus();
}
function selectAll(tabla, columnas) {
const cols = columnas.map(c => ` ${c}`).join(',\n');
const sql = `SELECT\n${cols}\nFROM ${tabla}\nROWS 50`;
editor.setValue(sql);
editor.focus();
}
// ── EJECUTAR ─────────────────────────────────────────────────────────
async function ejecutar() {
const sql = editor.getValue().trim();
if (!sql) { showToast('Escribí una consulta SQL', 'warning'); return; }
const resultDiv = document.getElementById('result-table');
const infoDiv = document.getElementById('result-info');
const statusBar = document.getElementById('status-bar');
const btnRun = document.getElementById('btn-run');
btnRun.disabled = true;
btnRun.innerHTML = '<i class="fas fa-spinner fa-spin text-xs mr-2"></i>Ejecutando...';
resultDiv.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400 text-sm gap-2"><i class="fas fa-spinner fa-spin"></i> Ejecutando...</div>';
infoDiv.textContent = '';
statusBar.innerHTML = '';
const t0 = performance.now();
try {
const form = new FormData();
form.append('sql', sql);
const resp = await fetch('/queries/run-sql', { method: 'POST', body: form });
const data = await resp.json();
const ms = performance.now() - t0;
const elapsed = ms < 1000 ? `${Math.round(ms)}ms` : `${(ms/1000).toFixed(2)}s`;
if (data.error) {
resultDiv.innerHTML = `<div class="p-4 text-red-600 text-sm flex items-start gap-2"><i class="fas fa-exclamation-circle mt-0.5"></i><pre class="whitespace-pre-wrap font-mono text-xs">${escHtml(data.error)}</pre></div>`;
statusBar.innerHTML = `<span class="text-red-500"><i class="fas fa-times-circle mr-1"></i>Error · ${elapsed}</span>`;
return;
}
if (!data.rows || !data.rows.length) {
resultDiv.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400 text-sm">Sin resultados</div>';
infoDiv.textContent = '0 filas';
statusBar.innerHTML = `<span class="text-gray-500">0 filas · ${elapsed}</span>`;
return;
}
let html = '<table><thead><tr>';
data.columns.forEach(c => {
html += `<th class="text-gray-700 font-semibold">${escHtml(c)}</th>`;
});
html += '</tr></thead><tbody>';
data.rows.forEach(r => {
html += '<tr>';
data.columns.forEach(c => {
const v = r[c] ?? '';
const vs = String(v);
html += `<td title="${escAttr(vs)}">${escHtml(vs)}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
resultDiv.innerHTML = html;
const shown = Math.min(data.total, 200);
const trunc = data.total > 200 ? ` <span class="text-yellow-600 font-medium">(truncado a 200)</span>` : '';
infoDiv.innerHTML = `${data.total} fila${data.total !== 1 ? 's' : ''}${trunc}`;
statusBar.innerHTML = `<span class="text-green-600 font-medium"><i class="fas fa-check-circle mr-1"></i>${shown} fila${shown !== 1 ? 's' : ''} · ${elapsed}</span>`;
} catch(e) {
resultDiv.innerHTML = `<div class="p-4 text-red-600 text-sm">Error de red: ${escHtml(e.message)}</div>`;
} finally {
btnRun.disabled = false;
btnRun.innerHTML = '<i class="fas fa-play text-xs"></i> Ejecutar';
}
}
function limpiar() {
if (editor) editor.setValue('');
document.getElementById('result-table').innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-300 select-none"><i class="fas fa-table text-4xl mb-3"></i><p class="text-sm">Ejecutá una consulta para ver resultados</p></div>';
document.getElementById('result-info').textContent = '';
document.getElementById('status-bar').innerHTML = '';
}
// ── GUARDAR / EDITAR ──────────────────────────────────────────────────
function abrirModalNueva() {
editandoId = null;
document.getElementById('form-save').action = '/queries/create';
document.getElementById('modal-title').textContent = 'Nueva consulta';
document.getElementById('form-name').value = document.getElementById('q-name').value;
document.getElementById('form-type').value = document.getElementById('q-type').value;
document.getElementById('form-sql').value = editor ? editor.getValue() : '';
document.getElementById('form-desc').value = '';
document.getElementById('modal-save').classList.remove('hidden');
setTimeout(() => document.getElementById('form-name').focus(), 50);
}
function guardar() {
const sql = editor ? editor.getValue().trim() : '';
if (!sql) { showToast('No hay SQL para guardar', 'warning'); return; }
document.getElementById('form-sql').value = sql;
document.getElementById('form-name').value = document.getElementById('q-name').value;
document.getElementById('form-type').value = document.getElementById('q-type').value;
if (editandoId) {
document.getElementById('form-save').action = '/queries/update/' + editandoId;
document.getElementById('modal-title').textContent = 'Actualizar consulta';
} else {
document.getElementById('form-save').action = '/queries/create';
document.getElementById('modal-title').textContent = 'Guardar consulta';
}
document.getElementById('modal-save').classList.remove('hidden');
setTimeout(() => document.getElementById('form-name').focus(), 50);
}
function cerrarModal() {
document.getElementById('modal-save').classList.add('hidden');
}
function cargarConsulta(sql, name, type) {
if (editor) editor.setValue(sql);
document.getElementById('q-name').value = name;
document.getElementById('q-type').value = type;
editor && editor.focus();
}
function editarConsulta(id, name, type, sql, desc) {
editandoId = id;
cargarConsulta(sql, name, type);
document.getElementById('form-desc').value = desc;
document.getElementById('btn-cancel').classList.replace('hidden', 'flex');
document.getElementById('editing-label').textContent = `Editando: ${name}`;
document.getElementById('editing-label').classList.remove('hidden');
}
function cancelarEdicion() {
editandoId = null;
limpiar();
document.getElementById('q-name').value = '';
document.getElementById('btn-cancel').classList.replace('flex', 'hidden');
document.getElementById('editing-label').classList.add('hidden');
}
// ── UTILS ─────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// close modal on backdrop click
document.getElementById('modal-save').addEventListener('click', e => {
if (e.target === e.currentTarget) cerrarModal();
});
</script>
{% endblock %}
+89 -103
View File
@@ -1,103 +1,89 @@
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from fastapi import FastAPI, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from app.database import init_db
from app.auth import decode_token
app = FastAPI(title="RIPS Manager", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "app", "templates")
)
app.state.templates = templates
def get_user_from_request(request: Request):
cookies = dict(request.cookies)
token = cookies.get("token")
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
if token:
decoded = decode_token(token)
if decoded:
return decoded
print(f" [AUTH] Invalid token: {token[:30]}...", flush=True)
return None
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
public_paths = ["/auth/login", "/auth/register", "/auth/api/login"]
if request.url.path in public_paths or request.url.path.startswith("/static"):
return await call_next(request)
if request.url.path.startswith("/auth"):
return await call_next(request)
user = get_user_from_request(request)
print(f" [AUTH] path={request.url.path} user={user['username'] if user else None} cookies={dict(request.cookies)}", flush=True)
if not user:
if request.url.path.startswith("/api/"):
from fastapi.responses import JSONResponse
print(f" [AUTH] -> 401 JSON for /api/ path", flush=True)
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
print(f" [AUTH] -> 307 redirect to /auth/login", flush=True)
return RedirectResponse(url="/auth/login")
request.state.user = user
return await call_next(request)
@app.on_event("startup")
async def startup():
init_db()
@app.get("/")
async def root():
return RedirectResponse(url="/dashboard")
# Register routes
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(config.router)
app.include_router(queries.router)
app.include_router(terceros.router)
app.include_router(transaccion.router)
app.include_router(logs.router)
app.include_router(automation.router)
if __name__ == "__main__":
import socket
hostname = socket.gethostname()
try:
lan_ip = socket.gethostbyname(hostname)
except:
lan_ip = "0.0.0.0"
print(f" Local: http://localhost:8080")
print(f" Red: http://{lan_ip}:8080")
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=False)
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
import uvicorn
from app.database import init_db
from app.auth import decode_token
app = FastAPI(title="RIPS Manager", version="1.0.0")
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "app", "templates")
)
app.state.templates = templates
def get_user_from_request(request: Request):
token = request.cookies.get("token")
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
if token:
decoded = decode_token(token)
if decoded:
return decoded
print(f" [AUTH] Invalid token: {token[:30]}...", flush=True)
return None
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if request.url.path.startswith("/auth") or request.url.path.startswith("/static"):
return await call_next(request)
user = get_user_from_request(request)
print(f" [AUTH] path={request.url.path} user={user['username'] if user else None}", flush=True)
if not user:
if request.url.path.startswith("/api/"):
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return RedirectResponse(url="/auth/login")
request.state.user = user
return await call_next(request)
@app.on_event("startup")
async def startup():
init_db()
from app.routes.config import ensure_defaults as config_defaults
from app.routes.queries import ensure_defaults as query_defaults
config_defaults()
query_defaults()
@app.get("/")
async def root():
return RedirectResponse(url="/dashboard")
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(config.router)
app.include_router(queries.router)
app.include_router(terceros.router)
app.include_router(transaccion.router)
app.include_router(logs.router)
app.include_router(automation.router)
if __name__ == "__main__":
import socket
hostname = socket.gethostname()
try:
lan_ip = socket.gethostbyname(hostname)
except Exception:
lan_ip = "0.0.0.0"
print(f" Local: http://localhost:8080")
print(f" Red: http://{lan_ip}:8080")
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=False)