Compare commits

..
154 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 e5d7a0ad45 fix(docs): quitar credenciales admin de la documentación pública
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:53:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 15cf6b3675 fix(docs): cambiar ruta /docs → /documentacion para evitar conflicto con Swagger FastAPI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:47:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b927fd7ec0 fix(print): agregar @page para reducir márgenes del navegador al imprimir
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 18:05:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eed3c8abd6 fix(print): ocultar sidebar y eliminar fondos de color al imprimir
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 15:49:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 400e6ff60b feat(docs): sección de documentación completa en /docs
- Nueva ruta /docs con manual de usuario, arquitectura y referencia técnica
- Sidebar con navegación sticky y resaltado de sección activa
- 23 secciones: objetivo, arquitectura, todos los módulos, JSONs de referencia,
  lógica de formas de pago, exclusiones y soporte
- Link "Documentación" en el sidebar de navegación principal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 15:45:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cfba7a657f fix(contratos): ampliar contenedor a max-w-full para mostrar columna Acciones
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:45:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 91dbcc8135 feat(contratos): gestión de códigos de pago + asignación por contrato
- Nueva tabla SQLite codigos_pago (codigo, nombre) con seed CIAC/CR/MU
- Columna cod_forma_pago en contratos (override por contrato)
- Página contratos: tarjeta para agregar/eliminar códigos, select inline por fila
- generar_factura_venta acepta forma_pago_override; si asignado lo usa, sino auto CIAC/CR
- Rutas: /codigos-pago/create, /codigos-pago/delete, /set-forma-pago

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:43:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 551dec9c11 feat(ventas): codFormaPago dinámico CIAC/CR según total + fechaVence por DIASVENC
- SQL trae fd.DIASVENC en ambas queries de ventas
- total==0 → codFormaPago=CIAC, plazoDias=0, fechaVence=fechaFactura
- total>0  → codFormaPago=CR,   plazoDias=DIASVENC, fechaVence=FECHAFACT+DIASVENC días

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:10:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6a54b61a2e fix: ventas JSON codFormaPago=MU, codBanco=""
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:06:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 64682de147 fix: ventas JSON uses FECHAFACT (billing date) instead of FECHA_RECEPCION
- generar_factura_venta: fecha = FECHAFACT with fallback to FECHA_RECEPCION
- _SQL_VENTAS_BY_FACTURA: add JOIN FACTURA_DIAN to fetch FECHAFACT for send-one

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 19:35:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f4f135ba29 fix: add ?codigosucursal to Ventas/Crear URL in ventas.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 19:07:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b5b6258270 fix: add sucursal 00 to ventas JSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 19:03:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2053d8a36f fix: codTercero uses full NIT with dash format (e.g. 900123456-7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 18:28:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 58dc364502 fix: ventas JSON - remove precioExcento, include NIT verification digit in codTercero
- Remove precioExcento field from detallePedido (not required by TNS)
- codTercero now uses full NIT without dash (e.g. 900123456-7 -> 9001234567)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 18:27:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 22e42463c7 fix: ventas page uses same SQL as automation (FACTURA_DIAN + FECHAFACT)
- Hardcode _SQL_VENTAS and _SQL_VENTAS_BY_FACTURA matching automation.py
- Remove stored query selector from UI — ventas always queries CMXC by billing date
- send-one now fetches factura directly by PREFIJO+NUM_FACTURA (no date range requery)
- Remove query_id dependency from all endpoints and JS formParams

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 17:28:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c0e66989ca fix: ventas page only shows CMXC records, remove duplicate column
- _query_rows now always filters by PREFIJO='CMXC' (unless user specifies another prefix in the factura field)
- Removed redundant first column IDRECEP from ventas table (factura_key and factura were identical)
- Renamed header to just Factura

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 17:23:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8ed8f0999f 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>
2026-07-28 17:08:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c1925ef6da fix: cast FECHAFACT to TIMESTAMP for BETWEEN comparison
FACTURA_DIAN.FECHAFACT is DATE type, incompatible with datetime strings.
CAST to TIMESTAMP allows comparison with :fecha_ini/:fecha_fin params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:55:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 33bd25c6c8 fix: reenviar-venta now fetches all receptions of the factura
Previously used IDRECEPCION to fetch only one reception's services.
Now resolves PREFIJO+NUM_FACTURA from the reception, then fetches all
receptions sharing that factura so the full detallePedido is sent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:46:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 090890fa46 fix: filter ventas by FECHAFACT (billing date) instead of FECHA_RECEPCION
Joins FACTURA_DIAN to get the actual invoice date. Receptions spread
across multiple days now appear together when their factura was billed
on the selected date. Excludes annulled invoices (ANULADA != 'F').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:45:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9dd234e124 debug: add /debug-fb/facturas endpoint to search by NUM_FACTURA range
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:13:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 55bb34a1fa chore: remove debug fields from preview response, simplify ventas filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:00:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7ac17e29a9 fix: remove duplicate agrupar_por_factura that was overriding the correct one
The second definition (for RIPS 2.0 transacción grouping) was never called
but silently overrode the CMXC ventas grouper, causing grupos_vta to always
be empty (values were dicts, not lists, so isinstance(v, list) was False).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:45:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8747ff950e ui: show ventas generation errors as toast in preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:38:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c463c6ecc3 debug: catch generar_factura_venta exceptions in preview, expose in response
Instead of crashing with 500, catches the error and exposes it in
ventas_gen_errors field so it can be diagnosed from the browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:31:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 29ab01e729 debug: add /debug-fb/preview-ventas GET endpoint to trace filter logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:29:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0e837e4a2d debug: run actual _SQL_VENTAS query in /debug-fb/cmxc to compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:26:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9e6f1171c6 debug: show SQLite contratos excluir_ventas state in /debug-fb/cmxc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:25:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 27b15b184c debug: add /debug-fb/cmxc endpoint for direct Firebird CMXC query
Shows IDRECEPCION, PREFIJO, NUM_FACTURA, CODCONTRATO, N_EXAMENES
for all CMXC receptions on a given date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:21:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b8bee68d6a debug: expose ventas filter info in preview response
ventas_raw = filas de Firebird antes de filtrar
ventas_grupos_total = grupos por factura antes del filtro
ventas_filtradas = grupos excluidos con motivo (sin_factura / excluido_ventas)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:10:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c58ecc1757 fix: defensive grouping filter in preview and run ventas endpoints
Replace dict comprehension with explicit loop so that non-list or empty
grupo values are safely skipped, preventing KeyError: 0 on v[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:05:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3a0964e4ec fix: generar_factura_venta uses PREFIJO from row, not config default
codigoPrefijo in ventas JSON must be the actual Firebird PREFIJO
(e.g. "CMXC"), not the prefijo_tns_default config value which is
intended for RDA/transaccion. Falls back to default_prefijo if row
has no PREFIJO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:59:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ae90eaf71c config: increase scheduler ventana from 10 to 60 minutes
Doctors can order exams up to ~45 min after the patient is registered
at reception. With 10 min window the exam sync missed those cases.
60 min ensures late-ordered exams are always captured and pushed to
WhatsApp Lab via upsert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:44:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 408325192a fix: use FECHA_RECEPCION for exam query in scheduler, not HORAINICIORECEPCION
HORAINICIORECEPCION is a TIME field that can be NULL or outside the
2-minute window even when FECHA_RECEPCION matches, causing patients
to sync without their exams. Using the same FECHA_RECEPCION filter
for both queries ensures consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:32:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7f2dffc63e fix: group ventas by NUM_FACTURA, not IDRECEPCION
A single CMXC invoice (e.g. CMXC-307) can have multiple receptions.
Previously each reception was sent as a separate ventas, causing TNS
to accept the first and reject the rest with "ya existe" — leaving
only 1/N of the services registered.

Now agrupar_por_factura groups all receptions sharing the same
NUM_FACTURA into one ventas JSON with all their services in
detallePedido. One factura = one send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 17:36:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 531fb29122 fix: group terceros by cedula not fecha-as-factura
For terceros, _guardar_envio stores the automation date in the factura
field (not a patient id), so COALESCE(factura, cedula) still collapsed
all terceros into one row. Now GROUP BY uses cedula for tipo=terceros
and factura for transaccion/ventas. Template hides the date in the
factura column for terceros rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 21:45:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4b70f6f2fa fix: resumen - group terceros by cedula, prioritize success over warning
GROUP BY COALESCE(factura, cedula) fixes terceros (factura=NULL)
collapsing into one row — each paciente now gets its own row.
CASE WHEN in estado_final explicitly prioritizes success > warning > error
instead of relying on alphabetical MAX which returned warning over success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 21:34:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6e9ee32646 fix: group resumen by factura+tipo only, not cedula
Grouping by cedula caused envíos with null/different cedula to split
into separate groups, making success envíos invisible — factura 4989
showed error persistente despite having a success send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 18:09:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ab2ef167f9 feat: add Resumen Diario TNS page as submodule
Groups envios by factura+tipo per day and shows estado_final
(exitoso if any send succeeded, error persistente if all failed).
Supports inline reenvío and JSON detail modal. Added to sidebar
under Historial TNS and as TNS submodule link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 17:59:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3859095f17 fix: contrato 050 tipo_usuario 11 → 12 (particular)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 10:33:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f0c4436622 fix: separar routing CMXC (Ventas) vs LHXC (RDA) por prefijo en SQL
- _SQL_PACIENTES y _SQL_RDA: excluyen PREFIJO = 'CMXC'
- _SQL_VENTAS: solo trae PREFIJO = 'CMXC'
- Contrato 050: excluir_ventas=0 para permitir CMXC+050 en Ventas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 08:48:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ebc24bd480 feat: agregar contrato 050 (PARTICULAR COLEGAS) — va a RDA, no a Ventas
Contrato 050 agrupa ~30 laboratorios y clínicas externas (CMXC/LHXC).
Debe enviarse a RDA pero no a Ventas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 08:37:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 02b0defef5 fix: ventas CMXC usan NIT_EMPRESA como codTercero en lugar del código de paciente
Las facturas de venta de contratos externos (ej. CMXC) deben ir a nombre
de la empresa referente (NIT sin dígito de verificación), no del paciente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 19:08:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cefc8b664b fix: automation.html usa status string (success/warning/error) no booleano ok
- Fila warning: amarillo, sin botón reenviar
- Tab 'Ya existe' en filtros para ver solo warnings
- Filtros basados en status en lugar de ok boolean
- Toast muestra conteo correcto de 3 estados

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 11:02:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6a452cd950 feat: sistema de 3 estados (success/warning/error) para envíos TNS
- _parse_tns_resp retorna 'success'|'warning'|'error' en lugar de bool
- 'ya existe/registrado/autorización' → warning (sin reenvío, no es error real)
- errores reales → error (con reenvío)
- Guarda JSON crudo completo en respuesta_api
- logs.html: badge amarillo para warning, verde para success, rojo para error
- Filtro de historial incluye opción 'warning'
- reenvío en logs.py solo disponible para status='error'
- reenvío en logs.py usa misma lógica _parse_tns_resp y soporta ventas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:45:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a37c1bb7d6 fix: _parse_tns_resp distingue status booleano (TNS) de entero HTTP (ASP.NET)
bool(400) era True en Python, haciendo que errores de validación de ASP.NET
(status:400, errors:{campo:[msg]}) se guardaran como success. Ahora:
- status bool → ok directamente
- status int → ok si < 400
- errors dict → mensaje legible "campo: mensaje"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:34:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2525e053bf fix: agregar codigosucursal al endpoint de ventas en automation
TNS requiere ?codigosucursal= en /v2/facturacion/Ventas/Crear igual que en RdaPaciente/Insertar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:21:05 -05:00
Lizandro Guarnizo 6950e5aaed tmp: debug script exploración muestras pendientes Firebird 2026-07-23 12:17:10 -05:00
Lizandro Guarnizo fff34d2003 tmp: debug script para verificar TIPOIDENT en Firebird 2026-07-23 12:09:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 59ee62591c chore: bajar intervalo scheduler a 20 segundos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:00:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 78e9c31d90 fix: usar FECHA_RECEPCION en SQL del scheduler en lugar de filtro Python por HORAINICIORECEPCION
El filtro Python era frágil: si HORAINICIORECEPCION era NULL o tenía formato inesperado, los pacientes no se capturaban. Ahora el filtro de ventana de tiempo va directo en Firebird usando DATEADD sobre FECHA_RECEPCION, que siempre está poblado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:38:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cffeae7e94 fix: parallelize WA sync and surface scheduler errors in historial
- whatsapp_sync: send patients concurrently (asyncio.gather + semaphore=10) instead of sequentially — eliminates long waits with many patients
- envios_erp: auto-refresh historial every 60s when scheduler tab is active
- main: wrap scheduler job to catch exceptions and log them to sync_wa_log so failures are visible in historial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:34:30 -05:00
Lizandro Guarnizo 5ca97159b4 tmp: debug query paciente 88272630 en Firebird 2026-07-22 23:22:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9bffe4209f fix(automation): parsear respuesta TNS correctamente, tratar 'ya existe' como OK
- _parse_tns_resp() centraliza parsing: lee status/data.success del body JSON
- "ya esta registrado", "ya existe autorización" → ok=True (no bloquea RDA)
- Aplica en los 4 pasos; terceros_fallidos solo acumula errores reales
- Elimina script tmp buscar_paciente.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 23:12:32 -05:00
Lizandro Guarnizo 988ac672e9 tmp: ver respuesta_api tercero fallido 2026-07-22 23:07:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 66d44392ee fix(automation): guardar mensaje_tns en _guardar_envio; eliminar script tmp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 23:01:27 -05:00
Lizandro Guarnizo 36ace04c35 tmp: script buscar paciente en db 2026-07-22 22:52:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9bd247219e fix(automation): skip RDA/preserv si el tercero del paciente falló
Acumula COD_PACIENTE en terceros_fallidos cuando paso1 falla.
En paso2 y paso3, si el paciente está en esa lista se omite el envío
con mensaje claro "Omitido: tercero no creado en TNS".
Evita el error confuso "paciente no existe" en TNS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:47:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 599391739f feat(logs): filtro por cédula de paciente en historial de envíos
- Agrega columna cedula a tabla envios (migración automática)
- _guardar_envio almacena la cédula en los 4 pasos de automation
- pac_map (COD_PACIENTE→DOCIDENT) para RDA/preserv/ventas
- logs.py busca por cedula LIKE en vez de idrecepcion/contrato
- logs.html etiqueta el input como "Cédula paciente"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:37:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 03655427a5 fix(rips): discapacidad siempre 08, diagnostico default Z017 si vacío
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:33:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0a234f056c fix: execute_query now handles positional tuple params directly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:26:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 329157ca7a fix: use DATEADD(MINUTE, -n, CURRENT_TIMESTAMP) for Firebird interval
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:24:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 04d2eb5563 fix: remove NUM_FACTURA > 0 filter — sync all receptions by time window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:03:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a880b7fb81 feat: filter exams by time window in SQL (last N minutes), default 10min
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:59:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 35859e8d75 fix: auto-load libfbclient on macOS from Firebird.framework
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:58:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6cbf0732e6 feat: resolve exam prices via TARIFA+EMPRESA JOIN, show company name
- PRECIO now falls back to catalog price (TARIFA) when 0/null
- JOIN EMPRESA + EMPRESA_SUB + TARIFA in exam query
- NOM_EMPRESA propagated to log and historial display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:58:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7aa02fe6ac fix: update /debug-fb/recepcion to show all columns and empresa tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:51:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 85ae3a0b3e feat: add /debug-fb/tarifas endpoint to explore price tables in Firebird
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:29:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 268451decc fix: render errores_det objects as readable text in historial
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:39:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 33e569c27a fix: historial shows error detail and correct Colombia timestamp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:37:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 83c8b2aa0b fix: format scheduler times in Colombia UTC-5 server-side
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:31:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 12f5f1388d fix: correct column names in scheduler status query (total, errores)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:26:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 467a24baa4 feat: scheduler live status — endpoint + UI polling every 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:21:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9d79fe932b fix: reduce diagnosticos batch 500→200, add start_from resume param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:13:14 -05:00
Lizandro Guarnizo 97f4561283 fix: capture raw PHP response on JSON error in sync-diagnosticos 2026-07-17 10:43:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9a7e4e219b feat: add CIE-10 sync endpoint and migration button in ERP tab
- POST /envios/erp/sync-diagnosticos: reads all 12422 diagnosticos from
  Firebird, sends to WhatsApp in batches of 500 via ingest_diagnosticos.php
- envios_erp.html: new "Migración CIE-10" card with Migrar button and result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 10:27:52 -05:00
Lizandro Guarnizo b65fdf0d9c fix: use uppercase column names for Firebird alias in diagnosticos query 2026-07-17 10:19:04 -05:00
Lizandro Guarnizo e185095e28 fix: use raw RDB$FIELD_NAME key instead of alias for Firebird metadata query 2026-07-17 10:18:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6235b1539c feat: add /debug-fb/diagnosticos endpoint to list CIE-10 from Firebird
Supports ?q=filter and ?limit=N. Returns total count, column names,
and list of {cod, concepto} rows — used to plan DIAGNOSTICO migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 10:11:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6157d2c2f9 fix: show raw PHP response in error message, clean up errores column UI
- sync_paciente: catch JSONDecodeError separately and include HTTP status
  + first 300 chars of raw response so PHP errors are visible in the log
- envios_erp.html: Errores column shows count only (not all inline messages)
  — error detail is already visible in the expandable Ver N row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:57:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cd63683869 feat: enrich sync log with exams, diagnosis, EPS and valor per patient
- scheduler.py: after sync_todos(), inject diagnostico, nit_empresa,
  valor_total and examenes_det (cups/nombre/precio) into each detalle entry
- whatsapp_sync.py: guardar_sync_log stores full enriched fields in detalle_json
- envios_erp.html: expanded row shows per-patient card with Dx, EPS, valor
  and a CUPS/examen/precio table — scrollable, max-h-96

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:52:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e044f21e3e feat: store and display per-patient sync detail in historial
- Add detalle_json column to sync_wa_log via migration
- guardar_sync_log() now stores doc, nombre, action, examenes count per patient
- Historial table shows "Ver N" button that expands a row with patient list,
  action badge (Nuevo/Actualizado/Omitido) and exam count per patient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:33:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 29b6aa4016 fix: pass user_id=None in scheduler sync log to avoid FK constraint
sync_wa_log.user_id has a FK to users; passing 0 fails since no user
with id=0 exists. Scheduler runs without a user context, so use NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:25:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 863e012c12 fix: handle Firebird Decimal and datetime types in JSON serialization
- PRECIO and VALORTOTAL are Decimal from fdb driver; convert via _safe_num()
- HORAINICIORECEPCION is a datetime object; use _fmt_hora_str() for HH:MM:SS
- _parse_hora() now handles datetime objects directly (no str() roundtrip)
- Same fixes applied to pacientes.py exam builder
- sync-now endpoint catches exceptions and returns traceback as JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:10:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fc18acc761 fix: use CAST(FECHA_RECEPCION AS DATE) for Firebird timestamp comparison
FECHA_RECEPCION is a TIMESTAMP field, so comparing directly with
CURRENT_DATE (DATE) always returns 0 rows in Firebird. Cast to DATE first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:45:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 37c4124559 debug: add /debug-fb/recepcion diagnostic endpoint
Shows recent recepciones without filters, CURRENT_DATE from Firebird,
and counts with/without NUM_FACTURA filter to diagnose zero-result sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:43:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dc18d128fb feat(envios/erp): add manual sync trigger endpoint and test UI
- POST /envios/erp/sync-now?ventana_min=N calls sync_recientes with configurable window
- sync_recientes() now returns a result dict (ok, total, created, updated, skipped, errores, detalle)
- ERP tab shows a "Prueba manual" card with ventana selector (2m / 30m / 2h / hoy) and result breakdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:40:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8e27225326 feat: módulos de envío TNS/ERP Lab + sync de médico, empresa y valor total
- Hub /envios/tns con 5 sub-tabs (Terceros, Transacción RIPS, Facturas Venta, Prueba RDA, Automatización) via iframe lazy
- Hub /envios/erp con sub-tabs Pacientes y Sync Automático (historial scheduler)
- Modo embed (?embed=1) en base.html para cargar páginas sin sidebar/header dentro de iframes
- Sidebar simplificado: 6 ítems individuales reemplazados por módulos TNS y ERP Lab
- Scheduler y endpoint /pacientes/examenes ahora incluyen médico ordenante (DOCIDMEDICO), empresa/EPS (NIT_EMPRESA) y valor total (VALORTOTAL) desde Firebird

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:13:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 31c0b71c90 fix(automation): quitar filtro por convenio del panel de control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 15:26:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c584097426 fix(automation): excluir ventas con NUM_FACTURA=0 (numero 00000)
No enviar facturas cuyo número sea 0 — quedarían como "00000" en TNS.
Filtro aplicado en preview, run y reenviar-venta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 15:22:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6dee8fba82 feat(automation): resultados con filtro Errores/Enviados/Todos y filas descriptivas
- Lista unificada reemplaza los dos paneles separados (failures/successes)
- Tabs de filtro: Errores (default si hay), Enviados, Todos — con contadores
- Filas de error: borde rojo, badge del paso, factura+paciente, mensaje inline, botón Reenviar
- Reenvío actualiza la fila en tiempo real: rojo → verde sin recarga
- btn-run solo se habilita si los pasos activos tienen datos reales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 15:12:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fe31af2f9a feat(automation): paso chips, results failure-first con reenvío por item
- 4 chips toggleables (Terceros/RDA/Pre-servicios/Ventas) que controlan
  qué pasos se ejecutan en vista previa y en el envío masivo
- Backend: parámetro `pasos` en run_automation para saltarse pasos no
  seleccionados; agrega `idrecepcion`/`codigo` a cada detalle item
- Nuevos endpoints POST /automation/reenviar-tercero, /reenviar-rda y
  /reenviar-venta para reenvío individual con SQL BY_ID para cada tipo
- Panel de resultados rediseñado: barra de resumen por paso, sección
  de fallos expandida con botón "Reenviar" por item (actualiza row in-
  place), sección de exitosos colapsada por defecto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 15:03:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f8c76f7ce2 fix: ensure_defaults no sobreescribe toggles de contratos al reiniciar
Los campos excluir/sin_contrato/excluir_ventas solo se aplican en INSERT
(contratos nuevos del seed). En contratos existentes solo se actualizan
los datos descriptivos (nit, tipo_usuario, descripcion) para preservar
la configuracion que el usuario haya definido desde la UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 14:38:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 73062af8bc feat: paso 4 ventas en automation + fixes varios
- Automation: agrega Paso 4 (Ventas/Crear) con fetch _SQL_VENTAS,
  filtro por excluded_ventas_set, loop de envío y log de actividad
- Automation: _guardar_envio ahora almacena idrecepcion y contrato
  para que /ventas reconozca registros enviados por automation
- Contratos: corrige NameError en toggle_excluir (faltaba request: Request)
- Ventas: agrega check if not q en send_one y send para evitar TypeError
- Ventas HTML: agrega columna Contrato en la tabla de facturas
- json_generator: normaliza etnia >2 chars a '99' (ej: 999 → 99)
- json_generator: agrega campo ciudadExp usando el mismo cod_ciudad

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 14:29:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 21eb99d18d fix: agregar tipo ventas al historial de logs
Badge verde para ventas, opción en filtro de tipo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 11:57:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f103d536c7 feat: excluir_ventas independiente por contrato
- Nueva columna excluir_ventas en tabla contratos (migración automática)
- load_excluded_ventas_set() para filtrar en /ventas
- Toggle independiente en /contratos para RDA y Ventas por separado
- Modal edición con checkbox excluir_ventas
- 040 marcado excluir_rda=1, excluir_ventas=0 en seed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 11:56:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 af71968c7f fix: TRIM en CODCONTRATO de queries ventas 040
Firebird rellena campos CHAR con espacios, causando que '040' != '040   '.
TRIM(e.CODCONTRATO) = '040' soluciona el filtro.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 11:37:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6fdca61636 fix: ampliar CHECK constraint para soportar tipo ventas
Las tablas queries y envios tenían CHECK constraints que solo permitían
terceros/transaccion. Se agrega ventas al constraint vía migración automática
en _migrate para bases de datos existentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 11:31:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6b84ecfd90 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>
2026-07-11 11:16:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6fb18a7af6 revert: especialidad siempre usa el default de config
Revertir el uso de COD_ESPECIALIDAD desde la BD; la especialidad
siempre debe venir del valor configurado (especialidad_default).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 10:23:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3e85e0a3f6 fix: corregir claves de config en preview_automation
preview_automation usaba _defecto en vez de _default, causando que
profesional y especialidad salieran null en la vista previa del automation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 10:20:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3313337672 fix: usar COD_ESPECIALIDAD de la BD en detallePedido
La especialidad ahora toma el valor del campo COD_ESPECIALIDAD de la tabla
MEDICO (ya presente en todos los SELECTs), con fallback al default de config.
El profesional sigue viniendo de configuración en todos los flujos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 10:19:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 daa50a70cf Add pre-servicios support in /transaccion + letter search fix
- Add 'RDA Pre-servicios por fecha' and 'RDA Pre-servicios por número'
  default queries joining PRESSERV_DIAN (ID_PS, PS_PREFIJO, PS_NUMERO)
- Refactor _query_rows: only pass params the query actually uses;
  prefix filter works for both PREFIJO and PS_PREFIJO columns
- Add _is_preserv(), _agrupar(), _build_rda() helpers that detect
  pre-servicio rows and produce correct prefijo/numero overrides
- All 4 send endpoints (preview, send-one, send, send-direct) now
  group by ID_PS for pre-servicio queries and pass correct RDA fields
- RCXC prefix still maps to '00' in the RDA codigoPrefijo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 10:14:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 987959a975 Double json_enviado storage limit from 5000 to 10000 chars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 10:03:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cb3b327938 Add reenviar button in /logs for failed envios
POST /logs/reenviar/{id} re-sends stored JSON to TNS and updates
status to 'success' in-place if it succeeds. Button appears only
on error rows with saved JSON; updates row color and badge without reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 09:24:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d848fbf2d Propagate sin_contrato_set to transaccion and test-rda routes
All generar_rda_paciente calls in transaccion.py (4) and test_rda.py (2)
now pass sin_contrato_set so contract 040 sends numeroContrato: null
consistently across all send paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 09:22:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c18da17317 Fix pre-servicios preview: clear store once, clean row HTML structure
- Move _rdaJsonStore={} reset to before both sections so ps-json-* keys
  are not wiped when pacientes section renders
- Replace divide-y wrapper divs with explicit border-b on each row,
  eliminating duplicate/misplaced dividers in the pre-servicios list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 09:18:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c4c6a2cd35 Send codigoPrefijo as '00' for RCXC pre-servicios
RCXC is an internal Firebird prefix not recognized by TNS; map it to '00'
in the RDA JSON. SC prefix remains unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 09:13:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2a7553267b Fix tipoDocumento PE/PT: map to single char 'E' for TNS API compatibility
TNS API (C#) expects System.Char for tipoDocumento; "PE" and "PT" caused
400 validation errors. Mapped both to "E" (Cédula Extranjería).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 08:31:33 -05:00
Lizandro Guarnizo 2794e9f9dd fix: verificar rows_ps en condicion de datos vacios para no saltar paso 3 2026-07-10 21:39:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 316205466f fix: tipo 'preserv' invalido en CHECK constraint de envios
- Paso 3 usaba tipo='preserv' que no esta en CHECK(tipo IN ('terceros','transaccion'))
- Cambia a 'transaccion' (factura RCXC-XXXXX distingue el registro)
- _guardar_envio con try/except para que error de DB no detenga el loop de envios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 21:37:42 -05:00
Lizandro Guarnizo a47e0a843a fix: mismo patron store+textContent para JSON de pre-servicios 2026-07-10 21:35:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e6860a6957 fix: JSON preview recepciones via store (evita innerHTML injection)
feat: paso 1 registra terceros EPS de pre-servicios junto a particulares

- _rdaJsonStore guarda JSON por key, toggleRdaJson usa textContent
- escHtml() para caracteres especiales en diagnostico
- _SQL_PACIENTES_EPS obtiene pacientes de PRESSERV_DIAN
- rows_pac_todos fusiona particulares + EPS deduplicando por CODIGO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 21:33:00 -05:00
Lizandro Guarnizo ef02303dde feat: mostrar JSON de recepciones paso 2 en preview (expandible) 2026-07-10 21:25:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9f2bd5164e 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>
2026-07-10 21:22:09 -05:00
Lizandro Guarnizo eeabb6268e debug: mostrar CODCONTRATO y si esta excluido en debug-cmxc 2026-07-10 21:12:05 -05:00
Lizandro Guarnizo 8d9d2ae89c debug: mostrar envios recientes y total en debug-cmxc 2026-07-10 21:08:13 -05:00
Lizandro Guarnizo 34b112919c debug: ampliar debug-cmxc para mostrar envios del dia 2026-07-10 21:04:55 -05:00
Lizandro Guarnizo fd2a107353 fix: parametros named en query debug-cmxc 2026-07-10 21:02:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3aa07543db debug: endpoint temporal /automation/debug-cmxc para diagnosticar recepciones CMXC
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 20:51:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 648a0a1910 feat: preview muestra lista de pre-servicios con JSON expandible
- Agrega preservicios_preview al endpoint /preview con el JSON generado por cada RCXC/SC
- Muestra sección naranja debajo de pacientes con factura, fecha, paciente y exámenes
- Clic en cada fila despliega el JSON completo que se enviará a TNS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 20:47:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c2a092c0e7 feat: paso 3 pre-servicios RCXC/SC y limpieza de código copago
- Agrega paso 3 en automatización: envía RDA desde PRESSERV_DIAN (prefijos RCXC/SC) para pacientes EPS con PS_NUM IS NOT NULL
- Filtra paso 2 con PS_NUM IS NULL para separar particulares/empresas de EPS
- Elimina parámetro copago y función _calcular_descuento_pct (código muerto)
- Limpia referencia a d.copago en automation.html (UI paso 3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 20:43:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c20e1a7476 feat: log de actividad por usuario
- Tabla activity_log en SQLite (migración automática)
- Registra: login, login_fallido, rda_enviado, rda_masivo, rda_directo,
  automation_run, contrato_creado, contrato_editado, contrato_toggle
- Endpoint GET /logs/actividad con filtros por usuario, acción y fecha
- UI /logs/actividad con badges de color por tipo de acción
- Enlace "Actividad" en navegación lateral

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 17:11:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8e29e2d04f fix: tipoIngreso siempre "1" (ambulatorio) en todos los flujos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 16:52:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d9a414396f feat: campo descuento en RDA calculado desde VALORDESC/VALORTOTAL
descuento = round(VALORDESC * 100 / VALORTOTAL, 2), 0 si sin descuento

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 16:52:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eb9c57b324 feat: botón re-enviar en registros ya enviados en /transaccion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 16:12:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c5f84aa900 feat: numero de factura con ceros a la izquierda hasta 5 dígitos (zfill 5)
Ejemplo: 355 → 00355, 3726 → 03726
Aplica a todos los flujos: transacción, automation, send-direct, test-rda.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 16:08:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f760d453aa fix: contratos 013 y 014 no estaban en la lista de excluidos del usuario
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 15:57:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 02d251685e feat: mostrar contrato en vista previa de recepciones en automation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 15:52:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7ec9af84c5 fix: omitir pacientes sin RDAs válidas en preview automation
Pacientes cuyos contratos son todos excluidos quedan fuera del listado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 15:50:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c9715e6a7e feat: filtrar contratos excluidos en /test-rda
- candidatos: omite contratos con excluir=1 del listado
- preview: retorna flag excluido y pasa contrato_map (fix bug)
- enviar: bloquea si contrato excluido, pasa contrato_map al generar RDA

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 15:48:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 200a3f550b feat: excluir contratos del envío TNS
- Columna excluir en tabla contratos (migración automática)
- 25 contratos nuevos sembrados con excluir=1 (laboratorios externos, clínicas, médicos particulares)
- load_excluded_set() en contratos.py para consultar el set en runtime
- preview: marca items excluidos y los reporta en el conteo
- send/send-one/send-direct: bloquea envío si contrato está excluido
- automation preview+run: filtra excluidos antes del loop, reporta count
- UI /contratos: columna Envío TNS con toggle Activo/Excluido, fila en rojo si excluida

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 15:44:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3baa6e5f75 feat(transaccion): envío RDA directo por prefijo + número de factura
Nuevo endpoint POST /transaccion/send-direct y panel "Enviar RDA directo"
en la UI: ingresa Tipo (prefijo) y Número, busca en Firebird y envía a TNS
sin necesidad de rango de fechas ni pasar por el flujo de preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:10:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d3a6ab7ab6 fix(tipousuario): contrato 012 no debe ser tratado como particular
El check `"012".lstrip("0") == "12"` causaba false positive marcando
contratos EPS como particulares. Ahora la heurística solo aplica si el
contrato no está en la tabla contratos; si está, el tipo_usuario del mapa
tiene precedencia y también controla es_particular.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:06:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0f3a760de4 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>
2026-07-09 15:00:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7d2a9df447 feat: GET /pacientes/examenes — exams registered in last 5 min for a cedula
Queries Firebird RECEPCION+RELACION+EXAMEN for today's date,
filters by HORAINICIORECEPCION >= now-5min in Python, returns
list of {recepcion_id, hora, cod_examen, nombre, cups, precio}.
Auth via X-Lab-Key header (whatsapp_api_key config value).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 14:06:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e2a9e7c4d3 fix(whatsapp_sync): enviar origen='lab' en lugar de 'rips'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:35:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0175a2ca5 feat(sync-log): historial de sincronizaciones WhatsApp en sync_wa_log
- Nueva tabla sync_wa_log (total, created, skipped, updated, errores, origen, modo)
- Log guardado en /pacientes/sync-all, /terceros/send y /automation/run
- Ruta GET /pacientes/historial devuelve últimos 100 registros
- UI: tabla de historial con origen, modo, conteos y detalle de errores

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:13:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1aab14f705 feat(pacientes): modo insertar en sync-all — solo crea nuevos, omite existentes
sync_todos() ahora acepta modo='insertar'|'upsert'. La ruta /pacientes/sync-all
usa insertar: skippea cédulas que ya están en lab_pacientes. Los hooks
automáticos (terceros/automation) siguen en upsert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:06:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e7c6bc794a feat(pacientes): sync de pacientes Firebird → WhatsApp Lab
- Nuevo servicio whatsapp_sync.py: mapeo de campos RIPS→lab_pacientes
  y cliente HTTP para /api/lab/ingest_paciente.php
- Nueva ruta /pacientes con sync masiva por rango de fechas o todos
- Hook en /terceros/send y /automation/run: sync silencioso a WhatsApp
  después de cada envío a TNS
- Config: campos whatsapp_url y whatsapp_api_key + botón probar conexión

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:49:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b4288ac899 fix: remove undefined facturas_usadas_sesion causing double RDA submission on test-rda
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 12:00:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a94e2cce9f feat: omit fechaAutorizacion and vigenciaAutorizacion when numeroAutorizacion is null
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 10:23:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b88d727623 feat: add 5 minutes to fechaHoraEgreso in RDA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 19:44:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 235e20e38d feat: remove unused fields from RDA JSON output
Remove codFormaPago, codBanco, descuento from header.
Remove tipoUnidad, descuento, porcentajeIva, impConsumo from detallePedido.
Remove detalleFormaPago entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 21:59:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3aa0e09aa6 chore: bump version marker in json_generator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 09:31:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b3b283a7d0 fix: syntaxHighlight regex adding spaces inside time values
The number regex /:(\s*)(\d+)/ was matching colons inside time strings
like "14:51:26" turning them into "14: 51: 26". Changed to require the
space that JSON.stringify always adds after key-value colons (": \d+")
so colons inside string values are not affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:41:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 07038ac431 fix: clean time spaces in logs view without modifying stored data
Applies _clean_times when serving json_enviado in /logs/detalle
so old records with "12: 47: 37" display correctly as "12:47:37".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:36:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 72973973d4 feat: calculate real discount % from TARIFA vs RELACION.PRECIO
Joins TARIFA using EMPRESA.TARIFA to get the base price per exam,
then calculates descuento = round((sum(TARIFA) - sum(PRECIO)) / sum(TARIFA) * 100)
and sends it as integer percentage in the RDA header encabezado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:26:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 15f6ca65db feat: add descuento from RECEPCION.VALORDESC to RDA header
Pulls r.VALORDESC in all SQL queries (automation, test_rda, queries
defaults) and maps it to the top-level descuento field in the
RdaPaciente JSON encabezado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:13:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f35a39bc9b fix: add EXAMEN join to QUERY_DEFAULTS so CUPs codes survive restarts
ensure_defaults() runs on every startup and was overwriting the db
queries with the old versions from QUERY_DEFAULTS — wiping any manual
fixes. Now the source-of-truth in code has the EXAMEN join and CUPS
column, so transaccion page always shows CUPs after any restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 14:38:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e32214fef7 tmp: add fix_queries.py to update transaccion SQL with EXAMEN join
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:11:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eba512720f fix: remove duplicate EXAMEN JOIN in saved transaccion queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:03:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b12e8fcf88 feat: use EXAMEN.NUM_ISS as codigoMaterial (CUPs) via LEFT JOIN EXAMEN
CH4=902210, PO=907106, GRAM=901107, etc. Falls back to COD_EXAMEN if empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 12:53:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b22b47a1e1 debug: search 903967 across all candidate tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 12:34:50 -05:00
42 changed files with 7402 additions and 525 deletions
+119 -2
View File
@@ -14,6 +14,10 @@ def get_connection():
def _migrate(conn): def _migrate(conn):
sync_cols = {r[1] for r in conn.execute("PRAGMA table_info(sync_wa_log)")}
if "detalle_json" not in sync_cols:
conn.execute("ALTER TABLE sync_wa_log ADD COLUMN detalle_json TEXT")
cols = {r[1] for r in conn.execute("PRAGMA table_info(envios)")} cols = {r[1] for r in conn.execute("PRAGMA table_info(envios)")}
if "idrecepcion" not in cols: if "idrecepcion" not in cols:
conn.execute("ALTER TABLE envios ADD COLUMN idrecepcion INTEGER") conn.execute("ALTER TABLE envios ADD COLUMN idrecepcion INTEGER")
@@ -21,6 +25,104 @@ def _migrate(conn):
conn.execute("ALTER TABLE envios ADD COLUMN contrato TEXT") conn.execute("ALTER TABLE envios ADD COLUMN contrato TEXT")
if "mensaje_tns" not in cols: if "mensaje_tns" not in cols:
conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT") conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT")
if "cedula" not in cols:
conn.execute("ALTER TABLE envios ADD COLUMN cedula TEXT")
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "activity_log" not in tables:
conn.execute("""
CREATE TABLE activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT NOT NULL DEFAULT '',
accion TEXT NOT NULL,
detalle TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
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 '',
excluir INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
else:
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")
if "excluir_ventas" not in contrato_cols:
conn.execute("ALTER TABLE contratos ADD COLUMN excluir_ventas INTEGER NOT NULL DEFAULT 0")
if "cod_forma_pago" not in contrato_cols:
conn.execute("ALTER TABLE contratos ADD COLUMN cod_forma_pago TEXT NOT NULL DEFAULT ''")
# Ampliar CHECK constraint de queries para incluir 'ventas'
q_sql = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='queries'").fetchone()
if q_sql and "'ventas'" not in q_sql[0]:
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("""CREATE TABLE queries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion','ventas')),
query_text TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)""")
conn.execute("INSERT INTO queries_new SELECT * FROM queries")
conn.execute("DROP TABLE queries")
conn.execute("ALTER TABLE queries_new RENAME TO queries")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
# Ampliar CHECK constraint de envios para incluir 'ventas'
e_sql = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='envios'").fetchone()
if e_sql and "'ventas'" not in e_sql[0]:
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("""CREATE TABLE envios_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion','ventas')),
factura TEXT,
fecha_inicio TEXT,
fecha_fin TEXT,
pacientes_count INTEGER DEFAULT 0,
servicios_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pendiente',
json_enviado TEXT,
respuesta_api TEXT,
codigo_cuv TEXT,
idrecepcion INTEGER,
contrato TEXT,
mensaje_tns TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
)""")
conn.execute("INSERT INTO envios_new SELECT id,user_id,tipo,factura,fecha_inicio,fecha_fin,"
"pacientes_count,servicios_count,status,json_enviado,respuesta_api,codigo_cuv,"
"idrecepcion,contrato,mensaje_tns,created_at FROM envios")
conn.execute("DROP TABLE envios")
conn.execute("ALTER TABLE envios_new RENAME TO envios")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
if "codigos_pago" not in tables:
conn.execute("""
CREATE TABLE codigos_pago (
id INTEGER PRIMARY KEY AUTOINCREMENT,
codigo TEXT UNIQUE NOT NULL,
nombre TEXT NOT NULL DEFAULT ''
)
""")
for codigo, nombre in [("CIAC", "Contado inmediato"), ("CR", "Crédito"), ("MU", "Mixto")]:
conn.execute("INSERT OR IGNORE INTO codigos_pago (codigo, nombre) VALUES (?,?)", (codigo, nombre))
conn.commit() conn.commit()
@@ -44,7 +146,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS queries ( CREATE TABLE IF NOT EXISTS queries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion')), query_type TEXT NOT NULL CHECK(query_type IN ('terceros','transaccion','ventas')),
query_text TEXT NOT NULL, query_text TEXT NOT NULL,
description TEXT, description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -63,7 +165,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS envios ( CREATE TABLE IF NOT EXISTS envios (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion')), tipo TEXT NOT NULL CHECK(tipo IN ('terceros','transaccion','ventas')),
factura TEXT, factura TEXT,
fecha_inicio TEXT, fecha_inicio TEXT,
fecha_fin TEXT, fecha_fin TEXT,
@@ -76,6 +178,21 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS sync_wa_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
origen TEXT NOT NULL DEFAULT 'manual',
modo TEXT NOT NULL DEFAULT 'insertar',
total INTEGER NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
errores INTEGER NOT NULL DEFAULT 0,
errores_det TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
""") """)
conn.commit() conn.commit()
_migrate(conn) _migrate(conn)
+3
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse, JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import hash_password, verify_password, create_token, get_current_user from app.auth import hash_password, verify_password, create_token, get_current_user
from app.utils.activity import log_activity, get_ip
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -62,10 +63,12 @@ async def login(
"SELECT * FROM users WHERE username = ?", (username,) "SELECT * FROM users WHERE username = ?", (username,)
).fetchone() ).fetchone()
if not user or not verify_password(password, user["password_hash"]): if not user or not verify_password(password, user["password_hash"]):
log_activity(0, username, "login_fallido", f"Intento fallido desde {get_ip(request)}", get_ip(request))
return request.app.state.templates.TemplateResponse("login.html", { return request.app.state.templates.TemplateResponse("login.html", {
"request": request, "error": "Usuario o contraseña incorrectos" "request": request, "error": "Usuario o contraseña incorrectos"
}) })
log_activity(user["id"], user["username"], "login", f"Inicio de sesión", get_ip(request))
token = create_token(user["id"], user["username"]) token = create_token(user["id"], user["username"])
resp = RedirectResponse("/dashboard", status_code=302) resp = RedirectResponse("/dashboard", status_code=302)
resp.set_cookie(key="token", value=token, httponly=True) resp.set_cookie(key="token", value=token, httponly=True)
+820 -74
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -27,6 +27,8 @@ DEFAULT_KEYS = [
("especialidad_default", ""), ("especialidad_default", ""),
("remisionante_default", "00"), ("remisionante_default", "00"),
("prefijo_tns_default", "00"), ("prefijo_tns_default", "00"),
("whatsapp_url", ""),
("whatsapp_api_key", "rips-lab-sync-2026"),
] ]
+323
View File
@@ -0,0 +1,323 @@
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
from app.utils.activity import log_activity, get_ip
router = APIRouter(prefix="/contratos", tags=["contratos"])
# (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir_rda, sin_contrato, excluir_ventas)
# excluir_ventas=0 → activo para Ventas/Crear | excluir_ventas=1 → bloqueado para ventas
_SEED = [
("001", "860078828", "11", "EPS SANITAS", 0, 0, 1),
("002", "830054904", "11", "EPS COMPENSAR", 0, 0, 1),
("003", "900278729", "12", "PARTICULAR", 0, 0, 1),
("004", "800106339", "11", "EPS NUEVA EPS", 0, 0, 1),
("005", "800153424", "11", "EPS SURA", 0, 0, 1),
("006", "805009741", "11", "EPS COOMEVA", 0, 0, 1),
("007", "860002183", "11", "EPS FAMISANAR", 0, 0, 1),
("008", "860002503", "11", "EPS CRUZ BLANCA", 0, 0, 1),
("009", "860027404", "11", "EPS COLSANITAS", 0, 0, 1),
("010", "860039988", "11", "EPS SALUD TOTAL", 0, 0, 1),
("011", "890903790", "11", "EPS SAVIA SALUD", 0, 0, 1),
("012", "900178724", "11", "EPS MUTUAL SER", 0, 0, 1),
("034", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
("035", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
("036", "860078828", "11", "EPS SANITAS (alt)", 0, 0, 1),
("20062026", "800182856", "01", "SUBSIDIADO", 0, 0, 1),
("CW225489", "899999068", "07", "POLIZA / SEGURO", 0, 0, 1),
# Contratos excluidos del envío RDA
("013", "", "11", "MEDILAVORO S.A.S", 0, 0, 1),
("014", "", "11", "LABORATORIO UROCLINICO",0, 0, 1),
("015", "", "11", "ANDRES AFANADOR VILLAMIZAR", 1, 0, 1),
("016", "", "11", "OMAR FERNANDO RIBERO GOMEZ", 1, 0, 1),
("017", "", "11", "CLAUDIA BELEN JULIO SEPULVEDA", 1, 0, 1),
("018", "", "11", "LABORATORIO MICROBIOLOGICO - MARGIE OJEDA", 1, 0, 1),
("019", "", "11", "LABORATORIO TOXICOLOGICO - MARTHA MORALES", 1, 0, 1),
("020", "", "11", "MARTHA LUCIA GALLARDO", 1, 0, 1),
("021", "", "11", "LABORATORIO VILMA OROZCO AYALA", 1, 0, 1),
("022", "", "11", "CLINICA SAN JOSE DE CUCUTA", 1, 0, 1),
("023", "", "11", "URONORTE S.A", 1, 0, 1),
("024", "", "11", "LABORATORIO CLINICO BIOLAB S.A.S", 1, 0, 1),
("025", "", "11", "JOEL LEONARDO CARRILLO CORREDOR", 1, 0, 1),
("026", "", "11", "ROLANDO IVAN PENARANDA DEVIA", 1, 0, 1),
("027", "", "11", "JOSE WILMER GARCIA CALDERON", 1, 0, 1),
("028", "", "11", "ONCOMEDICAL IPS S.A.S", 1, 0, 1),
("029", "", "11", "GENETIX S.A.S", 1, 0, 1),
("030", "", "11", "NORFETUS S.A.S", 1, 0, 1),
("031", "", "11", "TBTB GLOBAL LAB S.A.S", 1, 0, 1),
("032", "", "11", "COLGENES S.A.S", 1, 0, 1),
("033", "", "11", "IPS FIGURAS SPA CUCUTA S.A.S", 1, 0, 1),
("037", "", "11", "CLINICA URGENCIAS LA MERCED", 1, 0, 1),
("038", "", "11", "CLINICA COLSANITAS S.A.", 1, 0, 1),
("039", "", "11", "GOMEZ GIL JOSE JESUS", 1, 0, 1),
("040", "", "11", "MARTHA LILIANA SALGAR GALLEGO", 1, 1, 0),
("041", "", "11", "GENCELL PHARMA S.A.S", 1, 0, 1),
("042", "", "07", "AXA COLPATRIA HYC", 1, 0, 1),
("050", "", "12", "PARTICULAR COLEGAS", 0, 0, 0),
]
def ensure_defaults():
conn = get_connection()
for nc, nit, tu, desc, excluir, sin_contrato, excluir_ventas 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, sin_contrato, excluir_ventas) VALUES (?,?,?,?,?,?,?)",
(nc, nit, tu, desc, excluir, sin_contrato, excluir_ventas),
)
else:
# Solo actualiza datos descriptivos; los toggles (excluir/sin_contrato/excluir_ventas)
# se conservan tal como el usuario los dejó desde la UI
conn.execute(
"UPDATE contratos SET nit_empresa=?, tipo_usuario=?, descripcion=? WHERE numero_contrato=?",
(nit, tu, desc, nc),
)
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
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 marcados como excluir=1 (bloqueados para RDA)."""
conn = get_connection()
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE excluir=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_ventas_set() -> set:
"""Devuelve set de numero_contrato marcados como excluir_ventas=1 (bloqueados para Ventas)."""
conn = get_connection()
rows = conn.execute("SELECT numero_contrato FROM contratos WHERE excluir_ventas=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_forma_pago_map() -> dict:
"""Devuelve {numero_contrato: cod_forma_pago} para contratos con forma de pago asignada."""
conn = get_connection()
rows = conn.execute("SELECT numero_contrato, cod_forma_pago FROM contratos WHERE cod_forma_pago != ''").fetchall()
conn.close()
result = {}
for r in rows:
nc = r["numero_contrato"].strip()
result[nc] = r["cod_forma_pago"].strip()
nc_s = nc.lstrip("0") or nc
if nc_s != nc:
result[nc_s] = r["cod_forma_pago"].strip()
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()
codigos_pago = conn.execute("SELECT * FROM codigos_pago ORDER BY codigo").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("contratos.html", {
"request": request, "user": user, "contratos": rows,
"codigos_pago": codigos_pago,
})
@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(""),
excluir: str = Form("0"),
sin_contrato: str = Form("0"),
excluir_ventas: str = Form("0"),
):
conn = get_connection()
try:
conn.execute(
"INSERT INTO contratos (numero_contrato, nit_empresa, tipo_usuario, descripcion, excluir, sin_contrato, excluir_ventas) VALUES (?,?,?,?,?,?,?)",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas)),
)
conn.commit()
log_activity(user["user_id"], user["username"], "contrato_creado",
f"Contrato {numero_contrato.strip()}{descripcion.strip()}", get_ip(request))
except Exception:
pass
conn.close()
return RedirectResponse("/contratos", status_code=302)
@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"),
excluir_ventas: str = Form("0"),
cod_forma_pago: str = Form(""),
):
conn = get_connection()
conn.execute(
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=?, sin_contrato=?, excluir_ventas=?, cod_forma_pago=? WHERE id=?",
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas), cod_forma_pago.strip(), contrato_id),
)
conn.commit()
log_activity(user["user_id"], user["username"], "contrato_editado",
f"Contrato {numero_contrato.strip()}{descripcion.strip()}", get_ip(request))
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/toggle-excluir/{contrato_id}")
async def toggle_excluir(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute(
"UPDATE contratos SET excluir = CASE WHEN excluir=1 THEN 0 ELSE 1 END WHERE id=?",
(contrato_id,)
)
conn.commit()
row = conn.execute("SELECT numero_contrato, excluir FROM contratos WHERE id=?", (contrato_id,)).fetchone()
if row:
estado = "excluido" if row["excluir"] else "activado"
log_activity(user["user_id"], user["username"], "contrato_toggle",
f"Contrato {row['numero_contrato']}{estado}", get_ip(request))
conn.close()
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("/toggle-excluir-ventas/{contrato_id}")
async def toggle_excluir_ventas(contrato_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute(
"UPDATE contratos SET excluir_ventas = CASE WHEN excluir_ventas=1 THEN 0 ELSE 1 END WHERE id=?",
(contrato_id,)
)
conn.commit()
row = conn.execute("SELECT numero_contrato, excluir_ventas FROM contratos WHERE id=?", (contrato_id,)).fetchone()
if row:
log_activity(user["user_id"], user["username"], "contrato_toggle_ventas",
f"Contrato {row['numero_contrato']} excluir_ventas→{row['excluir_ventas']}", get_ip(request))
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/set-forma-pago/{contrato_id}")
async def set_forma_pago(
contrato_id: int, request: Request, user: dict = Depends(get_current_user),
cod_forma_pago: str = Form(""),
):
conn = get_connection()
conn.execute("UPDATE contratos SET cod_forma_pago=? WHERE id=?", (cod_forma_pago.strip(), contrato_id))
conn.commit()
row = conn.execute("SELECT numero_contrato FROM contratos WHERE id=?", (contrato_id,)).fetchone()
if row:
log_activity(user["user_id"], user["username"], "contrato_forma_pago",
f"Contrato {row['numero_contrato']} forma_pago→{cod_forma_pago.strip() or '(auto)'}",
get_ip(request))
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/codigos-pago/create")
async def codigo_pago_create(
request: Request, user: dict = Depends(get_current_user),
codigo: str = Form(...), nombre: str = Form(""),
):
conn = get_connection()
try:
conn.execute("INSERT INTO codigos_pago (codigo, nombre) VALUES (?,?)",
(codigo.strip().upper(), nombre.strip()))
conn.commit()
log_activity(user["user_id"], user["username"], "codigo_pago_creado",
f"{codigo.strip().upper()}{nombre.strip()}", get_ip(request))
except Exception:
pass
conn.close()
return RedirectResponse("/contratos", status_code=302)
@router.post("/codigos-pago/delete/{cp_id}")
async def codigo_pago_delete(cp_id: int, user: dict = Depends(get_current_user)):
conn = get_connection()
conn.execute("DELETE FROM codigos_pago WHERE id=?", (cp_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)
+415 -16
View File
@@ -1,5 +1,5 @@
import traceback import traceback
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
@@ -8,6 +8,389 @@ from app.services.firebird_service import get_firebird_from_config
router = APIRouter(prefix="/debug-fb", tags=["debug"]) router = APIRouter(prefix="/debug-fb", tags=["debug"])
@router.get("/diagnosticos")
async def listar_diagnosticos(
q: str = Query(default="", description="Filtro por código o concepto"),
limit: int = Query(default=100, ge=1, le=5000),
user: dict = Depends(get_current_user),
):
"""Lista diagnósticos CIE-10 desde Firebird DIAGNOSTICO."""
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"ok": False, "error": msg})
# Estructura de la tabla
ok_c, _, cols = fb.execute_query(
"SELECT f.RDB$FIELD_NAME "
"FROM RDB$RELATION_FIELDS f "
"WHERE TRIM(f.RDB$RELATION_NAME) = 'DIAGNOSTICO' "
"ORDER BY f.RDB$FIELD_POSITION", None
)
# Conteo total
ok_n, _, cnt = fb.execute_query(
"SELECT COUNT(*) AS TOTAL FROM DIAGNOSTICO", None
)
# Registros
if q:
q_up = q.upper()
ok_r, _, rows = fb.execute_query(
f"SELECT FIRST {limit} TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
f"FROM DIAGNOSTICO d "
f"WHERE UPPER(TRIM(d.COD_DIAG)) CONTAINING '{q_up}' "
f" OR UPPER(TRIM(d.CONCEPTO)) CONTAINING '{q_up}' "
f"ORDER BY d.COD_DIAG", None
)
else:
ok_r, _, rows = fb.execute_query(
f"SELECT FIRST {limit} TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
f"FROM DIAGNOSTICO d ORDER BY d.COD_DIAG", None
)
fb.disconnect()
return JSONResponse({
"ok": True,
"total": cnt[0]["TOTAL"] if ok_n and cnt else "?",
"columnas": [list(c.values())[0].strip() for c in cols] if ok_c and cols else [],
"limite": limit,
"filtro": q or None,
"rows": [{"cod": r["COD_DIAG"], "concepto": r["CONCEPTO"]} for r in (rows or [])],
})
except Exception:
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
@router.get("/recepcion")
async def debug_recepcion(user: dict = Depends(get_current_user)):
"""Diagnóstico: columnas de RECEPCION, tabla EMPRESA y link a tarifa."""
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"error": msg})
result = {}
# Todas las columnas de RECEPCION
ok_c, _, cols = fb.execute_query(
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL "
"FROM RDB$RELATION_FIELDS f "
"WHERE TRIM(f.RDB$RELATION_NAME) = 'RECEPCION' "
"ORDER BY f.RDB$FIELD_POSITION", None
)
result["columnas_recepcion"] = [r["COL"] for r in (cols or [])] if ok_c else []
# Tablas con EMPRESA o CONVENIO en el nombre
ok_e, _, tabs = fb.execute_query(
"SELECT TRIM(r.RDB$RELATION_NAME) AS TABLA FROM RDB$RELATIONS r "
"WHERE r.RDB$SYSTEM_FLAG = 0 "
" AND (UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'EMPRESA' "
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'CONVENIO') "
"ORDER BY TABLA", None
)
result["tablas_empresa_convenio"] = [r["TABLA"] for r in (tabs or [])] if ok_e else []
# Columnas + muestra de cada tabla encontrada
for t in result["tablas_empresa_convenio"][:4]:
ok_tc, _, tcols = fb.execute_query(
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL FROM RDB$RELATION_FIELDS f "
f"WHERE TRIM(f.RDB$RELATION_NAME) = '{t}' ORDER BY f.RDB$FIELD_POSITION", None
)
ok_tr, _, trows = fb.execute_query(f"SELECT FIRST 2 * FROM {t}", None)
result[f"tabla_{t}"] = {
"columnas": [c["COL"] for c in (tcols or [])] if ok_tc else [],
"muestra": list(trows or []) if ok_tr else [],
}
# 1 recepción completa para ver si tiene COD_TARIFA o NIT_EMPRESA
ok_r, _, rec = fb.execute_query(
"SELECT FIRST 1 r.* FROM RECEPCION r ORDER BY r.IDRECEPCION DESC", None
)
result["recepcion_completa"] = dict(rec[0]) if ok_r and rec else {}
fb.disconnect()
return JSONResponse(result)
except Exception:
return JSONResponse({"traceback": traceback.format_exc()})
@router.get("/tarifas")
async def debug_tarifas(user: dict = Depends(get_current_user)):
"""Explora tablas de tarifas/precios en Firebird."""
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"ok": False, "error": msg})
# Todas las tablas que contengan TARIF, PRECIO, ARANCEL, CONVENIO, ISS en el nombre
ok_t, _, tablas = fb.execute_query(
"SELECT TRIM(r.RDB$RELATION_NAME) AS TABLA "
"FROM RDB$RELATIONS r "
"WHERE r.RDB$SYSTEM_FLAG = 0 "
" AND (UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'TARIF' "
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'PRECIO' "
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'ARANCEL' "
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'CONVENIO' "
" OR UPPER(TRIM(r.RDB$RELATION_NAME)) CONTAINING 'VALOR') "
"ORDER BY TABLA", None
)
result = {"tablas_encontradas": [r["TABLA"] for r in (tablas or [])] if ok_t else []}
# Para cada tabla encontrada: columnas + 3 filas de muestra
for t in result["tablas_encontradas"][:6]:
ok_c, _, cols = fb.execute_query(
"SELECT TRIM(f.RDB$FIELD_NAME) AS COL "
"FROM RDB$RELATION_FIELDS f "
f"WHERE TRIM(f.RDB$RELATION_NAME) = '{t}' "
"ORDER BY f.RDB$FIELD_POSITION", None
)
ok_r, _, rows = fb.execute_query(
f"SELECT FIRST 3 * FROM {t}", None
)
ok_n, _, cnt = fb.execute_query(
f"SELECT COUNT(*) AS N FROM {t}", None
)
result[t] = {
"columnas": [c["COL"] for c in (cols or [])] if ok_c else [],
"total": cnt[0]["N"] if ok_n and cnt else "?",
"muestra": list(rows or []) if ok_r else [],
}
fb.disconnect()
return JSONResponse(result)
except Exception:
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
@router.get("/cmxc")
async def debug_cmxc(
fecha: str = Query(default="", description="YYYY-MM-DD, vacío = hoy"),
user: dict = Depends(get_current_user),
):
"""Consulta directa a Firebird: recepciones CMXC para la fecha dada."""
from datetime import date as date_cls
if not fecha:
fecha = date_cls.today().isoformat()
fecha_ini = f"{fecha} 00:00:00"
fecha_fin = f"{fecha} 23:59:59"
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"ok": False, "error": msg})
ok1, _, rows = fb.execute_query("""
SELECT
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
r.COD_PACIENTE, TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
TRIM(e.CODCONTRATO) AS CODCONTRATO, TRIM(e.NOMBRE) AS NOM_EMPRESA,
(SELECT COUNT(*) FROM RELACION rel WHERE rel.IDRECEPCION = r.IDRECEPCION) AS N_EXAMENES
FROM RECEPCION r
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
AND r.PREFIJO = 'CMXC'
ORDER BY r.IDRECEPCION
""", {"fi": fecha_ini, "ff": fecha_fin})
ok2, _, total = fb.execute_query("""
SELECT COUNT(*) AS N FROM RECEPCION r
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
""", {"fi": fecha_ini, "ff": fecha_fin})
# Correr el mismo _SQL_VENTAS que usa el preview
ok3, err3, rows_vta = fb.execute_query("""
SELECT
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL,
rel.COD_EXAMEN,
TRIM(e.CODCONTRATO) AS 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 r.PREFIJO = 'CMXC'
ORDER BY r.IDRECEPCION
""", {"fecha_ini": fecha_ini, "fecha_fin": fecha_fin})
fb.disconnect()
# Estado de contratos en SQLite (excluir_ventas)
conn2 = get_connection()
contratos_excluidos_vta = [
r["numero_contrato"]
for r in conn2.execute(
"SELECT numero_contrato FROM contratos WHERE excluir_ventas=1 ORDER BY numero_contrato"
).fetchall()
]
contratos_activos_vta = [
r["numero_contrato"]
for r in conn2.execute(
"SELECT numero_contrato FROM contratos WHERE excluir_ventas=0 ORDER BY numero_contrato"
).fetchall()
]
conn2.close()
return JSONResponse({
"ok": True,
"fecha": fecha,
"total_recepcion_del_dia": total[0]["N"] if ok2 and total else "?",
"cmxc_count": len(rows or []),
"cmxc_rows": list(rows or []),
"sql_ventas_ok": ok3,
"sql_ventas_error": err3 if not ok3 else None,
"sql_ventas_count": len(rows_vta or []),
"sql_ventas_rows": list(rows_vta or [])[:10],
"contratos_excluidos_ventas": contratos_excluidos_vta,
"contratos_activos_ventas": contratos_activos_vta,
})
except Exception:
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
@router.get("/preview-ventas")
async def debug_preview_ventas(
fecha: str = Query(default="", description="YYYY-MM-DD"),
user: dict = Depends(get_current_user),
):
"""Corre la lógica completa de ventas del preview y muestra cada paso."""
from datetime import date as date_cls
from collections import defaultdict
if not fecha:
fecha = date_cls.today().isoformat()
fecha_ini = f"{fecha} 00:00:00"
fecha_fin = f"{fecha} 23:59:59"
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
excluded_ventas = set(
r["numero_contrato"]
for r in conn.execute("SELECT numero_contrato FROM contratos WHERE excluir_ventas=1").fetchall()
)
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"ok": False, "error": msg})
ok_vta, err_vta, rows_vta = fb.execute_query("""
SELECT
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA,
r.COD_PACIENTE, r.NIT_EMPRESA, r.VALORTOTAL,
rel.COD_EXAMEN,
TRIM(e.CODCONTRATO) AS 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 r.PREFIJO = 'CMXC'
ORDER BY r.IDRECEPCION
""", {"fecha_ini": fecha_ini, "fecha_fin": fecha_fin})
fb.disconnect()
if not ok_vta:
return JSONResponse({"ok": False, "sql_error": err_vta})
# Agrupar por NUM_FACTURA
grupos: dict = defaultdict(list)
for row in (rows_vta or []):
key = str(row.get("NUM_FACTURA") or "")
grupos[key].append(dict(row))
pasos = []
grupos_finales = {}
for k, v in grupos.items():
paso = {"key": k, "filas": len(v),
"num_factura": v[0].get("NUM_FACTURA"),
"codcontrato": v[0].get("CODCONTRATO"),
"es_lista": isinstance(v, list),
"motivo_exclusion": None}
if not isinstance(v, list) or not v:
paso["motivo_exclusion"] = "no_es_lista_o_vacia"
elif int(v[0].get("NUM_FACTURA") or 0) == 0:
paso["motivo_exclusion"] = "num_factura_cero"
elif str(v[0].get("CODCONTRATO") or "").strip() in excluded_ventas:
paso["motivo_exclusion"] = f"contrato_excluido ({v[0].get('CODCONTRATO')})"
else:
grupos_finales[k] = v
pasos.append(paso)
return JSONResponse({
"ok": True,
"fecha": fecha,
"rows_vta_count": len(rows_vta or []),
"grupos_count": len(grupos),
"grupos_finales_count": len(grupos_finales),
"pasos": pasos,
"excluded_ventas_set": sorted(excluded_ventas),
})
except Exception:
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
@router.get("/facturas")
async def debug_facturas(
desde: int = Query(..., description="NUM_FACTURA desde"),
hasta: int = Query(..., description="NUM_FACTURA hasta"),
user: dict = Depends(get_current_user),
):
"""Busca recepciones por rango de NUM_FACTURA en Firebird."""
try:
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return JSONResponse({"ok": False, "error": msg})
ok1, _, rows = fb.execute_query("""
SELECT
r.IDRECEPCION, TRIM(r.PREFIJO) AS PREFIJO, r.NUM_FACTURA,
CAST(r.FECHA_RECEPCION AS VARCHAR(30)) AS FECHA_RECEPCION,
r.COD_PACIENTE,
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
TRIM(e.CODCONTRATO) AS CODCONTRATO,
TRIM(e.NOMBRE) AS NOM_EMPRESA,
(SELECT COUNT(*) FROM RELACION rel WHERE rel.IDRECEPCION = r.IDRECEPCION) AS N_EXAMENES
FROM RECEPCION r
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
WHERE r.NUM_FACTURA BETWEEN ? AND ?
ORDER BY r.NUM_FACTURA, r.IDRECEPCION
""", (desde, hasta))
fb.disconnect()
return JSONResponse({
"ok": ok1,
"desde": desde,
"hasta": hasta,
"count": len(rows or []),
"rows": list(rows or []),
})
except Exception:
return JSONResponse({"ok": False, "traceback": traceback.format_exc()})
@router.get("/cups") @router.get("/cups")
async def find_cups(request: Request, user: dict = Depends(get_current_user)): async def find_cups(request: Request, user: dict = Depends(get_current_user)):
try: try:
@@ -21,23 +404,39 @@ async def find_cups(request: Request, user: dict = Depends(get_current_user)):
results = {} results = {}
# 1. Buscar todas las tablas que tengan campo con "CUP" en nombre BUSCAR = "903967"
ok0, _, rows0 = fb.execute_query(
"SELECT TRIM(f.RDB$RELATION_NAME) AS t, TRIM(f.RDB$FIELD_NAME) AS c "
"FROM RDB$RELATION_FIELDS f WHERE f.RDB$FIELD_NAME LIKE '%CUP%' "
"AND f.RDB$SYSTEM_FLAG = 0 ORDER BY 1,2"
)
results["todos_campos_cup"] = list(rows0) if ok0 else []
# 2. Tabla PROTOCOLO - primera fila # Tablas y sus campos de texto para buscar
ok1, err1, rows1 = fb.execute_query("SELECT FIRST 1 p.* FROM PROTOCOLO p") tablas_candidatas = [
results["protocolo_fila"] = rows1[0] if (ok1 and rows1) else f"error: {err1}" ("PROTOCOLO", ["CODIGO", "NOMBRE"]),
("ITEM", ["COD_PROTOCOLO", "CUPS_DETALLE", "CODITEMINTERFACE"]),
("RELACION", ["COD_EXAMEN"]),
("RELACION_HIMS", ["CUPS_REF"]),
("ARTICULO", ["CODIGO", "CUPS"]),
("SERVICIO", ["CODIGO", "CUPS", "COD_CUPS"]),
("PROCEDIMIENTO", ["CODIGO", "CUPS"]),
]
# 3. PROTOCOLO para CH4 encontrado = {}
ok2, err2, rows2 = fb.execute_query( for tabla, campos in tablas_candidatas:
"SELECT FIRST 1 p.* FROM PROTOCOLO p WHERE TRIM(p.CODIGO) = 'CH4'" for campo in campos:
) ok_t, _, rows_t = fb.execute_query(
results["protocolo_ch4"] = rows2[0] if (ok2 and rows2) else f"error: {err2}" f"SELECT FIRST 3 t.* FROM {tabla} t "
f"WHERE TRIM(t.{campo}) = '{BUSCAR}'"
)
if ok_t and rows_t:
encontrado[f"{tabla}.{campo}"] = list(rows_t)
results["valor_903967_encontrado_en"] = encontrado if encontrado else "no encontrado en tablas comunes"
# Buscar en campos de tipo char/varchar de PROTOCOLO e ITEM que no conocemos
for tabla in ["PROTOCOLO", "ITEM"]:
ok_c, _, rows_c = fb.execute_query(
f"SELECT TRIM(f.RDB$FIELD_NAME) AS c FROM RDB$RELATION_FIELDS f "
f"WHERE TRIM(f.RDB$RELATION_NAME)='{tabla}' ORDER BY f.RDB$FIELD_POSITION"
)
if ok_c:
results[f"columnas_{tabla.lower()}"] = [r["c"] for r in rows_c]
fb.disconnect() fb.disconnect()
return JSONResponse(results) return JSONResponse(results)
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter, Request, Depends
from app.auth import get_current_user
router = APIRouter(prefix="/documentacion", tags=["docs"])
@router.get("")
async def docs_page(request: Request, user: dict = Depends(get_current_user)):
return request.app.state.templates.TemplateResponse("docs.html", {
"request": request, "user": user,
})
+116
View File
@@ -0,0 +1,116 @@
import httpx
import traceback as tb
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import JSONResponse
from app.auth import get_current_user
from app.database import get_connection
from app.services.scheduler import sync_recientes
from app.services.firebird_service import get_firebird_from_config
router = APIRouter(prefix="/envios", tags=["envios"])
@router.get("/tns")
async def envios_tns(request: Request, user: dict = Depends(get_current_user)):
return request.app.state.templates.TemplateResponse("envios_tns.html", {
"request": request, "user": user,
})
@router.get("/erp")
async def envios_erp(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "")
wa_key = configs.get("whatsapp_api_key", "")
return request.app.state.templates.TemplateResponse("envios_erp.html", {
"request": request, "user": user,
"wa_configurado": bool(wa_url and wa_key),
"wa_url": wa_url,
})
@router.post("/erp/sync-now")
async def erp_sync_now(
ventana_min: int = Query(default=30, ge=1, le=1440),
user: dict = Depends(get_current_user),
):
try:
resultado = await sync_recientes(ventana_min=ventana_min)
return JSONResponse(resultado)
except Exception as e:
return JSONResponse({"ok": False, "error": str(e), "traceback": tb.format_exc()})
@router.post("/erp/sync-diagnosticos")
async def erp_sync_diagnosticos(
user: dict = Depends(get_current_user),
start_from: int = Query(default=0, ge=0, description="Índice desde el que reanudar"),
):
"""Migra los 12.422 diagnósticos CIE-10 de Firebird → WhatsApp en lotes de 200."""
try:
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
if not wa_url or not wa_key:
return JSONResponse({"ok": False, "error": "whatsapp_url o whatsapp_api_key no configurados"})
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"ok": False, "error": f"Firebird: {msg}"})
ok_r, err_r, rows = fb.execute_query(
"SELECT TRIM(d.COD_DIAG) AS COD_DIAG, TRIM(d.CONCEPTO) AS CONCEPTO "
"FROM DIAGNOSTICO d ORDER BY d.COD_DIAG", None
)
fb.disconnect()
if not ok_r:
return JSONResponse({"ok": False, "error": f"Query Firebird: {err_r}"})
diagnosticos = [{"cod": r["COD_DIAG"], "concepto": r["CONCEPTO"]} for r in (rows or [])]
total = len(diagnosticos)
batch_size = 200
url = f"{wa_url}/api/lab/ingest_diagnosticos.php"
headers = {"Content-Type": "application/json", "X-Lab-Key": wa_key}
insertados = 0
errores = []
async with httpx.AsyncClient(timeout=60) as client:
for i in range(start_from, total, batch_size):
lote = diagnosticos[i:i + batch_size]
try:
resp = await client.post(url, json={"rows": lote}, headers=headers)
try:
data = resp.json()
except Exception:
raw = resp.text[:400].strip()
errores.append(f"Lote {i}-{i+len(lote)} HTTP {resp.status_code}: {raw!r}")
if i == 0:
break # Si el primer lote ya falla, no tiene sentido continuar
continue
if data.get("ok"):
insertados += data.get("insertados", len(lote))
else:
errores.append(f"Lote {i}-{i+len(lote)}: {data.get('error', '?')}")
if i == 0:
break
except Exception as ex:
errores.append(f"Lote {i}-{i+len(lote)}: {ex}")
if i == 0:
break
return JSONResponse({
"ok": len(errores) == 0,
"total": total,
"insertados": insertados,
"lotes": (total + batch_size - 1) // batch_size,
"errores": errores,
})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e), "traceback": tb.format_exc()})
+183 -2
View File
@@ -1,10 +1,26 @@
import json as json_lib
from datetime import datetime, date as date_cls
import httpx
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
from app.services.json_generator import _clean_times
from app.services.api_client import get_tns_token, TNS_BASE
from app.utils.activity import log_activity, get_ip
from app.routes.automation import _parse_tns_resp
router = APIRouter(prefix="/logs", tags=["logs"]) router = APIRouter(prefix="/logs", tags=["logs"])
def _limpiar_json_enviado(raw: str) -> str:
if not raw:
return raw
try:
return json_lib.dumps(_clean_times(json_lib.loads(raw)), ensure_ascii=False, indent=2)
except Exception:
return raw
PAGE_SIZE = 50 PAGE_SIZE = 50
@@ -34,7 +50,7 @@ async def logs_page(
where.append("e.factura LIKE ?") where.append("e.factura LIKE ?")
params.append(f"%{factura}%") params.append(f"%{factura}%")
if paciente: if paciente:
where.append("(CAST(e.idrecepcion AS TEXT) LIKE ? OR e.contrato LIKE ?)") where.append("(e.cedula LIKE ? OR e.factura LIKE ?)")
params += [f"%{paciente}%", f"%{paciente}%"] params += [f"%{paciente}%", f"%{paciente}%"]
if fecha_desde: if fecha_desde:
where.append("DATE(e.created_at) >= ?") where.append("DATE(e.created_at) >= ?")
@@ -76,6 +92,171 @@ async def logs_page(
}) })
@router.get("/actividad")
async def actividad_page(
request: Request,
user: dict = Depends(get_current_user),
username: str = "",
accion: str = "",
fecha_desde: str = "",
fecha_hasta: str = "",
offset: int = 0,
):
conn = get_connection()
where = ["1=1"]
params = []
if username:
where.append("a.username = ?")
params.append(username)
if accion:
where.append("a.accion = ?")
params.append(accion)
if fecha_desde:
where.append("DATE(a.created_at) >= ?")
params.append(fecha_desde)
if fecha_hasta:
where.append("DATE(a.created_at) <= ?")
params.append(fecha_hasta)
w = " AND ".join(where)
total = conn.execute(f"SELECT COUNT(*) FROM activity_log a WHERE {w}", params).fetchone()[0]
rows = conn.execute(f"""
SELECT a.* FROM activity_log a
WHERE {w} ORDER BY a.created_at DESC LIMIT ? OFFSET ?
""", params + [PAGE_SIZE, offset]).fetchall()
usuarios = conn.execute("SELECT DISTINCT username FROM activity_log ORDER BY username").fetchall()
acciones = conn.execute("SELECT DISTINCT accion FROM activity_log ORDER BY accion").fetchall()
conn.close()
return request.app.state.templates.TemplateResponse("actividad.html", {
"request": request, "user": user,
"rows": rows, "total": total,
"usuarios": [u["username"] for u in usuarios],
"acciones": [a["accion"] for a in acciones],
"filtro_username": username, "filtro_accion": accion,
"filtro_fecha_desde": fecha_desde, "filtro_fecha_hasta": fecha_hasta,
"offset": offset, "page_size": PAGE_SIZE,
"has_more": (offset + PAGE_SIZE) < total,
})
@router.post("/reenviar/{envio_id}")
async def reenviar_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
row = conn.execute("SELECT * FROM envios WHERE id = ?", (envio_id,)).fetchone()
conn.close()
if not row:
return JSONResponse({"success": False, "message": "Registro no encontrado"})
if row["status"] in ("success", "warning"):
return JSONResponse({"success": False, "message": "No aplica reenvío para este registro"})
if not row["json_enviado"]:
return JSONResponse({"success": False, "message": "Sin JSON guardado para reenviar"})
try:
rda_json = json_lib.loads(row["json_enviado"])
except Exception:
return JSONResponse({"success": False, "message": "JSON inválido en registro"})
conn_cfg = get_connection()
cfg = {r["key"]: r["value"] for r in conn_cfg.execute("SELECT * FROM config").fetchall()}
conn_cfg.close()
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: {err}"})
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
api_sucursal = cfg.get("api_sucursal", "00") or "00"
tipo = row["tipo"]
endpoint = (
f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
if tipo == "transaccion"
else f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
if tipo == "ventas"
else f"{TNS_BASE}/v2/tablas/Tercero/Crear"
)
new_status = "error"
msg = ""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(endpoint, json=rda_json, headers=headers)
new_status, msg = _parse_tns_resp(r)
except Exception as ex:
msg = str(ex)
if new_status != "error":
conn = get_connection()
conn.execute(
"UPDATE envios SET status=?, mensaje_tns=?, respuesta_api=?, created_at=? WHERE id=?",
(new_status, msg[:500], msg[:5000], datetime.now().isoformat(), envio_id)
)
conn.commit()
conn.close()
log_activity(user["user_id"], user["username"], "reenvio_ok",
f"Envío #{envio_id} factura {row['factura']} reenvío {new_status}", get_ip(request))
return JSONResponse({"success": new_status != "error", "message": msg})
@router.get("/resumen")
async def resumen_page(
request: Request,
user: dict = Depends(get_current_user),
fecha: str = "",
tipo: str = "",
):
if not fecha:
fecha = date_cls.today().isoformat()
conn = get_connection()
where_extra = ""
params_q: list = [fecha]
if tipo:
where_extra = "AND tipo = ?"
params_q.append(tipo)
rows = conn.execute(f"""
SELECT
factura, tipo,
MAX(contrato) AS contrato,
MIN(cedula) AS cedula,
MAX(idrecepcion) AS idrecepcion,
COUNT(*) AS intentos,
SUM(CASE WHEN status != 'error' THEN 1 ELSE 0 END) AS exitos,
MIN(created_at) AS primer_envio,
MAX(created_at) AS ultimo_envio,
MAX(CASE WHEN status = 'error' THEN id END) AS ultimo_error_id,
MAX(CASE WHEN status != 'error' THEN id END) AS exitoso_id,
CASE
WHEN SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) > 0 THEN 'success'
WHEN SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) > 0 THEN 'warning'
ELSE 'error'
END AS estado_final
FROM envios
WHERE DATE(created_at) = ? {where_extra}
GROUP BY CASE WHEN tipo = 'terceros' THEN cedula ELSE factura END, tipo
ORDER BY estado_final, tipo, CASE WHEN tipo = 'terceros' THEN cedula ELSE factura END
""", params_q).fetchall()
conn.close()
total = len(rows)
exitosos = sum(1 for r in rows if r["estado_final"] != "error")
errores = sum(1 for r in rows if r["estado_final"] == "error")
return request.app.state.templates.TemplateResponse("resumen.html", {
"request": request, "user": user,
"fecha": fecha,
"filtro_tipo": tipo,
"rows": rows,
"total": total,
"exitosos": exitosos,
"errores": errores,
})
@router.get("/detalle/{envio_id}") @router.get("/detalle/{envio_id}")
async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)): async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)):
conn = get_connection() conn = get_connection()
@@ -95,7 +276,7 @@ async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(ge
"status": row["status"], "status": row["status"],
"mensaje_tns": row["mensaje_tns"], "mensaje_tns": row["mensaje_tns"],
"respuesta_api": row["respuesta_api"], "respuesta_api": row["respuesta_api"],
"json_enviado": row["json_enviado"], "json_enviado": _limpiar_json_enviado(row["json_enviado"]),
"fecha_inicio": row["fecha_inicio"], "fecha_inicio": row["fecha_inicio"],
"fecha_fin": row["fecha_fin"], "fecha_fin": row["fecha_fin"],
"created_at": row["created_at"], "created_at": row["created_at"],
+256
View File
@@ -0,0 +1,256 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import JSONResponse
from app.auth import get_current_user
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
from app.services.whatsapp_sync import sync_todos, guardar_sync_log
router = APIRouter(prefix="/pacientes", tags=["pacientes"])
# Trae todos los pacientes distintos en un rango de fechas de recepción.
# Reutiliza la misma query de automation para no duplicar lógica.
_SQL_TODOS = """
SELECT DISTINCT
p.CODIGO,
p.TIPOIDENT,
p.DOCIDENT,
p.NOMBRES,
p.APELLIDOS,
p.DIRECCION,
p.CIUDAD AS COD_CIUDAD,
c.NOMBRE AS NOM_CIUDAD,
p.TELEFONOS,
p.EMAIL,
p.F_NACIMIENTO,
p.SEXO,
p.TIPORES,
p.CODETNIA
FROM PACIENTE p
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
LEFT JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
WHERE (:fecha_ini IS NULL OR r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin)
AND r.NUM_FACTURA > 0
"""
_SQL_EXAMENES_CEDULA = """
SELECT
r.IDRECEPCION,
r.HORAINICIORECEPCION,
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
TRIM(ex.NOMBRE) AS NOM_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO,
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
r.VALORTOTAL
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 DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
WHERE TRIM(p.DOCIDENT) = :cedula
AND CAST(r.FECHA_RECEPCION AS DATE) = CURRENT_DATE
ORDER BY r.IDRECEPCION DESC
"""
_SQL_SIN_FILTRO = """
SELECT DISTINCT
p.CODIGO,
p.TIPOIDENT,
p.DOCIDENT,
p.NOMBRES,
p.APELLIDOS,
p.DIRECCION,
p.CIUDAD AS COD_CIUDAD,
c.NOMBRE AS NOM_CIUDAD,
p.TELEFONOS,
p.EMAIL,
p.F_NACIMIENTO,
p.SEXO,
p.TIPORES,
p.CODETNIA
FROM PACIENTE p
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
WHERE p.DOCIDENT IS NOT NULL AND p.NOMBRES IS NOT NULL
"""
@router.get("/examenes")
async def examenes_paciente(request: Request, cedula: str = ""):
"""Server-to-server: exámenes registrados hoy (últimos 5 min) para una cédula."""
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
api_key = request.headers.get("X-Lab-Key", "")
if not api_key or api_key != configs.get("whatsapp_api_key", ""):
return JSONResponse({"ok": False, "error": "No autorizado"}, status_code=401)
cedula = cedula.strip()
if not cedula:
return JSONResponse({"ok": False, "error": "cedula requerida"}, status_code=400)
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"ok": False, "error": f"Error Firebird: {msg}"}, status_code=503)
ok_q, err_q, rows = fb.execute_query(_SQL_EXAMENES_CEDULA, {"cedula": cedula})
fb.disconnect()
if not ok_q:
return JSONResponse({"ok": False, "error": f"Error consulta: {err_q}"}, status_code=500)
# Filtrar últimos 5 minutos por HORAINICIORECEPCION
ahora = datetime.now()
limite = ahora - timedelta(minutes=5)
def parse_hora(raw) -> datetime | None:
if not raw:
return None
if isinstance(raw, datetime):
return ahora.replace(hour=raw.hour, minute=raw.minute, second=raw.second, microsecond=0)
s = str(raw)
try:
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
dt = datetime.fromisoformat(s.replace(" ", "T"))
return ahora.replace(hour=dt.hour, minute=dt.minute, second=dt.second, microsecond=0)
partes = s.split(":")
return ahora.replace(hour=int(partes[0]), minute=int(partes[1]),
second=int(partes[2].split(".")[0]), microsecond=0)
except Exception:
return None
def safe_num(val):
if val is None:
return None
try:
return float(val)
except Exception:
return None
def fmt_hora_str(val) -> str:
if not val:
return ""
if isinstance(val, datetime):
return val.strftime("%H:%M:%S")
s = str(val)
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
return s[11:19]
return s[:8]
examenes = []
for row in rows:
hora_dt = parse_hora(row.get("HORAINICIORECEPCION"))
if hora_dt and hora_dt < limite:
continue
examenes.append({
"recepcion_id": row.get("IDRECEPCION"),
"hora": fmt_hora_str(row.get("HORAINICIORECEPCION")),
"cod_examen": (row.get("COD_EXAMEN") or "").strip(),
"nombre": (row.get("NOM_EXAMEN") or "").strip(),
"cups": (row.get("CUPS") or "").strip(),
"precio": safe_num(row.get("PRECIO")),
"diagnostico_cod": (row.get("DIAG_PPAL") or "").strip(),
"diagnostico_nombre": (row.get("DIAG_CONCEPTO") or "").strip(),
"medico_docidmedico": (row.get("DOCIDMEDICO") or "").strip(),
"nit_empresa": (row.get("NIT_EMPRESA") or "").strip(),
"valor_total": safe_num(row.get("VALORTOTAL")),
})
return JSONResponse({"ok": True, "examenes": examenes})
@router.get("")
async def pacientes_page(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "")
wa_key = configs.get("whatsapp_api_key", "")
return request.app.state.templates.TemplateResponse("pacientes.html", {
"request": request,
"user": user,
"wa_configurado": bool(wa_url and wa_key),
"wa_url": wa_url,
})
@router.post("/sync-all")
async def sync_all(
request: Request,
user: dict = Depends(get_current_user),
fecha_ini: str = Form(""),
fecha_fin: str = Form(""),
todos: str = Form(""),
):
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
if not wa_url or not wa_key:
return JSONResponse({
"success": False,
"message": "Configura la URL y API Key de WhatsApp Lab antes de sincronizar.",
})
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return JSONResponse({"success": False, "message": f"Error Firebird: {msg}"})
if todos == "1" or (not fecha_ini and not fecha_fin):
ok_q, err_q, rows = fb.execute_query(_SQL_SIN_FILTRO, None)
else:
if not fecha_ini or not fecha_fin:
fb.disconnect()
return JSONResponse({"success": False, "message": "Indica fecha inicio y fecha fin."})
params = {
"fecha_ini": f"{fecha_ini} 00:00:00",
"fecha_fin": f"{fecha_fin} 23:59:59",
}
ok_q, err_q, rows = fb.execute_query(_SQL_TODOS, params)
fb.disconnect()
if not ok_q:
return JSONResponse({"success": False, "message": f"Error al consultar Firebird: {err_q}"})
if not rows:
return JSONResponse({"success": False, "message": "No se encontraron pacientes con esos criterios."})
timeout = int(configs.get("api_timeout", 30))
resultado = await sync_todos(rows, ingest_url, wa_key, timeout, modo="insertar")
guardar_sync_log(resultado, user["user_id"], origen="manual", modo="insertar")
resultado["success"] = True
resultado["message"] = (
f"{resultado['total']} procesados — "
f"{resultado['created']} nuevos, "
f"{resultado['skipped']} ya existían, "
f"{resultado['errores']} errores."
)
return JSONResponse(resultado)
@router.get("/historial")
async def historial(request: Request, user: dict = Depends(get_current_user)):
conn = get_connection()
rows = conn.execute("""
SELECT l.*, u.username
FROM sync_wa_log l
LEFT JOIN users u ON u.id = l.user_id
ORDER BY l.created_at DESC
LIMIT 100
""").fetchall()
conn.close()
return JSONResponse([dict(r) for r in rows])
+145
View File
@@ -47,8 +47,11 @@ WHERE p.DOCIDENT = :doc_num""",
r.CLASEPROC, r.CLASEPROC,
r.HORAINICIORECEPCION, r.HORAINICIORECEPCION,
r.VALORTOTAL, r.VALORTOTAL,
r.VALORDESC,
rel.COD_EXAMEN, rel.COD_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO, rel.PRECIO,
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
rel.FECHA_REPORTADO, rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD, m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional, COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
@@ -56,8 +59,10 @@ WHERE p.DOCIDENT = :doc_num""",
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
FROM RECEPCION r FROM RECEPCION r
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA 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""", WHERE r.NUM_FACTURA = :num_factura""",
"description": "Servicios de una recepción por número de factura" "description": "Servicios de una recepción por número de factura"
}, },
@@ -78,8 +83,11 @@ WHERE r.NUM_FACTURA = :num_factura""",
r.CLASEPROC, r.CLASEPROC,
r.HORAINICIORECEPCION, r.HORAINICIORECEPCION,
r.VALORTOTAL, r.VALORTOTAL,
r.VALORDESC,
rel.COD_EXAMEN, rel.COD_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO, rel.PRECIO,
COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
rel.FECHA_REPORTADO, rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD, m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional, COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
@@ -87,12 +95,149 @@ WHERE r.NUM_FACTURA = :num_factura""",
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
FROM RECEPCION r FROM RECEPCION r
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA 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 WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
ORDER BY r.IDRECEPCION""", ORDER BY r.IDRECEPCION""",
"description": "Servicios en un rango de fechas (para RdaPaciente/Insertar)" "description": "Servicios en un rango de fechas (para RdaPaciente/Insertar)"
}, },
{
"name": "RDA Pre-servicios por fecha",
"query_type": "transaccion",
"query_text": """SELECT
ps.ID_PS,
ps.PS_PREFIJO,
ps.PS_NUMERO,
r.IDRECEPCION,
r.PREFIJO,
r.NUM_FACTURA,
r.FECHA_RECEPCION,
r.COD_PACIENTE,
r.NIT_EMPRESA,
r.DIAG_PPAL,
r.TIPOUSU,
r.TIPOUSUSISPRO,
r.AUTORIZACION,
r.CLASEPROC,
r.HORAINICIORECEPCION,
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,
rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
e.CODCONTRATO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
FROM PRESSERV_DIAN ps
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
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 (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
ORDER BY ps.ID_PS, rel.COD_EXAMEN""",
"description": "Pre-servicios RCXC/SC en un rango de fechas (agrupa por ID_PS)"
},
{
"name": "RDA Pre-servicios por número",
"query_type": "transaccion",
"query_text": """SELECT
ps.ID_PS,
ps.PS_PREFIJO,
ps.PS_NUMERO,
r.IDRECEPCION,
r.PREFIJO,
r.NUM_FACTURA,
r.FECHA_RECEPCION,
r.COD_PACIENTE,
r.NIT_EMPRESA,
r.DIAG_PPAL,
r.TIPOUSU,
r.TIPOUSUSISPRO,
r.AUTORIZACION,
r.CLASEPROC,
r.HORAINICIORECEPCION,
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,
rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
e.CODCONTRATO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
FROM PRESSERV_DIAN ps
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
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 ps.PS_NUMERO = :ps_numero
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
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 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,
TRIM(e.CODCONTRATO) AS 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
ORDER BY r.IDRECEPCION""",
"description": "Facturas de venta en un rango de fechas (filtra por excluir_ventas en /contratos)"
},
{
"name": "Factura Venta 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,
TRIM(e.CODCONTRATO) AS 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""",
"description": "Factura de venta por número (filtra por excluir_ventas en /contratos)"
},
] ]
+27
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.firebird_service import get_firebird_from_config
from app.services.json_generator import generar_tercero_api from app.services.json_generator import generar_tercero_api
from app.services.api_client import get_tns_token, TNS_BASE from app.services.api_client import get_tns_token, TNS_BASE
from app.services.whatsapp_sync import sync_paciente, guardar_sync_log
router = APIRouter(prefix="/terceros", tags=["terceros"]) router = APIRouter(prefix="/terceros", tags=["terceros"])
@@ -183,9 +184,35 @@ async def send_terceros(
conn.commit() conn.commit()
conn.close() conn.close()
# ── Sync silencioso a WhatsApp Lab ───────────────────────────────────────
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
wa_sync = None
if wa_url and wa_key and rows:
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
try:
wa_result = await sync_paciente(rows[0], ingest_url, wa_key, modo="upsert")
wa_sync = {"ok": wa_result["ok"], "action": wa_result["action"]}
guardar_sync_log(
{
"total": 1,
"created": 1 if wa_result["action"] == "created" else 0,
"skipped": 1 if wa_result["action"] == "skipped" else 0,
"updated": 1 if wa_result["action"] == "updated" else 0,
"errores": 0 if wa_result["ok"] else 1,
"detalle": [] if wa_result["ok"] else [wa_result],
},
user["user_id"],
origen="tercero",
modo="upsert",
)
except Exception:
wa_sync = {"ok": False, "action": "error"}
return JSONResponse({ return JSONResponse({
"success": resp_ok, "success": resp_ok,
"status_code": resp_code, "status_code": resp_code,
"message": "Envío exitoso" if resp_ok else f"Error: {resp_text}", "message": "Envío exitoso" if resp_ok else f"Error: {resp_text}",
"cuv": resp_text[:200] if resp_ok else None, "cuv": resp_text[:200] if resp_ok else None,
"wa_sync": wa_sync,
}) })
+28 -10
View File
@@ -13,6 +13,7 @@ from app.services.json_generator import (
agrupar_por_recepcion, agrupar_por_recepcion,
) )
from app.services.api_client import get_tns_token, TNS_BASE from app.services.api_client import get_tns_token, TNS_BASE
from app.routes.contratos import load_contrato_map, load_excluded_set, load_sin_contrato_set
router = APIRouter(prefix="/test-rda", tags=["test-rda"]) router = APIRouter(prefix="/test-rda", tags=["test-rda"])
@@ -20,18 +21,20 @@ _SQL_RDA_BY_WHERE = """
SELECT SELECT
r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION, r.COD_PACIENTE, r.IDRECEPCION, r.PREFIJO, r.NUM_FACTURA, r.FECHA_RECEPCION, r.COD_PACIENTE,
r.NIT_EMPRESA, r.DIAG_PPAL, r.TIPOUSU, r.TIPOUSUSISPRO, r.AUTORIZACION, r.CLASEPROC, r.NIT_EMPRESA, r.DIAG_PPAL, r.TIPOUSU, r.TIPOUSUSISPRO, r.AUTORIZACION, r.CLASEPROC,
r.HORAINICIORECEPCION, r.VALORTOTAL, rel.COD_EXAMEN, r.HORAINICIORECEPCION, r.VALORTOTAL, r.VALORDESC, rel.COD_EXAMEN,
COALESCE(NULLIF(TRIM(it.CUPS_DETALLE), ''), TRIM(rel.COD_EXAMEN)) AS CUPS, COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO, rel.FECHA_REPORTADO, rel.PRECIO, COALESCE(t.VALOR, rel.PRECIO) AS PRECIO_TARIFA,
rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD, m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional, COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
e.CODCONTRATO, e.CODCONTRATO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
FROM RECEPCION r FROM RECEPCION r
JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION JOIN RELACION rel ON rel.IDRECEPCION = r.IDRECEPCION
LEFT JOIN ITEM it ON TRIM(it.COD_PROTOCOLO) = TRIM(rel.COD_EXAMEN) LEFT JOIN EXAMEN ex ON TRIM(ex.CODIGO) = TRIM(rel.COD_EXAMEN)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA 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 {where} WHERE {where}
AND r.NUM_FACTURA > 0 AND r.NUM_FACTURA > 0
AND e.CODCONTRATO IS NOT NULL AND e.CODCONTRATO IS NOT NULL
@@ -159,15 +162,19 @@ async def get_candidatos(request: Request, user: dict = Depends(get_current_user
if not ok2: if not ok2:
return JSONResponse({"error": err2}) return JSONResponse({"error": err2})
excluded = load_excluded_set()
candidatos = [] candidatos = []
for row in rows: for row in rows:
cod_c = str(row.get("CODCONTRATO") or "").strip()
if cod_c in excluded:
continue
candidatos.append({ candidatos.append({
"idrecepcion": row.get("IDRECEPCION"), "idrecepcion": row.get("IDRECEPCION"),
"factura": row.get("NUM_FACTURA"), "factura": row.get("NUM_FACTURA"),
"fecha": str(row.get("FECHA_RECEPCION", ""))[:10], "fecha": str(row.get("FECHA_RECEPCION", ""))[:10],
"paciente": row.get("COD_PACIENTE", ""), "paciente": row.get("COD_PACIENTE", ""),
"empresa": row.get("NIT_EMPRESA", ""), "empresa": row.get("NIT_EMPRESA", ""),
"contrato": row.get("CODCONTRATO", ""), "contrato": cod_c,
"examenes": row.get("EXAMENES", ""), "examenes": row.get("EXAMENES", ""),
"total": row.get("TOTAL_EXAMENES", 0), "total": row.get("TOTAL_EXAMENES", 0),
}) })
@@ -197,8 +204,13 @@ async def preview_rda(request: Request, user: dict = Depends(get_current_user),
if not ok2 or not rows_rda: if not ok2 or not rows_rda:
return JSONResponse({"error": err2 or "Sin registros"}) return JSONResponse({"error": err2 or "Sin registros"})
grupos = agrupar_por_recepcion(rows_rda) grupos = agrupar_por_recepcion(rows_rda)
rda_json = generar_rda_paciente(list(grupos.values())[0], prof_def, esp_def, remis_def, prefijo_def) grupo = list(grupos.values())[0]
return JSONResponse({"json": rda_json}) cod_c = str(grupo[0].get("CODCONTRATO") or "").strip()
excluido = cod_c in load_excluded_set()
rda_json = generar_rda_paciente(grupo, prof_def, esp_def, remis_def, prefijo_def,
contrato_map=load_contrato_map(),
sin_contrato_set=load_sin_contrato_set())
return JSONResponse({"json": rda_json, "excluido": excluido, "contrato": cod_c})
@router.post("/enviar") @router.post("/enviar")
@@ -250,6 +262,12 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
fb.disconnect() fb.disconnect()
return JSONResponse({"error": "Sin registros (¿NUM_FACTURA = 0 o contrato vacío?)"}) return JSONResponse({"error": "Sin registros (¿NUM_FACTURA = 0 o contrato vacío?)"})
# Bloquear si el contrato está excluido
cod_c_check = str(rows_rda[0].get("CODCONTRATO") or "").strip()
if cod_c_check in load_excluded_set():
fb.disconnect()
return JSONResponse({"error": f"Contrato {cod_c_check} está excluido del envío TNS"})
# Pacientes únicos # Pacientes únicos
pacientes_unicos = {} pacientes_unicos = {}
for row in rows_rda: for row in rows_rda:
@@ -299,12 +317,14 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
# Paso 2: enviar RDA # Paso 2: enviar RDA
grupos = agrupar_por_recepcion(rows_rda) grupos = agrupar_por_recepcion(rows_rda)
contrato_map = load_contrato_map()
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
for id_rec, grupo_rows in grupos.items(): for id_rec, grupo_rows in grupos.items():
num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip() num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
numero_override = num_factura numero_override = num_factura
rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, numero_override) rda_json = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def, numero_override,
contrato_map=contrato_map, sin_contrato_set=load_sin_contrato_set())
examenes = [r.get("COD_EXAMEN", "") for r in grupo_rows] examenes = [r.get("COD_EXAMEN", "") for r in grupo_rows]
try: try:
r = await client.post(endpoint_rda, json=rda_json, headers=headers) r = await client.post(endpoint_rda, json=rda_json, headers=headers)
@@ -333,8 +353,6 @@ async def test_enviar(request: Request, user: dict = Depends(get_current_user)):
"respuesta_tns": raw_resp, "respuesta_tns": raw_resp,
"json_enviado": rda_json, "json_enviado": rda_json,
}) })
if ok_rda:
facturas_usadas_sesion.add(num_factura)
db_log = get_connection() db_log = get_connection()
db_log.execute( db_log.execute(
"INSERT OR REPLACE INTO rda_test_log (idrecepcion, factura, contrato, ok, mensaje) VALUES (?,?,?,?,?)", "INSERT OR REPLACE INTO rda_test_log (idrecepcion, factura, contrato, ok, mensaje) VALUES (?,?,?,?,?)",
+222 -48
View File
@@ -6,8 +6,10 @@ from fastapi.responses import JSONResponse
from app.database import get_connection from app.database import get_connection
from app.auth import get_current_user from app.auth import get_current_user
from app.services.firebird_service import get_firebird_from_config 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.json_generator import generar_rda_paciente
from app.services.api_client import get_tns_token, TNS_BASE from app.services.api_client import get_tns_token, TNS_BASE
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="/transaccion", tags=["transaccion"]) router = APIRouter(prefix="/transaccion", tags=["transaccion"])
@@ -25,23 +27,81 @@ def _query_rows(cfg, query_text, factura, fecha_inicio, fecha_fin):
if not ok: if not ok:
return None, msg, None return None, msg, None
nums = _re.findall(r'\d+', factura or "") nums = _re.findall(r'\d+', factura or "")
num_val = int(nums[-1]) if nums else (factura or "") num_val = int(nums[-1]) if nums else 0
prefix = _re.sub(r'[\d\s]', '', factura or "").strip().upper() prefix = _re.sub(r'[\d\s]', '', factura or "").strip().upper()
params = {"fecha_ini": f"{fecha_inicio} 00:00:00", "fecha_fin": f"{fecha_fin} 23:59:59"}
# Solo agregar los parámetros que la query realmente usa
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: if ":num_factura" in query_text:
params["num_factura"] = num_val params["num_factura"] = num_val
if ":prefijo" in query_text: if ":prefijo" in query_text:
params["prefijo"] = prefix params["prefijo"] = prefix
if ":ps_numero" in query_text:
params["ps_numero"] = num_val
ok2, err, rows = fb.execute_query(query_text, params) ok2, err, rows = fb.execute_query(query_text, params)
fb.disconnect() fb.disconnect()
if not ok2: if not ok2:
return None, err, None return None, err, None
# Si el usuario escribió prefijo (ej. "LHXC03726") y el query devuelve PREFIJO, filtramos
if prefix and rows and "PREFIJO" in rows[0]: # Filtro Python por prefijo (PREFIJO o PS_PREFIJO)
rows = [r for r in rows if str(r.get("PREFIJO") or "").strip().upper() == prefix] if prefix and rows:
if "PS_PREFIJO" in rows[0]:
rows = [r for r in rows if str(r.get("PS_PREFIJO") or "").strip().upper() == prefix]
elif "PREFIJO" in rows[0]:
rows = [r for r in rows if str(r.get("PREFIJO") or "").strip().upper() == prefix]
return rows, None, fb return rows, None, fb
def _is_preserv(rows: list) -> bool:
return bool(rows) and "ID_PS" in rows[0]
def _agrupar(rows: list) -> dict:
"""Agrupa por ID_PS (pre-servicios) o IDRECEPCION (regulares)."""
from collections import defaultdict
grupos = defaultdict(list)
key_field = "ID_PS" if _is_preserv(rows) else "IDRECEPCION"
for row in rows:
grupos[row.get(key_field)].append(dict(row))
return dict(grupos)
def _build_rda(grupo_rows: list, cfg: dict, contrato_map: dict, sin_contrato_set_val: set) -> tuple:
"""Devuelve (rda_json, numero_override, prefijo_override, factura_display)."""
prof_def = cfg.get("profesional_default", "")
esp_def = cfg.get("especialidad_default", "")
remis_def = cfg.get("remisionante_default", "00")
prefijo_def = cfg.get("prefijo_tns_default", "00")
if _is_preserv(grupo_rows):
ps_prefijo = str(grupo_rows[0].get("PS_PREFIJO") or "SC").strip()
ps_numero = str(grupo_rows[0].get("PS_NUMERO") or "").strip()
prefijo_override = "00" if ps_prefijo == "RCXC" else ps_prefijo
rda = generar_rda_paciente(
grupo_rows, prof_def, esp_def, remis_def, prefijo_def,
numero_override=ps_numero,
contrato_map=contrato_map,
prefijo_override=prefijo_override,
sin_contrato_set=sin_contrato_set_val,
)
factura_display = f"{ps_prefijo}-{ps_numero.zfill(5)}"
return rda, ps_numero, prefijo_override, factura_display
else:
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
rda = generar_rda_paciente(
grupo_rows, prof_def, esp_def, remis_def, prefijo_def,
numero_override=num_fac,
contrato_map=contrato_map,
sin_contrato_set=sin_contrato_set_val,
)
return rda, num_fac, "", str(grupo_rows[0].get("NUM_FACTURA", ""))
def _is_sent(idrecepcion: int) -> dict: def _is_sent(idrecepcion: int) -> dict:
"""Returns the latest envio record for this idrecepcion, or None.""" """Returns the latest envio record for this idrecepcion, or None."""
conn = get_connection() conn = get_connection()
@@ -88,33 +148,40 @@ async def preview_transaccion(
if not rows: if not rows:
return JSONResponse({"success": False, "message": "Sin datos"}) return JSONResponse({"success": False, "message": "Sin datos"})
grupos = agrupar_por_recepcion(rows) excluded = load_excluded_set()
contrato_map = load_contrato_map()
sin_contrato_set_val = load_sin_contrato_set()
grupos_all = _agrupar(rows)
if contrato: if contrato:
grupos = {k: v for k, v in grupos.items() grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()} if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
prof_def = cfg.get("profesional_default", "") else:
esp_def = cfg.get("especialidad_default", "") grupos = {k: v for k, v in grupos_all.items()
remis_def = cfg.get("remisionante_default", "00") if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
prefijo_def = cfg.get("prefijo_tns_default", "00")
items = [] items = []
for id_rec, grupo_rows in grupos.items(): for key, grupo_rows in grupos.items():
enviado = _is_sent(id_rec) enviado = _is_sent(key)
rda = generar_rda_paciente(grupo_rows, prof_def, esp_def, remis_def, prefijo_def) cod_c = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
is_excluded = cod_c in excluded
rda, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
items.append({ items.append({
"idrecepcion": id_rec, "idrecepcion": key,
"factura": grupo_rows[0].get("NUM_FACTURA", ""), "factura": factura_display,
"paciente": grupo_rows[0].get("COD_PACIENTE", ""), "paciente": grupo_rows[0].get("COD_PACIENTE", ""),
"contrato": grupo_rows[0].get("CODCONTRATO", ""), "contrato": grupo_rows[0].get("CODCONTRATO", ""),
"examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows], "examenes": [r.get("COD_EXAMEN", "") for r in grupo_rows],
"valor": float(grupo_rows[0].get("VALORTOTAL") or 0), "valor": float(grupo_rows[0].get("VALORTOTAL") or 0),
"enviado": enviado, "enviado": enviado,
"excluido": is_excluded,
"json": rda, "json": rda,
}) })
pendientes = sum(1 for i in items if not i["enviado"]) pendientes = sum(1 for i in items if not i["enviado"] and not i["excluido"])
enviados_ok = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "success") 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") enviados_err = sum(1 for i in items if i["enviado"] and i["enviado"]["status"] == "error")
excluidos = sum(1 for i in items if i["excluido"])
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
@@ -122,6 +189,7 @@ async def preview_transaccion(
"pendientes": pendientes, "pendientes": pendientes,
"enviados_ok": enviados_ok, "enviados_ok": enviados_ok,
"enviados_err": enviados_err, "enviados_err": enviados_err,
"excluidos": excluidos,
"items": items, "items": items,
}) })
@@ -142,10 +210,14 @@ async def send_one(
if rows is None: if rows is None:
return JSONResponse({"success": False, "message": err}) return JSONResponse({"success": False, "message": err})
grupos = agrupar_por_recepcion(rows) grupos = _agrupar(rows)
grupo_rows = grupos.get(idrecepcion) grupo_rows = grupos.get(idrecepcion)
if not grupo_rows: if not grupo_rows:
return JSONResponse({"success": False, "message": f"IDRECEPCION {idrecepcion} no encontrado"}) return JSONResponse({"success": False, "message": f"Registro {idrecepcion} no encontrado"})
cod_c = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
if cod_c in load_excluded_set():
return JSONResponse({"success": False, "message": f"Contrato {cod_c} está excluido del envío TNS"})
token, token_err = await get_tns_token( token, token_err = await get_tns_token(
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "") cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
@@ -157,15 +229,8 @@ async def send_one(
api_sucursal = cfg.get("api_sucursal", "") or "00" api_sucursal = cfg.get("api_sucursal", "") or "00"
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}" endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
num_factura_one = str(grupo_rows[0].get("NUM_FACTURA") or "").strip() rda_json, numero_override_one, _, factura_display = _build_rda(
numero_override_one = num_factura_one grupo_rows, cfg, load_contrato_map(), load_sin_contrato_set()
rda_json = generar_rda_paciente(
grupo_rows,
cfg.get("profesional_default", ""),
cfg.get("especialidad_default", ""),
cfg.get("remisionante_default", "00"),
cfg.get("prefijo_tns_default", "00"),
numero_override_one,
) )
raw_resp = "" raw_resp = ""
@@ -193,12 +258,12 @@ async def send_one(
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", ( """, (
user["user_id"], "transaccion", user["user_id"], "transaccion",
str(grupo_rows[0].get("NUM_FACTURA", idrecepcion)), factura_display,
idrecepcion, idrecepcion,
str(grupo_rows[0].get("CODCONTRATO", "")), str(grupo_rows[0].get("CODCONTRATO", "")),
fecha_inicio, fecha_fin, 1, len(grupo_rows), fecha_inicio, fecha_fin, 1, len(grupo_rows),
"success" if ok_rda else "error", "success" if ok_rda else "error",
json_lib.dumps(rda_json, ensure_ascii=False)[:5000], json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
raw_resp[:2000], raw_resp[:2000],
msg_tns, msg_tns,
datetime.now().isoformat(), datetime.now().isoformat(),
@@ -206,6 +271,9 @@ async def send_one(
conn.commit() conn.commit()
conn.close() conn.close()
log_activity(user["user_id"], user["username"], "rda_enviado",
f"Factura {factura_display} | Contrato {grupo_rows[0].get('CODCONTRATO','')} | {'OK' if ok_rda else 'ERROR: '+msg_tns[:80]}",
get_ip(request))
return JSONResponse({"success": ok_rda, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion}) return JSONResponse({"success": ok_rda, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion})
@@ -227,13 +295,22 @@ async def send_transaccion(
if not rows: if not rows:
return JSONResponse({"success": False, "message": "Sin datos"}) return JSONResponse({"success": False, "message": "Sin datos"})
grupos = agrupar_por_recepcion(rows) excluded = load_excluded_set()
contrato_map = load_contrato_map()
sin_contrato_set_val = load_sin_contrato_set()
grupos_all = _agrupar(rows)
if contrato: if contrato:
grupos = {k: v for k, v in grupos.items() grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()} if str(v[0].get("CODCONTRATO") or "").strip() == contrato.strip()}
else:
grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded}
if solo_pendientes == "1": if solo_pendientes == "1":
grupos = {k: v for k, v in grupos.items() if not _is_sent(k)} grupos = {k: v for k, v in grupos.items() if not _is_sent(k)}
excluidos_count = len(grupos_all) - len(grupos)
token, token_err = await get_tns_token( token, token_err = await get_tns_token(
cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "") cfg.get("tns_empresa", ""), cfg.get("tns_usuario", ""), cfg.get("tns_password", "")
) )
@@ -246,17 +323,8 @@ async def send_transaccion(
resultados = [] resultados = []
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client: async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
for id_rec, grupo_rows in grupos.items(): for key, grupo_rows in grupos.items():
num_factura = str(grupo_rows[0].get("NUM_FACTURA") or "").strip() rda_json, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
numero_override = num_factura
rda_json = generar_rda_paciente(
grupo_rows,
cfg.get("profesional_default", ""),
cfg.get("especialidad_default", ""),
cfg.get("remisionante_default", "00"),
cfg.get("prefijo_tns_default", "00"),
numero_override,
)
raw_resp = "" raw_resp = ""
ok_rda = False ok_rda = False
msg_tns = "" msg_tns = ""
@@ -281,24 +349,130 @@ async def send_transaccion(
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", ( """, (
user["user_id"], "transaccion", user["user_id"], "transaccion",
str(grupo_rows[0].get("NUM_FACTURA", id_rec)), factura_display, key,
id_rec,
str(grupo_rows[0].get("CODCONTRATO", "")), str(grupo_rows[0].get("CODCONTRATO", "")),
fecha_inicio, fecha_fin, 1, len(grupo_rows), fecha_inicio, fecha_fin, 1, len(grupo_rows),
"success" if ok_rda else "error", "success" if ok_rda else "error",
json_lib.dumps(rda_json, ensure_ascii=False)[:5000], json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
raw_resp[:2000], msg_tns, raw_resp[:2000], msg_tns,
datetime.now().isoformat(), datetime.now().isoformat(),
)) ))
conn.commit() conn.commit()
conn.close() conn.close()
resultados.append({"idrecepcion": id_rec, "success": ok_rda, "msg": msg_tns}) resultados.append({"idrecepcion": key, "success": ok_rda, "msg": msg_tns})
ok_count = sum(1 for r in resultados if r["success"]) ok_count = sum(1 for r in resultados if r["success"])
err_count = len(resultados) - ok_count err_count = len(resultados) - ok_count
log_activity(user["user_id"], user["username"], "rda_masivo",
f"Enviados: {ok_count} OK, {err_count} errores, {excluidos_count} excluidos | {fecha_inicio}{fecha_fin}",
get_ip(request))
return JSONResponse({ return JSONResponse({
"success": err_count == 0, "success": err_count == 0,
"total_enviados": ok_count, "total_enviados": ok_count,
"total_errores": err_count, "total_errores": err_count,
"total_excluidos": excluidos_count,
"resultados": resultados,
})
@router.post("/send-direct")
async def send_direct(
request: Request,
user: dict = Depends(get_current_user),
prefijo: str = Form(...),
numero: str = Form(...),
query_id: int = 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"})
prefijo = prefijo.strip().upper()
numero = numero.strip()
factura_str = f"{prefijo}{numero}"
rows, err, _ = _query_rows(cfg, q["query_text"], factura_str, "2000-01-01", "2099-12-31")
if rows is None:
return JSONResponse({"success": False, "message": err})
if not rows:
return JSONResponse({"success": False, "message": f"No se encontró factura {factura_str} en Firebird"})
grupos = _agrupar(rows)
if not grupos:
return JSONResponse({"success": False, "message": "Sin datos agrupables"})
excluded = load_excluded_set()
primer_contrato = str(list(grupos.values())[0][0].get("CODCONTRATO") or "").strip()
if primer_contrato in excluded:
return JSONResponse({"success": False, "message": f"Contrato {primer_contrato} está excluido del envío TNS"})
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}"}
api_sucursal = cfg.get("api_sucursal", "") or "00"
endpoint = f"{TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}"
contrato_map = load_contrato_map()
sin_contrato_set_val = load_sin_contrato_set()
resultados = []
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
for key, grupo_rows in grupos.items():
rda_json, _, _, factura_display = _build_rda(grupo_rows, cfg, contrato_map, sin_contrato_set_val)
raw_resp = ""
ok_rda = False
msg_tns = ""
try:
r = await client.post(endpoint, json=rda_json, headers=headers)
raw_resp = r.text
try:
data = r.json()
ok_rda = bool(data.get("status") or (data.get("data") or {}).get("success", False))
msg_tns = ((data.get("data") or {}).get("response") or data.get("message") or raw_resp[:300])
except Exception:
ok_rda = r.status_code < 300
msg_tns = raw_resp[:300]
except Exception as ex:
msg_tns = str(ex)
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["user_id"], "transaccion", factura_display, key,
str(grupo_rows[0].get("CODCONTRATO", "")),
"2000-01-01", "2099-12-31", 1, len(grupo_rows),
"success" if ok_rda else "error",
json_lib.dumps(rda_json, ensure_ascii=False)[:10000],
raw_resp[:2000], msg_tns, datetime.now().isoformat(),
))
conn.commit()
conn.close()
resultados.append({
"idrecepcion": key,
"factura": factura_display,
"success": ok_rda,
"message": msg_tns,
"raw_tns": raw_resp[:1000],
"json": rda_json,
})
ok_count = sum(1 for r in resultados if r["success"])
log_activity(user["user_id"], user["username"], "rda_directo",
f"Factura {factura_str} | {'OK' if ok_count > 0 else 'ERROR'}",
get_ip(request))
return JSONResponse({
"success": ok_count > 0,
"total": len(resultados),
"resultados": resultados, "resultados": resultados,
}) })
+333
View File
@@ -0,0 +1,333 @@
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
from app.routes.contratos import load_excluded_ventas_set, load_forma_pago_map
router = APIRouter(prefix="/ventas", tags=["ventas"])
_SQL_VENTAS = """
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,
TRIM(e.CODCONTRATO) AS CODCONTRATO,
CAST(fd.FECHAFACT AS VARCHAR(30)) AS FECHAFACT,
fd.DIASVENC
FROM RECEPCION r
JOIN FACTURA_DIAN fd ON fd.PREFIJO = r.PREFIJO AND fd.NUM_FACTURA = r.NUM_FACTURA
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 CAST(fd.FECHAFACT AS TIMESTAMP) BETWEEN :fecha_ini AND :fecha_fin
AND (fd.ANULADA IS NULL OR fd.ANULADA = 'F')
AND r.PREFIJO = 'CMXC'
ORDER BY r.NUM_FACTURA, r.IDRECEPCION
"""
_SQL_VENTAS_BY_FACTURA = """
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,
TRIM(e.CODCONTRATO) AS CODCONTRATO,
CAST(fd.FECHAFACT AS VARCHAR(30)) AS FECHAFACT,
fd.DIASVENC
FROM RECEPCION r
JOIN FACTURA_DIAN fd ON fd.PREFIJO = r.PREFIJO AND fd.NUM_FACTURA = r.NUM_FACTURA
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.PREFIJO = :prefijo AND r.NUM_FACTURA = :num_factura
ORDER BY r.IDRECEPCION
"""
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, fecha_inicio, fecha_fin):
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return None, msg
ok2, err, rows = fb.execute_query(_SQL_VENTAS, {
"fecha_ini": f"{fecha_inicio} 00:00:00",
"fecha_fin": f"{fecha_fin} 23:59:59",
})
fb.disconnect()
if not ok2:
return None, err
return rows, None
def _query_rows_by_factura(cfg, prefijo, num_factura):
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
return None, msg
ok2, err, rows = fb.execute_query(_SQL_VENTAS_BY_FACTURA, {
"prefijo": prefijo,
"num_factura": int(num_factura),
})
fb.disconnect()
if not ok2:
return None, err
return [dict(r) for r in rows], None
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:
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)
def _build_venta(grupo_rows, cfg, forma_pago_map=None):
prefijo_def = cfg.get("prefijo_tns_default", "00")
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
fp_override = (forma_pago_map or {}).get(contrato, "")
return generar_factura_venta(grupo_rows, default_vendedor="00",
default_prefijo=prefijo_def, numero_override=num_fac,
forma_pago_override=fp_override)
def _is_sent(factura_key):
conn = get_connection()
row = conn.execute(
"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:
return {"status": row["status"], "mensaje": row["mensaje_tns"], "at": row["created_at"]}
return None
def _guardar_envio(user_id, factura, 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, None, 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)):
return request.app.state.templates.TemplateResponse("ventas.html", {
"request": request, "user": user,
})
@router.post("/preview")
async def preview_ventas(
request: Request, user: dict = Depends(get_current_user),
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
):
cfg = _cfg()
rows, err = _query_rows(cfg, fecha_inicio, fecha_fin)
if rows is None:
return JSONResponse({"success": False, "message": err})
if not rows:
return JSONResponse({"success": False, "message": "Sin datos para ese rango de fechas"})
excluded_ventas = load_excluded_ventas_set()
fp_map = load_forma_pago_map()
grupos_all = _agrupar(rows)
grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
items = []
for factura_key, grupo_rows in grupos.items():
enviado = _is_sent(factura_key)
venta_json = _build_venta(grupo_rows, cfg, fp_map)
items.append({
"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],
"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),
factura_key: str = Form(...),
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
):
import re as _re
cfg = _cfg()
parts = factura_key.split("-", 1)
if len(parts) != 2:
return JSONResponse({"success": False, "message": f"Clave de factura inválida: {factura_key}"})
prefijo, num_str = parts[0], parts[1]
nums = _re.findall(r'\d+', num_str)
if not nums:
return JSONResponse({"success": False, "message": f"Número de factura inválido: {factura_key}"})
grupo_rows, err = _query_rows_by_factura(cfg, prefijo, nums[-1])
if grupo_rows is None:
return JSONResponse({"success": False, "message": err})
if not grupo_rows:
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", "")
)
if not token:
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
api_sucursal = cfg.get("api_sucursal", "") or "00"
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
fp_map = load_forma_pago_map()
venta_json = _build_venta(grupo_rows, cfg, fp_map)
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_key, 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_key} | {'OK' if ok_v else 'ERROR: '+msg_tns[:80]}",
get_ip(request))
return JSONResponse({"success": ok_v, "message": msg_tns, "raw_tns": raw_resp, "factura_key": factura_key})
@router.post("/send")
async def send_ventas(
request: Request, user: dict = Depends(get_current_user),
fecha_inicio: str = Form(...), fecha_fin: str = Form(...),
solo_pendientes: str = Form("0"),
):
cfg = _cfg()
rows, err = _query_rows(cfg, fecha_inicio, fecha_fin)
if rows is None:
return JSONResponse({"success": False, "message": err})
if not rows:
return JSONResponse({"success": False, "message": "Sin datos"})
excluded_ventas = load_excluded_ventas_set()
fp_map = load_forma_pago_map()
grupos_all = _agrupar(rows)
grupos = {k: v for k, v in grupos_all.items()
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_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", "")
)
if not token:
return JSONResponse({"success": False, "message": f"Error login TNS: {token_err}"})
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
api_sucursal = cfg.get("api_sucursal", "") or "00"
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
resultados = []
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
for factura_key, grupo_rows in grupos.items():
venta_json = _build_venta(grupo_rows, cfg, fp_map)
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_key, contrato,
venta_json, raw_resp, ok_v, fecha_inicio, fecha_fin, len(grupo_rows))
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
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,
})
+16 -5
View File
@@ -30,7 +30,17 @@ class FirebirdService:
self.conn = None self.conn = None
def connect(self, host: str, port: int, database: str, user: str, password: str): def connect(self, host: str, port: int, database: str, user: str, password: str):
import platform, os
try: try:
if platform.system() == "Darwin":
mac_lib = "/Library/Frameworks/Firebird.framework/Versions/A/Resources/lib/libfbclient.dylib"
if os.path.exists(mac_lib):
os.environ.setdefault("DYLD_LIBRARY_PATH",
"/Library/Frameworks/Firebird.framework/Versions/A/Resources/lib")
try:
fdb.load_api(mac_lib)
except Exception:
pass
self.conn = fdb.connect( self.conn = fdb.connect(
host=host, host=host,
port=port, port=port,
@@ -68,14 +78,15 @@ class FirebirdService:
try: try:
cur = self.conn.cursor() cur = self.conn.cursor()
if params: if params:
# fdb no soporta :name — convertir a positional ? if isinstance(params, (list, tuple)):
param_names = re.findall(r':([a-zA-Z_][a-zA-Z0-9_]*)', query) # Parámetros posicionales directos (?)
if param_names: cur.execute(query, params)
else:
# Parámetros nombrados (:name) → convertir a positional
param_names = re.findall(r':([a-zA-Z_][a-zA-Z0-9_]*)', query)
positional_sql = re.sub(r':[a-zA-Z_][a-zA-Z0-9_]*', '?', query) positional_sql = re.sub(r':[a-zA-Z_][a-zA-Z0-9_]*', '?', query)
positional_vals = [params[n] for n in param_names] positional_vals = [params[n] for n in param_names]
cur.execute(positional_sql, positional_vals) cur.execute(positional_sql, positional_vals)
else:
cur.execute(query)
else: else:
cur.execute(query) cur.execute(query)
columns = [desc[0] for desc in cur.description] if cur.description else [] columns = [desc[0] for desc in cur.description] if cur.description else []
+156 -43
View File
@@ -1,7 +1,8 @@
import re as _re import re as _re
from collections import defaultdict from collections import defaultdict
from datetime import datetime, date from datetime import datetime, date, timedelta
from typing import Optional from typing import Optional
# v1.5.0
# ── helpers de formato de fecha ────────────────────────────────────────────── # ── helpers de formato de fecha ──────────────────────────────────────────────
@@ -58,7 +59,7 @@ def _fmt_datetime(val) -> str:
_TIPO_DOC_MAP = { _TIPO_DOC_MAP = {
"CC": "C", "TI": "T", "RC": "R", "CE": "E", "CC": "C", "TI": "T", "RC": "R", "CE": "E",
"PA": "P", "AS": "A", "MS": "M", "NU": "U", "PA": "P", "AS": "A", "MS": "M", "NU": "U",
"SC": "S", "PE": "PE", "PT": "PT", "SI": "A", "SC": "S", "PE": "E", "PT": "E", "SI": "A",
"CN": "C", "DE": "E", "CD": "P", "CN": "C", "DE": "E", "CD": "P",
} }
@@ -105,7 +106,8 @@ def generar_tercero_api(row: dict) -> dict:
"sexo": sexo, "sexo": sexo,
"identidadGenero": identidad_genero, "identidadGenero": identidad_genero,
"zona": zona, "zona": zona,
"etnia": str(row.get("CODETNIA") or "99").strip(), "etnia": (lambda v: v if v and len(v) <= 2 else "99")(str(row.get("CODETNIA") or "").strip()),
"ciudadExp": cod_ciudad,
"antecedentes": "", "antecedentes": "",
"cliente": "S", "cliente": "S",
"enfermedadCronica": False, "enfermedadCronica": False,
@@ -123,9 +125,19 @@ def agrupar_por_recepcion(rows: list) -> dict:
return grupos return grupos
def agrupar_por_factura(rows: list) -> dict:
"""Agrupa filas por NUM_FACTURA — para ventas CMXC donde una factura puede tener varias recepciones."""
grupos = defaultdict(list)
for row in rows:
key = str(row.get("NUM_FACTURA") or "")
grupos[key].append(dict(row))
return grupos
def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "", def generar_rda_paciente(rows: list, default_profesional: str = "", default_especialidad: str = "",
default_remisionante: str = "00", default_prefijo: str = "00", default_remisionante: str = "00", default_prefijo: str = "00",
numero_override: str = "") -> dict: numero_override: str = "", contrato_map: dict = None,
prefijo_override: str = "", sin_contrato_set: set = None) -> dict:
if not rows: if not rows:
return {} return {}
h = rows[0] h = rows[0]
@@ -152,38 +164,52 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
"codigoMaterial": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(), "codigoMaterial": str(row.get("CUPS") or row.get("COD_EXAMEN") or "").strip(),
"codigoBodega": "00", "codigoBodega": "00",
"cantidad": 1, "cantidad": 1,
"tipoUnidad": "D",
"descuento": 0,
"porcentajeIva": 0,
"impConsumo": 0,
"observacion": "", "observacion": "",
"profesional": default_profesional or None, "profesional": default_profesional or None,
"especialidad": default_especialidad or None, "especialidad": default_especialidad or None,
"profesionalRemisionante": cedula_medico or default_remisionante or None, "profesionalRemisionante": cedula_medico or default_remisionante or None,
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip(), "diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip() or "Z017",
"fechaHoraRealizacion": fecha_real or recepcion_dt, "fechaHoraRealizacion": fecha_real or recepcion_dt,
}) })
# Egreso = fechaHoraRealizacion del último examen en el detalle # Egreso = fechaHoraRealizacion del último examen + 5 minutos
egreso = max( _egreso_raw = max(
(d["fechaHoraRealizacion"] for d in detalle_pedido if d["fechaHoraRealizacion"]), (d["fechaHoraRealizacion"] for d in detalle_pedido if d["fechaHoraRealizacion"]),
default=recepcion_dt, default=recepcion_dt,
) )
try:
_egreso_dt = datetime.strptime(_egreso_raw, "%d/%m/%Y %H:%M:%S") + timedelta(minutes=5)
egreso = _egreso_dt.strftime("%d/%m/%Y %H:%M:%S")
except Exception:
egreso = _egreso_raw
nit_empresa = str(h.get("NIT_EMPRESA") or "").strip() nit_empresa = str(h.get("NIT_EMPRESA") or "").strip()
cod_contrato_raw = str(h.get("CODCONTRATO") or "").strip() cod_contrato_raw = str(h.get("CODCONTRATO") or "").strip()
# Particular: por NIT_EMPRESA "PART" o por contrato "12"/"012"
# Resolver tipo desde contrato_map primero (tiene prioridad sobre heurísticas)
_TIPOUSU_MAP = {"1": "11", "5": "07"} # EPS→11, Póliza→07
_nc_s = cod_contrato_raw.lstrip("0") or cod_contrato_raw
_map_tipo = ""
if contrato_map and cod_contrato_raw:
_map_tipo = contrato_map.get(cod_contrato_raw) or contrato_map.get(_nc_s) or ""
# es_particular: NIT "PART", o mapa dice "12", o heurística solo si no está en el mapa
es_particular = ( es_particular = (
nit_empresa.upper() in ("PART", "PARTICULAR", "", "0") nit_empresa.upper() in ("PART", "PARTICULAR", "", "0")
or cod_contrato_raw.lstrip("0") == "12" 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" cod_forma_pago = "CLIP" if es_particular else "INST"
autorizacion = None if es_particular else (str(h.get("AUTORIZACION") or "").strip() or None) 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→mapa contratos, 2→particular, 3→TIPOUSUSISPRO/TIPOUSU
_TIPOUSU_MAP = {"1": "11", "5": "07"} # EPS→11, Póliza→07 if _map_tipo:
if es_particular: tipoususispro = _map_tipo
elif es_particular:
tipoususispro = "12" tipoususispro = "12"
else: else:
tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip() tipoususispro = str(h.get("TIPOUSUSISPRO") or "").strip()
@@ -193,44 +219,141 @@ def generar_rda_paciente(rows: list, default_profesional: str = "", default_espe
via_ingreso = "01" via_ingreso = "01"
modalidad = "01" modalidad = "01"
vigencia = 30 vigencia = 30
_total = float(h.get("VALORTOTAL") or 0)
_desc = float(h.get("VALORDESC") or 0)
descuento_pct = round(_desc * 100 / _total, 2) if _total > 0 else 0
result = { result = {
"codigoPrefijo": str(h.get("PREFIJO") or "").strip() or default_prefijo or "00", "codigoPrefijo": prefijo_override or str(h.get("PREFIJO") or "").strip() or default_prefijo or "00",
"numero": numero_override if numero_override else ( "numero": (numero_override if numero_override else (
str(h.get("NUM_FACTURA") or "").strip() if (h.get("NUM_FACTURA") or 0) != 0 else "" str(h.get("NUM_FACTURA") or "").strip() if (h.get("NUM_FACTURA") or 0) != 0 else ""
), )).zfill(5) or "",
"fecha": fecha, "fecha": fecha,
"codTercero": str(h.get("COD_PACIENTE") or "").strip(), "codTercero": str(h.get("COD_PACIENTE") or "").strip(),
"codVendedor": "00", "codVendedor": "00",
"codFormaPago": cod_forma_pago,
"codBanco": "00",
"codigoCentroCosto": "00", "codigoCentroCosto": "00",
"tipoIngreso": str(h.get("CLASEPROC") or "1").strip() or "1", "tipoIngreso": "1",
"fechaHoraIngreso": ingreso, "fechaHoraIngreso": ingreso,
"fechaHoraEgreso": egreso, "fechaHoraEgreso": egreso,
"modalidadAtencion": modalidad, "modalidadAtencion": modalidad,
"numeroContrato": cod_contrato, "numeroContrato": cod_contrato,
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip(), "descuento": descuento_pct,
"diagnosticoprincipal": str(h.get("DIAG_PPAL") or "").strip() or "Z017",
"discapacidad": "08",
"tipousuario": tipoususispro, "tipousuario": tipoususispro,
"viaIngreso": via_ingreso, "viaIngreso": via_ingreso,
"esTerapia": False, "esTerapia": False,
"esProcedimiento": False, "esProcedimiento": False,
"numeroAutorizacion": autorizacion, "numeroAutorizacion": autorizacion,
"fechaAutorizacion": date.today().strftime("%d/%m/%Y"), **({
"vigenciaAutorizacion": vigencia, "fechaAutorizacion": date.today().strftime("%d/%m/%Y"),
"vigenciaAutorizacion": vigencia,
} if autorizacion else {}),
"detallePedido": detalle_pedido, "detallePedido": detalle_pedido,
"detalleFormaPago": [{
"codigoFormaPago": cod_forma_pago,
"plazoDias": "0" if es_particular else "30",
"fechaVencimiento": fecha,
"valor": str(int(float(h.get("VALORTOTAL") or 0))),
}],
} }
return _clean_times(result) return _clean_times(result)
# ── Ventas/Crear ─────────────────────────────────────────────────────────────
def _fecha_add_dias(fecha_val, dias: int) -> str:
"""Suma `dias` a una fecha ISO (YYYY-MM-DD...) o date y retorna dd/mm/YYYY."""
try:
if hasattr(fecha_val, "strftime"):
d = fecha_val if isinstance(fecha_val, date) else fecha_val.date()
else:
d = datetime.strptime(str(fecha_val)[:10], "%Y-%m-%d").date()
return (d + timedelta(days=dias)).strftime("%d/%m/%Y")
except Exception:
return _fmt_fecha(fecha_val)
def generar_factura_venta(rows: list, default_vendedor: str = "00",
default_prefijo: str = "00", numero_override: str = "",
forma_pago_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_raw = h.get("FECHAFACT") or h.get("FECHA_RECEPCION")
fecha = _fmt_fecha(fecha_raw)
detalle_pedido = []
total = 0.0
for row in rows:
precio = float(row.get("PRECIO_TARIFA") or row.get("PRECIO") or 0)
total += precio
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,
"centrosCostos": "00",
"porcIva": 0,
"valor": precio,
"impConsumo": 0,
"observacion": "",
"lote": "",
"fechaVenceLote": "",
"nroDocumento": "",
"itemsSerial": [],
"tipoSerial": "",
})
nit_raw = str(h.get("NIT_EMPRESA") or "").strip()
cod_tercero = nit_raw if nit_raw else str(h.get("COD_PACIENTE") or "").strip()
prefijo_real = str(h.get("PREFIJO") or "").strip() or default_prefijo or "00"
diasvenc = int(h.get("DIASVENC") or 30)
cod_forma_pago = forma_pago_override.strip() if forma_pago_override else ("CIAC" if total == 0 else "CR")
if cod_forma_pago == "CIAC":
plazo_dias = 0
fecha_vence = fecha
else:
plazo_dias = diasvenc
fecha_vence = _fecha_add_dias(fecha_raw, diasvenc)
return _clean_times({
"codigoPrefijo": prefijo_real,
"numero": num_fac,
"numeroFactura": num_fac,
"sucursal": "00",
"fecha": fecha,
"kardexId": 0,
"codigoPedido": "",
"nombreCliente": "",
"codTercero": cod_tercero,
"codVendedor": default_vendedor or "00",
"codDespachar": "00",
"codFormaPago": cod_forma_pago,
"codBanco": "",
"fechaVence": fecha_vence,
"fechaEntrega": fecha,
"plazoDias": plazo_dias,
"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) ───────────────────────────────── # ── funciones legacy RIPS 2.0 (se mantienen) ─────────────────────────────────
def generar_terceros(row: dict) -> dict: def generar_terceros(row: dict) -> dict:
@@ -280,16 +403,6 @@ def generar_procedimiento(row: dict, consecutivo: int) -> dict:
} }
def agrupar_por_factura(rows: list, factura_default: str = "") -> dict:
grupos = defaultdict(lambda: {"factura": "", "procedimientos": []})
for row in rows:
doc_key = (row.get("tipo_doc_paciente", "CC"), row.get("num_doc_paciente", ""))
fact = row.get("num_factura", factura_default)
grupos[(fact, doc_key)]["factura"] = fact
grupos[(fact, doc_key)]["procedimientos"].append(dict(row))
return grupos
def generar_transaccion( def generar_transaccion(
factura: str, factura: str,
num_doc_obligado: str, num_doc_obligado: str,
+198
View File
@@ -0,0 +1,198 @@
"""
Scheduler: sync automático de pacientes con recepción reciente → WhatsApp Lab.
Corre cada minuto y envía los pacientes con FECHA_RECEPCION en los últimos ventana_min minutos.
"""
from datetime import datetime, timedelta
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
from app.services.whatsapp_sync import sync_todos, guardar_sync_log
# Filtra directamente en Firebird por FECHA_RECEPCION para no depender de HORAINICIORECEPCION
_SQL_RECIENTES = """
SELECT DISTINCT
p.CODIGO,
p.TIPOIDENT,
p.DOCIDENT,
p.NOMBRES,
p.APELLIDOS,
p.DIRECCION,
p.CIUDAD AS COD_CIUDAD,
c.NOMBRE AS NOM_CIUDAD,
p.TELEFONOS,
p.EMAIL,
p.F_NACIMIENTO,
p.SEXO,
p.TIPORES,
p.CODETNIA,
r.HORAINICIORECEPCION
FROM PACIENTE p
LEFT JOIN CIUDAD c ON c.CODIGO = p.CIUDAD
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
WHERE r.FECHA_RECEPCION >= DATEADD(MINUTE, ?, CURRENT_TIMESTAMP)
"""
_SQL_EXAMENES_VENTANA = """
SELECT
TRIM(p.DOCIDENT) AS DOCIDENT,
r.IDRECEPCION,
r.HORAINICIORECEPCION,
TRIM(rel.COD_EXAMEN) AS COD_EXAMEN,
TRIM(ex.NOMBRE) AS NOM_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
COALESCE(NULLIF(rel.PRECIO, 0), t.VALOR, 0) AS PRECIO,
TRIM(r.DIAG_PPAL) AS DIAG_PPAL,
TRIM(d.CONCEPTO) AS DIAG_CONCEPTO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO,
TRIM(r.NIT_EMPRESA) AS NIT_EMPRESA,
TRIM(e.NOMBRE) AS NOM_EMPRESA,
r.VALORTOTAL
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 DIAGNOSTICO d ON TRIM(d.COD_DIAG) = TRIM(r.DIAG_PPAL)
LEFT JOIN MEDICO m ON m.CODIGO = r.COD_MEDICO
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
LEFT JOIN EMPRESA e ON TRIM(e.NIT) = TRIM(r.NIT_EMPRESA)
LEFT JOIN EMPRESA_SUB es ON TRIM(es.NIT_EMP) = TRIM(r.NIT_EMPRESA)
AND TRIM(es.SUBGRUPO) = TRIM(r.SUBGRUPO)
LEFT JOIN TARIFA t ON TRIM(t.COD_EXAMEN) = TRIM(rel.COD_EXAMEN)
AND t.TARIFA = COALESCE(es.TARIFA, e.TARIFA)
WHERE r.FECHA_RECEPCION >= DATEADD(MINUTE, ?, CURRENT_TIMESTAMP)
ORDER BY r.IDRECEPCION
"""
def _parse_hora(val) -> datetime | None:
if not val:
return None
# Si ya es datetime (fdb devuelve objetos datetime)
if isinstance(val, datetime):
ahora = datetime.now()
return ahora.replace(hour=val.hour, minute=val.minute, second=val.second, microsecond=0)
s = str(val)
try:
# "2026-07-16T15:20:12" o "2026-07-16 15:20:12"
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
dt = datetime.fromisoformat(s.replace(" ", "T"))
ahora = datetime.now()
return ahora.replace(hour=dt.hour, minute=dt.minute, second=dt.second, microsecond=0)
# "15:20:12"
partes = s.split(":")
ahora = datetime.now()
return ahora.replace(hour=int(partes[0]), minute=int(partes[1]),
second=int(partes[2].split(".")[0]), microsecond=0)
except Exception:
return None
def _fmt_hora_str(val) -> str:
"""Extrae HH:MM:SS de un datetime o string timestamp de Firebird."""
if not val:
return ""
if isinstance(val, datetime):
return val.strftime("%H:%M:%S")
s = str(val)
# "2026-07-16T15:20:12" o "2026-07-16 15:20:12"
if len(s) > 10 and (s[10] == "T" or s[10] == " "):
return s[11:19]
return s[:8]
def _safe_num(val):
"""Convierte Decimal/int/float a float, o None."""
if val is None:
return None
try:
return float(val)
except Exception:
return None
async def sync_recientes(ventana_min: int = 2) -> dict:
"""Job que corre cada minuto: sincroniza pacientes con recepción en los últimos `ventana_min` min."""
conn = get_connection()
configs = {row["key"]: row["value"] for row in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
wa_url = configs.get("whatsapp_url", "").rstrip("/")
wa_key = configs.get("whatsapp_api_key", "")
if not wa_url or not wa_key:
return {"ok": False, "error": "whatsapp_url o whatsapp_api_key no configurados"}
fb, ok, msg = get_firebird_from_config(configs)
if not ok:
return {"ok": False, "error": f"Firebird: {msg}"}
ok_pac, err_pac, rows_pac = fb.execute_query(_SQL_RECIENTES, (-ventana_min,))
ok_ex, _, rows_ex = fb.execute_query(_SQL_EXAMENES_VENTANA, (-ventana_min,))
fb.disconnect()
if not ok_pac:
return {"ok": False, "error": f"Query pacientes: {err_pac}"}
recientes = rows_pac or []
total_hoy = len(recientes)
if not recientes:
return {"ok": True, "total": 0, "created": 0, "updated": 0,
"skipped": 0, "errores": 0, "total_hoy": 0,
"ventana_min": ventana_min, "mensaje": "Sin recepciones en la ventana de tiempo"}
examenes_map: dict[str, list] = {}
if ok_ex and rows_ex:
for ex in rows_ex:
doc = str(ex.get("DOCIDENT") or "").strip()
if not doc:
continue
examenes_map.setdefault(doc, []).append({
"cod_examen": (ex.get("COD_EXAMEN") or "").strip(),
"nombre": (ex.get("NOM_EXAMEN") or "").strip(),
"cups": (ex.get("CUPS") or "").strip(),
"precio": _safe_num(ex.get("PRECIO")),
"recepcion_id": ex.get("IDRECEPCION"),
"hora": _fmt_hora_str(ex.get("HORAINICIORECEPCION")),
"diagnostico_cod": (ex.get("DIAG_PPAL") or "").strip(),
"diagnostico_nombre": (ex.get("DIAG_CONCEPTO") or "").strip(),
"medico_docidmedico": (ex.get("DOCIDMEDICO") or "").strip(),
"nit_empresa": (ex.get("NIT_EMPRESA") or "").strip(),
"nom_empresa": (ex.get("NOM_EMPRESA") or "").strip(),
"valor_total": _safe_num(ex.get("VALORTOTAL")),
})
ingest_url = f"{wa_url}/api/lab/ingest_paciente.php"
timeout = int(configs.get("api_timeout", 30))
resultado = await sync_todos(recientes, ingest_url, wa_key, timeout,
modo="upsert", examenes_map=examenes_map)
# Enriquecer detalle con exámenes, diagnóstico, empresa y valor para el log
for entry in resultado.get("detalle", []):
doc = entry.get("doc", "")
exams = examenes_map.get(doc, [])
if exams:
first = exams[0]
entry["diagnostico_cod"] = first.get("diagnostico_cod", "")
entry["diagnostico_nombre"] = first.get("diagnostico_nombre", "")
entry["nit_empresa"] = first.get("nit_empresa", "")
entry["nom_empresa"] = first.get("nom_empresa", "")
entry["valor_total"] = first.get("valor_total")
entry["examenes_det"] = [
{"cups": e.get("cups", ""), "nombre": e.get("nombre", ""), "precio": e.get("precio")}
for e in exams
]
if resultado["total"] > 0:
guardar_sync_log(resultado, None, origen="scheduler", modo="upsert")
return {
"ok": True,
"total": resultado["total"],
"created": resultado["created"],
"updated": resultado["updated"],
"skipped": resultado["skipped"],
"errores": resultado["errores"],
"total_hoy": total_hoy,
"ventana_min": ventana_min,
"detalle": resultado.get("detalle", []),
}
+211
View File
@@ -0,0 +1,211 @@
"""
Servicio de sincronización de pacientes RIPS → WhatsApp Lab.
Mapea campos Firebird al formato esperado por /api/lab/ingest_paciente.php.
"""
import re
import json
import asyncio
import httpx
from datetime import datetime
from typing import Optional
from app.database import get_connection
def guardar_sync_log(
resultado: dict,
user_id,
origen: str = "manual",
modo: str = "insertar",
) -> None:
detalle_raw = resultado.get("detalle", [])
errores_det = None
errores = [d for d in detalle_raw if not d.get("ok")]
if errores:
errores_det = json.dumps(
[{"doc": e["doc"], "nombre": e["nombre"], "msg": e["message"]} for e in errores],
ensure_ascii=False,
)
detalle_json = None
if detalle_raw:
detalle_json = json.dumps(
[{
"doc": d["doc"],
"nombre": d["nombre"],
"action": d.get("action", ""),
"examenes_cnt": d.get("examenes_guardados", 0),
"diagnostico_cod": d.get("diagnostico_cod", ""),
"diagnostico_nombre": d.get("diagnostico_nombre", ""),
"nit_empresa": d.get("nit_empresa", ""),
"nom_empresa": d.get("nom_empresa", ""),
"valor_total": d.get("valor_total"),
"examenes_det": d.get("examenes_det", []),
} for d in detalle_raw],
ensure_ascii=False,
)
conn = get_connection()
conn.execute(
"""INSERT INTO sync_wa_log
(user_id, origen, modo, total, created, skipped, updated, errores, errores_det, detalle_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
origen,
modo,
resultado.get("total", 0),
resultado.get("created", 0),
resultado.get("skipped", 0),
resultado.get("updated", 0),
resultado.get("errores", 0),
errores_det,
detalle_json,
),
)
conn.commit()
conn.close()
_TIPO_DOC_MAP = {
"CC": "CC", "TI": "TI", "RC": "RC", "CE": "CE",
"PA": "PA", "NIT": "NIT", "MS": "MS", "AS": "CC",
"SI": "CC", "CN": "CC", "DE": "CE", "CD": "PA",
"PE": "CE", "PT": "PA",
}
_SEXO_MAP = {"M": "M", "F": "F", "H": "M"}
def _fmt_fecha_iso(val) -> Optional[str]:
if not val:
return None
if hasattr(val, "strftime"):
return val.strftime("%Y-%m-%d")
s = str(val)[:10]
if re.match(r"\d{4}-\d{2}-\d{2}", s):
return s
# dd/mm/yyyy
parts = s.split("/")
if len(parts) == 3:
return f"{parts[2]}-{parts[1]}-{parts[0]}"
return None
def mapear_paciente(row: dict) -> dict:
"""Convierte una fila de Firebird PACIENTE al body de ingest_paciente.php."""
nombres = (row.get("NOMBRES") or "").strip()
apellidos = (row.get("APELLIDOS") or "").strip()
nombre_completo = f"{nombres} {apellidos}".strip().upper()
tipo_raw = str(row.get("TIPOIDENT") or "CC").strip()
tipo_doc = _TIPO_DOC_MAP.get(tipo_raw, "CC")
sexo_raw = str(row.get("SEXO") or "M").strip().upper()
genero = _SEXO_MAP.get(sexo_raw, "M")
email = (row.get("EMAIL") or "").strip().lower()
if not email or "@sinregistro" in email:
email = ""
return {
"nombre_completo": nombre_completo,
"numero_documento": str(row.get("DOCIDENT") or "").strip(),
"tipo_documento": tipo_doc,
"telefono": str(row.get("TELEFONOS") or "").strip(),
"email": email,
"fecha_nacimiento": _fmt_fecha_iso(row.get("F_NACIMIENTO")),
"genero": genero,
"direccion": (row.get("DIRECCION") or "").strip(),
"ciudad": (row.get("NOM_CIUDAD") or "").strip(),
"origen": "lab",
}
async def sync_paciente(
row: dict,
url: str,
api_key: str,
client: Optional[httpx.AsyncClient] = None,
modo: str = "upsert",
examenes: Optional[list] = None,
) -> dict:
"""Envía un paciente al endpoint de WhatsApp. Retorna {ok, action, message}."""
payload = mapear_paciente(row)
payload["modo"] = modo
if examenes:
payload["examenes"] = examenes
headers = {
"Content-Type": "application/json",
"X-Lab-Key": api_key,
}
try:
if client:
resp = await client.post(url, json=payload, headers=headers)
else:
async with httpx.AsyncClient(timeout=15) as c:
resp = await c.post(url, json=payload, headers=headers)
try:
data = resp.json()
except Exception as json_err:
raw = resp.text[:300].strip()
return {
"ok": False,
"action": "error",
"message": f"HTTP {resp.status_code} — respuesta no JSON: {raw!r}",
"doc": payload["numero_documento"],
"nombre": payload["nombre_completo"],
}
return {
"ok": data.get("ok", False),
"action": data.get("action", ""),
"message": data.get("message") or data.get("error", ""),
"doc": payload["numero_documento"],
"nombre": payload["nombre_completo"],
}
except Exception as e:
return {
"ok": False,
"action": "error",
"message": str(e),
"doc": payload.get("numero_documento", ""),
"nombre": payload.get("nombre_completo", ""),
}
async def sync_todos(rows: list, url: str, api_key: str, timeout: int = 30, modo: str = "upsert",
examenes_map: Optional[dict] = None, concurrencia: int = 10) -> dict:
"""Envía una lista de filas de pacientes en paralelo. Retorna resumen {total, created, skipped, updated, errores}."""
resultado = {"total": len(rows), "created": 0, "skipped": 0, "updated": 0, "errores": 0, "detalle": []}
sem = asyncio.Semaphore(concurrencia)
async def _enviar(row: dict, client: httpx.AsyncClient) -> dict:
async with sem:
doc = str(row.get("DOCIDENT") or "").strip()
examenes = examenes_map.get(doc) if examenes_map else None
return await sync_paciente(row, url, api_key, client, modo, examenes)
async with httpx.AsyncClient(timeout=timeout) as client:
tasks = [_enviar(row, client) for row in rows]
resultados = await asyncio.gather(*tasks, return_exceptions=True)
for r in resultados:
if isinstance(r, Exception):
resultado["errores"] += 1
resultado["detalle"].append({"ok": False, "action": "error", "message": str(r), "doc": "", "nombre": ""})
continue
if r["ok"]:
action = r["action"]
if action == "created":
resultado["created"] += 1
elif action == "skipped":
resultado["skipped"] += 1
else:
resultado["updated"] += 1
else:
resultado["errores"] += 1
resultado["detalle"].append(r)
return resultado
+145
View File
@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Actividad{% endblock %}
{% block header %}Registro de Actividad{% endblock %}
{% block content %}
<!-- Tabs -->
<div class="flex gap-2 mb-5">
<a href="/logs" class="px-4 py-2 rounded-lg text-sm font-medium bg-white border border-gray-200 text-gray-600 hover:bg-gray-50">
<i class="fas fa-paper-plane mr-1"></i> Envíos TNS
</a>
<span class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">
<i class="fas fa-user-clock mr-1"></i> Actividad usuarios
</span>
</div>
<!-- Filtros -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
<form method="get" action="/logs/actividad" class="p-4">
<input type="hidden" name="offset" value="0">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Usuario</label>
<select name="username" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
<option value="">Todos</option>
{% for u in usuarios %}
<option value="{{ u }}" {{ 'selected' if filtro_username == u }}>{{ u }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Acción</label>
<select name="accion" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[150px]">
<option value="">Todas</option>
{% for a in acciones %}
<option value="{{ a }}" {{ 'selected' if filtro_accion == a }}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Desde</label>
<input type="date" name="fecha_desde" value="{{ filtro_fecha_desde }}"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Hasta</label>
<input type="date" name="fecha_hasta" value="{{ filtro_fecha_hasta }}"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
</div>
<div class="flex gap-2">
<button type="submit" class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-search mr-1"></i> Filtrar
</button>
<a href="/logs/actividad" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Limpiar">
<i class="fas fa-times"></i>
</a>
</div>
</div>
</form>
</div>
<!-- Tabla -->
<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-400"></i>Eventos</h3>
<span class="text-xs text-gray-400">{{ total }} registro(s)</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">Fecha/Hora</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Usuario</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Acción</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Detalle</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">IP</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for r in rows %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2.5 text-xs text-gray-500 font-mono whitespace-nowrap">
{{ r.created_at[:16].replace('T',' ') }}
</td>
<td class="px-4 py-2.5">
<span class="font-medium text-gray-700">{{ r.username }}</span>
</td>
<td class="px-4 py-2.5">
{% set a = r.accion %}
{% if a == 'login' %}
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">Login</span>
{% elif a == 'login_fallido' %}
<span class="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-medium">Login fallido</span>
{% elif a == 'rda_enviado' %}
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">RDA enviado</span>
{% elif a == 'rda_masivo' %}
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">RDA masivo</span>
{% elif a == 'rda_directo' %}
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">RDA directo</span>
{% elif a == 'automation_run' %}
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded-full text-xs font-medium">Automatización</span>
{% elif a == 'contrato_creado' %}
<span class="px-2 py-0.5 bg-teal-100 text-teal-700 rounded-full text-xs font-medium">Contrato creado</span>
{% elif a == 'contrato_editado' %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">Contrato editado</span>
{% elif a == 'contrato_toggle' %}
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full text-xs font-medium">Contrato toggle</span>
{% else %}
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">{{ a }}</span>
{% endif %}
</td>
<td class="px-4 py-2.5 text-xs text-gray-600 max-w-xs truncate" title="{{ r.detalle }}">
{{ r.detalle }}
</td>
<td class="px-4 py-2.5 text-xs text-gray-400 font-mono">{{ r.ip }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-4 py-10 text-center text-gray-400">
No hay eventos registrados aún
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if has_more or offset > 0 %}
<div class="px-6 py-3 border-t border-gray-100 flex justify-between items-center">
{% if offset > 0 %}
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ [offset - page_size, 0]|max }}"
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
<i class="fas fa-chevron-left mr-1"></i> Anterior
</a>
{% else %}<span></span>{% endif %}
<span class="text-xs text-gray-400">{{ offset + 1 }}{{ [offset + page_size, total]|min }} de {{ total }}</span>
{% if has_more %}
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ offset + page_size }}"
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
Siguiente <i class="fas fa-chevron-right ml-1"></i>
</a>
{% else %}<span></span>{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+539 -161
View File
@@ -4,101 +4,154 @@
{% block content %} {% block content %}
<div class="max-w-4xl space-y-6"> <div class="max-w-4xl space-y-6">
<!-- Panel de control --> <!-- ─── Panel de control ─────────────────────────────────────────────────── -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200"> <div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<h3 class="font-semibold text-gray-800"> <h3 class="font-semibold text-gray-800">
<i class="fas fa-paper-plane mr-2 text-blue-500"></i>Envío automático por fecha <i class="fas fa-paper-plane mr-2 text-blue-500"></i>Envío automático por fecha
</h3> </h3>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Selecciona una fecha para ver cuántos pacientes y RDA se enviarán a TNS. Selecciona fecha y pasos activos para enviar a TNS.
</p> </p>
</div> </div>
<div class="p-6"> <div class="p-6 space-y-4">
<div class="flex items-end gap-4"> <!-- Inputs + Botones -->
<div class="flex-1"> <div class="flex items-end gap-3 flex-wrap">
<div class="flex-1 min-w-36">
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha de atención</label> <label class="block text-sm font-medium text-gray-700 mb-1">Fecha de atención</label>
<input type="date" id="fecha-input" value="{{ today }}" <input type="date" id="fecha-input" value="{{ today }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div> <button onclick="previewAutomation()" id="btn-preview"
<label class="block text-sm font-medium text-gray-700 mb-1">Convenio <span class="text-xs text-gray-400">(vacío = todos)</span></label> class="px-5 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
<input type="text" id="contrato-input" placeholder="ej: 011"
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<button onclick="previewAutomation()"
id="btn-preview"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
<i class="fas fa-eye"></i> Vista previa <i class="fas fa-eye"></i> Vista previa
</button> </button>
<button onclick="abrirModalConfirmar()" <button onclick="abrirModalConfirmar()" id="btn-run" disabled
id="btn-run" class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed">
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed" <i class="fas fa-play"></i> Ejecutar
disabled> </button>
<i class="fas fa-play"></i> Ejecutar envío </div>
<!-- Paso chips -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 font-medium mr-1">Pasos:</span>
<button id="chip-terceros" onclick="togglePaso('terceros')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-blue-500 bg-blue-500 text-white">
<i class="fas fa-user"></i> Terceros
</button>
<button id="chip-rda" onclick="togglePaso('rda')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-purple-500 bg-purple-500 text-white">
<i class="fas fa-exchange-alt"></i> RDA Servicios
</button>
<button id="chip-preserv" onclick="togglePaso('preserv')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-orange-500 bg-orange-500 text-white">
<i class="fas fa-file-alt"></i> Pre-servicios
</button>
<button id="chip-ventas" onclick="togglePaso('ventas')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all border-emerald-500 bg-emerald-500 text-white">
<i class="fas fa-file-invoice-dollar"></i> Ventas
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Vista previa de datos --> <!-- ─── Vista previa ─────────────────────────────────────────────────────── -->
<div id="preview-section" class="hidden bg-white rounded-xl shadow-sm border border-gray-200"> <div id="preview-section" class="hidden 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"> <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-search mr-2 text-gray-500"></i>Resumen de la fecha</h3> <h3 class="font-semibold text-gray-800"><i class="fas fa-search mr-2 text-gray-500"></i>Resumen de la fecha</h3>
<span id="preview-fecha" class="text-sm text-gray-500"></span> <span id="preview-fecha" class="text-sm text-gray-500"></span>
</div> </div>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<div class="grid grid-cols-3 gap-4"> <!-- Stat cards -->
<div class="bg-blue-50 rounded-lg p-4 text-center"> <div class="grid grid-cols-5 gap-4">
<div id="card-terceros" class="bg-blue-50 rounded-lg p-4 text-center transition-opacity">
<div id="cnt-pacientes" class="text-3xl font-bold text-blue-600">0</div> <div id="cnt-pacientes" class="text-3xl font-bold text-blue-600">0</div>
<div class="text-sm text-blue-700 mt-1">Pacientes</div> <div class="text-sm text-blue-700 mt-1">Pacientes</div>
</div> </div>
<div class="bg-purple-50 rounded-lg p-4 text-center"> <div id="card-rda" class="bg-purple-50 rounded-lg p-4 text-center transition-opacity">
<div id="cnt-recepciones" class="text-3xl font-bold text-purple-600">0</div> <div id="cnt-recepciones" class="text-3xl font-bold text-purple-600">0</div>
<div class="text-sm text-purple-700 mt-1">Recepciones</div> <div class="text-sm text-purple-700 mt-1">Recepciones</div>
</div> </div>
<div class="bg-green-50 rounded-lg p-4 text-center"> <div id="card-examenes" class="bg-green-50 rounded-lg p-4 text-center transition-opacity">
<div id="cnt-examenes" class="text-3xl font-bold text-green-600">0</div> <div id="cnt-examenes" class="text-3xl font-bold text-green-600">0</div>
<div class="text-sm text-green-700 mt-1">Exámenes</div> <div class="text-sm text-green-700 mt-1">Exámenes</div>
</div> </div>
<div id="card-preserv" class="bg-orange-50 rounded-lg p-4 text-center transition-opacity">
<div id="cnt-preservicios" class="text-3xl font-bold text-orange-600">0</div>
<div class="text-sm text-orange-700 mt-1">Pre-servicios</div>
<div class="text-xs text-orange-400">RCXC / SC</div>
</div>
<div id="card-ventas" class="bg-emerald-50 rounded-lg p-4 text-center transition-opacity">
<div id="cnt-ventas" class="text-3xl font-bold text-emerald-600">0</div>
<div class="text-sm text-emerald-700 mt-1">Ventas</div>
<div class="text-xs text-emerald-400">Ventas/Crear</div>
</div>
</div> </div>
<div>
<p class="text-xs font-medium text-gray-600 mb-2">Pacientes — clic para ver sus recepciones:</p> <!-- Terceros / RDA -->
<div id="sec-terceros-rda">
<p class="text-xs font-medium text-gray-600 mb-2">Pacientes — clic para ver recepciones:</p>
<div id="preview-table" class="border border-gray-100 rounded-lg overflow-hidden text-sm"></div> <div id="preview-table" class="border border-gray-100 rounded-lg overflow-hidden text-sm"></div>
</div> </div>
<!-- Pre-servicios -->
<div id="preservicios-section" class="hidden">
<p class="text-xs font-medium text-orange-600 mb-2 mt-2">Pre-servicios RCXC / SC — clic para ver JSON:</p>
<div id="preview-ps-table" class="border border-orange-100 rounded-lg overflow-hidden text-sm"></div>
</div>
<!-- Ventas -->
<div id="ventas-section" class="hidden">
<p class="text-xs font-medium text-emerald-600 mb-2 mt-2">Facturas Venta — clic para ver JSON:</p>
<div id="preview-vta-table" class="border border-emerald-100 rounded-lg overflow-hidden text-sm"></div>
</div>
</div> </div>
</div> </div>
<!-- Progreso --> <!-- ─── Resultados ────────────────────────────────────────────────────────── -->
<div id="progress-section" class="hidden space-y-4"> <div id="results-section" class="hidden space-y-3">
<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"> <!-- Barra de resumen + filtros -->
<div class="flex items-center gap-3"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 px-5 py-4">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">1</div> <div class="flex items-center justify-between gap-4 flex-wrap">
<span class="font-semibold text-gray-800">Terceros (pacientes)</span> <!-- Chips por paso -->
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span class="text-xs font-semibold text-gray-500 shrink-0">RESULTADO</span>
<div id="res-summary" class="flex flex-wrap gap-2"></div>
</div>
<!-- Filtros -->
<div class="flex items-center gap-1 bg-gray-100 rounded-lg p-1 shrink-0">
<button id="filter-btn-errores" onclick="setResultFilter('errores')"
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
<i class="fas fa-times-circle mr-1"></i>Errores <span id="filter-cnt-errores" class="font-bold"></span>
</button>
<button id="filter-btn-advertencias" onclick="setResultFilter('advertencias')"
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
<i class="fas fa-exclamation-circle mr-1"></i>Ya existe <span id="filter-cnt-advertencias" class="font-bold"></span>
</button>
<button id="filter-btn-enviados" onclick="setResultFilter('enviados')"
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
<i class="fas fa-check-circle mr-1"></i>Enviados <span id="filter-cnt-enviados" class="font-bold"></span>
</button>
<button id="filter-btn-todos" onclick="setResultFilter('todos')"
class="px-3 py-1 text-xs font-medium rounded-md transition-all">
<i class="fas fa-list mr-1"></i>Todos <span id="filter-cnt-todos" class="font-bold"></span>
</button>
</div> </div>
<span id="p1-badge" class="text-sm text-gray-400">Esperando...</span>
</div> </div>
<div id="p1-detalle" class="divide-y divide-gray-100 hidden"></div>
</div> </div>
<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"> <!-- Lista unificada -->
<div class="flex items-center gap-3"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white text-sm font-bold">2</div> <div id="results-list" class="divide-y divide-gray-100"></div>
<span class="font-semibold text-gray-800">RDA Paciente (recepciones)</span>
</div>
<span id="p2-badge" class="text-sm text-gray-400">Esperando...</span>
</div>
<div id="p2-detalle" class="divide-y divide-gray-100 hidden"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal de confirmación --> <!-- ─── Modal de confirmación ────────────────────────────────────────────── -->
<div id="modal-confirmar" class="fixed inset-0 z-50 hidden"> <div id="modal-confirmar" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/50" onclick="cerrarModalConfirmar()"></div> <div class="absolute inset-0 bg-black/50" onclick="cerrarModalConfirmar()"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white rounded-xl shadow-2xl"> <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white rounded-xl shadow-2xl">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="font-semibold text-gray-800 flex items-center gap-2"> <h3 class="font-semibold text-gray-800 flex items-center gap-2">
<i class="fas fa-shield-alt text-blue-500"></i> Validar envío <i class="fas fa-shield-alt text-blue-500"></i> Validar envío
@@ -107,39 +160,43 @@
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Se enviará la siguiente información a <strong>TNS (api.tns.co)</strong> para la fecha Se enviará a <strong>TNS</strong> para la fecha <strong id="modal-fecha" class="text-blue-600"></strong>:
<strong id="modal-fecha" class="text-blue-600"></strong>:
</p> </p>
<div class="grid grid-cols-5 gap-3">
<div class="grid grid-cols-3 gap-3"> <div id="mc-terceros" class="bg-blue-50 rounded-lg p-3 text-center transition-opacity">
<div class="bg-blue-50 rounded-lg p-3 text-center">
<div id="modal-cnt-pac" class="text-2xl font-bold text-blue-600">0</div> <div id="modal-cnt-pac" class="text-2xl font-bold text-blue-600">0</div>
<div class="text-xs text-blue-700">Terceros</div> <div class="text-xs text-blue-700">Terceros</div>
<div class="text-xs text-blue-400">Tercero/Crear</div> <div class="text-xs text-blue-400">Tercero/Crear</div>
</div> </div>
<div class="bg-purple-50 rounded-lg p-3 text-center"> <div id="mc-rda" class="bg-purple-50 rounded-lg p-3 text-center transition-opacity">
<div id="modal-cnt-rec" class="text-2xl font-bold text-purple-600">0</div> <div id="modal-cnt-rec" class="text-2xl font-bold text-purple-600">0</div>
<div class="text-xs text-purple-700">Recepciones</div> <div class="text-xs text-purple-700">RDA</div>
<div class="text-xs text-purple-400">RdaPaciente/Insertar</div> <div class="text-xs text-purple-400">RdaPaciente/Insertar</div>
</div> </div>
<div class="bg-green-50 rounded-lg p-3 text-center"> <div id="mc-examenes" class="bg-green-50 rounded-lg p-3 text-center transition-opacity">
<div id="modal-cnt-exa" class="text-2xl font-bold text-green-600">0</div> <div id="modal-cnt-exa" class="text-2xl font-bold text-green-600">0</div>
<div class="text-xs text-green-700">Exámenes</div> <div class="text-xs text-green-700">Exámenes</div>
<div class="text-xs text-green-400">detallePedido</div> <div class="text-xs text-green-400">detallePedido</div>
</div> </div>
<div id="mc-preserv" class="bg-orange-50 rounded-lg p-3 text-center transition-opacity">
<div id="modal-cnt-ps" class="text-2xl font-bold text-orange-600">0</div>
<div class="text-xs text-orange-700">Pre-servicios</div>
<div class="text-xs text-orange-400">RCXC / SC</div>
</div>
<div id="mc-ventas" class="bg-emerald-50 rounded-lg p-3 text-center transition-opacity">
<div id="modal-cnt-vta" class="text-2xl font-bold text-emerald-600">0</div>
<div class="text-xs text-emerald-700">Ventas</div>
<div class="text-xs text-emerald-400">Ventas/Crear</div>
</div>
</div> </div>
<div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-700 flex items-start gap-2"> <div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-xs text-amber-700 flex items-start gap-2">
<i class="fas fa-exclamation-triangle mt-0.5 shrink-0"></i> <i class="fas fa-exclamation-triangle mt-0.5 shrink-0"></i>
<span>Esta acción enviará datos reales a TNS. Los registros que ya existan serán actualizados. No se puede deshacer.</span> <span>Esta acción enviará datos reales a TNS. Los registros existentes serán actualizados. No se puede deshacer.</span>
</div> </div>
<div class="max-h-36 overflow-y-auto border border-gray-100 rounded-lg" id="modal-lista-pac"></div>
<div class="max-h-40 overflow-y-auto border border-gray-100 rounded-lg" id="modal-lista-pac"></div>
</div> </div>
<div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3"> <div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
<button onclick="cerrarModalConfirmar()" <button onclick="cerrarModalConfirmar()"
class="px-5 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200"> class="px-5 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200">
@@ -154,23 +211,56 @@
</div> </div>
<script> <script>
let _previewData = null; let _previewData = null;
let _rdaJsonStore = {};
let _resultAll = [];
let _activeFilter = 'errores';
// ── Estado de pasos ──────────────────────────────────────────────────────────
const _pasos = new Set(['terceros','rda','preserv','ventas']);
const _pasoMeta = {
terceros: { border:'border-blue-500', bg:'bg-blue-500', text:'text-blue-600' },
rda: { border:'border-purple-500', bg:'bg-purple-500', text:'text-purple-600' },
preserv: { border:'border-orange-500', bg:'bg-orange-500', text:'text-orange-600' },
ventas: { border:'border-emerald-500',bg:'bg-emerald-500', text:'text-emerald-600'},
};
function togglePaso(paso) {
if (_pasos.has(paso)) _pasos.delete(paso);
else _pasos.add(paso);
const m = _pasoMeta[paso];
const btn = document.getElementById('chip-' + paso);
if (_pasos.has(paso)) {
btn.className = `inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all ${m.border} ${m.bg} text-white`;
} else {
btn.className = `inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border-2 transition-all ${m.border} bg-white ${m.text}`;
}
document.getElementById('preview-section').classList.add('hidden');
document.getElementById('results-section').classList.add('hidden');
document.getElementById('btn-run').disabled = true;
_previewData = null;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Vista previa ─────────────────────────────────────────────────────────────
async function previewAutomation() { async function previewAutomation() {
const fecha = document.getElementById('fecha-input').value; const fecha = document.getElementById('fecha-input').value;
if (!fecha) { showToast('Selecciona una fecha', 'error'); return; } if (!fecha) { showToast('Selecciona una fecha', 'error'); return; }
if (_pasos.size === 0) { showToast('Activa al menos un paso', 'error'); return; }
const btn = document.getElementById('btn-preview'); const btn = document.getElementById('btn-preview');
showLoading(btn); showLoading(btn);
document.getElementById('preview-section').classList.add('hidden'); document.getElementById('preview-section').classList.add('hidden');
document.getElementById('progress-section').classList.add('hidden'); document.getElementById('results-section').classList.add('hidden');
document.getElementById('btn-run').disabled = true; document.getElementById('btn-run').disabled = true;
_previewData = null; _previewData = null;
_rdaJsonStore = {};
const contrato = document.getElementById('contrato-input').value.trim();
const form = new FormData(); const form = new FormData();
form.append('fecha', fecha); form.append('fecha', fecha);
if (contrato) form.append('contrato', contrato);
try { try {
const resp = await fetch('/automation/preview', { method: 'POST', body: form, credentials: 'include' }); const resp = await fetch('/automation/preview', { method: 'POST', body: form, credentials: 'include' });
@@ -180,66 +270,168 @@ async function previewAutomation() {
if (!data.success) { showToast(data.message || 'Error al consultar', 'error'); return; } if (!data.success) { showToast(data.message || 'Error al consultar', 'error'); return; }
_previewData = data; _previewData = data;
document.getElementById('cnt-pacientes').textContent = data.pacientes;
document.getElementById('cnt-recepciones').textContent = data.recepciones;
document.getElementById('cnt-examenes').textContent = data.examenes;
document.getElementById('preview-fecha').textContent = data.fecha;
const table = document.getElementById('preview-table'); // Stat cards
if (data.pacientes === 0) { document.getElementById('cnt-pacientes').textContent = data.pacientes;
table.innerHTML = '<div class="p-4 text-gray-400 text-center">No hay pacientes para esta fecha</div>'; document.getElementById('cnt-recepciones').textContent = data.recepciones;
showToast('No hay datos para esa fecha', 'warning'); document.getElementById('cnt-examenes').textContent = data.examenes;
} else { document.getElementById('cnt-preservicios').textContent= data.preservicios || 0;
table.innerHTML = data.pacientes_preview.map((p, i) => { document.getElementById('cnt-ventas').textContent = data.ventas || 0;
const rdaRows = p.rda.map(r => ` document.getElementById('preview-fecha').textContent = data.fecha;
<div class="flex items-center gap-3 px-6 py-1.5 bg-blue-50 border-t border-blue-100 text-xs">
<span class="text-blue-400 w-4"><i class="fas fa-flask"></i></span> document.getElementById('card-terceros').style.opacity = _pasos.has('terceros') ? '1' : '0.3';
<span class="font-mono text-blue-700 w-28">${r.factura}</span> document.getElementById('card-rda').style.opacity = _pasos.has('rda') ? '1' : '0.3';
<span class="text-blue-600 w-24">${r.fecha}</span> document.getElementById('card-examenes').style.opacity = (_pasos.has('rda')||_pasos.has('preserv')) ? '1' : '0.3';
<span class="text-blue-600 w-20">${r.examenes} examen(es)</span> document.getElementById('card-preserv').style.opacity = _pasos.has('preserv') ? '1' : '0.3';
<span class="text-blue-600 flex-1">${r.diag}</span> document.getElementById('card-ventas').style.opacity = _pasos.has('ventas') ? '1' : '0.3';
<span class="text-blue-700 font-medium">$${Number(r.valor).toLocaleString('es-CO')}</span>
</div>`).join(''); // Habilitar run solo si hay datos para al menos un paso activo
return ` const hasDatos = (
(_pasos.has('terceros') && data.pacientes > 0) ||
(_pasos.has('rda') && data.recepciones > 0) ||
(_pasos.has('preserv') && data.preservicios > 0) ||
(_pasos.has('ventas') && data.ventas > 0)
);
document.getElementById('btn-run').disabled = !hasDatos;
// Sección Terceros/RDA
const secTR = document.getElementById('sec-terceros-rda');
if (_pasos.has('terceros') || _pasos.has('rda')) {
secTR.classList.remove('hidden');
const table = document.getElementById('preview-table');
if (!data.pacientes_preview || data.pacientes_preview.length === 0) {
table.innerHTML = '<div class="p-4 text-gray-400 text-center">No hay pacientes para esta fecha</div>';
} else {
table.innerHTML = data.pacientes_preview.map((p, i) => {
const rdaRows = (p.rda || []).map((r, j) => {
const key = `rda-json-${i}-${j}`;
_rdaJsonStore[key] = r.json;
return `
<div>
<div class="flex items-center gap-3 px-6 py-1.5 bg-blue-50 border-t border-blue-100 text-xs cursor-pointer hover:bg-blue-100 select-none"
onclick="toggleJsonBlock('${key}','rdachev-${i}-${j}')">
<i class="fas fa-chevron-right text-blue-300 text-xs transition-transform" id="rdachev-${i}-${j}"></i>
<span class="text-blue-400 w-4"><i class="fas fa-flask"></i></span>
<span class="font-mono text-blue-700 w-28">${escHtml(r.factura)}</span>
<span class="text-blue-600 w-24">${escHtml(r.fecha)}</span>
<span class="font-mono bg-blue-100 text-blue-800 px-1.5 py-0.5 rounded w-14 text-center">${escHtml(r.contrato||'—')}</span>
<span class="text-blue-600 w-20">${r.examenes} exam.</span>
<span class="text-blue-600 flex-1 truncate">${escHtml(r.diag||'')}</span>
<span class="text-blue-700 font-medium">$${Number(r.valor||0).toLocaleString('es-CO')}</span>
</div>
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-blue-100"></div>
</div>`;
}).join('');
return `
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer select-none" <div class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer select-none"
onclick="toggleRda('rda-${i}', this)"> onclick="toggleSubRow('rda-${i}','chev-${i}')">
<i class="fas fa-chevron-right text-gray-300 text-xs transition-transform duration-150" id="chev-${i}"></i> <i class="fas fa-chevron-right text-gray-300 text-xs transition-transform" id="chev-${i}"></i>
<span class="text-gray-400 text-xs w-8">${p.tipo}</span> <span class="text-gray-400 text-xs w-8">${escHtml(p.tipo)}</span>
<span class="text-gray-500 w-32 font-mono text-xs">${p.doc}</span> <span class="text-gray-500 w-32 font-mono text-xs">${escHtml(p.doc)}</span>
<span class="text-gray-700 flex-1">${p.nombre}</span> <span class="text-gray-700 flex-1">${escHtml(p.nombre)}</span>
<span class="text-xs ${p.rda.length > 0 ? 'text-blue-600' : 'text-gray-300'}">${p.rda.length} RDA</span> <span class="text-xs ${p.rda&&p.rda.length>0?'text-blue-600':'text-gray-300'}">${(p.rda||[]).length} RDA</span>
</div> </div>
<div id="rda-${i}" class="hidden">${rdaRows || '<div class="px-6 py-2 text-xs text-gray-400 bg-gray-50">Sin recepciones</div>'}</div> <div id="rda-${i}" class="hidden">${rdaRows||'<div class="px-6 py-2 text-xs text-gray-400 bg-gray-50">Sin recepciones</div>'}</div>
</div>`; </div>`;
}).join(''); }).join('');
}
document.getElementById('btn-run').disabled = false; } else {
showToast(`${data.pacientes} paciente(s) · ${data.recepciones} recepción(es)`, 'success'); secTR.classList.add('hidden');
} }
// Pre-servicios
const psSection = document.getElementById('preservicios-section');
if (_pasos.has('preserv') && data.preservicios_preview && data.preservicios_preview.length > 0) {
document.getElementById('preview-ps-table').innerHTML = data.preservicios_preview.map((ps, i) => {
const key = `ps-json-${i}`;
_rdaJsonStore[key] = ps.json;
return `
<div>
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-orange-50 cursor-pointer select-none border-b border-orange-100"
onclick="toggleJsonBlock('${key}','pschev-${i}')">
<i class="fas fa-chevron-right text-orange-300 text-xs transition-transform" id="pschev-${i}"></i>
<span class="font-mono text-orange-700 font-medium w-32">${escHtml(ps.factura)}</span>
<span class="text-gray-500 w-24 text-xs">${escHtml(ps.fecha)}</span>
<span class="text-gray-600 flex-1 text-xs">Pac. ${escHtml(String(ps.paciente))}</span>
<span class="text-xs text-orange-500">${ps.examenes} examen(es)</span>
</div>
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-orange-100"></div>
</div>`;
}).join('');
psSection.classList.remove('hidden');
} else {
psSection.classList.add('hidden');
}
// Ventas - errores de generación
if (data.ventas_gen_errors && data.ventas_gen_errors.length > 0) {
data.ventas_gen_errors.forEach(e => {
showToast(`Error generando venta ${e.factura}: ${e.error}`, 'error');
console.error('ventas_gen_error', e);
});
}
// Ventas
const vtaSection = document.getElementById('ventas-section');
if (_pasos.has('ventas') && data.ventas_preview && data.ventas_preview.length > 0) {
document.getElementById('preview-vta-table').innerHTML = data.ventas_preview.map((vta, i) => {
const key = `vta-json-${i}`;
_rdaJsonStore[key] = vta.json;
return `
<div>
<div class="flex items-center gap-3 px-4 py-2.5 hover:bg-emerald-50 cursor-pointer select-none border-b border-emerald-100"
onclick="toggleJsonBlock('${key}','vtachev-${i}')">
<i class="fas fa-chevron-right text-emerald-300 text-xs transition-transform" id="vtachev-${i}"></i>
<span class="font-mono text-emerald-700 font-medium w-32">${escHtml(vta.factura)}</span>
<span class="text-gray-500 w-24 text-xs">${escHtml(vta.fecha)}</span>
<span class="font-mono bg-emerald-100 text-emerald-800 px-1.5 py-0.5 rounded text-xs w-14 text-center">${escHtml(vta.contrato||'—')}</span>
<span class="text-gray-600 flex-1 text-xs">Pac. ${escHtml(String(vta.paciente))}</span>
<span class="text-xs text-emerald-500">${vta.examenes} examen(es)</span>
</div>
<div id="${key}" class="hidden bg-gray-900 text-green-300 text-xs font-mono p-4 overflow-x-auto whitespace-pre border-b border-emerald-100"></div>
</div>`;
}).join('');
vtaSection.classList.remove('hidden');
} else {
vtaSection.classList.add('hidden');
}
document.getElementById('preview-section').classList.remove('hidden'); document.getElementById('preview-section').classList.remove('hidden');
} catch (e) {
if (!hasDatos) {
showToast('Sin datos para los pasos seleccionados en esa fecha', 'warning');
} else {
showToast(`${data.pacientes} pac · ${data.recepciones} RDA · ${data.preservicios||0} PS · ${data.ventas||0} VTA`, 'success');
}
} catch(e) {
hideLoading(btn, '<i class="fas fa-eye"></i> Vista previa'); hideLoading(btn, '<i class="fas fa-eye"></i> Vista previa');
showToast('Error de conexión', 'error'); showToast('Error de conexión', 'error');
} }
} }
// ── Modal ────────────────────────────────────────────────────────────────────
function abrirModalConfirmar() { function abrirModalConfirmar() {
if (!_previewData) return; if (!_previewData) return;
const d = _previewData; const d = _previewData;
document.getElementById('modal-fecha').textContent = d.fecha; document.getElementById('modal-fecha').textContent = d.fecha;
document.getElementById('modal-cnt-pac').textContent = d.pacientes; document.getElementById('mc-terceros').style.opacity = _pasos.has('terceros') ? '1' : '0.3';
document.getElementById('modal-cnt-rec').textContent = d.recepciones; document.getElementById('mc-rda').style.opacity = _pasos.has('rda') ? '1' : '0.3';
document.getElementById('modal-cnt-exa').textContent = d.examenes; document.getElementById('mc-examenes').style.opacity = (_pasos.has('rda')||_pasos.has('preserv')) ? '1' : '0.3';
document.getElementById('mc-preserv').style.opacity = _pasos.has('preserv') ? '1' : '0.3';
document.getElementById('modal-lista-pac').innerHTML = d.pacientes_preview.map(p => ` document.getElementById('mc-ventas').style.opacity = _pasos.has('ventas') ? '1' : '0.3';
document.getElementById('modal-cnt-pac').textContent = _pasos.has('terceros') ? d.pacientes : '—';
document.getElementById('modal-cnt-rec').textContent = _pasos.has('rda') ? d.recepciones : '—';
document.getElementById('modal-cnt-exa').textContent = (_pasos.has('rda')||_pasos.has('preserv')) ? d.examenes : '—';
document.getElementById('modal-cnt-ps').textContent = _pasos.has('preserv') ? (d.preservicios||0) : '—';
document.getElementById('modal-cnt-vta').textContent = _pasos.has('ventas') ? (d.ventas||0) : '—';
document.getElementById('modal-lista-pac').innerHTML = (d.pacientes_preview || []).map(p => `
<div class="flex items-center gap-3 px-3 py-1.5 border-b border-gray-50 text-xs last:border-0"> <div class="flex items-center gap-3 px-3 py-1.5 border-b border-gray-50 text-xs last:border-0">
<span class="text-gray-400 w-6">${p.tipo}</span> <span class="text-gray-400 w-6">${escHtml(p.tipo)}</span>
<span class="font-mono text-gray-500 w-28">${p.doc}</span> <span class="font-mono text-gray-500 w-28">${escHtml(p.doc)}</span>
<span class="text-gray-700 flex-1 truncate">${p.nombre}</span> <span class="text-gray-700 flex-1 truncate">${escHtml(p.nombre)}</span>
<span class="text-blue-500">${p.rda.length} RDA</span> <span class="text-blue-500">${(p.rda||[]).length} RDA</span>
</div>`).join(''); </div>`).join('');
document.getElementById('modal-confirmar').classList.remove('hidden'); document.getElementById('modal-confirmar').classList.remove('hidden');
} }
@@ -247,92 +439,278 @@ function cerrarModalConfirmar() {
document.getElementById('modal-confirmar').classList.add('hidden'); document.getElementById('modal-confirmar').classList.add('hidden');
} }
// ── Ejecutar envío ───────────────────────────────────────────────────────────
async function confirmarEnvio() { async function confirmarEnvio() {
cerrarModalConfirmar(); cerrarModalConfirmar();
const fecha = document.getElementById('fecha-input').value; const fecha = document.getElementById('fecha-input').value;
const btn = document.getElementById('btn-run'); const btn = document.getElementById('btn-run');
showLoading(btn); showLoading(btn);
document.getElementById('btn-preview').disabled = true; document.getElementById('btn-preview').disabled = true;
document.getElementById('progress-section').classList.remove('hidden'); document.getElementById('results-section').classList.add('hidden');
document.getElementById('p1-badge').textContent = 'Enviando...';
document.getElementById('p1-badge').className = 'text-sm text-blue-500';
document.getElementById('p2-badge').textContent = 'Esperando...';
document.getElementById('p2-badge').className = 'text-sm text-gray-400';
const contrato = document.getElementById('contrato-input').value.trim();
const form = new FormData(); const form = new FormData();
form.append('fecha', fecha); form.append('fecha', fecha);
if (contrato) form.append('contrato', contrato); form.append('pasos', [..._pasos].join(','));
try { try {
const resp = await fetch('/automation/run', { method: 'POST', body: form, credentials: 'include' }); const resp = await fetch('/automation/run', { method: 'POST', body: form, credentials: 'include' });
const data = await resp.json(); const data = await resp.json();
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar envío'); hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar');
document.getElementById('btn-preview').disabled = false; document.getElementById('btn-preview').disabled = false;
if (!data.success) { if (!data.success) {
showToast(data.message || 'Error', 'error'); showToast(data.message || 'Error', 'error');
document.getElementById('p1-badge').textContent = 'Error';
document.getElementById('p1-badge').className = 'text-sm text-red-500';
return; return;
} }
renderResults(data.resultado);
const r = data.resultado; } catch(e) {
renderBadge('p1-badge', r.paso1_terceros.enviados, r.paso1_terceros.errores); hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar');
renderDetalle('p1-detalle', r.paso1_terceros.detalle.map(d => ({
col1: d.doc, col2: d.nombre,
col3: `${d.ok ? '✅' : '❌'} ${d.msg || ''}`, ok: d.ok,
})));
renderBadge('p2-badge', r.paso2_rda.enviados, r.paso2_rda.errores);
renderDetalle('p2-detalle', r.paso2_rda.detalle.map(d => ({
col1: `Fac. ${d.factura}`,
col2: `Pac. ${d.paciente}${d.examenes} examen(es)`,
col3: `${d.ok ? '✅' : '❌'} ${d.msg || ''}`, ok: d.ok,
})));
const totalErr = r.paso1_terceros.errores + r.paso2_rda.errores;
showToast(totalErr === 0 ? 'Envío completado sin errores' : `Completado con ${totalErr} error(es)`,
totalErr === 0 ? 'success' : 'warning');
} catch (e) {
hideLoading(btn, '<i class="fas fa-play"></i> Ejecutar envío');
document.getElementById('btn-preview').disabled = false; document.getElementById('btn-preview').disabled = false;
showToast('Error de conexión', 'error'); showToast('Error de conexión', 'error');
} }
} }
function toggleRda(id, row) { // ── Definiciones de pasos ────────────────────────────────────────────────────
const _STEP_DEFS = [
{ pasoKey:'terceros', resultKey:'paso1_terceros', label:'Terceros', num:1, color:'blue', tipo:'tercero', icon:'fa-user' },
{ pasoKey:'rda', resultKey:'paso2_rda', label:'RDA', num:2, color:'purple', tipo:'rda', icon:'fa-exchange-alt' },
{ pasoKey:'preserv', resultKey:'paso3_preserv', label:'Pre-servicios', num:3, color:'orange', tipo:'preserv', icon:'fa-file-alt' },
{ pasoKey:'ventas', resultKey:'paso4_ventas', label:'Ventas', num:4, color:'emerald',tipo:'venta', icon:'fa-file-invoice-dollar'},
];
const _BADGE = {
blue: 'bg-blue-100 text-blue-700',
purple: 'bg-purple-100 text-purple-700',
orange: 'bg-orange-100 text-orange-700',
emerald:'bg-emerald-100 text-emerald-700',
};
const _CHIP = {
blue: 'bg-blue-100 text-blue-800',
purple: 'bg-purple-100 text-purple-800',
orange: 'bg-orange-100 text-orange-800',
emerald:'bg-emerald-100 text-emerald-800',
};
// ── Renderizar resultados ────────────────────────────────────────────────────
function renderResults(r) {
const activePasos = r.pasos || _STEP_DEFS.map(s => s.pasoKey);
const activeSteps = _STEP_DEFS.filter(s => activePasos.includes(s.pasoKey));
// Summary chips
document.getElementById('res-summary').innerHTML = activeSteps.map(s => {
const d = r[s.resultKey];
const hasErr = d.errores > 0;
return `<span class="${_CHIP[s.color]} rounded-full px-3 py-1 text-xs font-semibold inline-flex items-center gap-1.5">
<i class="fas ${s.icon} text-xs opacity-70"></i> ${s.label}
<span class="text-green-700">✓${d.enviados}</span>
${hasErr ? `<span class="text-red-600 font-bold">✗${d.errores}</span>` : ''}
</span>`;
}).join('');
// Clasificar todos los items
_resultAll = [];
activeSteps.forEach(s => {
(r[s.resultKey].detalle || []).forEach((item, i) => {
const st = item.status || (item.ok ? 'success' : 'error');
_resultAll.push({ item, step: s, idx: `${s.pasoKey}-${i}`, status: st });
});
});
const errCount = _resultAll.filter(e => e.status === 'error').length;
const warnCount = _resultAll.filter(e => e.status === 'warning').length;
const okCount = _resultAll.filter(e => e.status === 'success').length;
document.getElementById('filter-cnt-errores').textContent = `(${errCount})`;
document.getElementById('filter-cnt-advertencias').textContent = `(${warnCount})`;
document.getElementById('filter-cnt-enviados').textContent = `(${okCount})`;
document.getElementById('filter-cnt-todos').textContent = `(${_resultAll.length})`;
const defaultFilter = errCount > 0 ? 'errores' : warnCount > 0 ? 'advertencias' : 'todos';
setResultFilter(defaultFilter);
document.getElementById('results-section').classList.remove('hidden');
showToast(
errCount === 0 ? `Envío completado · ${okCount} OK · ${warnCount} ya existían` : `${errCount} fallo(s) · ${okCount} OK · ${warnCount} ya existían`,
errCount === 0 ? 'success' : 'warning'
);
}
// ── Filtro de resultados ─────────────────────────────────────────────────────
function setResultFilter(filter) {
_activeFilter = filter;
// Actualizar estilos de botones
const configs = {
errores: { active: 'bg-white shadow text-red-600', inactive: 'text-gray-500 hover:text-gray-700' },
advertencias: { active: 'bg-white shadow text-yellow-600', inactive: 'text-gray-500 hover:text-gray-700' },
enviados: { active: 'bg-white shadow text-green-600', inactive: 'text-gray-500 hover:text-gray-700' },
todos: { active: 'bg-white shadow text-gray-700', inactive: 'text-gray-500 hover:text-gray-700' },
};
['errores','advertencias','enviados','todos'].forEach(f => {
const btn = document.getElementById(`filter-btn-${f}`);
const base = 'px-3 py-1 text-xs font-medium rounded-md transition-all';
btn.className = `${base} ${f === filter ? configs[f].active : configs[f].inactive}`;
});
let items;
if (filter === 'errores') items = _resultAll.filter(e => e.status === 'error');
else if (filter === 'advertencias') items = _resultAll.filter(e => e.status === 'warning');
else if (filter === 'enviados') items = _resultAll.filter(e => e.status === 'success');
else items = _resultAll;
const listEl = document.getElementById('results-list');
if (items.length === 0) {
const msg = filter === 'errores' ? 'Sin errores — todo enviado correctamente' :
filter === 'advertencias' ? 'Sin advertencias' :
filter === 'enviados' ? 'No hay registros enviados' : 'Sin resultados';
listEl.innerHTML = `<div class="p-8 text-center text-gray-400 text-sm"><i class="fas ${filter==='errores'?'fa-check-circle text-green-400':'fa-inbox'} text-2xl mb-2 block"></i>${msg}</div>`;
return;
}
listEl.innerHTML = items.map(({ item, step: s, idx, status }) => {
let label1 = '', label2 = '', dtype = s.tipo, did = '';
if (s.tipo === 'tercero') {
label1 = item.doc || '—';
label2 = item.nombre || '—';
did = escHtml(String(item.codigo || ''));
} else {
label1 = item.factura || '—';
label2 = `Pac. ${item.paciente||'—'} · ${item.examenes||0} examen(es)`;
did = escHtml(String(item.idrecepcion || 0));
}
const msg = (item.msg || '').trim();
if (status === 'success') {
return `
<div class="px-5 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors">
<span class="text-green-500 shrink-0 text-base"><i class="fas fa-check-circle"></i></span>
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0">
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
</span>
<span class="font-mono text-gray-800 text-xs w-28 shrink-0 truncate" title="${escHtml(label1)}">${escHtml(label1)}</span>
<span class="text-gray-600 text-xs flex-1 truncate">${escHtml(label2)}</span>
<span class="text-green-600 text-xs max-w-[220px] truncate shrink-0" title="${escHtml(msg)}">${escHtml(msg.slice(0,60))}</span>
</div>`;
} else if (status === 'warning') {
return `
<div class="px-5 py-3 flex items-center gap-3 bg-yellow-50 hover:bg-yellow-100 transition-colors border-l-4 border-yellow-400">
<span class="text-yellow-500 shrink-0 text-base"><i class="fas fa-exclamation-circle"></i></span>
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0">
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
</span>
<span class="font-mono text-gray-800 text-xs w-28 shrink-0 truncate" title="${escHtml(label1)}">${escHtml(label1)}</span>
<span class="text-gray-600 text-xs flex-1 truncate">${escHtml(label2)}</span>
<span class="text-yellow-700 text-xs max-w-[260px] truncate shrink-0" title="${escHtml(msg)}">${escHtml(msg.slice(0,80))}</span>
</div>`;
} else {
return `
<div data-resend-row class="px-5 py-3 flex items-start gap-3 bg-red-50 hover:bg-red-100 transition-colors border-l-4 border-red-400">
<span class="text-red-400 shrink-0 text-base mt-0.5 fail-status-icon"><i class="fas fa-times-circle"></i></span>
<span class="${_BADGE[s.color]} text-xs font-semibold px-2 py-0.5 rounded shrink-0 mt-0.5">
<i class="fas ${s.icon} mr-1 opacity-70 text-xs"></i>${s.label}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-gray-800 text-xs font-semibold">${escHtml(label1)}</span>
<span class="text-gray-600 text-xs truncate">${escHtml(label2)}</span>
</div>
${msg ? `<div class="mt-1 text-xs text-red-600 break-words fail-msg" title="${escHtml(msg)}">${escHtml(msg.slice(0,120))}</div>` : ''}
</div>
<button id="rbtn-${idx}"
data-tipo="${dtype}" data-id="${did}"
onclick="reenviarItem(this)"
class="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-gray-200 hover:bg-blue-50 hover:border-blue-300 text-xs text-gray-600 hover:text-blue-700 rounded-lg transition-colors font-medium mt-0.5">
<i class="fas fa-redo-alt text-xs"></i> Reenviar
</button>
</div>`;
}
}).join('');
}
// ── Reenviar item individual ─────────────────────────────────────────────────
async function reenviarItem(btn) {
const tipo = btn.dataset.tipo;
const id = btn.dataset.id;
const row = btn.closest('[data-resend-row]');
const origHtml = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
btn.disabled = true;
const form = new FormData();
let endpoint = '';
if (tipo === 'tercero') {
endpoint = '/automation/reenviar-tercero';
form.append('codigo', String(id));
} else if (tipo === 'rda') {
endpoint = '/automation/reenviar-rda';
form.append('idrecepcion', String(id));
form.append('tipo', 'rda');
} else if (tipo === 'preserv') {
endpoint = '/automation/reenviar-rda';
form.append('idrecepcion', String(id));
form.append('tipo', 'preserv');
} else {
endpoint = '/automation/reenviar-venta';
form.append('idrecepcion', String(id));
}
try {
const resp = await fetch(endpoint, { method: 'POST', body: form, credentials: 'include' });
const data = await resp.json();
if (data.success) {
// Marcar la fila como resuelta visualmente
row.classList.remove('bg-red-50','bg-red-100','border-l-4','border-red-400');
row.classList.add('bg-green-50','border-l-4','border-green-400');
row.removeAttribute('data-resend-row');
const icon = row.querySelector('.fail-status-icon');
if (icon) icon.innerHTML = '<i class="fas fa-check-circle text-green-500"></i>';
const msgEl = row.querySelector('.fail-msg');
if (msgEl) { msgEl.textContent = (data.message||'OK enviado').slice(0,120); msgEl.className = 'mt-1 text-xs text-green-700'; }
btn.innerHTML = '<i class="fas fa-check text-green-500 text-xs"></i> OK';
btn.className = 'shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 bg-green-50 border border-green-200 text-xs text-green-700 rounded-lg font-medium cursor-default mt-0.5';
showToast('Reenviado correctamente', 'success');
} else {
btn.innerHTML = origHtml;
btn.disabled = false;
const msgEl = row.querySelector('.fail-msg');
if (msgEl) msgEl.textContent = (data.message||'Error').slice(0,120);
else {
const div = document.createElement('div');
div.className = 'mt-1 text-xs text-red-600 break-words fail-msg';
div.textContent = (data.message||'Error').slice(0,120);
row.querySelector('.flex-1').appendChild(div);
}
showToast(data.message || 'Error al reenviar', 'error');
}
} catch(e) {
btn.innerHTML = origHtml;
btn.disabled = false;
showToast('Error de conexión', 'error');
}
}
// ── Helpers UI ───────────────────────────────────────────────────────────────
function toggleSubRow(id, chevId) {
const div = document.getElementById(id); const div = document.getElementById(id);
const chev = row.querySelector('[id^="chev-"]'); const chev = document.getElementById(chevId);
const hidden = div.classList.contains('hidden'); const hidden = div.classList.contains('hidden');
div.classList.toggle('hidden', !hidden); div.classList.toggle('hidden', !hidden);
if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : ''; if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : '';
} }
function renderBadge(id, enviados, errores) { function toggleJsonBlock(id, chevId) {
const el = document.getElementById(id); const div = document.getElementById(id);
if (errores === 0) { const chev = document.getElementById(chevId);
el.textContent = `${enviados} enviados`; const hidden = div.classList.contains('hidden');
el.className = 'text-sm text-green-600 font-medium'; if (hidden && _rdaJsonStore[id]) div.textContent = JSON.stringify(_rdaJsonStore[id], null, 2);
} else if (enviados > 0) { div.classList.toggle('hidden', !hidden);
el.textContent = `⚠️ ${enviados} OK / ${errores} errores`; if (chev) chev.style.transform = hidden ? 'rotate(90deg)' : '';
el.className = 'text-sm text-yellow-600 font-medium';
} else {
el.textContent = `${errores} errores`;
el.className = 'text-sm text-red-600 font-medium';
}
}
function renderDetalle(id, items) {
const el = document.getElementById(id);
if (!items || items.length === 0) return;
el.innerHTML = items.map(i => `
<div class="px-6 py-2 flex items-center gap-3 text-sm ${i.ok ? '' : 'bg-red-50'}">
<span class="text-gray-500 w-28 shrink-0">${i.col1}</span>
<span class="text-gray-700 flex-1 truncate">${i.col2}</span>
<span class="text-xs ${i.ok ? 'text-green-600' : 'text-red-600'} shrink-0 max-w-xs truncate">${i.col3}</span>
</div>`).join('');
el.classList.remove('hidden');
} }
</script> </script>
{% endblock %} {% endblock %}
+198 -139
View File
@@ -1,139 +1,198 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es" class="h-full bg-gray-50"> <html lang="es" class="h-full bg-gray-50">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RIPS Manager - {% block title %}Dashboard{% endblock %}</title> <title>RIPS Manager - {% block title %}Dashboard{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/sql/sql.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/sql/sql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/show-hint.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/show-hint.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/sql-hint.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/hint/sql-hint.min.js"></script>
<style> <style>
.CodeMirror { border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 13px; height: auto; min-height: 200px; } .CodeMirror { border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 13px; height: auto; min-height: 200px; }
.CodeMirror-hints { font-size: 12px; } .CodeMirror-hints { font-size: 12px; }
</style> </style>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: {'50': '#eff6ff','100': '#dbeafe','200': '#bfdbfe','300': '#93c5fd','400': '#60a5fa','500': '#3b82f6','600': '#2563eb','700': '#1d4ed8','800': '#1e40af','900': '#1e3a8a'} primary: {'50': '#eff6ff','100': '#dbeafe','200': '#bfdbfe','300': '#93c5fd','400': '#60a5fa','500': '#3b82f6','600': '#2563eb','700': '#1d4ed8','800': '#1e40af','900': '#1e3a8a'}
} }
} }
} }
} }
</script> </script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head> <style>
<body class="h-full"> @page {
<div class="min-h-full"> margin: 1.5cm;
{% if user %}␍ /* Elimina encabezado/pie del navegador en navegadores que lo soporten */
<!-- Sidebar --> margin-top: 0.5cm;
<div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30"> margin-bottom: 0.5cm;
<div class="flex items-center h-16 px-6 border-b border-gray-700"> }
<i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i> @media print {
<span class="font-bold text-lg">RIPS Manager</span> /* Ocultar sidebar y header */
</div> .fixed { display: none !important; }
<nav class="mt-4 px-3 space-y-1"> header { display: none !important; }
<a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> /* Contenido ocupa toda la hoja */
<i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard␍ .pl-64 { padding-left: 0 !important; }
</a> main { padding: 0 !important; }
<a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> /* Eliminar fondos de color */
<i class="fas fa-cog w-5 mr-2"></i> Configuración␍ * { background: white !important; color: black !important;
</a> box-shadow: none !important; border-color: #ccc !important; }
<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 %}"> /* Mantener colores de texto en código */
<i class="fas fa-database w-5 mr-2"></i> Consultas SQL␍ pre, code { background: #f5f5f5 !important; color: #1a1a1a !important; }
</a> /* Evitar cortes en medio de secciones */
<hr class="my-3 border-gray-700"> section { page-break-inside: avoid; }
<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 %}"> </style>
<i class="fas fa-user w-5 mr-2"></i> Terceros␍ </head>
</a> <body class="h-full">
<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 %}"> {% set embedded = request.query_params.get('embed') == '1' %}
<i class="fas fa-exchange-alt w-5 mr-2"></i> Transacción RIPS␍ {% set cur = request.url.path %}
</a> <div class="min-h-full">
<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 %}"> {% if user %}
<i class="fas fa-robot w-5 mr-2"></i> Automatización␍ {% if not embedded %}
</a> <!-- Sidebar -->
<a href="/test-rda" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/test-rda' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> <div class="fixed inset-y-0 left-0 w-64 bg-gray-900 text-white z-30">
<i class="fas fa-flask w-5 mr-2"></i> Prueba RDA <div class="flex items-center h-16 px-6 border-b border-gray-700">
</a> <i class="fas fa-file-medical text-blue-400 text-xl mr-3"></i>
<hr class="my-3 border-gray-700"> <span class="font-bold text-lg">RIPS Manager</span>
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}"> </div>
<i class="fas fa-history w-5 mr-2"></i> Historial␍ <nav class="mt-4 px-3 space-y-1 overflow-y-auto" style="max-height: calc(100vh - 130px)">
</a> <a href="/dashboard" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/dashboard' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700"> <i class="fas fa-chart-pie w-5 mr-2"></i> Dashboard
<i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir␍ </a>
</a> <a href="/config" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/config' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
</nav> <i class="fas fa-cog w-5 mr-2"></i> Configuración
<div class="absolute bottom-0 left-0 right-0 px-4 py-3 border-t border-gray-700"> </a>
<div class="flex items-center"> <a href="/queries" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/queries' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm font-bold"> <i class="fas fa-database w-5 mr-2"></i> Consultas SQL
{{ user.username[0]|upper }}␍ </a>
</div> <a href="/contratos" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/contratos' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<div class="ml-3"> <i class="fas fa-file-contract w-5 mr-2"></i> Contratos
<p class="text-sm font-medium text-white">{{ user.username }}</p> </a>
<p class="text-xs text-gray-400">Sesión activa</p> <hr class="my-3 border-gray-700">
</div> <p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Módulos de Envío</p>
</div>
</div> <!-- Módulo TNS -->
</div> <a href="/envios/tns" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/tns' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
<i class="fas fa-paper-plane w-5 mr-2"></i>
<!-- Main content --> <span class="flex-1">TNS</span>
<div class="pl-64"> <span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/tns' %}bg-blue-500 text-blue-100{% else %}bg-gray-700 text-gray-400{% endif %}">5</span>
<header class="bg-white shadow-sm border-b border-gray-200"> </a>
<div class="flex items-center justify-between h-16 px-8"> {% if cur in ('/envios/tns', '/logs/resumen') %}
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1> <div class="ml-4 border-l border-gray-700 pl-3">
<div class="flex items-center space-x-4"> <p class="text-xs text-gray-500 py-0.5">Terceros · Transacción · Ventas</p>
<span class="text-sm text-gray-500"> <p class="text-xs text-gray-500 py-0.5">Prueba RDA · Automatización</p>
<i class="far fa-calendar-alt mr-1"></i> <a href="/logs/resumen" class="flex items-center py-0.5 text-xs {% if cur == '/logs/resumen' %}text-blue-400{% else %}text-gray-500 hover:text-gray-300{% endif %}">
<script>document.write(new Date().toLocaleDateString('es-CO', {year:'numeric',month:'long',day:'numeric'}))</script> <i class="fas fa-calendar-check mr-1.5 text-[10px]"></i> Resumen Diario
</span> </a>
</div> </div>
</div> {% endif %}
</header>
<main class="p-8"> <!-- Módulo ERP Lab -->
{% block content %}{% endblock %}␍ <a href="/envios/erp" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/envios/erp' %}bg-green-700 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
</main> <i class="fas fa-flask w-5 mr-2"></i>
</div> <span class="flex-1">ERP Lab</span>
{% else %}␍ <span class="text-xs px-1.5 py-0.5 rounded {% if cur == '/envios/erp' %}bg-green-500 text-green-100{% else %}bg-gray-700 text-gray-400{% endif %}">WA</span>
<main> </a>
{% block auth_content %}{% endblock %} {% if cur == '/envios/erp' %}
</main> <div class="ml-4 border-l border-gray-700 pl-3">
{% endif %}␍ <p class="text-xs text-gray-500 py-0.5">Pacientes · Sync Automático</p>
</div> </div>
{% endif %}
<script>
console.log('%c RIPS Manager v1.0 ', 'background:#1e3a8a;color:white;font-size:18px;font-weight:bold;padding:8px 12px;border-radius:4px;'); <hr class="my-3 border-gray-700">
console.log('%c Desarrollado por U-SITE SAS BIC', 'font-size:14px;'); <a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
console.log('%c https://u-site.app/', 'color:#2563eb;font-size:12px;'); <i class="fas fa-history w-5 mr-2"></i> Historial TNS
console.log('%c Sistema de generacion y envio de RIPS (Res. 2275/2023)', 'font-style:italic;font-size:12px;'); </a>
<a href="/logs/resumen" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/resumen' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
async function fetchJSON(url, options = {}) { <i class="fas fa-calendar-check w-5 mr-2"></i> Resumen Diario
const resp = await fetch(url, options); </a>
return await resp.json(); <a href="/logs/actividad" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/logs/actividad' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
} <i class="fas fa-user-clock w-5 mr-2"></i> Actividad
</a>
function showToast(message, type = 'success') { <hr class="my-3 border-gray-700">
const colors = {success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500', warning: 'bg-yellow-500'}; <a href="/documentacion" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if cur == '/documentacion' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">
const toast = document.createElement('div'); <i class="fas fa-book w-5 mr-2"></i> Documentación
toast.className = `fixed top-4 right-4 z-50 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transition-all duration-500`; </a>
toast.textContent = message; <a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700">
document.body.appendChild(toast); <i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 4000); </a>
} </nav>
<div class="absolute bottom-0 left-0 right-0 px-4 py-3 border-t border-gray-700">
function showLoading(btn) { <div class="flex items-center">
btn.disabled = true; <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm font-bold">
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Procesando...'; {{ user.username[0]|upper }}
} </div>
<div class="ml-3">
function hideLoading(btn, text) { <p class="text-sm font-medium text-white">{{ user.username }}</p>
btn.disabled = false; <p class="text-xs text-gray-400">Sesión activa</p>
btn.innerHTML = text; </div>
} </div>
</script> </div>
</body> </div>
</html> {% endif %}
<!-- Main content -->
<div class="{% if not embedded %}pl-64{% endif %}">
{% if not embedded %}
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex items-center justify-between h-16 px-8">
<h1 class="text-xl font-semibold text-gray-800">{% block header %}Dashboard{% endblock %}</h1>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">
<i class="far fa-calendar-alt mr-1"></i>
<script>document.write(new Date().toLocaleDateString('es-CO', {year:'numeric',month:'long',day:'numeric'}))</script>
</span>
</div>
</div>
</header>
{% endif %}
<main class="{% if not embedded %}p-8{% else %}p-4{% endif %}">
{% block content %}{% endblock %}
</main>
</div>
{% else %}
<main>
{% block auth_content %}{% endblock %}
</main>
{% endif %}
</div>
<script>
console.log('%c RIPS Manager v1.0 ', 'background:#1e3a8a;color:white;font-size:18px;font-weight:bold;padding:8px 12px;border-radius:4px;');
console.log('%c Desarrollado por U-SITE SAS BIC', 'font-size:14px;');
console.log('%c https://u-site.app/', 'color:#2563eb;font-size:12px;');
console.log('%c Sistema de generacion y envio de RIPS (Res. 2275/2023)', 'font-style:italic;font-size:12px;');
async function fetchJSON(url, options = {}) {
const resp = await fetch(url, options);
return await resp.json();
}
function showToast(message, type = 'success') {
const colors = {success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500', warning: 'bg-yellow-500'};
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transition-all duration-500`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 4000);
}
function showLoading(btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Procesando...';
}
function hideLoading(btn, text) {
btn.disabled = false;
btn.innerHTML = text;
}
</script>
</body>
</html>
+61
View File
@@ -154,6 +154,39 @@
</div> </div>
</div> </div>
<hr class="border-gray-200">
<h4 class="font-medium text-gray-800">
<i class="fab fa-whatsapp mr-2 text-green-500"></i>WhatsApp Lab — Ingesta de Pacientes
</h4>
<p class="text-xs text-gray-500 -mt-2">
URL base del sistema WhatsApp Lab. Los pacientes de RIPS se sincronizarán a
<code class="bg-gray-100 px-1 rounded">/api/lab/ingest_paciente.php</code>.
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">URL WhatsApp Lab</label>
<input type="text" name="config_whatsapp_url"
value="{{ configs|selectattr('key', 'equalto', 'whatsapp_url')|map(attribute='value')|first|default('') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
placeholder="Ej: https://lab.ximena.com.co">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key (X-Lab-Key)</label>
<input type="text" name="config_whatsapp_api_key"
value="{{ configs|selectattr('key', 'equalto', 'whatsapp_api_key')|map(attribute='value')|first|default('rips-lab-sync-2026') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
placeholder="rips-lab-sync-2026">
</div>
</div>
<div>
<button type="button" onclick="testWhatsapp()"
class="px-4 py-2 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 text-sm font-medium">
<i class="fab fa-whatsapp mr-1"></i> Probar conexión
</button>
<span id="wa-status" class="text-sm ml-3"></span>
</div>
<button type="submit" <button type="submit"
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"> class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-save mr-2"></i> Guardar Configuración <i class="fas fa-save mr-2"></i> Guardar Configuración
@@ -202,5 +235,33 @@ async function testFirebird() {
? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Conexión exitosa</span>' ? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Conexión exitosa</span>'
: '<span class="text-red-600"><i class="fas fa-times-circle"></i> ' + result.message + '</span>'; : '<span class="text-red-600"><i class="fas fa-times-circle"></i> ' + result.message + '</span>';
} }
async function testWhatsapp() {
const form = document.querySelector('form');
const status = document.getElementById('wa-status');
status.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Probando…';
const url = form.querySelector('[name="config_whatsapp_url"]').value.trim().replace(/\/$/, '');
const apiKey = form.querySelector('[name="config_whatsapp_api_key"]').value.trim();
if (!url) {
status.innerHTML = '<span class="text-red-600">Ingresa la URL primero</span>';
return;
}
try {
const resp = await fetch(`${url}/api/lab/ingest_paciente.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Lab-Key': apiKey },
body: JSON.stringify({}),
});
// 422 = llegó al endpoint pero sin datos = conexión OK
status.innerHTML = (resp.status === 200 || resp.status === 422 || resp.status === 400)
? '<span class="text-green-600"><i class="fas fa-check-circle"></i> Endpoint alcanzable</span>'
: `<span class="text-red-600"><i class="fas fa-times-circle"></i> HTTP ${resp.status}</span>`;
} catch (e) {
status.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle"></i> ${e.message}</span>`;
}
}
</script> </script>
{% endblock %} {% endblock %}
+301
View File
@@ -0,0 +1,301 @@
{% extends "base.html" %}
{% block title %}Contratos{% endblock %}
{% block header %}Contratos{% endblock %}
{% block content %}
<div class="max-w-full 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-5 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="flex items-end gap-3">
<label class="flex items-center gap-1.5 text-xs text-gray-600 mb-2 cursor-pointer">
<input type="checkbox" name="excluir" value="1" class="rounded">
Excluir TNS
</label>
<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>
<!-- Códigos de pago -->
<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-tags mr-2 text-indigo-500"></i>Códigos de Pago</h3>
</div>
<div class="p-6 space-y-4">
<div class="flex flex-wrap gap-3">
{% for cp in codigos_pago %}
<div class="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg text-sm">
<span class="font-mono font-semibold text-indigo-700">{{ cp.codigo }}</span>
<span class="text-gray-600">{{ cp.nombre }}</span>
<form method="POST" action="/contratos/codigos-pago/delete/{{ cp.id }}" class="inline"
onsubmit="return confirm('¿Eliminar código {{ cp.codigo }}?')">
<button type="submit" class="text-red-400 hover:text-red-600 ml-1">
<i class="fas fa-times text-xs"></i>
</button>
</form>
</div>
{% endfor %}
</div>
<form method="POST" action="/contratos/codigos-pago/create" class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Código</label>
<input type="text" name="codigo" required placeholder="Ej: CIAC" maxlength="10"
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm uppercase focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre</label>
<input type="text" name="nombre" placeholder="Ej: Contado inmediato"
class="w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
</div>
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
<i class="fas fa-plus mr-1"></i> Agregar
</button>
</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">Excluir RDA</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Excluir Ventas</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">Forma Pago</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 {% if c.excluir %}bg-red-50{% endif %}" id="row-{{ c.id }}">
<td class="px-4 py-3 font-mono font-semibold {% if c.excluir %}text-red-500{% else %}text-gray-800{% endif %}">{{ 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">
<form method="POST" action="/contratos/toggle-excluir/{{ c.id }}" class="inline">
{% if c.excluir %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded-full text-xs font-medium transition-colors">
<i class="fas fa-ban text-xs"></i> Excluido
</button>
{% else %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-700 hover:bg-green-200 rounded-full text-xs font-medium transition-colors">
<i class="fas fa-check text-xs"></i> Activo
</button>
{% endif %}
</form>
</td>
<td class="px-4 py-3 text-center">
<form method="POST" action="/contratos/toggle-excluir-ventas/{{ c.id }}" class="inline">
{% if c.excluir_ventas %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded-full text-xs font-medium transition-colors">
<i class="fas fa-ban text-xs"></i> Excluido
</button>
{% else %}
<button type="submit" class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 rounded-full text-xs font-medium transition-colors">
<i class="fas fa-check text-xs"></i> Activo
</button>
{% endif %}
</form>
</td>
<td class="px-4 py-3 text-center">
<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">
<form method="POST" action="/contratos/set-forma-pago/{{ c.id }}" class="inline">
<select name="cod_forma_pago" onchange="this.form.submit()"
class="text-xs border border-gray-200 rounded-lg px-2 py-1 focus:ring-2 focus:ring-indigo-400 bg-white {% if c.cod_forma_pago %}text-indigo-700 font-semibold{% else %}text-gray-400{% endif %}">
<option value="" {% if not c.cod_forma_pago %}selected{% endif %}>— auto —</option>
{% for cp in codigos_pago %}
<option value="{{ cp.codigo }}" {% if c.cod_forma_pago == cp.codigo %}selected{% endif %}>{{ cp.codigo }} — {{ cp.nombre }}</option>
{% endfor %}
</select>
</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 }}, {{ c.excluir_ventas }}, '{{ c.cod_forma_pago }}')"
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="6" 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>
<label class="block text-xs font-medium text-gray-600 mb-1">Forma de Pago (Ventas)</label>
<select name="cod_forma_pago" id="edit-forma-pago"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
<option value="">— automático (CIAC si total=0, CR si total>0) —</option>
{% for cp in codigos_pago %}
<option value="{{ cp.codigo }}">{{ cp.codigo }} — {{ cp.nombre }}</option>
{% endfor %}
</select>
</div>
<div class="flex flex-wrap items-center gap-4">
<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 RDA <span class="text-xs text-red-500">(bloquea transacción/automation)</span></span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="excluir_ventas" id="edit-excluir-ventas" value="1" class="rounded">
<span class="text-sm text-gray-700">Excluir Ventas <span class="text-xs text-red-500">(bloquea /ventas)</span></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">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()"
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, excluir, sinContrato, excluirVentas, formaPago) {
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-forma-pago').value = formaPago || '';
document.getElementById('edit-excluir').checked = excluir === 1;
document.getElementById('edit-sin-contrato').checked = sinContrato === 1;
document.getElementById('edit-excluir-ventas').checked = excluirVentas === 1;
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 %}
+623
View File
@@ -0,0 +1,623 @@
{% extends "base.html" %}
{% block title %}Documentación{% endblock %}
{% block header %}Documentación del Sistema{% endblock %}
{% block content %}
<div class="flex gap-6 max-w-full">
<!-- Sidebar de navegación -->
<aside class="w-64 flex-shrink-0">
<div class="sticky top-4 bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-blue-700 text-white">
<p class="text-xs font-semibold uppercase tracking-wider">Contenido</p>
</div>
<nav class="p-3 space-y-0.5 text-sm max-h-[80vh] overflow-y-auto">
<p class="px-2 pt-2 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">General</p>
<a href="#objetivo" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-bullseye w-4 mr-2 text-xs"></i>Objetivo</a>
<a href="#arquitectura" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-sitemap w-4 mr-2 text-xs"></i>Arquitectura</a>
<a href="#acceso" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-key w-4 mr-2 text-xs"></i>Acceso y credenciales</a>
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Módulos</p>
<a href="#dashboard" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-chart-pie w-4 mr-2 text-xs"></i>Dashboard</a>
<a href="#configuracion" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-cog w-4 mr-2 text-xs"></i>Configuración</a>
<a href="#contratos" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-file-contract w-4 mr-2 text-xs"></i>Contratos</a>
<a href="#ventas" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-shopping-cart w-4 mr-2 text-xs"></i>Ventas CMXC</a>
<a href="#automation" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-robot w-4 mr-2 text-xs"></i>Automatización RDA</a>
<a href="#terceros" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-user-plus w-4 mr-2 text-xs"></i>Terceros</a>
<a href="#transaccion" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-exchange-alt w-4 mr-2 text-xs"></i>Transacción RDA</a>
<a href="#pacientes" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-users w-4 mr-2 text-xs"></i>Pacientes / Sync</a>
<a href="#queries" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-database w-4 mr-2 text-xs"></i>Consultas SQL</a>
<a href="#test-rda" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-vial w-4 mr-2 text-xs"></i>Prueba RDA</a>
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Historial</p>
<a href="#logs" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-history w-4 mr-2 text-xs"></i>Historial envíos</a>
<a href="#resumen" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-calendar-check w-4 mr-2 text-xs"></i>Resumen diario</a>
<a href="#actividad" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-user-clock w-4 mr-2 text-xs"></i>Actividad</a>
<a href="#erp" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-flask w-4 mr-2 text-xs"></i>ERP / WhatsApp</a>
<p class="px-2 pt-3 pb-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Referencia técnica</p>
<a href="#json-venta" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-code w-4 mr-2 text-xs"></i>JSON Venta</a>
<a href="#json-rda" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-code w-4 mr-2 text-xs"></i>JSON RDA</a>
<a href="#logica-pago" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-credit-card w-4 mr-2 text-xs"></i>Formas de pago</a>
<a href="#logica-contratos" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-sliders-h w-4 mr-2 text-xs"></i>Exclusiones</a>
<a href="#soporte" class="doc-link flex items-center px-2 py-1.5 rounded text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors"><i class="fas fa-headset w-4 mr-2 text-xs"></i>Soporte</a>
</nav>
</div>
</aside>
<!-- Contenido principal -->
<div class="flex-1 space-y-8 min-w-0">
<!-- ── OBJETIVO ── -->
<section id="objetivo" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-bullseye text-blue-500"></i> Objetivo del sistema</h2>
<hr class="border-blue-100 mb-4">
<p class="text-gray-700 leading-relaxed">
Sistema web de integración de datos clínicos para el <strong>Laboratorio Clínico Ximena Caicedo</strong>, que permite el intercambio de información de ventas y recepciones (RDA) con el sistema <strong>TNS (Tecnología de Negocios en Salud)</strong>, de manera estructurada, segura y trazable.
</p>
<p class="text-gray-700 leading-relaxed mt-3">
Centraliza la gestión de contratos, el envío de facturas de venta, la generación de RDA de pacientes, la sincronización con WhatsApp ERP y el registro completo de toda la actividad del sistema.
</p>
<div class="mt-4 grid grid-cols-3 gap-3">
<div class="bg-blue-50 rounded-lg p-3 text-center"><i class="fas fa-paper-plane text-blue-500 text-lg mb-1"></i><p class="text-xs font-semibold text-blue-700">Envío TNS</p><p class="text-xs text-gray-500">Ventas y RDA</p></div>
<div class="bg-green-50 rounded-lg p-3 text-center"><i class="fas fa-database text-green-500 text-lg mb-1"></i><p class="text-xs font-semibold text-green-700">Firebird</p><p class="text-xs text-gray-500">Base de datos clínica</p></div>
<div class="bg-purple-50 rounded-lg p-3 text-center"><i class="fas fa-shield-alt text-purple-500 text-lg mb-1"></i><p class="text-xs font-semibold text-purple-700">Trazabilidad</p><p class="text-xs text-gray-500">Log completo</p></div>
</div>
</section>
<!-- ── ARQUITECTURA ── -->
<section id="arquitectura" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-sitemap text-blue-500"></i> Arquitectura técnica</h2>
<hr class="border-blue-100 mb-4">
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm font-semibold text-gray-700 mb-2">Stack tecnológico</p>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Componente</th><th class="px-3 py-2 text-left">Tecnología</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">Backend</td><td class="px-3 py-2">Python 3 + FastAPI (async)</td></tr>
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">Frontend</td><td class="px-3 py-2">Jinja2 + TailwindCSS + Font Awesome</td></tr>
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">BD local</td><td class="px-3 py-2">SQLite 3 (WAL mode)</td></tr>
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">BD clínica</td><td class="px-3 py-2">Firebird 2.5 — 192.168.0.125:3025</td></tr>
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">API externa</td><td class="px-3 py-2">TNS — REST JSON + Bearer Token</td></tr>
<tr class="bg-blue-50 hover:bg-blue-100"><td class="px-3 py-2 font-medium">Servidor</td><td class="px-3 py-2">Windows — uvicorn :8080</td></tr>
<tr class="hover:bg-gray-50"><td class="px-3 py-2 font-medium">Auth</td><td class="px-3 py-2">JWT + bcrypt</td></tr>
</tbody>
</table>
</div>
<div>
<p class="text-sm font-semibold text-gray-700 mb-2">Flujo de datos</p>
<ol class="space-y-2">
{% for step in [
('1','Navegador','Usuario accede y se autentica con JWT.','blue'),
('2','Firebird','Sistema consulta recepciones/facturas.','green'),
('3','Generador','Se construye el JSON según spec TNS.','purple'),
('4','TNS API','POST con Bearer Token al endpoint.','orange'),
('5','SQLite','Respuesta guardada (success/error).','gray'),
('6','Historial','Registro disponible para reenvío.','blue'),
] %}
<li class="flex items-start gap-2">
<span class="w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{{ step[0] }}</span>
<div><span class="text-xs font-semibold text-gray-700">{{ step[1] }}: </span><span class="text-xs text-gray-600">{{ step[2] }}</span></div>
</li>
{% endfor %}
</ol>
</div>
</div>
</section>
<!-- ── ACCESO ── -->
<section id="acceso" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-key text-blue-500"></i> Acceso y credenciales</h2>
<hr class="border-blue-100 mb-4">
<div class="flex items-center gap-3 mb-4 bg-blue-50 rounded-lg px-4 py-3">
<i class="fas fa-globe text-blue-500 text-lg"></i>
<div>
<p class="text-xs text-gray-500">URL del sistema (red local)</p>
<p class="font-mono font-bold text-blue-700 text-sm">http://192.168.0.125:8080</p>
</div>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-sm font-semibold text-amber-700 mb-1"><i class="fas fa-exclamation-triangle mr-1"></i> Importante</p>
<p class="text-xs text-amber-700">Las credenciales de acceso son suministradas por el administrador del sistema.</p>
<p class="text-xs text-amber-600 mt-2">Nuevos usuarios: acceder a <span class="font-mono">/register</span> con sesión activa.</p>
</div>
</section>
<!-- ── DASHBOARD ── -->
<section id="dashboard" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-chart-pie text-blue-500"></i> Dashboard — Panel principal</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/dashboard</span> — Pantalla de inicio. Resumen en tiempo real del estado del sistema.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total de envíos exitosos acumulados (todos los tipos).</li>
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total de envíos con error.</li>
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Conteo de terceros registrados en TNS.</li>
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Conteo de transacciones RDA enviadas.</li>
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Número de facturas únicas procesadas.</li>
<li class="flex items-start gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Últimos 10 envíos con usuario, tipo, factura y estado.</li>
</ul>
</section>
<!-- ── CONFIGURACIÓN ── -->
<section id="configuracion" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-cog text-blue-500"></i> Configuración</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/config</span> — Parámetros de conexión y credenciales del sistema.</p>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Parámetro</th><th class="px-3 py-2 text-left">Descripción</th></tr></thead>
<tbody class="divide-y divide-gray-100">
{% for row in [
('firebird_host','IP del servidor Firebird (ej: 192.168.0.125)'),
('firebird_port','Puerto Firebird (ej: 3025)'),
('firebird_database','Ruta completa del archivo .FDB en el servidor'),
('firebird_user / password','Credenciales de acceso a Firebird (SYSDBA / masterkey)'),
('tns_empresa','NIT de la empresa en TNS'),
('tns_usuario / password','Credenciales de acceso a la API TNS'),
('api_sucursal','Código de sucursal para los endpoints TNS (ej: 81080)'),
('prefijo_tns_default','Prefijo por defecto para facturas (ej: 00)'),
('num_documento_obligado','NIT del obligado para RIPS'),
('profesional_default','Código del profesional por defecto en RDA'),
('api_timeout','Tiempo máximo de espera para llamadas TNS (segundos)'),
('whatsapp_url / api_key','URL y clave para integración con WhatsApp ERP'),
] %}
<tr class="{% if loop.index is odd %}bg-blue-50{% endif %} hover:bg-blue-100">
<td class="px-3 py-2 font-mono text-blue-800 font-medium">{{ row[0] }}</td>
<td class="px-3 py-2 text-gray-600">{{ row[1] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3 flex gap-3">
<div class="flex-1 bg-gray-50 rounded-lg p-3 text-xs"><i class="fas fa-plug text-blue-500 mr-1"></i> <strong>Probar conexión TNS</strong> — verifica credenciales y obtiene token JWT.</div>
<div class="flex-1 bg-gray-50 rounded-lg p-3 text-xs"><i class="fas fa-server text-green-500 mr-1"></i> <strong>Probar Firebird</strong> — verifica conectividad con la base de datos clínica.</div>
</div>
</section>
<!-- ── CONTRATOS ── -->
<section id="contratos" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-file-contract text-blue-500"></i> Contratos</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/contratos</span> — Gestión de convenios. Define cómo se procesa la información de cada aseguradora.</p>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden mb-4">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Campo</th><th class="px-3 py-2 text-left">Descripción</th></tr></thead>
<tbody class="divide-y divide-gray-100">
{% for row in [
('N° Contrato','Código del convenio. Referenciado por CODCONTRATO en Firebird.'),
('NIT Empresa','NIT de la aseguradora o empresa del convenio.'),
('Tipo Usuario','Código RIPS: 11=Contributivo, 12=Particular, 07=Póliza, 01=Subsidiado.'),
('Descripción','Nombre descriptivo del convenio.'),
('Excluir RDA','Si activo, las recepciones de este contrato NO se envían en RDA.'),
('Excluir Ventas','Si activo, las facturas de este contrato NO aparecen en Ventas.'),
('Sin Contrato','JSON RDA se envía con numeroContrato: null.'),
('Forma de Pago','Código CIAC / CR / MU. Vacío = determinado automáticamente.'),
] %}
<tr class="{% if loop.index is odd %}bg-blue-50{% endif %}">
<td class="px-3 py-2 font-semibold text-gray-700">{{ row[0] }}</td>
<td class="px-3 py-2 text-gray-600">{{ row[1] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
<p class="text-xs font-semibold text-indigo-700 mb-1"><i class="fas fa-tags mr-1"></i> Códigos de Pago</p>
<p class="text-xs text-indigo-700">Sección en la misma página para agregar/eliminar códigos (CIAC, CR, MU ya vienen por defecto). El select inline de cada fila asigna la forma de pago al contrato sin recargar la página.</p>
</div>
</section>
<!-- ── VENTAS ── -->
<section id="ventas" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-shopping-cart text-blue-500"></i> Ventas — Facturación CMXC</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/ventas</span> — Consulta y envío de facturas de venta al endpoint TNS <span class="font-mono bg-gray-100 px-1 rounded">/v2/facturacion/Ventas/Crear</span>. Solo procesa facturas con <strong>PREFIJO = CMXC</strong>.</p>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Flujo de uso</p>
<ol class="space-y-1.5 text-xs text-gray-700">
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">1</span> Seleccionar rango de fechas (FECHAFACT).</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">2</span> Clic en "Cargar" → consulta Firebird y agrupa por factura.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">3</span> Tabla muestra: clave, paciente, contrato, exámenes, valor, estado.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">4</span> Botón "Enviar" individual o "Ver JSON" para inspeccionar.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">5</span> Botón "Enviar Todo" procesa todos los pendientes en lote.</li>
</ol>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros automáticos</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Solo facturas con PREFIJO = CMXC.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Excluye facturas anuladas (ANULADA = T).</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Excluye contratos marcados "Excluir Ventas".</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Fecha aplicada sobre FECHAFACT, no FECHA_RECEPCION.</li>
</ul>
<p class="text-xs font-semibold text-gray-600 mt-3 mb-1">Agrupación</p>
<p class="text-xs text-gray-600">Una factura puede tener múltiples recepciones. Se agrupan por CMXC-{NUM} y se envían como un único JSON con todos los ítems en <span class="font-mono bg-gray-100 px-0.5">detallePedido</span>.</p>
</div>
</div>
</section>
<!-- ── AUTOMATIZACIÓN RDA ── -->
<section id="automation" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-robot text-blue-500"></i> Automatización RDA</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/automation</span> — Envío masivo de recepciones RDA al endpoint <span class="font-mono bg-gray-100 px-1 rounded">/v2/rda/RdaPaciente/Insertar</span>. Procesa PREFIJO = LHXC, RCXC, SC.</p>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Flujo de uso</p>
<ol class="space-y-1.5 text-xs text-gray-700">
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">1</span> Seleccionar rango de fechas y (opcional) contrato específico.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">2</span> "Cargar" → consulta Firebird con join a PACIENTE, RELACION, EXAMEN, MEDICO.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">3</span> Tabla muestra estado de cada recepción.</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">4</span> Envío individual o masivo "Enviar Todo Pendiente".</li>
<li class="flex gap-2"><span class="w-4 h-4 rounded-full bg-blue-600 text-white flex items-center justify-center text-[10px] font-bold flex-shrink-0 mt-0.5">5</span> "Solo pendientes" omite las ya enviadas exitosamente.</li>
</ol>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Comportamiento automático</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-user-check text-blue-400 mt-0.5"></i> Si el paciente no existe en TNS, lo registra automáticamente antes del RDA.</li>
<li class="flex gap-2"><i class="fas fa-ban text-red-400 mt-0.5"></i> Excluye recepciones con PS_NUM (muestras especiales).</li>
<li class="flex gap-2"><i class="fas fa-ban text-red-400 mt-0.5"></i> Excluye contratos marcados "Excluir RDA".</li>
<li class="flex gap-2"><i class="fas fa-unlink text-yellow-500 mt-0.5"></i> Contratos "Sin Contrato" → numeroContrato: null en JSON.</li>
<li class="flex gap-2"><i class="fas fa-id-card text-purple-400 mt-0.5"></i> Tipo de usuario (tipousuario) tomado del mapa de contratos.</li>
<li class="flex gap-2"><i class="fas fa-comment text-green-500 mt-0.5"></i> Sincroniza el paciente en WhatsApp ERP (opcional).</li>
</ul>
</div>
</div>
</section>
<!-- ── TERCEROS ── -->
<section id="terceros" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-user-plus text-blue-500"></i> Terceros</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/terceros</span> — Registro manual de pacientes en TNS via <span class="font-mono bg-gray-100 px-1 rounded">/v2/tablas/Tercero/Crear</span>.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Búsqueda de paciente en Firebird por número de documento.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista previa del JSON antes de enviar a TNS.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Envío individual con respuesta inmediata.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Listado de los últimos 20 terceros registrados.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Acceso a consultas SQL guardadas de tipo "terceros".</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización opcional del paciente en WhatsApp ERP.</li>
</ul>
</section>
<!-- ── TRANSACCIÓN ── -->
<section id="transaccion" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-exchange-alt text-blue-500"></i> Transacción — RDA Manual</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/transaccion</span> — Envío manual de RDA para una factura o recepción específica usando una consulta SQL guardada.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Selector de consulta SQL (tipo "transaccion") guardada.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Campos de filtro: número de factura, rango de fechas.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista previa del JSON RDA generado antes de enviar.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Manejo de contratos: excluidos, sin contrato, tipo de usuario.</li>
</ul>
</section>
<!-- ── PACIENTES ── -->
<section id="pacientes" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-users text-blue-500"></i> Pacientes / Sincronización</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/pacientes</span> — Sincronización masiva de pacientes con el sistema WhatsApp ERP del laboratorio.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Consulta todos los pacientes en Firebird por rango de fechas.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización masiva al WhatsApp ERP (insertar o actualizar).</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Migración de diagnósticos CIE-10 (12.000+ registros en lotes de 200).</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Vista de exámenes por cédula de paciente.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Log de cada sincronización: creados, actualizados, omitidos, errores.</li>
</ul>
</section>
<!-- ── QUERIES ── -->
<section id="queries" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-database text-blue-500"></i> Consultas SQL (Queries)</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/queries</span> — Gestión de consultas SQL reutilizables que se ejecutan contra Firebird.</p>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="border border-blue-200 rounded-lg p-3 bg-blue-50"><p class="text-xs font-bold text-blue-700 mb-1">terceros</p><p class="text-xs text-gray-600">Consultas para datos de pacientes (Tercero/Crear en TNS).</p></div>
<div class="border border-green-200 rounded-lg p-3 bg-green-50"><p class="text-xs font-bold text-green-700 mb-1">transaccion</p><p class="text-xs text-gray-600">Consultas para recepciones RDA (RdaPaciente/Insertar).</p></div>
<div class="border border-purple-200 rounded-lg p-3 bg-purple-50"><p class="text-xs font-bold text-purple-700 mb-1">ventas</p><p class="text-xs text-gray-600">Consultas históricas para facturas de venta.</p></div>
</div>
<p class="text-xs text-gray-600">Las consultas se pueden crear, editar y eliminar. Se cargan como selector en los módulos Terceros y Transacción. Soportan parámetros <span class="font-mono bg-gray-100 px-0.5">:fecha_ini</span>, <span class="font-mono bg-gray-100 px-0.5">:fecha_fin</span>, <span class="font-mono bg-gray-100 px-0.5">:num_factura</span>, <span class="font-mono bg-gray-100 px-0.5">:doc_num</span>.</p>
</section>
<!-- ── PRUEBA RDA ── -->
<section id="test-rda" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-vial text-blue-500"></i> Prueba RDA</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/test-rda</span> — Herramienta de diagnóstico para probar el envío de RDA por recepción o factura específica.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Búsqueda por IDRECEPCION o NUM_FACTURA específico.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Genera y muestra el JSON RDA completo antes de enviar.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Opción: enviar solo tercero, solo RDA, o ambos.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Muestra respuesta completa de TNS incluyendo mensajes de error detallados.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Ideal para diagnóstico de registros individuales con problemas.</li>
</ul>
</section>
<!-- ── HISTORIAL ── -->
<section id="logs" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-history text-blue-500"></i> Historial de Envíos</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs</span> — Registro completo de todos los envíos al sistema TNS con filtros avanzados y reenvío.</p>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros disponibles</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Tipo: terceros / transaccion / ventas.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Estado: success / error / warning.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Número de factura o cédula.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Rango de fechas. Paginación 50/pág.</li>
</ul>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Por cada registro</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-info-circle text-blue-400 mt-0.5"></i> Tipo, factura, contrato, usuario, estado.</li>
<li class="flex gap-2"><i class="fas fa-code text-blue-400 mt-0.5"></i> JSON completo enviado (formateado).</li>
<li class="flex gap-2"><i class="fas fa-server text-blue-400 mt-0.5"></i> Respuesta completa de TNS.</li>
<li class="flex gap-2"><i class="fas fa-redo text-green-500 mt-0.5"></i> Botón "Reenviar" para registros con error.</li>
</ul>
</div>
</div>
<div class="mt-3 bg-green-50 border border-green-200 rounded-lg p-3 text-xs text-green-800">
<i class="fas fa-redo mr-1"></i> <strong>Reenvío:</strong> obtiene el JSON guardado, hace nueva autenticación TNS y reenvía al endpoint correcto según el tipo. El registro se actualiza con el nuevo resultado.
</div>
</section>
<!-- ── RESUMEN ── -->
<section id="resumen" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-calendar-check text-blue-500"></i> Resumen Diario</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs/resumen</span> — Vista consolidada de todos los envíos de un día, agrupados por factura/paciente.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Total del día, exitosos y con error.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Estado final por registro (éxito si algún intento fue exitoso).</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Número de intentos y hora del primer/último envío.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Filtro por tipo de envío.</li>
</ul>
</section>
<!-- ── ACTIVIDAD ── -->
<section id="actividad" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-user-clock text-blue-500"></i> Registro de Actividad</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/logs/actividad</span> — Log de auditoría de todas las acciones de los usuarios del sistema.</p>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Acciones registradas</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Creación, edición y eliminación de contratos.</li>
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Cambios en configuración del sistema.</li>
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Envíos individuales y masivos (terceros, RDA, ventas).</li>
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Reenvíos desde historial.</li>
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Cambios de forma de pago en contratos.</li>
<li class="flex gap-2"><i class="fas fa-dot-circle text-gray-400 mt-1"></i> Sincronizaciones con WhatsApp ERP.</li>
</ul>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 mb-2">Filtros</p>
<ul class="space-y-1 text-xs text-gray-700">
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por usuario.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por tipo de acción.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Por rango de fechas.</li>
<li class="flex gap-2"><i class="fas fa-filter text-blue-400 mt-0.5"></i> Paginación 50/pág.</li>
</ul>
</div>
</div>
</section>
<!-- ── ERP ── -->
<section id="erp" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-flask text-blue-500"></i> ERP Lab / WhatsApp</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-3"><span class="font-mono bg-gray-100 px-1 rounded">/envios/erp</span> — Integración con el sistema WhatsApp ERP del laboratorio.</p>
<ul class="space-y-1 text-sm text-gray-700">
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Sincronización de pacientes recientes (ventana configurable de minutos).</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Migración masiva de diagnósticos CIE-10 (12.000+) en lotes de 200.</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Indicador de estado de configuración (URL y API Key).</li>
<li class="flex gap-2"><i class="fas fa-check-circle text-green-500 mt-0.5 text-xs"></i> Log por sincronización: total, creados, actualizados, omitidos, errores.</li>
</ul>
<p class="text-sm text-gray-600 mt-3"><span class="font-mono bg-gray-100 px-1 rounded">/envios/tns</span> — Vista de envíos recientes al sistema TNS con estado consolidado.</p>
</section>
<!-- ── JSON VENTA ── -->
<section id="json-venta" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-code text-blue-500"></i> Referencia JSON — Factura de Venta</h2>
<hr class="border-blue-100 mb-4">
<p class="text-xs text-gray-500 mb-3 font-mono">POST {TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}</p>
<pre class="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-x-auto leading-relaxed">{
"codigoPrefijo": "CMXC",
"numero": "00309",
"numeroFactura": "00309",
"sucursal": "00",
"fecha": "15/07/2026",
"kardexId": 0,
"codigoPedido": "",
"nombreCliente": "",
"codTercero": "900123456-7", <span class="text-yellow-300">// NIT con dígito de verificación</span>
"codVendedor": "00",
"codDespachar": "00",
"codFormaPago": "CR", <span class="text-yellow-300">// CIAC | CR | MU</span>
"codBanco": "", <span class="text-yellow-300">// siempre vacío</span>
"fechaVence": "14/08/2026", <span class="text-yellow-300">// FECHAFACT + DIASVENC días</span>
"fechaEntrega": "15/07/2026",
"plazoDias": 30, <span class="text-yellow-300">// 0 si CIAC, DIASVENC si CR/MU</span>
"observacion": "",
"sucursal": "00",
"codigoCentroCosto": "00",
"codigoArea": "00",
"terminal": "00",
"detallePedido": [
{
"codMat": "90600", <span class="text-yellow-300">// CUPS del examen</span>
"codBodega": "00",
"cantidad": 1,
"tipoUnidad": "M",
"descuento": 0,
"descuentoValor": 0,
"centrosCostos": "00",
"porcIva": 0,
"valor": 7000,
"impConsumo": 0,
"observacion": "",
"lote": "",
"fechaVenceLote": "",
"nroDocumento": "",
"itemsSerial": [],
"tipoSerial": ""
}
],
"detalleFormaPago": [],
"asentar": 0,
"detalleDescuentos": []
}</pre>
</section>
<!-- ── JSON RDA ── -->
<section id="json-rda" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-code text-blue-500"></i> Referencia JSON — RDA Paciente</h2>
<hr class="border-blue-100 mb-4">
<p class="text-xs text-gray-500 mb-3 font-mono">POST {TNS_BASE}/v2/rda/RdaPaciente/Insertar?codigosucursal={api_sucursal}</p>
<pre class="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-x-auto leading-relaxed">{
"codigoPrefijo": "LHXC",
"numero": "05103",
"fecha": "15/07/2026",
"codTercero": "1090512345", <span class="text-yellow-300">// código paciente en Firebird</span>
"codVendedor": "00",
"codigoCentroCosto": "00",
"tipoIngreso": "1",
"fechaHoraIngreso": "15/07/2026 08:30:00",
"fechaHoraEgreso": "15/07/2026 08:35:00",
"modalidadAtencion": "01",
"numeroContrato": "040", <span class="text-yellow-300">// null si sin_contrato=1</span>
"descuento": 0,
"diagnosticoprincipal": "Z017",
"discapacidad": "08",
"tipousuario": "11", <span class="text-yellow-300">// del mapa de contratos</span>
"viaIngreso": "01",
"esTerapia": false,
"esProcedimiento": false,
"numeroAutorizacion": null,
"detallePedido": [
{
"codigoMaterial": "90600", <span class="text-yellow-300">// CUPS del examen</span>
"codigoBodega": "00",
"cantidad": 1,
"observacion": "",
"profesional": "12345678",
"especialidad": "01",
"profesionalRemisionante": "00",
"diagnosticoprincipal": "Z017",
"fechaHoraRealizacion": "15/07/2026 08:30:00"
}
]
}</pre>
</section>
<!-- ── LÓGICA FORMAS DE PAGO ── -->
<section id="logica-pago" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-credit-card text-blue-500"></i> Lógica de negocio — Formas de Pago</h2>
<hr class="border-blue-100 mb-4">
<p class="text-sm text-gray-600 mb-4">El sistema determina <code class="bg-gray-100 px-1 rounded text-xs">codFormaPago</code> con la siguiente prioridad:</p>
<div class="bg-blue-50 rounded-lg p-3 mb-4 text-sm">
<strong class="text-blue-800">Prioridad 1:</strong> Si el contrato tiene "Forma de Pago" asignada → se usa esa, ignorando el total.<br>
<strong class="text-blue-800">Prioridad 2:</strong> Si no tiene asignación → automático: <code class="bg-white px-1 rounded text-xs">CIAC</code> si total=$0, <code class="bg-white px-1 rounded text-xs">CR</code> si total&gt;$0.
</div>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Código</th><th class="px-3 py-2 text-left">Nombre</th><th class="px-3 py-2 text-left">plazoDias</th><th class="px-3 py-2 text-left">fechaVence</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold text-blue-700 font-mono">CIAC</td><td class="px-3 py-2">Contado inmediato</td><td class="px-3 py-2 font-mono">0</td><td class="px-3 py-2">= fecha de la factura</td></tr>
<tr><td class="px-3 py-2 font-bold text-blue-700 font-mono">CR</td><td class="px-3 py-2">Crédito</td><td class="px-3 py-2 font-mono">DIASVENC (30)</td><td class="px-3 py-2">FECHAFACT + 30 días</td></tr>
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold text-blue-700 font-mono">MU</td><td class="px-3 py-2">Mixto</td><td class="px-3 py-2 font-mono">DIASVENC (30)</td><td class="px-3 py-2">FECHAFACT + 30 días</td></tr>
</tbody>
</table>
<p class="text-xs text-gray-500 mt-2"><code class="bg-gray-100 px-1 rounded">codBanco</code> siempre se envía como cadena vacía <code class="bg-gray-100 px-1 rounded">""</code>. DIASVENC se lee de <code class="bg-gray-100 px-1 rounded">FACTURA_DIAN.DIASVENC</code> en Firebird.</p>
</section>
<!-- ── LÓGICA EXCLUSIONES ── -->
<section id="logica-contratos" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-sliders-h text-blue-500"></i> Lógica de negocio — Exclusiones y Prefijos</h2>
<hr class="border-blue-100 mb-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-semibold text-gray-700 mb-2">Banderas de contrato</p>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Bandera</th><th class="px-3 py-2 text-left">Efecto</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr class="bg-blue-50"><td class="px-3 py-2 font-mono">excluir_rda=1</td><td class="px-3 py-2">NO aparece en RDA / Automatización.</td></tr>
<tr><td class="px-3 py-2 font-mono">excluir_ventas=1</td><td class="px-3 py-2">NO aparece en Ventas.</td></tr>
<tr class="bg-blue-50"><td class="px-3 py-2 font-mono">sin_contrato=1</td><td class="px-3 py-2">JSON RDA con numeroContrato: null.</td></tr>
<tr><td class="px-3 py-2 font-mono">cod_forma_pago</td><td class="px-3 py-2">Override de forma de pago en venta.</td></tr>
</tbody>
</table>
</div>
<div>
<p class="text-sm font-semibold text-gray-700 mb-2">Prefijos de factura</p>
<table class="w-full text-xs border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-blue-700 text-white"><tr><th class="px-3 py-2 text-left">Prefijo</th><th class="px-3 py-2 text-left">Módulo</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold font-mono text-blue-700">CMXC</td><td class="px-3 py-2">Ventas — remisiones externas.</td></tr>
<tr><td class="px-3 py-2 font-bold font-mono text-green-700">LHXC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
<tr class="bg-blue-50"><td class="px-3 py-2 font-bold font-mono text-green-700">RCXC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
<tr><td class="px-3 py-2 font-bold font-mono text-green-700">SC</td><td class="px-3 py-2">Automatización RDA.</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ── SOPORTE ── -->
<section id="soporte" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-bold text-blue-800 mb-1 flex items-center gap-2"><i class="fas fa-headset text-blue-500"></i> Soporte técnico</h2>
<hr class="border-blue-100 mb-4">
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="bg-blue-50 rounded-lg p-4">
<p class="text-sm font-bold text-blue-800 mb-2">U-SITE S.A.S. BIC</p>
<p class="text-xs text-gray-600 mb-1"><i class="fas fa-envelope mr-1 text-blue-400"></i> contacto@u-s.app</p>
<p class="text-xs text-gray-600 mb-1"><i class="fas fa-globe mr-1 text-blue-400"></i> https://u-s.app</p>
<p class="text-xs text-gray-600"><i class="fas fa-clock mr-1 text-blue-400"></i> Lunes a viernes — días hábiles</p>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p class="text-sm font-bold text-amber-800 mb-2"><i class="fas fa-search mr-1"></i> Antes de contactar soporte</p>
<ul class="space-y-1 text-xs text-amber-800">
<li>• Verificar red entre servidor y 192.168.0.125 (Firebird).</li>
<li>• Confirmar que uvicorn está corriendo en el puerto 8080.</li>
<li>• Probar credenciales TNS en <span class="font-mono">/config</span>.</li>
<li>• Revisar error específico en <span class="font-mono">/logs</span>.</li>
<li>• Consultar <span class="font-mono">/logs/actividad</span> para rastrear la operación.</li>
</ul>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 text-xs text-gray-600 text-center border border-gray-200">
<strong>RIPS Manager v1.0</strong> — Desarrollado por U-SITE S.A.S. BIC para Laboratorio Clínico Ximena Caicedo
</div>
</section>
</div><!-- /contenido -->
</div>
<script>
// Resaltar sección activa en sidebar al hacer scroll
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.doc-link');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(s => {
if (window.scrollY >= s.offsetTop - 120) current = s.id;
});
navLinks.forEach(a => {
a.classList.remove('bg-blue-100', 'text-blue-800', 'font-semibold');
if (a.getAttribute('href') === '#' + current) {
a.classList.add('bg-blue-100', 'text-blue-800', 'font-semibold');
}
});
});
// Smooth scroll
navLinks.forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const target = document.querySelector(a.getAttribute('href'));
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
</script>
{% endblock %}
+464
View File
@@ -0,0 +1,464 @@
{% extends "base.html" %}
{% block title %}ERP Lab{% endblock %}
{% block header %}<i class="fas fa-flask mr-2 text-green-400"></i> ERP Lab · Ximena Caicedo{% endblock %}
{% block content %}
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
<!-- Barra de sub-tabs -->
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
<div class="flex">
<button id="btn-pacientes" onclick="switchTab('pacientes')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-users text-xs"></i> Pacientes
</button>
<button id="btn-scheduler" onclick="switchTab('scheduler')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-sync-alt text-xs"></i> Sync Automático
<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-semibold">20 seg</span>
</button>
</div>
</div>
<!-- Paneles -->
<div class="flex-1 relative overflow-hidden bg-gray-50">
<!-- Tab: Pacientes (iframe) -->
<div id="panel-pacientes" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-pacientes" class="w-full h-full border-0"></iframe>
</div>
<!-- Tab: Sync Automático (inline) -->
<div id="panel-scheduler" class="tab-panel absolute inset-0 hidden overflow-y-auto">
<div class="p-6 space-y-5 max-w-5xl mx-auto">
<!-- Estado del scheduler -->
<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-robot mr-2 text-green-500"></i>Estado del Scheduler
</h3>
<span id="sched-badge" class="flex items-center gap-2 text-sm font-medium text-gray-400">
<span id="sched-dot" class="w-2 h-2 rounded-full bg-gray-300"></span>
<span id="sched-label">Verificando…</span>
</span>
</div>
<div id="sched-live" class="px-5 pt-3 pb-2 text-xs text-gray-500 flex flex-wrap gap-x-6 gap-y-1 border-b border-gray-100"></div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
<div class="flex items-start gap-3">
<i class="fas fa-database text-blue-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Fuente</p>
<p class="text-xs text-gray-500">Firebird · DBLAB_XIMENA_FB25</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-filter text-purple-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Ventana de captura</p>
<p class="text-xs text-gray-500">Recepciones con HORAINICIORECEPCION en los últimos 2 minutos</p>
</div>
</div>
<div class="flex items-start gap-3">
<i class="fas fa-paper-plane text-green-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-700">Destino</p>
<p class="text-xs text-gray-500">WhatsApp Lab · <code>ingest_paciente.php</code></p>
</div>
</div>
</div>
<div class="px-5 pb-4 border-t border-gray-100 pt-3">
<p class="text-xs text-gray-500 font-medium mb-2">Datos sincronizados por recepción:</p>
<div class="flex flex-wrap gap-2">
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">Paciente (upsert)</span>
<span class="text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded">Exámenes + CUPS</span>
<span class="text-xs bg-orange-50 text-orange-700 px-2 py-1 rounded">Diagnóstico CIE-10</span>
<span class="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">Médico ordenante</span>
<span class="text-xs bg-teal-50 text-teal-700 px-2 py-1 rounded">Empresa / EPS</span>
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded">Valor total</span>
</div>
</div>
</div>
<!-- Migración diagnósticos CIE-10 -->
<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-stethoscope mr-2 text-orange-400"></i>Migración CIE-10
</h3>
<span class="text-xs text-gray-400">12.422 diagnósticos de Firebird → WhatsApp</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-4">
<p class="text-sm text-gray-500 flex-1">Pobla la tabla <code class="bg-gray-100 px-1 rounded">cie10_diagnosticos</code> en WhatsApp. Idempotente — se puede re-ejecutar. Si corta a la mitad, usa "Reanudar" para continuar desde donde quedó.</p>
<button id="btn-sync-diag" onclick="ejecutarSyncDiag()"
class="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors whitespace-nowrap">
<i class="fas fa-upload text-xs"></i> Migrar diagnósticos
</button>
</div>
<div id="sync-diag-result" class="hidden px-5 pb-4">
<div id="sync-diag-inner"></div>
</div>
</div>
<!-- Prueba manual -->
<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-play-circle mr-2 text-blue-500"></i>Prueba manual
</h3>
</div>
<div class="p-5 flex flex-wrap items-center gap-4">
<select id="sel-ventana"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300">
<option value="2">Últimos 2 minutos (igual al scheduler)</option>
<option value="30" selected>Últimos 30 minutos</option>
<option value="120">Últimas 2 horas</option>
<option value="1440">Hoy completo</option>
</select>
<button id="btn-sync-now" onclick="ejecutarSyncNow()"
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-bolt text-xs"></i> Ejecutar ahora
</button>
</div>
<div id="sync-now-result" class="hidden px-5 pb-5">
<div id="sync-now-inner"></div>
</div>
</div>
<!-- Historial de sync automático -->
<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-history mr-2 text-gray-400"></i>Historial de sync automático
</h3>
<button onclick="cargarHistorialScheduler()"
class="text-sm text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-refresh text-xs"></i> Actualizar
</button>
</div>
<div id="scheduler-hist" class="p-4">
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const _TAB_ACTIVE = 'border-green-500 text-green-700 bg-green-50';
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
let _schedulerLoaded = false;
let _histRefreshTimer = null;
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
b.classList.add(..._TAB_INACTIVE.split(' '));
});
document.getElementById('panel-' + name).classList.remove('hidden');
const btn = document.getElementById('btn-' + name);
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
btn.classList.add(..._TAB_ACTIVE.split(' '));
if (name === 'pacientes') {
const iframe = document.getElementById('iframe-pacientes');
if (!iframe.src) iframe.src = '/pacientes?embed=1';
}
if (name === 'scheduler') {
if (!_schedulerLoaded) {
_schedulerLoaded = true;
cargarHistorialScheduler();
pollSchedulerStatus();
}
// Auto-refresh historial cada 60s mientras el tab está activo
clearInterval(_histRefreshTimer);
_histRefreshTimer = setInterval(cargarHistorialScheduler, 60000);
} else {
clearInterval(_histRefreshTimer);
}
sessionStorage.setItem('erp-tab', name);
}
let _schedPollTimer = null;
async function pollSchedulerStatus() {
try {
const r = await fetch('/api/scheduler/status');
const d = await r.json();
const dot = document.getElementById('sched-dot');
const label = document.getElementById('sched-label');
const live = document.getElementById('sched-live');
if (d.running && d.job_exists) {
dot.className = 'w-2 h-2 rounded-full bg-green-400 animate-pulse';
label.textContent = 'Activo — cada 20 segundos';
label.className = 'text-green-600';
} else {
dot.className = 'w-2 h-2 rounded-full bg-red-400';
label.textContent = d.running ? 'Sin tarea registrada' : 'DETENIDO';
label.className = 'text-red-600';
}
const parts = [];
if (d.next_run) {
parts.push(`<span><i class="fas fa-clock mr-1 text-blue-400"></i>Próxima ejecución: <strong>${d.next_run}</strong></span>`);
}
if (d.ultimo_log) {
const ul = d.ultimo_log;
parts.push(`<span><i class="fas fa-history mr-1 text-purple-400"></i>Último sync scheduler: <strong>${ul.hora}</strong> · ${ul.total ?? 0} procesados, ${ul.errores ?? 0} errores</span>`);
}
live.innerHTML = parts.join('');
} catch(_) {}
_schedPollTimer = setTimeout(pollSchedulerStatus, 15000);
}
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
async function ejecutarSyncDiag(startFrom = 0) {
const btn = document.getElementById('btn-sync-diag');
const resBox = document.getElementById('sync-diag-result');
const inner = document.getElementById('sync-diag-inner');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Migrando…';
resBox.classList.remove('hidden');
inner.innerHTML = `<p class="text-sm text-gray-400">Enviando diagnósticos en lotes de 200${startFrom ? ` (desde #${startFrom.toLocaleString()})` : ''}…</p>`;
try {
const url = `/envios/erp/sync-diagnosticos${startFrom ? `?start_from=${startFrom}` : ''}`;
const resp = await fetch(url, { method: 'POST' });
const d = await resp.json();
const errHTML = d.errores?.length
? `<ul class="mt-2 ml-4 list-disc text-xs space-y-0.5">${d.errores.map(e => `<li>${e}</li>`).join('')}</ul>`
: '';
if (d.ok) {
inner.innerHTML = `<div class="text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3">
<i class="fas fa-check-circle mr-1"></i>
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> diagnósticos migrados.
</div>`;
} else {
const firstErr = d.errores?.[0] || '';
const resumeMatch = firstErr.match(/Lote (\d+)-/);
const resumeFrom = resumeMatch ? parseInt(resumeMatch[1]) : null;
inner.innerHTML = `<div class="text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-lg px-4 py-3">
<i class="fas fa-exclamation-triangle mr-1"></i>
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> migrados — ${d.errores?.length || 0} lote(s) fallaron.
${errHTML}
${resumeFrom != null ? `<button onclick="ejecutarSyncDiag(${resumeFrom})" class="mt-3 flex items-center gap-1 bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium px-3 py-1.5 rounded-lg">
<i class="fas fa-play text-xs"></i> Reanudar desde #${resumeFrom.toLocaleString()}
</button>` : ''}
</div>`;
}
} catch(e) {
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-upload text-xs"></i> Migrar diagnósticos';
}
}
function toggleDet(id) {
const row = document.getElementById(id);
if (row) row.classList.toggle('hidden');
}
async function ejecutarSyncNow() {
const btn = document.getElementById('btn-sync-now');
const ventana = document.getElementById('sel-ventana').value;
const resBox = document.getElementById('sync-now-result');
const inner = document.getElementById('sync-now-inner');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Sincronizando…';
resBox.classList.remove('hidden');
inner.innerHTML = '<p class="text-sm text-gray-400">Ejecutando…</p>';
try {
const resp = await fetch(`/envios/erp/sync-now?ventana_min=${ventana}`, { method: 'POST' });
const d = await resp.json();
if (!d.ok) {
inner.innerHTML = `
<div class="flex items-center gap-2 text-red-600 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm">
<i class="fas fa-exclamation-circle"></i>
<span>${d.error || 'Error desconocido'}</span>
</div>`;
} else if (d.total === 0) {
inner.innerHTML = `
<div class="flex items-center gap-2 text-gray-500 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-sm">
<i class="fas fa-info-circle text-blue-400"></i>
<span>${d.mensaje || 'Sin recepciones en la ventana seleccionada.'} (Hoy: <strong>${d.total_hoy}</strong> recepción/es registradas)</span>
</div>`;
} else {
const detalles = (d.detalle || []).map(p => {
const a = p.action || (p.ok ? 'updated' : 'error');
const badge = a === 'created' ? '<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
: a === 'updated' ? '<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
: a === 'skipped' ? '<span class="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">Omitido</span>'
: `<span class="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded" title="${p.message || ''}">${a}</span>`;
return `<div class="flex items-center justify-between py-1 border-b border-gray-50 text-xs">
<span class="text-gray-700">${p.nombre || p.doc || '—'}</span>
${badge}
</div>`;
}).join('');
inner.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mb-3">
<div class="flex flex-wrap gap-4 text-sm">
<span class="text-green-700 font-semibold"><i class="fas fa-check-circle mr-1"></i>${d.total} procesados</span>
<span class="text-blue-600">${d.created} nuevos</span>
<span class="text-green-600">${d.updated} actualizados</span>
<span class="text-gray-400">${d.skipped} omitidos</span>
${d.errores > 0 ? `<span class="text-red-500 font-medium">${d.errores} errores</span>` : ''}
<span class="text-gray-400 ml-auto text-xs">Hoy: ${d.total_hoy} recepciones</span>
</div>
</div>
${detalles ? `<div class="max-h-48 overflow-y-auto pr-1">${detalles}</div>` : ''}`;
}
} catch (e) {
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-bolt text-xs"></i> Ejecutar ahora';
}
}
async function cargarHistorialScheduler() {
const el = document.getElementById('scheduler-hist');
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
try {
const resp = await fetch('/pacientes/historial');
const rows = await resp.json();
const sched = rows.filter(r => r.origen === 'scheduler');
if (!sched.length) {
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin ejecuciones automáticas registradas aún.</p>';
return;
}
el.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
<th class="pb-2 pr-4 font-medium">Fecha</th>
<th class="pb-2 pr-3 font-medium">Usuario</th>
<th class="pb-2 pr-3 font-medium text-right">Procesados</th>
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
<th class="pb-2 font-medium text-right">Detalle</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
${sched.map((r, i) => {
const dt = new Date(r.created_at.replace(' ', 'T') + 'Z');
const fecha = dt.toLocaleString('es-CO', {
timeZone: 'America/Bogota',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
});
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
const pacientes = r.detalle_json ? JSON.parse(r.detalle_json) : [];
const rowId = 'hist-det-' + i;
const detHtml = pacientes.map((p, pi) => {
const badge = p.action === 'created' ? '<span class="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
: p.action === 'updated' ? '<span class="bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
: p.action === 'skipped' ? '<span class="bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">Omitido</span>'
: `<span class="bg-red-100 text-red-600 px-1.5 py-0.5 rounded">${p.action}</span>`;
const diag = p.diagnostico_cod
? `<span class="text-orange-600">${p.diagnostico_cod}</span>${p.diagnostico_nombre ? ' · ' + p.diagnostico_nombre : ''}`
: '<span class="text-gray-300">Sin diagnóstico</span>';
const empresa = (p.nom_empresa || p.nit_empresa)
? `<span class="text-teal-600">${p.nom_empresa || p.nit_empresa}</span>`
: '<span class="text-gray-300">Sin empresa</span>';
const valor = p.valor_total > 0
? `<span class="text-yellow-700">$${Number(p.valor_total).toLocaleString('es-CO')}</span>`
: '';
const exams = (p.examenes_det || []).map(e =>
`<tr class="border-b border-gray-50">
<td class="py-0.5 pr-3 font-mono text-purple-600">${e.cups || '—'}</td>
<td class="py-0.5 pr-3 text-gray-600">${e.nombre || '—'}</td>
<td class="py-0.5 text-right text-gray-500">${e.precio != null ? '$' + Number(e.precio).toLocaleString('es-CO') : '—'}</td>
</tr>`
).join('');
return `<div class="mb-3 border border-gray-100 rounded-lg overflow-hidden">
<div class="flex items-center gap-3 px-3 py-2 bg-gray-50 border-b border-gray-100">
<span class="text-gray-400 font-mono text-xs">${p.doc}</span>
<span class="font-medium text-gray-700 flex-1">${p.nombre}</span>
${badge}
</div>
<div class="px-3 py-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-xs border-b border-gray-50">
<span>Dx: ${diag}</span>
<span>EPS: ${empresa}</span>
${valor ? `<span>Total: ${valor}</span>` : ''}
</div>
${exams ? `<table class="w-full text-xs px-3">
<thead><tr class="text-gray-400 border-b border-gray-100">
<th class="py-1 pr-3 text-left font-medium px-3">CUPS</th>
<th class="py-1 pr-3 text-left font-medium">Examen</th>
<th class="py-1 text-right font-medium pr-3">Precio</th>
</tr></thead>
<tbody class="px-3">${exams}</tbody>
</table>` : '<p class="text-xs text-gray-300 px-3 py-1">Sin exámenes registrados</p>'}
</div>`;
}).join('');
return `
<tr class="hover:bg-gray-50">
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
<td class="py-2 pr-3 text-xs text-gray-400">${r.username || '—'}</td>
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
<td class="py-2 pr-3 text-right">
${r.errores > 0
? `<button onclick="toggleDet('${rowId}')" class="text-red-500 font-medium hover:underline">${r.errores}</button>`
: '<span class="text-gray-300">—</span>'}
</td>
<td class="py-2 text-right">
${pacientes.length ? `
<button onclick="toggleDet('${rowId}')"
class="text-xs text-blue-500 hover:underline">Ver ${pacientes.length}</button>` : '—'}
</td>
</tr>
${(pacientes.length || errDet.length) ? `
<tr id="${rowId}" class="hidden">
<td colspan="7" class="px-3 pb-4 pt-2">
${errDet.length ? `
<div class="mb-3 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-xs text-red-700">
<p class="font-medium mb-1"><i class="fas fa-exclamation-circle mr-1"></i>${errDet.length} error(es):</p>
<ul class="list-disc ml-4 space-y-0.5">${errDet.map(e =>
typeof e === 'object'
? `<li><span class="font-mono text-red-400">${e.doc || ''}</span> ${e.nombre || ''}${e.msg || JSON.stringify(e)}</li>`
: `<li>${e}</li>`
).join('')}</ul>
</div>` : ''}
<div class="max-h-96 overflow-y-auto space-y-2 text-xs">
${detHtml}
</div>
</td>
</tr>` : ''}`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
}
}
(function init() {
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
});
const saved = sessionStorage.getItem('erp-tab') || 'pacientes';
switchTab(saved);
})();
</script>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Módulo TNS{% endblock %}
{% block header %}<i class="fas fa-paper-plane mr-2 text-blue-400"></i> Módulo TNS{% endblock %}
{% block content %}
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
<!-- Barra de sub-tabs -->
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
<div class="flex">
<button id="btn-terceros" onclick="switchTab('terceros', '/terceros?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-user text-xs"></i> Terceros
</button>
<button id="btn-transaccion" onclick="switchTab('transaccion', '/transaccion?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-exchange-alt text-xs"></i> Transacción RIPS
</button>
<button id="btn-ventas" onclick="switchTab('ventas', '/ventas?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-file-invoice-dollar text-xs"></i> Facturas Venta
</button>
<button id="btn-rda" onclick="switchTab('rda', '/test-rda?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-flask text-xs"></i> Prueba RDA
</button>
<button id="btn-automation" onclick="switchTab('automation', '/automation?embed=1')"
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
<i class="fas fa-robot text-xs"></i> Automatización
</button>
</div>
</div>
<!-- Paneles de iframe -->
<div class="flex-1 relative overflow-hidden bg-gray-50">
<div id="panel-terceros" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-terceros" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-transaccion" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-transaccion" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-ventas" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-ventas" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-rda" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-rda" class="w-full h-full border-0"></iframe>
</div>
<div id="panel-automation" class="tab-panel absolute inset-0 hidden">
<iframe id="iframe-automation" class="w-full h-full border-0"></iframe>
</div>
</div>
</div>
<script>
const _TAB_ACTIVE = 'border-blue-500 text-blue-600 bg-blue-50';
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
function switchTab(name, src) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
b.classList.add(..._TAB_INACTIVE.split(' '));
});
const panel = document.getElementById('panel-' + name);
const btn = document.getElementById('btn-' + name);
const iframe = document.getElementById('iframe-' + name);
panel.classList.remove('hidden');
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
btn.classList.add(..._TAB_ACTIVE.split(' '));
if (src && !iframe.src) {
iframe.src = src;
}
sessionStorage.setItem('tns-tab', name);
sessionStorage.setItem('tns-src-' + name, src);
}
(function init() {
// Establecer clases base en todos los botones
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
});
const saved = sessionStorage.getItem('tns-tab') || 'terceros';
const srcMap = {
terceros: '/terceros?embed=1',
transaccion:'/transaccion?embed=1',
ventas: '/ventas?embed=1',
rda: '/test-rda?embed=1',
automation: '/automation?embed=1',
};
switchTab(saved, srcMap[saved] || srcMap.terceros);
})();
</script>
{% endblock %}
+59 -18
View File
@@ -10,11 +10,12 @@
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3"> <div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<span class="px-2 py-0.5 rounded text-xs font-medium <span class="px-2 py-0.5 rounded text-xs font-medium
{% if s.tipo == 'transaccion' %}bg-purple-100 text-purple-700 {% if s.tipo == 'transaccion' %}bg-purple-100 text-purple-700
{% elif s.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
{% else %}bg-blue-100 text-blue-700{% endif %}">{{ s.tipo }}</span> {% else %}bg-blue-100 text-blue-700{% endif %}">{{ s.tipo }}</span>
<span class="{% if s.status == 'success' %}text-green-600{% else %}text-red-600{% endif %} font-bold text-sm"> <span class="{% if s.status == 'success' %}text-green-600{% elif s.status == 'warning' %}text-yellow-600{% else %}text-red-600{% endif %} font-bold text-sm">
{{ s.cnt }} {{ s.cnt }}
</span> </span>
<span class="text-gray-400 text-xs">{{ 'exitosos' if s.status == 'success' else 'errores' }}</span> <span class="text-gray-400 text-xs">{{ 'enviados' if s.status == 'success' else ('ya existe' if s.status == 'warning' else 'errores') }}</span>
</div> </div>
{% endfor %} {% endfor %}
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-2 text-gray-500 text-sm"> <div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-2 text-gray-500 text-sm">
@@ -34,14 +35,16 @@
<option value="">Todos los tipos</option> <option value="">Todos los tipos</option>
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option> <option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option> <option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
<option value="ventas" {{ 'selected' if filtro_tipo == 'ventas' }}>Ventas</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-500 mb-1">Estado</label> <label class="block text-xs font-medium text-gray-500 mb-1">Estado</label>
<select name="status" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[120px]"> <select name="status" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[120px]">
<option value="">Todos</option> <option value="">Todos</option>
<option value="success" {{ 'selected' if filtro_status == 'success' }}>Exitoso</option> <option value="success" {{ 'selected' if filtro_status == 'success' }}>✓ Enviado</option>
<option value="error" {{ 'selected' if filtro_status == 'error' }}>Error</option> <option value="warning" {{ 'selected' if filtro_status == 'warning' }}>! Ya existe</option>
<option value="error" {{ 'selected' if filtro_status == 'error' }}>✗ Error</option>
</select> </select>
</div> </div>
<div> <div>
@@ -60,8 +63,8 @@
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-28"> class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-28">
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-500 mb-1">IDRECEP / Contrato</label> <label class="block text-xs font-medium text-gray-500 mb-1">Cédula paciente</label>
<input type="text" name="paciente" value="{{ filtro_paciente }}" placeholder="ID o contrato" <input type="text" name="paciente" value="{{ filtro_paciente }}" placeholder="Nro. documento"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-36"> class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm w-36">
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -107,11 +110,12 @@
<tbody> <tbody>
{% for e in envios %} {% for e in envios %}
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors <tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
{% if e.status == 'success' %}bg-green-50/25{% elif e.status == 'error' %}bg-red-50/25{% endif %}"> {% if e.status == 'success' %}bg-green-50/25{% elif e.status == 'warning' %}bg-yellow-50/40{% elif e.status == 'error' %}bg-red-50/25{% endif %}">
<td class="px-4 py-2.5 text-gray-400 font-mono">{{ e.id }}</td> <td class="px-4 py-2.5 text-gray-400 font-mono">{{ e.id }}</td>
<td class="px-2 py-2.5"> <td class="px-2 py-2.5">
<span class="px-2 py-0.5 rounded text-xs font-medium <span class="px-2 py-0.5 rounded text-xs font-medium
{% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700 {% if e.tipo == 'terceros' %}bg-blue-100 text-blue-700
{% elif e.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
{% else %}bg-purple-100 text-purple-700{% endif %}"> {% else %}bg-purple-100 text-purple-700{% endif %}">
{{ e.tipo }} {{ e.tipo }}
</span> </span>
@@ -120,11 +124,13 @@
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ e.idrecepcion or '-' }}</td> <td class="px-2 py-2.5 text-gray-500 font-mono">{{ e.idrecepcion or '-' }}</td>
<td class="px-2 py-2.5 text-gray-500">{{ e.contrato or '-' }}</td> <td class="px-2 py-2.5 text-gray-500">{{ e.contrato or '-' }}</td>
<td class="px-2 py-2.5"> <td class="px-2 py-2.5">
<span class="px-2 py-0.5 rounded text-xs font-medium {% if e.status == 'success' %}
{% if e.status == 'success' %}bg-green-100 text-green-700 <span class="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>
{% else %}bg-red-100 text-red-700{% endif %}"> {% elif e.status == 'warning' %}
{{ '✓ OK' if e.status == 'success' else '✗ Error' }} <span class="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">! Ya existe</span>
</span> {% else %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">✗ Error</span>
{% endif %}
</td> </td>
<td class="px-2 py-2.5 text-gray-500"> <td class="px-2 py-2.5 text-gray-500">
{% if e.mensaje_tns %} {% if e.mensaje_tns %}
@@ -135,11 +141,17 @@
</td> </td>
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ e.created_at[:16] if e.created_at else '-' }}</td> <td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ e.created_at[:16] if e.created_at else '-' }}</td>
<td class="px-2 py-2.5 text-gray-400">{{ e.username or '-' }}</td> <td class="px-2 py-2.5 text-gray-400">{{ e.username or '-' }}</td>
<td class="px-2 py-2.5"> <td class="px-2 py-2.5 flex items-center gap-1.5">
<button onclick="verDetalle({{ e.id }})" <button onclick="verDetalle({{ e.id }})"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle completo"> class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</button> </button>
{% if e.status == 'error' and e.json_enviado %}
<button onclick="reenviar({{ e.id }}, this)"
class="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 rounded" title="Reenviar a TNS">
<i class="fas fa-redo text-xs"></i>
</button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -204,14 +216,43 @@
</div> </div>
<script> <script>
async function reenviar(id, btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
try {
const resp = await fetch(`/logs/reenviar/${id}`, {method: 'POST', credentials: 'include'});
const data = await resp.json();
if (data.success) {
showToast('Reenvío exitoso', 'success');
const row = btn.closest('tr');
row.classList.remove('bg-red-50/25');
row.classList.add('bg-green-50/25');
const badge = row.querySelector('td:nth-child(6) span');
if (badge) {
badge.textContent = '✓ OK';
badge.className = 'px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700';
}
btn.remove();
} else {
showToast(data.message || 'Error al reenviar', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
} catch(e) {
showToast('Error de conexión', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
}
async function verDetalle(id) { async function verDetalle(id) {
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'}); const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
const data = await resp.json(); const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; } if (data.error) { showToast(data.error, 'error'); return; }
const estadoLabel = data.status === 'success' ? '✓ Exitoso' : '✗ Error'; const estadoLabel = data.status === 'success' ? '✓ Enviado' : data.status === 'warning' ? '! Ya existe' : '✗ Error';
document.getElementById('modal-titulo').innerHTML = document.getElementById('modal-titulo').innerHTML =
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : 'text-red-600'}">${estadoLabel}</span>`; `<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : data.status === 'warning' ? 'text-yellow-600' : 'text-red-600'}">${estadoLabel}</span>`;
const info = [ const info = [
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '', data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
@@ -266,8 +307,8 @@ function syntaxHighlight(obj) {
return json return json
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>') .replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>') .replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
.replace(/:(\s*)(\d+(?:\.\d+)?)/g,': $1<span class="text-orange-600">$2</span>') .replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
.replace(/:(\s*)(null|true|false)/g,': $1<span class="text-purple-600">$2</span>'); .replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
} }
</script> </script>
{% endblock %} {% endblock %}
+277
View File
@@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block title %}Pacientes — Sync WhatsApp{% endblock %}
{% block header %}Pacientes — Sincronizar con WhatsApp Lab{% endblock %}
{% block content %}
<div class="max-w-3xl space-y-6">
{% if not wa_configurado %}
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
<i class="fas fa-exclamation-triangle text-yellow-500 mt-0.5"></i>
<div>
<p class="font-medium text-yellow-800">WhatsApp Lab no está configurado</p>
<p class="text-sm text-yellow-700 mt-1">
Ve a <a href="/config" class="underline">Configuración</a> y completa la
<strong>URL de WhatsApp Lab</strong> y la <strong>API Key</strong>.
</p>
</div>
</div>
{% endif %}
<!-- Sync masiva -->
<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-sync-alt mr-2 text-green-500"></i>Sincronizar pacientes a WhatsApp Lab
</h3>
<p class="text-sm text-gray-500 mt-1">
Destino: <code class="bg-gray-100 px-1 rounded text-xs">{{ wa_url or "—" }}/api/lab/ingest_paciente.php</code>
</p>
</div>
<div class="p-6 space-y-5">
<form id="syncForm" class="space-y-4">
<div class="flex items-center gap-3">
<input type="checkbox" id="todosCheck" name="todos" value="1"
class="w-4 h-4 text-blue-600 rounded border-gray-300">
<label for="todosCheck" class="text-sm font-medium text-gray-700">
Sincronizar <strong>todos</strong> los pacientes (sin filtro de fecha)
</label>
</div>
<div id="fechaFields" class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
<input type="date" id="fecha_ini" name="fecha_ini"
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-sm font-medium text-gray-700 mb-1">Fecha fin</label>
<input type="date" id="fecha_fin" name="fecha_fin"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
</div>
</div>
<button type="button" onclick="runSync()"
id="btnSync"
{% if not wa_configurado %}disabled{% endif %}
class="px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm">
<i class="fas fa-upload mr-2"></i> Iniciar sincronización
</button>
</form>
<!-- Progreso -->
<div id="syncProgress" class="hidden">
<div class="flex items-center gap-3 text-sm text-gray-600">
<i class="fas fa-spinner fa-spin text-blue-500"></i>
<span>Sincronizando pacientes con WhatsApp Lab…</span>
</div>
<p class="text-xs text-gray-400 mt-1">Esto puede tomar varios segundos según la cantidad de pacientes.</p>
</div>
<!-- Resultado -->
<div id="syncResult" class="hidden"></div>
</div>
</div>
<!-- Historial -->
<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-history mr-2 text-gray-400"></i>Historial de sincronizaciones
</h3>
<button onclick="cargarHistorial()" class="text-sm text-blue-600 hover:underline">
<i class="fas fa-refresh mr-1"></i>Actualizar
</button>
</div>
<div id="historialContainer" class="p-4">
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
</div>
</div>
<!-- Info sobre la API -->
<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-info-circle mr-2 text-blue-400"></i>Cómo funciona
</h3>
</div>
<div class="p-6 text-sm text-gray-600 space-y-3">
<div class="flex gap-3">
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</span>
<p>Se consultan los pacientes en Firebird (<code class="bg-gray-100 px-1 rounded">PACIENTE</code>)
filtrados por rango de fecha de recepción (o todos si marcas la opción).</p>
</div>
<div class="flex gap-3">
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</span>
<p>Cada paciente se envía a WhatsApp Lab via <code class="bg-gray-100 px-1 rounded">POST /api/lab/ingest_paciente.php</code>
con el header <code class="bg-gray-100 px-1 rounded">X-Lab-Key</code>.</p>
</div>
<div class="flex gap-3">
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</span>
<p>El endpoint hace <strong>UPSERT</strong>: si el paciente ya existe por número de documento lo actualiza,
si no existe lo crea en <code class="bg-gray-100 px-1 rounded">lab_pacientes</code>.</p>
</div>
<div class="flex gap-3">
<span class="w-6 h-6 bg-green-100 text-green-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"></span>
<p>La sync también se dispara <strong>automáticamente</strong> al enviar un Tercero individual
o al ejecutar la Automatización RIPS.</p>
</div>
</div>
</div>
</div>
<script>
document.getElementById('todosCheck').addEventListener('change', function () {
document.getElementById('fechaFields').style.display = this.checked ? 'none' : '';
});
async function runSync() {
const btn = document.getElementById('btnSync');
const prog = document.getElementById('syncProgress');
const result = document.getElementById('syncResult');
const form = document.getElementById('syncForm');
btn.disabled = true;
prog.classList.remove('hidden');
result.classList.add('hidden');
result.innerHTML = '';
const data = new FormData(form);
try {
const resp = await fetch('/pacientes/sync-all', { method: 'POST', body: data });
const json = await resp.json();
prog.classList.add('hidden');
result.classList.remove('hidden');
if (json.success) {
const errColor = json.errores > 0 ? 'text-red-600' : 'text-gray-500';
result.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
<p class="font-medium text-green-800"><i class="fas fa-check-circle mr-2"></i>${json.message}</p>
<div class="grid grid-cols-4 gap-3 text-center text-sm">
<div class="bg-white rounded-lg p-3 border border-green-100">
<p class="text-2xl font-bold text-gray-800">${json.total}</p>
<p class="text-gray-500">Total</p>
</div>
<div class="bg-white rounded-lg p-3 border border-green-100">
<p class="text-2xl font-bold text-blue-600">${json.created}</p>
<p class="text-gray-500">Nuevos</p>
</div>
<div class="bg-white rounded-lg p-3 border border-green-100">
<p class="text-2xl font-bold text-gray-400">${json.skipped ?? 0}</p>
<p class="text-gray-500">Ya existían</p>
</div>
<div class="bg-white rounded-lg p-3 border border-green-100">
<p class="text-2xl font-bold text-red-500">${json.errores}</p>
<p class="text-gray-500">Errores</p>
</div>
</div>
${json.errores > 0 ? `<p class="text-sm text-red-600"><i class="fas fa-exclamation-triangle mr-1"></i>${json.errores} errores — revisa los detalles abajo.</p>` : ''}
${renderDetalle(json.detalle || [])}
</div>`;
} else {
result.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="font-medium text-red-800"><i class="fas fa-times-circle mr-2"></i>${json.message}</p>
</div>`;
}
} catch (e) {
prog.classList.add('hidden');
result.classList.remove('hidden');
result.innerHTML = `<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">Error de conexión: ${e.message}</div>`;
}
btn.disabled = false;
}
function renderDetalle(detalle) {
const errores = detalle.filter(d => !d.ok);
if (!errores.length) return '';
return `
<details class="mt-2">
<summary class="text-sm text-red-600 cursor-pointer">Ver ${errores.length} errores</summary>
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
${errores.map(d => `
<div class="flex gap-2 text-xs text-red-700 bg-red-50 rounded px-2 py-1">
<span class="font-mono">${d.doc || '—'}</span>
<span class="text-gray-500">${d.nombre || ''}</span>
<span class="ml-auto">${d.message || ''}</span>
</div>`).join('')}
</div>
</details>`;
}
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', automation: 'Automatización' };
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
async function cargarHistorial() {
const el = document.getElementById('historialContainer');
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
try {
const resp = await fetch('/pacientes/historial');
const rows = await resp.json();
if (!rows.length) {
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin registros aún.</p>';
return;
}
el.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
<th class="pb-2 pr-4 font-medium">Fecha</th>
<th class="pb-2 pr-4 font-medium">Origen</th>
<th class="pb-2 pr-4 font-medium">Modo</th>
<th class="pb-2 pr-3 font-medium text-right">Total</th>
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
<th class="pb-2 pr-3 font-medium text-right text-gray-400">Omitidos</th>
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
<th class="pb-2 font-medium">Usuario</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
${rows.map(r => {
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
return `
<tr class="hover:bg-gray-50">
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
<td class="py-2 pr-4">
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${
r.origen === 'manual' ? 'bg-blue-50 text-blue-700' :
r.origen === 'tercero' ? 'bg-orange-50 text-orange-700' :
'bg-purple-50 text-purple-700'
}">${_origenLabel[r.origen] || r.origen}</span>
</td>
<td class="py-2 pr-4 text-gray-500 text-xs">${_modoLabel[r.modo] || r.modo}</td>
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
<td class="py-2 pr-3 text-right text-gray-400">${r.skipped}</td>
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
<td class="py-2 pr-3 text-right ${r.errores > 0 ? 'text-red-500 font-medium' : 'text-gray-300'}">
${r.errores > 0 && errDet.length ? `
<details>
<summary class="cursor-pointer">${r.errores}</summary>
<div class="absolute z-10 bg-white border border-red-100 rounded-lg shadow-lg p-2 text-xs mt-1 max-w-xs">
${errDet.map(e => `<div class="text-red-600">${e.doc}${e.msg}</div>`).join('')}
</div>
</details>` : r.errores || '—'}
</td>
<td class="py-2 text-gray-400 text-xs">${r.username || '—'}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
}
}
// Cargar historial al abrir la página
cargarHistorial();
</script>
{% endblock %}
+274
View File
@@ -0,0 +1,274 @@
{% extends "base.html" %}
{% block title %}Resumen Diario{% endblock %}
{% block header %}Resumen Diario TNS{% endblock %}
{% block content %}
<!-- Stats -->
<div class="flex flex-wrap gap-3 mb-5">
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-layer-group text-gray-400 text-sm"></i>
<span class="font-bold text-sm text-gray-700">{{ total }}</span>
<span class="text-gray-400 text-xs">facturas procesadas</span>
</div>
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-check-circle text-green-500 text-sm"></i>
<span class="font-bold text-sm text-green-600">{{ exitosos }}</span>
<span class="text-gray-400 text-xs">exitosas</span>
</div>
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 shadow-sm flex items-center gap-3">
<i class="fas fa-times-circle text-red-500 text-sm"></i>
<span class="font-bold text-sm text-red-600">{{ errores }}</span>
<span class="text-gray-400 text-xs">error persistente</span>
</div>
</div>
<!-- Filtros -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
<form method="get" action="/logs/resumen" class="p-4">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Fecha</label>
<input type="date" name="fecha" value="{{ fecha }}"
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Tipo</label>
<select name="tipo" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
<option value="">Todos</option>
<option value="terceros" {{ 'selected' if filtro_tipo == 'terceros' }}>Terceros</option>
<option value="transaccion" {{ 'selected' if filtro_tipo == 'transaccion' }}>Transacción</option>
<option value="ventas" {{ 'selected' if filtro_tipo == 'ventas' }}>Ventas</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-search mr-1"></i> Consultar
</button>
<a href="/logs/resumen" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Hoy">
<i class="fas fa-calendar-day"></i>
</a>
</div>
</div>
</form>
</div>
<!-- Tabla -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-6 py-3 border-b border-gray-100 flex items-center justify-between">
<span class="text-xs text-gray-500">
{% if total %}{{ total }} registro{{ 's' if total != 1 }} — {{ fecha }}{% else %}Sin registros para {{ fecha }}{% endif %}
</span>
<span class="text-xs text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Estado final: si hubo al menos un envío exitoso, se marca como exitoso
</span>
</div>
{% if rows %}
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-gray-400 border-b border-gray-200 bg-gray-50/60">
<th class="px-4 py-3 font-medium">Tipo</th>
<th class="px-2 py-3 font-medium">Factura</th>
<th class="px-2 py-3 font-medium">IDRECEP.</th>
<th class="px-2 py-3 font-medium">Contrato</th>
<th class="px-2 py-3 font-medium">Cédula</th>
<th class="px-2 py-3 font-medium text-center">Intentos</th>
<th class="px-2 py-3 font-medium">Estado final</th>
<th class="px-2 py-3 font-medium">Primer envío</th>
<th class="px-2 py-3 font-medium">Último envío</th>
<th class="px-2 py-3"></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="border-b border-gray-50 hover:bg-gray-50 transition-colors
{% if r.estado_final == 'success' %}bg-green-50/20
{% elif r.estado_final == 'warning' %}bg-yellow-50/30
{% else %}bg-red-50/20{% endif %}"
data-error-id="{{ r.ultimo_error_id or '' }}"
data-exitoso-id="{{ r.exitoso_id or '' }}">
<td class="px-4 py-2.5">
<span class="px-2 py-0.5 rounded text-xs font-medium
{% if r.tipo == 'terceros' %}bg-blue-100 text-blue-700
{% elif r.tipo == 'ventas' %}bg-emerald-100 text-emerald-700
{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ r.tipo }}
</span>
</td>
<td class="px-2 py-2.5 text-gray-700 font-medium">{{ '—' if r.tipo == 'terceros' else (r.factura or '—') }}</td>
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.idrecepcion or '—' }}</td>
<td class="px-2 py-2.5 text-gray-500">{{ r.contrato or '—' }}</td>
<td class="px-2 py-2.5 text-gray-500 font-mono">{{ r.cedula or '—' }}</td>
<td class="px-2 py-2.5 text-center">
<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-mono">{{ r.intentos }}</span>
{% if r.exitos > 0 and r.intentos > r.exitos %}
<span class="text-gray-400 text-[10px] ml-0.5">({{ r.exitos }} ok)</span>
{% endif %}
</td>
<td class="px-2 py-2.5">
{% if r.estado_final == 'success' %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">✓ Enviado</span>
{% elif r.estado_final == 'warning' %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">! Ya existe</span>
{% else %}
<span class="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">✗ Error persistente</span>
{% endif %}
</td>
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.primer_envio[:16] if r.primer_envio else '—' }}</td>
<td class="px-2 py-2.5 text-gray-400 whitespace-nowrap">{{ r.ultimo_envio[:16] if r.ultimo_envio else '—' }}</td>
<td class="px-2 py-2.5 flex items-center gap-1.5">
{% set ver_id = r.exitoso_id or r.ultimo_error_id %}
{% if ver_id %}
<button onclick="verDetalle({{ ver_id }})"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 rounded" title="Ver detalle">
<i class="fas fa-eye"></i>
</button>
{% endif %}
{% if r.estado_final == 'error' and r.ultimo_error_id %}
<button onclick="reenviar({{ r.ultimo_error_id }}, this)"
class="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700 rounded" title="Reenviar a TNS">
<i class="fas fa-redo text-xs"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<i class="fas fa-calendar-times text-5xl mb-4 block opacity-30"></i>
<p class="text-sm">Sin envíos registrados para {{ fecha }}</p>
</div>
{% endif %}
</div>
<!-- Modal detalle (idéntico al de logs.html) -->
<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-[88vh] 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 text-sm"></h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div id="modal-info" class="px-6 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 text-xs text-gray-500 flex flex-wrap gap-4"></div>
<div class="flex border-b border-gray-200 shrink-0 px-4">
<button onclick="showTab('t-json')" id="btn-t-json"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-500 text-blue-600 -mb-px">
JSON enviado</button>
<button onclick="showTab('t-tns')" id="btn-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>
<button onclick="showTab('t-msg')" id="btn-t-msg"
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">
Mensaje</button>
</div>
<div class="overflow-y-auto flex-1 p-5">
<pre id="t-json" class="tab-pane text-xs font-mono bg-gray-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap"></pre>
<pre id="t-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>
<pre id="t-msg" class="tab-pane hidden text-xs font-mono bg-blue-50 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap text-blue-900"></pre>
</div>
</div>
</div>
<script>
async function reenviar(id, btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i>';
try {
const resp = await fetch(`/logs/reenviar/${id}`, {method: 'POST', credentials: 'include'});
const data = await resp.json();
if (data.success) {
showToast('Reenvío exitoso', 'success');
const row = btn.closest('tr');
row.classList.remove('bg-red-50/20');
row.classList.add('bg-green-50/20');
const badge = row.querySelector('td:nth-child(7) span');
if (badge) {
badge.textContent = '✓ Enviado';
badge.className = 'px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700';
}
btn.remove();
} else {
showToast(data.message || 'Error al reenviar', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
} catch(e) {
showToast('Error de conexión', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo text-xs"></i>';
}
}
async function verDetalle(id) {
const resp = await fetch(`/logs/detalle/${id}`, {credentials: 'include'});
const data = await resp.json();
if (data.error) { showToast(data.error, 'error'); return; }
const estadoLabel = data.status === 'success' ? '✓ Enviado' : data.status === 'warning' ? '! Ya existe' : '✗ Error';
document.getElementById('modal-titulo').innerHTML =
`<i class="fas fa-file-alt mr-2 text-blue-500"></i>Envío #${data.id} — <b>${data.tipo}</b> — <span class="${data.status === 'success' ? 'text-green-600' : data.status === 'warning' ? 'text-yellow-600' : 'text-red-600'}">${estadoLabel}</span>`;
const info = [
data.factura ? `<span><b>Factura:</b> ${data.factura}</span>` : '',
data.idrecepcion ? `<span><b>IDRECEPCION:</b> ${data.idrecepcion}</span>` : '',
data.contrato ? `<span><b>Contrato:</b> ${data.contrato}</span>` : '',
(data.fecha_inicio && data.fecha_fin) ? `<span><b>Período:</b> ${data.fecha_inicio}${data.fecha_fin}</span>` : '',
data.username ? `<span><b>Usuario:</b> ${data.username}</span>` : '',
data.created_at ? `<span><b>Enviado:</b> ${data.created_at.slice(0, 16)}</span>` : '',
].filter(Boolean).join('');
document.getElementById('modal-info').innerHTML = info;
const jsonPre = document.getElementById('t-json');
if (data.json_enviado) {
try { jsonPre.innerHTML = syntaxHighlight(JSON.parse(data.json_enviado)); }
catch(e) { jsonPre.textContent = data.json_enviado; }
} else {
jsonPre.textContent = '(sin JSON guardado)';
}
const tnsPre = document.getElementById('t-tns');
const raw = data.respuesta_api || '(sin respuesta)';
try { tnsPre.textContent = JSON.stringify(JSON.parse(raw), null, 2); }
catch(e) { tnsPre.textContent = raw; }
document.getElementById('t-msg').textContent = data.mensaje_tns || '(sin mensaje)';
showTab('t-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-blue-500','text-blue-600');
b.classList.add('border-transparent','text-gray-500');
});
document.getElementById(tabId).classList.remove('hidden');
const btn = document.getElementById('btn-' + tabId);
if (btn) {
btn.classList.add('border-blue-500','text-blue-600');
btn.classList.remove('border-transparent','text-gray-500');
}
}
function closeModal() {
document.getElementById('modal-detalle').classList.add('hidden');
}
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 %}
+2 -2
View File
@@ -123,8 +123,8 @@ function syntaxHighlight(obj) {
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return json.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g, '<span class="text-blue-600">$1</span>') return json.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g, '<span class="text-blue-600">$1</span>')
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g, ': $1<span class="text-green-600">$2</span>') .replace(/:(\s*)("(?:[^"\\]|\\.)*")/g, ': $1<span class="text-green-600">$2</span>')
.replace(/:(\s*)(\d+)/g, ': $1<span class="text-orange-600">$2</span>') .replace(/: (\d+)/g, ': <span class="text-orange-600">$1</span>')
.replace(/:(\s*)(null|true|false)/g, ': $1<span class="text-purple-600">$2</span>'); .replace(/: (null|true|false)/g, ': <span class="text-purple-600">$1</span>');
} }
</script> </script>
{% endblock %} {% endblock %}
+95 -4
View File
@@ -47,6 +47,43 @@
</div> </div>
</div> </div>
<!-- Enviar RDA directo -->
<div class="bg-white rounded-xl shadow-sm border border-indigo-200">
<div class="px-6 py-4 border-b border-indigo-100 flex items-center gap-2">
<i class="fas fa-bolt text-indigo-500"></i>
<h3 class="font-semibold text-gray-800">Enviar RDA directo</h3>
</div>
<div class="p-5 space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Consulta SQL</label>
<select id="d-query" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">Seleccionar...</option>
{% for q in queries %}
<option value="{{ q.id }}">{{ q.name }}</option>
{% endfor %}
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo</label>
<input id="d-prefijo" type="text" placeholder="LHXC"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm uppercase"
style="text-transform:uppercase">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Número</label>
<input id="d-numero" type="text" placeholder="4358"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
</div>
<button onclick="enviarDirecto()" id="btn-directo"
class="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">
<i class="fas fa-paper-plane mr-1"></i> Enviar ahora
</button>
<div id="directo-result" class="hidden"></div>
</div>
</div>
<!-- Resumen --> <!-- Resumen -->
<div id="resumen-box" class="hidden bg-white rounded-xl shadow-sm border border-gray-200 p-5"> <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="grid grid-cols-3 gap-2 text-center mb-4">
@@ -215,7 +252,7 @@ function filaHtml(item) {
const at = (env.at || '').slice(0, 16).replace('T', ' '); 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>`; 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'; rowCls = 'bg-green-50/50 opacity-80';
accionHtml = ''; 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 { } else {
const tip = escAttr(env.mensaje || 'Error'); 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>`; badgeHtml = `<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="${tip}">✗ Error</span>`;
@@ -263,7 +300,7 @@ function marcarFila(id, ok, mensaje, rawTns) {
if (ok) { if (ok) {
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-green-50/50 opacity-80'; 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>`; 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 = ''; 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 { } else {
row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50'; row.className = row.className.replace(/bg-red-50\/50|bg-green-50\/50/g, '') + ' bg-red-50/50';
const tip = escAttr(mensaje || 'Error'); const tip = escAttr(mensaje || 'Error');
@@ -351,6 +388,60 @@ function closeModal() {
document.getElementById('modal-detalle').classList.add('hidden'); document.getElementById('modal-detalle').classList.add('hidden');
} }
async function enviarDirecto() {
const qid = document.getElementById('d-query').value;
const prefijo = document.getElementById('d-prefijo').value.trim().toUpperCase();
const numero = document.getElementById('d-numero').value.trim();
const resultBox = document.getElementById('directo-result');
if (!qid) { showToast('Selecciona una consulta SQL', 'warning'); return; }
if (!prefijo || !numero) { showToast('Ingresa Tipo y Número', 'warning'); return; }
const btn = document.getElementById('btn-directo');
showLoading(btn);
resultBox.classList.add('hidden');
const fd = new URLSearchParams({ query_id: qid, prefijo, numero });
let result;
try {
const resp = await fetch('/transaccion/send-direct', { method: 'POST', credentials: 'include', body: fd });
result = await resp.json();
} catch(e) {
result = { success: false, message: String(e) };
}
hideLoading(btn, '<i class="fas fa-paper-plane mr-1"></i> Enviar ahora');
if (result.success) {
const r0 = (result.resultados || [])[0] || {};
resultBox.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-xs space-y-1">
<div class="font-semibold text-green-700"><i class="fas fa-check-circle mr-1"></i>Enviado correctamente</div>
<div class="text-gray-600">Factura: <strong>${escHtml(r0.factura||'')}</strong> — IDRECEP: ${r0.idrecepcion||''}</div>
<div class="text-gray-500">${escHtml(r0.message||'')}</div>
<button onclick="verDirectoJSON(this)" data-json='${JSON.stringify(r0.json||{}).replace(/'/g,"&#39;")}'
class="mt-1 text-blue-600 underline">Ver JSON enviado</button>
</div>`;
} else {
const msgs = (result.resultados||[]).map(r => escHtml(r.message||'')).join('<br>') || escHtml(result.message||'Error');
resultBox.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs">
<div class="font-semibold text-red-700"><i class="fas fa-times-circle mr-1"></i>Error al enviar</div>
<div class="text-gray-600 mt-1">${msgs}</div>
</div>`;
}
resultBox.classList.remove('hidden');
}
function verDirectoJSON(btn) {
try {
const obj = JSON.parse(btn.dataset.json);
document.getElementById('modal-titulo').innerHTML = '<i class="fas fa-code mr-2 text-indigo-500"></i>JSON — Envío directo';
document.getElementById('tab-json').innerHTML = syntaxHighlight(obj);
document.getElementById('tab-tns').textContent = '(usa ver resultado arriba)';
showTab('tab-json');
document.getElementById('modal-detalle').classList.remove('hidden');
} catch(e) {}
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); } function escAttr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
@@ -360,8 +451,8 @@ function syntaxHighlight(obj) {
return json return json
.replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>') .replace(/("(?:[^"\\]|\\.)*")(?=\s*:)/g,'<span class="text-blue-600">$1</span>')
.replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>') .replace(/:(\s*)("(?:[^"\\]|\\.)*")/g,': $1<span class="text-green-700">$2</span>')
.replace(/:(\s*)(\d+(?:\.\d+)?)/g,': $1<span class="text-orange-600">$2</span>') .replace(/: (\d+(?:\.\d+)?)/g,': <span class="text-orange-600">$1</span>')
.replace(/:(\s*)(null|true|false)/g,': $1<span class="text-purple-600">$2</span>'); .replace(/: (null|true|false)/g,': <span class="text-purple-600">$1</span>');
} }
</script> </script>
{% endblock %} {% endblock %}
+350
View File
@@ -0,0 +1,350 @@
{% 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">Factura <span class="text-gray-400">(opcional)</span></label>
<input id="f-factura" type="text" placeholder="Ej: CMXC309"
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 {
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.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">Factura</th>
<th class="pb-2 font-medium pr-3">Paciente</th>
<th class="pb-2 font-medium pr-3">Contrato</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.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('${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('${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('${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="${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.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="${estadoId}" class="py-2 pr-3">${badgeHtml}</td>
<td class="py-2 flex gap-1 items-center">
<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>`;
}
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 _safeId(id) { return id.replace(/[^a-zA-Z0-9]/g, '_'); }
function marcarFila(id, ok, mensaje, rawTns) {
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 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('${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('${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 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, factura_key: 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(`Factura ${escHtml(id)}: enviada`, 'success');
} else {
showToast(`Factura ${escHtml(id)}: ${escHtml(result.message || 'Error')}`, '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.factura_key);
if (allItems.find(i => i.factura_key === item.factura_key)?.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.factura_key === id);
if (!item) return;
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) {
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 %}
View File
+21
View File
@@ -0,0 +1,21 @@
from app.database import get_connection
def log_activity(user_id: int, username: str, accion: str, detalle: str = "", ip: str = ""):
try:
conn = get_connection()
conn.execute(
"INSERT INTO activity_log (user_id, username, accion, detalle, ip) VALUES (?,?,?,?,?)",
(user_id, username, accion, detalle, ip),
)
conn.commit()
conn.close()
except Exception:
pass
def get_ip(request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
+70
View File
@@ -0,0 +1,70 @@
"""
Explora tablas de toma de muestras pendientes en Firebird.
Uso: python debug_muestras.py
"""
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
print("Error Firebird:", msg)
exit(1)
# 1. Buscar tablas que suenen a muestras/toma
print("=== Tablas con nombres relacionados a muestras ===")
_, _, tablas = fb.execute_query("""
SELECT TRIM(rdb$relation_name) AS tabla
FROM rdb$relations
WHERE rdb$system_flag = 0
AND (UPPER(rdb$relation_name) LIKE '%MUESTRA%'
OR UPPER(rdb$relation_name) LIKE '%TOMA%'
OR UPPER(rdb$relation_name) LIKE '%PENDIENTE%'
OR UPPER(rdb$relation_name) LIKE '%EXAMEN%'
OR UPPER(rdb$relation_name) LIKE '%RESULTADO%'
OR UPPER(rdb$relation_name) LIKE '%RELACION%'
OR UPPER(rdb$relation_name) LIKE '%ORDEN%')
ORDER BY 1
""", None)
for t in (tablas or []):
print(" ", t["TABLA"])
# 2. Ver columnas de RELACION (exámenes por recepción)
print("\n=== Columnas de RELACION ===")
_, _, cols = fb.execute_query("""
SELECT TRIM(rdb$field_name) AS campo
FROM rdb$relation_fields
WHERE rdb$relation_name = 'RELACION'
ORDER BY rdb$field_position
""", None)
for c in (cols or []):
print(" ", c["CAMPO"])
# 3. Ver si hay campo de estado/pendiente en RELACION
print("\n=== Muestra de RELACION (primeras 3 filas) ===")
_, _, sample = fb.execute_query("SELECT FIRST 3 * FROM RELACION", None)
for r in (sample or []):
print(" ", dict(r))
# 4. Si existe TOMA_MUESTRA o similar, ver su estructura
for tabla in ["TOMA_MUESTRA", "MUESTRAS", "TOMA", "MUESTRA"]:
ok_t, _, _ = fb.execute_query(f"SELECT FIRST 1 * FROM {tabla}", None)
if ok_t:
print(f"\n=== Tabla {tabla} existe — columnas ===")
_, _, tcols = fb.execute_query(f"""
SELECT TRIM(rdb$field_name) AS campo
FROM rdb$relation_fields
WHERE rdb$relation_name = '{tabla}'
ORDER BY rdb$field_position
""", None)
for c in (tcols or []):
print(" ", c["CAMPO"])
_, _, trows = fb.execute_query(f"SELECT FIRST 3 * FROM {tabla}", None)
for r in (trows or []):
print(" ", dict(r))
fb.disconnect()
print("\nFin.")
+67
View File
@@ -0,0 +1,67 @@
"""
Verifica si el paciente 88272630 aparece en las queries de automation para una fecha dada.
Uso: python debug_paciente.py 2026-07-22
"""
import sys
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
fecha = sys.argv[1] if len(sys.argv) > 1 else "2026-07-22"
fecha_ini = f"{fecha} 00:00:00"
fecha_fin = f"{fecha} 23:59:59"
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
print(f"Error Firebird: {msg}")
exit(1)
print(f"=== Buscando paciente 88272630 para fecha {fecha} ===\n")
# 1. Query pacientes regulares
ok1, err1, rows = fb.execute_query("""
SELECT DISTINCT p.CODIGO, p.DOCIDENT, p.NOMBRES, p.APELLIDOS
FROM PACIENTE p
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
AND r.NUM_FACTURA > 0
AND r.PS_NUM IS NULL
AND TRIM(p.DOCIDENT) = '88272630'
""", {"fi": fecha_ini, "ff": fecha_fin})
print(f"_SQL_PACIENTES (regulares, PS_NUM IS NULL): {len(rows) if ok1 else 'ERROR: '+err1} resultados")
for r in (rows or []):
print(f" CODIGO={r['CODIGO']} DOC={r['DOCIDENT']} NOMBRE={r['NOMBRES']} {r['APELLIDOS']}")
# 2. Query pacientes EPS (via PRESSERV_DIAN)
ok2, err2, rows2 = fb.execute_query("""
SELECT DISTINCT p.CODIGO, p.DOCIDENT, p.NOMBRES, p.APELLIDOS
FROM PACIENTE p
JOIN RECEPCION r ON r.COD_PACIENTE = p.CODIGO
JOIN PRESSERV_DIAN ps ON ps.ID_RECEP = r.IDRECEPCION
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
AND TRIM(p.DOCIDENT) = '88272630'
""", {"fi": fecha_ini, "ff": fecha_fin})
print(f"\n_SQL_PACIENTES_EPS (via PRESSERV_DIAN): {len(rows2) if ok2 else 'ERROR: '+err2} resultados")
for r in (rows2 or []):
print(f" CODIGO={r['CODIGO']} DOC={r['DOCIDENT']} NOMBRE={r['NOMBRES']} {r['APELLIDOS']}")
# 3. Query preserv
ok3, err3, rows3 = fb.execute_query("""
SELECT ps.ID_PS, ps.PS_NUMERO, ps.PS_PREFIJO, r.COD_PACIENTE, p.DOCIDENT
FROM PRESSERV_DIAN ps
JOIN RECEPCION r ON r.IDRECEPCION = ps.ID_RECEP
JOIN PACIENTE p ON p.CODIGO = r.COD_PACIENTE
WHERE r.FECHA_RECEPCION BETWEEN :fi AND :ff
AND (ps.PS_ANULADA IS NULL OR ps.PS_ANULADA = 'F')
AND TRIM(p.DOCIDENT) = '88272630'
""", {"fi": fecha_ini, "ff": fecha_fin})
print(f"\n_SQL_PRESERV (pre-servicios): {len(rows3) if ok3 else 'ERROR: '+err3} resultados")
for r in (rows3 or []):
print(f" ID_PS={r['ID_PS']} PREFIJO={r['PS_PREFIJO']} NUM={r['PS_NUMERO']} COD_PAC={r['COD_PACIENTE']}")
fb.disconnect()
print("\nFin.")
+25
View File
@@ -0,0 +1,25 @@
from app.database import get_connection
from app.services.firebird_service import get_firebird_from_config
conn = get_connection()
cfg = {r["key"]: r["value"] for r in conn.execute("SELECT * FROM config").fetchall()}
conn.close()
fb, ok, msg = get_firebird_from_config(cfg)
if not ok:
print("Error Firebird:", msg)
else:
_, e, rows = fb.execute_query(
"SELECT DOCIDENT, TIPOIDENT FROM PACIENTE WHERE TRIM(DOCIDENT) = '13225821'", None
)
print("Resultado:", rows)
# También ver qué tipos de documento distintos existen en la BD
_, e2, tipos = fb.execute_query(
"SELECT DISTINCT TIPOIDENT, COUNT(*) AS CNT FROM PACIENTE GROUP BY TIPOIDENT ORDER BY CNT DESC", None
)
print("\nTodos los TIPOIDENT en PACIENTE:")
for t in (tipos or []):
print(" ", dict(t))
fb.disconnect()
+67
View File
@@ -0,0 +1,67 @@
import sqlite3
Q5 = """SELECT
r.IDRECEPCION,
r.PREFIJO,
r.NUM_FACTURA,
r.FECHA_RECEPCION,
r.COD_PACIENTE,
r.NIT_EMPRESA,
r.DIAG_PPAL,
r.TIPOUSU,
r.TIPOUSUSISPRO,
r.AUTORIZACION,
r.CLASEPROC,
r.HORAINICIORECEPCION,
r.VALORTOTAL,
rel.COD_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO,
rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
e.CODCONTRATO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
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 MEDICO m ON m.CODIGO = r.COD_MEDICO
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
WHERE r.NUM_FACTURA = :num_factura"""
Q6 = """SELECT
r.IDRECEPCION,
r.PREFIJO,
r.NUM_FACTURA,
r.FECHA_RECEPCION,
r.COD_PACIENTE,
r.NIT_EMPRESA,
r.DIAG_PPAL,
r.TIPOUSU,
r.TIPOUSUSISPRO,
r.AUTORIZACION,
r.CLASEPROC,
r.HORAINICIORECEPCION,
r.VALORTOTAL,
rel.COD_EXAMEN,
COALESCE(NULLIF(TRIM(ex.NUM_ISS), ''), TRIM(rel.COD_EXAMEN)) AS CUPS,
rel.PRECIO,
rel.FECHA_REPORTADO,
m.COD_ESPECIALIDAD,
COALESCE(NULLIF(TRIM(m.CODIGO), ''), NULLIF(TRIM(r.USUARIO), ''), '') AS profesional,
e.CODCONTRATO,
TRIM(m.DOCIDMEDICO) AS DOCIDMEDICO
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 MEDICO m ON m.CODIGO = r.COD_MEDICO
LEFT JOIN EMPRESA e ON e.NIT = r.NIT_EMPRESA
WHERE r.FECHA_RECEPCION BETWEEN :fecha_ini AND :fecha_fin
ORDER BY r.IDRECEPCION"""
conn = sqlite3.connect("rips_manager.db")
conn.execute("UPDATE queries SET query_text=? WHERE id=5", (Q5,))
conn.execute("UPDATE queries SET query_text=? WHERE id=6", (Q6,))
conn.commit()
conn.close()
print("Queries actualizados OK")
+76 -1
View File
@@ -8,11 +8,15 @@ from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import uvicorn import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.database import init_db from app.database import init_db
from app.auth import decode_token from app.auth import decode_token
from app.services.scheduler import sync_recientes
from app.services.whatsapp_sync import guardar_sync_log
app = FastAPI(title="RIPS Manager", version="1.0.0") app = FastAPI(title="RIPS Manager", version="1.0.0")
_scheduler = AsyncIOScheduler()
templates = Jinja2Templates( templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "app", "templates") directory=os.path.join(os.path.dirname(__file__), "app", "templates")
@@ -56,8 +60,74 @@ async def startup():
init_db() init_db()
from app.routes.config import ensure_defaults as config_defaults from app.routes.config import ensure_defaults as config_defaults
from app.routes.queries import ensure_defaults as query_defaults from app.routes.queries import ensure_defaults as query_defaults
from app.routes.contratos import ensure_defaults as contratos_defaults
config_defaults() config_defaults()
query_defaults() query_defaults()
contratos_defaults()
async def _job_sync():
try:
resultado = await sync_recientes(ventana_min=60)
if not resultado.get("ok") and resultado.get("error"):
guardar_sync_log(
{"total": 0, "created": 0, "skipped": 0, "updated": 0, "errores": 1, "detalle": [
{"ok": False, "action": "error", "message": resultado["error"], "doc": "", "nombre": "scheduler-error"}
]},
None, origen="scheduler", modo="upsert"
)
except Exception as exc:
import traceback
guardar_sync_log(
{"total": 0, "created": 0, "skipped": 0, "updated": 0, "errores": 1, "detalle": [
{"ok": False, "action": "error", "message": str(exc), "doc": "", "nombre": "scheduler-exception"}
]},
None, origen="scheduler", modo="upsert"
)
_scheduler.add_job(_job_sync, "interval", seconds=20, id="wa_sync_recientes",
max_instances=1, coalesce=True)
_scheduler.start()
@app.on_event("shutdown")
async def shutdown():
_scheduler.shutdown(wait=False)
@app.get("/api/scheduler/status")
async def scheduler_status():
from fastapi.responses import JSONResponse
from app.database import get_connection
from datetime import timezone, timedelta
COL = timezone(timedelta(hours=-5))
job = _scheduler.get_job("wa_sync_recientes")
next_run_str = None
if job and job.next_run_time:
next_run_str = job.next_run_time.astimezone(COL).strftime("%H:%M:%S")
conn = get_connection()
row = conn.execute(
"SELECT created_at, total, errores FROM sync_wa_log "
"WHERE origen='scheduler' ORDER BY id DESC LIMIT 1"
).fetchone()
conn.close()
ultimo = None
if row:
from datetime import datetime
ts_utc = datetime.fromisoformat(row["created_at"].replace(" ", "T") + "+00:00")
ultimo = {
"hora": ts_utc.astimezone(COL).strftime("%H:%M:%S"),
"total": row["total"],
"errores": row["errores"],
}
return JSONResponse({
"running": _scheduler.running,
"job_exists": job is not None,
"next_run": next_run_str,
"ultimo_log": ultimo,
})
@app.get("/") @app.get("/")
@@ -65,18 +135,23 @@ async def root():
return RedirectResponse(url="/dashboard") return RedirectResponse(url="/dashboard")
from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb from app.routes import auth, dashboard, config, queries, terceros, transaccion, logs, automation, test_rda, debug_fb, pacientes, contratos, ventas, envios, docs
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(queries.router) app.include_router(queries.router)
app.include_router(envios.router)
app.include_router(terceros.router) app.include_router(terceros.router)
app.include_router(transaccion.router) app.include_router(transaccion.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(automation.router) app.include_router(automation.router)
app.include_router(test_rda.router) app.include_router(test_rda.router)
app.include_router(debug_fb.router) app.include_router(debug_fb.router)
app.include_router(pacientes.router)
app.include_router(contratos.router)
app.include_router(ventas.router)
app.include_router(docs.router)
if __name__ == "__main__": if __name__ == "__main__":
+1
View File
@@ -7,3 +7,4 @@ bcrypt==4.2.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
httpx==0.28.1 httpx==0.28.1
fdb>=2.0.0 fdb>=2.0.0
apscheduler>=3.10.0
BIN
View File
Binary file not shown.