feat: opcion 'sin contrato' para enviar numeroContrato null en TNS

- Agrega columna sin_contrato en tabla contratos (migration automatica)
- Contrato 040 marcado excluir=0, sin_contrato=1 en seed
- load_sin_contrato_set() retorna contratos que deben ir con null
- generar_rda_paciente acepta sin_contrato_set y fuerza numeroContrato=null
- Automation paso 2 y 3 pasan sin_contrato_set al generador
- Pagina contratos: columna y toggle "Sin contrato", checkbox en modal editar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-10 21:22:09 -05:00
co-authored by Claude Sonnet 4.6
parent eeabb6268e
commit 9f2bd5164e
5 changed files with 127 additions and 64 deletions
+2
View File
@@ -51,6 +51,8 @@ def _migrate(conn):
contrato_cols = {r[1] for r in conn.execute("PRAGMA table_info(contratos)")}
if "excluir" not in contrato_cols:
conn.execute("ALTER TABLE contratos ADD COLUMN excluir INTEGER NOT NULL DEFAULT 0")
if "sin_contrato" not in contrato_cols:
conn.execute("ALTER TABLE contratos ADD COLUMN sin_contrato INTEGER NOT NULL DEFAULT 0")
conn.commit()
+6 -2
View File
@@ -14,7 +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, load_excluded_set
from app.routes.contratos import load_contrato_map, load_excluded_set, load_sin_contrato_set
from app.utils.activity import log_activity, get_ip
router = APIRouter(prefix="/automation", tags=["automation"])
@@ -260,6 +260,7 @@ async def preview_automation(
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
contrato_map = load_contrato_map()
sin_contrato_set = load_sin_contrato_set()
prof_def = configs.get("profesional_defecto", "")
esp_def = configs.get("especialidad_defecto", "")
remis_def = configs.get("remisionante_defecto", "00")
@@ -274,6 +275,7 @@ async def preview_automation(
numero_override=ps_numero,
contrato_map=contrato_map,
prefijo_override=ps_prefijo,
sin_contrato_set=sin_contrato_set,
)
preservicios_preview.append({
"factura": f"{ps_prefijo}-{ps_numero.zfill(5)}",
@@ -429,6 +431,7 @@ async def run_automation(
# ── PASO 2: Enviar RDA Paciente ───────────────────────────────────────────
excluded = load_excluded_set()
sin_contrato_set = load_sin_contrato_set()
grupos_all = agrupar_por_recepcion(rows_rda)
grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
@@ -445,7 +448,7 @@ async def run_automation(
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, contrato_map=contrato_map)
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, num_override, contrato_map=contrato_map, sin_contrato_set=sin_contrato_set)
factura = num_override or str(id_recepcion)
try:
resp = await client.post(endpoint, json=rda_json, headers=headers)
@@ -486,6 +489,7 @@ async def run_automation(
numero_override=ps_numero,
contrato_map=contrato_map,
prefijo_override=ps_prefijo,
sin_contrato_set=sin_contrato_set,
)
factura_ps = f"{ps_prefijo}-{ps_numero.zfill(5)}"
try:
+88 -55
View File
@@ -6,72 +6,71 @@ from app.utils.activity import log_activity, get_ip
router = APIRouter(prefix="/contratos", tags=["contratos"])
# (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir)
# (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato)
_SEED = [
("001", "860078828", "11", "EPS SANITAS", 0),
("002", "830054904", "11", "EPS COMPENSAR", 0),
("003", "900278729", "12", "PARTICULAR", 0),
("004", "800106339", "11", "EPS NUEVA EPS", 0),
("005", "800153424", "11", "EPS SURA", 0),
("006", "805009741", "11", "EPS COOMEVA", 0),
("007", "860002183", "11", "EPS FAMISANAR", 0),
("008", "860002503", "11", "EPS CRUZ BLANCA", 0),
("009", "860027404", "11", "EPS COLSANITAS", 0),
("010", "860039988", "11", "EPS SALUD TOTAL", 0),
("011", "890903790", "11", "EPS SAVIA SALUD", 0),
("012", "900178724", "11", "EPS MUTUAL SER", 0),
("034", "860078828", "11", "EPS SANITAS (alt)", 0),
("035", "860078828", "11", "EPS SANITAS (alt)", 0),
("036", "860078828", "11", "EPS SANITAS (alt)", 0),
("20062026", "800182856", "01", "SUBSIDIADO", 0),
("CW225489", "899999068", "07", "POLIZA / SEGURO", 0),
("001", "860078828", "11", "EPS SANITAS", 0, 0),
("002", "830054904", "11", "EPS COMPENSAR", 0, 0),
("003", "900278729", "12", "PARTICULAR", 0, 0),
("004", "800106339", "11", "EPS NUEVA EPS", 0, 0),
("005", "800153424", "11", "EPS SURA", 0, 0),
("006", "805009741", "11", "EPS COOMEVA", 0, 0),
("007", "860002183", "11", "EPS FAMISANAR", 0, 0),
("008", "860002503", "11", "EPS CRUZ BLANCA", 0, 0),
("009", "860027404", "11", "EPS COLSANITAS", 0, 0),
("010", "860039988", "11", "EPS SALUD TOTAL", 0, 0),
("011", "890903790", "11", "EPS SAVIA SALUD", 0, 0),
("012", "900178724", "11", "EPS MUTUAL SER", 0, 0),
("034", "860078828", "11", "EPS SANITAS (alt)", 0, 0),
("035", "860078828", "11", "EPS SANITAS (alt)", 0, 0),
("036", "860078828", "11", "EPS SANITAS (alt)", 0, 0),
("20062026", "800182856", "01", "SUBSIDIADO", 0, 0),
("CW225489", "899999068", "07", "POLIZA / SEGURO", 0, 0),
# Contratos excluidos del envío TNS
("013", "", "11", "MEDILAVORO S.A.S", 0),
("014", "", "11", "LABORATORIO UROCLINICO",0),
("015", "", "11", "ANDRES AFANADOR VILLAMIZAR", 1),
("016", "", "11", "OMAR FERNANDO RIBERO GOMEZ", 1),
("017", "", "11", "CLAUDIA BELEN JULIO SEPULVEDA", 1),
("018", "", "11", "LABORATORIO MICROBIOLOGICO - MARGIE OJEDA", 1),
("019", "", "11", "LABORATORIO TOXICOLOGICO - MARTHA MORALES", 1),
("020", "", "11", "MARTHA LUCIA GALLARDO", 1),
("021", "", "11", "LABORATORIO VILMA OROZCO AYALA", 1),
("022", "", "11", "CLINICA SAN JOSE DE CUCUTA", 1),
("023", "", "11", "URONORTE S.A", 1),
("024", "", "11", "LABORATORIO CLINICO BIOLAB S.A.S", 1),
("025", "", "11", "JOEL LEONARDO CARRILLO CORREDOR", 1),
("026", "", "11", "ROLANDO IVAN PENARANDA DEVIA", 1),
("027", "", "11", "JOSE WILMER GARCIA CALDERON", 1),
("028", "", "11", "ONCOMEDICAL IPS S.A.S", 1),
("029", "", "11", "GENETIX S.A.S", 1),
("030", "", "11", "NORFETUS S.A.S", 1),
("031", "", "11", "TBTB GLOBAL LAB S.A.S", 1),
("032", "", "11", "COLGENES S.A.S", 1),
("033", "", "11", "IPS FIGURAS SPA CUCUTA S.A.S", 1),
("037", "", "11", "CLINICA URGENCIAS LA MERCED", 1),
("038", "", "11", "CLINICA COLSANITAS S.A.", 1),
("039", "", "11", "GOMEZ GIL JOSE JESUS", 1),
("040", "", "11", "MARTHA LILIANA SALGAR GALLEGO", 1),
("041", "", "11", "GENCELL PHARMA S.A.S", 1),
("042", "", "07", "AXA COLPATRIA HYC", 1),
("013", "", "11", "MEDILAVORO S.A.S", 0, 0),
("014", "", "11", "LABORATORIO UROCLINICO",0, 0),
("015", "", "11", "ANDRES AFANADOR VILLAMIZAR", 1, 0),
("016", "", "11", "OMAR FERNANDO RIBERO GOMEZ", 1, 0),
("017", "", "11", "CLAUDIA BELEN JULIO SEPULVEDA", 1, 0),
("018", "", "11", "LABORATORIO MICROBIOLOGICO - MARGIE OJEDA", 1, 0),
("019", "", "11", "LABORATORIO TOXICOLOGICO - MARTHA MORALES", 1, 0),
("020", "", "11", "MARTHA LUCIA GALLARDO", 1, 0),
("021", "", "11", "LABORATORIO VILMA OROZCO AYALA", 1, 0),
("022", "", "11", "CLINICA SAN JOSE DE CUCUTA", 1, 0),
("023", "", "11", "URONORTE S.A", 1, 0),
("024", "", "11", "LABORATORIO CLINICO BIOLAB S.A.S", 1, 0),
("025", "", "11", "JOEL LEONARDO CARRILLO CORREDOR", 1, 0),
("026", "", "11", "ROLANDO IVAN PENARANDA DEVIA", 1, 0),
("027", "", "11", "JOSE WILMER GARCIA CALDERON", 1, 0),
("028", "", "11", "ONCOMEDICAL IPS S.A.S", 1, 0),
("029", "", "11", "GENETIX S.A.S", 1, 0),
("030", "", "11", "NORFETUS S.A.S", 1, 0),
("031", "", "11", "TBTB GLOBAL LAB S.A.S", 1, 0),
("032", "", "11", "COLGENES S.A.S", 1, 0),
("033", "", "11", "IPS FIGURAS SPA CUCUTA S.A.S", 1, 0),
("037", "", "11", "CLINICA URGENCIAS LA MERCED", 1, 0),
("038", "", "11", "CLINICA COLSANITAS S.A.", 1, 0),
("039", "", "11", "GOMEZ GIL JOSE JESUS", 1, 0),
("040", "", "11", "MARTHA LILIANA SALGAR GALLEGO", 0, 1),
("041", "", "11", "GENCELL PHARMA S.A.S", 1, 0),
("042", "", "07", "AXA COLPATRIA HYC", 1, 0),
]
def ensure_defaults():
conn = get_connection()
for nc, nit, tu, desc, excluir in _SEED:
for nc, nit, tu, desc, excluir, sin_contrato in _SEED:
exists = conn.execute(
"SELECT id FROM contratos WHERE numero_contrato = ?", (nc,)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir) VALUES (?,?,?,?,?)",
(nc, nit, tu, desc, excluir),
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato) VALUES (?,?,?,?,?,?)",
(nc, nit, tu, desc, excluir, sin_contrato),
)
else:
# Forzar excluir según el seed para que el DB siempre coincida
conn.execute(
"UPDATE contratos SET excluir=? WHERE numero_contrato=?",
(excluir, nc),
"UPDATE contratos SET excluir=?, sin_contrato=? WHERE numero_contrato=?",
(excluir, sin_contrato, nc),
)
conn.commit()
conn.close()
@@ -93,6 +92,21 @@ def load_contrato_map() -> dict:
return result
def load_sin_contrato_set() -> set:
"""Devuelve set de numero_contrato marcados como sin_contrato=1 (se envían con numeroContrato: null)."""
conn = get_connection()
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE sin_contrato=1").fetchall()
conn.close()
result = set()
for r in rows:
nc = r["numero_contrato"].strip()
result.add(nc)
nc_s = nc.lstrip("0") or nc
if nc_s != nc:
result.add(nc_s)
return result
def load_excluded_set() -> set:
"""Devuelve set de numero_contrato (raw + sin ceros) marcados como excluir=1."""
conn = get_connection()
@@ -127,12 +141,13 @@ async def contrato_create(
tipo_usuario: str = Form(...),
descripcion: str = Form(""),
excluir: str = Form("0"),
sin_contrato: str = Form("0"),
):
conn = get_connection()
try:
conn.execute(
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir) VALUES (?,?,?,?,?)",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir)),
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato) VALUES (?,?,?,?,?,?)",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato)),
)
conn.commit()
log_activity(user["user_id"], user["username"], "contrato_creado",
@@ -146,17 +161,19 @@ async def contrato_create(
@router.post("/update/{contrato_id}")
async def contrato_update(
contrato_id: int,
request: Request,
user: dict = Depends(get_current_user),
numero_contrato: str = Form(...),
nit_empresa: str = Form(""),
tipo_usuario: str = Form(...),
descripcion: str = Form(""),
excluir: str = Form("0"),
sin_contrato: str = Form("0"),
):
conn = get_connection()
conn.execute(
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=? WHERE id=?",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), contrato_id),
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=?, sin_contrato=? WHERE id=?",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), contrato_id),
)
conn.commit()
log_activity(user["user_id"], user["username"], "contrato_editado",
@@ -182,6 +199,22 @@ async def toggle_excluir(contrato_id: int, user: dict = Depends(get_current_user
return RedirectResponse("/contratos", status_code=302)
@router.post("/toggle-sin-contrato/{contrato_id}")
async def toggle_sin_contrato(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute(
"UPDATE contratos SET sin_contrato = CASE WHEN sin_contrato=1 THEN 0 ELSE 1 END WHERE id=?",
(contrato_id,)
)
conn.commit()
row = conn.execute("SELECT numero_contrato, sin_contrato FROM contratos WHERE id=?", (contrato_id,)).fetchone()
if row:
log_activity(user["user_id"], user["username"], "contrato_toggle_sin_contrato",
f"Contrato {row['numero_contrato']} sin_contrato→{row['sin_contrato']}", get_ip(request))
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/delete/{contrato_id}")
async def contrato_delete(contrato_id: int, user: dict = Depends(get_current_user)):
conn = get_connection()
+5 -2
View File
@@ -127,7 +127,7 @@ def agrupar_por_recepcion(rows: list) -> dict:
def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "",
default_remisionante: str = "00", default_prefijo: str = "00",
numero_override: str = "", contrato_map: dict = None,
prefijo_override: str = "") -> dict:
prefijo_override: str = "", sin_contrato_set: set = None) -> dict:
if not rows:
return {}
h = rows[0]
@@ -189,7 +189,10 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
or (_map_tipo == "12")
or (not _map_tipo and cod_contrato_raw.lstrip("0") == "12")
)
cod_contrato = cod_contrato_raw or ("012" if es_particular else None)
if sin_contrato_set and cod_contrato_raw and cod_contrato_raw in sin_contrato_set:
cod_contrato = None
else:
cod_contrato = cod_contrato_raw or ("012" if es_particular else None)
cod_forma_pago = "CLIP" if es_particular else "INST"
autorizacion = None if es_particular else (str(h.get("AUTORIZACION") or "").strip() or None)
+26 -5
View File
@@ -68,6 +68,7 @@
<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">Envío TNS</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Sin Contrato</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
@@ -105,7 +106,20 @@
</form>
</td>
<td class="px-4 py-3 text-center">
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}', {{ c.excluir }})"
<form method="POST" action="/contratos/toggle-sin-contrato/{{ c.id }}" class="inline">
{% if c.sin_contrato %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-yellow-100 text-yellow-700 hover:bg-yellow-200 rounded-full text-xs font-medium transition-colors" title="numeroContrato: null en JSON">
<i class="fas fa-unlink text-xs"></i> Sin contrato
</button>
{% else %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-gray-100 text-gray-400 hover:bg-gray-200 rounded-full text-xs font-medium transition-colors">
<i class="fas fa-link text-xs"></i> Normal
</button>
{% endif %}
</form>
</td>
<td class="px-4 py-3 text-center">
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}', {{ c.excluir }}, {{ c.sin_contrato }})"
class="text-blue-600 hover:text-blue-800 mr-3 text-xs">
<i class="fas fa-edit"></i> Editar
</button>
@@ -161,9 +175,15 @@
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 items-center gap-2">
<input type="checkbox" name="excluir" id="edit-excluir" value="1" class="rounded">
<label for="edit-excluir" class="text-sm text-gray-700">Excluir del envío TNS</label>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="excluir" id="edit-excluir" value="1" class="rounded">
<span class="text-sm text-gray-700">Excluir del envío TNS</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="sin_contrato" id="edit-sin-contrato" value="1" class="rounded">
<span class="text-sm text-gray-700">Enviar sin contrato <span class="text-xs text-yellow-600">(numeroContrato: null)</span></span>
</label>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" onclick="closeEdit()"
@@ -180,13 +200,14 @@
</div>
<script>
function openEdit(id, nc, nit, tu, desc, excluir) {
function openEdit(id, nc, nit, tu, desc, excluir, sinContrato) {
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-excluir').checked = excluir === 1;
document.getElementById('edit-sin-contrato').checked = sinContrato === 1;
document.getElementById('edit-modal').classList.remove('hidden');
}
function closeEdit() {