Initial commit: RIPS Manager web system

This commit is contained in:
Lizandro Guarnizo
2026-06-17 13:22:39 -05:00
commit 8ae3b44bc9
34 changed files with 3076 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
venv/
__pycache__/
*.pyc
*.pyo
*.db-shm
*.db-wal
.env
python/
*.zip
+347
View File
@@ -0,0 +1,347 @@
# RIPS Manager — Documentación Técnica
Sistema web para generar y enviar JSON de RIPS (Res. 2275/2023) a una API, conectándose a una base de datos Firebird.
---
## Arquitectura
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Navegador │────▶│ FastAPI (8080) │────▶│ Firebird BD │
│ (Tailwind) │◀────│ + Jinja2 │◀────│ (datos) │
└─────────────┘ └──────────────────┘ └──────────────┘
┌──────────────┐
│ SQLite │
│ (app.db) │
└──────────────┘
┌──────────────┐
│ API externa │
│ (envío RIPS)│
└──────────────┘
```
## Stack técnico
| Componente | Tecnología |
|---|---|
| Backend | Python 3.9+ / FastAPI |
| Frontend | Jinja2 + Tailwind CSS (CDN) + FontAwesome |
| Base de datos app | SQLite (usuarios, config, logs, queries) |
| Base de datos datos | Firebird (vía `fdb`) |
| Autenticación | JWT + bcrypt + HttpOnly cookies |
| Cliente HTTP | httpx (async) |
| Servidor | uvicorn |
---
## Endpoints (20 totales)
### Autenticación (6 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/auth/login` | Página de inicio de sesión | Pública |
| POST | `/auth/login` | Procesa login, devuelve cookie JWT | Pública |
| GET | `/auth/register` | Página de registro | Pública |
| POST | `/auth/register` | Registra nuevo usuario | Pública |
| GET | `/auth/logout` | Cierra sesión (elimina cookie) | Pública |
| POST | `/auth/api/login` | Login vía API (devuelve token JSON) | Pública |
### Dashboard (1 endpoint)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/dashboard` | Estadísticas de envíos, últimos registros | Requerida |
### Configuración (2 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/config` | Página de configuración (Firebird + API + prestador) | Requerida |
| POST | `/config/save` | Guarda toda la configuración | Requerida |
### Consultas SQL (3 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/queries` | Página de gestión de consultas SQL | Requerida |
| POST | `/queries/create` | Crea nueva consulta SQL | Requerida |
| POST | `/queries/delete/{id}` | Elimina consulta SQL | Requerida |
| POST | `/queries/update/{id}` | Actualiza consulta SQL | Requerida |
### Terceros (3 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/terceros` | Página de envío de terceros | Requerida |
| POST | `/terceros/test-connection` | Prueba conexión Firebird | Requerida |
| POST | `/terceros/preview` | Vista previa del JSON de terceros | Requerida |
| POST | `/terceros/send` | Genera y envía JSON de terceros a la API | Requerida |
### Transacción RIPS (3 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/transaccion` | Página de envío de transacción RIPS | Requerida |
| POST | `/transaccion/preview` | Vista previa del JSON de transacción | Requerida |
| POST | `/transaccion/send` | Genera y envía JSON de transacción a la API | Requerida |
### Automatización (2 endpoints)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/automation` | Página de automatización 2 pasos | Requerida |
| POST | `/automation/run` | Ejecuta Paso 1 (terceros) + Paso 2 (transacción) | Requerida |
### Logs (1 endpoint)
| Método | Ruta | Descripción | Autenticación |
|---|---|---|---|
| GET | `/logs` | Historial de envíos con filtros | Requerida |
---
## Flujo de autenticación
```
Navegador Servidor
│ │
│── GET /auth/login ───────▶│
│◀──── HTML login page ─────│
│ │
│── POST /auth/login ───────▶│ (username + password)
│ │── verifica credenciales
│ │── genera JWT
│◀── 302 /dashboard ────────│ (Set-Cookie: token=JWT; HttpOnly)
│ │
│── GET /dashboard ────────▶│ (Cookie: token=JWT)
│ │── middleware verifica JWT
│ │── setea request.state.user
│◀──── HTML dashboard ──────│
```
- **Cookie HttpOnly**: no accesible desde JavaScript (seguridad XSS)
- **JWT expira en 12 horas**
- **Bearer token** también soportado para llamadas API (`Authorization: Bearer <token>`)
---
## Flujo de envío de RIPS
### Manual (página Terceros + Transacción)
```
1. Configurar conexión Firebird y API (una vez)
2. Definir consultas SQL en "Consultas SQL"
3. Ir a "Terceros" → seleccionar consulta → "Vista Previa" → "Enviar a API"
4. Ir a "Transacción RIPS" → seleccionar consulta + fechas → "Enviar a API"
```
### Automatizado (página Automatización)
```
1. Seleccionar consulta de terceros (Paso 1)
2. Seleccionar consulta de transacción (Paso 2)
3. Definir rango de fechas
4. "Ejecutar Automatización Completa"
└── Paso 1: Envía todos los terceros del período
└── Paso 2: Envía todas las transacciones del período
```
### Estructura de los JSON generados
#### JSON Terceros
```json
{
"tipoDocumentoIdentificacion": "CC",
"numDocumentoIdentificacion": "27765610",
"primerNombre": "MARIA",
"segundoNombre": "ELENA",
"primerApellido": "GOMEZ",
"segundoApellido": "RUIZ",
"fechaNacimiento": "1954-01-19",
"codSexo": "F",
"codEntidadAdministradora": "EPS010",
"tipoUsuario": "12",
"codPaisResidencia": "170",
"codMunicipioResidencia": "54001",
"codZonaTerritorialResidencia": "02",
"incapacidad": "NO",
"codPaisOrigen": "170",
"direccionResidencia": "CRA 5 #10-20",
"codZonaResidencia": "02"
}
```
#### JSON Transacción RIPS
```json
{
"numDocumentoIdObligado": "900278729",
"numFactura": "LHXC03404",
"tipoNota": null,
"numNota": null,
"usuarios": [{
"tipoDocumentoIdentificacion": "CC",
"numDocumentoIdentificacion": "27765610",
"codEntidadAdministradora": "EPS010",
"tipoUsuario": "12",
"fechaNacimiento": "1954-01-19",
"codSexo": "F",
"codPaisResidencia": "170",
"codMunicipioResidencia": "54001",
"codZonaTerritorialResidencia": "02",
"incapacidad": "NO",
"consecutivo": 1,
"codPaisOrigen": "170",
"servicios": {
"procedimientos": [{
"consecutivo": 1,
"codProcedimiento": "903841",
"fechaInicioAtencion": "2026-06-11 00:00",
"codDiagnosticoPrincipal": "R790",
"codDiagnosticoRelacionado": null,
"finalidadTecnologiaSalud": "23",
"viaIngresoServicioSalud": "02",
"modalidadGrupoServicioTecSal": "01",
"grupoServicios": "02",
"codServicio": 706,
"codPrestador": "540010152002",
"tipoDocumentoIdentificacion": "CC",
"numDocumentoIdentificacion": "27898369",
"vrServicio": 8000,
"valorPagoModerador": 0,
"conceptoRecaudo": "05",
"numAutorizacion": null,
"idMIPRES": null,
"codComplicacion": null,
"numFEVPagoModerador": null
}]
}
}]
}
```
---
## Base de datos SQLite (app)
### Tabla `users`
| Columna | Tipo | Descripción |
|---|---|---|
| id | INTEGER PK | Auto-incrementable |
| username | TEXT UNIQUE | Nombre de usuario |
| email | TEXT UNIQUE | Correo electrónico |
| password_hash | TEXT | Hash bcrypt de la contraseña |
| created_at | TEXT | Fecha de creación |
### Tabla `config`
| Columna | Tipo | Descripción |
|---|---|---|
| id | INTEGER PK | Auto-incrementable |
| key | TEXT UNIQUE | Clave de configuración |
| value | TEXT | Valor |
Claves predefinidas:
- `firebird_host`, `firebird_port`, `firebird_database`, `firebird_user`, `firebird_password`
- `api_url`, `api_method`, `api_key`, `api_timeout`
- `num_documento_obligado`, `cod_prestador`
### Tabla `queries`
| Columna | Tipo | Descripción |
|---|---|---|
| id | INTEGER PK | Auto-incrementable |
| name | TEXT | Nombre descriptivo |
| query_type | TEXT | `terceros` o `transaccion` |
| query_text | TEXT | Sentencia SQL con parámetros |
| description | TEXT | Descripción |
| created_at | TEXT | Fecha de creación |
### Tabla `envios`
| Columna | Tipo | Descripción |
|---|---|---|
| id | INTEGER PK | Auto-incrementable |
| user_id | INTEGER FK | Usuario que realizó el envío |
| tipo | TEXT | `terceros` o `transaccion` |
| factura | TEXT | Número de factura |
| fecha_inicio / fecha_fin | TEXT | Rango de fechas del envío |
| pacientes_count / servicios_count | INTEGER | Cantidades |
| status | TEXT | `success` o `error` |
| json_enviado | TEXT | JSON completo enviado |
| respuesta_api | TEXT | Respuesta de la API |
| codigo_cuv | TEXT | Código CUV (si aplica) |
| created_at | TEXT | Fecha del envío |
---
## Parámetros en consultas SQL
Las consultas pueden usar estos parámetros que el sistema reemplaza automáticamente:
| Parámetro | Ejemplo | Uso |
|---|---|---|
| `:doc_num` | `WHERE doc = :doc_num` | Filtrar por documento de paciente |
| `:factura` | `WHERE fact = :factura` | Filtrar por número de factura |
| `:fecha_ini` | `WHERE fecha >= :fecha_ini` | Fecha inicio del rango |
| `:fecha_fin` | `WHERE fecha <= :fecha_fin` | Fecha fin del rango |
---
## Estructura del proyecto
```
rips_manager/
├── main.py # Entry point, middleware, rutas
├── requirements.txt # Dependencias Python
├── rips_manager.db # SQLite (autogenerado)
├── DOCUMENTACION.md # Este archivo
├── README.md # Documentación de RIPS
├── ejemplo_terceros.json # JSON de ejemplo terceros
├── ejemplo_transaccion_rips.json # JSON de ejemplo transacción
├── app/
│ ├── __init__.py
│ ├── auth.py # JWT, bcrypt, get_current_user
│ ├── database.py # SQLite init y conexión
│ ├── models.py # Pydantic models
│ ├── routes/
│ │ ├── auth.py # Login, register, logout
│ │ ├── dashboard.py # Página principal con stats
│ │ ├── config.py # Configuración Firebird + API
│ │ ├── queries.py # CRUD de consultas SQL
│ │ ├── terceros.py # Generar y enviar JSON terceros
│ │ ├── transaccion.py # Generar y enviar JSON transacción
│ │ ├── automation.py # Paso 1 + Paso 2 automático
│ │ └── logs.py # Historial de envíos
│ ├── services/
│ │ ├── firebird_service.py # Conexión y consultas Firebird
│ │ ├── json_generator.py # Construcción de JSON RIPS
│ │ └── api_client.py # Envío HTTP a API externa
│ └── templates/ # 10 plantillas Jinja2 + Tailwind
```
---
## Cómo ejecutar
```bash
cd /Users/lizandro/Documents/proyectos/rips_manager
pip3 install -r requirements.txt
python3 main.py
```
Abrir en el navegador: **http://localhost:8080**
---
## Secuencia para primer uso
1. Abrir http://localhost:8080/auth/register
2. Crear usuario y contraseña
3. Iniciar sesión
4. Ir a **Configuración** → configurar Firebird, API, datos del prestador
5. Ir a **Consultas SQL** → ajustar las queries a los nombres reales de tablas
6. Usar **Terceros**, **Transacción RIPS** o **Automatización**
View File
+66
View File
@@ -0,0 +1,66 @@
import bcrypt
from datetime import datetime, timedelta
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET_KEY = "rips-manager-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 12
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
def create_token(user_id: int, username: str) -> str:
payload = {
"user_id": user_id,
"username": username,
"exp": datetime.utcnow() + 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])
return payload
except JWTError:
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",
)
payload = decode_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
return payload
def optional_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
if hasattr(request.state, "user") and request.state.user:
return request.state.user
if credentials is None:
return None
return decode_token(credentials.credentials)
+73
View File
@@ -0,0 +1,73 @@
import sqlite3
import os
from datetime import datetime
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "rips_manager.db")
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_connection()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT
);
CREATE TABLE IF NOT EXISTS queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion')),
query_text TEXT NOT NULL,
description TEXT,
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,
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion')),
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,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
""")
conn.commit()
conn.close()
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserLogin(BaseModel):
username: str
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
View File
+96
View File
@@ -0,0 +1,96 @@
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from app.database import get_connection
from app.auth import hash_password, verify_password, create_token, get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login")
async def login_page(request: Request):
return request.app.state.templates.TemplateResponse("login.html", {"request": request})
@router.get("/register")
async def register_page(request: Request):
return request.app.state.templates.TemplateResponse("register.html", {"request": request})
@router.post("/register")
async def register(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
):
if password != confirm_password:
return request.app.state.templates.TemplateResponse("register.html", {
"request": request, "error": "Las contraseñas no coinciden"
})
conn = get_connection()
try:
existing = conn.execute(
"SELECT id FROM users WHERE username = ? OR email = ?",
(username, email)
).fetchone()
if existing:
return request.app.state.templates.TemplateResponse("register.html", {
"request": request, "error": "Usuario o email ya registrado"
})
conn.execute(
"INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
(username, email, hash_password(password))
)
conn.commit()
return RedirectResponse("/auth/login", status_code=302)
finally:
conn.close()
@router.post("/login")
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
conn = get_connection()
try:
user = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
if not user or not verify_password(password, user["password_hash"]):
return request.app.state.templates.TemplateResponse("login.html", {
"request": request, "error": "Usuario o contraseña incorrectos"
})
token = create_token(user["id"], user["username"])
resp = RedirectResponse("/dashboard", status_code=302)
resp.set_cookie(key="token", value=token, httponly=True, max_age=43200)
return resp
finally:
conn.close()
@router.get("/logout")
async def logout():
resp = RedirectResponse("/auth/login", status_code=302)
resp.delete_cookie("token")
return resp
@router.post("/api/login")
async def api_login(username: str = Form(...), password: str = Form(...)):
conn = get_connection()
try:
user = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
if not user or not verify_password(password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(user["id"], user["username"])
return {"access_token": token, "token_type": "bearer"}
finally:
conn.close()
+204
View File
@@ -0,0 +1,204 @@
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
router = APIRouter(prefix="/automation", tags=["automation"])
@router.get("")
async def automation_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
queries = conn.execute("SELECT * FROM queries ORDER BY query_type, name").fetchall()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
return request.app.state.templates.TemplateResponse("automation.html", {
"request": request, "user": user,
"queries": queries, "configs": configs,
})
@router.post("/run")
async def run_automation(
request: Request,
user: dict = Depends(get_current_user),
query_terceros_id: int = Form(...),
query_transaccion_id: int = Form(...),
fecha_inicio: str = Form(...),
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()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
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:
return JSONResponse({"success": False, "message": f"Error Firebird: {fb_msg}"})
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}"
resultado = {"paso1_terceros": {"status": "pendiente"}, "paso2_transaccion": {"status": "pendiente"}}
# ---------------------------------------------------------------
# PASO 1: Enviar TERCEROS
# ---------------------------------------------------------------
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q_terceros["query_text"] and factura:
params["factura"] = factura
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)
if not success:
resultado["paso1_terceros"] = {"status": "error", "message": error}
elif not rows:
resultado["paso1_terceros"] = {"status": "error", "message": "No hay pacientes para enviar"}
else:
terceros_enviados = 0
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)
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)
if resp.is_success:
terceros_enviados += 1
else:
terceros_errores += 1
except Exception as e:
terceros_errores += 1
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()
resultado["paso1_terceros"] = {
"status": "success" if terceros_errores == 0 else "partial",
"enviados": terceros_enviados,
"errores": terceros_errores,
}
# ---------------------------------------------------------------
# PASO 2: Enviar TRANSACCION
# ---------------------------------------------------------------
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q_trans["query_text"] and factura:
params["factura"] = factura
success, error, rows = fb.execute_query(q_trans["query_text"], params)
if not success:
resultado["paso2_transaccion"] = {"status": "error", "message": error}
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))
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"],
)
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
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:
status_ok = False
resp_text = str(e)
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()
resultado["paso2_transaccion"] = {
"status": "success" if trans_errores == 0 else "partial",
"enviados": trans_enviados,
"errores": trans_errores,
}
fb.disconnect()
return JSONResponse({"success": True, "resultado": resultado})
+57
View File
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import RedirectResponse
from app.database import get_connection
from app.auth import get_current_user
router = APIRouter(prefix="/config", tags=["config"])
DEFAULT_KEYS = [
("firebird_host", "localhost"),
("firebird_port", "3050"),
("firebird_database", "/path/to/database.fdb"),
("firebird_user", "SYSDBA"),
("firebird_password", "masterkey"),
("api_url", "https://api.example.com/rips"),
("api_method", "POST"),
("api_key", ""),
("api_timeout", "30"),
("num_documento_obligado", ""),
("cod_prestador", ""),
]
def ensure_defaults():
conn = get_connection()
for key, default in DEFAULT_KEYS:
exists = conn.execute("SELECT id FROM config WHERE key = ?", (key,)).fetchone()
if not exists:
conn.execute("INSERT INTO config (key, value) VALUES (?, ?)", (key, default))
conn.commit()
conn.close()
@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()
return request.app.state.templates.TemplateResponse("config.html", {
"request": request, "user": user, "configs": configs
})
@router.post("/save")
async def config_save(request: Request, user: dict = Depends(get_current_user)):
form = await request.form()
conn = get_connection()
for key, value in form.multi_items():
if key.startswith("config_"):
real_key = key.replace("config_", "", 1)
conn.execute(
"UPDATE config SET value = ? WHERE key = ?",
(value, real_key)
)
conn.commit()
conn.close()
return RedirectResponse("/config", status_code=302)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Request, Depends
from app.database import get_connection
from app.auth import get_current_user
router = APIRouter(tags=["dashboard"])
@router.get("/dashboard")
async def dashboard(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
try:
stats = conn.execute("""
SELECT
(SELECT COUNT(*) FROM envios WHERE status = 'success') as total_exitosos,
(SELECT COUNT(*) FROM envios WHERE status = 'error') as total_errores,
(SELECT COUNT(*) FROM envios WHERE tipo = 'terceros') as total_terceros,
(SELECT COUNT(*) FROM envios WHERE tipo = 'transaccion') as total_transacciones,
(SELECT COUNT(DISTINCT factura) FROM envios WHERE factura IS NOT NULL) as total_facturas
""").fetchone()
ultimos = conn.execute("""
SELECT e.*, u.username FROM envios e
LEFT JOIN users u ON e.user_id = u.id
ORDER BY e.created_at DESC LIMIT 10
""").fetchall()
return request.app.state.templates.TemplateResponse("dashboard.html", {
"request": request, "user": user, "stats": stats, "ultimos": ultimos
})
finally:
conn.close()
+55
View File
@@ -0,0 +1,55 @@
from fastapi import APIRouter, Request, Depends
from app.database import get_connection
from app.auth import get_current_user
router = APIRouter(prefix="/logs", tags=["logs"])
@router.get("")
async def logs_page(
request: Request,
user: dict = Depends(get_current_user),
tipo: str = "",
status: str = "",
factura: str = "",
):
conn = get_connection()
where = ["1=1"]
params = []
if tipo:
where.append("e.tipo = ?")
params.append(tipo)
if status:
where.append("e.status = ?")
params.append(status)
if factura:
where.append("e.factura LIKE ?")
params.append(f"%{factura}%")
envios = conn.execute(f"""
SELECT e.*, u.username FROM envios e
LEFT JOIN users u ON e.user_id = u.id
WHERE {' AND '.join(where)}
ORDER BY e.created_at DESC LIMIT 100
""", params).fetchall()
stats = conn.execute("""
SELECT
tipo,
status,
COUNT(*) as total,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as exitosos,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as fallidos
FROM envios
GROUP BY tipo, status
""").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("logs.html", {
"request": request, "user": user,
"envios": envios, "stats": stats,
"filtro_tipo": tipo, "filtro_status": status, "filtro_factura": factura,
})
+152
View File
@@ -0,0 +1,152 @@
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("/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)
+191
View File
@@ -0,0 +1,191 @@
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.services.firebird_service import FirebirdService
from app.services.json_generator import generar_terceros
router = APIRouter(prefix="/terceros", tags=["terceros"])
@router.get("")
async def terceros_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
envios = conn.execute("""
SELECT * FROM envios WHERE tipo = 'terceros'
ORDER BY created_at DESC LIMIT 20
""").fetchall()
queries = conn.execute(
"SELECT * FROM queries WHERE query_type = 'terceros' ORDER BY name"
).fetchall()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
return request.app.state.templates.TemplateResponse("terceros.html", {
"request": request, "user": user,
"envios": envios, "queries": queries,
"configs": configs,
"firebird_host": configs.get("firebird_host", "localhost"),
"firebird_port": configs.get("firebird_port", "3050"),
"firebird_database": configs.get("firebird_database", ""),
})
@router.post("/test-connection")
async def test_connection(
request: Request,
user: dict = Depends(get_current_user),
host: str = Form(...),
port: int = Form(...),
database: str = Form(...),
fb_user: str = Form(...),
fb_password: str = Form(...),
):
fb = FirebirdService()
success, msg = fb.connect(host, port, database, fb_user, fb_password)
if success:
fb.disconnect()
return JSONResponse({"success": success, "message": msg})
@router.post("/preview")
async def preview_query(
request: Request,
user: dict = Depends(get_current_user),
query_id: int = Form(...),
doc_num: str = Form(""),
):
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({"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}"})
params = {}
if ":doc_num" in q["query_text"] and doc_num:
params["doc_num"] = doc_num
if ":fecha_ini" in q["query_text"]:
params["fecha_ini"] = "1900-01-01"
params["fecha_fin"] = "2100-12-31"
if ":factura" in q["query_text"]:
params["factura"] = doc_num if doc_num else ""
success, error, rows = fb.execute_query(q["query_text"], params if params else None)
fb.disconnect()
if not success:
return JSONResponse({"success": False, "message": error})
json_result = None
if rows:
json_result = generar_terceros(rows[0])
return JSONResponse({
"success": True,
"rows_count": len(rows),
"columns": list(rows[0].keys()) if rows else [],
"preview": rows[:5],
"generated_json": json_result,
})
@router.post("/send")
async def send_terceros(
request: Request,
user: dict = Depends(get_current_user),
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()}
conn.close()
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}"})
params = {}
if ":doc_num" in q["query_text"]:
params["doc_num"] = doc_num or configs.get("doc_num_default", "")
success, error, rows = fb.execute_query(q["query_text"], params if params else None)
fb.disconnect()
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}"
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
except Exception as e:
result = (0, False, 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",
json.dumps(tercero_json, indent=2, ensure_ascii=False),
str(result[2])[: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,
})
+218
View File
@@ -0,0 +1,218 @@
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
router = APIRouter(prefix="/transaccion", tags=["transaccion"])
@router.get("")
async def transaccion_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
envios = conn.execute("""
SELECT * FROM envios WHERE tipo = 'transaccion'
ORDER BY created_at DESC LIMIT 20
""").fetchall()
queries = conn.execute(
"SELECT * FROM queries WHERE query_type = 'transaccion' ORDER BY name"
).fetchall()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
return request.app.state.templates.TemplateResponse("transaccion.html", {
"request": request, "user": user,
"envios": envios, "queries": queries,
"configs": configs,
})
@router.post("/preview")
async def preview_transaccion(
request: Request,
user: dict = Depends(get_current_user),
query_id: int = Form(...),
factura: str = Form(""),
fecha_inicio: str = Form(...),
fecha_fin: str = Form(...),
):
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({"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}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura:
params["factura"] = factura
success, error, rows = fb.execute_query(q["query_text"], params)
fb.disconnect()
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))
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)
return JSONResponse({
"success": True,
"rows_count": len(rows),
"grupos_count": len(grupos),
"columns": list(rows[0].keys()) if rows else [],
"preview": rows[:5],
"generated_json": json_result[0] if json_result else None,
"total_json": len(json_result),
})
@router.post("/send")
async def send_transaccion(
request: Request,
user: dict = Depends(get_current_user),
query_id: int = Form(...),
factura: str = Form(""),
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()}
conn.close()
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}"})
params = {"fecha_ini": fecha_inicio, "fecha_fin": fecha_fin}
if ":factura" in q["query_text"] and factura:
params["factura"] = factura
success, error, rows = fb.execute_query(q["query_text"], params)
fb.disconnect()
if not success:
return JSONResponse({"success": False, "message": error})
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))
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}"
total_enviados = 0
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"],
)
try:
async with httpx.AsyncClient(timeout=int(configs.get("api_timeout", 30))) as client:
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:
status_ok = False
response_text = str(e)
if status_ok:
total_enviados += 1
else:
total_errores += 1
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()
return JSONResponse({
"success": total_errores == 0,
"total_enviados": total_enviados,
"total_errores": total_errores,
"resultados": resultados,
"message": f"Enviados: {total_enviados}, Errores: {total_errores}",
})
View File
+35
View File
@@ -0,0 +1,35 @@
import httpx
import json
from typing import Optional
async def send_json(
url: str,
json_data: dict,
method: str = "POST",
headers: Optional[dict] = None,
timeout: int = 30,
) -> dict:
default_headers = {"Content-Type": "application/json"}
if headers:
default_headers.update(headers)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
if method == "POST":
resp = await client.post(url, json=json_data, headers=default_headers)
elif method == "PUT":
resp = await client.put(url, json=json_data, headers=default_headers)
else:
resp = await client.get(url, headers=default_headers)
return {
"status_code": resp.status_code,
"success": resp.is_success,
"body": resp.text,
"headers": dict(resp.headers),
}
except httpx.TimeoutException:
return {"status_code": 0, "success": False, "body": "Timeout", "headers": {}}
except Exception as e:
return {"status_code": 0, "success": False, "body": str(e), "headers": {}}
+55
View File
@@ -0,0 +1,55 @@
import fdb
from typing import Optional
class FirebirdService:
def __init__(self):
self.conn = None
def connect(self, host: str, port: int, database: str, user: str, password: str):
try:
self.conn = fdb.connect(
host=host,
port=port,
database=database,
user=user,
password=password,
charset="UTF8",
)
return True, "Conexión exitosa"
except Exception as e:
return False, str(e)
def disconnect(self):
if self.conn:
self.conn.close()
self.conn = None
def is_connected(self) -> bool:
return self.conn is not None
def test_connection(self) -> tuple:
if not self.conn:
return False, "No hay conexión activa"
try:
cur = self.conn.cursor()
cur.execute("SELECT 1 FROM RDB$DATABASE")
cur.fetchone()
return True, "Conexión OK"
except Exception as e:
return False, str(e)
def execute_query(self, query: str, params: Optional[dict] = None) -> tuple:
if not self.conn:
return False, "No hay conexión activa", []
try:
cur = self.conn.cursor()
if params:
cur.execute(query, params)
else:
cur.execute(query)
columns = [desc[0] for desc in cur.description] if cur.description else []
rows = cur.fetchall()
return True, "", [dict(zip(columns, row)) for row in rows]
except Exception as e:
return False, str(e), []
+89
View File
@@ -0,0 +1,89 @@
from datetime import datetime
from typing import Optional
def generar_terceros(row: dict) -> dict:
return {
"tipoDocumentoIdentificacion": row.get("tipo_documento", "CC"),
"numDocumentoIdentificacion": row.get("numero_documento", ""),
"primerNombre": (row.get("primer_nombre") or "").upper(),
"segundoNombre": (row.get("segundo_nombre") or "").upper(),
"primerApellido": (row.get("primer_apellido") or "").upper(),
"segundoApellido": (row.get("segundo_apellido") or "").upper(),
"fechaNacimiento": str(row.get("fecha_nacimiento", ""))[:10],
"codSexo": row.get("cod_sexo", "M"),
"codEntidadAdministradora": row.get("cod_entidad", ""),
"tipoUsuario": row.get("tipo_usuario", "01"),
"codPaisResidencia": row.get("cod_pais", "170"),
"codMunicipioResidencia": row.get("cod_municipio", ""),
"codZonaTerritorialResidencia": row.get("cod_zona", "01"),
"incapacidad": row.get("incapacidad", "NO"),
"codPaisOrigen": row.get("cod_pais_origen", "170"),
"direccionResidencia": row.get("direccion", ""),
"codZonaResidencia": row.get("cod_zona", "01"),
}
def generar_procedimiento(row: dict, consecutivo: int) -> dict:
return {
"consecutivo": consecutivo,
"codProcedimiento": row.get("cod_procedimiento", ""),
"fechaInicioAtencion": str(row.get("fecha_atencion", datetime.now().strftime("%Y-%m-%d %H:%M")))[:16],
"codDiagnosticoPrincipal": row.get("cod_diagnostico", "R790"),
"codDiagnosticoRelacionado": row.get("cod_diagnostico_rel", None),
"finalidadTecnologiaSalud": row.get("finalidad", "23"),
"viaIngresoServicioSalud": row.get("via_ingreso", "02"),
"modalidadGrupoServicioTecSal": row.get("modalidad", "01"),
"grupoServicios": row.get("grupo_servicio", "02"),
"codServicio": int(row.get("cod_servicio", 706)),
"codPrestador": row.get("cod_prestador", ""),
"tipoDocumentoIdentificacion": row.get("tipo_doc_profesional", "CC"),
"numDocumentoIdentificacion": row.get("num_doc_profesional", ""),
"vrServicio": int(row.get("vr_servicio", 0)),
"valorPagoModerador": int(row.get("valor_pago_moderador", 0)),
"conceptoRecaudo": row.get("concepto_recaudo", "05"),
"numAutorizacion": row.get("num_autorizacion", None),
"idMIPRES": row.get("id_mipres", None),
"codComplicacion": row.get("cod_complicacion", None),
"numFEVPagoModerador": row.get("num_fev_pago_moderador", None),
}
def generar_transaccion(
factura: str,
num_doc_obligado: str,
rows_tercero: Optional[dict],
rows_procedimientos: list,
) -> dict:
transaccion = {
"numDocumentoIdObligado": num_doc_obligado,
"numFactura": factura,
"tipoNota": None,
"numNota": None,
"usuarios": [],
}
if rows_tercero and rows_procedimientos:
usuario = {
"tipoDocumentoIdentificacion": rows_tercero.get("tipoDocumentoIdentificacion", "CC"),
"numDocumentoIdentificacion": rows_tercero.get("numDocumentoIdentificacion", ""),
"codEntidadAdministradora": rows_tercero.get("codEntidadAdministradora", ""),
"tipoUsuario": rows_tercero.get("tipoUsuario", "01"),
"fechaNacimiento": rows_tercero.get("fechaNacimiento", ""),
"codSexo": rows_tercero.get("codSexo", "M"),
"codPaisResidencia": rows_tercero.get("codPaisResidencia", "170"),
"codMunicipioResidencia": rows_tercero.get("codMunicipioResidencia", ""),
"codZonaTerritorialResidencia": rows_tercero.get("codZonaTerritorialResidencia", "01"),
"incapacidad": rows_tercero.get("incapacidad", "NO"),
"consecutivo": 1,
"codPaisOrigen": rows_tercero.get("codPaisOrigen", "170"),
"servicios": {
"procedimientos": [
generar_procedimiento(p, i + 1)
for i, p in enumerate(rows_procedimientos)
]
},
}
transaccion["usuarios"].append(usuario)
return transaccion
+163
View File
@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Automatización{% endblock %}
{% block header %}Automatización Completa{% endblock %}
{% block content %}
<div class="max-w-4xl">
<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-robot mr-2 text-blue-500"></i>Paso 1 + Paso 2 Automático</h3>
<p class="text-xs text-gray-500 mt-1">Selecciona las consultas y el rango de fechas. El sistema enviará primero los terceros y luego las transacciones RIPS automáticamente.</p>
</div>
<div class="p-6">
<form id="form-automation" class="space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="p-4 bg-blue-50 rounded-xl border border-blue-200">
<div class="flex items-center mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">1</div>
<span class="ml-2 font-medium text-blue-800">Paso 1: Terceros</span>
</div>
<select name="query_terceros_id" required
class="w-full px-3 py-2 border border-blue-300 rounded-lg text-sm">
<option value="">Seleccionar consulta...</option>
{% for q in queries if q.query_type == 'terceros' %}
<option value="{{ q.id }}">{{ q.name }}</option>
{% endfor %}
</select>
</div>
<div class="p-4 bg-purple-50 rounded-xl border border-purple-200">
<div class="flex items-center mb-3">
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white text-sm font-bold">2</div>
<span class="ml-2 font-medium text-purple-800">Paso 2: Transacción RIPS</span>
</div>
<select name="query_transaccion_id" required
class="w-full px-3 py-2 border border-purple-300 rounded-lg text-sm">
<option value="">Seleccionar consulta...</option>
{% for q in queries if q.query_type == 'transaccion' %}
<option value="{{ q.id }}">{{ q.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha Inicio</label>
<input type="date" name="fecha_inicio" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha Fin</label>
<input type="date" name="fecha_fin" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Factura <span class="text-xs text-gray-400">(opcional)</span></label>
<input type="text" name="factura"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Filtrar por factura">
</div>
</div>
<button type="button" onclick="runAutomation()"
class="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium rounded-lg transition-all flex items-center justify-center">
<i class="fas fa-play mr-2"></i> Ejecutar Automatización Completa
</button>
</form>
</div>
</div>
<!-- Progress -->
<div id="progress-section" class="mt-6 hidden">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h4 class="font-semibold text-gray-800 mb-4"><i class="fas fa-spinner fa-spin mr-2 text-blue-500"></i>Progreso</h4>
<div class="space-y-4">
<div id="step1" class="p-4 rounded-lg border border-gray-200">
<div class="flex items-center">
<div id="step1-icon" class="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center text-sm">1</div>
<div class="ml-3">
<p class="font-medium text-gray-800">Paso 1: Envío de Terceros</p>
<p id="step1-msg" class="text-sm text-gray-500">Esperando...</p>
</div>
</div>
</div>
<div id="step2" class="p-4 rounded-lg border border-gray-200">
<div class="flex items-center">
<div id="step2-icon" class="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center text-sm">2</div>
<div class="ml-3">
<p class="font-medium text-gray-800">Paso 2: Envío de Transacciones</p>
<p id="step2-msg" class="text-sm text-gray-500">Esperando...</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
async function runAutomation() {
if (!confirm('¿Ejecutar la automatización completa?\n\nPaso 1: Enviar terceros\nPaso 2: Enviar transacciones RIPS')) return;
const form = document.getElementById('form-automation');
const data = new FormData(form);
const btn = form.querySelector('button[onclick="runAutomation()"]');
showLoading(btn);
document.getElementById('progress-section').classList.remove('hidden');
updateStep('step1', 'processing', 'Enviando terceros...');
const resp = await fetch('/automation/run', {method:'POST', body: data, credentials: 'include'});
const result = await resp.json();
hideLoading(btn, '<i class="fas fa-play mr-2"></i> Ejecutar Automatización Completa');
if (result.success) {
const r = result.resultado;
if (r.paso1_terceros.status === 'success') {
updateStep('step1', 'success', `${r.paso1_terceros.enviados} terceros enviados`);
} else if (r.paso1_terceros.status === 'partial') {
updateStep('step1', 'warning', `⚠️ ${r.paso1_terceros.enviados} enviados, ${r.paso1_terceros.errores} errores`);
} else {
updateStep('step1', 'error', `${r.paso1_terceros.message || 'Error'}`);
}
if (r.paso2_transaccion.status === 'success') {
updateStep('step2', 'success', `${r.paso2_transaccion.enviados} transacciones enviadas`);
} else if (r.paso2_transaccion.status === 'partial') {
updateStep('step2', 'warning', `⚠️ ${r.paso2_transaccion.enviados} enviados, ${r.paso2_transaccion.errores} errores`);
} else {
updateStep('step2', 'error', `${r.paso2_transaccion.message || 'Error'}`);
}
showToast('Automatización completada', 'success');
} else {
updateStep('step1', 'error', 'Error en la automatización');
showToast('Error en la automatización', 'error');
}
}
function updateStep(id, status, msg) {
const step = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
const msgEl = document.getElementById(id + '-msg');
const icons = {
processing: '<i class="fas fa-spinner fa-spin text-blue-500"></i>',
success: '<i class="fas fa-check text-green-500"></i>',
warning: '<i class="fas fa-exclamation-triangle text-yellow-500"></i>',
error: '<i class="fas fa-times text-red-500"></i>',
};
const borders = {
processing: 'border-blue-300 bg-blue-50',
success: 'border-green-300 bg-green-50',
warning: 'border-yellow-300 bg-yellow-50',
error: 'border-red-300 bg-red-50',
};
icon.innerHTML = icons[status] || '1';
step.className = `p-4 rounded-lg border ${borders[status] || 'border-gray-200'}`;
msgEl.textContent = msg;
}
</script>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="es" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RIPS Manager - {% block title %}Dashboard{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {'50': '#eff6ff','100': '#dbeafe','200': '#bfdbfe','300': '#93c5fd','400': '#60a5fa','500': '#3b82f6','600': '#2563eb','700': '#1d4ed8','800': '#1e40af','900': '#1e3a8a'}
}
}
}
}
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body class="h-full">
<div class="min-h-full">
{% if user %}
<!-- Sidebar -->
<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">
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
<span class="font-bold text-lg">RIPS Manager</span>
</div>
<nav class="mt-4 px-3 space-y-1">
<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 %}">
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
</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 %}">
<i class="fas fa-cog w-5 mr-2"></i> Configuración
</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 %}">
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL
</a>
<hr class="my-3 border-gray-700">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Envíos</p>
<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>
<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 %}">
<i class="fas fa-history w-5 mr-2"></i> Historial
</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">
<i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir
</a>
</nav>
<div class="absolute bottom-0 left-0 right-0 px-4 py-3 border-t border-gray-700">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm font-bold">
{{ user.username[0]|upper }}
</div>
<div class="ml-3">
<p class="text-sm font-medium text-white">{{ user.username }}</p>
<p class="text-xs text-gray-400">Sesión activa</p>
</div>
</div>
</div>
</div>
<!-- Main content -->
<div class="pl-64">
<header class="bg-white shadow-sm border-b border-gray-200">
<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>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">
<i class="far fa-calendar-alt mr-1"></i>
<script>document.write(new Date().toLocaleDateString('es-CO', {year:'numeric',month:'long',day:'numeric'}))</script>
</span>
</div>
</div>
</header>
<main class="p-8">
{% block content %}{% endblock %}
</main>
</div>
{% else %}
<main>
{% block auth_content %}{% endblock %}
</main>
{% endif %}
</div>
<script>
async function fetchJSON(url, options = {}) {
const resp = await fetch(url, options);
return await resp.json();
}
function showToast(message, type = 'success') {
const colors = {success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500', warning: 'bg-yellow-500'};
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transition-all duration-500`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 4000);
}
function showLoading(btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Procesando...';
}
function hideLoading(btn, text) {
btn.disabled = false;
btn.innerHTML = text;
}
</script>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
{% extends "base.html" %}
{% block title %}Configuración{% endblock %}
{% block header %}Configuración{% endblock %}
{% block content %}
<div class="max-w-3xl">
<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-database mr-2 text-blue-500"></i>Conexión Firebird</h3>
</div>
<div class="p-6">
<form method="POST" action="/config/save" class="space-y-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Host</label>
<input type="text" name="config_firebird_host"
value="{{ configs|selectattr('key', 'equalto', 'firebird_host')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Puerto</label>
<input type="text" name="config_firebird_port"
value="{{ configs|selectattr('key', 'equalto', 'firebird_port')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Base de Datos (ruta)</label>
<input type="text" name="config_firebird_database"
value="{{ configs|selectattr('key', 'equalto', 'firebird_database')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Usuario</label>
<input type="text" name="config_firebird_user"
value="{{ configs|selectattr('key', 'equalto', 'firebird_user')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<input type="password" name="config_firebird_password"
value="{{ configs|selectattr('key', 'equalto', 'firebird_password')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
</div>
<div class="flex items-center space-x-3">
<button type="button" onclick="testFirebird()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium">
<i class="fas fa-plug mr-1"></i> Probar Conexión
</button>
<span id="fb-status" class="text-sm"></span>
</div>
<hr class="border-gray-200">
<h4 class="font-medium text-gray-800"><i class="fas fa-cloud mr-2 text-blue-500"></i>API de Envío</h4>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">URL Base de la API</label>
<input type="text" name="config_api_url"
value="{{ configs|selectattr('key', 'equalto', 'api_url')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
placeholder="https://api.ejemplo.com">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Método HTTP</label>
<select name="config_api_method"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
<option value="POST" {% if (configs|selectattr('key', 'equalto', 'api_method')|map(attribute='value')|first) == 'POST' %}selected{% endif %}>POST</option>
<option value="PUT" {% if (configs|selectattr('key', 'equalto', 'api_method')|map(attribute='value')|first) == 'PUT' %}selected{% endif %}>PUT</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Timeout (seg)</label>
<input type="number" name="config_api_timeout"
value="{{ configs|selectattr('key', 'equalto', 'api_timeout')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key (Bearer token)</label>
<input type="text" name="config_api_key"
value="{{ configs|selectattr('key', 'equalto', 'api_key')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<hr class="border-gray-200">
<h4 class="font-medium text-gray-800"><i class="fas fa-building mr-2 text-blue-500"></i>Datos del Prestador</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">NIT / Documento Obligado</label>
<input type="text" name="config_num_documento_obligado"
value="{{ configs|selectattr('key', 'equalto', 'num_documento_obligado')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Código Prestador</label>
<input type="text" name="config_cod_prestador"
value="{{ configs|selectattr('key', 'equalto', 'cod_prestador')|map(attribute='value')|first }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm">
</div>
</div>
<button type="submit"
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
</button>
</form>
</div>
</div>
</div>
<script>
async function testFirebird() {
const btn = event.target;
const status = document.getElementById('fb-status');
btn.disabled = true;
status.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Probando...';
const form = btn.closest('form');
const data = new FormData();
data.append('host', form.querySelector('[name="config_firebird_host"]').value);
data.append('port', form.querySelector('[name="config_firebird_port"]').value);
data.append('database', form.querySelector('[name="config_firebird_database"]').value);
data.append('fb_user', form.querySelector('[name="config_firebird_user"]').value);
data.append('fb_password', form.querySelector('[name="config_firebird_password"]').value);
const resp = await fetch('/terceros/test-connection', {method:'POST', body: data});
const result = await resp.json();
btn.disabled = false;
status.innerHTML = result.success
? '<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>';
}
</script>
{% endblock %}
+139
View File
@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block header %}Dashboard{% endblock %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-green-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 font-medium">Envíos Exitosos</p>
<p class="text-2xl font-bold text-gray-800">{{ stats.total_exitosos or 0 }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 font-medium">Envíos Fallidos</p>
<p class="text-2xl font-bold text-gray-800">{{ stats.total_errores or 0 }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-user text-blue-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 font-medium">Terceros</p>
<p class="text-2xl font-bold text-gray-800">{{ stats.total_terceros or 0 }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-invoice text-purple-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 font-medium">Transacciones</p>
<p class="text-2xl font-bold text-gray-800">{{ stats.total_transacciones or 0 }}</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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-history mr-2 text-blue-500"></i>Últimos Envíos</h3>
</div>
<div class="p-6">
{% if ultimos %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b border-gray-100">
<th class="pb-3 font-medium">Tipo</th>
<th class="pb-3 font-medium">Factura</th>
<th class="pb-3 font-medium">Estado</th>
<th class="pb-3 font-medium">Fecha</th>
<th class="pb-3 font-medium">Usuario</th>
</tr>
</thead>
<tbody>
{% for e in ultimos %}
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="py-3">
<span class="px-2 py-1 rounded text-xs font-medium {% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ e.tipo }}
</span>
</td>
<td class="py-3 text-gray-600">{{ e.factura or '-' }}</td>
<td class="py-3">
<span class="px-2 py-1 rounded text-xs font-medium {% if e.status == 'success' %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
{{ 'Exitoso' if e.status == 'success' else 'Error' }}
</span>
</td>
<td class="py-3 text-gray-500 text-xs">{{ e.created_at[:19] }}</td>
<td class="py-3 text-gray-500">{{ e.username }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-8 text-gray-400">
<i class="fas fa-inbox text-4xl mb-3 block"></i>
<p>No hay envíos registrados</p>
<p class="text-xs mt-1">Usa el menú lateral para comenzar</p>
</div>
{% endif %}
</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">
<h3 class="font-semibold text-gray-800"><i class="fas fa-rocket mr-2 text-blue-500"></i>Acciones Rápidas</h3>
</div>
<div class="p-6 space-y-4">
<a href="/terceros" class="block p-4 bg-blue-50 rounded-xl hover:bg-blue-100 transition-colors">
<div class="flex items-center">
<i class="fas fa-user text-blue-500 text-xl mr-4"></i>
<div>
<p class="font-medium text-gray-800">Enviar Terceros</p>
<p class="text-xs text-gray-500">Genera y envía datos maestros de pacientes</p>
</div>
<i class="fas fa-chevron-right text-blue-400 ml-auto"></i>
</div>
</a>
<a href="/transaccion" class="block p-4 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors">
<div class="flex items-center">
<i class="fas fa-exchange-alt text-purple-500 text-xl mr-4"></i>
<div>
<p class="font-medium text-gray-800">Enviar Transacción RIPS</p>
<p class="text-xs text-gray-500">Genera y envía procedimientos por factura</p>
</div>
<i class="fas fa-chevron-right text-purple-400 ml-auto"></i>
</div>
</a>
<a href="/automation" class="block p-4 bg-green-50 rounded-xl hover:bg-green-100 transition-colors">
<div class="flex items-center">
<i class="fas fa-robot text-green-500 text-xl mr-4"></i>
<div>
<p class="font-medium text-gray-800">Automatización Completa</p>
<p class="text-xs text-gray-500">Paso 1 (terceros) + Paso 2 (transacción) automático</p>
</div>
<i class="fas fa-chevron-right text-green-400 ml-auto"></i>
</div>
</a>
</div>
</div>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Iniciar Sesión{% endblock %}
{% block auth_content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-900">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-2xl p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
<i class="fas fa-file-medical text-blue-600 text-2xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-800">RIPS Manager</h2>
<p class="text-sm text-gray-500 mt-1">Gestión de envío de RIPS</p>
</div>
{% if error %}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i> {{ error }}
</div>
{% endif %}
<form method="POST" action="/auth/login" class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Usuario</label>
<div class="relative">
<i class="fas fa-user absolute left-3 top-3 text-gray-400"></i>
<input type="text" name="username" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Ingresa tu usuario">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<div class="relative">
<i class="fas fa-lock absolute left-3 top-3 text-gray-400"></i>
<input type="password" name="password" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Ingresa tu contraseña">
</div>
</div>
<button type="submit"
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i> Iniciar Sesión
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
¿No tienes cuenta? <a href="/auth/register" class="text-blue-600 hover:underline font-medium">Regístrate</a>
</p>
</div>
<p class="text-center text-white/70 text-xs mt-4">RIPS Manager v1.0 — Resolución 2275/2023</p>
</div>
</div>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Historial{% endblock %}
{% block header %}Historial de Envíos{% endblock %}
{% block content %}
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<form class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo</label>
<select name="tipo" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
<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>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Estado</label>
<select name="status" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
<option value="">Todos</option>
<option value="success" {{ 'selected' if filtro_status == 'success' }}>Exitoso</option>
<option value="error" {{ 'selected' if filtro_status == 'error' }}>Error</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Factura</label>
<input type="text" name="factura" value="{{ filtro_factura }}"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm" placeholder="Buscar factura...">
</div>
<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>
</form>
</div>
<div class="p-6">
{% if envios %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b border-gray-200">
<th class="pb-3 font-medium">ID</th>
<th class="pb-3 font-medium">Tipo</th>
<th class="pb-3 font-medium">Factura</th>
<th class="pb-3 font-medium">Estado</th>
<th class="pb-3 font-medium">Pacientes</th>
<th class="pb-3 font-medium">Servicios</th>
<th class="pb-3 font-medium">Respuesta API</th>
<th class="pb-3 font-medium">Fecha</th>
<th class="pb-3 font-medium">Usuario</th>
<th class="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{% for e in envios %}
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="py-3 text-gray-600">{{ e.id }}</td>
<td class="py-3">
<span class="px-2 py-1 rounded text-xs font-medium {% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ e.tipo }}
</span>
</td>
<td class="py-3 text-gray-600 font-medium">{{ e.factura or '-' }}</td>
<td class="py-3">
<span class="px-2 py-1 rounded text-xs font-medium {% if e.status == 'success' %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
{{ 'Exitoso' if e.status == 'success' else 'Error' }}
</span>
</td>
<td class="py-3 text-gray-600">{{ e.pacientes_count or 0 }}</td>
<td class="py-3 text-gray-600">{{ e.servicios_count or 0 }}</td>
<td class="py-3 text-gray-500 text-xs max-w-xs truncate">{{ e.respuesta_api[:80] if e.respuesta_api else '-' }}</td>
<td class="py-3 text-gray-500 text-xs">{{ e.created_at[:19] }}</td>
<td class="py-3 text-gray-500">{{ e.username }}</td>
<td class="py-3">
<button onclick='showDetail({{ e.id|tojson }}, {{ e.json_enviado|tojson if e.json_enviado else "null" }})'
class="text-blue-600 hover:text-blue-800">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12 text-gray-400">
<i class="fas fa-inbox text-5xl mb-4 block"></i>
<p>No hay registros con los filtros seleccionados</p>
</div>
{% endif %}
</div>
</div>
<!-- Modal JSON -->
<div id="json-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/50" onclick="closeModal()"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white rounded-xl shadow-2xl max-h-[80vh] overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="font-semibold text-gray-800"><i class="fas fa-code mr-2 text-blue-500"></i>JSON Enviado</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
<pre id="modal-json" class="text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto"></pre>
</div>
</div>
</div>
<script>
function showDetail(id, json) {
if (!json) { showToast('No hay JSON disponible', 'info'); return; }
const pre = document.getElementById('modal-json');
try {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
pre.innerHTML = syntaxHighlight(obj);
} catch(e) {
pre.textContent = json;
}
document.getElementById('json-modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('json-modal').classList.add('hidden');
}
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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*)(\d+)/g, ': $1<span class="text-orange-600">$2</span>')
.replace(/:(\s*)(null|true|false)/g, ': $1<span class="text-purple-600">$2</span>');
}
</script>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Consultas SQL{% endblock %}
{% block header %}Consultas SQL{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1">
<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-blue-500"></i>Mis Consultas</h3>
<button onclick="document.getElementById('modal-new-query').classList.remove('hidden')"
class="text-blue-600 hover:text-blue-800"><i class="fas fa-plus"></i></button>
</div>
<div class="p-4 space-y-2">
{% for q in queries %}
<div class="p-3 rounded-lg border border-gray-200 hover:border-blue-300 cursor-pointer"
onclick="editQuery({{ q.id }}, '{{ q.name }}', '{{ q.query_type }}', `{{ q.query_text|e }}`, `{{ q.description|e }}`)">
<div class="flex items-center justify-between">
<span class="font-medium text-sm text-gray-800">{{ q.name }}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium {% if q.query_type == 'terceros' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ q.query_type }}
</span>
</div>
<p class="text-xs text-gray-500 mt-1 truncate">{{ q.description or 'Sin descripción' }}</p>
</div>
{% endfor %}
</div>
</div>
</div>
<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">
<h3 class="font-semibold text-gray-800"><i class="fas fa-code mr-2 text-blue-500"></i>Editor SQL</h3>
</div>
<div class="p-6">
<form method="POST" action="/queries/create" class="space-y-4">
<input type="hidden" name="query_id" id="query_id" value="">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" name="name" id="q_name" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Nombre descriptivo">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
<select name="query_type" id="q_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="terceros">Terceros (datos paciente)</option>
<option value="transaccion">Transacción (procedimientos)</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Consulta SQL</label>
<textarea name="query_text" id="q_text" rows="10" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono bg-gray-50"
placeholder="SELECT ... FROM ... WHERE ..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
<input type="text" name="description" id="q_desc"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="¿Qué hace esta consulta?">
</div>
<div class="flex space-x-3">
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
<i class="fas fa-save mr-1"></i> Guardar
</button>
<button type="button" onclick="cancelEdit()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 hidden" id="btn-cancel">
<i class="fas fa-times mr-1"></i> Cancelar
</button>
</div>
</form>
</div>
</div>
<div class="mt-4 bg-blue-50 rounded-xl border border-blue-200 p-4">
<h4 class="text-sm font-medium text-blue-800"><i class="fas fa-info-circle mr-1"></i> Parámetros disponibles</h4>
<p class="text-xs text-blue-600 mt-1">
Usa <code class="bg-blue-100 px-1 rounded">:doc_num</code> para filtro por documento,
<code class="bg-blue-100 px-1 rounded">:factura</code> para filtro por factura,
<code class="bg-blue-100 px-1 rounded">:fecha_ini</code> y <code class="bg-blue-100 px-1 rounded">:fecha_fin</code> para rango de fechas.
</p>
</div>
</div>
</div>
<script>
function editQuery(id, name, type, text, desc) {
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 = desc;
document.getElementById('btn-cancel').classList.remove('hidden');
document.querySelector('form').action = '/queries/update/' + id;
}
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('form').action = '/queries/create';
}
</script>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Registro{% endblock %}
{% block auth_content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-900">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-2xl p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
<i class="fas fa-user-plus text-blue-600 text-2xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-800">Crear Cuenta</h2>
<p class="text-sm text-gray-500 mt-1">Regístrate para gestionar RIPS</p>
</div>
{% if error %}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i> {{ error }}
</div>
{% endif %}
<form method="POST" action="/auth/register" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Usuario</label>
<div class="relative">
<i class="fas fa-user absolute left-3 top-3 text-gray-400"></i>
<input type="text" name="username" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Nombre de usuario">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div class="relative">
<i class="fas fa-envelope absolute left-3 top-3 text-gray-400"></i>
<input type="email" name="email" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="correo@ejemplo.com">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<div class="relative">
<i class="fas fa-lock absolute left-3 top-3 text-gray-400"></i>
<input type="password" name="password" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Mínimo 6 caracteres">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Confirmar Contraseña</label>
<div class="relative">
<i class="fas fa-lock absolute left-3 top-3 text-gray-400"></i>
<input type="password" name="confirm_password" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Repite la contraseña">
</div>
</div>
<button type="submit"
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center">
<i class="fas fa-user-plus mr-2"></i> Crear Cuenta
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
¿Ya tienes cuenta? <a href="/auth/login" class="text-blue-600 hover:underline font-medium">Inicia Sesión</a>
</p>
</div>
</div>
</div>
{% endblock %}
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Terceros{% endblock %}
{% block header %}Envío de Terceros{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<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-blue-500"></i>Generar y Enviar</h3>
</div>
<div class="p-6">
<form id="form-terceros" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Consulta SQL</label>
<select name="query_id" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Seleccionar consulta...</option>
{% for q in queries %}
<option value="{{ q.id }}">{{ q.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Número de Documento <span class="text-xs text-gray-400">(opcional, según consulta)</span>
</label>
<input type="text" name="doc_num"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Ej: 27765610">
</div>
<div class="flex space-x-3">
<button type="button" onclick="previewTerceros()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200">
<i class="fas fa-eye mr-1"></i> Vista Previa
</button>
<button type="button" onclick="sendTerceros()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
<i class="fas fa-paper-plane mr-1"></i> Enviar a API
</button>
</div>
</form>
</div>
</div>
<div class="mt-6 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-eye mr-2 text-blue-500"></i>JSON Generado</h3>
</div>
<div class="p-6">
<pre id="json-preview" class="text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto max-h-96 text-gray-600"><i class="fas fa-info-circle mr-1"></i> Haz clic en "Vista Previa" para ver el JSON</pre>
</div>
</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">
<h3 class="font-semibold text-gray-800"><i class="fas fa-history mr-2 text-blue-500"></i>Últimos Envíos</h3>
</div>
<div class="p-4">
{% if envios %}
<div class="space-y-2">
{% for e in envios %}
<div class="p-3 rounded-lg border border-gray-100 text-sm">
<div class="flex justify-between items-center">
<span class="px-2 py-0.5 rounded text-xs font-medium {% if e.status == 'success' %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
{{ 'Exitoso' if e.status == 'success' else 'Error' }}
</span>
<span class="text-xs text-gray-400">{{ e.created_at[:19] }}</span>
</div>
<p class="text-xs text-gray-500 mt-1 truncate">{{ e.respuesta_api[:100] if e.respuesta_api else 'Sin respuesta' }}</p>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center text-gray-400 py-4 text-sm">No hay envíos de terceros aún</p>
{% endif %}
</div>
</div>
</div>
</div>
<script>
async function previewTerceros() {
const form = document.getElementById('form-terceros');
const data = new FormData(form);
const btn = form.querySelector('[onclick="previewTerceros()"]');
showLoading(btn);
const resp = await fetch('/terceros/preview', {method:'POST', body: data, credentials: 'include'});
const result = await resp.json();
hideLoading(btn, '<i class="fas fa-eye mr-1"></i> Vista Previa');
const pre = document.getElementById('json-preview');
if (result.success && result.generated_json) {
pre.innerHTML = syntaxHighlight(result.generated_json);
} else {
pre.innerHTML = '<span class="text-red-600">Error: ' + (result.message || 'Sin datos') + '</span>';
}
}
async function sendTerceros() {
if (!confirm('¿Enviar terceros a la API?')) return;
const form = document.getElementById('form-terceros');
const data = new FormData(form);
const btn = form.querySelector('[onclick="sendTerceros()"]');
showLoading(btn);
const resp = await fetch('/terceros/send', {method:'POST', body: data, credentials: 'include'});
const result = await resp.json();
hideLoading(btn, '<i class="fas fa-paper-plane mr-1"></i> Enviar a API');
if (result.success) {
showToast('Envío exitoso! CUV: ' + (result.cuv || ''), 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Error: ' + result.message, 'error');
}
}
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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*)(\d+)/g, ': $1<span class="text-orange-600">$2</span>')
.replace(/:(\s*)(null|true|false)/g, ': $1<span class="text-purple-600">$2</span>');
}
</script>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Transacción RIPS{% endblock %}
{% block header %}Envío de Transacción RIPS{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<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-blue-500"></i>Generar y Enviar</h3>
</div>
<div class="p-6">
<form id="form-transaccion" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Consulta SQL</label>
<select name="query_id" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Seleccionar consulta...</option>
{% for q in queries %}
<option value="{{ q.id }}">{{ q.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Factura <span class="text-xs text-gray-400">(opcional)</span></label>
<input type="text" name="factura"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Ej: LHXC03404">
</div>
<div 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" name="fecha_inicio" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha Fin</label>
<input type="date" name="fecha_fin" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
</div>
<div class="flex space-x-3">
<button type="button" onclick="previewTransaccion()"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200">
<i class="fas fa-eye mr-1"></i> Vista Previa
</button>
<button type="button" onclick="sendTransaccion()"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium">
<i class="fas fa-paper-plane mr-1"></i> Enviar a API
</button>
</div>
</form>
</div>
</div>
<div class="mt-6 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-eye mr-2 text-blue-500"></i>JSON Generado</h3>
</div>
<div class="p-6">
<div id="preview-info" class="text-xs text-gray-500 mb-2"></div>
<pre id="json-preview" class="text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto max-h-96 text-gray-600"><i class="fas fa-info-circle mr-1"></i> Haz clic en "Vista Previa" para ver el JSON</pre>
</div>
</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">
<h3 class="font-semibold text-gray-800"><i class="fas fa-history mr-2 text-blue-500"></i>Últimos Envíos</h3>
</div>
<div class="p-4">
{% if envios %}
<div class="space-y-2">
{% for e in envios %}
<div class="p-3 rounded-lg border border-gray-100 text-sm">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-700 text-xs">{{ e.factura or 'N/A' }}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium {% if e.status == 'success' %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
{{ 'Exitoso' if e.status == 'success' else 'Error' }}
</span>
</div>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>{{ e.pacientes_count or 0 }} pacientes / {{ e.servicios_count or 0 }} servicios</span>
<span>{{ e.created_at[:19] }}</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center text-gray-400 py-4 text-sm">No hay envíos de transacciones aún</p>
{% endif %}
</div>
</div>
</div>
</div>
<script>
async function previewTransaccion() {
const form = document.getElementById('form-transaccion');
const data = new FormData(form);
const btn = form.querySelector('[onclick="previewTransaccion()"]');
showLoading(btn);
const resp = await fetch('/transaccion/preview', {method:'POST', body: data, credentials: 'include'});
const result = await resp.json();
hideLoading(btn, '<i class="fas fa-eye mr-1"></i> Vista Previa');
document.getElementById('preview-info').innerHTML = result.success
? `<span class="text-green-600"><i class="fas fa-check-circle"></i> ${result.rows_count} registros, ${result.grupos_count} grupos, ${result.total_json} JSON(s)</span>`
: `<span class="text-red-600">Error: ${result.message}</span>`;
const pre = document.getElementById('json-preview');
if (result.success && result.generated_json) {
pre.innerHTML = syntaxHighlight(result.generated_json);
} else {
pre.innerHTML = '<span class="text-red-600">Error: ' + (result.message || 'Sin datos') + '</span>';
}
}
async function sendTransaccion() {
if (!confirm('¿Enviar transacción(es) RIPS a la API?')) return;
const form = document.getElementById('form-transaccion');
const data = new FormData(form);
const btn = form.querySelector('[onclick="sendTransaccion()"]');
showLoading(btn);
const resp = await fetch('/transaccion/send', {method:'POST', body: data, credentials: 'include'});
const result = await resp.json();
hideLoading(btn, '<i class="fas fa-paper-plane mr-1"></i> Enviar a API');
if (result.success) {
showToast(`Envío exitoso! ${result.total_enviados} transacciones`, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast(`Enviados: ${result.total_enviados}, Errores: ${result.total_errores}`, result.total_errores > 0 ? 'warning' : 'success');
}
}
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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*)(\d+)/g, ': $1<span class="text-orange-600">$2</span>')
.replace(/:(\s*)(null|true|false)/g, ': $1<span class="text-purple-600">$2</span>');
}
</script>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
python3 -m venv venv 2>/dev/null || python -m venv venv
source venv/bin/activate 2>/dev/null || source venv/Scripts/activate
pip install -q -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8080
+95
View File
@@ -0,0 +1,95 @@
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__":
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
aiofiles==24.1.0
bcrypt==4.2.1
python-jose[cryptography]==3.3.0
httpx==0.28.1
fdb>=2.0.0
BIN
View File
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
@echo off
chcp 65001 >nul
title RIPS Manager - Iniciando...
:: Verificar si ya existe Python portable
if exist python\python.exe goto :start
echo ==========================================
echo Descargando Python portable...
echo ==========================================
echo.
:: Descargar Python embeddable (64-bit)
powershell -Command "& {Invoke-WebRequest -Uri 'https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip' -OutFile 'python.zip'}"
if not exist python.zip (
echo ERROR: No se pudo descargar Python
pause
exit /b
)
echo.
echo Extrayendo...
powershell -Command "& {Expand-Archive -Path 'python.zip' -DestinationPath 'python' -Force}"
del python.zip
:: Habilitar pip (descomentar línea en el archivo de configuración)
set PYTHON_EXE=%~dp0python\python.exe
set PTH_FILE=%~dp0python\python312._pth
powershell -Command "& {(Get-Content '%PTH_FILE%') -replace '#import site','import site' | Set-Content '%PTH_FILE%'}"
:: Instalar pip
echo.
echo Instalando pip...
%PYTHON_EXE% -c "import urllib.request; exec(urllib.request.urlopen('https://bootstrap.pypa.io/get-pip.py').read())"
:start
echo.
echo Instalando dependencias...
python\python.exe -m pip install -q -r requirements.txt 2>nul
echo.
echo ==========================================
echo Servidor iniciado en http://localhost:8080
echo Presiona Ctrl+C para detener
echo ==========================================
python\python.exe main.py
pause