feat: módulo de facturas venta (Ventas/Crear) para contrato 040
- generar_factura_venta en json_generator.py con mapeo completo del JSON - Queries default tipo 'ventas' (por fecha y por número) filtradas a contrato 040 - Ruta /ventas con preview, send-one y send masivo - Template ventas.html con tabla, resumen y modal JSON - Link en navegación lateral Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6fb18a7af6
commit
6b84ecfd90
@@ -187,6 +187,59 @@ WHERE ps.PS_NUMERO = :ps_numero
|
||||
ORDER BY ps.ID_PS, rel.COD_EXAMEN""",
|
||||
"description": "Pre-servicio por número PS (buscar RCXC05291 → escribe solo el número)"
|
||||
},
|
||||
{
|
||||
"name": "Factura Venta 040 por fecha",
|
||||
"query_type": "ventas",
|
||||
"query_text": """SELECT
|
||||
r.IDRECEPCION,
|
||||
r.PREFIJO,
|
||||
r.NUM_FACTURA,
|
||||
r.FECHA_RECEPCION,
|
||||
r.COD_PACIENTE,
|
||||
r.NIT_EMPRESA,
|
||||
r.VALORTOTAL,
|
||||
r.VALORDESC,
|
||||
rel.COD_EXAMEN,
|
||||
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||
rel.PRECIO,
|
||||
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||
e.CODCONTRATO
|
||||
FROM RECEPCION r
|
||||
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
|
||||
AND e.CODCONTRATO = '040'
|
||||
ORDER BY r.IDRECEPCION""",
|
||||
"description": "Facturas de venta contrato 040 en un rango de fechas"
|
||||
},
|
||||
{
|
||||
"name": "Factura Venta 040 por número",
|
||||
"query_type": "ventas",
|
||||
"query_text": """SELECT
|
||||
r.IDRECEPCION,
|
||||
r.PREFIJO,
|
||||
r.NUM_FACTURA,
|
||||
r.FECHA_RECEPCION,
|
||||
r.COD_PACIENTE,
|
||||
r.NIT_EMPRESA,
|
||||
r.VALORTOTAL,
|
||||
r.VALORDESC,
|
||||
rel.COD_EXAMEN,
|
||||
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
|
||||
rel.PRECIO,
|
||||
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
|
||||
e.CODCONTRATO
|
||||
FROM RECEPCION r
|
||||
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
|
||||
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
|
||||
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
|
||||
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN) AND t.TARIFA = e.TARIFA
|
||||
WHERE r.NUM_FACTURA = :num_factura
|
||||
AND e.CODCONTRATO = '040'""",
|
||||
"description": "Factura de venta contrato 040 por número de factura"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import json as json_lib
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.database import get_connection
|
||||
from app.auth import get_current_user
|
||||
from app.services.firebird_service import get_firebird_from_config
|
||||
from app.services.json_generator import generar_factura_venta
|
||||
from app.services.api_client import get_tns_token, TNS_BASE
|
||||
from app.utils.activity import log_activity, get_ip
|
||||
|
||||
router = APIRouter(prefix="/ventas", tags=["ventas"])
|
||||
|
||||
|
||||
def _cfg():
|
||||
conn = get_connection()
|
||||
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||
conn.close()
|
||||
return cfg
|
||||
|
||||
|
||||
def _query_rows(cfg, query_text, factura, fecha_inicio, fecha_fin):
|
||||
import re as _re
|
||||
fb, ok, msg = get_firebird_from_config(cfg)
|
||||
if not ok:
|
||||
return None, msg
|
||||
nums = _re.findall(r'\d+', factura or "")
|
||||
num_val = int(nums[-1]) if nums else 0
|
||||
prefix = _re.sub(r'[\d\s]', '', factura or "").strip().upper()
|
||||
|
||||
params = {}
|
||||
if ":fecha_ini" in query_text:
|
||||
params["fecha_ini"] = f"{fecha_inicio} 00:00:00"
|
||||
if ":fecha_fin" in query_text:
|
||||
params["fecha_fin"] = f"{fecha_fin} 23:59:59"
|
||||
if ":num_factura" in query_text:
|
||||
params["num_factura"] = num_val
|
||||
|
||||
ok2, err, rows = fb.execute_query(query_text, params)
|
||||
fb.disconnect()
|
||||
if not ok2:
|
||||
return None, err
|
||||
|
||||
if prefix and rows and "PREFIJO" in rows[0]:
|
||||
rows = [r for r in rows if str(r.get("PREFIJO") or "").strip().upper() == prefix]
|
||||
|
||||
return rows, None
|
||||
|
||||
|
||||
def _agrupar(rows):
|
||||
from collections import defaultdict
|
||||
grupos = defaultdict(list)
|
||||
for row in rows:
|
||||
grupos[row.get("IDRECEPCION")].append(dict(row))
|
||||
return dict(grupos)
|
||||
|
||||
|
||||
def _build_venta(grupo_rows, cfg):
|
||||
prefijo_def = cfg.get("prefijo_tns_default", "00")
|
||||
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
||||
return generar_factura_venta(grupo_rows, default_vendedor="00",
|
||||
default_prefijo=prefijo_def, numero_override=num_fac)
|
||||
|
||||
|
||||
def _is_sent(idrecepcion):
|
||||
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,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return {"status": row["status"], "mensaje": row["mensaje_tns"], "at": row["created_at"]}
|
||||
return None
|
||||
|
||||
|
||||
def _guardar_envio(user_id, factura, idrecepcion, contrato, json_data,
|
||||
respuesta, ok, fecha_inicio, fecha_fin, servicios):
|
||||
try:
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT INTO envios (user_id, tipo, factura, idrecepcion, contrato,
|
||||
fecha_inicio, fecha_fin, pacientes_count, servicios_count,
|
||||
status, json_enviado, respuesta_api, mensaje_tns, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
user_id, "ventas", factura, idrecepcion, contrato,
|
||||
fecha_inicio, fecha_fin, 1, servicios,
|
||||
"success" if ok else "error",
|
||||
json_lib.dumps(json_data, ensure_ascii=False)[:10000],
|
||||
respuesta[:2000] if respuesta else "",
|
||||
respuesta[:300] if respuesta else "",
|
||||
datetime.now().isoformat(),
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_tns(r):
|
||||
try:
|
||||
data = r.json()
|
||||
ok = bool(data.get("status") or (data.get("data") or {}).get("success", False))
|
||||
msg = ((data.get("data") or {}).get("response") or data.get("message") or r.text[:300])
|
||||
except Exception:
|
||||
ok = r.status_code < 300
|
||||
msg = r.text[:300]
|
||||
return ok, msg
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def ventas_page(request: Request, user: dict = Depends(get_current_user)):
|
||||
conn = get_connection()
|
||||
queries = conn.execute(
|
||||
"SELECT * FROM queries WHERE query_type = 'ventas' ORDER BY name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return request.app.state.templates.TemplateResponse("ventas.html", {
|
||||
"request": request, "user": user, "queries": queries,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview_ventas(
|
||||
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()
|
||||
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||
conn.close()
|
||||
if not q:
|
||||
return JSONResponse({"success": False, "message": "Consulta no encontrada"})
|
||||
|
||||
rows, err = _query_rows(cfg, q["query_text"], factura, fecha_inicio, fecha_fin)
|
||||
if rows is None:
|
||||
return JSONResponse({"success": False, "message": err})
|
||||
if not rows:
|
||||
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||
|
||||
grupos = _agrupar(rows)
|
||||
items = []
|
||||
for key, grupo_rows in grupos.items():
|
||||
enviado = _is_sent(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}",
|
||||
"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],
|
||||
"valor": float(grupo_rows[0].get("VALORTOTAL") or 0),
|
||||
"enviado": enviado,
|
||||
"json": venta_json,
|
||||
})
|
||||
|
||||
pendientes = sum(1 for i in items if not i["enviado"])
|
||||
enviados_ok = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "success")
|
||||
enviados_err = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "error")
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"total": len(items),
|
||||
"pendientes": pendientes,
|
||||
"enviados_ok": enviados_ok,
|
||||
"enviados_err": enviados_err,
|
||||
"items": items,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/send-one")
|
||||
async def send_one(
|
||||
request: Request, user: dict = Depends(get_current_user),
|
||||
idrecepcion: int = Form(...), 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()
|
||||
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||
conn.close()
|
||||
|
||||
rows, err = _query_rows(cfg, q["query_text"], factura, fecha_inicio, fecha_fin)
|
||||
if rows is None:
|
||||
return JSONResponse({"success": False, "message": err})
|
||||
|
||||
grupos = _agrupar(rows)
|
||||
grupo_rows = grupos.get(idrecepcion)
|
||||
if not grupo_rows:
|
||||
return JSONResponse({"success": False, "message": f"Registro {idrecepcion} no encontrado"})
|
||||
|
||||
token, token_err = await get_tns_token(
|
||||
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
|
||||
)
|
||||
if not token:
|
||||
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
|
||||
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear"
|
||||
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
|
||||
msg_tns = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||
r = await client.post(endpoint, json=venta_json, headers=headers)
|
||||
raw_resp = r.text
|
||||
ok_v, msg_tns = _parse_tns(r)
|
||||
except Exception as ex:
|
||||
msg_tns = str(ex)
|
||||
|
||||
_guardar_envio(user["user_id"], factura_display, idrecepcion, 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})
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
async def send_ventas(
|
||||
request: Request, user: dict = Depends(get_current_user),
|
||||
query_id: int = Form(...), factura: str = Form(""),
|
||||
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
|
||||
solo_pendientes: str = Form("0"),
|
||||
):
|
||||
conn = get_connection()
|
||||
q = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
|
||||
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
|
||||
conn.close()
|
||||
|
||||
rows, err = _query_rows(cfg, q["query_text"], factura, fecha_inicio, fecha_fin)
|
||||
if rows is None:
|
||||
return JSONResponse({"success": False, "message": err})
|
||||
if not rows:
|
||||
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||
|
||||
grupos = _agrupar(rows)
|
||||
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", "")
|
||||
)
|
||||
if not token:
|
||||
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
|
||||
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear"
|
||||
|
||||
resultados = []
|
||||
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||
for 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
|
||||
msg_tns = ""
|
||||
try:
|
||||
r = await client.post(endpoint, json=venta_json, headers=headers)
|
||||
raw_resp = r.text
|
||||
ok_v, msg_tns = _parse_tns(r)
|
||||
except Exception as ex:
|
||||
msg_tns = str(ex)
|
||||
|
||||
_guardar_envio(user["user_id"], factura_display, key, contrato,
|
||||
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
|
||||
resultados.append({"idrecepcion": key, "success": ok_v, "msg": msg_tns})
|
||||
|
||||
ok_count = sum(1 for r in resultados if r["success"])
|
||||
err_count = len(resultados) - ok_count
|
||||
log_activity(user["user_id"], user["username"], "ventas_masivo",
|
||||
f"Enviados: {ok_count} OK, {err_count} errores | {fecha_inicio} → {fecha_fin}",
|
||||
get_ip(request))
|
||||
return JSONResponse({
|
||||
"success": err_count == 0,
|
||||
"total_enviados": ok_count,
|
||||
"total_errores": err_count,
|
||||
"resultados": resultados,
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import re as _re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
# v1.4.0
|
||||
# v1.5.0
|
||||
|
||||
|
||||
# ── helpers de formato de fecha ──────────────────────────────────────────────
|
||||
@@ -245,6 +245,74 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
|
||||
return _clean_times(result)
|
||||
|
||||
|
||||
# ── Ventas/Crear ─────────────────────────────────────────────────────────────
|
||||
|
||||
def generar_factura_venta(rows: list, default_vendedor: str = "00",
|
||||
default_prefijo: str = "00", numero_override: str = "") -> dict:
|
||||
if not rows:
|
||||
return {}
|
||||
h = rows[0]
|
||||
|
||||
num_fac = (numero_override if numero_override else str(h.get("NUM_FACTURA") or "")).zfill(5)
|
||||
fecha = _fmt_fecha(h.get("FECHA_RECEPCION"))
|
||||
|
||||
detalle_pedido = []
|
||||
for row in rows:
|
||||
precio = float(row.get("PRECIO_TARIFA") or row.get("PRECIO") or 0)
|
||||
detalle_pedido.append({
|
||||
"codMat": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(),
|
||||
"codBodega": "00",
|
||||
"codTalla": "",
|
||||
"codColor": "",
|
||||
"cantidad": 1,
|
||||
"tipoUnidad": "M",
|
||||
"descuento": 0,
|
||||
"descuentoValor": 0,
|
||||
"precioExcento": precio,
|
||||
"centrosCostos": "00",
|
||||
"porcIva": 0,
|
||||
"valor": precio,
|
||||
"impConsumo": 0,
|
||||
"observacion": "",
|
||||
"lote": "",
|
||||
"fechaVenceLote": "",
|
||||
"nroDocumento": "",
|
||||
"itemsSerial": [],
|
||||
"tipoSerial": "",
|
||||
})
|
||||
|
||||
return _clean_times({
|
||||
"codigoPrefijo": default_prefijo or "00",
|
||||
"numero": num_fac,
|
||||
"numeroFactura": num_fac,
|
||||
"fecha": fecha,
|
||||
"kardexId": 0,
|
||||
"codigoPedido": "",
|
||||
"nombreCliente": "",
|
||||
"codTercero": str(h.get("COD_PACIENTE") or "").strip(),
|
||||
"codVendedor": default_vendedor or "00",
|
||||
"codDespachar": "00",
|
||||
"codFormaPago": "CO",
|
||||
"codBanco": "00",
|
||||
"fechaVence": fecha,
|
||||
"fechaEntrega": fecha,
|
||||
"plazoDias": 30,
|
||||
"observacion": "",
|
||||
"latitud": "",
|
||||
"longitud": "",
|
||||
"motivo": "",
|
||||
"numeroFacturaDevolucion": "",
|
||||
"tipoOperacion": "",
|
||||
"codigoCentroCosto": "00",
|
||||
"codigoArea": "00",
|
||||
"terminal": "00",
|
||||
"detallePedido": detalle_pedido,
|
||||
"detalleFormaPago": [],
|
||||
"asentar": 0,
|
||||
"detalleDescuentos": [],
|
||||
})
|
||||
|
||||
|
||||
# ── funciones legacy RIPS 2.0 (se mantienen) ─────────────────────────────────
|
||||
|
||||
def generar_terceros(row: dict) -> dict:
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
<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="/ventas" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/ventas' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
|
||||
<i class="fas fa-file-invoice-dollar w-5 mr-2"></i> Facturas Venta
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Facturas Venta{% endblock %}
|
||||
{% block header %}Envío de Facturas Venta{% endblock %}
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Formulario -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<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-emerald-500"></i>Parámetros</h3>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Consulta SQL</label>
|
||||
<select id="f-query" 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-xs font-medium text-gray-600 mb-1">Factura <span class="text-gray-400">(opcional)</span></label>
|
||||
<input id="f-factura" type="text" placeholder="Ej: LHXC03404"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Fecha inicio</label>
|
||||
<input id="f-fi" type="date" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Fecha fin</label>
|
||||
<input id="f-ff" type="date" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="cargarRegistros()" id="btn-cargar"
|
||||
class="w-full px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium">
|
||||
<i class="fas fa-search mr-1"></i> Cargar registros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resumen -->
|
||||
<div id="resumen-box" class="hidden bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="grid grid-cols-3 gap-2 text-center mb-4">
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div id="res-total" class="text-xl font-bold text-gray-700">0</div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-3">
|
||||
<div id="res-ok" class="text-xl font-bold text-green-600">0</div>
|
||||
<div class="text-xs text-gray-500">Enviados</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-3">
|
||||
<div id="res-pend" class="text-xl font-bold text-yellow-600">0</div>
|
||||
<div class="text-xs text-gray-500">Pendientes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="res-err-wrap" class="hidden mb-3">
|
||||
<div class="bg-red-50 rounded-lg p-3 text-center">
|
||||
<div id="res-err" class="text-xl font-bold text-red-600">0</div>
|
||||
<div class="text-xs text-gray-500">Con error</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="enviarTodosPendientes()" id="btn-enviar-pend"
|
||||
class="flex-1 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-medium">
|
||||
<i class="fas fa-paper-plane mr-1"></i> Enviar pendientes
|
||||
</button>
|
||||
<button onclick="cargarRegistros()"
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-xs" title="Actualizar">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<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 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-800"><i class="fas fa-file-invoice-dollar mr-2 text-emerald-500"></i>Facturas</h3>
|
||||
<span id="tabla-count" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<div id="tabla-wrap" class="p-6">
|
||||
<div class="text-center py-12 text-gray-400 text-sm">
|
||||
<i class="fas fa-file-invoice text-4xl block mb-3 opacity-30"></i>
|
||||
Carga los registros para ver el estado de envío
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal JSON -->
|
||||
<div id="modal-detalle" class="fixed inset-0 z-50 hidden">
|
||||
<div class="absolute inset-0 bg-black/60" onclick="closeModal()"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-3xl bg-white rounded-xl shadow-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center shrink-0">
|
||||
<h3 id="modal-titulo" class="font-semibold text-gray-800"><i class="fas fa-code mr-2 text-emerald-500"></i>Detalle</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="flex border-b border-gray-200 shrink-0 px-4">
|
||||
<button onclick="showTab('tab-json')" id="t-json" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-emerald-500 text-emerald-600 -mb-px">JSON enviado</button>
|
||||
<button onclick="showTab('tab-tns')" id="t-tns" class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">Respuesta TNS</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 p-5">
|
||||
<pre id="tab-json" class="tab-pane text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<pre id="tab-tns" class="tab-pane hidden text-xs font-mono bg-yellow-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-yellow-900"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
let formParams = {};
|
||||
|
||||
(function() {
|
||||
const today = new Date();
|
||||
const ymd = d => d.toISOString().slice(0, 10);
|
||||
document.getElementById('f-ff').value = ymd(today);
|
||||
document.getElementById('f-fi').value = ymd(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
})();
|
||||
|
||||
function getParams() {
|
||||
return {
|
||||
query_id: document.getElementById('f-query').value,
|
||||
factura: document.getElementById('f-factura').value,
|
||||
fecha_inicio: document.getElementById('f-fi').value,
|
||||
fecha_fin: document.getElementById('f-ff').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function cargarRegistros() {
|
||||
formParams = getParams();
|
||||
if (!formParams.query_id) { showToast('Selecciona una consulta SQL', 'warning'); return; }
|
||||
if (!formParams.fecha_inicio || !formParams.fecha_fin) { showToast('Ingresa las fechas', 'warning'); return; }
|
||||
|
||||
document.getElementById('tabla-wrap').innerHTML = `
|
||||
<div class="text-center py-12 text-gray-400 text-sm">
|
||||
<i class="fas fa-spinner fa-spin text-3xl block mb-3"></i>Consultando Firebird...
|
||||
</div>`;
|
||||
document.getElementById('resumen-box').classList.add('hidden');
|
||||
|
||||
const resp = await fetch('/ventas/preview', {
|
||||
method: 'POST', credentials: 'include',
|
||||
body: new URLSearchParams(formParams),
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (!result.success) {
|
||||
document.getElementById('tabla-wrap').innerHTML = `
|
||||
<div class="text-center py-10 text-red-500 text-sm">
|
||||
<i class="fas fa-exclamation-circle text-3xl block mb-2"></i>
|
||||
<strong>Error:</strong> ${escHtml(result.message)}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = result.items;
|
||||
renderTabla();
|
||||
actualizarResumen();
|
||||
document.getElementById('resumen-box').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function renderTabla() {
|
||||
if (!allItems.length) {
|
||||
document.getElementById('tabla-wrap').innerHTML = '<div class="text-center py-10 text-gray-400 text-sm">Sin registros para los parámetros indicados</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('tabla-count').textContent = `${allItems.length} facturas`;
|
||||
|
||||
let html = `<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead><tr class="text-left text-gray-400 border-b border-gray-200">
|
||||
<th class="pb-2 font-medium pr-3">IDRECEP.</th>
|
||||
<th class="pb-2 font-medium pr-3">Factura</th>
|
||||
<th class="pb-2 font-medium pr-3">Paciente</th>
|
||||
<th class="pb-2 font-medium pr-3">Exámenes</th>
|
||||
<th class="pb-2 font-medium pr-3">Estado</th>
|
||||
<th class="pb-2 font-medium"></th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const item of allItems) {
|
||||
html += filaHtml(item);
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('tabla-wrap').innerHTML = html;
|
||||
}
|
||||
|
||||
function filaHtml(item) {
|
||||
const id = item.idrecepcion;
|
||||
const env = item.enviado;
|
||||
const examsStr = Array.isArray(item.examenes) ? item.examenes.join(', ') : (item.examenes || '');
|
||||
|
||||
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>`;
|
||||
} 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>`;
|
||||
} 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>`;
|
||||
}
|
||||
|
||||
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>
|
||||
<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 text-gray-500 max-w-[200px] truncate" title="${escAttr(examsStr)}">${escHtml(examsStr)}</td>
|
||||
<td id="estado-${id}" 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>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function actualizarResumen() {
|
||||
const total = allItems.length;
|
||||
const ok = allItems.filter(i => i.enviado && i.enviado.status === 'success').length;
|
||||
const pend = allItems.filter(i => !i.enviado).length;
|
||||
const err = allItems.filter(i => i.enviado && i.enviado.status === 'error').length;
|
||||
document.getElementById('res-total').textContent = total;
|
||||
document.getElementById('res-ok').textContent = ok;
|
||||
document.getElementById('res-pend').textContent = pend;
|
||||
document.getElementById('res-err').textContent = err;
|
||||
document.getElementById('res-err-wrap').classList.toggle('hidden', err === 0);
|
||||
}
|
||||
|
||||
function marcarFila(id, ok, mensaje, rawTns) {
|
||||
const idx = allItems.findIndex(i => i.idrecepcion === 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}`);
|
||||
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>`;
|
||||
} 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>`;
|
||||
}
|
||||
actualizarResumen();
|
||||
}
|
||||
|
||||
async function enviarUno(id) {
|
||||
const btnWrap = document.getElementById(`btnenv-${id}`);
|
||||
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 });
|
||||
let result;
|
||||
try {
|
||||
const resp = await fetch('/ventas/send-one', { method: 'POST', credentials: 'include', body: fd });
|
||||
result = await resp.json();
|
||||
} catch (e) {
|
||||
result = { success: false, message: String(e) };
|
||||
}
|
||||
|
||||
marcarFila(id, result.success, result.message, result.raw_tns);
|
||||
if (result.success) {
|
||||
showToast(`IDRECEPCION ${id}: enviado`, 'success');
|
||||
} else {
|
||||
showToast(`IDRECEPCION ${id}: ${result.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarTodosPendientes() {
|
||||
const pendientes = allItems.filter(i => !i.enviado);
|
||||
if (!pendientes.length) { showToast('No hay registros pendientes', 'info'); return; }
|
||||
if (!confirm(`¿Enviar ${pendientes.length} facturas pendientes?`)) return;
|
||||
|
||||
const btn = document.getElementById('btn-enviar-pend');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Enviando...';
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane mr-1"></i> Enviar pendientes';
|
||||
showToast(`Completado: ${ok} enviados, ${err} errores`, err === 0 ? 'success' : 'warning');
|
||||
}
|
||||
|
||||
function verDetalle(id) {
|
||||
const item = allItems.find(i => i.idrecepcion === 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('tab-json').innerHTML = syntaxHighlight(item.json || {});
|
||||
const tnsPre = document.getElementById('tab-tns');
|
||||
if (item.enviado) {
|
||||
let tnsText = item._rawTns || item.enviado.mensaje || '(sin respuesta)';
|
||||
try { tnsText = JSON.stringify(JSON.parse(tnsText), null, 2); } catch(e) {}
|
||||
tnsPre.textContent = tnsText;
|
||||
} else {
|
||||
tnsPre.textContent = '(aún no enviado)';
|
||||
}
|
||||
showTab('tab-json');
|
||||
document.getElementById('modal-detalle').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showTab(tabId) {
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.classList.remove('border-emerald-500', 'text-emerald-600');
|
||||
b.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
const btnId = tabId === 'tab-json' ? 't-json' : 't-tns';
|
||||
const btn = document.getElementById(btnId);
|
||||
btn.classList.add('border-emerald-500', 'text-emerald-600');
|
||||
btn.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-detalle').classList.add('hidden');
|
||||
}
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
||||
|
||||
function syntaxHighlight(obj) {
|
||||
let json = JSON.stringify(obj, null, 2);
|
||||
json = json.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return json
|
||||
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
|
||||
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
|
||||
.replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
|
||||
.replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -67,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, contratos
|
||||
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes, contratos, ventas
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
@@ -81,6 +81,7 @@ app.include_router(test_rda.router)
|
||||
app.include_router(debug_fb.router)
|
||||
app.include_router(pacientes.router)
|
||||
app.include_router(contratos.router)
|
||||
app.include_router(ventas.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user