Agregar Bono 14 Guatemala y actualizar dist
This commit is contained in:
+7
-3
@@ -9,20 +9,24 @@ VITE_BASE_PATH="/cruce-cuentas/guatemala/"
|
|||||||
VITE_N8N_GUATEMALA_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
|
VITE_N8N_GUATEMALA_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
|
||||||
# Compatibilidad con instalaciones anteriores.
|
# Compatibilidad con instalaciones anteriores.
|
||||||
VITE_N8N_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
|
VITE_N8N_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
|
||||||
|
VITE_N8N_GUATEMALA_BONO14_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/bono14gt-bamboo-test"
|
||||||
|
|
||||||
# Guatemala: históricos y resolución.
|
# Guatemala: históricos y resolución.
|
||||||
VITE_HISTORICOS_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos"
|
VITE_HISTORICOS_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos"
|
||||||
VITE_MARCAR_RESUELTO_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto"
|
VITE_MARCAR_RESUELTO_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto"
|
||||||
|
|
||||||
# Trinidad y Tobago: completar cuando los workflows estén listos.
|
# Trinidad y Tobago: completar cuando los workflows estén listos.
|
||||||
VITE_N8N_TRINIDAD_WEBHOOK_URL=""
|
VITE_N8N_TRINIDAD_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominatt-bamboo-test"
|
||||||
VITE_HISTORICOS_TT_URL=""
|
VITE_HISTORICOS_TT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-tt-historicos"
|
||||||
VITE_MARCAR_RESUELTO_TT_URL=""
|
VITE_MARCAR_RESUELTO_TT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-tt-marcar-resuelto"
|
||||||
|
|
||||||
# Endpoint opcional de n8n + Gemini para analizar el nombre del archivo de nómina.
|
# Endpoint opcional de n8n + Gemini para analizar el nombre del archivo de nómina.
|
||||||
# La app ya incluye detección local como respaldo.
|
# La app ya incluye detección local como respaldo.
|
||||||
VITE_ANALIZAR_NOMINA_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-analizar-nomina"
|
VITE_ANALIZAR_NOMINA_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-analizar-nomina"
|
||||||
|
|
||||||
|
# Reporte manual de falsos positivos Banco sin BambooHR -> RRHH.
|
||||||
|
VITE_REPORTAR_BAMBOO_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-bamboo-correccion"
|
||||||
|
|
||||||
# Login Google vía Supabase.
|
# Login Google vía Supabase.
|
||||||
VITE_ENABLE_SUPABASE_AUTH="true"
|
VITE_ENABLE_SUPABASE_AUTH="true"
|
||||||
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
|
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
|
||||||
|
|||||||
-280
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,169 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Portal de Verificación de Nómina GT - Históricos",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"path": "cruce-cuentas-gt-historicos",
|
|
||||||
"responseMode": "responseNode",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2.1,
|
|
||||||
"position": [
|
|
||||||
-112,
|
|
||||||
-16
|
|
||||||
],
|
|
||||||
"id": "47845bfd-64f5-47ec-89e6-c0faa317e08b",
|
|
||||||
"name": "Webhook Históricos GT",
|
|
||||||
"webhookId": "e92a1ebc-80e5-4afe-b38d-48a92c4b7fea"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "const items = $input.all();\n\nfunction formatDate(value) {\n if (!value) return '';\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);\n return date.toISOString().slice(0, 10);\n}\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction normalizeStatus(value, discrepancias) {\n const status = String(value || '').toLowerCase();\n\n if (status === 'resuelto') return 'resuelto';\n if (status === 'procesado' && toNumber(discrepancias) === 0) return 'procesado';\n\n return 'pendiente_revision';\n}\n\nfunction statusLabel(status) {\n if (status === 'resuelto') return 'RESUELTO';\n if (status === 'procesado') return 'PROCESADO';\n return 'PENDIENTE REVISIÓN';\n}\n\nconst reports = items.map((item) => {\n const row = item.json || {};\n\n const discrepancias = toNumber(row.discrepancias);\n const bancoSinNomina = toNumber(row.banco_sin_nomina);\n const nominaSinCuenta = toNumber(row.nomina_sin_cuenta);\n const bancoSinBamboo = toNumber(row.banco_sin_bamboo);\n const nominaSinBamboo = toNumber(row.nomina_sin_bamboo);\n const bambooSinNomina = toNumber(row.bamboo_sin_nomina);\n\n const totalPendientes =\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n bancoSinBamboo +\n nominaSinBamboo +\n bambooSinNomina;\n\n const estado = normalizeStatus(row.estado, totalPendientes);\n\n return {\n id: row.id,\n\n fecha: formatDate(row.period_end || row.ejecutado_en || row.created_at),\n fechaEjecucion: row.ejecutado_en || row.created_at,\n\n periodo: row.period_label || '',\n year: row.year,\n month: row.month,\n periodType: row.period_type,\n periodStart: row.period_start,\n periodEnd: row.period_end,\n\n archivoNomina: row.payroll_file_name || '',\n archivosBanco: row.bank_file_names || [],\n\n coincidencias: toNumber(row.coincidencias),\n discrepancias,\n bancoSinNomina,\n nominaSinCuenta,\n bancoSinBamboo,\n nominaSinBamboo,\n bambooSinNomina,\n totalPendientes,\n\n totalNomina: toNumber(row.total_nomina),\n totalBanco: toNumber(row.total_banco),\n diferenciaTotal: toNumber(row.diferencia_total),\n\n estado,\n estadoLabel: statusLabel(estado),\n\n reportUrl: row.report_url || '',\n spreadsheetId: row.spreadsheet_id || '',\n\n ejecutadoPorNombre: row.ejecutado_por_nombre || '',\n ejecutadoPorEmail: row.ejecutado_por_email || '',\n\n resueltoPorNombre: row.resuelto_por_nombre || '',\n resueltoPorEmail: row.resuelto_por_email || '',\n resueltoEn: row.resuelto_en || null,\n comentarioResolucion: row.comentario_resolucion || '',\n };\n});\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'historicos_gt_listos',\n count: reports.length,\n reports,\n },\n },\n];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
304,
|
|
||||||
-16
|
|
||||||
],
|
|
||||||
"id": "0fb42965-9a23-42e1-bd82-152452dc54ed",
|
|
||||||
"name": "Normalizar históricos para app"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?select=*&order=ejecutado_en.desc&limit=100",
|
|
||||||
"sendHeaders": true,
|
|
||||||
"headerParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "apikey",
|
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
96,
|
|
||||||
-16
|
|
||||||
],
|
|
||||||
"id": "3b8cd950-a973-433e-82a1-fcd67bc9a483",
|
|
||||||
"name": "Consultar históricos Supabase"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{ $json }}",
|
|
||||||
"options": {
|
|
||||||
"responseHeaders": {
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Origin",
|
|
||||||
"value": "*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Methods",
|
|
||||||
"value": "GET,POST,OPTIONS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Access-Control-Allow-Headers",
|
|
||||||
"value": "Content-Type, Authorization"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
528,
|
|
||||||
-16
|
|
||||||
],
|
|
||||||
"id": "31c2d67f-3989-4342-9be3-ed718fc63e79",
|
|
||||||
"name": "Responder históricos app"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"content": "# 📚 HISTÓRICOS DE VERIFICACIÓN — GUATEMALA\n\nEntrega a la aplicación el historial de reportes procesados para Guatemala.\n\n## Recepción de la solicitud\n\nEl Webhook recibe desde el Portal de Verificación de Nóminas una solicitud GET para cargar los registros históricos.\n\nLa solicitud puede incluir parámetros utilizados por la aplicación, como:\n\n- Año.\n- Mes.\n- Tipo de período.\n- Estado del reporte.\n- Usuario responsable.\n- Identificador del reporte.\n- Parámetros de ordenamiento o filtrado.\n\n## Consulta en Supabase\n\nEl flujo consulta en Supabase los reportes históricos correspondientes a Guatemala.\n\nSupabase funciona como fuente oficial para recuperar información como:\n\n- Identificador del reporte.\n- Año y mes procesados.\n- Tipo de nómina o período.\n- Fecha de ejecución.\n- Estado del reporte.\n- Cantidad de hallazgos.\n- Enlace al reporte generado.\n- Usuario que ejecutó el proceso.\n- Usuario y fecha de resolución.\n- Comentario de resolución, cuando exista.\n\n## Normalización para la aplicación\n\nLos registros recuperados se transforman al formato esperado por el portal.\n\nEste bloque:\n\n- Normaliza nombres de campos.\n- Convierte fechas al formato utilizado por la interfaz.\n- Establece valores predeterminados para campos vacíos.\n- Ordena los reportes según la lógica del portal.\n- Conserva los enlaces necesarios.\n- Prepara una estructura consistente aunque no existan resultados.\n\n## Respuesta al portal\n\nEl workflow devuelve a la aplicación:\n\n- Indicador de éxito.\n- Lista de históricos encontrados.\n- Cantidad total de registros.\n- Datos normalizados para mostrar en la interfaz.\n\nCuando no existen históricos, debe responder correctamente con una lista vacía y no tratar el resultado como un error.\n\n## Reglas\n\n- Consultar únicamente reportes correspondientes a Guatemala.\n- No modificar ningún registro durante esta operación.\n- No devolver campos internos o sensibles que la aplicación no necesita.\n- No inventar valores cuando Supabase devuelve campos vacíos.\n- Mantener una estructura de respuesta estable.\n- Responder siempre al Webhook para evitar solicitudes abiertas.\n- Supabase es la fuente oficial de los históricos mostrados en el portal.",
|
|
||||||
"height": 1296,
|
|
||||||
"width": 1504,
|
|
||||||
"color": 6
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.stickyNote",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
-768,
|
|
||||||
-752
|
|
||||||
],
|
|
||||||
"id": "0a6676a2-9aff-4a7c-b138-05bef8bb8752",
|
|
||||||
"name": "Sticky Note"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"Webhook Históricos GT": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Consultar históricos Supabase",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Consultar históricos Supabase": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Normalizar históricos para app",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Normalizar históricos para app": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Responder históricos app",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": true,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate",
|
|
||||||
"availableInMCP": true,
|
|
||||||
"timeSavedMode": "fixed",
|
|
||||||
"errorWorkflow": "puF4LUczoSz3hcek",
|
|
||||||
"timezone": "America/Santo_Domingo",
|
|
||||||
"callerPolicy": "workflowsFromSameOwner"
|
|
||||||
},
|
|
||||||
"versionId": "fab93c67-7332-4b99-80fe-648c4cace390",
|
|
||||||
"meta": {
|
|
||||||
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
|
|
||||||
},
|
|
||||||
"id": "QDKFgvoJKviQbDl3",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
echo Generando dist actualizado de Cruce de Cuentas GLM con Bono 14...
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Si aparece un error de dependencia nativa de Rollup, ejecuta npm ci y vuelve a intentar.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
echo Dist generado correctamente.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Banco sin BambooHR — correcciones persistentes
|
||||||
|
|
||||||
|
Esta versión conserva el flujo existente de conciliación y amplía únicamente la gestión de falsos positivos de **Banco sin BambooHR**.
|
||||||
|
|
||||||
|
## Qué cambia
|
||||||
|
|
||||||
|
1. Cada reporte nuevo guarda en Supabase el detalle completo de `Banco sin BambooHR` asociado al ID histórico del reporte.
|
||||||
|
2. En **Históricos**, los reportes con casos de Banco sin Bamboo pueden reabrirse días después y cada caso puede enviarse a RRHH con **Reportar a RRHH**.
|
||||||
|
3. La aplicación consulta el estado de correcciones ya notificadas y muestra **Reportado a RRHH** para evitar repetir el mismo envío desde el mismo reporte.
|
||||||
|
4. El workflow de corrección BambooHR existente sigue siendo el responsable de validar la identidad, registrar aliases/equivalencias y enviar el correo a RRHH. No requiere cambios en esta entrega.
|
||||||
|
5. Los workflows generadores de Guatemala y Trinidad y Tobago devuelven ahora el `executionId` real del histórico para asociar la corrección al reporte exacto.
|
||||||
|
|
||||||
|
## Robustez después de dejar la pestaña abierta
|
||||||
|
|
||||||
|
Antes de una nueva ejecución, la aplicación revalida la sesión Supabase y reactiva de forma segura la conexión con el origen de n8n. Cada intento recibe además un `client_request_id` y el POST se envía sin caché. No se hace un reintento automático del cruce porque eso podría crear dos Google Sheets, dos históricos o dos correos si el primer POST sí llegó al servidor.
|
||||||
|
|
||||||
|
El `dist` incluido contiene además una capa de compatibilidad para la versión compilada recibida: reactiva la conexión de n8n antes del POST y habilita la gestión histórica de Banco sin Bamboo sin modificar el bundle previo que ya estaba funcionando.
|
||||||
|
|
||||||
|
## Orden de instalación
|
||||||
|
|
||||||
|
1. Ejecutar `SUPABASE_Cruce_Cuentas_BambooHR_Historicos_READY.sql` en Supabase.
|
||||||
|
2. Reemplazar/importar los workflows actualizados de Guatemala y Trinidad y Tobago.
|
||||||
|
3. Mantener activo el workflow existente `Cruce de Cuentas - Reportar Corrección BambooHR a RRHH`.
|
||||||
|
4. Publicar el `dist` incluido o compilar nuevamente el proyecto en el entorno normal de desarrollo.
|
||||||
|
|
||||||
|
Los reportes anteriores a esta migración pueden no tener detalle histórico de Banco sin Bamboo; los reportes nuevos sí lo conservarán automáticamente.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Bono 14 Guatemala — Implementación final
|
||||||
|
|
||||||
|
Esta versión incorpora **Bono 14** como un reporte anual independiente dentro
|
||||||
|
del módulo de Guatemala, sin mezclar sus importes con la nómina ordinaria.
|
||||||
|
|
||||||
|
## Disponibilidad en la app
|
||||||
|
|
||||||
|
La sección aparece únicamente con:
|
||||||
|
|
||||||
|
- País: Guatemala
|
||||||
|
- Mes: Julio
|
||||||
|
- Tipo de cruce: Quincena 15
|
||||||
|
|
||||||
|
La ejecución normal de la Quincena 15 permanece independiente.
|
||||||
|
|
||||||
|
## Archivos
|
||||||
|
|
||||||
|
Bono 14 recibe:
|
||||||
|
|
||||||
|
1. Un Excel anual de Bono 14.
|
||||||
|
2. Uno o varios CSV bancarios.
|
||||||
|
|
||||||
|
El webhook independiente es:
|
||||||
|
|
||||||
|
`bono14gt-bamboo-test`
|
||||||
|
|
||||||
|
Variable frontend:
|
||||||
|
|
||||||
|
`VITE_N8N_GUATEMALA_BONO14_WEBHOOK_URL`
|
||||||
|
|
||||||
|
## Identidad del reporte
|
||||||
|
|
||||||
|
El histórico utiliza:
|
||||||
|
|
||||||
|
`GT-AAAA-07-bono14`
|
||||||
|
|
||||||
|
Ejemplo 2026:
|
||||||
|
|
||||||
|
`GT-2026-07-bono14`
|
||||||
|
|
||||||
|
Si se procesa nuevamente el mismo año, se reutiliza el mismo Google Sheet y el
|
||||||
|
mismo histórico; las resoluciones que sigan correspondiendo a casos presentes
|
||||||
|
se conservan.
|
||||||
|
|
||||||
|
## Período anual
|
||||||
|
|
||||||
|
Para Bono 14 del año `AAAA`:
|
||||||
|
|
||||||
|
- inicio: `AAAA-1-07-01`
|
||||||
|
- fin: `AAAA-06-30`
|
||||||
|
|
||||||
|
Ejemplo 2026: `2025-07-01` a `2026-06-30`.
|
||||||
|
|
||||||
|
## Lectura del Excel
|
||||||
|
|
||||||
|
El workflow detecta dinámicamente las columnas principales de las siete hojas
|
||||||
|
canónicas y conserva índices de respaldo por compatibilidad.
|
||||||
|
|
||||||
|
También identifica el **total oficial de cada hoja** y cierra la tabla en esa
|
||||||
|
fila. Esto evita incorporar filas auxiliares o de ajustes colocadas debajo del
|
||||||
|
total oficial.
|
||||||
|
|
||||||
|
Antes de continuar, el workflow valida que la suma de empleados leída coincida
|
||||||
|
con el total oficial de cada hoja. Si no coincide, se detiene para evitar
|
||||||
|
generar un reporte incorrecto.
|
||||||
|
|
||||||
|
Hojas canónicas:
|
||||||
|
|
||||||
|
- P&G
|
||||||
|
- B14 MOTO
|
||||||
|
- B14 WP
|
||||||
|
- PMI
|
||||||
|
- B14 Nestle
|
||||||
|
- B14 GLM Q
|
||||||
|
- B14 GLM $
|
||||||
|
|
||||||
|
El flujo trabaja con QTZ y USD de forma separada.
|
||||||
|
|
||||||
|
## Google Sheet
|
||||||
|
|
||||||
|
El reporte genera:
|
||||||
|
|
||||||
|
1. `01 Bono 14 vs Banco`
|
||||||
|
2. `02 Banco sin Bono 14`
|
||||||
|
3. `03 Banco sin Bamboo`
|
||||||
|
4. `04 Diferencias nombre banco`
|
||||||
|
5. `05 Cuenta Mal Digitada` (cuando aplica)
|
||||||
|
6. `06 Resumen`
|
||||||
|
|
||||||
|
Conserva las mismas condiciones finales de Guatemala: protección de datos,
|
||||||
|
`Resolución` editable, reutilización del mismo reporte, limpieza de formatos
|
||||||
|
residuales y orden automático de pestañas.
|
||||||
|
|
||||||
|
## Supabase
|
||||||
|
|
||||||
|
No se crea una segunda tabla de históricos. Se reutiliza
|
||||||
|
`cruces_cuentas_gt_reportes` y Bono 14 se diferencia mediante:
|
||||||
|
|
||||||
|
- `country = GT`
|
||||||
|
- `month = 7`
|
||||||
|
- `period_type = bono14`
|
||||||
|
- `report_identity = GT-AAAA-07-bono14`
|
||||||
|
|
||||||
|
Ejecutar:
|
||||||
|
|
||||||
|
`SUPABASE_Cruce_Cuentas_Bono14_Guatemala_READY.sql`
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Reporte único por período
|
||||||
|
|
||||||
|
Esta versión conserva un único reporte por **país + año + mes + tipo de período/quincena**.
|
||||||
|
|
||||||
|
Cuando n8n detecta que el período ya existe en Supabase, reutiliza el `spreadsheet_id` existente, lee las resoluciones ya completadas, recalcula los datos, limpia y vuelve a escribir el mismo Google Sheet y conserva las resoluciones que todavía corresponden al mismo caso. El histórico se actualiza mediante `report_identity` en lugar de insertar otra fila.
|
||||||
|
|
||||||
|
La aplicación también prioriza `updated_at` al ordenar Históricos para que una regeneración del mismo período vuelva a aparecer arriba sin crear otro registro.
|
||||||
@@ -1,566 +1,409 @@
|
|||||||
# Cruce de Cuentas GLM - Centralizado
|
# Cruce de Cuentas GLM
|
||||||
|
|
||||||
> Aplicación centralizada para automatizar y gestionar el cruce de cuentas de nómina contra archivos bancarios, validando empleados con BambooHR, generando reportes en Google Sheets y dejando una arquitectura preparada para incorporar progresivamente los demás países de GomezLee Marketing.
|
> Portal centralizado para acceder a los módulos de Cruce de Cuentas por país, permitiendo tener un único punto de entrada seguro para los portales regionales de conciliación.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## INFORMACIÓN GENERAL
|
## Información General
|
||||||
|
|
||||||
| Campo | Detalle |
|
| Campo | Detalle |
|
||||||
|---|---|
|
| ------------------------ | ------------------------------------ |
|
||||||
| Proyecto | Cruce de Cuentas GLM - Centralizado |
|
| Proyecto | Cruce de Cuentas GLM |
|
||||||
| Área | Administración |
|
| Área | Nómina |
|
||||||
| Estado | Guatemala y Trinidad y Tobago completados |
|
| Estado | Prototipo funcional / Portal central |
|
||||||
| Developer Principal | Isaac Aracena |
|
| Developer Principal | Isaac Aracena |
|
||||||
| IT Manager | Luis Matos |
|
| IT Manager | Luis Matos |
|
||||||
|
| Product Owner | Máximo Gómez |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OBJETIVO
|
## Objetivo
|
||||||
|
|
||||||
### Problema que resuelve
|
### Problema que resuelve
|
||||||
|
|
||||||
El proceso de validar que los pagos enviados al banco coincidan correctamente con la nómina requiere comparar archivos con estructuras diferentes, revisar cuentas bancarias, nombres, montos y empleados, y confirmar manualmente si una persona existe o no en BambooHR.
|
Actualmente, los procesos de cruce de cuentas por país pueden quedar dispersos en diferentes aplicaciones, enlaces, flujos o repositorios. Esto dificulta el acceso ordenado, el control de permisos y la visibilidad de cuáles países tienen un portal activo, pendiente o en desarrollo.
|
||||||
|
|
||||||
Este trabajo manual consume tiempo, aumenta el riesgo de errores y hace más difícil identificar rápidamente diferencias reales de pago, cuentas mal digitadas, empleados pagados que no aparecen en la nómina o registros bancarios que no corresponden a empleados válidos.
|
|
||||||
|
|
||||||
### Solución implementada
|
### Solución implementada
|
||||||
|
|
||||||
Cruce de Cuentas GLM centraliza este proceso en una aplicación web.
|
Cruce de Cuentas GLM funciona como un portal central de acceso. Desde esta aplicación, los usuarios autorizados pueden iniciar sesión con Google, visualizar los países disponibles y acceder al portal correspondiente de cada país.
|
||||||
|
|
||||||
El usuario selecciona el país y período, carga la nómina y los archivos bancarios correspondientes, y el sistema procesa la información automáticamente mediante workflows de n8n.
|
Este repositorio no ejecuta el cruce de nómina ni procesa archivos bancarios. Su responsabilidad principal es centralizar el acceso y redireccionar a los portales operativos de cada país.
|
||||||
|
|
||||||
El resultado incluye:
|
|
||||||
|
|
||||||
- Comparación de cuentas bancarias.
|
|
||||||
- Comparación de montos pagados contra nómina.
|
|
||||||
- Identificación de coincidencias.
|
|
||||||
- Identificación de discrepancias reales.
|
|
||||||
- Detección de posibles cuentas bancarias mal digitadas.
|
|
||||||
- Validación de empleados contra BambooHR.
|
|
||||||
- Identificación de pagos bancarios que no corresponden a empleados localizados en BambooHR.
|
|
||||||
- Detección de diferencias de nombres en los archivos bancarios.
|
|
||||||
- Generación automática de un Google Sheet estructurado.
|
|
||||||
- Historial de reportes y seguimiento de resolución desde la aplicación.
|
|
||||||
- Formato automático del reporte para evitar ajustes manuales de columnas y filas.
|
|
||||||
|
|
||||||
La solución comenzó con Guatemala y Trinidad y Tobago y está diseñada para agregar progresivamente los demás países de GomezLee Marketing sin crear aplicaciones independientes.
|
|
||||||
|
|
||||||
### Usuarios / Beneficiarios
|
### Usuarios / Beneficiarios
|
||||||
|
|
||||||
- Equipos administrativos responsables de validar pagos y nóminas.
|
* Equipo de Nómina.
|
||||||
- Recursos Humanos.
|
* IT.
|
||||||
- Personal autorizado para consultar históricos y dar seguimiento a discrepancias.
|
* Gerencia regional.
|
||||||
- IT, para soporte, mantenimiento y expansión del sistema a nuevos países.
|
* Usuarios autorizados que necesiten acceder a los portales de cruce por país.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ARQUITECTURA
|
## Estado Actual del Proyecto
|
||||||
|
|
||||||
### Diagrama de flujo
|
Este repositorio contiene un **portal frontend centralizado** desarrollado con React, Vite, TypeScript y Supabase Auth.
|
||||||
|
|
||||||
```text
|
La aplicación permite:
|
||||||
[Usuario]
|
|
||||||
|
|
* Iniciar sesión con Google mediante Supabase Auth.
|
||||||
v
|
* Validar correos autorizados.
|
||||||
[App Web React / Vite]
|
* Mostrar una pantalla central con países disponibles.
|
||||||
|
|
* Visualizar tarjetas por país.
|
||||||
+----> [Supabase Auth + Control de acceso]
|
* Identificar el portal de Guatemala como módulo iniciado.
|
||||||
|
|
* Mostrar los demás países como módulos pendientes o no iniciados.
|
||||||
v
|
* Preparar la navegación hacia los portales independientes de cada país.
|
||||||
[Selección de país + período + archivos]
|
|
||||||
|
|
---
|
||||||
v
|
|
||||||
[Webhook n8n del país]
|
|
||||||
|
|
## Arquitectura
|
||||||
+----> [Parseo de nómina]
|
|
||||||
|
|
### Arquitectura actual
|
||||||
+----> [Parseo de archivos bancarios]
|
|
||||||
|
|
```txt
|
||||||
+----> [BambooHR Custom Report - onlyCurrent=false]
|
Usuario autorizado
|
||||||
|
|
↓
|
||||||
v
|
Google Login
|
||||||
[Normalización + Conciliación + Validaciones]
|
↓
|
||||||
|
|
Supabase Auth
|
||||||
v
|
↓
|
||||||
[Clasificación de resultados]
|
Portal Central Cruce de Cuentas GLM
|
||||||
|
|
↓
|
||||||
+----> Coincidencias
|
Tarjetas por país
|
||||||
+----> Discrepancias
|
↓
|
||||||
+----> Banco sin Bamboo
|
Modal informativo / redirección futura
|
||||||
+----> Diferencias de Nombre
|
|
||||||
+----> Posibles cuentas mal digitadas
|
|
||||||
+----> Resumen
|
|
||||||
|
|
|
||||||
v
|
|
||||||
[Google Sheets]
|
|
||||||
|
|
|
||||||
+----> [Histórico / seguimiento en Supabase]
|
|
||||||
|
|
|
||||||
v
|
|
||||||
[Resultado mostrado en la aplicación]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stack tecnológico
|
### Arquitectura objetivo
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Usuario autorizado
|
||||||
|
↓
|
||||||
|
Portal Central Cruce de Cuentas GLM
|
||||||
|
↓
|
||||||
|
Selección de país
|
||||||
|
↓
|
||||||
|
Redirección al portal del país
|
||||||
|
↓
|
||||||
|
Portal específico ejecuta su propio flujo
|
||||||
|
↓
|
||||||
|
n8n / BambooHR / Banco / Nómina / Reportes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
| Componente | Tecnología | Propósito |
|
| Componente | Tecnología | Propósito |
|
||||||
|---|---|---|
|
| --------------- | ----------------------------- | -------------------------------------- |
|
||||||
| Frontend | React + TypeScript + Vite | Aplicación web centralizada |
|
| Frontend | React 19 | Interfaz del portal central |
|
||||||
| Automatización | n8n | Orquestación de cruces y generación de reportes |
|
| Build Tool | Vite | Desarrollo local y build de producción |
|
||||||
| Base de datos / Auth | Supabase / PostgreSQL | Autenticación, permisos e información persistente |
|
| Lenguaje | TypeScript | Tipado del frontend |
|
||||||
| RRHH | BambooHR API | Validación de empleados activos e inactivos |
|
| Estilos | Tailwind CSS 4 | Diseño visual |
|
||||||
| Reportes | Google Sheets API | Generación y formato de reportes |
|
| Animaciones | Motion | Transiciones y animaciones |
|
||||||
| Repositorio | Gitea | Control de versiones |
|
| Iconos | Lucide React | Iconografía |
|
||||||
|
| Autenticación | Supabase Auth | Login con Google |
|
||||||
### Integraciones externas
|
| Package Manager | npm | Gestión de dependencias |
|
||||||
|
| Infraestructura | Servidor GLM | Hosting del portal |
|
||||||
| Sistema | Tipo de integración | Datos que fluyen |
|
|
||||||
|---|---|---|
|
|
||||||
| Supabase | Auth + REST / RPC | Sesión, autorización, histórico y seguimiento |
|
|
||||||
| n8n | Webhook HTTP | Archivos de nómina, archivos bancarios y metadata del período |
|
|
||||||
| BambooHR | API REST | Empleados, estado, ubicación, fecha de ingreso y Employee Number |
|
|
||||||
| Google Sheets | Google API | Creación y formato del reporte final |
|
|
||||||
| Gitea | Git | Código fuente y control de versiones |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PAÍSES IMPLEMENTADOS
|
## Integraciones
|
||||||
|
|
||||||
### Guatemala - COMPLETADO
|
### Integraciones actuales
|
||||||
|
|
||||||
El flujo de Guatemala procesa una nómina Excel con múltiples hojas y uno o varios archivos bancarios.
|
| Sistema | Tipo de integración | Uso |
|
||||||
|
| -------------- | -------------------- | ---------------------------- |
|
||||||
|
| Supabase Auth | OAuth con Google | Autenticación de usuarios |
|
||||||
|
| Google Login | Provider de Supabase | Inicio de sesión corporativo |
|
||||||
|
| Frontend React | SPA | Portal visual centralizado |
|
||||||
|
|
||||||
Estado actual:
|
### Integraciones futuras
|
||||||
|
|
||||||
- Cruce de nómina vs banco operativo.
|
| Sistema | Tipo de integración | Uso |
|
||||||
- Validación BambooHR operativa.
|
| ----------------- | ------------------- | -------------------------------------------------------------- |
|
||||||
- Se consultan empleados activos e inactivos mediante `onlyCurrent=false`.
|
| Portales por país | Redirección por URL | Enviar al usuario al portal correspondiente |
|
||||||
- Matching de nombres optimizado para evitar bloqueos del task runner de n8n.
|
| Supabase Database | Opcional | Configurar países, URLs, estado y permisos desde base de datos |
|
||||||
- Matching por Employee Number cuando existe evidencia disponible.
|
| n8n | Indirecta | Cada portal país podrá disparar su propio flujo de cruce |
|
||||||
- Matching por nombres normalizados, alias y variantes controladas.
|
| BambooHR | Indirecta | Será usado por el flujo del país, no por este portal central |
|
||||||
- No se fuerzan coincidencias ambiguas.
|
|
||||||
- Banco sin Bamboo validado con el dataset de regresión de junio 2026.
|
|
||||||
- Baseline validado del caso de prueba: **34 casos reales de Banco sin Bamboo**.
|
|
||||||
- Diferencias de nombre bancario incluidas en el reporte.
|
|
||||||
- Posibles cuentas mal digitadas incluidas.
|
|
||||||
- Columna de resolución incluida donde corresponde.
|
|
||||||
- Formato del Google Sheet automatizado.
|
|
||||||
- Altura de filas y ajuste de texto automático.
|
|
||||||
- Reporte final validado visualmente.
|
|
||||||
|
|
||||||
### Trinidad y Tobago - COMPLETADO
|
|
||||||
|
|
||||||
El flujo de Trinidad y Tobago procesa la estructura de nómina utilizada por el país y los archivos bancarios ACH correspondientes.
|
|
||||||
|
|
||||||
Estado actual:
|
|
||||||
|
|
||||||
- Cruce de nómina vs banco operativo.
|
|
||||||
- Validación BambooHR operativa.
|
|
||||||
- Empleados activos e inactivos incluidos.
|
|
||||||
- Normalización de nombres adaptada a nombres con apóstrofes, guiones y variantes.
|
|
||||||
- Matching BambooHR optimizado.
|
|
||||||
- Banco sin Bamboo validado con el dataset de regresión de junio 2026.
|
|
||||||
- Baseline validado del caso de prueba: **0 casos reales de Banco sin Bamboo**.
|
|
||||||
- Casos anteriormente problemáticos fueron reconocidos correctamente por BambooHR.
|
|
||||||
- Columna de resolución incluida donde corresponde.
|
|
||||||
- Formato automático del Google Sheet habilitado.
|
|
||||||
- Reporte final validado visualmente.
|
|
||||||
|
|
||||||
### Próximos países
|
|
||||||
|
|
||||||
La arquitectura no está limitada a GT y TT.
|
|
||||||
|
|
||||||
Los siguientes países se incorporarán progresivamente reutilizando:
|
|
||||||
|
|
||||||
1. El mismo frontend.
|
|
||||||
2. La misma autenticación.
|
|
||||||
3. El mismo control centralizado de acceso.
|
|
||||||
4. La misma estructura de histórico.
|
|
||||||
5. El mismo modelo de generación de reportes.
|
|
||||||
6. Un workflow específico por país cuando la estructura de nómina o banco lo requiera.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## REGLAS DE NEGOCIO
|
## Regla Principal del Sistema
|
||||||
|
|
||||||
1. El país seleccionado determina qué workflow de conciliación debe ejecutarse.
|
Este portal solo centraliza accesos.
|
||||||
2. El período debe contener año, mes y tipo de período antes de ejecutar el cruce.
|
|
||||||
3. La nómina y los archivos bancarios deben analizarse manteniendo sus datos originales para trazabilidad.
|
```txt
|
||||||
4. Las cuentas bancarias se normalizan antes de compararse.
|
Portal Central ≠ Motor de Cruce
|
||||||
5. Los montos se comparan con precisión monetaria y tolerancias controladas.
|
Portal Central = Login + Selección de país + Redirección
|
||||||
6. Una coincidencia por nombre nunca debe forzarse si existen candidatos ambiguos.
|
```
|
||||||
7. BambooHR debe consultar empleados activos e inactivos mediante `onlyCurrent=false`.
|
|
||||||
8. Employee Number tiene prioridad cuando existe una referencia confiable que permita utilizarlo.
|
La lógica operativa debe mantenerse separada por país para evitar mezclar reglas, formatos de nómina, monedas, bancos, excepciones y reportes.
|
||||||
9. Las variaciones de nombre pueden resolverse mediante nombres completos, alias, normalización y reglas de similitud controladas.
|
|
||||||
10. Los casos ambiguos deben permanecer como pendientes de revisión en lugar de convertirse en falsos positivos.
|
|
||||||
11. Un registro de Banco sin Bamboo solo debe permanecer en esa categoría cuando no existe evidencia suficiente para asociarlo con una persona de BambooHR.
|
|
||||||
12. El reporte debe ser legible al generarse; el usuario no debe tener que expandir manualmente filas o columnas para visualizar información.
|
|
||||||
13. Los reportes históricos deben conservar su estado de resolución.
|
|
||||||
14. La incorporación de un nuevo país no debe requerir crear otra aplicación independiente.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CONFIGURACIÓN Y SETUP
|
## Reglas de Negocio
|
||||||
|
|
||||||
|
1. Solo usuarios autorizados pueden acceder al portal.
|
||||||
|
|
||||||
|
2. El usuario debe iniciar sesión mediante Google.
|
||||||
|
|
||||||
|
3. La autenticación se gestiona con Supabase Auth.
|
||||||
|
|
||||||
|
4. Los correos autorizados se validan actualmente desde el frontend.
|
||||||
|
|
||||||
|
5. El portal debe mostrar todos los países disponibles.
|
||||||
|
|
||||||
|
6. Guatemala debe mostrarse como el primer módulo iniciado.
|
||||||
|
|
||||||
|
7. Los países no implementados deben mostrarse como pendientes, no iniciados o deshabilitados.
|
||||||
|
|
||||||
|
8. Al seleccionar un país activo, el sistema debe redireccionar al portal correspondiente.
|
||||||
|
|
||||||
|
9. Al seleccionar un país no activo, el sistema debe informar que el módulo aún no está disponible.
|
||||||
|
|
||||||
|
10. Este portal no debe procesar archivos de nómina ni banco.
|
||||||
|
|
||||||
|
11. Este portal no debe ejecutar conciliaciones.
|
||||||
|
|
||||||
|
12. Este portal no debe almacenar archivos sensibles.
|
||||||
|
|
||||||
|
13. Cada portal país debe manejar su propia lógica, flujo de n8n y reglas de cruce.
|
||||||
|
|
||||||
|
14. Las URLs de redirección deben mantenerse documentadas y controladas.
|
||||||
|
|
||||||
|
15. Cualquier cambio de permisos debe ser validado por IT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Cualquier otro correo que inicie sesión con Google debe ser rechazado y cerrado automáticamente.
|
||||||
|
|
||||||
|
> Nota: En una versión futura, esta validación debería moverse a una tabla de permisos en Supabase o a una política controlada por IT, para evitar mantener correos hardcodeados en el frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Estado actual
|
||||||
|
|
||||||
|
El portal usa Supabase Auth para autenticación, pero no requiere una base de datos compleja para operar como portal central.
|
||||||
|
|
||||||
|
### Uso opcional de Supabase Database
|
||||||
|
|
||||||
|
Si se desea evitar URLs hardcodeadas en el frontend, Supabase puede usarse para guardar configuración dinámica de portales.
|
||||||
|
|
||||||
|
Tabla sugerida:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create table country_portals (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
country_name text not null,
|
||||||
|
country_code text not null unique,
|
||||||
|
portal_url text,
|
||||||
|
status text not null default 'pending',
|
||||||
|
is_visible boolean not null default true,
|
||||||
|
display_order int not null default 0,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Campos sugeridos:
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
| ------------- | ------------------------------------- |
|
||||||
|
| country_name | Nombre del país |
|
||||||
|
| country_code | Código del país |
|
||||||
|
| portal_url | URL del portal específico |
|
||||||
|
| status | Estado del portal |
|
||||||
|
| is_visible | Define si se muestra o no en pantalla |
|
||||||
|
| display_order | Orden visual |
|
||||||
|
| created_at | Fecha de creación |
|
||||||
|
| updated_at | Fecha de actualización |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración y Setup
|
||||||
|
|
||||||
### Prerrequisitos
|
### Prerrequisitos
|
||||||
|
|
||||||
- Node.js compatible con el proyecto.
|
* Node.js v18 o superior.
|
||||||
- npm.
|
* npm.
|
||||||
- Acceso al repositorio Gitea.
|
* Acceso al repositorio.
|
||||||
- Acceso al proyecto Supabase.
|
* Proyecto Supabase configurado.
|
||||||
- Acceso a n8n.
|
* Supabase Auth con Google habilitado.
|
||||||
- Credenciales BambooHR configuradas en n8n.
|
* Variables de entorno configuradas.
|
||||||
- Credenciales Google configuradas en n8n.
|
* URLs de redirección configuradas en Supabase.
|
||||||
- Acceso al servidor donde se publica el frontend.
|
|
||||||
- Workflows de los países habilitados en n8n.
|
|
||||||
|
|
||||||
### Variables de entorno
|
|
||||||
|
|
||||||
Las variables reales deben permanecer fuera del repositorio y documentarse mediante `.env.example`.
|
|
||||||
|
|
||||||
Entre las configuraciones necesarias se encuentran:
|
|
||||||
|
|
||||||
| Variable / configuración | Descripción | Dónde se obtiene |
|
|
||||||
|---|---|---|
|
|
||||||
| Supabase URL | URL del proyecto Supabase | Supabase |
|
|
||||||
| Supabase public/anon key | Clave pública utilizada por el frontend | Supabase |
|
|
||||||
| URLs de webhook n8n | Endpoints para ejecutar cada país | n8n |
|
|
||||||
| Redirect URLs de autenticación | URLs válidas de login/callback | Supabase Auth |
|
|
||||||
|
|
||||||
> **Nunca commitear credenciales, service-role keys, contraseñas, API keys privadas ni secretos de BambooHR al repositorio.**
|
|
||||||
|
|
||||||
### Control de acceso en Supabase
|
|
||||||
|
|
||||||
El acceso centralizado de la aplicación utiliza la tabla:
|
|
||||||
|
|
||||||
```text
|
|
||||||
public.cruce_cuentas_usuarios_autorizados
|
|
||||||
```
|
|
||||||
|
|
||||||
y las funciones/RPC implementadas para validar acceso:
|
|
||||||
|
|
||||||
```text
|
|
||||||
cruce_cuentas_mi_acceso()
|
|
||||||
cruce_cuentas_tiene_acceso_app()
|
|
||||||
```
|
|
||||||
|
|
||||||
El frontend consulta Supabase para determinar si el usuario autenticado tiene acceso a la aplicación.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## INSTALACIÓN / DESARROLLO LOCAL
|
## Variables de Entorno
|
||||||
|
|
||||||
|
Crear un archivo `.env.local` en la raíz del proyecto basado en `.env.example`.
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_SUPABASE_URL="https://TU-PROYECTO.supabase.co"
|
||||||
|
VITE_SUPABASE_ANON_KEY="TU_SUPABASE_ANON_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación / Ejecución Local
|
||||||
|
|
||||||
|
### Instalar dependencias
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.digitalcompass.agency/Isaac_Aracena/cruce-cuentas-glm-centralizado
|
|
||||||
cd cruce-cuentas-glm-centralizado
|
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Crear el archivo de entorno local a partir del ejemplo disponible en el repositorio:
|
### Ejecutar en desarrollo
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Configurar las variables necesarias y ejecutar:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Para generar el build de producción:
|
La aplicación estará disponible en:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generar build de producción
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
El resultado se genera en:
|
### Preview del build
|
||||||
|
|
||||||
```text
|
```bash
|
||||||
/dist
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validar TypeScript
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limpiar build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run clean
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DEPLOY
|
## Configuración de Vite
|
||||||
|
|
||||||
La aplicación se publica bajo:
|
El proyecto está configurado para desplegarse bajo la ruta:
|
||||||
|
|
||||||
```text
|
```txt
|
||||||
https://digitalcompass.agency/cruce-cuentas/
|
|
||||||
```
|
|
||||||
|
|
||||||
Antes de publicar una nueva versión:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Luego debe desplegarse el contenido actualizado de `dist` en la ubicación correspondiente del servidor.
|
|
||||||
|
|
||||||
### Importante
|
|
||||||
|
|
||||||
El `base` de Vite debe mantenerse compatible con:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/cruce-cuentas/
|
/cruce-cuentas/
|
||||||
```
|
```
|
||||||
|
|
||||||
Después del deploy se debe comprobar:
|
En `vite.config.ts`:
|
||||||
|
|
||||||
- Login.
|
```ts
|
||||||
- Recuperación de sesión.
|
base: "/cruce-cuentas/"
|
||||||
- Acceso autorizado/no autorizado.
|
|
||||||
- Carga de archivos.
|
|
||||||
- Ejecución de GT.
|
|
||||||
- Ejecución de TT.
|
|
||||||
- Apertura del Google Sheet generado.
|
|
||||||
- Histórico de reportes.
|
|
||||||
- Persistencia de resoluciones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CÓMO FUNCIONA
|
|
||||||
|
|
||||||
### Flujo paso a paso
|
|
||||||
|
|
||||||
1. El usuario inicia sesión.
|
|
||||||
2. Supabase valida la sesión.
|
|
||||||
3. La aplicación consulta si el usuario está autorizado.
|
|
||||||
4. El usuario selecciona el país.
|
|
||||||
5. Selecciona año, mes y período.
|
|
||||||
6. Adjunta la nómina.
|
|
||||||
7. Adjunta los archivos bancarios requeridos.
|
|
||||||
8. La aplicación envía los datos al webhook de n8n correspondiente al país.
|
|
||||||
9. n8n extrae y normaliza las diferentes hojas de nómina.
|
|
||||||
10. n8n procesa los archivos bancarios.
|
|
||||||
11. BambooHR devuelve la base de empleados utilizando un reporte custom con empleados actuales e históricos.
|
|
||||||
12. Se ejecutan las reglas de matching y conciliación.
|
|
||||||
13. Se clasifican las coincidencias, discrepancias y casos de revisión.
|
|
||||||
14. Se genera el Google Sheet.
|
|
||||||
15. Se aplican automáticamente anchos, ajuste de texto y alturas de filas.
|
|
||||||
16. El enlace del reporte vuelve a la aplicación.
|
|
||||||
17. El usuario puede abrir el Google Sheet y consultar posteriormente el histórico.
|
|
||||||
|
|
||||||
### Triggers
|
|
||||||
|
|
||||||
| Trigger | Frecuencia | Descripción |
|
|
||||||
|---|---|---|
|
|
||||||
| Webhook GT | On demand | Ejecutado al procesar un cruce de Guatemala |
|
|
||||||
| Webhook TT | On demand | Ejecutado al procesar un cruce de Trinidad y Tobago |
|
|
||||||
| Futuros webhooks | On demand | Se agregarán al incorporar nuevos países |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TESTING
|
|
||||||
|
|
||||||
### Casos de prueba mínimos
|
|
||||||
|
|
||||||
| Caso | Input | Output esperado | Estado |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Guatemala - junio 2026 Q30 | Nómina + archivos bancarios reales de prueba | 34 casos reales de Banco sin Bamboo | ✅ Validado |
|
|
||||||
| Trinidad y Tobago - junio 2026 Q30 | Nómina + archivo bancario real de prueba | 0 casos reales de Banco sin Bamboo | ✅ Validado |
|
|
||||||
| Empleado inactivo en BambooHR | Pago de persona histórica | Debe poder localizarse con `onlyCurrent=false` | ✅ Validado |
|
|
||||||
| Variación de nombre | Tildes, segundo nombre, apóstrofes o guiones | Match cuando existe evidencia suficiente | ✅ Validado |
|
|
||||||
| Nombre ambiguo | Dos candidatos posibles | No forzar match | ✅ Validado |
|
|
||||||
| Cuenta diferente con nombre y monto correctos | Nómina y banco con cuentas distintas | Clasificar como posible cuenta mal digitada | ✅ Validado |
|
|
||||||
| Diferencia de monto | Misma persona/cuenta con monto diferente | Crear discrepancia | ✅ Validado |
|
|
||||||
| Banco sin nómina | Pago bancario sin registro equivalente | Mostrar para revisión | ✅ Validado |
|
|
||||||
| Formato del reporte | Observaciones/nombres largos | Contenido visible sin expansión manual | ✅ Implementado |
|
|
||||||
| Rendimiento BambooHR | Miles de empleados | No bloquear el task runner de n8n | ✅ Optimizado |
|
|
||||||
|
|
||||||
### Regresión obligatoria antes de cambios en BambooHR
|
|
||||||
|
|
||||||
Cualquier modificación al matching de BambooHR debe volver a probar como mínimo:
|
|
||||||
|
|
||||||
- Dataset GT de junio 2026.
|
|
||||||
- Dataset TT de junio 2026.
|
|
||||||
- Casos de nombres cortos.
|
|
||||||
- Casos de nombres completos.
|
|
||||||
- Empleados inactivos.
|
|
||||||
- Empleados con ubicación inconsistente.
|
|
||||||
- Nombres con tildes.
|
|
||||||
- Nombres con apóstrofes o guiones.
|
|
||||||
- Ambigüedades.
|
|
||||||
- Tiempo de ejecución del nodo Code.
|
|
||||||
|
|
||||||
No se debe reducir globalmente el nivel de confianza únicamente para hacer desaparecer filas de Banco sin Bamboo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ERRORES CONOCIDOS Y TROUBLESHOOTING
|
|
||||||
|
|
||||||
| Error | Causa probable | Solución |
|
|
||||||
|---|---|---|
|
|
||||||
| `Task execution aborted because runner became unresponsive` | Código de matching recorriendo demasiados empleados/repeticiones | Mantener matching indexado y reutilizar resultados precalculados; no volver a búsquedas O(N×M) sobre toda la base |
|
|
||||||
| Banco sin Bamboo aumenta repentinamente | Reporte BambooHR incompleto o regresión del matching | Revisar `onlyCurrent=false`, normalizador y dataset de regresión |
|
|
||||||
| Falso positivo de BambooHR | Umbral demasiado permisivo o nombre ambiguo | Mantener reglas conservadoras y no aceptar candidatos sin evidencia suficiente |
|
|
||||||
| No abre el reporte | URL del Sheet no llegó correctamente al frontend | Revisar respuesta final del workflow y ejecución de n8n |
|
|
||||||
| Login vuelve a una ruta incorrecta | Redirect URL de Supabase no configurada | Revisar URLs permitidas para `/cruce-cuentas/` y callbacks |
|
|
||||||
| Contenido cortado en Google Sheets | Formato final no aplicado | Revisar las requests de wrap, ancho de columnas y auto-resize de filas |
|
|
||||||
| Workflow rojo en n8n | Error de input, credencial o nodo Code | Revisar `Executions` y el primer nodo que falla |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MONITOREO
|
|
||||||
|
|
||||||
### n8n
|
|
||||||
|
|
||||||
Revisar `Executions` ante cualquier reporte de error.
|
|
||||||
|
|
||||||
- Verde = ejecución terminada correctamente.
|
|
||||||
- Rojo = identificar el primer nodo fallido.
|
|
||||||
- En problemas de BambooHR, revisar especialmente:
|
|
||||||
- HTTP BambooHR.
|
|
||||||
- Normalizar BambooHR.
|
|
||||||
- Cruzar Nómina vs Banco.
|
|
||||||
|
|
||||||
### Supabase
|
|
||||||
|
|
||||||
Revisar:
|
|
||||||
|
|
||||||
- Sesiones.
|
|
||||||
- Usuarios autorizados.
|
|
||||||
- Histórico.
|
|
||||||
- Errores de RLS/RPC cuando corresponda.
|
|
||||||
|
|
||||||
### Output esperado
|
|
||||||
|
|
||||||
Una ejecución correcta debe:
|
|
||||||
|
|
||||||
1. Terminar sin errores.
|
|
||||||
2. Generar un Google Sheet.
|
|
||||||
3. Devolver la URL del reporte a la aplicación.
|
|
||||||
4. Mostrar un reporte legible y completamente formateado.
|
|
||||||
5. Mantener únicamente diferencias reales o casos que requieren revisión humana.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ESTRUCTURA DEL REPOSITORIO
|
|
||||||
|
|
||||||
Estructura principal esperada/actual del frontend:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/cruce-cuentas-glm-centralizado
|
|
||||||
|-- README.md
|
|
||||||
|-- package.json
|
|
||||||
|-- vite.config.ts
|
|
||||||
|-- .env.example
|
|
||||||
|-- /src
|
|
||||||
| |-- componentes y lógica de la aplicación
|
|
||||||
| └-- integraciones del frontend
|
|
||||||
|-- /public
|
|
||||||
|-- /dist
|
|
||||||
| └-- build utilizado para producción
|
|
||||||
└-- ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Los workflows de n8n deben mantenerse exportados y versionados de forma controlada durante la evolución del proyecto.
|
URL de producción esperada:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://digitalcompass.agency/cruce-cuentas/
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CHANGELOG
|
## Supabase Auth
|
||||||
|
|
||||||
### 2026-08-08 - Estado actual
|
En Supabase Auth se debe configurar Google como proveedor de autenticación.
|
||||||
|
|
||||||
- Guatemala completado funcionalmente.
|
|
||||||
- Trinidad y Tobago completado funcionalmente.
|
|
||||||
- Frontend centralizado para múltiples países.
|
|
||||||
- Autenticación y control de acceso mediante Supabase.
|
|
||||||
- Histórico y seguimiento integrados.
|
|
||||||
- Validación BambooHR con empleados activos e inactivos.
|
|
||||||
- Matching BambooHR optimizado para rendimiento y precisión.
|
|
||||||
- Baseline GT: 34 casos reales de Banco sin Bamboo.
|
|
||||||
- Baseline TT: 0 casos reales de Banco sin Bamboo.
|
|
||||||
- Formato automático de Google Sheets implementado.
|
|
||||||
- Arquitectura preparada para incorporar países adicionales.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DECISIONS LOG
|
## Decisions Log
|
||||||
|
|
||||||
### DEC-001 - Una sola aplicación para todos los países
|
### DEC-001 — Crear un portal central en lugar de mezclar todos los cruces
|
||||||
|
|
||||||
- **Contexto:** El proceso de cruce se repetirá en diferentes países.
|
* Fecha: Junio 2026.
|
||||||
- **Opciones consideradas:** Una aplicación por país vs una aplicación centralizada.
|
* Contexto: Cada país puede tener reglas, bancos, formatos y flujos distintos.
|
||||||
- **Decisión:** Mantener una única aplicación y agregar lógica/workflows por país.
|
* Opciones consideradas: Un único sistema para todos los países vs portal central con módulos separados.
|
||||||
- **Razón:** Facilita mantenimiento, acceso, histórico, despliegue y crecimiento.
|
* Decisión: Crear un portal central que redireccione a los portales por país.
|
||||||
|
* Razón: Permite centralizar el acceso sin mezclar la lógica operativa de cada país.
|
||||||
|
|
||||||
### DEC-002 - Supabase como control centralizado de acceso
|
### DEC-002 — Usar Supabase Auth para el acceso
|
||||||
|
|
||||||
- **Contexto:** El acceso no debe depender de listas hardcodeadas en el frontend.
|
* Fecha: Junio 2026.
|
||||||
- **Decisión:** Mantener usuarios autorizados en Supabase.
|
* Contexto: El portal necesita login seguro con usuarios autorizados.
|
||||||
- **Razón:** Permite agregar o retirar acceso sin recompilar la aplicación.
|
* Opciones consideradas: Login manual vs Google SSO vía Supabase.
|
||||||
|
* Decisión: Usar Supabase Auth con Google.
|
||||||
|
* Razón: Aprovecha autenticación existente, reduce complejidad y permite controlar acceso.
|
||||||
|
|
||||||
### DEC-003 - Consultar históricos de BambooHR
|
### DEC-003 — Guatemala como primer país iniciado
|
||||||
|
|
||||||
- **Contexto:** Un pago puede corresponder a una persona actualmente inactiva.
|
* Fecha: Junio 2026.
|
||||||
- **Decisión:** Utilizar el reporte custom de BambooHR con `onlyCurrent=false`.
|
* Contexto: El primer flujo operativo de cruce se está trabajando para Guatemala.
|
||||||
- **Razón:** Banco sin Bamboo debe significar realmente que no existe evidencia suficiente en BambooHR, no simplemente que el empleado está inactivo.
|
* Opciones consideradas: Activar todos los países vs iniciar con Guatemala.
|
||||||
|
* Decisión: Marcar Guatemala como primer módulo iniciado.
|
||||||
|
* Razón: Permite validar la arquitectura antes de escalar a otros países.
|
||||||
|
|
||||||
### DEC-004 - Matching BambooHR conservador
|
### DEC-004 — Separar portal central del motor de cruce
|
||||||
|
|
||||||
- **Contexto:** Nombres pueden variar entre nómina, banco y BambooHR.
|
* Fecha: Junio 2026.
|
||||||
- **Decisión:** Combinar Employee Number, nombres normalizados, aliases y matching indexado, manteniendo reglas estrictas para ambigüedades.
|
* Contexto: El portal central no debe cargar lógica pesada ni procesar datos sensibles.
|
||||||
- **Razón:** Reducir falsos negativos sin generar falsos positivos.
|
* Opciones consideradas: Procesar cruces desde este portal vs redireccionar a portales especializados.
|
||||||
|
* Decisión: Este repositorio solo centraliza y redirecciona.
|
||||||
|
* Razón: Mejora seguridad, mantenimiento y escalabilidad.
|
||||||
|
|
||||||
### DEC-005 - Precalcular e indexar búsquedas de BambooHR
|
### DEC-005 — Mantener países pendientes visibles
|
||||||
|
|
||||||
- **Contexto:** Comparar cada fila contra miles de empleados provocó bloqueos del task runner.
|
* Fecha: Junio 2026.
|
||||||
- **Decisión:** Indexar candidatos y reutilizar resoluciones precalculadas.
|
* Contexto: Se quiere mostrar la visión regional completa aunque solo algunos módulos estén activos.
|
||||||
- **Razón:** Mantener tiempos de ejecución estables.
|
* Opciones consideradas: Ocultar países no activos vs mostrarlos como pendientes.
|
||||||
|
* Decisión: Mostrar países pendientes con aviso de no iniciado.
|
||||||
### DEC-006 - Formato del reporte completamente automático
|
* Razón: Comunica el roadmap regional sin habilitar funciones incompletas.
|
||||||
|
|
||||||
- **Contexto:** Algunas celdas quedaban cortadas y requerían ajustes manuales.
|
|
||||||
- **Decisión:** Aplicar wrap, anchos definidos y auto-resize de filas durante la creación del Google Sheet.
|
|
||||||
- **Razón:** El reporte debe quedar listo para uso inmediatamente después de generarse.
|
|
||||||
|
|
||||||
### DEC-007 - Expansión progresiva a los demás países
|
|
||||||
|
|
||||||
- **Fecha:** 2026-08-08
|
|
||||||
- **Contexto:** GT y TT son únicamente la primera etapa.
|
|
||||||
- **Decisión:** Continuar incorporando países dentro de la misma plataforma.
|
|
||||||
- **Razón:** Mantener una solución GLM centralizada y escalable.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CONTACTOS DEL PROYECTO
|
## Contactos del Proyecto
|
||||||
|
|
||||||
| Rol | Nombre | Contacto |
|
| Rol | Nombre |
|
||||||
|---|---|---|
|
| -------------------- | ------------- |
|
||||||
| IT Manager | Luis Matos | lmatos@gomezleemarketing.com |
|
| Product Owner | Máximo Gomez |
|
||||||
| Developer Principal | Isaac Aracena | iaracena@gomezleemarketing.com |
|
| IT Manager | Luis Matos |
|
||||||
|
| Developer | Isaac Aracena |
|
||||||
|
| Usuarios autorizados | A completar |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DEFINITION OF DONE
|
## Definition of Done
|
||||||
|
|
||||||
### Alcance actual - Guatemala y Trinidad y Tobago
|
* README completo y actualizado.
|
||||||
|
* Portal carga correctamente en local.
|
||||||
- [x] Frontend centralizado operativo.
|
* Portal carga correctamente en `/cruce-cuentas/`.
|
||||||
- [x] Login mediante Supabase.
|
* Supabase Auth configurado.
|
||||||
- [x] Acceso centralizado mediante base de datos.
|
* Google Login funcionando.
|
||||||
- [x] Workflow Guatemala operativo.
|
* Correos autorizados validados.
|
||||||
- [x] Workflow Trinidad y Tobago operativo.
|
* Usuario no autorizado bloqueado.
|
||||||
- [x] Cruce de cuentas y montos.
|
* Tarjetas de países visibles.
|
||||||
- [x] Validación BambooHR.
|
* Guatemala configurado como módulo iniciado.
|
||||||
- [x] Empleados activos e inactivos incluidos.
|
* Países pendientes muestran aviso correcto.
|
||||||
- [x] Matching optimizado para no bloquear n8n.
|
* Países activos redireccionan a su portal correspondiente.
|
||||||
- [x] Reporte Google Sheets generado automáticamente.
|
* Variables de entorno documentadas en `.env.example`.
|
||||||
- [x] Banco sin Bamboo validado contra datasets de regresión.
|
* Código commiteado y pusheado al repositorio.
|
||||||
- [x] Formato automático de filas y columnas.
|
* Probado en ambiente real, no solo local.
|
||||||
- [x] Histórico disponible.
|
* Luis Matos validó el acceso.
|
||||||
- [x] Código frontend versionado en Gitea.
|
* Product Owner aprobó el resultado final.
|
||||||
- [x] Pruebas con datos reales de referencia para GT y TT.
|
|
||||||
- [x] Documentar Board de ejecución definitivo.
|
|
||||||
- [x] Documentar PRD definitivo en el repositorio.
|
|
||||||
- [x] Mantener `.env.example` sincronizado con las variables utilizadas.
|
|
||||||
- [x] Versionar los exports finales de n8n en la estructura definitiva del repositorio.
|
|
||||||
- [x] Registrar validación/cierre formal del negocio cuando corresponda.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Documento mantenido por el equipo GLM IT.**
|
---
|
||||||
|
|
||||||
|
## Bono 14 — Guatemala
|
||||||
|
|
||||||
|
El módulo de Guatemala incluye un cruce anual independiente de **Bono 14**
|
||||||
|
visible únicamente para **Julio · Quincena 15**. Utiliza un Excel anual y CSV
|
||||||
|
bancarios propios, genera un Google Sheet/histórico separado y reutiliza la
|
||||||
|
identidad `GT-AAAA-07-bono14` cuando el mismo año se vuelve a procesar.
|
||||||
|
|
||||||
|
Ver `IMPLEMENTACION_BONO14_GUATEMALA.md`.
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
-- Cruce de Cuentas GLM
|
||||||
|
-- Equivalencias validadas para falsos positivos de "Banco sin BambooHR"
|
||||||
|
-- Ejecutar en el SQL Editor del Supabase empresarial.
|
||||||
|
-- Script idempotente: puede ejecutarse nuevamente sin borrar datos existentes.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_normalizar_nombre(p_value text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
strict
|
||||||
|
as $$
|
||||||
|
select trim(
|
||||||
|
regexp_replace(
|
||||||
|
translate(
|
||||||
|
lower(coalesce(p_value, '')),
|
||||||
|
'áàäâãåéèëêíìïîóòöôõúùüûñçÁÀÄÂÃÅÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑÇ',
|
||||||
|
'aaaaaaeeeeiiiiooooouuuuncAAAAAAEEEEIIIIOOOOOUUUUNC'
|
||||||
|
),
|
||||||
|
'[^a-z0-9]+',
|
||||||
|
' ',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create table if not exists public.cruce_cuentas_bamboo_aliases (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
pais text not null check (pais in ('GT', 'TT')),
|
||||||
|
nombre_origen text not null,
|
||||||
|
nombre_origen_normalizado text not null,
|
||||||
|
cuenta_bancaria text not null default '',
|
||||||
|
bamboo_employee_number text,
|
||||||
|
bamboo_employee_id text,
|
||||||
|
bamboo_nombre text not null,
|
||||||
|
motivo text,
|
||||||
|
comentario text,
|
||||||
|
activo boolean not null default true,
|
||||||
|
validado_por_email text not null,
|
||||||
|
validado_por_nombre text,
|
||||||
|
last_reported_at timestamptz not null default now(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Compatibilidad si una versión anterior del script creó la tabla sin cuenta.
|
||||||
|
alter table public.cruce_cuentas_bamboo_aliases
|
||||||
|
add column if not exists cuenta_bancaria text not null default '';
|
||||||
|
|
||||||
|
drop index if exists public.ux_cruce_cuentas_bamboo_aliases_pais_nombre;
|
||||||
|
|
||||||
|
create unique index if not exists ux_cruce_cuentas_bamboo_aliases_pais_nombre_cuenta
|
||||||
|
on public.cruce_cuentas_bamboo_aliases (pais, nombre_origen_normalizado, cuenta_bancaria);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_aliases_employee_number
|
||||||
|
on public.cruce_cuentas_bamboo_aliases (pais, bamboo_employee_number)
|
||||||
|
where activo = true and bamboo_employee_number is not null;
|
||||||
|
|
||||||
|
create table if not exists public.cruce_cuentas_bamboo_correcciones (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
pais text not null check (pais in ('GT', 'TT')),
|
||||||
|
nombre_origen_principal text not null,
|
||||||
|
nombres_origen text[] not null default array[]::text[],
|
||||||
|
cuenta_bancaria text,
|
||||||
|
monto_banco numeric(18,2),
|
||||||
|
moneda text,
|
||||||
|
bamboo_employee_number text,
|
||||||
|
bamboo_employee_id text,
|
||||||
|
bamboo_nombre text not null,
|
||||||
|
motivo text not null,
|
||||||
|
comentario text,
|
||||||
|
execution_id text,
|
||||||
|
report_url text,
|
||||||
|
period_label text,
|
||||||
|
period_start date,
|
||||||
|
period_end date,
|
||||||
|
solicitado_por_email text not null,
|
||||||
|
solicitado_por_nombre text,
|
||||||
|
destinatarios_rrhh text[] not null default array[]::text[],
|
||||||
|
alias_ids uuid[] not null default array[]::uuid[],
|
||||||
|
estado text not null default 'pendiente_notificacion'
|
||||||
|
check (estado in ('pendiente_notificacion', 'notificado_rrhh', 'corregido_bamboo', 'cancelado')),
|
||||||
|
correo_enviado boolean not null default false,
|
||||||
|
correo_enviado_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_pais_created
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (pais, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_estado
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (estado, created_at desc);
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_set_normalized()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.pais := upper(trim(new.pais));
|
||||||
|
new.nombre_origen := trim(new.nombre_origen);
|
||||||
|
new.nombre_origen_normalizado := public.cruce_cuentas_normalizar_nombre(new.nombre_origen);
|
||||||
|
new.cuenta_bancaria := regexp_replace(coalesce(new.cuenta_bancaria, ''), '[^0-9A-Za-z]+', '', 'g');
|
||||||
|
new.validado_por_email := lower(trim(new.validado_por_email));
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cruce_cuentas_bamboo_alias_normalized
|
||||||
|
on public.cruce_cuentas_bamboo_aliases;
|
||||||
|
|
||||||
|
create trigger trg_cruce_cuentas_bamboo_alias_normalized
|
||||||
|
before insert or update on public.cruce_cuentas_bamboo_aliases
|
||||||
|
for each row execute function public.cruce_cuentas_bamboo_set_normalized();
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_touch_updated_at()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cruce_cuentas_bamboo_correcciones_updated_at
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones;
|
||||||
|
|
||||||
|
create trigger trg_cruce_cuentas_bamboo_correcciones_updated_at
|
||||||
|
before update on public.cruce_cuentas_bamboo_correcciones
|
||||||
|
for each row execute function public.cruce_cuentas_bamboo_touch_updated_at();
|
||||||
|
|
||||||
|
-- RPC consumida únicamente por los workflows generadores de reportes.
|
||||||
|
-- Siempre devuelve un objeto con aliases=[], incluso cuando no existen registros.
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_aliases_activos(p_pais text)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'pais', upper(trim(coalesce(p_pais, ''))),
|
||||||
|
'aliases', coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', a.id,
|
||||||
|
'pais', a.pais,
|
||||||
|
'nombre_origen', a.nombre_origen,
|
||||||
|
'nombre_origen_normalizado', a.nombre_origen_normalizado,
|
||||||
|
'cuenta_bancaria', a.cuenta_bancaria,
|
||||||
|
'bamboo_employee_number', a.bamboo_employee_number,
|
||||||
|
'bamboo_employee_id', a.bamboo_employee_id,
|
||||||
|
'bamboo_nombre', a.bamboo_nombre,
|
||||||
|
'motivo', a.motivo,
|
||||||
|
'activo', a.activo
|
||||||
|
)
|
||||||
|
order by a.updated_at desc
|
||||||
|
) filter (where a.id is not null),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from public.cruce_cuentas_bamboo_aliases a
|
||||||
|
where a.activo = true
|
||||||
|
and a.pais = upper(trim(coalesce(p_pais, '')))
|
||||||
|
and upper(trim(coalesce(p_pais, ''))) in ('GT', 'TT');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter table public.cruce_cuentas_bamboo_aliases enable row level security;
|
||||||
|
alter table public.cruce_cuentas_bamboo_correcciones enable row level security;
|
||||||
|
|
||||||
|
-- El navegador nunca escribe ni lee estas tablas directamente.
|
||||||
|
-- Toda operación pasa por n8n con service_role.
|
||||||
|
revoke all on table public.cruce_cuentas_bamboo_aliases from anon, authenticated;
|
||||||
|
revoke all on table public.cruce_cuentas_bamboo_correcciones from anon, authenticated;
|
||||||
|
grant all on table public.cruce_cuentas_bamboo_aliases to service_role;
|
||||||
|
grant all on table public.cruce_cuentas_bamboo_correcciones to service_role;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_bamboo_aliases_activos(text) from public, anon, authenticated;
|
||||||
|
grant execute on function public.cruce_cuentas_bamboo_aliases_activos(text) to service_role;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- HISTÓRICOS: conservar y recuperar Banco sin Bamboo por reporte
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Los workflows ya envían el detalle de cada caso. Esta columna hace que el
|
||||||
|
-- detalle quede persistido y pueda abrirse días después desde Históricos.
|
||||||
|
alter table public.cruces_cuentas_gt_reportes
|
||||||
|
add column if not exists detalle_banco_sin_bamboo jsonb not null default '[]'::jsonb;
|
||||||
|
|
||||||
|
-- Identificador generado por el navegador para trazabilidad de una ejecución.
|
||||||
|
-- No se usa como llave única porque no se reintenta automáticamente un POST
|
||||||
|
-- de conciliación (evita duplicar reportes, correos o Sheets).
|
||||||
|
alter table public.cruces_cuentas_gt_reportes
|
||||||
|
add column if not exists client_request_id text;
|
||||||
|
|
||||||
|
update public.cruces_cuentas_gt_reportes
|
||||||
|
set detalle_banco_sin_bamboo = '[]'::jsonb
|
||||||
|
where detalle_banco_sin_bamboo is null;
|
||||||
|
|
||||||
|
create index if not exists ix_cruces_cuentas_reportes_client_request
|
||||||
|
on public.cruces_cuentas_gt_reportes (client_request_id)
|
||||||
|
where client_request_id is not null;
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_execution
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (execution_id, created_at desc)
|
||||||
|
where execution_id is not null;
|
||||||
|
|
||||||
|
-- Reemplaza la RPC histórica manteniendo exactamente la misma firma y
|
||||||
|
-- estructura pública, pero excluye detalle_banco_sin_bamboo de la lista para
|
||||||
|
-- no descargar todos los casos cada vez que se abre Históricos. El detalle se
|
||||||
|
-- solicita únicamente cuando el usuario pulsa Banco sin Bamboo.
|
||||||
|
create or replace function public.cruce_cuentas_get_historicos(
|
||||||
|
p_country text,
|
||||||
|
p_page integer default 1,
|
||||||
|
p_page_size integer default 10,
|
||||||
|
p_search text default null,
|
||||||
|
p_status text default null
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_country text := upper(trim(coalesce(p_country, ''));
|
||||||
|
v_page integer := greatest(coalesce(p_page, 1), 1);
|
||||||
|
v_page_size integer := least(greatest(coalesce(p_page_size, 10), 1), 100);
|
||||||
|
v_offset integer;
|
||||||
|
v_search text := nullif(trim(coalesce(p_search, '')), '');
|
||||||
|
v_status text := nullif(trim(coalesce(p_status, '')), '');
|
||||||
|
v_total bigint;
|
||||||
|
v_filtered_total bigint;
|
||||||
|
v_reports jsonb;
|
||||||
|
begin
|
||||||
|
if v_country not in ('GT', 'TT') then
|
||||||
|
raise exception 'País no permitido: %', v_country;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_offset := (v_page - 1) * v_page_size;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_filtered_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
);
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
(to_jsonb(q) - 'detalle_banco_sin_bamboo')
|
||||||
|
order by q.created_at desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_reports
|
||||||
|
from (
|
||||||
|
select r.*
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
)
|
||||||
|
order by r.created_at desc
|
||||||
|
limit v_page_size
|
||||||
|
offset v_offset
|
||||||
|
) q;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'country', v_country,
|
||||||
|
'page', v_page,
|
||||||
|
'page_size', v_page_size,
|
||||||
|
'total', v_total,
|
||||||
|
'filtered_total', v_filtered_total,
|
||||||
|
'total_pages', greatest(ceil(v_filtered_total::numeric / v_page_size)::integer, 1),
|
||||||
|
'reports', v_reports
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) from public, anon;
|
||||||
|
grant execute on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- RPC de detalle utilizada por Históricos al abrir un reporte específico.
|
||||||
|
-- No otorga SELECT directo sobre las tablas de aliases/correcciones.
|
||||||
|
create or replace function public.cruce_cuentas_get_banco_sin_bamboo_reporte(
|
||||||
|
p_report_id text,
|
||||||
|
p_country text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_country text := upper(trim(coalesce(p_country, ''));
|
||||||
|
v_report public.cruces_cuentas_gt_reportes%rowtype;
|
||||||
|
v_corrections jsonb := '[]'::jsonb;
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'No autenticado.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_country not in ('GT', 'TT') then
|
||||||
|
raise exception 'País no permitido: %', v_country;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select r.*
|
||||||
|
into v_report
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where r.id::text = trim(coalesce(p_report_id, ''))
|
||||||
|
and upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', false,
|
||||||
|
'message', 'No se encontró el reporte solicitado.'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', c.id,
|
||||||
|
'pais', c.pais,
|
||||||
|
'nombre_origen_principal', c.nombre_origen_principal,
|
||||||
|
'nombres_origen', c.nombres_origen,
|
||||||
|
'cuenta_bancaria', c.cuenta_bancaria,
|
||||||
|
'bamboo_employee_number', c.bamboo_employee_number,
|
||||||
|
'bamboo_nombre', c.bamboo_nombre,
|
||||||
|
'estado', c.estado,
|
||||||
|
'correo_enviado', c.correo_enviado,
|
||||||
|
'correo_enviado_at', c.correo_enviado_at,
|
||||||
|
'created_at', c.created_at
|
||||||
|
)
|
||||||
|
order by c.created_at desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_corrections
|
||||||
|
from public.cruce_cuentas_bamboo_correcciones c
|
||||||
|
where c.pais = v_country
|
||||||
|
and (
|
||||||
|
c.execution_id = v_report.id::text
|
||||||
|
or (
|
||||||
|
nullif(trim(coalesce(c.execution_id, '')), '') is null
|
||||||
|
and nullif(trim(coalesce(c.report_url, '')), '') is not null
|
||||||
|
and c.report_url = v_report.report_url
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'report', jsonb_build_object(
|
||||||
|
'id', v_report.id,
|
||||||
|
'country', v_report.country,
|
||||||
|
'period_label', v_report.period_label,
|
||||||
|
'period_start', v_report.period_start,
|
||||||
|
'period_end', v_report.period_end,
|
||||||
|
'report_url', v_report.report_url,
|
||||||
|
'banco_sin_bamboo', v_report.banco_sin_bamboo
|
||||||
|
),
|
||||||
|
'cases', coalesce(v_report.detalle_banco_sin_bamboo, '[]'::jsonb),
|
||||||
|
'corrections', v_corrections
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text, text) from public, anon;
|
||||||
|
grant execute on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text, text) to authenticated;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- Validaciones opcionales después de ejecutar:
|
||||||
|
-- select public.cruce_cuentas_bamboo_aliases_activos('GT');
|
||||||
|
-- select public.cruce_cuentas_bamboo_aliases_activos('TT');
|
||||||
|
-- select column_name, data_type from information_schema.columns
|
||||||
|
-- where table_schema='public' and table_name='cruces_cuentas_gt_reportes'
|
||||||
|
-- and column_name in ('detalle_banco_sin_bamboo', 'client_request_id');
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
-- Cruce de Cuentas GLM
|
||||||
|
-- Equivalencias validadas para falsos positivos de "Banco sin BambooHR"
|
||||||
|
-- Ejecutar en el SQL Editor del Supabase empresarial.
|
||||||
|
-- Script idempotente: puede ejecutarse nuevamente sin borrar datos existentes.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_normalizar_nombre(p_value text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
strict
|
||||||
|
as $$
|
||||||
|
select trim(
|
||||||
|
regexp_replace(
|
||||||
|
translate(
|
||||||
|
lower(coalesce(p_value, '')),
|
||||||
|
'áàäâãåéèëêíìïîóòöôõúùüûñçÁÀÄÂÃÅÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑÇ',
|
||||||
|
'aaaaaaeeeeiiiiooooouuuuncAAAAAAEEEEIIIIOOOOOUUUUNC'
|
||||||
|
),
|
||||||
|
'[^a-z0-9]+',
|
||||||
|
' ',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create table if not exists public.cruce_cuentas_bamboo_aliases (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
pais text not null check (pais in ('GT', 'TT')),
|
||||||
|
nombre_origen text not null,
|
||||||
|
nombre_origen_normalizado text not null,
|
||||||
|
cuenta_bancaria text not null default '',
|
||||||
|
bamboo_employee_number text,
|
||||||
|
bamboo_employee_id text,
|
||||||
|
bamboo_nombre text not null,
|
||||||
|
motivo text,
|
||||||
|
comentario text,
|
||||||
|
activo boolean not null default true,
|
||||||
|
validado_por_email text not null,
|
||||||
|
validado_por_nombre text,
|
||||||
|
last_reported_at timestamptz not null default now(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Compatibilidad si una versión anterior del script creó la tabla sin cuenta.
|
||||||
|
alter table public.cruce_cuentas_bamboo_aliases
|
||||||
|
add column if not exists cuenta_bancaria text not null default '';
|
||||||
|
|
||||||
|
drop index if exists public.ux_cruce_cuentas_bamboo_aliases_pais_nombre;
|
||||||
|
|
||||||
|
create unique index if not exists ux_cruce_cuentas_bamboo_aliases_pais_nombre_cuenta
|
||||||
|
on public.cruce_cuentas_bamboo_aliases (pais, nombre_origen_normalizado, cuenta_bancaria);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_aliases_employee_number
|
||||||
|
on public.cruce_cuentas_bamboo_aliases (pais, bamboo_employee_number)
|
||||||
|
where activo = true and bamboo_employee_number is not null;
|
||||||
|
|
||||||
|
create table if not exists public.cruce_cuentas_bamboo_correcciones (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
pais text not null check (pais in ('GT', 'TT')),
|
||||||
|
nombre_origen_principal text not null,
|
||||||
|
nombres_origen text[] not null default array[]::text[],
|
||||||
|
cuenta_bancaria text,
|
||||||
|
monto_banco numeric(18,2),
|
||||||
|
moneda text,
|
||||||
|
bamboo_employee_number text,
|
||||||
|
bamboo_employee_id text,
|
||||||
|
bamboo_nombre text not null,
|
||||||
|
motivo text not null,
|
||||||
|
comentario text,
|
||||||
|
execution_id text,
|
||||||
|
report_url text,
|
||||||
|
period_label text,
|
||||||
|
period_start date,
|
||||||
|
period_end date,
|
||||||
|
solicitado_por_email text not null,
|
||||||
|
solicitado_por_nombre text,
|
||||||
|
destinatarios_rrhh text[] not null default array[]::text[],
|
||||||
|
alias_ids uuid[] not null default array[]::uuid[],
|
||||||
|
estado text not null default 'pendiente_notificacion'
|
||||||
|
check (estado in ('pendiente_notificacion', 'notificado_rrhh', 'corregido_bamboo', 'cancelado')),
|
||||||
|
correo_enviado boolean not null default false,
|
||||||
|
correo_enviado_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_pais_created
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (pais, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_estado
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (estado, created_at desc);
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_set_normalized()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.pais := upper(trim(new.pais));
|
||||||
|
new.nombre_origen := trim(new.nombre_origen);
|
||||||
|
new.nombre_origen_normalizado := public.cruce_cuentas_normalizar_nombre(new.nombre_origen);
|
||||||
|
new.cuenta_bancaria := regexp_replace(coalesce(new.cuenta_bancaria, ''), '[^0-9A-Za-z]+', '', 'g');
|
||||||
|
new.validado_por_email := lower(trim(new.validado_por_email));
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cruce_cuentas_bamboo_alias_normalized
|
||||||
|
on public.cruce_cuentas_bamboo_aliases;
|
||||||
|
|
||||||
|
create trigger trg_cruce_cuentas_bamboo_alias_normalized
|
||||||
|
before insert or update on public.cruce_cuentas_bamboo_aliases
|
||||||
|
for each row execute function public.cruce_cuentas_bamboo_set_normalized();
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_touch_updated_at()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cruce_cuentas_bamboo_correcciones_updated_at
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones;
|
||||||
|
|
||||||
|
create trigger trg_cruce_cuentas_bamboo_correcciones_updated_at
|
||||||
|
before update on public.cruce_cuentas_bamboo_correcciones
|
||||||
|
for each row execute function public.cruce_cuentas_bamboo_touch_updated_at();
|
||||||
|
|
||||||
|
-- RPC consumida únicamente por los workflows generadores de reportes.
|
||||||
|
-- Siempre devuelve un objeto con aliases=[], incluso cuando no existen registros.
|
||||||
|
create or replace function public.cruce_cuentas_bamboo_aliases_activos(p_pais text)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'pais', upper(trim(coalesce(p_pais, ''))),
|
||||||
|
'aliases', coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', a.id,
|
||||||
|
'pais', a.pais,
|
||||||
|
'nombre_origen', a.nombre_origen,
|
||||||
|
'nombre_origen_normalizado', a.nombre_origen_normalizado,
|
||||||
|
'cuenta_bancaria', a.cuenta_bancaria,
|
||||||
|
'bamboo_employee_number', a.bamboo_employee_number,
|
||||||
|
'bamboo_employee_id', a.bamboo_employee_id,
|
||||||
|
'bamboo_nombre', a.bamboo_nombre,
|
||||||
|
'motivo', a.motivo,
|
||||||
|
'activo', a.activo
|
||||||
|
)
|
||||||
|
order by a.updated_at desc
|
||||||
|
) filter (where a.id is not null),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from public.cruce_cuentas_bamboo_aliases a
|
||||||
|
where a.activo = true
|
||||||
|
and a.pais = upper(trim(coalesce(p_pais, '')))
|
||||||
|
and upper(trim(coalesce(p_pais, ''))) in ('GT', 'TT');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter table public.cruce_cuentas_bamboo_aliases enable row level security;
|
||||||
|
alter table public.cruce_cuentas_bamboo_correcciones enable row level security;
|
||||||
|
|
||||||
|
-- El navegador nunca escribe ni lee estas tablas directamente.
|
||||||
|
-- Toda operación pasa por n8n con service_role.
|
||||||
|
revoke all on table public.cruce_cuentas_bamboo_aliases from anon, authenticated;
|
||||||
|
revoke all on table public.cruce_cuentas_bamboo_correcciones from anon, authenticated;
|
||||||
|
grant all on table public.cruce_cuentas_bamboo_aliases to service_role;
|
||||||
|
grant all on table public.cruce_cuentas_bamboo_correcciones to service_role;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_bamboo_aliases_activos(text) from public, anon, authenticated;
|
||||||
|
grant execute on function public.cruce_cuentas_bamboo_aliases_activos(text) to service_role;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- HISTÓRICOS: conservar y recuperar Banco sin Bamboo por reporte
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Los workflows ya envían el detalle de cada caso. Esta columna hace que el
|
||||||
|
-- detalle quede persistido y pueda abrirse días después desde Históricos.
|
||||||
|
alter table public.cruces_cuentas_gt_reportes
|
||||||
|
add column if not exists detalle_banco_sin_bamboo jsonb not null default '[]'::jsonb;
|
||||||
|
|
||||||
|
-- Identificador generado por el navegador para trazabilidad de una ejecución.
|
||||||
|
-- No se usa como llave única porque no se reintenta automáticamente un POST
|
||||||
|
-- de conciliación (evita duplicar reportes, correos o Sheets).
|
||||||
|
alter table public.cruces_cuentas_gt_reportes
|
||||||
|
add column if not exists client_request_id text;
|
||||||
|
|
||||||
|
update public.cruces_cuentas_gt_reportes
|
||||||
|
set detalle_banco_sin_bamboo = '[]'::jsonb
|
||||||
|
where detalle_banco_sin_bamboo is null;
|
||||||
|
|
||||||
|
create index if not exists ix_cruces_cuentas_reportes_client_request
|
||||||
|
on public.cruces_cuentas_gt_reportes (client_request_id)
|
||||||
|
where client_request_id is not null;
|
||||||
|
|
||||||
|
create index if not exists ix_cruce_cuentas_bamboo_correcciones_execution
|
||||||
|
on public.cruce_cuentas_bamboo_correcciones (execution_id, created_at desc)
|
||||||
|
where execution_id is not null;
|
||||||
|
|
||||||
|
-- Reemplaza la RPC histórica manteniendo exactamente la misma firma y
|
||||||
|
-- estructura pública, pero excluye detalle_banco_sin_bamboo de la lista para
|
||||||
|
-- no descargar todos los casos cada vez que se abre Históricos. El detalle se
|
||||||
|
-- solicita únicamente cuando el usuario pulsa Banco sin Bamboo.
|
||||||
|
create or replace function public.cruce_cuentas_get_historicos(
|
||||||
|
p_country text,
|
||||||
|
p_page integer default 1,
|
||||||
|
p_page_size integer default 10,
|
||||||
|
p_search text default null,
|
||||||
|
p_status text default null
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_country text := upper(trim(coalesce(p_country, ''));
|
||||||
|
v_page integer := greatest(coalesce(p_page, 1), 1);
|
||||||
|
v_page_size integer := least(greatest(coalesce(p_page_size, 10), 1), 100);
|
||||||
|
v_offset integer;
|
||||||
|
v_search text := nullif(trim(coalesce(p_search, '')), '');
|
||||||
|
v_status text := nullif(trim(coalesce(p_status, '')), '');
|
||||||
|
v_total bigint;
|
||||||
|
v_filtered_total bigint;
|
||||||
|
v_reports jsonb;
|
||||||
|
begin
|
||||||
|
if v_country not in ('GT', 'TT') then
|
||||||
|
raise exception 'País no permitido: %', v_country;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_offset := (v_page - 1) * v_page_size;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_filtered_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
);
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
(to_jsonb(q) - 'detalle_banco_sin_bamboo')
|
||||||
|
order by q.created_at desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_reports
|
||||||
|
from (
|
||||||
|
select r.*
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
)
|
||||||
|
order by r.created_at desc
|
||||||
|
limit v_page_size
|
||||||
|
offset v_offset
|
||||||
|
) q;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'country', v_country,
|
||||||
|
'page', v_page,
|
||||||
|
'page_size', v_page_size,
|
||||||
|
'total', v_total,
|
||||||
|
'filtered_total', v_filtered_total,
|
||||||
|
'total_pages', greatest(ceil(v_filtered_total::numeric / v_page_size)::integer, 1),
|
||||||
|
'reports', v_reports
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) from public, anon;
|
||||||
|
grant execute on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- RPC de detalle utilizada por Históricos al abrir un reporte específico.
|
||||||
|
-- No otorga SELECT directo sobre las tablas de aliases/correcciones.
|
||||||
|
create or replace function public.cruce_cuentas_get_banco_sin_bamboo_reporte(
|
||||||
|
p_report_id text,
|
||||||
|
p_country text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_country text := upper(trim(coalesce(p_country, ''));
|
||||||
|
v_report public.cruces_cuentas_gt_reportes%rowtype;
|
||||||
|
v_corrections jsonb := '[]'::jsonb;
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'No autenticado.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_country not in ('GT', 'TT') then
|
||||||
|
raise exception 'País no permitido: %', v_country;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select r.*
|
||||||
|
into v_report
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where r.id::text = trim(coalesce(p_report_id, ''))
|
||||||
|
and upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', false,
|
||||||
|
'message', 'No se encontró el reporte solicitado.'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', c.id,
|
||||||
|
'pais', c.pais,
|
||||||
|
'nombre_origen_principal', c.nombre_origen_principal,
|
||||||
|
'nombres_origen', c.nombres_origen,
|
||||||
|
'cuenta_bancaria', c.cuenta_bancaria,
|
||||||
|
'bamboo_employee_number', c.bamboo_employee_number,
|
||||||
|
'bamboo_nombre', c.bamboo_nombre,
|
||||||
|
'estado', c.estado,
|
||||||
|
'correo_enviado', c.correo_enviado,
|
||||||
|
'correo_enviado_at', c.correo_enviado_at,
|
||||||
|
'created_at', c.created_at
|
||||||
|
)
|
||||||
|
order by c.created_at desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_corrections
|
||||||
|
from public.cruce_cuentas_bamboo_correcciones c
|
||||||
|
where c.pais = v_country
|
||||||
|
and (
|
||||||
|
c.execution_id = v_report.id::text
|
||||||
|
or (
|
||||||
|
nullif(trim(coalesce(c.execution_id, '')), '') is null
|
||||||
|
and nullif(trim(coalesce(c.report_url, '')), '') is not null
|
||||||
|
and c.report_url = v_report.report_url
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'report', jsonb_build_object(
|
||||||
|
'id', v_report.id,
|
||||||
|
'country', v_report.country,
|
||||||
|
'period_label', v_report.period_label,
|
||||||
|
'period_start', v_report.period_start,
|
||||||
|
'period_end', v_report.period_end,
|
||||||
|
'report_url', v_report.report_url,
|
||||||
|
'banco_sin_bamboo', v_report.banco_sin_bamboo
|
||||||
|
),
|
||||||
|
'cases', coalesce(v_report.detalle_banco_sin_bamboo, '[]'::jsonb),
|
||||||
|
'corrections', v_corrections
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text, text) from public, anon;
|
||||||
|
grant execute on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text, text) to authenticated;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- Validaciones opcionales después de ejecutar:
|
||||||
|
-- select public.cruce_cuentas_bamboo_aliases_activos('GT');
|
||||||
|
-- select public.cruce_cuentas_bamboo_aliases_activos('TT');
|
||||||
|
-- select column_name, data_type from information_schema.columns
|
||||||
|
-- where table_schema='public' and table_name='cruces_cuentas_gt_reportes'
|
||||||
|
-- and column_name in ('detalle_banco_sin_bamboo', 'client_request_id');
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Cruce de Cuentas GLM - Bono 14 Guatemala
|
||||||
|
-- Script incremental / idempotente
|
||||||
|
-- ============================================================
|
||||||
|
-- Objetivo:
|
||||||
|
-- Habilitar y verificar que Bono 14 use la MISMA infraestructura
|
||||||
|
-- de reporte único ya instalada, pero con una identidad independiente:
|
||||||
|
--
|
||||||
|
-- GT-<AÑO>-07-bono14
|
||||||
|
--
|
||||||
|
-- Ejemplo:
|
||||||
|
-- GT-2026-07-bono14
|
||||||
|
--
|
||||||
|
-- No crea otra tabla de históricos y no elimina datos existentes.
|
||||||
|
-- Puede ejecutarse nuevamente sin borrar reportes.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1. Validación de prerrequisitos de la versión "reporte único"
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_missing text[] := array[]::text[];
|
||||||
|
begin
|
||||||
|
if to_regclass('public.cruces_cuentas_gt_reportes') is null then
|
||||||
|
raise exception
|
||||||
|
'No existe public.cruces_cuentas_gt_reportes. Instala primero la estructura base de Cruce de Cuentas.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'report_identity'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'report_identity');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'updated_at'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'updated_at');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'country'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'country');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'year'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'year');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'month'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'month');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'cruces_cuentas_gt_reportes'
|
||||||
|
and column_name = 'period_type'
|
||||||
|
) then
|
||||||
|
v_missing := array_append(v_missing, 'period_type');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if cardinality(v_missing) > 0 then
|
||||||
|
raise exception
|
||||||
|
'Faltan columnas requeridas en public.cruces_cuentas_gt_reportes: %. Ejecuta primero el script de REPORTE ÚNICO POR PERÍODO.',
|
||||||
|
array_to_string(v_missing, ', ');
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2. Constructor canónico de identidad
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Se mantiene genérico para nómina GT, nómina TT y Bono 14.
|
||||||
|
create or replace function public.cruce_cuentas_build_report_identity(
|
||||||
|
p_country text,
|
||||||
|
p_year integer,
|
||||||
|
p_month integer,
|
||||||
|
p_period_type text
|
||||||
|
)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
upper(trim(coalesce(p_country, 'GT')))
|
||||||
|
|| '-' ||
|
||||||
|
coalesce(p_year, 0)::text
|
||||||
|
|| '-' ||
|
||||||
|
lpad(coalesce(p_month, 0)::text, 2, '0')
|
||||||
|
|| '-' ||
|
||||||
|
lower(trim(coalesce(p_period_type, 'periodo')));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3. Trigger de identidad / updated_at
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Reinstalado de forma idempotente para garantizar que un UPSERT de
|
||||||
|
-- Bono 14 mantenga la misma identidad y actualice updated_at.
|
||||||
|
create or replace function public.cruce_cuentas_set_report_identity()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_canonical text;
|
||||||
|
begin
|
||||||
|
v_canonical :=
|
||||||
|
public.cruce_cuentas_build_report_identity(
|
||||||
|
coalesce(new.country, 'GT'),
|
||||||
|
new.year,
|
||||||
|
new.month,
|
||||||
|
new.period_type
|
||||||
|
);
|
||||||
|
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
if nullif(trim(coalesce(new.report_identity, '')), '') is null then
|
||||||
|
new.report_identity := v_canonical;
|
||||||
|
end if;
|
||||||
|
else
|
||||||
|
-- Los duplicados históricos antiguos conservan su sufijo :legacy:.
|
||||||
|
if coalesce(old.report_identity, '') like '%:legacy:%' then
|
||||||
|
new.report_identity := old.report_identity;
|
||||||
|
else
|
||||||
|
new.report_identity := v_canonical;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cruce_cuentas_report_identity
|
||||||
|
on public.cruces_cuentas_gt_reportes;
|
||||||
|
|
||||||
|
create trigger trg_cruce_cuentas_report_identity
|
||||||
|
before insert or update on public.cruces_cuentas_gt_reportes
|
||||||
|
for each row
|
||||||
|
execute function public.cruce_cuentas_set_report_identity();
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 4. Garantizar unicidad por identidad
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- La versión de reporte único ya debe tener este índice. Se valida antes
|
||||||
|
-- de recrearlo para no alterar ni eliminar históricos existentes.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_indexes
|
||||||
|
where schemaname = 'public'
|
||||||
|
and tablename = 'cruces_cuentas_gt_reportes'
|
||||||
|
and indexname = 'ux_cruces_cuentas_reportes_report_identity'
|
||||||
|
) then
|
||||||
|
if exists (
|
||||||
|
select report_identity
|
||||||
|
from public.cruces_cuentas_gt_reportes
|
||||||
|
where report_identity is not null
|
||||||
|
group by report_identity
|
||||||
|
having count(*) > 1
|
||||||
|
) then
|
||||||
|
raise exception
|
||||||
|
'Existen report_identity duplicados. Ejecuta primero el script de REPORTE ÚNICO POR PERÍODO para aplicar su backfill seguro antes de Bono 14.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
create unique index ux_cruces_cuentas_reportes_report_identity
|
||||||
|
on public.cruces_cuentas_gt_reportes (report_identity);
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Índice auxiliar específico para consultas/soporte de Bono 14.
|
||||||
|
create index if not exists ix_cruces_cuentas_gt_reportes_bono14_updated
|
||||||
|
on public.cruces_cuentas_gt_reportes (year, updated_at desc)
|
||||||
|
where upper(coalesce(country, 'GT')) = 'GT'
|
||||||
|
and lower(coalesce(period_type, '')) = 'bono14';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 5. RPC que n8n consulta ANTES de crear/reutilizar el Google Sheet
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
create or replace function public.cruce_cuentas_get_reporte_periodo(
|
||||||
|
p_country text,
|
||||||
|
p_year integer,
|
||||||
|
p_month integer,
|
||||||
|
p_period_type text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_identity text;
|
||||||
|
v_report public.cruces_cuentas_gt_reportes%rowtype;
|
||||||
|
begin
|
||||||
|
v_identity :=
|
||||||
|
public.cruce_cuentas_build_report_identity(
|
||||||
|
p_country,
|
||||||
|
p_year,
|
||||||
|
p_month,
|
||||||
|
p_period_type
|
||||||
|
);
|
||||||
|
|
||||||
|
select r.*
|
||||||
|
into v_report
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where r.report_identity = v_identity
|
||||||
|
order by
|
||||||
|
r.updated_at desc nulls last,
|
||||||
|
r.created_at desc nulls last
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'found', false,
|
||||||
|
'report_identity', v_identity,
|
||||||
|
'report', null
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'found', true,
|
||||||
|
'report_identity', v_identity,
|
||||||
|
'report', jsonb_build_object(
|
||||||
|
'id', v_report.id,
|
||||||
|
'report_identity', v_report.report_identity,
|
||||||
|
'country', v_report.country,
|
||||||
|
'year', v_report.year,
|
||||||
|
'month', v_report.month,
|
||||||
|
'period_type', v_report.period_type,
|
||||||
|
'period_label', v_report.period_label,
|
||||||
|
'spreadsheet_id', v_report.spreadsheet_id,
|
||||||
|
'report_url', v_report.report_url,
|
||||||
|
'estado', v_report.estado,
|
||||||
|
'created_at', v_report.created_at,
|
||||||
|
'updated_at', v_report.updated_at
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_reporte_periodo(text, integer, integer, text)
|
||||||
|
from public, anon, authenticated;
|
||||||
|
|
||||||
|
grant execute on function public.cruce_cuentas_get_reporte_periodo(text, integer, integer, text)
|
||||||
|
to service_role;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VALIDACIÓN FINAL (solo lectura)
|
||||||
|
-- Debe devolver: GT-2026-07-bono14
|
||||||
|
-- ============================================================
|
||||||
|
select
|
||||||
|
public.cruce_cuentas_build_report_identity(
|
||||||
|
'GT',
|
||||||
|
2026,
|
||||||
|
7,
|
||||||
|
'bono14'
|
||||||
|
) as identidad_bono14_esperada;
|
||||||
|
|
||||||
|
-- Si ya existe un Bono 14 2026, esta consulta devuelve found=true.
|
||||||
|
-- Si todavía no se ha generado, devuelve found=false (lo esperado antes
|
||||||
|
-- de la primera ejecución).
|
||||||
|
select public.cruce_cuentas_get_reporte_periodo(
|
||||||
|
'GT',
|
||||||
|
2026,
|
||||||
|
7,
|
||||||
|
'bono14'
|
||||||
|
) as estado_bono14_2026;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- RPC paginada para Reportes Históricos del Cruce de Cuentas GLM.
|
||||||
|
-- Ejecutar una sola vez en el SQL Editor del Supabase empresarial.
|
||||||
|
-- La función filtra el país en la base de datos antes de paginar.
|
||||||
|
|
||||||
|
create index if not exists idx_cruces_cuentas_reportes_country_created
|
||||||
|
on public.cruces_cuentas_gt_reportes (country, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_cruces_cuentas_reportes_country_status_created
|
||||||
|
on public.cruces_cuentas_gt_reportes (country, estado, created_at desc);
|
||||||
|
|
||||||
|
create or replace function public.cruce_cuentas_get_historicos(
|
||||||
|
p_country text,
|
||||||
|
p_page integer default 1,
|
||||||
|
p_page_size integer default 10,
|
||||||
|
p_search text default null,
|
||||||
|
p_status text default null
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_country text := upper(trim(coalesce(p_country, '')));
|
||||||
|
v_page integer := greatest(coalesce(p_page, 1), 1);
|
||||||
|
v_page_size integer := least(greatest(coalesce(p_page_size, 10), 1), 100);
|
||||||
|
v_offset integer;
|
||||||
|
v_search text := nullif(trim(coalesce(p_search, '')), '');
|
||||||
|
v_status text := nullif(trim(coalesce(p_status, '')), '');
|
||||||
|
v_total bigint;
|
||||||
|
v_filtered_total bigint;
|
||||||
|
v_reports jsonb;
|
||||||
|
begin
|
||||||
|
if v_country not in ('GT', 'TT') then
|
||||||
|
raise exception 'País no permitido: %', v_country;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_offset := (v_page - 1) * v_page_size;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country;
|
||||||
|
|
||||||
|
select count(*)
|
||||||
|
into v_filtered_total
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
);
|
||||||
|
|
||||||
|
select coalesce(jsonb_agg(to_jsonb(q) order by q.created_at desc), '[]'::jsonb)
|
||||||
|
into v_reports
|
||||||
|
from (
|
||||||
|
select r.*
|
||||||
|
from public.cruces_cuentas_gt_reportes r
|
||||||
|
where upper(coalesce(r.country, 'GT')) = v_country
|
||||||
|
and (v_status is null or r.estado = v_status)
|
||||||
|
and (
|
||||||
|
v_search is null
|
||||||
|
or coalesce(r.period_label, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.payroll_file_name, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.ejecutado_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or coalesce(r.resuelto_por_nombre, '') ilike '%' || v_search || '%'
|
||||||
|
or r.id::text ilike '%' || v_search || '%'
|
||||||
|
)
|
||||||
|
order by r.created_at desc
|
||||||
|
limit v_page_size
|
||||||
|
offset v_offset
|
||||||
|
) q;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'country', v_country,
|
||||||
|
'page', v_page,
|
||||||
|
'page_size', v_page_size,
|
||||||
|
'total', v_total,
|
||||||
|
'filtered_total', v_filtered_total,
|
||||||
|
'total_pages', greatest(ceil(v_filtered_total::numeric / v_page_size)::integer, 1),
|
||||||
|
'reports', v_reports
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) from public;
|
||||||
|
grant execute on function public.cruce_cuentas_get_historicos(text, integer, integer, text, text) to authenticated;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Validación técnica — Bono 14 Guatemala
|
||||||
|
|
||||||
|
Validaciones offline realizadas sobre los archivos reales suministrados:
|
||||||
|
|
||||||
|
- TypeScript: `npm run lint` requerido y validado en este paquete.
|
||||||
|
- Workflow n8n: 52 nodos y 46 conexiones raíz.
|
||||||
|
- Code nodes: 11 validados con `node --check`.
|
||||||
|
- Total Bono 14 QTZ leído: Q 1,332,673.42
|
||||||
|
- Total Bono 14 USD leído: $ 6,873.75
|
||||||
|
- Las siete hojas canónicas coinciden con su fila de total oficial.
|
||||||
|
- Los CSV 1263–1268 reproducen exactamente los totales QTZ de las hojas correspondientes.
|
||||||
|
- El CSV 912 US reproduce exactamente el total USD.
|
||||||
|
- El CSV 1269 se acepta como archivo bancario adicional y NO se excluye silenciosamente; cualquier monto no respaldado por el Excel aparecerá en el cruce.
|
||||||
|
|
||||||
|
## Build de producción
|
||||||
|
|
||||||
|
El código fuente TypeScript está validado.
|
||||||
|
|
||||||
|
El `node_modules` del ZIP original contiene paquetes nativos de Windows para
|
||||||
|
Rollup. El sandbox Linux no puede generar de forma confiable un `dist` nuevo
|
||||||
|
con esos binarios, por lo que este ZIP **no incluye el dist anterior**.
|
||||||
|
|
||||||
|
Antes de desplegar en el servidor, ejecutar en el entorno habitual del proyecto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el entorno donde se compila es Linux y aparece un error de Rollup por
|
||||||
|
dependencia nativa, ejecutar primero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-253
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB |
Vendored
-16
@@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Portal de Cruce de Cuentas GLM</title>
|
|
||||||
<link rel="icon" type="image/png" href="/cruce-cuentas/favicon.png" />
|
|
||||||
<link rel="apple-touch-icon" href="/cruce-cuentas/favicon.png" />
|
|
||||||
<script type="module" crossorigin src="/cruce-cuentas/assets/index-Cj1bVpIM.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/cruce-cuentas/assets/index-9ESZyUmu.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+176
-12
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
@@ -14,8 +14,14 @@ import {
|
|||||||
UploadedFile,
|
UploadedFile,
|
||||||
ViewType,
|
ViewType,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isAllowedEmail } from './lib/auth';
|
import { getCurrentUserAccess, normalizeEmail } from './lib/auth';
|
||||||
|
import { ensureFreshSupabaseSession } from './lib/sessionHealth';
|
||||||
import { isSupabaseConfigured, supabase } from './lib/supabase';
|
import { isSupabaseConfigured, supabase } from './lib/supabase';
|
||||||
|
import {
|
||||||
|
clearTabSessionDraft,
|
||||||
|
loadTabSessionDraft,
|
||||||
|
saveTabSessionDraft,
|
||||||
|
} from './lib/tabSessionState';
|
||||||
import { COUNTRIES, getCountryConfig } from './config/countries';
|
import { COUNTRIES, getCountryConfig } from './config/countries';
|
||||||
|
|
||||||
const AUTH_ENABLED = import.meta.env.VITE_ENABLE_SUPABASE_AUTH === 'true';
|
const AUTH_ENABLED = import.meta.env.VITE_ENABLE_SUPABASE_AUTH === 'true';
|
||||||
@@ -56,7 +62,9 @@ export default function App() {
|
|||||||
const [selectedCountryCode, setSelectedCountryCode] = useState<CountryCode | null>(() => getStoredCountry());
|
const [selectedCountryCode, setSelectedCountryCode] = useState<CountryCode | null>(() => getStoredCountry());
|
||||||
const [currentUser, setCurrentUser] = useState<AppUser | null>(AUTH_ENABLED ? null : TEMPORARY_USER);
|
const [currentUser, setCurrentUser] = useState<AppUser | null>(AUTH_ENABLED ? null : TEMPORARY_USER);
|
||||||
const [authLoading, setAuthLoading] = useState(AUTH_ENABLED);
|
const [authLoading, setAuthLoading] = useState(AUTH_ENABLED);
|
||||||
|
const [tabDraftHydrated, setTabDraftHydrated] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null);
|
||||||
|
const authValidationIdRef = useRef(0);
|
||||||
|
|
||||||
const authReady = useMemo(() => AUTH_ENABLED && isSupabaseConfigured && supabase, []);
|
const authReady = useMemo(() => AUTH_ENABLED && isSupabaseConfigured && supabase, []);
|
||||||
const selectedCountry = selectedCountryCode ? getCountryConfig(selectedCountryCode) : null;
|
const selectedCountry = selectedCountryCode ? getCountryConfig(selectedCountryCode) : null;
|
||||||
@@ -76,43 +84,198 @@ export default function App() {
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const applySessionUser = async (sessionUser: any) => {
|
const applySessionUser = async (sessionUser: any) => {
|
||||||
|
const validationId = ++authValidationIdRef.current;
|
||||||
|
|
||||||
if (!sessionUser?.email) {
|
if (!sessionUser?.email) {
|
||||||
|
if (isMounted && validationId === authValidationIdRef.current) {
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAllowedEmail(sessionUser.email)) {
|
try {
|
||||||
await supabase.auth.signOut();
|
const access = await getCurrentUserAccess();
|
||||||
|
|
||||||
|
if (!isMounted || validationId !== authValidationIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionEmail = normalizeEmail(sessionUser.email);
|
||||||
|
const accessEmailMatchesSession =
|
||||||
|
Boolean(access.email) && access.email === sessionEmail;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!access.ok ||
|
||||||
|
!access.authenticated ||
|
||||||
|
!access.authorized ||
|
||||||
|
!access.active ||
|
||||||
|
!access.appAccess ||
|
||||||
|
!accessEmailMatchesSession
|
||||||
|
) {
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
handleShowToast('Tu usuario no tiene acceso autorizado a este módulo.', 'error');
|
handleShowToast(
|
||||||
|
access.reason || 'Tu usuario no tiene acceso autorizado a este módulo.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
void supabase.auth.signOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentUser({
|
setCurrentUser({
|
||||||
name: getDisplayNameFromSessionUser(sessionUser),
|
name: access.name || getDisplayNameFromSessionUser(sessionUser),
|
||||||
email: sessionUser.email,
|
email: sessionEmail,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted || validationId !== authValidationIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('No se pudo validar el acceso en Supabase:', error);
|
||||||
|
setCurrentUser(null);
|
||||||
|
handleShowToast(
|
||||||
|
'No se pudo validar tu acceso en Supabase. Intenta iniciar sesión nuevamente.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
void supabase.auth.signOut();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async () => {
|
const loadSession = async () => {
|
||||||
const { data } = await supabase.auth.getSession();
|
try {
|
||||||
|
const { data, error } = await supabase.auth.getSession();
|
||||||
|
if (error) throw error;
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
await applySessionUser(data.session?.user);
|
await applySessionUser(data.session?.user);
|
||||||
setAuthLoading(false);
|
} catch (error) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
console.error('No se pudo recuperar la sesión de Supabase:', error);
|
||||||
|
setCurrentUser(null);
|
||||||
|
handleShowToast(
|
||||||
|
'No se pudo recuperar tu sesión. Intenta iniciar sesión nuevamente.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setAuthLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSession();
|
void loadSession();
|
||||||
|
|
||||||
const { data: listener } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
const { data: listener } = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
await applySessionUser(session?.user);
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
// Supabase recomienda no encadenar llamadas asíncronas al cliente dentro
|
||||||
|
// del callback. Se difiere la RPC para evitar bloqueos durante el OAuth o
|
||||||
|
// la renovación automática del token.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (isMounted) void applySessionUser(session.user);
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evita desmontar toda la interfaz por un estado nulo transitorio durante
|
||||||
|
// la reanudación o renovación de la sesión al volver a una pestaña inactiva.
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
authValidationIdRef.current += 1;
|
||||||
|
setCurrentUser(null);
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
|
authValidationIdRef.current += 1;
|
||||||
listener.subscription.unsubscribe();
|
listener.subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [authReady]);
|
}, [authReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!AUTH_ENABLED || !authReady || !supabase) return;
|
||||||
|
|
||||||
|
let lastResumeAttempt = 0;
|
||||||
|
|
||||||
|
const resumeSession = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastResumeAttempt < 5000) return;
|
||||||
|
lastResumeAttempt = now;
|
||||||
|
|
||||||
|
// Una pestaña suspendida varias horas puede volver antes de que Supabase
|
||||||
|
// complete su renovación automática. La revalidación es silenciosa y no
|
||||||
|
// desmonta la interfaz por errores transitorios de red.
|
||||||
|
void ensureFreshSupabaseSession(300).catch((error) => {
|
||||||
|
console.warn('No se pudo revalidar la sesión al reanudar la pestaña:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', resumeSession);
|
||||||
|
window.addEventListener('focus', resumeSession);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', resumeSession);
|
||||||
|
window.removeEventListener('focus', resumeSession);
|
||||||
|
};
|
||||||
|
}, [authReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const restoreTabDraft = async () => {
|
||||||
|
const draft = await loadTabSessionDraft();
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
// Si el país fue cambiado explícitamente en otra parte de la app, no se
|
||||||
|
// restaura un borrador perteneciente a otro módulo.
|
||||||
|
if (!selectedCountryCode || draft.countryCode === selectedCountryCode) {
|
||||||
|
if (draft.countryCode) {
|
||||||
|
setSelectedCountryCode(draft.countryCode);
|
||||||
|
}
|
||||||
|
setCurrentView(draft.currentView);
|
||||||
|
setUploadedPayroll(draft.uploadedPayroll);
|
||||||
|
setUploadedBankFiles(draft.uploadedBankFiles);
|
||||||
|
setIsCruceExecuted(draft.isCruceExecuted);
|
||||||
|
setCruceResponse(draft.cruceResponse);
|
||||||
|
} else {
|
||||||
|
await clearTabSessionDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) setTabDraftHydrated(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
void restoreTabDraft();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
// La restauración ocurre una sola vez usando los valores iniciales del tab.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabDraftHydrated) return;
|
||||||
|
|
||||||
|
void saveTabSessionDraft({
|
||||||
|
countryCode: selectedCountryCode,
|
||||||
|
currentView,
|
||||||
|
uploadedPayroll,
|
||||||
|
uploadedBankFiles,
|
||||||
|
isCruceExecuted,
|
||||||
|
cruceResponse,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
tabDraftHydrated,
|
||||||
|
selectedCountryCode,
|
||||||
|
currentView,
|
||||||
|
uploadedPayroll,
|
||||||
|
uploadedBankFiles,
|
||||||
|
isCruceExecuted,
|
||||||
|
cruceResponse,
|
||||||
|
]);
|
||||||
|
|
||||||
const clearCurrentCruce = () => {
|
const clearCurrentCruce = () => {
|
||||||
setUploadedPayroll(null);
|
setUploadedPayroll(null);
|
||||||
setUploadedBankFiles([]);
|
setUploadedBankFiles([]);
|
||||||
@@ -122,6 +285,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
await clearTabSessionDraft();
|
||||||
if (supabase) await supabase.auth.signOut();
|
if (supabase) await supabase.auth.signOut();
|
||||||
setCurrentUser(AUTH_ENABLED ? null : TEMPORARY_USER);
|
setCurrentUser(AUTH_ENABLED ? null : TEMPORARY_USER);
|
||||||
setSelectedCountryCode(null);
|
setSelectedCountryCode(null);
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { AlertTriangle, Send, X } from 'lucide-react';
|
||||||
|
import { CountryConfig, PeriodRange, ReconciliationRow } from '../types';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { ensureFreshSupabaseSession } from '../lib/sessionHealth';
|
||||||
|
|
||||||
|
const REPORT_BAMBOO_URL =
|
||||||
|
(import.meta.env.VITE_REPORTAR_BAMBOO_URL as string | undefined) || '';
|
||||||
|
|
||||||
|
interface BambooCorrectionModalProps {
|
||||||
|
row: ReconciliationRow;
|
||||||
|
country: CountryConfig;
|
||||||
|
periodRange: PeriodRange;
|
||||||
|
operatorName: string;
|
||||||
|
operatorEmail: string;
|
||||||
|
executionId?: string;
|
||||||
|
reportUrl?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (rowId: string) => void;
|
||||||
|
onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASONS = [
|
||||||
|
'Nombre mal escrito en BambooHR',
|
||||||
|
'Apodo o nombre de uso',
|
||||||
|
'Nombre incompleto / nombres adicionales',
|
||||||
|
'Otro',
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseJsonSafely(text: string) {
|
||||||
|
try {
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BambooCorrectionModal({
|
||||||
|
row,
|
||||||
|
country,
|
||||||
|
periodRange,
|
||||||
|
operatorName,
|
||||||
|
operatorEmail,
|
||||||
|
executionId,
|
||||||
|
reportUrl,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onShowToast,
|
||||||
|
}: BambooCorrectionModalProps) {
|
||||||
|
const suggested = row.bestBambooCandidate;
|
||||||
|
const [bambooName, setBambooName] = useState(suggested?.employeeName || '');
|
||||||
|
const [employeeNumber, setEmployeeNumber] = useState(suggested?.employeeNumber || '');
|
||||||
|
const [reason, setReason] = useState(REASONS[0]);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBambooName(suggested?.employeeName || '');
|
||||||
|
setEmployeeNumber(suggested?.employeeNumber || '');
|
||||||
|
setReason(REASONS[0]);
|
||||||
|
setComment('');
|
||||||
|
setConfirmed(false);
|
||||||
|
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
||||||
|
|
||||||
|
const recipientLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
country.code === 'TT'
|
||||||
|
? 'Laurele Nicome-Jack'
|
||||||
|
: 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler',
|
||||||
|
[country.code],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceNames = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
[row.employee, row.bankNameFile, row.bankAccountHolder]
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[row.employee, row.bankNameFile, row.bankAccountHolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!REPORT_BAMBOO_URL) {
|
||||||
|
onShowToast('Falta configurar VITE_REPORTAR_BAMBOO_URL.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bambooName.trim()) {
|
||||||
|
onShowToast('Escribe el nombre correcto del empleado en BambooHR.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
onShowToast('Confirma que se trata de la misma persona antes de enviar el caso.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
onShowToast('Supabase no está configurado en esta aplicación.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await ensureFreshSupabaseSession(300);
|
||||||
|
const accessToken = session?.access_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de reportar el caso.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctionPayload = {
|
||||||
|
country: country.code,
|
||||||
|
country_name: country.name,
|
||||||
|
source_name: row.employee,
|
||||||
|
source_names: sourceNames,
|
||||||
|
bank_name_file: row.bankNameFile || '',
|
||||||
|
bank_account_holder: row.bankAccountHolder || '',
|
||||||
|
bank_account: row.account || '',
|
||||||
|
bank_amount: row.amountBank,
|
||||||
|
currency: country.currency,
|
||||||
|
bamboo_name: bambooName.trim(),
|
||||||
|
bamboo_employee_number: employeeNumber.trim(),
|
||||||
|
reason,
|
||||||
|
comment: comment.trim(),
|
||||||
|
confirmed_same_person: true,
|
||||||
|
execution_id: executionId || '',
|
||||||
|
report_url: reportUrl || '',
|
||||||
|
period_label: periodRange.label,
|
||||||
|
period_start: periodRange.start,
|
||||||
|
period_end: periodRange.end,
|
||||||
|
requested_by_name: operatorName,
|
||||||
|
requested_by_email: operatorEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
// FormData evita una preflight CORS innecesaria al llamar el webhook de n8n.
|
||||||
|
// El JWT sigue viajando únicamente por HTTPS y n8n lo valida contra Supabase.
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(correctionPayload));
|
||||||
|
formData.append('access_token', accessToken);
|
||||||
|
|
||||||
|
const response = await fetch(REPORT_BAMBOO_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
const payload = parseJsonSafely(responseText);
|
||||||
|
|
||||||
|
if (!response.ok || payload?.ok === false) {
|
||||||
|
const message =
|
||||||
|
payload?.message ||
|
||||||
|
payload?.error ||
|
||||||
|
payload?.errors?.[0] ||
|
||||||
|
responseText ||
|
||||||
|
'No se pudo enviar el caso a RRHH.';
|
||||||
|
throw new Error(String(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess(row.id);
|
||||||
|
onShowToast(
|
||||||
|
`Caso enviado a RRHH de ${country.name}. La equivalencia quedó registrada para futuros cruces.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'No se pudo enviar el caso a RRHH.';
|
||||||
|
onShowToast(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 bg-black/45"
|
||||||
|
onClick={isSubmitting ? undefined : onClose}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-2xl bg-white border border-[#D0D0D0] shadow-2xl max-h-[92vh] overflow-y-auto">
|
||||||
|
<div className="h-1.5 bg-[#6CC24A]" />
|
||||||
|
|
||||||
|
<div className="px-6 py-5 border-b border-neutral-200 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-black text-[#6B8FA3]">Banco sin BambooHR</p>
|
||||||
|
<h2 className="text-xl font-black text-[#4F758B] mt-1">Reportar corrección de identidad a RRHH</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2 leading-relaxed">
|
||||||
|
Esta validación crea una equivalencia permanente para que el mismo falso positivo no vuelva a aparecer mientras RRHH corrige BambooHR.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-2 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 disabled:opacity-40"
|
||||||
|
aria-label="Cerrar modal"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-[#F5F5F5] border border-neutral-200 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Nombre detectado</p>
|
||||||
|
<p className="text-sm font-black text-neutral-800 mt-1">{row.employee}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Cuenta bancaria</p>
|
||||||
|
<p className="text-sm font-mono font-bold text-neutral-700 mt-1">{row.account || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Se notificará a</p>
|
||||||
|
<p className="text-xs font-bold text-neutral-700 mt-1">{recipientLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggested?.employeeName && (
|
||||||
|
<div className="border border-[#D6E8F4] bg-[#F7FBFD] p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-neutral-600 leading-relaxed">
|
||||||
|
<strong className="text-[#4F758B]">Candidato sugerido por el cruce:</strong>{' '}
|
||||||
|
{suggested.employeeName}
|
||||||
|
{suggested.employeeNumber ? ` · Employee Number ${suggested.employeeNumber}` : ''}.
|
||||||
|
Verifícalo manualmente antes de confirmar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<label className="sm:col-span-2">
|
||||||
|
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||||
|
Nombre correcto en BambooHR *
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bambooName}
|
||||||
|
onChange={(event) => setBambooName(event.target.value)}
|
||||||
|
placeholder="Ej.: Jonathan Alexander Pérez"
|
||||||
|
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||||
|
Employee Number
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={employeeNumber}
|
||||||
|
onChange={(event) => setEmployeeNumber(event.target.value)}
|
||||||
|
placeholder="Recomendado para mayor seguridad"
|
||||||
|
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||||
|
Motivo *
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B] bg-white"
|
||||||
|
>
|
||||||
|
{REASONS.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="sm:col-span-2">
|
||||||
|
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||||
|
Comentario para RRHH
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Detalle adicional que ayude a RRHH a corregir el registro."
|
||||||
|
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B] resize-y"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 border border-[#D0D0D0] p-4 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(event) => setConfirmed(event.target.checked)}
|
||||||
|
className="mt-0.5 accent-[#6CC24A]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-neutral-600 leading-relaxed">
|
||||||
|
Confirmo que revisé el caso manualmente y que <strong>{row.employee}</strong> corresponde a la misma persona indicada arriba en BambooHR.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2.5 border border-neutral-300 bg-white text-neutral-600 text-xs font-black uppercase tracking-wider disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !confirmed || !bambooName.trim()}
|
||||||
|
className="px-5 py-2.5 bg-[#4F758B] text-white text-xs font-black uppercase tracking-wider inline-flex items-center justify-center gap-2 border-l-4 border-[#6CC24A] disabled:bg-neutral-300 disabled:border-neutral-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||||
|
Enviando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Enviar a RRHH
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+749
-32
File diff suppressed because it is too large
Load Diff
+556
-54
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CountryConfig, HistoricalReport } from '../types';
|
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
||||||
|
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
interface HistoryProps {
|
interface HistoryProps {
|
||||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||||
@@ -122,7 +124,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
|||||||
: Array.isArray(report.bank_file_names)
|
: Array.isArray(report.bank_file_names)
|
||||||
? report.bank_file_names.map(String)
|
? report.bank_file_names.map(String)
|
||||||
: [];
|
: [];
|
||||||
const rawExecutionDate = report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
|
const rawExecutionDate = report.updated_at ?? report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(report.id ?? `reporte-${index + 1}`),
|
id: String(report.id ?? `reporte-${index + 1}`),
|
||||||
@@ -130,6 +132,8 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
|||||||
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
||||||
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
||||||
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
||||||
|
periodStart: report.period_start ? String(report.period_start) : undefined,
|
||||||
|
periodEnd: report.period_end ? String(report.period_end) : undefined,
|
||||||
payrollFile: String(report.archivoNomina ?? report.payroll_file_name ?? report.payrollFile ?? ''),
|
payrollFile: String(report.archivoNomina ?? report.payroll_file_name ?? report.payrollFile ?? ''),
|
||||||
bankFiles,
|
bankFiles,
|
||||||
matches: toNumber(report.coincidencias ?? report.matches),
|
matches: toNumber(report.coincidencias ?? report.matches),
|
||||||
@@ -171,6 +175,170 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
|||||||
.map(({ _sortTime: _ignored, ...report }) => report);
|
.map(({ _sortTime: _ignored, ...report }) => report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeIdentityText(value: unknown) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9 ]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAccount(value: unknown) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/\.0$/g, '')
|
||||||
|
.replace(/\D/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCaseNumber(item: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = item[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(
|
||||||
|
value
|
||||||
|
.replace(/[A-Za-z$Q,\s]/g, '')
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHistoricalBambooCases(input: unknown): ReconciliationRow[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
return input.map((raw, index) => {
|
||||||
|
const item = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const sourceName = String(
|
||||||
|
item.employee ??
|
||||||
|
item.employee_name ??
|
||||||
|
item.bank_name_file ??
|
||||||
|
item.bank_account_holder ??
|
||||||
|
'Sin nombre',
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountBank = extractCaseNumber(item, [
|
||||||
|
'bankAmount',
|
||||||
|
'bank_amount',
|
||||||
|
'amount',
|
||||||
|
'monto_banco',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bestCandidate =
|
||||||
|
item.best_bamboo_candidate &&
|
||||||
|
typeof item.best_bamboo_candidate === 'object'
|
||||||
|
? item.best_bamboo_candidate as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(
|
||||||
|
item.id ??
|
||||||
|
`historical-bamboo-${normalizeIdentityText(sourceName)}-${normalizeAccount(item.account ?? item.bank_account)}-${index + 1}`,
|
||||||
|
),
|
||||||
|
employee: sourceName,
|
||||||
|
employeeNumber: '',
|
||||||
|
account: String(item.account ?? item.bank_account ?? item.bankAccount ?? ''),
|
||||||
|
amountPayroll: 0,
|
||||||
|
amountBank,
|
||||||
|
difference: amountBank === null ? null : 0 - amountBank,
|
||||||
|
status: 'Pendiente revisión',
|
||||||
|
category: 'banco_sin_bamboo',
|
||||||
|
source: 'banco_sin_bamboo',
|
||||||
|
observation: String(
|
||||||
|
item.observation ??
|
||||||
|
item.observacion ??
|
||||||
|
'Pago bancario sin empleado identificado de forma confiable en BambooHR.',
|
||||||
|
),
|
||||||
|
bankNameFile: String(item.bank_name_file ?? item.bankNameFile ?? ''),
|
||||||
|
bankAccountHolder: String(item.bank_account_holder ?? item.bankAccountHolder ?? ''),
|
||||||
|
bestBambooCandidate: bestCandidate
|
||||||
|
? {
|
||||||
|
employeeNumber: String(bestCandidate.employee_number ?? bestCandidate.employeeNumber ?? ''),
|
||||||
|
employeeName: String(bestCandidate.employee_name ?? bestCandidate.employeeName ?? ''),
|
||||||
|
score: extractCaseNumber(bestCandidate, ['score']),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
ambiguousBambooMatch: Boolean(item.ambiguous_bamboo_match),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoricalBambooCorrection {
|
||||||
|
id: string;
|
||||||
|
sourceName: string;
|
||||||
|
sourceNames: string[];
|
||||||
|
bankAccount: string;
|
||||||
|
status: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHistoricalBambooCorrections(input: unknown): HistoricalBambooCorrection[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
return input.map((raw) => {
|
||||||
|
const item = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item.id ?? ''),
|
||||||
|
sourceName: String(item.nombre_origen_principal ?? item.source_name ?? ''),
|
||||||
|
sourceNames: Array.isArray(item.nombres_origen)
|
||||||
|
? item.nombres_origen.map(String)
|
||||||
|
: [],
|
||||||
|
bankAccount: String(item.cuenta_bancaria ?? item.bank_account ?? ''),
|
||||||
|
status: String(item.estado ?? ''),
|
||||||
|
emailSent: item.correo_enviado === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function correctionMatchesRow(
|
||||||
|
correction: HistoricalBambooCorrection,
|
||||||
|
row: ReconciliationRow,
|
||||||
|
) {
|
||||||
|
const normalizedStatus = correction.status.toLowerCase();
|
||||||
|
const wasActuallyNotified =
|
||||||
|
correction.emailSent ||
|
||||||
|
normalizedStatus === 'notificado_rrhh' ||
|
||||||
|
normalizedStatus === 'corregido_bamboo';
|
||||||
|
|
||||||
|
// Si n8n logró registrar el intento pero el correo falló, se permite volver
|
||||||
|
// a enviarlo desde Históricos en vez de bloquear el caso por error.
|
||||||
|
if (!wasActuallyNotified) return false;
|
||||||
|
|
||||||
|
const correctionAccount = normalizeAccount(correction.bankAccount);
|
||||||
|
const rowAccount = normalizeAccount(row.account);
|
||||||
|
|
||||||
|
if (
|
||||||
|
correctionAccount &&
|
||||||
|
rowAccount &&
|
||||||
|
correctionAccount !== rowAccount
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctionNames = new Set(
|
||||||
|
[correction.sourceName, ...correction.sourceNames]
|
||||||
|
.map(normalizeIdentityText)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowNames = [
|
||||||
|
row.employee,
|
||||||
|
row.bankNameFile,
|
||||||
|
row.bankAccountHolder,
|
||||||
|
]
|
||||||
|
.map(normalizeIdentityText)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return rowNames.some((name) => correctionNames.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(status: HistoricalReport['status']) {
|
function statusClass(status: HistoricalReport['status']) {
|
||||||
if (status === 'Resuelto') return 'bg-blue-50 text-blue-800 border-blue-200';
|
if (status === 'Resuelto') return 'bg-blue-50 text-blue-800 border-blue-200';
|
||||||
return 'bg-amber-50 text-amber-800 border-amber-200';
|
return 'bg-amber-50 text-amber-800 border-amber-200';
|
||||||
@@ -197,10 +365,106 @@ export default function History({
|
|||||||
const [selectedReport, setSelectedReport] = useState<HistoricalReport | null>(null);
|
const [selectedReport, setSelectedReport] = useState<HistoricalReport | null>(null);
|
||||||
const [resolutionComment, setResolutionComment] = useState('');
|
const [resolutionComment, setResolutionComment] = useState('');
|
||||||
|
|
||||||
const filteredReports = useMemo(() => {
|
const [bambooReviewReport, setBambooReviewReport] = useState<HistoricalReport | null>(null);
|
||||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
const [bambooCases, setBambooCases] = useState<ReconciliationRow[]>([]);
|
||||||
|
const [reportedBambooRows, setReportedBambooRows] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoadingBambooCases, setIsLoadingBambooCases] = useState(false);
|
||||||
|
const [bambooCasesError, setBambooCasesError] = useState('');
|
||||||
|
const [bambooCorrectionRow, setBambooCorrectionRow] = useState<ReconciliationRow | null>(null);
|
||||||
|
|
||||||
return reports.filter((report) => {
|
const [totalReports, setTotalReports] = useState(0);
|
||||||
|
const [totalFilteredReports, setTotalFilteredReports] = useState(0);
|
||||||
|
const requestSequenceRef = useRef(0);
|
||||||
|
const activeRequestControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalFilteredReports / REPORTS_PER_PAGE));
|
||||||
|
const paginatedReports = reports;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchTerm, statusFilter, country.code]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const loadReports = async () => {
|
||||||
|
const requestId = ++requestSequenceRef.current;
|
||||||
|
activeRequestControllerRef.current?.abort();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeRequestControllerRef.current = controller;
|
||||||
|
|
||||||
|
const isCurrentRequest = () =>
|
||||||
|
requestSequenceRef.current === requestId && !controller.signal.aborted;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Camino principal: RPC paginada en Supabase. Cada respuesta se valida
|
||||||
|
// contra un identificador monotónico para que una solicitud vieja nunca
|
||||||
|
// pueda sobrescribir el filtro seleccionado más recientemente.
|
||||||
|
if (supabase) {
|
||||||
|
const rpcStatus =
|
||||||
|
statusFilter === 'pendiente'
|
||||||
|
? 'pendiente_revision'
|
||||||
|
: statusFilter === 'resuelto'
|
||||||
|
? 'resuelto'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { data: rpcData, error: rpcError } = await supabase.rpc(
|
||||||
|
'cruce_cuentas_get_historicos',
|
||||||
|
{
|
||||||
|
p_country: country.code,
|
||||||
|
p_page: currentPage,
|
||||||
|
p_page_size: REPORTS_PER_PAGE,
|
||||||
|
p_search: searchTerm.trim() || null,
|
||||||
|
p_status: rpcStatus,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCurrentRequest()) return;
|
||||||
|
|
||||||
|
if (!rpcError && rpcData) {
|
||||||
|
const payload = Array.isArray(rpcData) ? rpcData[0] : rpcData;
|
||||||
|
const normalized = normalizeReports(payload, country);
|
||||||
|
const total = toNumber((payload as any)?.total);
|
||||||
|
const filteredTotal = toNumber((payload as any)?.filtered_total ?? total);
|
||||||
|
|
||||||
|
setReports(normalized);
|
||||||
|
setTotalReports(total);
|
||||||
|
setTotalFilteredReports(filteredTotal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentRequest()) return;
|
||||||
|
|
||||||
|
// Respaldo temporal: webhook histórico existente. También se envía el país
|
||||||
|
// para impedir que se mezclen reportes de Guatemala y Trinidad y Tobago.
|
||||||
|
const historyUrl = getHistoryUrl(country.code);
|
||||||
|
if (!historyUrl) {
|
||||||
|
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(historyUrl);
|
||||||
|
url.searchParams.set('country', country.code);
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!isCurrentRequest()) return;
|
||||||
|
|
||||||
|
if (!response.ok || payload?.ok === false) {
|
||||||
|
throw new Error('No se pudieron cargar los reportes históricos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCountryReports = normalizeReports(payload, country);
|
||||||
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
|
const filtered = allCountryReports.filter((report) => {
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
statusFilter === 'todos' ||
|
statusFilter === 'todos' ||
|
||||||
(statusFilter === 'pendiente' && report.status === 'Pendiente revisión') ||
|
(statusFilter === 'pendiente' && report.status === 'Pendiente revisión') ||
|
||||||
@@ -216,56 +480,129 @@ export default function History({
|
|||||||
|
|
||||||
return matchesStatus && matchesSearch;
|
return matchesStatus && matchesSearch;
|
||||||
});
|
});
|
||||||
}, [reports, searchTerm, statusFilter]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredReports.length / REPORTS_PER_PAGE));
|
if (!isCurrentRequest()) return;
|
||||||
const paginatedReports = filteredReports.slice(
|
|
||||||
(currentPage - 1) * REPORTS_PER_PAGE,
|
|
||||||
currentPage * REPORTS_PER_PAGE
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const offset = (currentPage - 1) * REPORTS_PER_PAGE;
|
||||||
setCurrentPage(1);
|
setReports(filtered.slice(offset, offset + REPORTS_PER_PAGE));
|
||||||
}, [searchTerm, statusFilter, country.code]);
|
setTotalReports(allCountryReports.length);
|
||||||
|
setTotalFilteredReports(filtered.length);
|
||||||
useEffect(() => {
|
} catch (error) {
|
||||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
if (
|
||||||
}, [currentPage, totalPages]);
|
!isCurrentRequest() ||
|
||||||
|
(error instanceof DOMException && error.name === 'AbortError')
|
||||||
const loadReports = async () => {
|
) {
|
||||||
const historyUrl = getHistoryUrl(country.code);
|
|
||||||
if (!historyUrl) {
|
|
||||||
setReports([]);
|
|
||||||
setLoadError(`Los reportes de ${country.name} estarán disponibles próximamente.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setReports([]);
|
||||||
setLoadError('');
|
setTotalReports(0);
|
||||||
|
setTotalFilteredReports(0);
|
||||||
try {
|
setLoadError(
|
||||||
const url = new URL(historyUrl);
|
error instanceof Error
|
||||||
url.searchParams.set('country', country.code);
|
? error.message
|
||||||
const response = await fetch(url.toString(), { method: 'GET' });
|
: 'No se pudieron cargar los reportes históricos. Intenta nuevamente.'
|
||||||
const payload = await response.json().catch(() => null);
|
);
|
||||||
|
|
||||||
if (!response.ok || payload?.ok === false) {
|
|
||||||
throw new Error('No se pudieron cargar los reportes históricos.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setReports(normalizeReports(payload, country));
|
|
||||||
setCurrentPage(1);
|
|
||||||
} catch {
|
|
||||||
setLoadError('No se pudieron cargar los reportes históricos. Intenta nuevamente.');
|
|
||||||
onShowToast('No se pudieron cargar los reportes históricos.', 'error');
|
onShowToast('No se pudieron cargar los reportes históricos.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isCurrentRequest()) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
if (activeRequestControllerRef.current === controller) {
|
||||||
|
activeRequestControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadReports();
|
const timer = window.setTimeout(() => {
|
||||||
}, [country.code]);
|
void loadReports();
|
||||||
|
}, searchTerm.trim() ? 300 : 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
requestSequenceRef.current += 1;
|
||||||
|
activeRequestControllerRef.current?.abort();
|
||||||
|
activeRequestControllerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [country.code, currentPage, searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
const closeBambooReview = () => {
|
||||||
|
setBambooCorrectionRow(null);
|
||||||
|
setBambooReviewReport(null);
|
||||||
|
setBambooCases([]);
|
||||||
|
setReportedBambooRows(new Set());
|
||||||
|
setBambooCasesError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenBambooCases = async (report: HistoricalReport) => {
|
||||||
|
if (report.bankWithoutBamboo <= 0) {
|
||||||
|
onShowToast('Este reporte no tiene casos de Banco sin Bamboo.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
onShowToast('Supabase no está configurado en esta aplicación.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBambooReviewReport(report);
|
||||||
|
setBambooCorrectionRow(null);
|
||||||
|
setBambooCases([]);
|
||||||
|
setReportedBambooRows(new Set());
|
||||||
|
setBambooCasesError('');
|
||||||
|
setIsLoadingBambooCases(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc(
|
||||||
|
'cruce_cuentas_get_banco_sin_bamboo_reporte',
|
||||||
|
{
|
||||||
|
p_report_id: report.id,
|
||||||
|
p_country: country.code,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const payload = Array.isArray(data) ? data[0] : data;
|
||||||
|
if (!payload || payload.ok === false) {
|
||||||
|
throw new Error(
|
||||||
|
String(payload?.message || 'No se pudieron recuperar los casos de Banco sin Bamboo.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = normalizeHistoricalBambooCases(payload.cases);
|
||||||
|
const corrections = normalizeHistoricalBambooCorrections(payload.corrections);
|
||||||
|
const alreadyReported = new Set(
|
||||||
|
cases
|
||||||
|
.filter((row) => corrections.some((correction) => correctionMatchesRow(correction, row)))
|
||||||
|
.map((row) => row.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
setBambooCases(cases);
|
||||||
|
setReportedBambooRows(alreadyReported);
|
||||||
|
|
||||||
|
if (cases.length === 0) {
|
||||||
|
setBambooCasesError(
|
||||||
|
'Este reporte fue creado antes de que se guardara el detalle de Banco sin Bamboo. Los reportes nuevos conservarán los casos para revisarlos desde Históricos.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'No se pudieron recuperar los casos de Banco sin Bamboo.';
|
||||||
|
setBambooCasesError(message);
|
||||||
|
onShowToast(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBambooCases(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bambooPeriodRange: PeriodRange = {
|
||||||
|
label: bambooReviewReport?.period || 'Período no especificado',
|
||||||
|
start: bambooReviewReport?.periodStart || '',
|
||||||
|
end: bambooReviewReport?.periodEnd || '',
|
||||||
|
};
|
||||||
|
|
||||||
const handleViewReport = (report: HistoricalReport) => {
|
const handleViewReport = (report: HistoricalReport) => {
|
||||||
if (report.reportUrl) {
|
if (report.reportUrl) {
|
||||||
@@ -420,7 +757,7 @@ export default function History({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500">
|
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500">
|
||||||
<Filter className="w-4 h-4 text-neutral-400" />
|
<Filter className="w-4 h-4 text-neutral-400" />
|
||||||
<span>Mostrando: {filteredReports.length} de {reports.length} reportes</span>
|
<span>Mostrando: {totalFilteredReports} de {totalReports} reportes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,7 +776,7 @@ export default function History({
|
|||||||
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
|
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
|
||||||
<th className="py-3 px-3 xl:px-4">Fecha</th>
|
<th className="py-3 px-3 xl:px-4">Fecha</th>
|
||||||
<th className="py-3 px-3 xl:px-4">Período</th>
|
<th className="py-3 px-3 xl:px-4">Período</th>
|
||||||
<th className="py-3 px-3 xl:px-4">Archivo de Nómina</th>
|
<th className="py-3 px-3 xl:px-4">Archivo de Nómina / Bono 14</th>
|
||||||
<th className="py-3 px-3 xl:px-4 text-center">Coincidencias</th>
|
<th className="py-3 px-3 xl:px-4 text-center">Coincidencias</th>
|
||||||
<th className="py-3 px-3 xl:px-4 text-center">Banco sin Bamboo</th>
|
<th className="py-3 px-3 xl:px-4 text-center">Banco sin Bamboo</th>
|
||||||
<th className="py-3 px-3 xl:px-4 text-center">Pendientes</th>
|
<th className="py-3 px-3 xl:px-4 text-center">Pendientes</th>
|
||||||
@@ -484,15 +821,21 @@ export default function History({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 xl:px-4 text-center font-black text-emerald-800">{report.matches}</td>
|
<td className="py-3 px-3 xl:px-4 text-center font-black text-emerald-800">{report.matches}</td>
|
||||||
<td className="py-3 px-3 xl:px-4 text-center">
|
<td className="py-3 px-3 xl:px-4 text-center">
|
||||||
<span
|
{report.bankWithoutBamboo > 0 ? (
|
||||||
className={`inline-block min-w-8 px-2.5 py-1 text-[10px] font-black border ${
|
<button
|
||||||
report.bankWithoutBamboo > 0
|
type="button"
|
||||||
? 'bg-orange-50 text-orange-800 border-orange-200'
|
onClick={() => void handleOpenBambooCases(report)}
|
||||||
: 'bg-neutral-50 text-neutral-400 border-neutral-200'
|
className="inline-flex items-center gap-1.5 min-w-8 px-2.5 py-1 text-[10px] font-black border bg-orange-50 text-orange-800 border-orange-200 hover:bg-orange-100 hover:border-orange-300 transition-colors"
|
||||||
}`}
|
title="Revisar casos y reportar correcciones a RRHH"
|
||||||
>
|
>
|
||||||
{report.bankWithoutBamboo}
|
<span>{report.bankWithoutBamboo}</span>
|
||||||
|
<span className="hidden 2xl:inline">Revisar</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block min-w-8 px-2.5 py-1 text-[10px] font-black border bg-neutral-50 text-neutral-400 border-neutral-200">
|
||||||
|
0
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 xl:px-4 text-center">
|
<td className="py-3 px-3 xl:px-4 text-center">
|
||||||
<span
|
<span
|
||||||
@@ -556,7 +899,7 @@ export default function History({
|
|||||||
|
|
||||||
<div className="p-4 border-t border-neutral-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs text-neutral-500">
|
<div className="p-4 border-t border-neutral-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs text-neutral-500">
|
||||||
<span>
|
<span>
|
||||||
Mostrando {paginatedReports.length} de {filteredReports.length} reporte(s).
|
Mostrando {paginatedReports.length} de {totalFilteredReports} reporte(s).
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -579,6 +922,165 @@ export default function History({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{bambooReviewReport && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/45 px-4 py-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 12 }}
|
||||||
|
className="w-full max-w-5xl bg-white border border-[#D0D0D0] shadow-2xl max-h-[92vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="h-1.5 bg-[#6CC24A] flex-shrink-0" />
|
||||||
|
<div className="border-b border-neutral-200 px-5 sm:px-6 py-4 flex items-start justify-between gap-4 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-black text-[#6B8FA3]">Histórico · Banco sin BambooHR</p>
|
||||||
|
<h2 className="text-lg sm:text-xl font-black text-[#4F758B] mt-1">{bambooReviewReport.period}</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{country.name} · {bambooReviewReport.bankWithoutBamboo} caso{bambooReviewReport.bankWithoutBamboo === 1 ? '' : 's'} guardado{bambooReviewReport.bankWithoutBamboo === 1 ? '' : 's'} en este reporte
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeBambooReview}
|
||||||
|
className="p-2 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100"
|
||||||
|
aria-label="Cerrar revisión de Banco sin Bamboo"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 sm:px-6 py-4 overflow-y-auto">
|
||||||
|
<div className="border border-[#D6E8F4] bg-[#F7FBFD] px-4 py-3 text-xs text-neutral-600 leading-relaxed mb-4">
|
||||||
|
Aquí puedes volver días después al reporte y enviar a RRHH únicamente los falsos positivos que ya verificaste manualmente. Los casos previamente enviados se muestran como <strong>Reportado a RRHH</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingBambooCases ? (
|
||||||
|
<div className="py-14 text-center text-neutral-500 text-sm">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2 text-[#4F758B]" />
|
||||||
|
Recuperando casos del reporte...
|
||||||
|
</div>
|
||||||
|
) : bambooCasesError ? (
|
||||||
|
<div className="border border-amber-200 bg-amber-50 text-amber-800 px-4 py-3 text-xs font-medium flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{bambooCasesError}</span>
|
||||||
|
</div>
|
||||||
|
) : bambooCases.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto border border-neutral-200">
|
||||||
|
<table className="w-full text-left border-collapse min-w-[760px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#4F758B] text-white text-[10px] font-black uppercase tracking-wider">
|
||||||
|
<th className="px-3 py-3">Nombre detectado</th>
|
||||||
|
<th className="px-3 py-3">Cuenta</th>
|
||||||
|
<th className="px-3 py-3 text-right">Monto banco</th>
|
||||||
|
<th className="px-3 py-3">Candidato BambooHR</th>
|
||||||
|
<th className="px-3 py-3 text-center">Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200 text-xs">
|
||||||
|
{bambooCases.map((row) => {
|
||||||
|
const isReported = reportedBambooRows.has(row.id);
|
||||||
|
const amount = row.amountBank;
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<p className="font-black text-neutral-800">{row.employee}</p>
|
||||||
|
{(row.bankNameFile || row.bankAccountHolder) && (
|
||||||
|
<p className="text-[10px] text-neutral-400 mt-1">
|
||||||
|
{[row.bankNameFile, row.bankAccountHolder].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 font-mono font-bold text-neutral-600">{row.account || '—'}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-black text-neutral-700">
|
||||||
|
{amount === null
|
||||||
|
? '—'
|
||||||
|
: new Intl.NumberFormat(country.locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: country.currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-neutral-600 max-w-[260px]">
|
||||||
|
{row.bestBambooCandidate?.employeeName ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-[#4F758B]">{row.bestBambooCandidate.employeeName}</p>
|
||||||
|
{row.bestBambooCandidate.employeeNumber && (
|
||||||
|
<p className="text-[10px] text-neutral-400 mt-0.5">Employee Number {row.bestBambooCandidate.employeeNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">Sin candidato confiable</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-center">
|
||||||
|
{isReported ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-emerald-200 bg-emerald-50 text-emerald-800 text-[10px] font-black uppercase tracking-wider">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
Reportado a RRHH
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBambooCorrectionRow(row)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-[#6CC24A] hover:bg-[#5aaa3f] text-white text-[10px] font-black uppercase tracking-wider transition-colors"
|
||||||
|
>
|
||||||
|
Reportar a RRHH
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-neutral-400 text-sm">No hay casos guardados en este reporte.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-200 px-5 sm:px-6 py-4 flex items-center justify-between gap-3 flex-shrink-0">
|
||||||
|
<span className="text-[10px] text-neutral-400">ID del reporte: {bambooReviewReport.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeBambooReview}
|
||||||
|
className="border border-neutral-300 bg-white px-4 py-2 text-xs font-bold text-neutral-600 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{bambooCorrectionRow && bambooReviewReport && (
|
||||||
|
<BambooCorrectionModal
|
||||||
|
row={bambooCorrectionRow}
|
||||||
|
country={country}
|
||||||
|
periodRange={bambooPeriodRange}
|
||||||
|
operatorName={operatorName}
|
||||||
|
operatorEmail={operatorEmail}
|
||||||
|
executionId={bambooReviewReport.id}
|
||||||
|
reportUrl={bambooReviewReport.reportUrl}
|
||||||
|
onClose={() => setBambooCorrectionRow(null)}
|
||||||
|
onSuccess={(rowId) => {
|
||||||
|
setReportedBambooRows((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.add(rowId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onShowToast={onShowToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selectedReport && (
|
{selectedReport && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const COUNTRIES: CountryConfig[] = [
|
|||||||
flag: import.meta.env.BASE_URL + 'tt-flag.png',
|
flag: import.meta.env.BASE_URL + 'tt-flag.png',
|
||||||
currency: 'TTD',
|
currency: 'TTD',
|
||||||
locale: 'en-TT',
|
locale: 'en-TT',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+108
-8
@@ -1,14 +1,114 @@
|
|||||||
export const ALLOWED_EMAILS = [
|
import { supabase } from './supabase';
|
||||||
'ymadera@gomezleemarketing.com',
|
|
||||||
'iaracena@gomezleemarketing.com',
|
const ACCESS_RPC_NAME = 'cruce_cuentas_mi_acceso';
|
||||||
'mgomez@gomezleemarketing.com',
|
const ACCESS_RPC_MAX_ATTEMPTS = 2;
|
||||||
'jgomez@gomezleemarketing.com',
|
|
||||||
] as const;
|
interface AccessRpcPayload {
|
||||||
|
ok?: unknown;
|
||||||
|
autenticado?: unknown;
|
||||||
|
autorizado?: unknown;
|
||||||
|
email?: unknown;
|
||||||
|
nombre?: unknown;
|
||||||
|
activo?: unknown;
|
||||||
|
acceso_app?: unknown;
|
||||||
|
acceso_historicos?: unknown;
|
||||||
|
recibe_reportes_generados?: unknown;
|
||||||
|
recibe_correos_resuelto?: unknown;
|
||||||
|
paises?: unknown;
|
||||||
|
motivo?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserAccess {
|
||||||
|
ok: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
authorized: boolean;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
appAccess: boolean;
|
||||||
|
historyAccess: boolean;
|
||||||
|
receivesGeneratedReports: boolean;
|
||||||
|
receivesResolvedEmails: boolean;
|
||||||
|
countries: string[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeEmail(email?: string | null) {
|
export function normalizeEmail(email?: string | null) {
|
||||||
return (email ?? '').trim().toLowerCase();
|
return (email ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedEmail(email?: string | null) {
|
function normalizeCountries(value: unknown): string[] {
|
||||||
return ALLOWED_EMAILS.includes(normalizeEmail(email) as (typeof ALLOWED_EMAILS)[number]);
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value
|
||||||
|
.map((country) => String(country ?? '').trim().toUpperCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAccessPayload(value: unknown): CurrentUserAccess {
|
||||||
|
const payload = (
|
||||||
|
Array.isArray(value) ? value[0] : value
|
||||||
|
) as AccessRpcPayload | null | undefined;
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new Error('Supabase no devolvió una respuesta válida al comprobar el acceso.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: payload.ok === true,
|
||||||
|
authenticated: payload.autenticado === true,
|
||||||
|
authorized: payload.autorizado === true,
|
||||||
|
email: normalizeEmail(typeof payload.email === 'string' ? payload.email : ''),
|
||||||
|
name: typeof payload.nombre === 'string' ? payload.nombre.trim() : '',
|
||||||
|
active: payload.activo === true,
|
||||||
|
appAccess: payload.acceso_app === true,
|
||||||
|
historyAccess: payload.acceso_historicos === true,
|
||||||
|
receivesGeneratedReports: payload.recibe_reportes_generados === true,
|
||||||
|
receivesResolvedEmails: payload.recibe_correos_resuelto === true,
|
||||||
|
countries: normalizeCountries(payload.paises),
|
||||||
|
reason: typeof payload.motivo === 'string' ? payload.motivo.trim() : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(milliseconds: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprueba el acceso usando exclusivamente la sesión autenticada y la RPC
|
||||||
|
* security-definer de Supabase. La lista de correos ya no vive en el frontend.
|
||||||
|
*/
|
||||||
|
export async function getCurrentUserAccess(): Promise<CurrentUserAccess> {
|
||||||
|
if (!supabase) {
|
||||||
|
throw new Error('Supabase no está configurado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= ACCESS_RPC_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
const { data, error } = await supabase.rpc(ACCESS_RPC_NAME);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return normalizeAccessPayload(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (attempt < ACCESS_RPC_MAX_ATTEMPTS) {
|
||||||
|
await wait(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
lastError && typeof lastError === 'object' && 'message' in lastError
|
||||||
|
? String((lastError as { message?: unknown }).message ?? '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
message || 'No se pudo validar el acceso del usuario en Supabase.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
const DEFAULT_MIN_VALIDITY_SECONDS = 180;
|
||||||
|
const WARMUP_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalida la sesión antes de operaciones importantes. Supabase normalmente
|
||||||
|
* renueva el token por sí solo, pero una pestaña suspendida por varias horas
|
||||||
|
* puede reanudarse antes de que el ciclo automático termine.
|
||||||
|
*/
|
||||||
|
export async function ensureFreshSupabaseSession(
|
||||||
|
minValiditySeconds = DEFAULT_MIN_VALIDITY_SECONDS,
|
||||||
|
) {
|
||||||
|
if (!supabase) return null;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.getSession();
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
let session = data.session;
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de continuar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAtMs = Number(session.expires_at || 0) * 1000;
|
||||||
|
const needsRefresh =
|
||||||
|
!expiresAtMs || expiresAtMs - Date.now() <= minValiditySeconds * 1000;
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
const refreshed = await supabase.auth.refreshSession();
|
||||||
|
if (refreshed.error) throw refreshed.error;
|
||||||
|
session = refreshed.data.session;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de continuar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hace una petición mínima al mismo origen de n8n antes de enviar archivos
|
||||||
|
* grandes. Es deliberadamente no-cors y no ejecuta el webhook: solo obliga al
|
||||||
|
* navegador/proxy a reabrir una conexión después de una pestaña inactiva.
|
||||||
|
*/
|
||||||
|
export async function warmRemoteOrigin(targetUrl: string) {
|
||||||
|
if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
|
||||||
|
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
throw new Error('No hay conexión a Internet. Verifica tu conexión e intenta nuevamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: URL;
|
||||||
|
try {
|
||||||
|
target = new URL(targetUrl, window.location.origin);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.origin === window.location.origin) return;
|
||||||
|
|
||||||
|
const warmUrl = new URL('/favicon.ico', target.origin);
|
||||||
|
warmUrl.searchParams.set('_glm_resume', String(Date.now()));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(warmUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'no-cors',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'omit',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Si el navegador declara estar offline, sí detenemos la ejecución. Para
|
||||||
|
// cualquier otro fallo del favicon continuamos: el POST real decidirá si
|
||||||
|
// el servicio está disponible y evitamos falsos bloqueos por un 404/CDN.
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
throw new Error('No hay conexión a Internet. Verifica tu conexión e intenta nuevamente.');
|
||||||
|
}
|
||||||
|
console.warn('No se pudo precalentar la conexión con n8n:', error);
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientRequestId() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `glm-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withFreshRequestQuery(targetUrl: string, requestId: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(targetUrl, window.location.origin);
|
||||||
|
url.searchParams.set('client_request_id', requestId);
|
||||||
|
url.searchParams.set('_ts', String(Date.now()));
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return targetUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
CountryCode,
|
||||||
|
ReconciliationResponse,
|
||||||
|
UploadedFile,
|
||||||
|
ViewType,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const DATABASE_NAME = 'glm_cruce_cuentas_tab_state';
|
||||||
|
const DATABASE_VERSION = 1;
|
||||||
|
const STORE_NAME = 'drafts';
|
||||||
|
const TAB_SESSION_KEY = 'glm_cruce_cuentas_tab_session_id';
|
||||||
|
const DRAFT_VERSION = 1;
|
||||||
|
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface TabSessionDraft {
|
||||||
|
version: number;
|
||||||
|
sessionId: string;
|
||||||
|
updatedAt: number;
|
||||||
|
countryCode: CountryCode | null;
|
||||||
|
currentView: ViewType;
|
||||||
|
uploadedPayroll: UploadedFile | null;
|
||||||
|
uploadedBankFiles: UploadedFile[];
|
||||||
|
isCruceExecuted: boolean;
|
||||||
|
cruceResponse: ReconciliationResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let databasePromise: Promise<IDBDatabase> | null = null;
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
let fallbackSessionId: string | null = null;
|
||||||
|
|
||||||
|
function createSessionId() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTabSessionId() {
|
||||||
|
try {
|
||||||
|
const existing = window.sessionStorage.getItem(TAB_SESSION_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const created = createSessionId();
|
||||||
|
window.sessionStorage.setItem(TAB_SESSION_KEY, created);
|
||||||
|
return created;
|
||||||
|
} catch {
|
||||||
|
// Fallback defensivo para navegadores que bloqueen sessionStorage.
|
||||||
|
fallbackSessionId ??= createSessionId();
|
||||||
|
return fallbackSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase() {
|
||||||
|
if (databasePromise) return databasePromise;
|
||||||
|
|
||||||
|
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
reject(new Error('IndexedDB no está disponible en este navegador.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
database.createObjectStore(STORE_NAME, { keyPath: 'sessionId' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error ?? new Error('No se pudo abrir IndexedDB.'));
|
||||||
|
request.onblocked = () => reject(new Error('La base local está bloqueada por otra pestaña.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
databasePromise.catch(() => {
|
||||||
|
databasePromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return databasePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestToPromise<T>(request: IDBRequest<T>) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error ?? new Error('Falló una operación local.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDone(transaction: IDBTransaction) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error ?? new Error('Falló la transacción local.'));
|
||||||
|
transaction.onabort = () => reject(transaction.error ?? new Error('La transacción local fue cancelada.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDraft(value: unknown): value is TabSessionDraft {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
|
||||||
|
const draft = value as Partial<TabSessionDraft>;
|
||||||
|
return (
|
||||||
|
draft.version === DRAFT_VERSION &&
|
||||||
|
typeof draft.sessionId === 'string' &&
|
||||||
|
typeof draft.updatedAt === 'number' &&
|
||||||
|
(draft.currentView === 'cruce' || draft.currentView === 'historial') &&
|
||||||
|
Array.isArray(draft.uploadedBankFiles) &&
|
||||||
|
typeof draft.isCruceExecuted === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupExpiredDrafts(database: IDBDatabase, currentSessionId: string) {
|
||||||
|
const cutoff = Date.now() - MAX_DRAFT_AGE_MS;
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const request = store.openCursor();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (!cursor) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = cursor.value as Partial<TabSessionDraft>;
|
||||||
|
if (
|
||||||
|
draft.sessionId !== currentSessionId &&
|
||||||
|
typeof draft.updatedAt === 'number' &&
|
||||||
|
draft.updatedAt < cutoff
|
||||||
|
) {
|
||||||
|
cursor.delete();
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error ?? new Error('No se pudieron limpiar borradores antiguos.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTabSessionDraft() {
|
||||||
|
const sessionId = getTabSessionId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = await openDatabase();
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
const stored = await requestToPromise(
|
||||||
|
transaction.objectStore(STORE_NAME).get(sessionId)
|
||||||
|
);
|
||||||
|
await done;
|
||||||
|
|
||||||
|
// La limpieza es secundaria y nunca debe impedir la restauración.
|
||||||
|
void cleanupExpiredDrafts(database, sessionId).catch(() => undefined);
|
||||||
|
|
||||||
|
return isValidDraft(stored) ? stored : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTabSessionDraft(
|
||||||
|
draft: Omit<TabSessionDraft, 'version' | 'sessionId' | 'updatedAt'>
|
||||||
|
) {
|
||||||
|
const sessionId = getTabSessionId();
|
||||||
|
const record: TabSessionDraft = {
|
||||||
|
...draft,
|
||||||
|
version: DRAFT_VERSION,
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serializar las escrituras evita que una operación anterior con archivos
|
||||||
|
// pesados termine después y sobrescriba un estado más reciente.
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
const database = await openDatabase();
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
transaction.objectStore(STORE_NAME).put(record);
|
||||||
|
await done;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return writeQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTabSessionDraft() {
|
||||||
|
const sessionId = getTabSessionId();
|
||||||
|
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
const database = await openDatabase();
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
transaction.objectStore(STORE_NAME).delete(sessionId);
|
||||||
|
await done;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return writeQueue;
|
||||||
|
}
|
||||||
@@ -45,11 +45,18 @@ export interface PeriodRange {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BambooCandidate {
|
||||||
|
employeeNumber: string;
|
||||||
|
employeeName: string;
|
||||||
|
score: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReconciliationRow {
|
export interface ReconciliationRow {
|
||||||
id: string;
|
id: string;
|
||||||
employee: string;
|
employee: string;
|
||||||
employeeNumber: string;
|
employeeNumber: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
currency?: string;
|
||||||
amountPayroll: number | null;
|
amountPayroll: number | null;
|
||||||
amountBank: number | null;
|
amountBank: number | null;
|
||||||
difference: number | null;
|
difference: number | null;
|
||||||
@@ -57,6 +64,10 @@ export interface ReconciliationRow {
|
|||||||
category?: string;
|
category?: string;
|
||||||
observation: string;
|
observation: string;
|
||||||
source?: ReconciliationSource;
|
source?: ReconciliationSource;
|
||||||
|
bankNameFile?: string;
|
||||||
|
bankAccountHolder?: string;
|
||||||
|
bestBambooCandidate?: BambooCandidate | null;
|
||||||
|
ambiguousBambooMatch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReconciliationSummary {
|
export interface ReconciliationSummary {
|
||||||
@@ -76,6 +87,7 @@ export interface ReconciliationSummary {
|
|||||||
|
|
||||||
export interface ReconciliationResponse {
|
export interface ReconciliationResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
reportKind?: 'nomina' | 'bono14';
|
||||||
executionId?: string;
|
executionId?: string;
|
||||||
reportUrl?: string;
|
reportUrl?: string;
|
||||||
summary: ReconciliationSummary;
|
summary: ReconciliationSummary;
|
||||||
@@ -89,6 +101,8 @@ export interface HistoricalReport {
|
|||||||
date: string;
|
date: string;
|
||||||
executionDate?: string;
|
executionDate?: string;
|
||||||
period: string;
|
period: string;
|
||||||
|
periodStart?: string;
|
||||||
|
periodEnd?: string;
|
||||||
payrollFile: string;
|
payrollFile: string;
|
||||||
bankFiles: string[];
|
bankFiles: string[];
|
||||||
matches: number;
|
matches: number;
|
||||||
|
|||||||
Vendored
+1
@@ -9,6 +9,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_HISTORICOS_TT_URL?: string;
|
readonly VITE_HISTORICOS_TT_URL?: string;
|
||||||
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
|
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
|
||||||
readonly VITE_ANALIZAR_NOMINA_URL?: string;
|
readonly VITE_ANALIZAR_NOMINA_URL?: string;
|
||||||
|
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
||||||
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
||||||
readonly VITE_SUPABASE_URL?: string;
|
readonly VITE_SUPABASE_URL?: string;
|
||||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user