[MCH-BE] feat: Add export endpoints for PDF/Excel reports

Implement export functionality for four report types:
- Sales: Daily/period sales with totals and items
- Inventory: Current stock with value calculations
- Fiados: Customer credit accounts and balances
- Movements: Inventory entries/exits with references

Features:
- PDF exports using pdfkit with formatted tables and summaries
- Excel exports using exceljs with styling and formulas
- Filter support: date ranges, status, category, etc.
- Proper content-type headers for file downloads
- JWT authentication on all endpoints

Endpoints:
GET /api/v1/exports/sales/:format
GET /api/v1/exports/inventory/:format
GET /api/v1/exports/fiados/:format
GET /api/v1/exports/movements/:format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 02:30:11 -06:00
parent c936f447cf
commit b3eaebb54c
7 changed files with 2127 additions and 4 deletions

1006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,13 +33,16 @@
"@nestjs/swagger": "^11.2.5",
"@nestjs/typeorm": "^11.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@types/pdfkit": "^0.17.4",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"exceljs": "^4.4.0",
"helmet": "^8.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",

View File

@ -75,6 +75,7 @@ import { ExportsModule } from './modules/exports/exports.module';
TemplatesModule,
OnboardingModule,
SettingsModule,
ExportsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,60 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
export enum ExportFormat {
PDF = 'pdf',
XLSX = 'xlsx',
}
export class ExportFilterDto {
@ApiPropertyOptional({ description: 'Fecha de inicio (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ description: 'Fecha de fin (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
endDate?: string;
}
export class SalesExportFilterDto extends ExportFilterDto {
@ApiPropertyOptional({ description: 'Estado de la venta' })
@IsOptional()
@IsString()
status?: string;
}
export class InventoryExportFilterDto {
@ApiPropertyOptional({ description: 'ID de categoria' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional({ description: 'Solo productos con stock bajo' })
@IsOptional()
lowStock?: boolean;
}
export class FiadosExportFilterDto extends ExportFilterDto {
@ApiPropertyOptional({ description: 'Estado del fiado (pending, partial, paid)' })
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ description: 'Solo fiados vencidos' })
@IsOptional()
overdue?: boolean;
}
export class MovementsExportFilterDto extends ExportFilterDto {
@ApiPropertyOptional({ description: 'Tipo de movimiento (purchase, sale, adjustment, loss, return, transfer)' })
@IsOptional()
@IsString()
movementType?: string;
@ApiPropertyOptional({ description: 'ID del producto' })
@IsOptional()
@IsString()
productId?: string;
}

View File

@ -0,0 +1,184 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
Request,
Res,
BadRequestException,
} from '@nestjs/common';
import { Response } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { ExportsService } from './exports.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
ExportFormat,
SalesExportFilterDto,
InventoryExportFilterDto,
FiadosExportFilterDto,
MovementsExportFilterDto,
} from './dto/export-filter.dto';
@ApiTags('exports')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/exports')
export class ExportsController {
constructor(private readonly exportsService: ExportsService) {}
@Get('sales/:format')
@ApiOperation({ summary: 'Exportar reporte de ventas' })
@ApiParam({ name: 'format', enum: ExportFormat, description: 'Formato de exportación (pdf o xlsx)' })
@ApiQuery({ name: 'startDate', required: false, description: 'Fecha inicio (YYYY-MM-DD)' })
@ApiQuery({ name: 'endDate', required: false, description: 'Fecha fin (YYYY-MM-DD)' })
@ApiQuery({ name: 'status', required: false, description: 'Estado de la venta' })
@ApiResponse({ status: 200, description: 'Archivo de exportación' })
@ApiResponse({ status: 400, description: 'Formato no válido' })
async exportSales(
@Request() req: { user: { tenantId: string } },
@Param('format') format: string,
@Query() filters: SalesExportFilterDto,
@Res() res: Response,
): Promise<void> {
this.validateFormat(format);
const tenantId = req.user.tenantId;
const filename = `ventas_${this.getDateFilename()}`;
if (format === ExportFormat.PDF) {
const buffer = await this.exportsService.exportSalesPdf(tenantId, filters);
this.sendPdfResponse(res, buffer, filename);
} else {
const buffer = await this.exportsService.exportSalesExcel(tenantId, filters);
this.sendExcelResponse(res, buffer, filename);
}
}
@Get('inventory/:format')
@ApiOperation({ summary: 'Exportar reporte de inventario' })
@ApiParam({ name: 'format', enum: ExportFormat, description: 'Formato de exportación (pdf o xlsx)' })
@ApiQuery({ name: 'categoryId', required: false, description: 'ID de categoría' })
@ApiQuery({ name: 'lowStock', required: false, type: Boolean, description: 'Solo productos con stock bajo' })
@ApiResponse({ status: 200, description: 'Archivo de exportación' })
@ApiResponse({ status: 400, description: 'Formato no válido' })
async exportInventory(
@Request() req: { user: { tenantId: string } },
@Param('format') format: string,
@Query() filters: InventoryExportFilterDto,
@Res() res: Response,
): Promise<void> {
this.validateFormat(format);
const tenantId = req.user.tenantId;
const filename = `inventario_${this.getDateFilename()}`;
if (format === ExportFormat.PDF) {
const buffer = await this.exportsService.exportInventoryPdf(tenantId, filters);
this.sendPdfResponse(res, buffer, filename);
} else {
const buffer = await this.exportsService.exportInventoryExcel(tenantId, filters);
this.sendExcelResponse(res, buffer, filename);
}
}
@Get('fiados/:format')
@ApiOperation({ summary: 'Exportar reporte de fiados (clientes con crédito)' })
@ApiParam({ name: 'format', enum: ExportFormat, description: 'Formato de exportación (pdf o xlsx)' })
@ApiQuery({ name: 'startDate', required: false, description: 'Fecha inicio (YYYY-MM-DD)' })
@ApiQuery({ name: 'endDate', required: false, description: 'Fecha fin (YYYY-MM-DD)' })
@ApiQuery({ name: 'status', required: false, description: 'Estado del fiado (pending, partial, paid)' })
@ApiQuery({ name: 'overdue', required: false, type: Boolean, description: 'Solo fiados vencidos' })
@ApiResponse({ status: 200, description: 'Archivo de exportación' })
@ApiResponse({ status: 400, description: 'Formato no válido' })
async exportFiados(
@Request() req: { user: { tenantId: string } },
@Param('format') format: string,
@Query() filters: FiadosExportFilterDto,
@Res() res: Response,
): Promise<void> {
this.validateFormat(format);
const tenantId = req.user.tenantId;
const filename = `fiados_${this.getDateFilename()}`;
if (format === ExportFormat.PDF) {
const buffer = await this.exportsService.exportFiadosPdf(tenantId, filters);
this.sendPdfResponse(res, buffer, filename);
} else {
const buffer = await this.exportsService.exportFiadosExcel(tenantId, filters);
this.sendExcelResponse(res, buffer, filename);
}
}
@Get('movements/:format')
@ApiOperation({ summary: 'Exportar reporte de movimientos de inventario' })
@ApiParam({ name: 'format', enum: ExportFormat, description: 'Formato de exportación (pdf o xlsx)' })
@ApiQuery({ name: 'startDate', required: false, description: 'Fecha inicio (YYYY-MM-DD)' })
@ApiQuery({ name: 'endDate', required: false, description: 'Fecha fin (YYYY-MM-DD)' })
@ApiQuery({ name: 'movementType', required: false, description: 'Tipo de movimiento (purchase, sale, adjustment, loss, return, transfer)' })
@ApiQuery({ name: 'productId', required: false, description: 'ID del producto' })
@ApiResponse({ status: 200, description: 'Archivo de exportación' })
@ApiResponse({ status: 400, description: 'Formato no válido' })
async exportMovements(
@Request() req: { user: { tenantId: string } },
@Param('format') format: string,
@Query() filters: MovementsExportFilterDto,
@Res() res: Response,
): Promise<void> {
this.validateFormat(format);
const tenantId = req.user.tenantId;
const filename = `movimientos_${this.getDateFilename()}`;
if (format === ExportFormat.PDF) {
const buffer = await this.exportsService.exportMovementsPdf(tenantId, filters);
this.sendPdfResponse(res, buffer, filename);
} else {
const buffer = await this.exportsService.exportMovementsExcel(tenantId, filters);
this.sendExcelResponse(res, buffer, filename);
}
}
// ============ HELPER METHODS ============
private validateFormat(format: string): void {
if (format !== ExportFormat.PDF && format !== ExportFormat.XLSX) {
throw new BadRequestException(
`Formato no válido: ${format}. Use 'pdf' o 'xlsx'`,
);
}
}
private getDateFilename(): string {
const now = new Date();
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
}
private sendPdfResponse(res: Response, buffer: Buffer, filename: string): void {
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}.pdf"`,
'Content-Length': buffer.length,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
});
res.end(buffer);
}
private sendExcelResponse(res: Response, buffer: Buffer, filename: string): void {
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}.xlsx"`,
'Content-Length': buffer.length,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
});
res.end(buffer);
}
}

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExportsController } from './exports.controller';
import { ExportsService } from './exports.service';
import { Sale } from '../sales/entities/sale.entity';
import { Product } from '../products/entities/product.entity';
import { Fiado } from '../customers/entities/fiado.entity';
import { InventoryMovement } from '../inventory/entities/inventory-movement.entity';
import { Customer } from '../customers/entities/customer.entity';
import { Category } from '../categories/entities/category.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([
Sale,
Product,
Fiado,
InventoryMovement,
Customer,
Category,
]),
AuthModule,
],
controllers: [ExportsController],
providers: [ExportsService],
exports: [ExportsService],
})
export class ExportsModule {}

View File

@ -0,0 +1,848 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual, LessThan } from 'typeorm';
import * as PDFDocument from 'pdfkit';
import * as ExcelJS from 'exceljs';
import { Sale, SaleStatus } from '../sales/entities/sale.entity';
import { Product } from '../products/entities/product.entity';
import { Fiado, FiadoStatus } from '../customers/entities/fiado.entity';
import { InventoryMovement } from '../inventory/entities/inventory-movement.entity';
import { Customer } from '../customers/entities/customer.entity';
import { Category } from '../categories/entities/category.entity';
import {
SalesExportFilterDto,
InventoryExportFilterDto,
FiadosExportFilterDto,
MovementsExportFilterDto,
} from './dto/export-filter.dto';
@Injectable()
export class ExportsService {
constructor(
@InjectRepository(Sale)
private readonly saleRepository: Repository<Sale>,
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
@InjectRepository(Fiado)
private readonly fiadoRepository: Repository<Fiado>,
@InjectRepository(InventoryMovement)
private readonly movementRepository: Repository<InventoryMovement>,
@InjectRepository(Customer)
private readonly customerRepository: Repository<Customer>,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) {}
// ============ SALES EXPORTS ============
async getSalesData(tenantId: string, filters: SalesExportFilterDto): Promise<Sale[]> {
const query = this.saleRepository
.createQueryBuilder('sale')
.leftJoinAndSelect('sale.items', 'items')
.leftJoinAndSelect('sale.paymentMethod', 'paymentMethod')
.where('sale.tenantId = :tenantId', { tenantId });
if (filters.startDate && filters.endDate) {
query.andWhere('DATE(sale.createdAt) BETWEEN :startDate AND :endDate', {
startDate: filters.startDate,
endDate: filters.endDate,
});
} else if (filters.startDate) {
query.andWhere('DATE(sale.createdAt) >= :startDate', {
startDate: filters.startDate,
});
} else if (filters.endDate) {
query.andWhere('DATE(sale.createdAt) <= :endDate', {
endDate: filters.endDate,
});
}
if (filters.status) {
query.andWhere('sale.status = :status', { status: filters.status });
}
query.orderBy('sale.createdAt', 'DESC');
return query.getMany();
}
async exportSalesPdf(tenantId: string, filters: SalesExportFilterDto): Promise<Buffer> {
const sales = await this.getSalesData(tenantId, filters);
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc.fontSize(20).text('Reporte de Ventas', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).text(this.getDateRangeText(filters.startDate, filters.endDate), { align: 'center' });
doc.moveDown();
// Summary
const totalSales = sales.filter(s => s.status === SaleStatus.COMPLETED).length;
const totalRevenue = sales
.filter(s => s.status === SaleStatus.COMPLETED)
.reduce((sum, s) => sum + Number(s.total), 0);
const cancelledCount = sales.filter(s => s.status === SaleStatus.CANCELLED).length;
doc.fontSize(12).text('Resumen:', { underline: true });
doc.fontSize(10);
doc.text(`Total de ventas: ${totalSales}`);
doc.text(`Ingresos totales: $${totalRevenue.toFixed(2)}`);
doc.text(`Ventas canceladas: ${cancelledCount}`);
doc.moveDown();
// Table header
doc.fontSize(10).text('Detalle de Ventas:', { underline: true });
doc.moveDown(0.5);
const tableTop = doc.y;
const col1 = 50;
const col2 = 130;
const col3 = 230;
const col4 = 330;
const col5 = 430;
doc.font('Helvetica-Bold');
doc.text('Ticket', col1, tableTop);
doc.text('Fecha', col2, tableTop);
doc.text('Cliente', col3, tableTop);
doc.text('Total', col4, tableTop);
doc.text('Estado', col5, tableTop);
doc.font('Helvetica');
let y = tableTop + 20;
doc.moveTo(col1, y - 5).lineTo(530, y - 5).stroke();
for (const sale of sales.slice(0, 50)) { // Limit to 50 for PDF
if (y > 700) {
doc.addPage();
y = 50;
}
doc.text(sale.ticketNumber || '-', col1, y);
doc.text(new Date(sale.createdAt).toLocaleDateString('es-MX'), col2, y);
doc.text(sale.customerName || 'Público General', col3, y, { width: 90 });
doc.text(`$${Number(sale.total).toFixed(2)}`, col4, y);
doc.text(this.getStatusLabel(sale.status), col5, y);
y += 20;
}
if (sales.length > 50) {
doc.moveDown();
doc.fontSize(8).text(`... y ${sales.length - 50} ventas más. Exporte a Excel para ver todos los registros.`, { align: 'center' });
}
// Footer
doc.fontSize(8);
const bottom = doc.page.height - 50;
doc.text(`Generado: ${new Date().toLocaleString('es-MX')}`, 50, bottom, { align: 'center' });
doc.end();
});
}
async exportSalesExcel(tenantId: string, filters: SalesExportFilterDto): Promise<Buffer> {
const sales = await this.getSalesData(tenantId, filters);
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MiChangarrito';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Ventas');
// Header styling
sheet.columns = [
{ header: 'Ticket', key: 'ticket', width: 15 },
{ header: 'Fecha', key: 'date', width: 12 },
{ header: 'Hora', key: 'time', width: 10 },
{ header: 'Cliente', key: 'customer', width: 25 },
{ header: 'Subtotal', key: 'subtotal', width: 12 },
{ header: 'Impuesto', key: 'tax', width: 12 },
{ header: 'Descuento', key: 'discount', width: 12 },
{ header: 'Total', key: 'total', width: 12 },
{ header: 'Método Pago', key: 'paymentMethod', width: 15 },
{ header: 'Estado', key: 'status', width: 12 },
{ header: 'Productos', key: 'items', width: 40 },
];
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' },
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Add data
for (const sale of sales) {
const itemsList = sale.items
?.map(item => `${item.productName} x${item.quantity}`)
.join(', ') || '';
sheet.addRow({
ticket: sale.ticketNumber || '-',
date: new Date(sale.createdAt).toLocaleDateString('es-MX'),
time: new Date(sale.createdAt).toLocaleTimeString('es-MX'),
customer: sale.customerName || 'Público General',
subtotal: Number(sale.subtotal),
tax: Number(sale.taxAmount),
discount: Number(sale.discountAmount),
total: Number(sale.total),
paymentMethod: sale.paymentMethod?.name || 'Efectivo',
status: this.getStatusLabel(sale.status),
items: itemsList,
});
}
// Format currency columns
['E', 'F', 'G', 'H'].forEach(col => {
sheet.getColumn(col).numFmt = '"$"#,##0.00';
});
// Summary section
sheet.addRow([]);
const summaryRow = sheet.addRow(['Resumen']);
summaryRow.font = { bold: true };
const completedSales = sales.filter(s => s.status === SaleStatus.COMPLETED);
sheet.addRow(['Total Ventas Completadas', completedSales.length]);
sheet.addRow(['Ingresos Totales', completedSales.reduce((sum, s) => sum + Number(s.total), 0)]);
sheet.addRow(['Ventas Canceladas', sales.filter(s => s.status === SaleStatus.CANCELLED).length]);
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
// ============ INVENTORY EXPORTS ============
async getInventoryData(tenantId: string, filters: InventoryExportFilterDto): Promise<Product[]> {
const query = this.productRepository
.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category')
.where('product.tenantId = :tenantId', { tenantId })
.andWhere('product.status = :status', { status: 'active' });
if (filters.categoryId) {
query.andWhere('product.categoryId = :categoryId', { categoryId: filters.categoryId });
}
if (filters.lowStock) {
query.andWhere('product.trackInventory = true')
.andWhere('product.stockQuantity <= product.lowStockThreshold');
}
query.orderBy('product.name', 'ASC');
return query.getMany();
}
async exportInventoryPdf(tenantId: string, filters: InventoryExportFilterDto): Promise<Buffer> {
const products = await this.getInventoryData(tenantId, filters);
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc.fontSize(20).text('Reporte de Inventario', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).text(`Generado: ${new Date().toLocaleString('es-MX')}`, { align: 'center' });
if (filters.lowStock) {
doc.text('(Solo productos con stock bajo)', { align: 'center' });
}
doc.moveDown();
// Summary
const totalProducts = products.length;
const totalValue = products.reduce((sum, p) => sum + (Number(p.price) * Number(p.stockQuantity)), 0);
const lowStockCount = products.filter(p =>
p.trackInventory && Number(p.stockQuantity) <= Number(p.lowStockThreshold)
).length;
doc.fontSize(12).text('Resumen:', { underline: true });
doc.fontSize(10);
doc.text(`Total de productos: ${totalProducts}`);
doc.text(`Valor total del inventario: $${totalValue.toFixed(2)}`);
doc.text(`Productos con stock bajo: ${lowStockCount}`);
doc.moveDown();
// Table
doc.fontSize(10).text('Listado de Productos:', { underline: true });
doc.moveDown(0.5);
const tableTop = doc.y;
const col1 = 50;
const col2 = 200;
const col3 = 280;
const col4 = 340;
const col5 = 410;
const col6 = 480;
doc.font('Helvetica-Bold');
doc.text('Producto', col1, tableTop);
doc.text('Categoría', col2, tableTop);
doc.text('SKU', col3, tableTop);
doc.text('Stock', col4, tableTop);
doc.text('Precio', col5, tableTop);
doc.text('Valor', col6, tableTop);
doc.font('Helvetica');
let y = tableTop + 20;
doc.moveTo(col1, y - 5).lineTo(550, y - 5).stroke();
for (const product of products.slice(0, 50)) {
if (y > 700) {
doc.addPage();
y = 50;
}
const stockValue = Number(product.price) * Number(product.stockQuantity);
const isLowStock = product.trackInventory && Number(product.stockQuantity) <= Number(product.lowStockThreshold);
doc.text(product.name.substring(0, 25), col1, y, { width: 145 });
doc.text(product.category?.name || '-', col2, y, { width: 75 });
doc.text(product.sku || '-', col3, y, { width: 55 });
doc.text(`${product.stockQuantity}${isLowStock ? ' ⚠' : ''}`, col4, y);
doc.text(`$${Number(product.price).toFixed(2)}`, col5, y);
doc.text(`$${stockValue.toFixed(2)}`, col6, y);
y += 20;
}
if (products.length > 50) {
doc.moveDown();
doc.fontSize(8).text(`... y ${products.length - 50} productos más. Exporte a Excel para ver todos.`, { align: 'center' });
}
doc.end();
});
}
async exportInventoryExcel(tenantId: string, filters: InventoryExportFilterDto): Promise<Buffer> {
const products = await this.getInventoryData(tenantId, filters);
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MiChangarrito';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Inventario');
sheet.columns = [
{ header: 'SKU', key: 'sku', width: 15 },
{ header: 'Código Barras', key: 'barcode', width: 18 },
{ header: 'Producto', key: 'name', width: 30 },
{ header: 'Categoría', key: 'category', width: 20 },
{ header: 'Precio Venta', key: 'price', width: 14 },
{ header: 'Costo', key: 'costPrice', width: 12 },
{ header: 'Stock', key: 'stock', width: 10 },
{ header: 'Mínimo', key: 'threshold', width: 10 },
{ header: 'Unidad', key: 'unit', width: 10 },
{ header: 'Valor Inventario', key: 'value', width: 16 },
{ header: 'Stock Bajo', key: 'lowStock', width: 12 },
];
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF70AD47' },
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
for (const product of products) {
const isLowStock = product.trackInventory &&
Number(product.stockQuantity) <= Number(product.lowStockThreshold);
const stockValue = Number(product.price) * Number(product.stockQuantity);
const row = sheet.addRow({
sku: product.sku || '-',
barcode: product.barcode || '-',
name: product.name,
category: product.category?.name || '-',
price: Number(product.price),
costPrice: product.costPrice ? Number(product.costPrice) : null,
stock: Number(product.stockQuantity),
threshold: product.trackInventory ? Number(product.lowStockThreshold) : '-',
unit: product.unit,
value: stockValue,
lowStock: isLowStock ? 'Sí' : 'No',
});
if (isLowStock) {
row.getCell('lowStock').fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFC000' },
};
}
}
['E', 'F', 'J'].forEach(col => {
sheet.getColumn(col).numFmt = '"$"#,##0.00';
});
// Summary
sheet.addRow([]);
const summaryRow = sheet.addRow(['Resumen']);
summaryRow.font = { bold: true };
sheet.addRow(['Total Productos', products.length]);
sheet.addRow(['Valor Total Inventario', products.reduce((sum, p) =>
sum + (Number(p.price) * Number(p.stockQuantity)), 0)]);
sheet.addRow(['Productos Stock Bajo', products.filter(p =>
p.trackInventory && Number(p.stockQuantity) <= Number(p.lowStockThreshold)).length]);
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
// ============ FIADOS EXPORTS ============
async getFiadosData(tenantId: string, filters: FiadosExportFilterDto): Promise<Fiado[]> {
const query = this.fiadoRepository
.createQueryBuilder('fiado')
.leftJoinAndSelect('fiado.customer', 'customer')
.leftJoinAndSelect('fiado.payments', 'payments')
.where('fiado.tenantId = :tenantId', { tenantId });
if (filters.startDate && filters.endDate) {
query.andWhere('DATE(fiado.createdAt) BETWEEN :startDate AND :endDate', {
startDate: filters.startDate,
endDate: filters.endDate,
});
} else if (filters.startDate) {
query.andWhere('DATE(fiado.createdAt) >= :startDate', { startDate: filters.startDate });
} else if (filters.endDate) {
query.andWhere('DATE(fiado.createdAt) <= :endDate', { endDate: filters.endDate });
}
if (filters.status) {
query.andWhere('fiado.status = :status', { status: filters.status });
}
if (filters.overdue) {
query.andWhere('fiado.dueDate < :today', { today: new Date() })
.andWhere('fiado.status IN (:...activeStatuses)', {
activeStatuses: [FiadoStatus.PENDING, FiadoStatus.PARTIAL]
});
}
query.orderBy('fiado.createdAt', 'DESC');
return query.getMany();
}
async exportFiadosPdf(tenantId: string, filters: FiadosExportFilterDto): Promise<Buffer> {
const fiados = await this.getFiadosData(tenantId, filters);
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc.fontSize(20).text('Reporte de Fiados', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).text(this.getDateRangeText(filters.startDate, filters.endDate), { align: 'center' });
if (filters.overdue) {
doc.text('(Solo fiados vencidos)', { align: 'center' });
}
doc.moveDown();
// Summary
const activeFiados = fiados.filter(f =>
f.status === FiadoStatus.PENDING || f.status === FiadoStatus.PARTIAL
);
const totalDebt = activeFiados.reduce((sum, f) => sum + Number(f.remainingAmount), 0);
const overdueCount = fiados.filter(f =>
f.dueDate && new Date(f.dueDate) < new Date() &&
(f.status === FiadoStatus.PENDING || f.status === FiadoStatus.PARTIAL)
).length;
doc.fontSize(12).text('Resumen:', { underline: true });
doc.fontSize(10);
doc.text(`Total de fiados activos: ${activeFiados.length}`);
doc.text(`Deuda total pendiente: $${totalDebt.toFixed(2)}`);
doc.text(`Fiados vencidos: ${overdueCount}`);
doc.moveDown();
// Table
doc.fontSize(10).text('Detalle de Fiados:', { underline: true });
doc.moveDown(0.5);
const tableTop = doc.y;
const col1 = 50;
const col2 = 150;
const col3 = 240;
const col4 = 310;
const col5 = 380;
const col6 = 450;
doc.font('Helvetica-Bold');
doc.text('Cliente', col1, tableTop);
doc.text('Teléfono', col2, tableTop);
doc.text('Monto', col3, tableTop);
doc.text('Pagado', col4, tableTop);
doc.text('Pendiente', col5, tableTop);
doc.text('Vence', col6, tableTop);
doc.font('Helvetica');
let y = tableTop + 20;
doc.moveTo(col1, y - 5).lineTo(550, y - 5).stroke();
for (const fiado of fiados.slice(0, 50)) {
if (y > 700) {
doc.addPage();
y = 50;
}
const isOverdue = fiado.dueDate && new Date(fiado.dueDate) < new Date() &&
(fiado.status === FiadoStatus.PENDING || fiado.status === FiadoStatus.PARTIAL);
doc.text(fiado.customer?.name?.substring(0, 18) || '-', col1, y);
doc.text(fiado.customer?.phone || '-', col2, y);
doc.text(`$${Number(fiado.amount).toFixed(2)}`, col3, y);
doc.text(`$${Number(fiado.paidAmount).toFixed(2)}`, col4, y);
doc.text(`$${Number(fiado.remainingAmount).toFixed(2)}`, col5, y);
doc.text(fiado.dueDate ? `${new Date(fiado.dueDate).toLocaleDateString('es-MX')}${isOverdue ? ' ⚠' : ''}` : '-', col6, y);
y += 20;
}
if (fiados.length > 50) {
doc.moveDown();
doc.fontSize(8).text(`... y ${fiados.length - 50} fiados más. Exporte a Excel para ver todos.`, { align: 'center' });
}
doc.end();
});
}
async exportFiadosExcel(tenantId: string, filters: FiadosExportFilterDto): Promise<Buffer> {
const fiados = await this.getFiadosData(tenantId, filters);
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MiChangarrito';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Fiados');
sheet.columns = [
{ header: 'Cliente', key: 'customer', width: 25 },
{ header: 'Teléfono', key: 'phone', width: 15 },
{ header: 'Fecha', key: 'date', width: 12 },
{ header: 'Descripción', key: 'description', width: 30 },
{ header: 'Monto Original', key: 'amount', width: 15 },
{ header: 'Pagado', key: 'paid', width: 12 },
{ header: 'Pendiente', key: 'remaining', width: 12 },
{ header: 'Fecha Vencimiento', key: 'dueDate', width: 18 },
{ header: 'Estado', key: 'status', width: 12 },
{ header: 'Vencido', key: 'overdue', width: 10 },
];
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFED7D31' },
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
for (const fiado of fiados) {
const isOverdue = fiado.dueDate && new Date(fiado.dueDate) < new Date() &&
(fiado.status === FiadoStatus.PENDING || fiado.status === FiadoStatus.PARTIAL);
const row = sheet.addRow({
customer: fiado.customer?.name || '-',
phone: fiado.customer?.phone || '-',
date: new Date(fiado.createdAt).toLocaleDateString('es-MX'),
description: fiado.description || '-',
amount: Number(fiado.amount),
paid: Number(fiado.paidAmount),
remaining: Number(fiado.remainingAmount),
dueDate: fiado.dueDate ? new Date(fiado.dueDate).toLocaleDateString('es-MX') : '-',
status: this.getFiadoStatusLabel(fiado.status),
overdue: isOverdue ? 'Sí' : 'No',
});
if (isOverdue) {
row.getCell('overdue').fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFF0000' },
};
row.getCell('overdue').font = { color: { argb: 'FFFFFFFF' } };
}
}
['E', 'F', 'G'].forEach(col => {
sheet.getColumn(col).numFmt = '"$"#,##0.00';
});
// Summary
sheet.addRow([]);
const summaryRow = sheet.addRow(['Resumen']);
summaryRow.font = { bold: true };
const activeFiados = fiados.filter(f =>
f.status === FiadoStatus.PENDING || f.status === FiadoStatus.PARTIAL
);
sheet.addRow(['Fiados Activos', activeFiados.length]);
sheet.addRow(['Deuda Total Pendiente', activeFiados.reduce((sum, f) => sum + Number(f.remainingAmount), 0)]);
sheet.addRow(['Fiados Vencidos', fiados.filter(f =>
f.dueDate && new Date(f.dueDate) < new Date() &&
(f.status === FiadoStatus.PENDING || f.status === FiadoStatus.PARTIAL)
).length]);
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
// ============ MOVEMENTS EXPORTS ============
async getMovementsData(tenantId: string, filters: MovementsExportFilterDto): Promise<InventoryMovement[]> {
const query = this.movementRepository
.createQueryBuilder('movement')
.leftJoinAndSelect('movement.product', 'product')
.where('movement.tenantId = :tenantId', { tenantId });
if (filters.startDate && filters.endDate) {
query.andWhere('DATE(movement.createdAt) BETWEEN :startDate AND :endDate', {
startDate: filters.startDate,
endDate: filters.endDate,
});
} else if (filters.startDate) {
query.andWhere('DATE(movement.createdAt) >= :startDate', { startDate: filters.startDate });
} else if (filters.endDate) {
query.andWhere('DATE(movement.createdAt) <= :endDate', { endDate: filters.endDate });
}
if (filters.movementType) {
query.andWhere('movement.movementType = :movementType', { movementType: filters.movementType });
}
if (filters.productId) {
query.andWhere('movement.productId = :productId', { productId: filters.productId });
}
query.orderBy('movement.createdAt', 'DESC');
return query.getMany();
}
async exportMovementsPdf(tenantId: string, filters: MovementsExportFilterDto): Promise<Buffer> {
const movements = await this.getMovementsData(tenantId, filters);
const doc = new PDFDocument({ margin: 50, size: 'LETTER', layout: 'landscape' });
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc.fontSize(20).text('Reporte de Movimientos de Inventario', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).text(this.getDateRangeText(filters.startDate, filters.endDate), { align: 'center' });
doc.moveDown();
// Summary by type
const typeGroups = this.groupByMovementType(movements);
doc.fontSize(12).text('Resumen por Tipo:', { underline: true });
doc.fontSize(10);
Object.entries(typeGroups).forEach(([type, items]) => {
doc.text(`${this.getMovementTypeLabel(type)}: ${items.length} movimientos`);
});
doc.moveDown();
// Table
doc.fontSize(10).text('Detalle de Movimientos:', { underline: true });
doc.moveDown(0.5);
const tableTop = doc.y;
const col1 = 50;
const col2 = 130;
const col3 = 270;
const col4 = 380;
const col5 = 450;
const col6 = 520;
const col7 = 590;
const col8 = 660;
doc.font('Helvetica-Bold');
doc.text('Fecha', col1, tableTop);
doc.text('Producto', col2, tableTop);
doc.text('Tipo', col3, tableTop);
doc.text('Cantidad', col4, tableTop);
doc.text('Antes', col5, tableTop);
doc.text('Después', col6, tableTop);
doc.text('Costo Unit.', col7, tableTop);
doc.text('Notas', col8, tableTop);
doc.font('Helvetica');
let y = tableTop + 20;
doc.moveTo(col1, y - 5).lineTo(750, y - 5).stroke();
for (const movement of movements.slice(0, 40)) {
if (y > 500) {
doc.addPage();
y = 50;
}
const qtySign = Number(movement.quantity) >= 0 ? '+' : '';
doc.text(new Date(movement.createdAt).toLocaleDateString('es-MX'), col1, y);
doc.text(movement.product?.name?.substring(0, 22) || '-', col2, y, { width: 135 });
doc.text(this.getMovementTypeLabel(movement.movementType), col3, y);
doc.text(`${qtySign}${Number(movement.quantity)}`, col4, y);
doc.text(String(Number(movement.quantityBefore)), col5, y);
doc.text(String(Number(movement.quantityAfter)), col6, y);
doc.text(movement.unitCost ? `$${Number(movement.unitCost).toFixed(2)}` : '-', col7, y);
doc.text(movement.notes?.substring(0, 15) || '-', col8, y, { width: 90 });
y += 20;
}
if (movements.length > 40) {
doc.moveDown();
doc.fontSize(8).text(`... y ${movements.length - 40} movimientos más. Exporte a Excel para ver todos.`, { align: 'center' });
}
doc.end();
});
}
async exportMovementsExcel(tenantId: string, filters: MovementsExportFilterDto): Promise<Buffer> {
const movements = await this.getMovementsData(tenantId, filters);
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MiChangarrito';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Movimientos');
sheet.columns = [
{ header: 'Fecha', key: 'date', width: 12 },
{ header: 'Hora', key: 'time', width: 10 },
{ header: 'Producto', key: 'product', width: 30 },
{ header: 'SKU', key: 'sku', width: 15 },
{ header: 'Tipo Movimiento', key: 'type', width: 15 },
{ header: 'Cantidad', key: 'quantity', width: 12 },
{ header: 'Stock Antes', key: 'before', width: 12 },
{ header: 'Stock Después', key: 'after', width: 14 },
{ header: 'Costo Unitario', key: 'cost', width: 14 },
{ header: 'Referencia', key: 'reference', width: 15 },
{ header: 'Notas', key: 'notes', width: 30 },
];
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF7030A0' },
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
for (const movement of movements) {
const row = sheet.addRow({
date: new Date(movement.createdAt).toLocaleDateString('es-MX'),
time: new Date(movement.createdAt).toLocaleTimeString('es-MX'),
product: movement.product?.name || '-',
sku: movement.product?.sku || '-',
type: this.getMovementTypeLabel(movement.movementType),
quantity: Number(movement.quantity),
before: Number(movement.quantityBefore),
after: Number(movement.quantityAfter),
cost: movement.unitCost ? Number(movement.unitCost) : null,
reference: movement.referenceType ? `${movement.referenceType}: ${movement.referenceId}` : '-',
notes: movement.notes || '-',
});
// Color code by movement type
const typeColors: Record<string, string> = {
purchase: 'FF92D050',
sale: 'FFFFC000',
adjustment: 'FF00B0F0',
loss: 'FFFF0000',
return: 'FF00B050',
transfer: 'FFFFA500',
};
const color = typeColors[movement.movementType] || 'FFFFFFFF';
row.getCell('type').fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: color },
};
}
sheet.getColumn('I').numFmt = '"$"#,##0.00';
// Summary by type
sheet.addRow([]);
const summaryRow = sheet.addRow(['Resumen por Tipo']);
summaryRow.font = { bold: true };
const typeGroups = this.groupByMovementType(movements);
Object.entries(typeGroups).forEach(([type, items]) => {
sheet.addRow([this.getMovementTypeLabel(type), items.length]);
});
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
// ============ HELPER METHODS ============
private getDateRangeText(startDate?: string, endDate?: string): string {
if (startDate && endDate) {
return `Periodo: ${startDate} - ${endDate}`;
} else if (startDate) {
return `Desde: ${startDate}`;
} else if (endDate) {
return `Hasta: ${endDate}`;
}
return 'Todos los registros';
}
private getStatusLabel(status: SaleStatus): string {
const labels: Record<SaleStatus, string> = {
[SaleStatus.COMPLETED]: 'Completada',
[SaleStatus.CANCELLED]: 'Cancelada',
[SaleStatus.REFUNDED]: 'Reembolsada',
};
return labels[status] || status;
}
private getFiadoStatusLabel(status: FiadoStatus): string {
const labels: Record<FiadoStatus, string> = {
[FiadoStatus.PENDING]: 'Pendiente',
[FiadoStatus.PARTIAL]: 'Pago Parcial',
[FiadoStatus.PAID]: 'Pagado',
[FiadoStatus.CANCELLED]: 'Cancelado',
};
return labels[status] || status;
}
private getMovementTypeLabel(type: string): string {
const labels: Record<string, string> = {
purchase: 'Compra',
sale: 'Venta',
adjustment: 'Ajuste',
loss: 'Pérdida',
return: 'Devolución',
transfer: 'Transferencia',
};
return labels[type] || type;
}
private groupByMovementType(movements: InventoryMovement[]): Record<string, InventoryMovement[]> {
return movements.reduce((groups, movement) => {
const type = movement.movementType;
if (!groups[type]) {
groups[type] = [];
}
groups[type].push(movement);
return groups;
}, {} as Record<string, InventoryMovement[]>);
}
}