fix: refactor ventas page to group by factura instead of idrecepcion

- _agrupar now keys by PREFIJO-NUM_FACTURA so all services of a factura are grouped together
- _is_sent checks by factura key instead of idrecepcion
- send-one and send endpoints accept/return factura_key instead of idrecepcion
- ventas.html JS updated: factura_key used throughout (filaHtml, marcarFila, enviarUno, enviarTodosPendientes, verDetalle)
- DOM element IDs sanitized with _safeId() to handle dashes in keys like CMXC-309
- onclick handlers now pass string keys with proper quoting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-28 17:08:20 -05:00
co-authored by Claude Sonnet 4.6
parent c1925ef6da
commit 8ed8f0999f
3 changed files with 54 additions and 44 deletions
+21 -21
View File
@@ -50,10 +50,14 @@ def _query_rows(cfg, query_text, factura, fecha_inicio, fecha_fin):
def _agrupar(rows):
"""Agrupa por PREFIJO+NUM_FACTURA para incluir todos los servicios de una factura."""
from collections import defaultdict
grupos = defaultdict(list)
for row in rows:
grupos[row.get("IDRECEPCION")].append(dict(row))
prefijo = str(row.get("PREFIJO") or "").strip()
num = str(row.get("NUM_FACTURA") or "").strip()
key = f"{prefijo}-{num}"
grupos[key].append(dict(row))
return dict(grupos)
@@ -64,11 +68,11 @@ def _build_venta(grupo_rows, cfg):
default_prefijo=prefijo_def, numero_override=num_fac)
def _is_sent(idrecepcion):
def _is_sent(factura_key):
conn = get_connection()
row = conn.execute(
"SELECT status, mensaje_tns, created_at FROM envios WHERE idrecepcion=? AND tipo='ventas' ORDER BY id DESC LIMIT 1",
(idrecepcion,)
"SELECT status, mensaje_tns, created_at FROM envios WHERE factura=? AND tipo='ventas' ORDER BY id DESC LIMIT 1",
(factura_key,)
).fetchone()
conn.close()
if row:
@@ -148,14 +152,12 @@ async def preview_ventas(
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
items = []
for key, grupo_rows in grupos.items():
enviado = _is_sent(key)
for factura_key, grupo_rows in grupos.items():
enviado = _is_sent(factura_key)
venta_json = _build_venta(grupo_rows, cfg)
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "")
prefijo = str(grupo_rows[0].get("PREFIJO") or "").strip()
items.append({
"idrecepcion": key,
"factura": f"{prefijo}-{num_fac}",
"factura_key": factura_key,
"factura": factura_key,
"paciente": grupo_rows[0].get("COD_PACIENTE", ""),
"contrato": str(grupo_rows[0].get("CODCONTRATO") or "").strip(),
"examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows],
@@ -181,7 +183,7 @@ async def preview_ventas(
@router.post("/send-one")
async def send_one(
request: Request, user: dict = Depends(get_current_user),
idrecepcion: int = Form(...), query_id: int = Form(...),
factura_key: str = Form(...), query_id: int = Form(...),
factura: str = Form(""), fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
):
conn = get_connection()
@@ -196,9 +198,9 @@ async def send_one(
return JSONResponse({"success": False, "message": err})
grupos = _agrupar(rows)
grupo_rows = grupos.get(idrecepcion)
grupo_rows = grupos.get(factura_key)
if not grupo_rows:
return JSONResponse({"success": False, "message": f"Registro {idrecepcion} no encontrado"})
return JSONResponse({"success": False, "message": f"Factura {factura_key} no encontrada"})
token, token_err = await get_tns_token(
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
@@ -225,13 +227,13 @@ async def send_one(
except Exception as ex:
msg_tns = str(ex)
_guardar_envio(user["user_id"], factura_display, idrecepcion, contrato,
_guardar_envio(user["user_id"], factura_display, None, contrato,
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
log_activity(user["user_id"], user["username"], "venta_enviada",
f"Factura {factura_display} | {'OK' if ok_v else 'ERROR: '+msg_tns[:80]}",
get_ip(request))
return JSONResponse({"success": ok_v, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion})
return JSONResponse({"success": ok_v, "message": msg_tns, "raw_tns": raw_resp, "factura_key": factura_key})
@router.post("/send")
@@ -261,6 +263,7 @@ async def send_ventas(
if solo_pendientes == "1":
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
token, token_err = await get_tns_token(
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
)
@@ -272,11 +275,8 @@ async def send_ventas(
resultados = []
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
for key, grupo_rows in grupos.items():
for factura_key, grupo_rows in grupos.items():
venta_json = _build_venta(grupo_rows, cfg)
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
prefijo = str(grupo_rows[0].get("PREFIJO") or "").strip()
factura_display = f"{prefijo}-{num_fac}"
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
raw_resp = ""
ok_v = False
@@ -288,9 +288,9 @@ async def send_ventas(
except Exception as ex:
msg_tns = str(ex)
_guardar_envio(user["user_id"], factura_display, key, contrato,
_guardar_envio(user["user_id"], factura_key, None, contrato,
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
resultados.append({"idrecepcion": key, "success": ok_v, "msg": msg_tns})
resultados.append({"factura": factura_key, "success": ok_v, "msg": msg_tns})
ok_count = sum(1 for r in resultados if r["success"])
err_count = len(resultados) - ok_count
+32 -23
View File
@@ -193,37 +193,41 @@ function renderTabla() {
}
function filaHtml(item) {
const id = item.idrecepcion;
const id = item.factura_key;
const sid = escAttr(id);
const env = item.enviado;
const examsStr = Array.isArray(item.examenes) ? item.examenes.join(', ') : (item.examenes || '');
const rowId = 'row-' + id.replace(/[^a-zA-Z0-9]/g, '_');
const estadoId = 'estado-' + id.replace(/[^a-zA-Z0-9]/g, '_');
const btnId = 'btnenv-' + id.replace(/[^a-zA-Z0-9]/g, '_');
let badgeHtml, rowCls, accionHtml;
if (!env) {
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">Pendiente</span>`;
rowCls = '';
accionHtml = `<button onclick="enviarUno(${id})" title="Enviar" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded text-xs"><i class="fas fa-paper-plane"></i></button>`;
accionHtml = `<button onclick="enviarUno('${sid}')" title="Enviar" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded text-xs"><i class="fas fa-paper-plane"></i></button>`;
} else if (env.status === 'success') {
const at = (env.at || '').slice(0, 16).replace('T', ' ');
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700" title="Enviado ${at}">✓ Enviado</span>`;
rowCls = 'bg-green-50/50 opacity-80';
accionHtml = `<button onclick="enviarUno(${id})" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
accionHtml = `<button onclick="enviarUno('${sid}')" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
} else {
const tip = escAttr(env.mensaje || 'Error');
badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${tip}">✗ Error</span>`;
rowCls = 'bg-red-50/50';
accionHtml = `<button onclick="enviarUno(${id})" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
accionHtml = `<button onclick="enviarUno('${sid}')" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
}
return `<tr id="row-${id}" class="border-b border-gray-50 hover:bg-gray-50/80 transition-colors ${rowCls}">
<td class="py-2 pr-3 font-mono text-gray-600">${id}</td>
return `<tr id="${rowId}" class="border-b border-gray-50 hover:bg-gray-50/80 transition-colors ${rowCls}">
<td class="py-2 pr-3 font-mono text-gray-600">${escHtml(id)}</td>
<td class="py-2 pr-3 text-gray-600">${escHtml(String(item.factura || '-'))}</td>
<td class="py-2 pr-3 text-gray-600">${escHtml(String(item.paciente || '-'))}</td>
<td class="py-2 pr-3"><span class="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-xs font-mono">${escHtml(String(item.contrato || '-'))}</span></td>
<td class="py-2 pr-3 text-gray-500 max-w-[180px] truncate" title="${escAttr(examsStr)}">${escHtml(examsStr)}</td>
<td id="estado-${id}" class="py-2 pr-3">${badgeHtml}</td>
<td id="${estadoId}" class="py-2 pr-3">${badgeHtml}</td>
<td class="py-2 flex gap-1 items-center">
<button onclick="verDetalle(${id})" title="Ver JSON" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded text-xs"><i class="fas fa-code"></i></button>
<span id="btnenv-${id}">${accionHtml}</span>
<button onclick="verDetalle('${sid}')" title="Ver JSON" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded text-xs"><i class="fas fa-code"></i></button>
<span id="${btnId}">${accionHtml}</span>
</td>
</tr>`;
}
@@ -240,34 +244,39 @@ function actualizarResumen() {
document.getElementById('res-err-wrap').classList.toggle('hidden', err === 0);
}
function _safeId(id) { return id.replace(/[^a-zA-Z0-9]/g, '_'); }
function marcarFila(id, ok, mensaje, rawTns) {
const idx = allItems.findIndex(i => i.idrecepcion === id);
const idx = allItems.findIndex(i => i.factura_key === id);
if (idx !== -1) {
allItems[idx].enviado = { status: ok ? 'success' : 'error', mensaje, at: new Date().toISOString() };
if (rawTns !== undefined) allItems[idx]._rawTns = rawTns;
}
const row = document.getElementById(`row-${id}`);
const estadoCell = document.getElementById(`estado-${id}`);
const btnWrap = document.getElementById(`btnenv-${id}`);
const sid = escAttr(id);
const safe = _safeId(id);
const row = document.getElementById(`row-${safe}`);
const estadoCell = document.getElementById(`estado-${safe}`);
const btnWrap = document.getElementById(`btnenv-${safe}`);
if (!row || !estadoCell) return;
if (ok) {
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-green-50/50 opacity-80';
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>`;
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno(${id})" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno('${sid}')" title="Volver a enviar" class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
} else {
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50';
estadoCell.innerHTML = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${escAttr(mensaje||'')}">✗ Error</span>`;
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno(${id})" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
if (btnWrap) btnWrap.innerHTML = `<button onclick="enviarUno('${sid}')" title="Reintentar" class="px-2 py-1 bg-orange-500 hover:bg-orange-600 text-white rounded text-xs"><i class="fas fa-redo"></i></button>`;
}
actualizarResumen();
}
async function enviarUno(id) {
const btnWrap = document.getElementById(`btnenv-${id}`);
const safe = _safeId(id);
const btnWrap = document.getElementById(`btnenv-${safe}`);
if (btnWrap) btnWrap.innerHTML = '<span class="px-2 py-1 text-gray-400 text-xs"><i class="fas fa-spinner fa-spin"></i></span>';
const fd = new URLSearchParams({ ...formParams, idrecepcion: id });
const fd = new URLSearchParams({ ...formParams, factura_key: id });
let result;
try {
const resp = await fetch('/ventas/send-one', { method: 'POST', credentials: 'include', body: fd });
@@ -278,9 +287,9 @@ async function enviarUno(id) {
marcarFila(id, result.success, result.message, result.raw_tns);
if (result.success) {
showToast(`IDRECEPCION ${id}: enviado`, 'success');
showToast(`Factura ${escHtml(id)}: enviada`, 'success');
} else {
showToast(`IDRECEPCION ${id}: ${result.message}`, 'error');
showToast(`Factura ${escHtml(id)}: ${escHtml(result.message || 'Error')}`, 'error');
}
}
@@ -295,8 +304,8 @@ async function enviarTodosPendientes() {
let ok = 0, err = 0;
for (const item of pendientes) {
await enviarUno(item.idrecepcion);
if (allItems.find(i => i.idrecepcion === item.idrecepcion)?.enviado?.status === 'success') ok++; else err++;
await enviarUno(item.factura_key);
if (allItems.find(i => i.factura_key === item.factura_key)?.enviado?.status === 'success') ok++; else err++;
}
btn.disabled = false;
@@ -305,9 +314,9 @@ async function enviarTodosPendientes() {
}
function verDetalle(id) {
const item = allItems.find(i => i.idrecepcion === id);
const item = allItems.find(i => i.factura_key === id);
if (!item) return;
document.getElementById('modal-titulo').innerHTML = `<i class="fas fa-code mr-2 text-emerald-500"></i>IDRECEPCION ${id}${item.paciente || ''} — Factura ${item.factura || '-'}`;
document.getElementById('modal-titulo').innerHTML = `<i class="fas fa-code mr-2 text-emerald-500"></i>${escHtml(id)}${escHtml(item.paciente || '')} — Factura ${escHtml(item.factura || '-')}`;
document.getElementById('tab-json').innerHTML = syntaxHighlight(item.json || {});
const tnsPre = document.getElementById('tab-tns');
if (item.enviado) {
+1
View File
@@ -7,3 +7,4 @@ bcrypt==4.2.1
python-jose[cryptography]==3.3.0
httpx==0.28.1
fdb>=2.0.0
apscheduler>=3.10.0