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 * 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
|
||||
Reference in New Issue
Block a user