diff --git a/app/routes/queries.py b/app/routes/queries.py index 271a775..4543102 100644 --- a/app/routes/queries.py +++ b/app/routes/queries.py @@ -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" + }, ] diff --git a/app/routes/ventas.py b/app/routes/ventas.py new file mode 100644 index 0000000..a780882 --- /dev/null +++ b/app/routes/ventas.py @@ -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, + }) diff --git a/app/services/json_generator.py b/app/services/json_generator.py index b5068ac..0f3aa28 100644 --- a/app/services/json_generator.py +++ b/app/services/json_generator.py @@ -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: diff --git a/app/templates/base.html b/app/templates/base.html index 12e41ee..6ccf29c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -57,6 +57,9 @@ Transacción RIPS + + Facturas Venta + Automatización diff --git a/app/templates/ventas.html b/app/templates/ventas.html new file mode 100644 index 0000000..19b070b --- /dev/null +++ b/app/templates/ventas.html @@ -0,0 +1,352 @@ +{% extends "base.html" %} +{% block title %}Facturas Venta{% endblock %} +{% block header %}Envío de Facturas Venta{% endblock %} +{% block content %} +
+ + +
+
+
+

Parámetros

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + + +
+ + +
+
+
+

Facturas

+ +
+
+
+ + Carga los registros para ver el estado de envío +
+
+
+
+
+ + + + + +{% endblock %} diff --git a/main.py b/main.py index f704972..dcd9ed7 100644 --- a/main.py +++ b/main.py @@ -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__":