Refactor: Mejora la lógica de facturación y la UI

Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.

Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
  agrupar las suscripciones por cliente y empresa, generando una
  factura consolidada para cada combinación. Esto asegura la correcta
  separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
  de un período (ej. Septiembre) aplique únicamente los ajustes
  pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
  `GenerarFacturacionMensual` que impide generar la facturación de un
  período si el anterior no ha sido cerrado, garantizando el orden
  cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
  generar el cierre ahora envía un único email consolidado por
  suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
  para que opere sobre una `idFactura` individual y adjunte el PDF
  correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
  `FacturaRepository` y `AjusteRepository` para soportar los nuevos
  requisitos de filtrado y consulta de datos consolidados.

Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
  - `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
    débito, procesar respuesta).
  - `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
    filtrar y gestionar facturas individuales con una interfaz de doble
    acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
  filtros por nombre de suscriptor, estado de pago y estado de
  facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
  ahora filtra por el mes actual por defecto para mejorar el rendimiento
  y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
  impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
  registrar un monto superior al saldo pendiente de la factura.
This commit is contained in:
2025-08-08 09:48:15 -03:00
parent 9cfe9d012e
commit 899e0a173f
87 changed files with 2947 additions and 1231 deletions

View File

@@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens
return response.data;
};
const getReporteFacturasPublicidadPdf = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => {
const params = new URLSearchParams({ anio: String(anio), mes: String(mes) });
const url = `/reportes/suscripciones/facturas-para-publicidad/pdf?${params.toString()}`;
const response = await apiClient.get(url, {
responseType: 'blob',
});
const contentDisposition = response.headers['content-disposition'];
let fileName = `ReportePublicidad_Suscripciones_${anio}-${String(mes).padStart(2, '0')}.pdf`; // Fallback
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch && fileNameMatch.length > 1) {
fileName = fileNameMatch[1];
}
}
return { fileContent: response.data, fileName: fileName };
};
const reportesService = {
getExistenciaPapel,
getExistenciaPapelPdf,
@@ -487,7 +506,8 @@ const reportesService = {
getListadoDistMensualDiarios,
getListadoDistMensualDiariosPdf,
getListadoDistMensualPorPublicacion,
getListadoDistMensualPorPublicacionPdf,
getListadoDistMensualPorPublicacionPdf,
getReporteFacturasPublicidadPdf,
};
export default reportesService;

View File

@@ -1,12 +1,26 @@
import apiClient from '../apiClient';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto';
const API_URL_BY_SUSCRIPTOR = '/suscriptores';
const API_URL_BASE = '/ajustes';
const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise<AjusteDto[]> => {
const response = await apiClient.get<AjusteDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`);
const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise<AjusteDto[]> => {
// URLSearchParams nos ayuda a construir la query string de forma segura y limpia
const params = new URLSearchParams();
if (fechaDesde) {
params.append('fechaDesde', fechaDesde);
}
if (fechaHasta) {
params.append('fechaHasta', fechaHasta);
}
// Si hay parámetros, los añadimos a la URL. Si no, la URL queda limpia.
const queryString = params.toString();
const url = `${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<AjusteDto[]>(url);
return response.data;
};
@@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`);
};
const updateAjuste = async (idAjuste: number, data: UpdateAjusteDto): Promise<void> => {
await apiClient.put(`${API_URL_BASE}/${idAjuste}`, data);
};
export default {
getAjustesPorSuscriptor,
createAjusteManual,
anularAjuste
anularAjuste,
updateAjuste,
};

View File

@@ -1,29 +1,33 @@
import apiClient from '../apiClient';
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto';
import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
const API_URL = '/facturacion';
const DEBITOS_URL = '/debitos';
const PAGOS_URL = '/pagos';
const FACTURAS_URL = '/facturas';
const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => {
const formData = new FormData();
formData.append('archivo', archivo);
const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => {
const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`);
const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => {
const params = new URLSearchParams();
if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor);
if (estadoPago) params.append('estadoPago', estadoPago);
if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion);
const queryString = params.toString();
const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<ResumenCuentaSuscriptorDto[]>(url);
return response.data;
};
@@ -36,7 +40,6 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, {
responseType: 'blob',
});
const contentDisposition = response.headers['content-disposition'];
let fileName = `debito_${anio}_${mes}.txt`;
if (contentDisposition) {
@@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
fileName = fileNameMatch[1];
}
}
return { fileContent: response.data, fileName: fileName };
};
const getPagosPorFactura = async (idFactura: number): Promise<PagoDto[]> => {
const response = await apiClient.get<PagoDto[]>(`${FACTURAS_URL}/${idFactura}/pagos`);
return response.data;
};
const registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => {
const response = await apiClient.post<PagoDto>(PAGOS_URL, data);
return response.data;
};
const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => {
await apiClient.post(`${API_URL}/${idFactura}/enviar-email`);
const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise<void> => {
await apiClient.put(`${API_URL}/${idFactura}/numero-factura`, `"${numeroFactura}"`, {
headers: { 'Content-Type': 'application/json' }
});
};
const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor: number): Promise<void> => {
await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`);
};
const enviarFacturaPdfPorEmail = async (idFactura: number): Promise<void> => {
await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`);
};
export default {
procesarArchivoRespuesta,
getFacturasPorPeriodo,
getResumenesDeCuentaPorPeriodo,
generarFacturacionMensual,
generarArchivoDebito,
getPagosPorFactura,
registrarPagoManual,
enviarFacturaPorEmail,
actualizarNumeroFactura,
enviarAvisoCuentaPorEmail,
enviarFacturaPdfPorEmail,
};

View File

@@ -3,12 +3,15 @@ import type { SuscripcionDto } from '../../models/dtos/Suscripciones/Suscripcion
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto';
import type { PromocionAsignadaDto } from '../../models/dtos/Suscripciones/PromocionAsignadaDto';
import type { AsignarPromocionDto } from '../../models/dtos/Suscripciones/AsignarPromocionDto';
const API_URL_BASE = '/suscripciones';
const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada
const API_URL_SUSCRIPTORES = '/suscriptores';
const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise<SuscripcionDto[]> => {
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`);
// La URL correcta es /suscriptores/{id}/suscripciones, no /suscripciones/suscriptor/...
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_SUSCRIPTORES}/${idSuscriptor}/suscripciones`);
return response.data;
};
@@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis
await apiClient.put(`${API_URL_BASE}/${id}`, data);
};
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => {
const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionAsignadaDto[]> => {
const response = await apiClient.get<PromocionAsignadaDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
return response.data;
};
@@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise<Promoci
return response.data;
};
const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`);
const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data);
};
const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
@@ -52,5 +55,5 @@ export default {
getPromocionesAsignadas,
getPromocionesDisponibles,
asignarPromocion,
quitarPromocion
quitarPromocion,
};