feat(reportes): rediseño Estado de Resultados vertical con drill-down, análisis horizontal/vertical y export Excel
- 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
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import * as reportesService from '../services/reportes.service.js';
|
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) {
|
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
@@ -83,3 +84,73 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF
|
|||||||
next(error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ router.use(checkPlanLimits);
|
|||||||
router.use(requireFeature('reportes'));
|
router.use(requireFeature('reportes'));
|
||||||
|
|
||||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
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('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||||
router.get('/comparativo', reportesController.getComparativo);
|
router.get('/comparativo', reportesController.getComparativo);
|
||||||
router.get('/concentrado-rfc', reportesController.getConcentradoRfc);
|
router.get('/concentrado-rfc', reportesController.getConcentradoRfc);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Pool } from 'pg';
|
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 { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from './dashboard.service.js';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resuelve condiciones `esEmisor` / `esReceptor` para un contribuyente
|
* 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<EstadoResultadosDetallado> {
|
||||||
|
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<DrillDownResumenItem[] | DrillDownCfdiItem[]> {
|
||||||
|
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<string, DrillDownResumenItem>();
|
||||||
|
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<Buffer> {
|
||||||
|
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(
|
export async function getCuentasXCobrar(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string | null>(null);
|
||||||
|
const [selectedNombre, setSelectedNombre] = useState<string>('');
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedRfc && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedRfc(null)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedRfc ? `${titulo} — ${selectedNombre}` : titulo}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No hay datos para esta categoría
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{resumen.length > 0
|
||||||
|
? `${resumen.length} RFCs encontrados`
|
||||||
|
: `${cfdis.length} CFDIs encontrados`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span>
|
||||||
|
Total: <strong>{formatCurrency(totalMonto)}</strong>
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Excel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
{resumen.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<th className="pb-3 font-medium">RFC</th>
|
||||||
|
<th className="pb-3 font-medium">Nombre</th>
|
||||||
|
<th className="pb-3 font-medium text-right">CFDIs</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Monto</th>
|
||||||
|
<th className="pb-3 w-8"></th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th className="pb-3 font-medium">UUID</th>
|
||||||
|
<th className="pb-3 font-medium">Comp.</th>
|
||||||
|
<th className="pb-3 font-medium">Fecha</th>
|
||||||
|
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||||
|
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||||
|
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||||
|
<th className="pb-3 font-medium">Nombre Receptor</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Monto</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resumen.length > 0
|
||||||
|
? resumen.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.rfc}
|
||||||
|
className="border-b hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRfc(item.rfc);
|
||||||
|
setSelectedNombre(item.nombre);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-2 font-mono text-xs">{item.rfc}</td>
|
||||||
|
<td className="py-2 text-xs truncate max-w-[200px]">{item.nombre}</td>
|
||||||
|
<td className="py-2 text-right text-xs">{item.cantidad}</td>
|
||||||
|
<td className="py-2 text-right text-xs font-medium">
|
||||||
|
{formatCurrency(item.monto)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: cfdis.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b hover:bg-muted/50">
|
||||||
|
<td className="py-2 font-mono text-xs" title={item.uuid}>
|
||||||
|
{item.uuid?.substring(0, 8)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs font-mono">{item.tipoComprobante}</td>
|
||||||
|
<td className="py-2 text-xs">
|
||||||
|
{new Date(item.fechaEmision).toLocaleDateString('es-MX')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 font-mono text-xs">{item.rfcEmisor}</td>
|
||||||
|
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||||
|
{item.nombreEmisor}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 font-mono text-xs">{item.rfcReceptor}</td>
|
||||||
|
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||||
|
{item.nombreReceptor}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-xs font-medium">
|
||||||
|
{formatCurrency(item.monto)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Estado de Resultados</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Exportar Excel
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="pb-3 font-medium">Concepto</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Monto</th>
|
||||||
|
<th className="pb-3 font-medium text-right">% Vertical</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Año Anterior</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Var. %</th>
|
||||||
|
<th className="pb-3 w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.map((fila) => (
|
||||||
|
<tr
|
||||||
|
key={fila.concepto}
|
||||||
|
className={`border-b ${
|
||||||
|
fila.isTotal ? 'font-bold bg-muted/30' : 'hover:bg-muted/50'
|
||||||
|
} ${fila.categoria ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => fila.categoria && setDrillDownCategoria(fila.categoria)}
|
||||||
|
>
|
||||||
|
<td className="py-3">{fila.concepto}</td>
|
||||||
|
<td
|
||||||
|
className={`py-3 text-right ${
|
||||||
|
fila.monto >= 0 ? 'text-success' : 'text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(fila.monto)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right text-muted-foreground">
|
||||||
|
{fila.vertical.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right text-muted-foreground">
|
||||||
|
{formatCurrency(fila.anterior)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">
|
||||||
|
<span className={fila.variacion >= 0 ? 'text-success' : 'text-destructive'}>
|
||||||
|
{fila.variacion >= 0 ? '+' : ''}
|
||||||
|
{fila.variacion.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">
|
||||||
|
{fila.categoria && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{drillDownCategoria && (
|
||||||
|
<EstadoResultadosDrillDownModal
|
||||||
|
categoria={drillDownCategoria}
|
||||||
|
fechaInicio={fechaInicio}
|
||||||
|
fechaFin={fechaFin}
|
||||||
|
regimen={regimen}
|
||||||
|
onClose={() => setDrillDownCategoria(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { useState } from 'react';
|
|||||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@horux/shared-ui';
|
import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@horux/shared-ui';
|
||||||
import { PeriodSelector, RegimenSelector, KpiCard } 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 { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||||
import { BarChart } from '@/components/charts/bar-chart';
|
import { BarChart } from '@/components/charts/bar-chart';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
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';
|
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
||||||
|
|
||||||
export default function ReportesPage() {
|
export default function ReportesPage() {
|
||||||
@@ -21,7 +22,7 @@ export default function ReportesPage() {
|
|||||||
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||||
|
|
||||||
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
|
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 || [];
|
const regimenesDisponibles = regimenesPeriodo || [];
|
||||||
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
|
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
|
||||||
@@ -70,85 +71,65 @@ export default function ReportesPage() {
|
|||||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
) : errorER ? (
|
) : errorER ? (
|
||||||
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
|
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
|
||||||
) : !estadoResultados ? (
|
) : !estadoResultadosDetallado ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
|
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<EstadoResultadosTable
|
||||||
<Card>
|
data={estadoResultadosDetallado}
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
fechaInicio={fechaInicio}
|
||||||
<CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
|
fechaFin={fechaFin}
|
||||||
<TrendingUp className="h-4 w-4 text-success" />
|
regimen={regimenSeleccionado}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-success">
|
|
||||||
{formatCurrency(estadoResultados.totalIngresos)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
|
|
||||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-destructive">
|
|
||||||
{formatCurrency(estadoResultados.totalEgresos)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
|
|
||||||
{formatCurrency(estadoResultados.utilidadBruta)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
|
|
||||||
{formatCurrency(estadoResultados.utilidadNeta)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 10 Ingresos por Cliente</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Top 10 Clientes
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{estadoResultados.ingresos.map((item, i) => (
|
{clientes && clientes.length > 0 ? (
|
||||||
|
clientes.slice(0, 10).map((c, i) => (
|
||||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
|
<div>
|
||||||
<span className="font-medium">{formatCurrency(item.monto)}</span>
|
<div className="font-medium text-sm">{c.nombre}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 10 Egresos por Proveedor</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Top 10 Proveedores
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{estadoResultados.egresos.map((item, i) => (
|
{proveedores && proveedores.length > 0 ? (
|
||||||
|
proveedores.slice(0, 10).map((p, i) => (
|
||||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
|
<div>
|
||||||
<span className="font-medium">{formatCurrency(item.monto)}</span>
|
<div className="font-medium text-sm">{p.nombre}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from './client';
|
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<EstadoResultados> {
|
export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string, contribuyenteId?: string): Promise<EstadoResultados> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -10,6 +10,71 @@ export async function getEstadoResultados(fechaInicio?: string, fechaFin?: strin
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEstadoResultadosDetallado(
|
||||||
|
fechaInicio?: string,
|
||||||
|
fechaFin?: string,
|
||||||
|
regimen?: string,
|
||||||
|
contribuyenteId?: string,
|
||||||
|
): Promise<EstadoResultadosDetallado> {
|
||||||
|
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<EstadoResultadosDetallado>(`/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<DrillDownResumenItem[] | DrillDownCfdiItem[]> {
|
||||||
|
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<DrillDownResumenItem[] | DrillDownCfdiItem[]>(`/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<FlujoEfectivo> {
|
export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string, contribuyenteId?: string): Promise<FlujoEfectivo> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (fechaInicio) params.set('fechaInicio', fechaInicio);
|
if (fechaInicio) params.set('fechaInicio', fechaInicio);
|
||||||
|
|||||||
@@ -57,3 +57,28 @@ export function useCuentasXCobrar(fechaInicio: string, fechaFin: string, regimen
|
|||||||
enabled: !!fechaInicio && !!fechaFin,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`
|
- 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
|
- 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
|
- 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.
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ export interface ConcentradoRfc {
|
|||||||
cantidadCfdis: number;
|
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 {
|
export interface ReporteFilters {
|
||||||
fechaInicio: string;
|
fechaInicio: string;
|
||||||
fechaFin: string;
|
fechaFin: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user