[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:
parent
c936f447cf
commit
b3eaebb54c
1006
package-lock.json
generated
1006
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -75,6 +75,7 @@ import { ExportsModule } from './modules/exports/exports.module';
|
||||
TemplatesModule,
|
||||
OnboardingModule,
|
||||
SettingsModule,
|
||||
ExportsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
60
src/modules/exports/dto/export-filter.dto.ts
Normal file
60
src/modules/exports/dto/export-filter.dto.ts
Normal 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;
|
||||
}
|
||||
184
src/modules/exports/exports.controller.ts
Normal file
184
src/modules/exports/exports.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/modules/exports/exports.module.ts
Normal file
29
src/modules/exports/exports.module.ts
Normal 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 {}
|
||||
848
src/modules/exports/exports.service.ts
Normal file
848
src/modules/exports/exports.service.ts
Normal 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[]>);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user