feat(contratos): tabla de contratos con tipo_usuario por contrato

Agrega tabla `contratos` en SQLite sembrada con los 17 contratos del
laboratorio. `generar_rda_paciente` consulta el mapa contrato→tipoUsuario
como primera prioridad antes de derivar de TIPOUSU/TIPOUSUSISPRO.
Incluye CRUD en /contratos con UI en sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-09 15:00:21 -05:00
co-authored by Claude Sonnet 4.6
parent 7d2a9df447
commit 0f3a760de4
8 changed files with 337 additions and 12 deletions
+13
View File
@@ -21,6 +21,19 @@ def _migrate(conn):
conn.execute("ALTER TABLE envios ADD COLUMN contrato TEXT")
if "mensaje_tns" not in cols:
conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT")
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "contratos" not in tables:
conn.execute("""
CREATE TABLE contratos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
numero_contrato TEXT UNIQUE NOT NULL,
nit_empresa TEXT NOT NULL DEFAULT '',
tipo_usuario TEXT NOT NULL,
descripcion TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.commit()
+3 -1
View File
@@ -14,6 +14,7 @@ from app.services.json_generator import (
)
from app.services.api_client import get_tns_token, TNS_BASE
from app.services.whatsapp_sync import sync_paciente, sync_todos, guardar_sync_log
from app.routes.contratos import load_contrato_map
router = APIRouter(prefix="/automation", tags=["automation"])
@@ -278,12 +279,13 @@ async def run_automation(
esp_def = configs.get("especialidad_default", "")
remis_def = configs.get("remisionante_default", "00")
prefijo_def = configs.get("prefijo_tns_default", "00")
contrato_map = load_contrato_map()
async with httpx.AsyncClient(timeout=timeout) as client:
for id_recepcion, grupo_rows in grupos.items():
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
num_override = num_fac
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, num_override)
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, num_override, contrato_map=contrato_map)
factura = num_override or str(id_recepcion)
try:
resp = await client.post(endpoint, json=rda_json, headers=headers)
+117
View File
@@ -0,0 +1,117 @@
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
router = APIRouter(prefix="/contratos", tags=["contratos"])
_SEED = [
("001", "860078828", "11", "EPS SANITAS"),
("002", "830054904", "11", "EPS COMPENSAR"),
("003", "900278729", "12", "PARTICULAR"),
("004", "800106339", "11", "EPS NUEVA EPS"),
("005", "800153424", "11", "EPS SURA"),
("006", "805009741", "11", "EPS COOMEVA"),
("007", "860002183", "11", "EPS FAMISANAR"),
("008", "860002503", "11", "EPS CRUZ BLANCA"),
("009", "860027404", "11", "EPS COLSANITAS"),
("010", "860039988", "11", "EPS SALUD TOTAL"),
("011", "890903790", "11", "EPS SAVIA SALUD"),
("012", "900178724", "11", "EPS MUTUAL SER"),
("034", "860078828", "11", "EPS SANITAS (alt)"),
("035", "860078828", "11", "EPS SANITAS (alt)"),
("036", "860078828", "11", "EPS SANITAS (alt)"),
("20062026", "800182856", "01", "SUBSIDIADO"),
("CW225489", "899999068", "07", "POLIZA / SEGURO"),
]
def ensure_defaults():
conn = get_connection()
for nc, nit, tu, desc in _SEED:
exists = conn.execute(
"SELECT id FROM contratos WHERE numero_contrato = ?", (nc,)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion) VALUES (?,?,?,?)",
(nc, nit, tu, desc),
)
conn.commit()
conn.close()
def load_contrato_map() -> dict:
"""Devuelve {numero_contrato: tipo_usuario} incluyendo variante sin ceros a la izquierda."""
conn = get_connection()
rows = conn.execute("SELECT numero_contrato, tipo_usuario FROM contratos").fetchall()
conn.close()
result = {}
for r in rows:
nc = r["numero_contrato"].strip()
tu = r["tipo_usuario"].strip()
result[nc] = tu
nc_s = nc.lstrip("0") or nc
if nc_s != nc:
result[nc_s] = tu
return result
@router.get("")
async def contratos_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
rows = conn.execute("SELECT * FROM contratos ORDER BY numero_contrato").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("contratos.html", {
"request": request, "user": user, "contratos": rows,
})
@router.post("/create")
async def contrato_create(
request: Request,
user: dict = Depends(get_current_user),
numero_contrato: str = Form(...),
nit_empresa: str = Form(""),
tipo_usuario: str = Form(...),
descripcion: str = Form(""),
):
conn = get_connection()
try:
conn.execute(
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion) VALUES (?,?,?,?)",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip()),
)
conn.commit()
except Exception:
pass
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/update/{contrato_id}")
async def contrato_update(
contrato_id: int,
user: dict = Depends(get_current_user),
numero_contrato: str = Form(...),
nit_empresa: str = Form(""),
tipo_usuario: str = Form(...),
descripcion: str = Form(""),
):
conn = get_connection()
conn.execute(
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=? WHERE id=?",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), contrato_id),
)
conn.commit()
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/delete/{contrato_id}")
async def contrato_delete(contrato_id: int, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute("DELETE FROM contratos WHERE id = ?", (contrato_id,))
conn.commit()
conn.close()
return RedirectResponse("/contratos", status_code=302)
+7 -1
View File
@@ -8,6 +8,7 @@ from app.auth import get_current_user
from app.services.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_rda_paciente, agrupar_por_recepcion
from app.services.api_client import get_tns_token, TNS_BASE
from app.routes.contratos import load_contrato_map
router = APIRouter(prefix="/transaccion", tags=["transaccion"])
@@ -96,11 +97,12 @@ async def preview_transaccion(
esp_def = cfg.get("especialidad_default", "")
remis_def = cfg.get("remisionante_default", "00")
prefijo_def = cfg.get("prefijo_tns_default", "00")
contrato_map = load_contrato_map()
items = []
for id_rec, grupo_rows in grupos.items():
enviado = _is_sent(id_rec)
rda = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def)
rda = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, contrato_map=contrato_map)
items.append({
"idrecepcion": id_rec,
"factura": grupo_rows[0].get("NUM_FACTURA", ""),
@@ -166,6 +168,7 @@ async def send_one(
cfg.get("remisionante_default", "00"),
cfg.get("prefijo_tns_default", "00"),
numero_override_one,
contrato_map=load_contrato_map(),
)
raw_resp = ""
@@ -234,6 +237,8 @@ async def send_transaccion(
if solo_pendientes == "1":
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
contrato_map = load_contrato_map()
token, token_err = await get_tns_token(
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
)
@@ -256,6 +261,7 @@ async def send_transaccion(
cfg.get("remisionante_default", "00"),
cfg.get("prefijo_tns_default", "00"),
numero_override,
contrato_map=contrato_map,
)
raw_resp = ""
ok_rda = False
+14 -9
View File
@@ -134,7 +134,7 @@ def _calcular_descuento_pct(rows: list) -> int:
def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "",
default_remisionante: str = "00", default_prefijo: str = "00",
numero_override: str = "") -> dict:
numero_override: str = "", contrato_map: dict = None) -> dict:
if not rows:
return {}
h = rows[0]
@@ -191,15 +191,20 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
cod_forma_pago = "CLIP" if es_particular else "INST"
autorizacion = None if es_particular else (str(h.get("AUTORIZACION") or "").strip() or None)
# tipousuario: particular siempre "12"; para convenios usar TIPOUSUSISPRO o derivar
# tipousuario: prioridad 1→tabla contratos, 2→particular, 3→TIPOUSUSISPRO/TIPOUSU
_TIPOUSU_MAP = {"1": "11", "5": "07"} # EPS→11, Póliza→07
if es_particular:
tipoususispro = "12"
else:
tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip()
if not tipoususispro:
tipousu = str(h.get("TIPOUSU") or "").strip()
tipoususispro = _TIPOUSU_MAP.get(tipousu, "11")
tipoususispro = ""
if contrato_map and cod_contrato_raw:
_nc_s = cod_contrato_raw.lstrip("0") or cod_contrato_raw
tipoususispro = contrato_map.get(cod_contrato_raw) or contrato_map.get(_nc_s) or ""
if not tipoususispro:
if es_particular:
tipoususispro = "12"
else:
tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip()
if not tipoususispro:
tipousu = str(h.get("TIPOUSU") or "").strip()
tipoususispro = _TIPOUSU_MAP.get(tipousu, "11")
via_ingreso = "01"
modalidad = "01"
+3
View File
@@ -46,6 +46,9 @@
<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>
<a href="/contratos" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/contratos' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-file-contract w-5 mr-2"></i> Contratos␍
</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 %}">
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Contratos{% endblock %}
{% block header %}Contratos{% endblock %}
{% block content %}
<div class="max-w-4xl space-y-6">
<!-- Agregar contrato -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="font-semibold text-gray-800"><i class="fas fa-plus-circle mr-2 text-blue-500"></i>Agregar Contrato</h3>
</div>
<div class="p-6">
<form method="POST" action="/contratos/create" class="grid grid-cols-4 gap-3 items-end">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">N° Contrato</label>
<input type="text" name="numero_contrato" required placeholder="Ej: 001"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">NIT Empresa</label>
<input type="text" name="nit_empresa" placeholder="Ej: 860078828"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo Usuario</label>
<select name="tipo_usuario" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="11">11 — Contributivo (EPS)</option>
<option value="12">12 — Particular</option>
<option value="07">07 — Póliza / Seguro</option>
<option value="01">01 — Subsidiado</option>
<option value="10">10 — No Afiliado</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Descripción</label>
<input type="text" name="descripcion" placeholder="Ej: EPS SANITAS"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
<div class="col-span-4 flex justify-end">
<button type="submit"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
<i class="fas fa-plus mr-1"></i> Agregar
</button>
</div>
</form>
</div>
</div>
<!-- Tabla de contratos -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="font-semibold text-gray-800">
<i class="fas fa-list mr-2 text-gray-500"></i>Contratos registrados
<span class="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{{ contratos|length }}</span>
</h3>
<span class="text-xs text-gray-400">El tipo de usuario se aplica automáticamente al generar RIPS por contrato</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">N° Contrato</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">NIT Empresa</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Tipo Usuario</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Descripción</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for c in contratos %}
<tr class="hover:bg-gray-50" id="row-{{ c.id }}">
<td class="px-4 py-3 font-mono font-semibold text-gray-800">{{ c.numero_contrato }}</td>
<td class="px-4 py-3 text-gray-600">{{ c.nit_empresa }}</td>
<td class="px-4 py-3">
{% set tu = c.tipo_usuario %}
{% if tu == '11' %}
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">11 — Contributivo</span>
{% elif tu == '12' %}
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full text-xs font-medium">12 — Particular</span>
{% elif tu == '07' %}
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">07 — Póliza</span>
{% elif tu == '01' %}
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">01 — Subsidiado</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">{{ tu }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500">{{ c.descripcion }}</td>
<td class="px-4 py-3 text-center">
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}')"
class="text-blue-600 hover:text-blue-800 mr-3 text-xs">
<i class="fas fa-edit"></i> Editar
</button>
<form method="POST" action="/contratos/delete/{{ c.id }}" class="inline"
onsubmit="return confirm('¿Eliminar contrato {{ c.numero_contrato }}?')">
<button type="submit" class="text-red-500 hover:text-red-700 text-xs">
<i class="fas fa-trash"></i> Eliminar
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400">No hay contratos registrados</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal editar -->
<div id="edit-modal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 class="font-semibold text-gray-800 mb-4"><i class="fas fa-edit mr-2 text-blue-500"></i>Editar Contrato</h3>
<form method="POST" id="edit-form" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">N° Contrato</label>
<input type="text" name="numero_contrato" id="edit-nc" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">NIT Empresa</label>
<input type="text" name="nit_empresa" id="edit-nit"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo Usuario</label>
<select name="tipo_usuario" id="edit-tu"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="11">11 — Contributivo (EPS)</option>
<option value="12">12 — Particular</option>
<option value="07">07 — Póliza / Seguro</option>
<option value="01">01 — Subsidiado</option>
<option value="10">10 — No Afiliado</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Descripción</label>
<input type="text" name="descripcion" id="edit-desc"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" onclick="closeEdit()"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg">
Cancelar
</button>
<button type="submit"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
<i class="fas fa-save mr-1"></i> Guardar
</button>
</div>
</form>
</div>
</div>
<script>
function openEdit(id, nc, nit, tu, desc) {
document.getElementById('edit-form').action = '/contratos/update/' + id;
document.getElementById('edit-nc').value = nc;
document.getElementById('edit-nit').value = nit;
document.getElementById('edit-tu').value = tu;
document.getElementById('edit-desc').value = desc;
document.getElementById('edit-modal').classList.remove('hidden');
}
function closeEdit() {
document.getElementById('edit-modal').classList.add('hidden');
}
document.getElementById('edit-modal').addEventListener('click', function(e) {
if (e.target === this) closeEdit();
});
</script>
{% endblock %}
+4 -1
View File
@@ -56,8 +56,10 @@ async def startup():
init_db()
from app.routes.config import ensure_defaults as config_defaults
from app.routes.queries import ensure_defaults as query_defaults
from app.routes.contratos import ensure_defaults as contratos_defaults
config_defaults()
query_defaults()
contratos_defaults()
@app.get("/")
@@ -65,7 +67,7 @@ async def root():
return RedirectResponse(url="/dashboard")
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes, contratos
app.include_router(auth.router)
app.include_router(dashboard.router)
@@ -78,6 +80,7 @@ app.include_router(automation.router)
app.include_router(test_rda.router)
app.include_router(debug_fb.router)
app.include_router(pacientes.router)
app.include_router(contratos.router)
if __name__ == "__main__":