From 2208cee87f2fe18818efd177b0d20035938a9999 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Thu, 28 May 2026 02:38:30 +0000 Subject: [PATCH] fix(impuestos): desactivar JIT en queries con subplans correlacionados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agrega helper withJitOff en impuestos.service.ts - Ejecuta getResumenIva, getIvaMensual y readResumenIvaFromCache con SET LOCAL jit = off - Evita compilación JIT de ~17s en queries con costo estimado alto feat(contribuyentes): auto-asignar a cartera del supervisor - Al crear contribuyente con supervisorUserId, se agrega automáticamente a todas las carteras top-level del supervisor feat(permisos): restricciones de UI por rol en contribuyentes - Oculta botón Add-ons para roles distintos de owner/cfo - Oculta botón Eliminar contribuyente para no-owner - Oculta botón Agregar RFC para auxiliar/visor/cliente/contador feat(cfdi): ver CFDI desde conceptos y forma de pago en Excel - Agrega botón Ver CFDI en cada fila de la tabla de Conceptos - Agrega columna Forma de Pago en export Excel de CFDIs - Agrega columna Forma de Pago en export individual de CFDI chore(migraciones): índices GIN para relaciones de activos - 048: índices btree parciales para activos - 049: índices GIN para cfdis_relacionados y uuid_relacionado --- apps/api/src/config/database.ts | 4 +- .../controllers/asignaciones.controller.ts | 68 +++++- .../controllers/contribuyente.controller.ts | 14 ++ .../tenant/048_cfdis_activos_indices.sql | 11 + .../tenant/049_cfdis_relaciones_gin.sql | 11 + apps/api/src/routes/cartera.routes.ts | 1 + apps/api/src/services/_shared/cfdi-filters.ts | 86 ++++---- apps/api/src/services/asignaciones.service.ts | 44 +++- apps/api/src/services/impuestos.service.ts | 193 +++++++++++------- .../carteras/seguimiento-auxiliares.tsx | 36 +++- apps/web/app/(dashboard)/cfdi/page.tsx | 18 ++ .../app/(dashboard)/contribuyentes/page.tsx | 44 ++-- apps/web/lib/api/asignaciones.ts | 3 + apps/web/lib/hooks/use-asignaciones.ts | 9 + 14 files changed, 390 insertions(+), 152 deletions(-) create mode 100644 apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql create mode 100644 apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts index 3f6b162..a958b39 100644 --- a/apps/api/src/config/database.ts +++ b/apps/api/src/config/database.ts @@ -71,9 +71,9 @@ class TenantConnectionManager { user: connectionOverride?.user ?? this.dbConfig.user, password: connectionOverride?.password ?? this.dbConfig.password, database: databaseName, - max: 3, + max: 10, idleTimeoutMillis: 300_000, - connectionTimeoutMillis: 10_000, + connectionTimeoutMillis: 30_000, }; pool = new Pool(poolConfig); diff --git a/apps/api/src/controllers/asignaciones.controller.ts b/apps/api/src/controllers/asignaciones.controller.ts index 77a4a05..4191296 100644 --- a/apps/api/src/controllers/asignaciones.controller.ts +++ b/apps/api/src/controllers/asignaciones.controller.ts @@ -7,6 +7,8 @@ import { AppError } from '../middlewares/error.middleware.js'; /** * Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner). * Owner puede asignar a cualquier auxiliar del tenant. + * La relación se infiere desde carteras (directas y subcarteras) con fallback + * a la tabla legacy auxiliar_supervisores. */ async function validarAuxiliarDelSupervisor( pool: import('pg').Pool, @@ -17,10 +19,22 @@ async function validarAuxiliarDelSupervisor( if (callerRole === 'owner') return; const { rows } = await pool.query( - `SELECT 1 FROM auxiliar_supervisores - WHERE auxiliar_user_id = $1 AND supervisor_user_id = $2 - LIMIT 1`, - [auxiliarUserId, supervisorUserId], + `SELECT 1 FROM ( + SELECT c.auxiliar_user_id + FROM carteras c + WHERE c.supervisor_user_id = $1 + AND c.auxiliar_user_id = $2 + UNION + SELECT sub.auxiliar_user_id + FROM carteras sub + JOIN carteras p ON p.id = sub.parent_id + WHERE p.supervisor_user_id = $1 + AND sub.auxiliar_user_id = $2 + UNION + SELECT auxiliar_user_id FROM auxiliar_supervisores + WHERE supervisor_user_id = $1 AND auxiliar_user_id = $2 + ) t LIMIT 1`, + [supervisorUserId, auxiliarUserId], ); if (rows.length === 0) { @@ -28,10 +42,30 @@ async function validarAuxiliarDelSupervisor( } } +/** + * Valida que el auxiliar tenga al contribuyente en alguna de sus subcarteras. + * Si no hay ningún auxiliar con ese contribuyente en su subcartera, la asignación + * se rechaza (el supervisor debe agregar el contribuyente a una subcartera primero). + */ +async function validarAuxiliarEnSubcartera( + pool: import('pg').Pool, + contribuyenteId: string, + auxiliarUserId: string, +): Promise { + const elegibles = await asignacionesService.getAuxiliaresElegibles(pool, contribuyenteId); + if (elegibles.length === 0) { + throw new AppError(403, 'Ningún auxiliar tiene este contribuyente en su subcartera'); + } + if (!elegibles.includes(auxiliarUserId)) { + throw new AppError(403, 'El auxiliar no tiene este contribuyente en ninguna de sus subcarteras'); + } +} + // ── Obligaciones ── export async function asignarObligacion(req: Request, res: Response, next: NextFunction) { try { + const contribuyenteId = String(req.params.id); const obligacionId = String(req.params.obligacionId); const schema = z.object({ auxiliarUserId: z.string().uuid() }); const { auxiliarUserId } = schema.parse(req.body); @@ -42,6 +76,11 @@ export async function asignarObligacion(req: Request, res: Response, next: NextF auxiliarUserId, req.user!.role, ); + await validarAuxiliarEnSubcartera( + req.tenantPool!, + contribuyenteId, + auxiliarUserId, + ); await asignacionesService.asignarObligacion( req.tenantPool!, @@ -80,6 +119,19 @@ export async function asignarTarea(req: Request, res: Response, next: NextFuncti req.user!.role, ); + // Obtener contribuyenteId de la tarea para validar subcartera + const { rows } = await req.tenantPool!.query<{ contribuyente_id: string }>( + `SELECT contribuyente_id FROM tareas_catalogo WHERE id = $1 LIMIT 1`, + [tareaId], + ); + if (rows.length > 0) { + await validarAuxiliarEnSubcartera( + req.tenantPool!, + rows[0].contribuyente_id, + auxiliarUserId, + ); + } + await asignacionesService.asignarTarea( req.tenantPool!, tareaId, @@ -135,3 +187,11 @@ export async function listSinAsignar(req: Request, res: Response, next: NextFunc res.json({ obligaciones, tareas }); } catch (err) { next(err); } } + +export async function listAuxiliaresElegibles(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.contribuyenteId); + const auxIds = await asignacionesService.getAuxiliaresElegibles(req.tenantPool!, contribuyenteId); + res.json({ auxiliares: auxIds }); + } catch (err) { next(err); } +} diff --git a/apps/api/src/controllers/contribuyente.controller.ts b/apps/api/src/controllers/contribuyente.controller.ts index 5ba5baf..d516d9f 100644 --- a/apps/api/src/controllers/contribuyente.controller.ts +++ b/apps/api/src/controllers/contribuyente.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import * as contribuyenteService from '../services/contribuyente.service.js'; +import * as carteraService from '../services/cartera.service.js'; import { AppError } from '../middlewares/error.middleware.js'; import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { adjustDespachoOverage } from '../services/payment/addon.service.js'; @@ -77,6 +78,19 @@ export async function create(req: Request, res: Response, next: NextFunction) { const row = await contribuyenteService.createContribuyente(req.tenantPool!, data); + // Si se asignó un supervisor, agregar el contribuyente a todas las carteras + // top-level de ese supervisor para que aparezca directamente en su vista. + if (data.supervisorUserId) { + try { + const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId); + await Promise.all( + carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id)) + ); + } catch (err: any) { + console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err); + } + } + // Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea // el addon y devuelve paymentUrl para que el frontend redirija al usuario. // Fail-soft: si falla el addon, el contribuyente queda creado y se loguea. diff --git a/apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql b/apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql new file mode 100644 index 0000000..9bd9306 --- /dev/null +++ b/apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql @@ -0,0 +1,11 @@ +-- Índices para acelerar los filtros de "Considerar activos" en Impuestos. + +-- Lookup rápido de facturas tipo I con uso de activo fijo +CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_uso_activos + ON cfdis(tipo_comprobante, uso_cfdi) + WHERE tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'); + +-- Filtrar E's que tienen relacionados (reduce el universo del anti-join) +CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_relacionados + ON cfdis(tipo_comprobante) + WHERE cfdis_relacionados IS NOT NULL; diff --git a/apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql b/apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql new file mode 100644 index 0000000..4511019 --- /dev/null +++ b/apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql @@ -0,0 +1,11 @@ +-- Índices GIN para acelerar búsquedas de activos en cfdis_relacionados y uuid_relacionado. +-- El filtro "Considerar activos" usa string_to_array(..., '|') para buscar UUIDs +-- relacionados; el índice GIN permite búsquedas @> y ANY eficientes sobre arrays. + +CREATE INDEX IF NOT EXISTS idx_cfdis_relacionados_gin + ON cfdis USING gin(string_to_array(LOWER(cfdis_relacionados), '|')) + WHERE cfdis_relacionados IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_cfdis_uuid_relacionado_gin + ON cfdis USING gin(string_to_array(LOWER(uuid_relacionado), '|')) + WHERE uuid_relacionado IS NOT NULL; diff --git a/apps/api/src/routes/cartera.routes.ts b/apps/api/src/routes/cartera.routes.ts index b0e076d..623adbe 100644 --- a/apps/api/src/routes/cartera.routes.ts +++ b/apps/api/src/routes/cartera.routes.ts @@ -16,6 +16,7 @@ router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor); router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar); router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar); +router.get('/asignaciones/auxiliares-elegibles/:contribuyenteId', authorize('owner', 'supervisor'), asignacionesCtrl.listAuxiliaresElegibles); // Read: owner + supervisor + auxiliar router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); diff --git a/apps/api/src/services/_shared/cfdi-filters.ts b/apps/api/src/services/_shared/cfdi-filters.ts index 98eaf8a..6c4e70c 100644 --- a/apps/api/src/services/_shared/cfdi-filters.ts +++ b/apps/api/src/services/_shared/cfdi-filters.ts @@ -24,44 +24,62 @@ * el de activos aplica también pero algunos predicados son no-op funcional * en subqueries que filtran por tipo_comprobante específico (Postgres los * optimiza away). + * + * OPTIMIZACIÓN: los subqueries de exclusiones de activos se reescribieron + * para usar subqueries NO-correlacionados donde sea posible (casos 1-3). + * Esto permite a PostgreSQL ejecutar el subquery una sola vez por query + * principal, en lugar de una vez por cada fila. Solo el caso 4 (anticipo + * referenciado por I07) requiere un correlated EXISTS. */ const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')"; +/** + * Subquery no-correlacionado que devuelve todos los UUIDs de facturas tipo I + * con uso de activo. Usado para lookups P→I y E→I. + */ +const UUIDS_ACTIVOS = `SELECT LOWER(uuid) AS uuid FROM cfdis WHERE tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}`; + +/** + * Subquery no-correlacionado que devuelve todos los UUIDs de E's que + * referencian un activo (directamente I-activo, o indirectamente P→I-activo). + * + * Usa JOIN + UNION en lugar de EXISTS + OR para que PostgreSQL pueda usar + * índices de forma más efectiva (especialmente el GIN en cfdis_relacionados). + */ +const UUIDS_E_DE_ACTIVOS = ` + SELECT e.uuid + FROM cfdis e + JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + WHERE e.tipo_comprobante = 'E' + AND e.cfdis_relacionados IS NOT NULL + AND r_act.tipo_comprobante = 'I' + AND r_act.uso_cfdi IN ${ACTIVOS_USOS} + UNION ALL + SELECT e.uuid + FROM cfdis e + JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + JOIN cfdis pi_act ON LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado) + WHERE e.tipo_comprobante = 'E' + AND e.cfdis_relacionados IS NOT NULL + AND r_act.tipo_comprobante = 'P' + AND pi_act.tipo_comprobante = 'I' + AND pi_act.uso_cfdi IN ${ACTIVOS_USOS} +`; + /** * Predicado SQL que detecta si el row actual (sin alias de tabla, asume * `FROM cfdis`) referencia un activo directamente (I), indirectamente vía * pago (P→I), o transitivamente vía relación (E→I, E→P→I). - * - * IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act` - * y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado` - * y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las - * columnas internas (NO al row outer), volviendo el predicado a no-op. - * Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados` - * explícitamente — fuerza la resolución al outer. */ function activosExclusionNoAlias(): string { return ` AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (tipo_comprobante = 'P' AND EXISTS ( - SELECT 1 FROM cfdis i_act - WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado) - AND i_act.tipo_comprobante = 'I' - AND i_act.uso_cfdi IN ${ACTIVOS_USOS} - )) - AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS ( - SELECT 1 FROM cfdis r_act - WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|')) - AND ( - (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS}) - OR (r_act.tipo_comprobante = 'P' AND EXISTS ( - SELECT 1 FROM cfdis pi_act - WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado) - AND pi_act.tipo_comprobante = 'I' - AND pi_act.uso_cfdi IN ${ACTIVOS_USOS} - )) - ) + SELECT 1 FROM (${UUIDS_ACTIVOS}) ua + WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|')) )) + AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS})) AND NOT (tipo_comprobante = 'I' AND EXISTS ( -- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es -- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD @@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string { return ` AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS ( - SELECT 1 FROM cfdis i_act - WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado) - AND i_act.tipo_comprobante = 'I' - AND i_act.uso_cfdi IN ${ACTIVOS_USOS} - )) - AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS ( - SELECT 1 FROM cfdis r_act - WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|')) - AND ( - (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS}) - OR (r_act.tipo_comprobante = 'P' AND EXISTS ( - SELECT 1 FROM cfdis pi_act - WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado) - AND pi_act.tipo_comprobante = 'I' - AND pi_act.uso_cfdi IN ${ACTIVOS_USOS} - )) - ) + SELECT 1 FROM (${UUIDS_ACTIVOS}) ua + WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|')) )) + AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS})) AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS ( SELECT 1 FROM cfdis i07_act WHERE i07_act.tipo_comprobante = 'I' diff --git a/apps/api/src/services/asignaciones.service.ts b/apps/api/src/services/asignaciones.service.ts index 2ff70bb..dc9122f 100644 --- a/apps/api/src/services/asignaciones.service.ts +++ b/apps/api/src/services/asignaciones.service.ts @@ -96,12 +96,32 @@ export async function getAsignacionesPorSupervisor( ): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> { const isOwner = role === 'owner' || role === 'cfo' || role === 'contador'; + // Relación supervisor → auxiliar se infiere desde carteras (directas y + // subcarteras) con fallback a la tabla legacy auxiliar_supervisores. + const supervisorFilter = isOwner + ? '' + : `AND EXISTS ( + SELECT 1 FROM ( + SELECT c.auxiliar_user_id + FROM carteras c + WHERE c.supervisor_user_id = $1 + AND c.auxiliar_user_id IS NOT NULL + UNION + SELECT sub.auxiliar_user_id + FROM carteras sub + JOIN carteras p ON p.id = sub.parent_id + WHERE p.supervisor_user_id = $1 + AND sub.auxiliar_user_id IS NOT NULL + UNION + SELECT auxiliar_user_id FROM auxiliar_supervisores WHERE supervisor_user_id = $1 + ) sup_aux WHERE sup_aux.auxiliar_user_id = __AUX_COL__ + )`; const whereObl = isOwner ? 'WHERE 1=1' - : 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = oa.auxiliar_user_id AND asp.supervisor_user_id = $1)'; + : `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'oa.auxiliar_user_id')}`; const whereTarea = isOwner ? 'WHERE 1=1' - : 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = ta.auxiliar_user_id AND asp.supervisor_user_id = $1)'; + : `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'ta.auxiliar_user_id')}`; const params = isOwner ? [] : [supervisorUserId]; const { rows: obligaciones } = await pool.query( @@ -301,3 +321,23 @@ export async function getAuxiliarAsignadoTarea( const names = await resolveUserNames([auxId]); return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null }; } + +/** + * Devuelve los userIds de auxiliares que tienen al contribuyente en alguna + * de sus subcarteras (carteras con auxiliar_user_id no nulo que contienen + * al contribuyente en cartera_entidades). + */ +export async function getAuxiliaresElegibles( + pool: Pool, + contribuyenteId: string, +): Promise { + const { rows } = await pool.query<{ auxiliar_user_id: string }>( + `SELECT DISTINCT c.auxiliar_user_id + FROM carteras c + JOIN cartera_entidades ce ON ce.cartera_id = c.id + WHERE ce.entidad_id = $1 + AND c.auxiliar_user_id IS NOT NULL`, + [contribuyenteId], + ); + return rows.map(r => r.auxiliar_user_id); +} diff --git a/apps/api/src/services/impuestos.service.ts b/apps/api/src/services/impuestos.service.ts index 8e3829b..ed02c81 100644 --- a/apps/api/src/services/impuestos.service.ts +++ b/apps/api/src/services/impuestos.service.ts @@ -1,4 +1,4 @@ -import type { Pool } from 'pg'; +import type { Pool, PoolClient } from 'pg'; import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared'; import { getRegimenesIgnoradosClaves } from './regimen.service.js'; import { @@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = ( esLadoE: string, considerarActivos: boolean, considerarNCs: boolean, -) => `COALESCE(( - SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')}) - FROM cfdis e - WHERE e.tipo_comprobante = 'E' - AND e.metodo_pago = 'PUE' - AND e.status NOT IN ('Cancelado', '0') - AND ${esLadoE} - AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) - AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) - = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} -), 0)`; +) => { + if (!considerarNCs) return '0'; + return `COALESCE(( + SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')}) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND e.cfdis_relacionados IS NOT NULL + AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)] + AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) + = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} + ), 0)`; +}; const SUM_E_REFERENCING_RET = ( esLadoE: string, considerarActivos: boolean, considerarNCs: boolean, -) => `COALESCE(( - SELECT SUM(${IVA_RET_EXPR_ALIAS('e')}) - FROM cfdis e - WHERE e.tipo_comprobante = 'E' - AND e.metodo_pago = 'PUE' - AND e.status NOT IN ('Cancelado', '0') - AND ${esLadoE} - AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) - AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) - = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} -), 0)`; +) => { + if (!considerarNCs) return '0'; + return `COALESCE(( + SELECT SUM(${IVA_RET_EXPR_ALIAS('e')}) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND e.cfdis_relacionados IS NOT NULL + AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)] + AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) + = date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} + ), 0)`; +}; // Régimen del contribuyente según su lado: emisor/receptor del CFDI. // Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para // determinar el lado, no el `type` de BD. @@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = ( esLadoE: string, considerarActivos: boolean, considerarNCs: boolean, -) => `EXISTS ( - SELECT 1 FROM cfdis e - WHERE e.tipo_comprobante = 'E' - AND e.metodo_pago = 'PUE' - AND e.status NOT IN ('Cancelado', '0') - AND ${esLadoE} - AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) - AND date_trunc('month', e.fecha_emision) - = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} -)`; +) => { + if (!considerarNCs) return 'FALSE'; + return `EXISTS ( + SELECT 1 FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND e.cfdis_relacionados IS NOT NULL + AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)] + AND date_trunc('month', e.fecha_emision) + = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} + )`; +}; // Atribución por lado usando RFC en lugar de `type`. Los buckets son // factories que reciben el context del contribuyente: @@ -397,8 +409,8 @@ export async function getIvaMensual( const añoEnd = `${año}-12-31`; const extra = buildExtraFilters(considerarActivos, considerarNCs); - const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ - pool.query<{ mes: number; trasladado: string; retencion: string }>(` + const { rows: causadoRows } = await withJitOff(pool, (client) => + client.query<{ mes: number; trasladado: string; retencion: string }>(` SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion @@ -407,8 +419,10 @@ export async function getIvaMensual( AND ${VIGENTE} AND ${FR}${extra} AND (${REGIMEN_TENANT}) = ANY($3) GROUP BY mes - `, [añoStart, añoEnd, TODOS_REGIMENES]), - pool.query<{ mes: number; trasladado: string; retencion: string }>(` + `, [añoStart, añoEnd, TODOS_REGIMENES]) + ); + const { rows: acreditableRows } = await withJitOff(pool, (client) => + client.query<{ mes: number; trasladado: string; retencion: string }>(` SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion @@ -417,8 +431,8 @@ export async function getIvaMensual( AND ${VIGENTE} AND ${FR}${extra} AND (${REGIMEN_TENANT}) = ANY($3) GROUP BY mes - `, [añoStart, añoEnd, TODOS_REGIMENES]), - ]); + `, [añoStart, añoEnd, TODOS_REGIMENES]) + ); perMes = new Map(); for (const row of causadoRows) { @@ -648,20 +662,22 @@ async function readResumenIvaFromCache( const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; const REGIMEN_TENANT = regimenTenantExpr(ctx); - const acumRow = (await pool.query(` - SELECT - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - - ( - COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) - ) as total - FROM cfdis - WHERE ${VIGENTE} - AND (${REGIMEN_TENANT}) = ANY($3) - AND ${acumFR} - AND (${ctx.esEmisor} OR ${ctx.esReceptor}) - `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0]; + const acumRow = (await withJitOff(pool, (client) => + client.query(` + SELECT + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - + ( + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) + ) as total + FROM cfdis + WHERE ${VIGENTE} + AND (${REGIMEN_TENANT}) = ANY($3) + AND ${acumFR} + AND (${ctx.esEmisor} OR ${ctx.esReceptor}) + `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]) + )).rows[0]; // Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache // aún no persiste esos campos — si se hace crítico para BI, agregar columna @@ -698,6 +714,29 @@ async function readResumenIvaFromCache( * * Algebraicamente: T − A − R == dashboard.balance, céntimo por céntimo. */ +/** + * Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off). + * Usa una transacción implícita para que el SET LOCAL se restaure automáticamente + * al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con + * muchos subplans (correlacionados), lo cual puede tardar >15s en queries con + * costo estimado muy alto aunque la ejecución real sea rápida. + */ +async function withJitOff(pool: Pool, fn: (client: PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('SET LOCAL jit = off'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } +} + export async function getResumenIva( pool: Pool, fechaInicio: string, @@ -725,10 +764,10 @@ export async function getResumenIva( if (cached) return cached; } - // Una query por lado (causado / acreditable). Filtro por RFC via - // ctx.esEmisor/esReceptor (embedded en buckets/signed exprs). - const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ - pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` + // Queries con JIT off: evitan compilación JIT de >15s en queries con muchos + // subplans correlacionados (activado por costo estimado >100k). + const { rows: causadoRows } = await withJitOff(pool, (client) => + client.query<{ regimen: string | null; trasladado: string; retencion: string }>(` SELECT ${REGIMEN_TENANT} as regimen, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion @@ -737,8 +776,10 @@ export async function getResumenIva( AND ${VIGENTE} AND ${FR}${extra} AND (${REGIMEN_TENANT}) = ANY($3) GROUP BY ${REGIMEN_TENANT} - `, [fechaInicio, fechaFin, TODOS_REGIMENES]), - pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` + `, [fechaInicio, fechaFin, TODOS_REGIMENES]) + ); + const { rows: acreditableRows } = await withJitOff(pool, (client) => + client.query<{ regimen: string | null; trasladado: string; retencion: string }>(` SELECT ${REGIMEN_TENANT} as regimen, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion @@ -747,8 +788,8 @@ export async function getResumenIva( AND ${VIGENTE} AND ${FR}${extra} AND (${REGIMEN_TENANT}) = ANY($3) GROUP BY ${REGIMEN_TENANT} - `, [fechaInicio, fechaFin, TODOS_REGIMENES]), - ]); + `, [fechaInicio, fechaFin, TODOS_REGIMENES]) + ); // Combinar por régimen: el set de régimenes posibles es la unión de ambos lados. type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number }; @@ -799,20 +840,22 @@ export async function getResumenIva( // Acumulado anual (misma fórmula T − A − R, pero rango = enero → fechaFin). const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; - const { rows: [acumRow] } = await pool.query(` - SELECT - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - - ( - COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - - COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) - ) as total - FROM cfdis - WHERE ${VIGENTE} - AND (${REGIMEN_TENANT}) = ANY($3) - AND ${acumFR}${extra} - AND (${ctx.esEmisor} OR ${ctx.esReceptor}) - `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]); + const { rows: [acumRow] } = await withJitOff(pool, (client) => + client.query(` + SELECT + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - + ( + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) + ) as total + FROM cfdis + WHERE ${VIGENTE} + AND (${REGIMEN_TENANT}) = ANY($3) + AND ${acumFR}${extra} + AND (${ctx.esEmisor} OR ${ctx.esReceptor}) + `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]) + ); // IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR). // No participa en `resultado` — ya excluido del `acreditable` arriba via filtro diff --git a/apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx b/apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx index b484f1a..51b3acf 100644 --- a/apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx +++ b/apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx @@ -14,6 +14,7 @@ import { useDesasignarObligacion, useAsignarTarea, useDesasignarTarea, + useAuxiliaresElegibles, } from '@/lib/hooks/use-asignaciones'; import { useUsuarios } from '@/lib/hooks/use-usuarios'; import { useAuthStore } from '@/stores/auth-store'; @@ -36,6 +37,11 @@ export default function SeguimientoAuxiliares() { const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar'); + const { data: elegiblesData, isLoading: loadingElegibles } = useAuxiliaresElegibles(modalItem?.contribuyenteId); + const auxiliaresIdsElegibles = elegiblesData?.auxiliares ?? []; + const auxiliaresFiltrados = auxiliares.filter((a: any) => auxiliaresIdsElegibles.includes(a.id)); + const puedeAsignar = !loadingElegibles && auxiliaresFiltrados.length > 0; + const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => { setModalType(type); setModalItem(item); @@ -169,20 +175,28 @@ export default function SeguimientoAuxiliares() {

Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})

- + {loadingElegibles ? ( +

Verificando subcarteras...

+ ) : auxiliaresFiltrados.length === 0 ? ( +

+ Ningún auxiliar tiene este contribuyente en su subcartera. No se puede asignar. +

+ ) : ( + + )} - diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 10f5b08..71579f8 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -425,6 +425,7 @@ export default function CfdiPage() { 'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Uso CFDI': (cfdi as any).usoCfdi || '', + 'Forma de Pago': cfdi.formaPago || '', 'Serie': cfdi.serie || '', 'Folio': cfdi.folio || '', 'RFC Emisor': cfdi.rfcEmisor, @@ -541,6 +542,7 @@ export default function CfdiPage() { 'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Uso CFDI': (cfdi as any).usoCfdi || '', + 'Forma de Pago': cfdi.formaPago || '', 'Serie': cfdi.serie || '', 'Folio': cfdi.folio || '', 'RFC Emisor': cfdi.rfcEmisor, @@ -1699,6 +1701,7 @@ export default function CfdiPage() { )} + @@ -1715,6 +1718,21 @@ export default function CfdiPage() { {row.clave_unidad || '-'} ${Number(row.valor_unitario ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })} ${Number(row.importe ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })} + + + ))} diff --git a/apps/web/app/(dashboard)/contribuyentes/page.tsx b/apps/web/app/(dashboard)/contribuyentes/page.tsx index 5fe803a..765b36d 100644 --- a/apps/web/app/(dashboard)/contribuyentes/page.tsx +++ b/apps/web/app/(dashboard)/contribuyentes/page.tsx @@ -88,6 +88,8 @@ export default function ContribuyentesPage() { setShowDialog(true); }; + const canCreate = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor'; + return (
@@ -95,14 +97,16 @@ export default function ContribuyentesPage() {

Contribuyentes

RFCs que gestiona tu despacho · {rfcCounterText}

- + {canCreate && ( + + )}
{isLoading ?

Cargando...

: !contribuyentes || contribuyentes.length === 0 ? ( @@ -110,13 +114,15 @@ export default function ContribuyentesPage() {

Sin contribuyentes

Agrega el primer RFC para empezar.

- + {canCreate && ( + + )} ) : (
{contribuyentes.map((c) => ( @@ -127,9 +133,13 @@ export default function ContribuyentesPage() { {c.regimenFiscal &&

Régimen: {c.regimenFiscal}

}
- + {(user?.role === 'owner' || user?.role === 'cfo') && ( + + )} - + {user?.role === 'owner' && ( + + )}
))} diff --git a/apps/web/lib/api/asignaciones.ts b/apps/web/lib/api/asignaciones.ts index 716cc8f..72556a6 100644 --- a/apps/web/lib/api/asignaciones.ts +++ b/apps/web/lib/api/asignaciones.ts @@ -56,3 +56,6 @@ export const asignarTarea = (tareaId: string, auxiliarUserId: string) => export const desasignarTarea = (tareaId: string) => apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data); + +export const getAuxiliaresElegibles = (contribuyenteId: string) => + apiClient.get<{ auxiliares: string[] }>(`/carteras/asignaciones/auxiliares-elegibles/${contribuyenteId}`).then(r => r.data); diff --git a/apps/web/lib/hooks/use-asignaciones.ts b/apps/web/lib/hooks/use-asignaciones.ts index 85646a4..df1d07b 100644 --- a/apps/web/lib/hooks/use-asignaciones.ts +++ b/apps/web/lib/hooks/use-asignaciones.ts @@ -7,6 +7,7 @@ import { desasignarObligacion, asignarTarea, desasignarTarea, + getAuxiliaresElegibles, } from '../api/asignaciones'; export function useAsignacionesSupervisor() { @@ -87,3 +88,11 @@ export function useDesasignarTarea() { }, }); } + +export function useAuxiliaresElegibles(contribuyenteId: string | undefined) { + return useQuery({ + queryKey: ['auxiliares-elegibles', contribuyenteId], + queryFn: () => getAuxiliaresElegibles(contribuyenteId!), + enabled: !!contribuyenteId, + }); +}