From 7b1f60cbf2df9e306ef7ad894baad72194579383 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Fri, 15 May 2026 22:53:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(reportes):=20redise=C3=B1o=20Estado=20de?= =?UTF-8?q?=20Resultados=20vertical=20con=20drill-down,=20an=C3=A1lisis=20?= =?UTF-8?q?horizontal/vertical=20y=20export=20Excel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nuevo endpoint GET /reportes/estado-resultados-detallado con cálculo contable: * Ventas, Devoluciones, Ventas netas, Costo de ventas, Utilidad bruta, Gastos operativos, Utilidad de la operación * Fórmula: subtotal_mxn - descuento_mxn (sin impuestos), nómina usa total_mxn * Excluye anticipos (uso_cfdi=P01 o clave_prod_serv=84111506) * Filtro por régimen fiscal opcional * Año anterior calculado automáticamente - Nuevo endpoint GET /reportes/estado-resultados/drill-down: * Nivel 1: resumen agrupado por RFC * Nivel 2: CFDIs individuales filtrados por categoría * Categorías: ventas, devoluciones, costo-ventas, gastos-operativos - Nuevo endpoint GET /reportes/estado-resultados/export: * Genera Excel con formato condicional (verde/rojo, negritas) - Frontend: * Tabla vertical con % vertical, año anterior y variación % * Filas clickeables para drill-down modal de 2 niveles * Top 10 Clientes/Proveedores mantenidos debajo * Selector de régimen conectado al reporte - Fix: NaN en total de drill-down nivel 2 por numeric como string en pg * Agregado ::float en queries SQL de CFDIs individuales --- .../src/controllers/reportes.controller.ts | 71 +++ apps/api/src/routes/reportes.routes.ts | 3 + apps/api/src/services/reportes.service.ts | 466 +++++++++++++++++- .../reportes/components/drill-down-modal.tsx | 223 +++++++++ .../components/estado-resultados-table.tsx | 189 +++++++ apps/web/app/(dashboard)/reportes/page.tsx | 109 ++-- apps/web/lib/api/reportes.ts | 67 ++- apps/web/lib/hooks/use-reportes.ts | 25 + docs/CAMBIOS-2026-05-09.md | 53 ++ packages/shared/src/types/reportes.ts | 20 + 10 files changed, 1160 insertions(+), 66 deletions(-) create mode 100644 apps/web/app/(dashboard)/reportes/components/drill-down-modal.tsx create mode 100644 apps/web/app/(dashboard)/reportes/components/estado-resultados-table.tsx diff --git a/apps/api/src/controllers/reportes.controller.ts b/apps/api/src/controllers/reportes.controller.ts index 6d44e22..efb6362 100644 --- a/apps/api/src/controllers/reportes.controller.ts +++ b/apps/api/src/controllers/reportes.controller.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction } from 'express'; import * as reportesService from '../services/reportes.service.js'; +import { exportEstadoResultadosToExcel } from '../services/reportes.service.js'; export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) { try { @@ -83,3 +84,73 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF next(error); } } + +export async function getEstadoResultadosDetallado(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, contribuyenteId, regimen } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getEstadoResultadosDetallado( + req.tenantPool!, + inicio, + fin, + req.user!.tenantId, + contribuyenteId as string | undefined || null, + regimen as string | undefined || null, + ); + res.json(data); + } catch (error) { + console.error('[reportes] Error en getEstadoResultadosDetallado:', error); + next(error); + } +} + +export async function getEstadoResultadosDrillDown(req: Request, res: Response, next: NextFunction) { + try { + const { categoria, fechaInicio, fechaFin, contribuyenteId, regimen, rfc } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getEstadoResultadosDrillDown( + req.tenantPool!, + categoria as string, + inicio, + fin, + contribuyenteId as string | undefined || null, + regimen as string | undefined || null, + rfc as string | undefined || null, + ); + res.json(data); + } catch (error) { + console.error('[reportes] Error en getEstadoResultadosDrillDown:', error); + next(error); + } +} + +export async function exportEstadoResultados(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, contribuyenteId, regimen } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const buffer = await exportEstadoResultadosToExcel( + req.tenantPool!, + inicio, + fin, + req.user!.tenantId, + contribuyenteId as string | undefined || null, + regimen as string | undefined || null, + ); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="estado-resultados-${inicio}-${fin}.xlsx"`); + res.send(buffer); + } catch (error) { + console.error('[reportes] Error en exportEstadoResultados:', error); + next(error); + } +} diff --git a/apps/api/src/routes/reportes.routes.ts b/apps/api/src/routes/reportes.routes.ts index 4ae8ad6..89dc322 100644 --- a/apps/api/src/routes/reportes.routes.ts +++ b/apps/api/src/routes/reportes.routes.ts @@ -15,6 +15,9 @@ router.use(checkPlanLimits); router.use(requireFeature('reportes')); router.get('/estado-resultados', reportesController.getEstadoResultados); +router.get('/estado-resultados-detallado', reportesController.getEstadoResultadosDetallado); +router.get('/estado-resultados/drill-down', reportesController.getEstadoResultadosDrillDown); +router.get('/estado-resultados/export', reportesController.exportEstadoResultados); router.get('/flujo-efectivo', reportesController.getFlujoEfectivo); router.get('/comparativo', reportesController.getComparativo); router.get('/concentrado-rfc', reportesController.getConcentradoRfc); diff --git a/apps/api/src/services/reportes.service.ts b/apps/api/src/services/reportes.service.ts index cd45474..29ca951 100644 --- a/apps/api/src/services/reportes.service.ts +++ b/apps/api/src/services/reportes.service.ts @@ -1,6 +1,7 @@ import type { Pool } from 'pg'; -import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; +import type { EstadoResultados, EstadoResultadosDetallado, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from './dashboard.service.js'; +import ExcelJS from 'exceljs'; /** * Resuelve condiciones `esEmisor` / `esReceptor` para un contribuyente @@ -359,6 +360,469 @@ export async function getCuentasXPagar( }; } +// ───────────────────────────────────────────────────────────────────────────── +// Estado de Resultados Detallado (contable) +// ───────────────────────────────────────────────────────────────────────────── + +const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`; +const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; +const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`; + +function sameDateLastYear(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + d.setFullYear(d.getFullYear() - 1); + return d.toISOString().split('T')[0]; +} + +function buildRegimenFilter(regimen?: string | null, campo: 'emisor' | 'receptor' = 'emisor'): string { + if (!regimen) return ''; + const safe = regimen.replace(/[^0-9]/g, '').slice(0, 3); + if (!safe) return ''; + const col = campo === 'emisor' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor'; + return `AND ${col} = '${safe}'`; +} + +async function calcularLineaEstadoResultados( + pool: Pool, + fechaInicio: string, + fechaFin: string, + esEmisor: string, + esReceptor: string, + regimen?: string | null, +): Promise<{ + ventas: number; + devoluciones: number; + costoVentas: number; + gastosOperativosRecibidos: number; + gastosOperativosNomina: number; +}> { + const rfEmisor = buildRegimenFilter(regimen, 'emisor'); + const rfReceptor = buildRegimenFilter(regimen, 'receptor'); + + const anticipoExcl = ` + AND NOT (uso_cfdi = 'P01') + AND NOT EXISTS ( + SELECT 1 FROM cfdi_conceptos cc + WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv = '84111506' + ) + `; + + const [ventasR, devolR, costoR, gastosRecR, nominaR] = await Promise.all([ + // Ventas + pool.query<{ monto: string }>(` + SELECT COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${anticipoExcl} + ${rfEmisor} + `, [fechaInicio, fechaFin]), + + // Devoluciones + pool.query<{ monto: string }>(` + SELECT COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' + AND COALESCE(cfdi_tipo_relacion, '') IN ('01', '03') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + `, [fechaInicio, fechaFin]), + + // Costo de ventas + pool.query<{ monto: string }>(` + SELECT COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND uso_cfdi = 'G01' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + `, [fechaInicio, fechaFin]), + + // Gastos operativos (recibidos, excluyendo G01) + pool.query<{ monto: string }>(` + SELECT COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND (uso_cfdi != 'G01' OR uso_cfdi IS NULL) + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + `, [fechaInicio, fechaFin]), + + // Gastos operativos (nómina emitida) + pool.query<{ monto: string }>(` + SELECT COALESCE(SUM(total_mxn), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'N' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + `, [fechaInicio, fechaFin]), + ]); + + return { + ventas: toNumber(ventasR.rows[0]?.monto), + devoluciones: toNumber(devolR.rows[0]?.monto), + costoVentas: toNumber(costoR.rows[0]?.monto), + gastosOperativosRecibidos: toNumber(gastosRecR.rows[0]?.monto), + gastosOperativosNomina: toNumber(nominaR.rows[0]?.monto), + }; +} + +export async function getEstadoResultadosDetallado( + pool: Pool, + fechaInicio: string, + fechaFin: string, + _tenantId: string, + contribuyenteId?: string | null, + regimen?: string | null, +): Promise { + const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + + const [actual, anterior] = await Promise.all([ + calcularLineaEstadoResultados(pool, fechaInicio, fechaFin, esEmisor, esReceptor, regimen), + calcularLineaEstadoResultados(pool, sameDateLastYear(fechaInicio), sameDateLastYear(fechaFin), esEmisor, esReceptor, regimen), + ]); + + function build(d: typeof actual) { + const ventasNetas = d.ventas - d.devoluciones; + const utilidadBruta = ventasNetas - d.costoVentas; + const gastosOperativos = d.gastosOperativosRecibidos + d.gastosOperativosNomina; + const utilidadOperacion = utilidadBruta - gastosOperativos; + return { ventasNetas, utilidadBruta, gastosOperativos, utilidadOperacion }; + } + + const a = build(actual); + const ant = build(anterior); + + return { + periodo: { inicio: fechaInicio, fin: fechaFin }, + ventas: actual.ventas, + devoluciones: actual.devoluciones, + ventasNetas: a.ventasNetas, + costoVentas: actual.costoVentas, + utilidadBruta: a.utilidadBruta, + gastosOperativos: a.gastosOperativos, + utilidadOperacion: a.utilidadOperacion, + anterior: { + ventas: anterior.ventas, + devoluciones: anterior.devoluciones, + ventasNetas: ant.ventasNetas, + costoVentas: anterior.costoVentas, + utilidadBruta: ant.utilidadBruta, + gastosOperativos: ant.gastosOperativos, + utilidadOperacion: ant.utilidadOperacion, + }, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Drill-down del Estado de Resultados +// ───────────────────────────────────────────────────────────────────────────── + +export interface DrillDownResumenItem { + rfc: string; + nombre: string; + cantidad: number; + monto: number; +} + +export interface DrillDownCfdiItem { + id: number; + uuid: string; + tipoComprobante: string; + fechaEmision: string; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + monto: number; + metodoPago: string | null; + regimenFiscalEmisor: string | null; + regimenFiscalReceptor: string | null; +} + +export async function getEstadoResultadosDrillDown( + pool: Pool, + categoria: string, + fechaInicio: string, + fechaFin: string, + contribuyenteId?: string | null, + regimen?: string | null, + rfc?: string | null, +): Promise { + const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + const rfEmisor = buildRegimenFilter(regimen, 'emisor'); + const rfReceptor = buildRegimenFilter(regimen, 'receptor'); + + const anticipoExcl = ` + AND NOT (uso_cfdi = 'P01') + AND NOT EXISTS ( + SELECT 1 FROM cfdi_conceptos cc + WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv = '84111506' + ) + `; + + if (categoria === 'ventas') { + if (!rfc) { + const { rows } = await pool.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${anticipoExcl} + ${rfEmisor} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY monto DESC + `, [fechaInicio, fechaFin]); + return rows.map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, monto: toNumber(r.monto) })); + } + const { rows } = await pool.query(` + SELECT id, uuid, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + (${BASE_MONTO})::float as monto, + metodo_pago as "metodoPago", + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor" + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${anticipoExcl} + ${rfEmisor} + AND rfc_receptor = $3 + ORDER BY fecha_emision DESC + `, [fechaInicio, fechaFin, rfc]); + return rows as DrillDownCfdiItem[]; + } + + if (categoria === 'devoluciones') { + if (!rfc) { + const { rows } = await pool.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' + AND COALESCE(cfdi_tipo_relacion, '') IN ('01', '03') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY monto DESC + `, [fechaInicio, fechaFin]); + return rows.map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, monto: toNumber(r.monto) })); + } + const { rows } = await pool.query(` + SELECT id, uuid, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + (${BASE_MONTO})::float as monto, + metodo_pago as "metodoPago", + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor" + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' + AND COALESCE(cfdi_tipo_relacion, '') IN ('01', '03') + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + AND rfc_receptor = $3 + ORDER BY fecha_emision DESC + `, [fechaInicio, fechaFin, rfc]); + return rows as DrillDownCfdiItem[]; + } + + if (categoria === 'costo-ventas') { + if (!rfc) { + const { rows } = await pool.query(` + SELECT rfc_emisor as rfc, nombre_emisor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND uso_cfdi = 'G01' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + GROUP BY rfc_emisor, nombre_emisor + ORDER BY monto DESC + `, [fechaInicio, fechaFin]); + return rows.map((r: any) => ({ rfc: r.rfc, nombre: r.nombre, cantidad: r.cantidad, monto: toNumber(r.monto) })); + } + const { rows } = await pool.query(` + SELECT id, uuid, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + (${BASE_MONTO})::float as monto, + metodo_pago as "metodoPago", + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor" + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND uso_cfdi = 'G01' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + AND rfc_emisor = $3 + ORDER BY fecha_emision DESC + `, [fechaInicio, fechaFin, rfc]); + return rows as DrillDownCfdiItem[]; + } + + if (categoria === 'gastos-operativos') { + if (!rfc) { + const [{ rows: recRows }, { rows: nomRows }] = await Promise.all([ + pool.query(` + SELECT rfc_emisor as rfc, nombre_emisor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(${BASE_MONTO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND (uso_cfdi != 'G01' OR uso_cfdi IS NULL) + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + GROUP BY rfc_emisor, nombre_emisor + ORDER BY monto DESC + `, [fechaInicio, fechaFin]), + pool.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(total_mxn), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'N' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY monto DESC + `, [fechaInicio, fechaFin]), + ]); + const map = new Map(); + for (const r of recRows) { + const key = r.rfc as string; + const existing = map.get(key); + if (existing) { + existing.cantidad += (r.cantidad as number); + existing.monto += toNumber(r.monto); + } else { + map.set(key, { rfc: key, nombre: r.nombre as string, cantidad: r.cantidad as number, monto: toNumber(r.monto) }); + } + } + for (const r of nomRows) { + const key = r.rfc as string; + const existing = map.get(key); + if (existing) { + existing.cantidad += (r.cantidad as number); + existing.monto += toNumber(r.monto); + } else { + map.set(key, { rfc: key, nombre: r.nombre as string, cantidad: r.cantidad as number, monto: toNumber(r.monto) }); + } + } + return Array.from(map.values()).sort((a, b) => b.monto - a.monto); + } + const [{ rows: recCfdis }, { rows: nomCfdis }] = await Promise.all([ + pool.query(` + SELECT id, uuid, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + (${BASE_MONTO})::float as monto, + metodo_pago as "metodoPago", + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor" + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND (uso_cfdi != 'G01' OR uso_cfdi IS NULL) + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfReceptor} + AND rfc_emisor = $3 + ORDER BY fecha_emision DESC + `, [fechaInicio, fechaFin, rfc]), + pool.query(` + SELECT id, uuid, tipo_comprobante as "tipoComprobante", fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + total_mxn::float as monto, + metodo_pago as "metodoPago", + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor" + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'N' + AND ${VIGENTE_ER} AND ${RANGO_FECHA} + ${rfEmisor} + AND rfc_receptor = $3 + ORDER BY fecha_emision DESC + `, [fechaInicio, fechaFin, rfc]), + ]); + const combined = [...recCfdis, ...nomCfdis].sort((a: any, b: any) => + new Date(b.fechaEmision).getTime() - new Date(a.fechaEmision).getTime() + ); + return combined as DrillDownCfdiItem[]; + } + + throw new Error('Categoría de drill-down no válida'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Export Excel del Estado de Resultados +// ───────────────────────────────────────────────────────────────────────────── + +export async function exportEstadoResultadosToExcel( + pool: Pool, + fechaInicio: string, + fechaFin: string, + _tenantId: string, + contribuyenteId?: string | null, + regimen?: string | null, +): Promise { + const data = await getEstadoResultadosDetallado(pool, fechaInicio, fechaFin, _tenantId, contribuyenteId, regimen); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('Estado de Resultados'); + + sheet.columns = [ + { header: 'Concepto', key: 'concepto', width: 35 }, + { header: 'Monto Actual', key: 'actual', width: 20 }, + { header: '% Vertical', key: 'vertical', width: 12 }, + { header: 'Año Anterior', key: 'anterior', width: 20 }, + { header: 'Var. %', key: 'variacion', width: 12 }, + ]; + + // Header style + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }; + + const ventas = data.ventas || 1; + + const rows = [ + { concepto: 'Ventas', actual: data.ventas, vertical: (data.ventas / ventas) * 100, anterior: data.anterior.ventas, variacion: data.anterior.ventas ? ((data.ventas - data.anterior.ventas) / data.anterior.ventas) * 100 : 0 }, + { concepto: 'Devoluciones y cancelaciones', actual: -data.devoluciones, vertical: -(data.devoluciones / ventas) * 100, anterior: -data.anterior.devoluciones, variacion: data.anterior.devoluciones ? ((-data.devoluciones + data.anterior.devoluciones) / data.anterior.devoluciones) * 100 : 0 }, + { concepto: 'Ventas netas', actual: data.ventasNetas, vertical: (data.ventasNetas / ventas) * 100, anterior: data.anterior.ventasNetas, variacion: data.anterior.ventasNetas ? ((data.ventasNetas - data.anterior.ventasNetas) / data.anterior.ventasNetas) * 100 : 0, isTotal: true }, + { concepto: 'Costo de ventas', actual: -data.costoVentas, vertical: -(data.costoVentas / ventas) * 100, anterior: -data.anterior.costoVentas, variacion: data.anterior.costoVentas ? ((-data.costoVentas + data.anterior.costoVentas) / data.anterior.costoVentas) * 100 : 0 }, + { concepto: 'Utilidad bruta', actual: data.utilidadBruta, vertical: (data.utilidadBruta / ventas) * 100, anterior: data.anterior.utilidadBruta, variacion: data.anterior.utilidadBruta ? ((data.utilidadBruta - data.anterior.utilidadBruta) / data.anterior.utilidadBruta) * 100 : 0, isTotal: true }, + { concepto: 'Gastos operativos', actual: -data.gastosOperativos, vertical: -(data.gastosOperativos / ventas) * 100, anterior: -data.anterior.gastosOperativos, variacion: data.anterior.gastosOperativos ? ((-data.gastosOperativos + data.anterior.gastosOperativos) / data.anterior.gastosOperativos) * 100 : 0 }, + { concepto: 'Utilidad de la operación', actual: data.utilidadOperacion, vertical: (data.utilidadOperacion / ventas) * 100, anterior: data.anterior.utilidadOperacion, variacion: data.anterior.utilidadOperacion ? ((data.utilidadOperacion - data.anterior.utilidadOperacion) / data.anterior.utilidadOperacion) * 100 : 0, isTotal: true }, + ]; + + rows.forEach((r) => { + const row = sheet.addRow({ + concepto: r.concepto, + actual: r.actual, + vertical: `${r.vertical.toFixed(1)}%`, + anterior: r.anterior, + variacion: `${r.variacion.toFixed(1)}%`, + }); + if (r.isTotal) { + row.font = { bold: true }; + } + // Color para montos negativos + const actualCell = row.getCell(2); + if (r.actual < 0) { + actualCell.font = { ...(row.font || {}), color: { argb: 'FFC00000' } }; + } else if (r.actual > 0) { + actualCell.font = { ...(row.font || {}), color: { argb: 'FF00B050' } }; + } + }); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + export async function getCuentasXCobrar( pool: Pool, fechaInicio: string, diff --git a/apps/web/app/(dashboard)/reportes/components/drill-down-modal.tsx b/apps/web/app/(dashboard)/reportes/components/drill-down-modal.tsx new file mode 100644 index 0000000..e16f6f9 --- /dev/null +++ b/apps/web/app/(dashboard)/reportes/components/drill-down-modal.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Button, +} from '@horux/shared-ui'; +import { useEstadoResultadosDrillDown } from '@/lib/hooks/use-reportes'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { ArrowLeft, Download, Eye } from 'lucide-react'; +import type { DrillDownResumenItem, DrillDownCfdiItem } from '@/lib/api/reportes'; + +interface Props { + categoria: string; + fechaInicio: string; + fechaFin: string; + regimen?: string | null; + onClose: () => void; +} + +const CATEGORIA_TITULO: Record = { + ventas: 'Ventas', + devoluciones: 'Devoluciones y cancelaciones', + 'costo-ventas': 'Costo de ventas', + 'gastos-operativos': 'Gastos operativos', +}; + +const RFC_COLUMNS = [ + { header: 'RFC', key: 'rfc', width: 15 }, + { header: 'Nombre', key: 'nombre', width: 35 }, + { header: 'CFDIs', key: 'cantidad', width: 10 }, + { header: 'Monto', key: '_monto', width: 20 }, +]; + +const CFDI_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Comp.', key: 'tipoComprobante', width: 10 }, + { header: 'Fecha', key: '_fecha', width: 15 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'Monto', key: '_monto', width: 15 }, +]; + +function isResumen(data: unknown[]): data is DrillDownResumenItem[] { + return data.length > 0 && 'cantidad' in (data[0] as any); +} + +function isCfdis(data: unknown[]): data is DrillDownCfdiItem[] { + return data.length > 0 && 'uuid' in (data[0] as any); +} + +export function EstadoResultadosDrillDownModal({ + categoria, + fechaInicio, + fechaFin, + regimen, + onClose, +}: Props) { + const [selectedRfc, setSelectedRfc] = useState(null); + const [selectedNombre, setSelectedNombre] = useState(''); + + const { data, isLoading } = useEstadoResultadosDrillDown( + categoria, + fechaInicio, + fechaFin, + regimen || undefined, + selectedRfc || undefined, + ); + + const resumen = useMemo(() => (data && isResumen(data) ? data : []), [data]); + const cfdis = useMemo(() => (data && isCfdis(data) ? data : []), [data]); + + const totalMonto = useMemo(() => { + if (resumen.length > 0) return resumen.reduce((s, r) => s + Number(r.monto || 0), 0); + if (cfdis.length > 0) return cfdis.reduce((s, c) => s + Number(c.monto || 0), 0); + return 0; + }, [resumen, cfdis]); + + const handleExport = () => { + if (resumen.length > 0) { + const rows = resumen.map((r) => ({ + ...r, + _monto: r.monto, + })); + exportToExcel(rows, RFC_COLUMNS, `drill-down-${categoria}-rfc`); + } else if (cfdis.length > 0) { + const rows = cfdis.map((c) => ({ + ...c, + _fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _monto: c.monto, + })); + exportToExcel(rows, CFDI_COLUMNS, `drill-down-${categoria}-cfdis`); + } + }; + + const titulo = CATEGORIA_TITULO[categoria] || categoria; + + return ( + !open && onClose()}> + + +
+ {selectedRfc && ( + + )} + + {selectedRfc ? `${titulo} — ${selectedNombre}` : titulo} + +
+
+ + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
+ No hay datos para esta categoría +
+ ) : ( + <> +
+

+ {resumen.length > 0 + ? `${resumen.length} RFCs encontrados` + : `${cfdis.length} CFDIs encontrados`} +

+
+ + Total: {formatCurrency(totalMonto)} + + +
+
+ +
+ + + + {resumen.length > 0 ? ( + <> + + + + + + + ) : ( + <> + + + + + + + + + + )} + + + + {resumen.length > 0 + ? resumen.map((item) => ( + { + setSelectedRfc(item.rfc); + setSelectedNombre(item.nombre); + }} + > + + + + + + + )) + : cfdis.map((item) => ( + + + + + + + + + + + ))} + +
RFCNombreCFDIsMontoUUIDComp.FechaRFC EmisorNombre EmisorRFC ReceptorNombre ReceptorMonto
{item.rfc}{item.nombre}{item.cantidad} + {formatCurrency(item.monto)} + + +
+ {item.uuid?.substring(0, 8)} + {item.tipoComprobante} + {new Date(item.fechaEmision).toLocaleDateString('es-MX')} + {item.rfcEmisor} + {item.nombreEmisor} + {item.rfcReceptor} + {item.nombreReceptor} + + {formatCurrency(item.monto)} +
+
+ + )} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/reportes/components/estado-resultados-table.tsx b/apps/web/app/(dashboard)/reportes/components/estado-resultados-table.tsx new file mode 100644 index 0000000..639c885 --- /dev/null +++ b/apps/web/app/(dashboard)/reportes/components/estado-resultados-table.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui'; +import { formatCurrency } from '@/lib/utils'; +import { Download, ChevronRight } from 'lucide-react'; +import type { EstadoResultadosDetallado } from '@horux/shared'; +import { getExportEstadoResultadosUrl } from '@/lib/api/reportes'; +import { EstadoResultadosDrillDownModal } from './drill-down-modal'; + +interface Props { + data: EstadoResultadosDetallado; + fechaInicio: string; + fechaFin: string; + regimen?: string | null; +} + +type FilaConcepto = { + concepto: string; + monto: number; + vertical: number; + anterior: number; + variacion: number; + isTotal: boolean; + categoria?: string; +}; + +export function EstadoResultadosTable({ data, fechaInicio, fechaFin, regimen }: Props) { + const [drillDownCategoria, setDrillDownCategoria] = useState(null); + + const ventas = data.ventas || 1; + + const filas: FilaConcepto[] = [ + { + concepto: 'Ventas', + monto: data.ventas, + vertical: (data.ventas / ventas) * 100, + anterior: data.anterior.ventas, + variacion: data.anterior.ventas ? ((data.ventas - data.anterior.ventas) / data.anterior.ventas) * 100 : 0, + isTotal: false, + categoria: 'ventas', + }, + { + concepto: 'Devoluciones y cancelaciones', + monto: -data.devoluciones, + vertical: -(data.devoluciones / ventas) * 100, + anterior: -data.anterior.devoluciones, + variacion: data.anterior.devoluciones + ? ((-data.devoluciones + data.anterior.devoluciones) / data.anterior.devoluciones) * 100 + : 0, + isTotal: false, + categoria: 'devoluciones', + }, + { + concepto: 'Ventas netas', + monto: data.ventasNetas, + vertical: (data.ventasNetas / ventas) * 100, + anterior: data.anterior.ventasNetas, + variacion: data.anterior.ventasNetas + ? ((data.ventasNetas - data.anterior.ventasNetas) / data.anterior.ventasNetas) * 100 + : 0, + isTotal: true, + }, + { + concepto: 'Costo de ventas', + monto: -data.costoVentas, + vertical: -(data.costoVentas / ventas) * 100, + anterior: -data.anterior.costoVentas, + variacion: data.anterior.costoVentas + ? ((-data.costoVentas + data.anterior.costoVentas) / data.anterior.costoVentas) * 100 + : 0, + isTotal: false, + categoria: 'costo-ventas', + }, + { + concepto: 'Utilidad bruta', + monto: data.utilidadBruta, + vertical: (data.utilidadBruta / ventas) * 100, + anterior: data.anterior.utilidadBruta, + variacion: data.anterior.utilidadBruta + ? ((data.utilidadBruta - data.anterior.utilidadBruta) / data.anterior.utilidadBruta) * 100 + : 0, + isTotal: true, + }, + { + concepto: 'Gastos operativos', + monto: -data.gastosOperativos, + vertical: -(data.gastosOperativos / ventas) * 100, + anterior: -data.anterior.gastosOperativos, + variacion: data.anterior.gastosOperativos + ? ((-data.gastosOperativos + data.anterior.gastosOperativos) / data.anterior.gastosOperativos) * 100 + : 0, + isTotal: false, + categoria: 'gastos-operativos', + }, + { + concepto: 'Utilidad de la operación', + monto: data.utilidadOperacion, + vertical: (data.utilidadOperacion / ventas) * 100, + anterior: data.anterior.utilidadOperacion, + variacion: data.anterior.utilidadOperacion + ? ((data.utilidadOperacion - data.anterior.utilidadOperacion) / data.anterior.utilidadOperacion) * 100 + : 0, + isTotal: true, + }, + ]; + + const handleExport = () => { + const url = getExportEstadoResultadosUrl(fechaInicio, fechaFin, regimen || undefined); + window.open(url, '_blank'); + }; + + return ( + <> + + + Estado de Resultados + + + +
+ + + + + + + + + + + + + {filas.map((fila) => ( + fila.categoria && setDrillDownCategoria(fila.categoria)} + > + + + + + + + + ))} + +
ConceptoMonto% VerticalAño AnteriorVar. %
{fila.concepto}= 0 ? 'text-success' : 'text-destructive' + }`} + > + {formatCurrency(fila.monto)} + + {fila.vertical.toFixed(1)}% + + {formatCurrency(fila.anterior)} + + = 0 ? 'text-success' : 'text-destructive'}> + {fila.variacion >= 0 ? '+' : ''} + {fila.variacion.toFixed(1)}% + + + {fila.categoria && ( + + )} +
+
+
+
+ + {drillDownCategoria && ( + setDrillDownCategoria(null)} + /> + )} + + ); +} diff --git a/apps/web/app/(dashboard)/reportes/page.tsx b/apps/web/app/(dashboard)/reportes/page.tsx index 664177b..eddff40 100644 --- a/apps/web/app/(dashboard)/reportes/page.tsx +++ b/apps/web/app/(dashboard)/reportes/page.tsx @@ -4,11 +4,12 @@ import { useState } from 'react'; import { DashboardShell } from '@/components/layouts/dashboard-shell'; import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@horux/shared-ui'; import { PeriodSelector, RegimenSelector, KpiCard } from '@horux/shared-ui'; -import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc, useCuentasXPagar, useCuentasXCobrar } from '@/lib/hooks/use-reportes'; +import { useEstadoResultadosDetallado, useFlujoEfectivo, useComparativo, useConcentradoRfc, useCuentasXPagar, useCuentasXCobrar } from '@/lib/hooks/use-reportes'; +import { EstadoResultadosTable } from './components/estado-resultados-table'; import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard'; import { BarChart } from '@/components/charts/bar-chart'; import { formatCurrency } from '@/lib/utils'; -import { FileText, TrendingUp, TrendingDown, Users, CreditCard, Banknote } from 'lucide-react'; +import { FileText, Users, CreditCard, Banknote } from 'lucide-react'; import { FiscalDisclaimer } from '@/components/fiscal-disclaimer'; export default function ReportesPage() { @@ -21,7 +22,7 @@ export default function ReportesPage() { const año = new Date(fechaInicio + 'T00:00:00').getFullYear(); const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin); - const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin); + const { data: estadoResultadosDetallado, isLoading: loadingER, error: errorER } = useEstadoResultadosDetallado(fechaInicio, fechaFin, regimenSeleccionado || undefined); const regimenesDisponibles = regimenesPeriodo || []; if (regimenSeleccionado && regimenesDisponibles.length > 0 && @@ -70,85 +71,65 @@ export default function ReportesPage() {
Cargando...
) : errorER ? (
Error: {(errorER as Error).message}
- ) : !estadoResultados ? ( + ) : !estadoResultadosDetallado ? (
No hay datos disponibles para el período seleccionado
) : ( <> -
- - - Total Ingresos - - - -
- {formatCurrency(estadoResultados.totalIngresos)} -
-
-
- - - Total Egresos - - - -
- {formatCurrency(estadoResultados.totalEgresos)} -
-
-
- - - Utilidad Bruta - - - -
= 0 ? 'text-success' : 'text-destructive'}`}> - {formatCurrency(estadoResultados.utilidadBruta)} -
-
-
- - - Utilidad Neta - - - -
= 0 ? 'text-success' : 'text-destructive'}`}> - {formatCurrency(estadoResultados.utilidadNeta)} -
-
-
-
+
- Top 10 Ingresos por Cliente + + + Top 10 Clientes +
- {estadoResultados.ingresos.map((item, i) => ( -
- {item.concepto} - {formatCurrency(item.monto)} -
- ))} + {clientes && clientes.length > 0 ? ( + clientes.slice(0, 10).map((c, i) => ( +
+
+
{c.nombre}
+
{c.rfc} - {c.cantidadCfdis} CFDIs
+
+ {formatCurrency(c.totalFacturado)} +
+ )) + ) : ( +
Sin clientes
+ )}
- Top 10 Egresos por Proveedor + + + Top 10 Proveedores +
- {estadoResultados.egresos.map((item, i) => ( -
- {item.concepto} - {formatCurrency(item.monto)} -
- ))} + {proveedores && proveedores.length > 0 ? ( + proveedores.slice(0, 10).map((p, i) => ( +
+
+
{p.nombre}
+
{p.rfc} - {p.cantidadCfdis} CFDIs
+
+ {formatCurrency(p.totalFacturado)} +
+ )) + ) : ( +
Sin proveedores
+ )}
diff --git a/apps/web/lib/api/reportes.ts b/apps/web/lib/api/reportes.ts index d9d40da..48b026d 100644 --- a/apps/web/lib/api/reportes.ts +++ b/apps/web/lib/api/reportes.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; +import type { EstadoResultados, EstadoResultadosDetallado, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string, contribuyenteId?: string): Promise { const params = new URLSearchParams(); @@ -10,6 +10,71 @@ export async function getEstadoResultados(fechaInicio?: string, fechaFin?: strin return response.data; } +export async function getEstadoResultadosDetallado( + fechaInicio?: string, + fechaFin?: string, + regimen?: string, + contribuyenteId?: string, +): Promise { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + if (regimen) params.set('regimen', regimen); + if (contribuyenteId) params.set('contribuyenteId', contribuyenteId); + const response = await apiClient.get(`/reportes/estado-resultados-detallado?${params}`); + return response.data; +} + +export interface DrillDownResumenItem { + rfc: string; + nombre: string; + cantidad: number; + monto: number; +} + +export interface DrillDownCfdiItem { + id: number; + uuid: string; + tipoComprobante: string; + fechaEmision: string; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + monto: number; + metodoPago: string | null; + regimenFiscalEmisor: string | null; + regimenFiscalReceptor: string | null; +} + +export async function getEstadoResultadosDrillDown( + categoria: string, + fechaInicio?: string, + fechaFin?: string, + regimen?: string, + rfc?: string, + contribuyenteId?: string, +): Promise { + const params = new URLSearchParams(); + params.set('categoria', categoria); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + if (regimen) params.set('regimen', regimen); + if (rfc) params.set('rfc', rfc); + if (contribuyenteId) params.set('contribuyenteId', contribuyenteId); + const response = await apiClient.get(`/reportes/estado-resultados/drill-down?${params}`); + return response.data; +} + +export function getExportEstadoResultadosUrl(fechaInicio?: string, fechaFin?: string, regimen?: string, contribuyenteId?: string): string { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + if (regimen) params.set('regimen', regimen); + if (contribuyenteId) params.set('contribuyenteId', contribuyenteId); + return `/reportes/estado-resultados/export?${params}`; +} + export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string, contribuyenteId?: string): Promise { const params = new URLSearchParams(); if (fechaInicio) params.set('fechaInicio', fechaInicio); diff --git a/apps/web/lib/hooks/use-reportes.ts b/apps/web/lib/hooks/use-reportes.ts index 2098b3c..4d102f5 100644 --- a/apps/web/lib/hooks/use-reportes.ts +++ b/apps/web/lib/hooks/use-reportes.ts @@ -57,3 +57,28 @@ export function useCuentasXCobrar(fechaInicio: string, fechaFin: string, regimen enabled: !!fechaInicio && !!fechaFin, }); } + +export function useEstadoResultadosDetallado(fechaInicio?: string, fechaFin?: string, regimen?: string) { + const { selectedContribuyenteId } = useContribuyenteStore(); + + return useQuery({ + queryKey: ['estado-resultados-detallado', fechaInicio, fechaFin, regimen, selectedContribuyenteId], + queryFn: () => reportesApi.getEstadoResultadosDetallado(fechaInicio, fechaFin, regimen || undefined, selectedContribuyenteId || undefined), + }); +} + +export function useEstadoResultadosDrillDown( + categoria: string, + fechaInicio?: string, + fechaFin?: string, + regimen?: string, + rfc?: string, +) { + const { selectedContribuyenteId } = useContribuyenteStore(); + + return useQuery({ + queryKey: ['estado-resultados-drill-down', categoria, fechaInicio, fechaFin, regimen, rfc, selectedContribuyenteId], + queryFn: () => reportesApi.getEstadoResultadosDrillDown(categoria, fechaInicio, fechaFin, regimen || undefined, rfc || undefined, selectedContribuyenteId || undefined), + enabled: !!categoria && !!fechaInicio && !!fechaFin, + }); +} diff --git a/docs/CAMBIOS-2026-05-09.md b/docs/CAMBIOS-2026-05-09.md index faba64c..44eeb54 100644 --- a/docs/CAMBIOS-2026-05-09.md +++ b/docs/CAMBIOS-2026-05-09.md @@ -337,3 +337,56 @@ El usuario esperaba verlo en abril porque el pago ocurrió en abril, pero el sis - El endpoint `POST /emitir-factura-pago/:paymentId` requiere rol `platform_admin` - La regla "primer pago no se factura automáticamente" sigue vigente; los subsecuentes sí son automáticos - Los CFDIs importados por SAT sync ahora se asocian correctamente al `contribuyente_id` correspondiente + +--- + +## 12. Rediseño del Estado de Resultados en `/reportes` + +**Fecha:** 4 de mayo de 2026 + +### Problema +El tab "Estado de Resultados" mostraba solo 4 KPI cards y dos listas con títulos engañosos (decían "Cliente/Proveedor" pero mostraban regímenes fiscales). No había análisis horizontal, vertical, ni drill-down. + +### Solución +Se reemplazó por un estado de resultados vertical contable con 7 líneas, análisis comparativo vs año anterior, análisis vertical (% de ventas), drill-down por RFC → CFDI, exportación a Excel y filtro por régimen fiscal. + +### Backend (`apps/api/`) + +**Nuevos archivos/modificaciones:** + +| Archivo | Cambio | +|---|---| +| `src/services/reportes.service.ts` | **Nuevas funciones:** `getEstadoResultadosDetallado`, `getEstadoResultadosDrillDown`, `exportEstadoResultadosToExcel` | +| `src/controllers/reportes.controller.ts` | **3 nuevos handlers:** `getEstadoResultadosDetallado`, `getEstadoResultadosDrillDown`, `exportEstadoResultados` | +| `src/routes/reportes.routes.ts` | Registradas 3 rutas nuevas | +| `packages/shared/src/types/reportes.ts` | **Nuevo tipo:** `EstadoResultadosDetallado` | + +**Endpoints nuevos:** +- `GET /reportes/estado-resultados-detallado` — Tabla vertical con año anterior +- `GET /reportes/estado-resultados/drill-down?categoria=X&rfc=Y` — Resumen por RFC o CFDIs individuales +- `GET /reportes/estado-resultados/export` — Descarga Excel con formato condicional + +**Lógica de cálculo:** +| Línea | Fórmula | Filtros | +|---|---|---| +| Ventas | `subtotal_mxn - descuento_mxn` | Emitidas tipo I, PUE/PPD, vigentes, excluyendo anticipos (`uso_cfdi != 'P01'` ni concepto `84111506`) | +| Devoluciones | `subtotal_mxn - descuento_mxn` | Emitidas tipo E, relación `01` o `03`, vigentes | +| Costo de ventas | `subtotal_mxn - descuento_mxn` | Recibidas tipo I, PUE/PPD, `uso_cfdi = 'G01'`, vigentes | +| Gastos operativos | `subtotal_mxn - descuento_mxn` (recibidos) + `total_mxn` (nómina) | Recibidas tipo I excluyendo G01 + Emitidas tipo N, vigentes | +| Totales | Calculados | Ventas netas, Utilidad bruta, Utilidad de la operación | + +### Frontend (`apps/web/`) + +| Archivo | Cambio | +|---|---| +| `app/(dashboard)/reportes/components/estado-resultados-table.tsx` | **Nuevo** — Tabla vertical con concepto, monto, % vertical, año anterior, variación % | +| `app/(dashboard)/reportes/components/drill-down-modal.tsx` | **Nuevo** — Modal de dos niveles: RFC resumen → CFDIs individuales | +| `lib/api/reportes.ts` | Agregados wrappers para los 3 endpoints nuevos | +| `lib/hooks/use-reportes.ts` | Agregados `useEstadoResultadosDetallado` y `useEstadoResultadosDrillDown` | +| `app/(dashboard)/reportes/page.tsx` | Integrada tabla nueva; conectado `RegimenSelector` al reporte; mantenidos Top 10 Clientes/Proveedores debajo | + +### Fix posterior: Total NaN en drill-down nivel 2 + +**Causa:** PostgreSQL devolvía `numeric` como string en el driver `pg`. Al sumar strings en el `reduce` del frontend, JavaScript concatenaba en lugar de sumar, generando `NaN` al formatear. + +**Fix:** Se agregó `::float` en las 5 queries SQL de CFDIs individuales del drill-down, forzando que el backend devuelva números reales. diff --git a/packages/shared/src/types/reportes.ts b/packages/shared/src/types/reportes.ts index f451563..25c8e66 100644 --- a/packages/shared/src/types/reportes.ts +++ b/packages/shared/src/types/reportes.ts @@ -39,6 +39,26 @@ export interface ConcentradoRfc { cantidadCfdis: number; } +export interface EstadoResultadosDetallado { + periodo: { inicio: string; fin: string }; + ventas: number; + devoluciones: number; + ventasNetas: number; + costoVentas: number; + utilidadBruta: number; + gastosOperativos: number; + utilidadOperacion: number; + anterior: { + ventas: number; + devoluciones: number; + ventasNetas: number; + costoVentas: number; + utilidadBruta: number; + gastosOperativos: number; + utilidadOperacion: number; + }; +} + export interface ReporteFilters { fechaInicio: string; fechaFin: string;