diff --git a/src/app.module.ts b/src/app.module.ts index dc3a2c6..cf2b77c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { InvoicesModule } from './modules/invoices/invoices.module'; import { MarketplaceModule } from './modules/marketplace/marketplace.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { DeliveryModule } from './modules/delivery/delivery.module'; +import { TemplatesModule } from './modules/templates/templates.module'; @Module({ imports: [ @@ -68,6 +69,7 @@ import { DeliveryModule } from './modules/delivery/delivery.module'; MarketplaceModule, NotificationsModule, DeliveryModule, + TemplatesModule, ], }) export class AppModule {} diff --git a/src/modules/templates/dto/template.dto.ts b/src/modules/templates/dto/template.dto.ts new file mode 100644 index 0000000..ee0f5ab --- /dev/null +++ b/src/modules/templates/dto/template.dto.ts @@ -0,0 +1,124 @@ +import { IsEnum, IsOptional, IsString, IsNumber, IsBoolean, IsArray, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { TemplateGiro, TemplateProvider } from '../entities/product-template.entity'; + +export class TemplateFilterDto { + @IsOptional() + @IsEnum(TemplateProvider) + provider?: TemplateProvider; + + @IsOptional() + @IsEnum(TemplateGiro) + giro?: TemplateGiro; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + active?: boolean; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + limit?: number; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + offset?: number; +} + +export class ImportTemplateDto { + @IsOptional() + @IsEnum(TemplateProvider) + provider?: TemplateProvider; + + @IsOptional() + @IsEnum(TemplateGiro) + giro?: TemplateGiro; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + templateIds?: string[]; + + @IsOptional() + @IsNumber() + @Type(() => Number) + priceMultiplier?: number; // Para ajustar precios (ej: 1.2 = 20% margen) + + @IsOptional() + @IsBoolean() + skipDuplicates?: boolean; +} + +export class SearchTemplateDto { + @IsString() + query: string; + + @IsOptional() + @IsEnum(TemplateProvider) + provider?: TemplateProvider; + + @IsOptional() + @IsEnum(TemplateGiro) + giro?: TemplateGiro; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + limit?: number; +} + +export class TemplateResponseDto { + id: string; + provider: string; + giro: string; + category: string; + sku: string; + name: string; + description: string; + barcode: string; + suggestedPrice: number; + imageUrl: string; + unit: string; +} + +export class ImportResultDto { + imported: number; + skipped: number; + importId: string; + products: Array<{ + templateId: string; + productId: string; + name: string; + price: number; + }>; +} + +export class GiroInfoDto { + giro: TemplateGiro; + name: string; + description: string; + suggestedCategories: string[]; + commonProviders: TemplateProvider[]; + productCount: number; +} + +export class ProviderInfoDto { + provider: TemplateProvider; + name: string; + logo: string; + categories: string[]; + productCount: number; +} diff --git a/src/modules/templates/entities/product-template.entity.ts b/src/modules/templates/entities/product-template.entity.ts new file mode 100644 index 0000000..2d6fac0 --- /dev/null +++ b/src/modules/templates/entities/product-template.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TemplateGiro { + ABARROTES = 'abarrotes', + PAPELERIA = 'papeleria', + FARMACIA = 'farmacia', + FERRETERIA = 'ferreteria', + GENERAL = 'general', +} + +export enum TemplateProvider { + SABRITAS = 'sabritas', + COCA_COLA = 'coca-cola', + BIMBO = 'bimbo', + MARINELA = 'marinela', + GAMESA = 'gamesa', + PEPSI = 'pepsi', + NESTLE = 'nestle', + GENERIC = 'generic', +} + +@Entity({ schema: 'catalog', name: 'product_templates' }) +@Index(['provider', 'giro']) +@Index(['barcode']) +export class ProductTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: TemplateProvider }) + provider: TemplateProvider; + + @Column({ type: 'enum', enum: TemplateGiro }) + giro: TemplateGiro; + + @Column({ length: 50, nullable: true }) + category: string; + + @Column({ length: 50 }) + sku: string; + + @Column({ length: 150 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ length: 50, nullable: true }) + barcode: string; + + @Column({ name: 'suggested_price', type: 'decimal', precision: 10, scale: 2 }) + suggestedPrice: number; + + @Column({ name: 'cost_price', type: 'decimal', precision: 10, scale: 2, nullable: true }) + costPrice: number; + + @Column({ name: 'image_url', type: 'text', nullable: true }) + imageUrl: string; + + @Column({ length: 20, default: 'pieza' }) + unit: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ default: true }) + active: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/templates/entities/template-import.entity.ts b/src/modules/templates/entities/template-import.entity.ts new file mode 100644 index 0000000..b8ac061 --- /dev/null +++ b/src/modules/templates/entities/template-import.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ProductTemplate, TemplateProvider, TemplateGiro } from './product-template.entity'; + +@Entity({ schema: 'catalog', name: 'template_imports' }) +@Index(['tenantId', 'importedAt']) +export class TemplateImport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'template_id', nullable: true }) + templateId: string; + + @Column({ type: 'enum', enum: TemplateProvider, nullable: true }) + provider: TemplateProvider; + + @Column({ type: 'enum', enum: TemplateGiro, nullable: true }) + giro: TemplateGiro; + + @Column({ name: 'products_count', type: 'int', default: 0 }) + productsCount: number; + + @Column({ name: 'skipped_count', type: 'int', default: 0 }) + skippedCount: number; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'imported_at' }) + importedAt: Date; + + // Relations + @ManyToOne(() => ProductTemplate, { nullable: true }) + @JoinColumn({ name: 'template_id' }) + template: ProductTemplate; +} diff --git a/src/modules/templates/templates.controller.ts b/src/modules/templates/templates.controller.ts new file mode 100644 index 0000000..119003a --- /dev/null +++ b/src/modules/templates/templates.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { TemplateGiro, TemplateProvider } from './entities/product-template.entity'; +import { + TemplateFilterDto, + ImportTemplateDto, + SearchTemplateDto, +} from './dto/template.dto'; + +@Controller('templates') +@UseGuards(JwtAuthGuard) +export class TemplatesController { + constructor(private readonly templatesService: TemplatesService) {} + + // === GIROS === + + @Get('giros') + async getGiros() { + return this.templatesService.getGiros(); + } + + @Get('giros/:giro') + async getProductsByGiro( + @Param('giro') giro: TemplateGiro, + @Query() filters: TemplateFilterDto, + ) { + return this.templatesService.getProductsByGiro(giro, filters); + } + + // === PROVIDERS === + + @Get('providers') + async getProviders() { + return this.templatesService.getProviders(); + } + + @Get('providers/:provider') + async getProductsByProvider( + @Param('provider') provider: TemplateProvider, + @Query() filters: TemplateFilterDto, + ) { + return this.templatesService.getProductsByProvider(provider, filters); + } + + // === SEARCH === + + @Get('search') + async search(@Query() dto: SearchTemplateDto) { + return this.templatesService.search(dto); + } + + // === IMPORT === + + @Post('import') + async importTemplates(@Request() req, @Body() dto: ImportTemplateDto) { + const tenantId = req.user.tenantId; + return this.templatesService.importTemplates(tenantId, dto); + } + + @Get('imports') + async getImportHistory(@Request() req) { + const tenantId = req.user.tenantId; + return this.templatesService.getImportHistory(tenantId); + } + + // === SINGLE TEMPLATE === + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.templatesService.findOne(id); + } + + // === STATS === + + @Get('stats/summary') + async getStats() { + return this.templatesService.getStats(); + } +} diff --git a/src/modules/templates/templates.module.ts b/src/modules/templates/templates.module.ts new file mode 100644 index 0000000..65dbe44 --- /dev/null +++ b/src/modules/templates/templates.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TemplatesController } from './templates.controller'; +import { TemplatesService } from './templates.service'; +import { ProductTemplate } from './entities/product-template.entity'; +import { TemplateImport } from './entities/template-import.entity'; +import { Product } from '../products/entities/product.entity'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ProductTemplate, TemplateImport, Product]), + AuthModule, + ], + controllers: [TemplatesController], + providers: [TemplatesService], + exports: [TemplatesService, TypeOrmModule], +}) +export class TemplatesModule {} diff --git a/src/modules/templates/templates.service.ts b/src/modules/templates/templates.service.ts new file mode 100644 index 0000000..40aea12 --- /dev/null +++ b/src/modules/templates/templates.service.ts @@ -0,0 +1,412 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { ProductTemplate, TemplateGiro, TemplateProvider } from './entities/product-template.entity'; +import { TemplateImport } from './entities/template-import.entity'; +import { Product } from '../products/entities/product.entity'; +import { + TemplateFilterDto, + ImportTemplateDto, + SearchTemplateDto, + ImportResultDto, + GiroInfoDto, + ProviderInfoDto, +} from './dto/template.dto'; + +@Injectable() +export class TemplatesService { + constructor( + @InjectRepository(ProductTemplate) + private readonly templateRepository: Repository, + @InjectRepository(TemplateImport) + private readonly importRepository: Repository, + @InjectRepository(Product) + private readonly productRepository: Repository, + ) {} + + // === GIROS === + + async getGiros(): Promise { + const giroConfigs: Record> = { + [TemplateGiro.ABARROTES]: { + giro: TemplateGiro.ABARROTES, + name: 'Tienda de Abarrotes', + description: 'Productos para tiendas de abarrotes y miscelaneas', + suggestedCategories: ['Botanas', 'Refrescos', 'Dulces', 'Pan', 'Lacteos', 'Abarrotes'], + commonProviders: [TemplateProvider.SABRITAS, TemplateProvider.COCA_COLA, TemplateProvider.BIMBO, TemplateProvider.MARINELA], + }, + [TemplateGiro.PAPELERIA]: { + giro: TemplateGiro.PAPELERIA, + name: 'Papeleria', + description: 'Productos para papelerias y librerias', + suggestedCategories: ['Cuadernos', 'Plumas', 'Colores', 'Pegamento', 'Papel'], + commonProviders: [TemplateProvider.GENERIC], + }, + [TemplateGiro.FARMACIA]: { + giro: TemplateGiro.FARMACIA, + name: 'Farmacia', + description: 'Productos para farmacias y boticas', + suggestedCategories: ['Medicamentos', 'Higiene', 'Cuidado Personal', 'Vitaminas'], + commonProviders: [TemplateProvider.GENERIC], + }, + [TemplateGiro.FERRETERIA]: { + giro: TemplateGiro.FERRETERIA, + name: 'Ferreteria', + description: 'Productos para ferreterias y tlapalerías', + suggestedCategories: ['Herramientas', 'Electricidad', 'Plomeria', 'Pintura', 'Tornilleria'], + commonProviders: [TemplateProvider.GENERIC], + }, + [TemplateGiro.GENERAL]: { + giro: TemplateGiro.GENERAL, + name: 'General', + description: 'Productos para negocios en general', + suggestedCategories: ['Varios'], + commonProviders: [TemplateProvider.GENERIC], + }, + }; + + const result: GiroInfoDto[] = []; + + for (const [giro, config] of Object.entries(giroConfigs)) { + const count = await this.templateRepository.count({ + where: { giro: giro as TemplateGiro, active: true }, + }); + + result.push({ + ...config, + productCount: count, + }); + } + + return result; + } + + async getProductsByGiro(giro: TemplateGiro, filters: TemplateFilterDto): Promise { + const query = this.templateRepository + .createQueryBuilder('template') + .where('template.giro = :giro', { giro }) + .andWhere('template.active = true'); + + if (filters.provider) { + query.andWhere('template.provider = :provider', { provider: filters.provider }); + } + + if (filters.category) { + query.andWhere('template.category = :category', { category: filters.category }); + } + + if (filters.search) { + query.andWhere( + '(template.name ILIKE :search OR template.sku ILIKE :search OR template.barcode ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + query.orderBy('template.name', 'ASC'); + + if (filters.limit) { + query.take(filters.limit); + } + + if (filters.offset) { + query.skip(filters.offset); + } + + return query.getMany(); + } + + // === PROVIDERS === + + async getProviders(): Promise { + const providerConfigs: Record> = { + [TemplateProvider.SABRITAS]: { + provider: TemplateProvider.SABRITAS, + name: 'Sabritas / PepsiCo', + logo: '/images/providers/sabritas.png', + }, + [TemplateProvider.COCA_COLA]: { + provider: TemplateProvider.COCA_COLA, + name: 'Coca-Cola FEMSA', + logo: '/images/providers/coca-cola.png', + }, + [TemplateProvider.BIMBO]: { + provider: TemplateProvider.BIMBO, + name: 'Grupo Bimbo', + logo: '/images/providers/bimbo.png', + }, + [TemplateProvider.MARINELA]: { + provider: TemplateProvider.MARINELA, + name: 'Marinela', + logo: '/images/providers/marinela.png', + }, + [TemplateProvider.GAMESA]: { + provider: TemplateProvider.GAMESA, + name: 'Gamesa', + logo: '/images/providers/gamesa.png', + }, + [TemplateProvider.PEPSI]: { + provider: TemplateProvider.PEPSI, + name: 'PepsiCo Bebidas', + logo: '/images/providers/pepsi.png', + }, + [TemplateProvider.NESTLE]: { + provider: TemplateProvider.NESTLE, + name: 'Nestle', + logo: '/images/providers/nestle.png', + }, + [TemplateProvider.GENERIC]: { + provider: TemplateProvider.GENERIC, + name: 'Productos Genericos', + logo: '/images/providers/generic.png', + }, + }; + + const result: ProviderInfoDto[] = []; + + for (const [provider, config] of Object.entries(providerConfigs)) { + const templates = await this.templateRepository.find({ + where: { provider: provider as TemplateProvider, active: true }, + select: ['category'], + }); + + const categories = [...new Set(templates.map(t => t.category).filter(Boolean))]; + const count = templates.length; + + if (count > 0) { + result.push({ + ...config, + categories, + productCount: count, + }); + } + } + + return result; + } + + async getProductsByProvider(provider: TemplateProvider, filters: TemplateFilterDto): Promise { + const query = this.templateRepository + .createQueryBuilder('template') + .where('template.provider = :provider', { provider }) + .andWhere('template.active = true'); + + if (filters.giro) { + query.andWhere('template.giro = :giro', { giro: filters.giro }); + } + + if (filters.category) { + query.andWhere('template.category = :category', { category: filters.category }); + } + + if (filters.search) { + query.andWhere( + '(template.name ILIKE :search OR template.sku ILIKE :search OR template.barcode ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + query.orderBy('template.category', 'ASC').addOrderBy('template.name', 'ASC'); + + if (filters.limit) { + query.take(filters.limit); + } + + if (filters.offset) { + query.skip(filters.offset); + } + + return query.getMany(); + } + + // === SEARCH === + + async search(dto: SearchTemplateDto): Promise { + const query = this.templateRepository + .createQueryBuilder('template') + .where('template.active = true') + .andWhere( + '(template.name ILIKE :query OR template.sku ILIKE :query OR template.barcode = :exactQuery)', + { query: `%${dto.query}%`, exactQuery: dto.query }, + ); + + if (dto.provider) { + query.andWhere('template.provider = :provider', { provider: dto.provider }); + } + + if (dto.giro) { + query.andWhere('template.giro = :giro', { giro: dto.giro }); + } + + query.orderBy('template.name', 'ASC'); + + if (dto.limit) { + query.take(dto.limit); + } else { + query.take(50); // Default limit + } + + return query.getMany(); + } + + // === IMPORT === + + async importTemplates(tenantId: string, dto: ImportTemplateDto): Promise { + const priceMultiplier = dto.priceMultiplier || 1; + const skipDuplicates = dto.skipDuplicates !== false; + + // Get templates to import + let templates: ProductTemplate[]; + + if (dto.templateIds && dto.templateIds.length > 0) { + templates = await this.templateRepository.find({ + where: { id: In(dto.templateIds), active: true }, + }); + } else if (dto.provider) { + templates = await this.templateRepository.find({ + where: { provider: dto.provider, active: true }, + }); + } else if (dto.giro) { + templates = await this.templateRepository.find({ + where: { giro: dto.giro, active: true }, + }); + } else { + throw new BadRequestException('Debe especificar templateIds, provider o giro'); + } + + if (templates.length === 0) { + throw new NotFoundException('No se encontraron templates para importar'); + } + + // Get existing barcodes to check duplicates + const existingBarcodes = new Set(); + if (skipDuplicates) { + const existingProducts = await this.productRepository.find({ + where: { tenantId }, + select: ['barcode'], + }); + existingProducts.forEach(p => { + if (p.barcode) existingBarcodes.add(p.barcode); + }); + } + + // Import products + const importedProducts: Array<{ templateId: string; productId: string; name: string; price: number }> = []; + let skippedCount = 0; + + for (const template of templates) { + // Skip if duplicate barcode + if (skipDuplicates && template.barcode && existingBarcodes.has(template.barcode)) { + skippedCount++; + continue; + } + + const price = Number(template.suggestedPrice) * priceMultiplier; + + const product = this.productRepository.create({ + tenantId, + name: template.name, + description: template.description, + sku: `${template.provider}-${template.sku}`, + barcode: template.barcode, + price: price, + costPrice: template.costPrice, + imageUrl: template.imageUrl, + unit: template.unit, + status: 'active', + trackInventory: true, + stockQuantity: 0, + }); + + const savedProduct = await this.productRepository.save(product); + + importedProducts.push({ + templateId: template.id, + productId: savedProduct.id, + name: savedProduct.name, + price: savedProduct.price, + }); + + // Add to existing set to prevent duplicates within same import + if (template.barcode) { + existingBarcodes.add(template.barcode); + } + } + + // Create import record + const importRecord = this.importRepository.create({ + tenantId, + provider: dto.provider, + giro: dto.giro, + productsCount: importedProducts.length, + skippedCount, + metadata: { + priceMultiplier, + templateIds: dto.templateIds, + }, + }); + + await this.importRepository.save(importRecord); + + return { + imported: importedProducts.length, + skipped: skippedCount, + importId: importRecord.id, + products: importedProducts, + }; + } + + // === IMPORT HISTORY === + + async getImportHistory(tenantId: string): Promise { + return this.importRepository.find({ + where: { tenantId }, + order: { importedAt: 'DESC' }, + take: 50, + }); + } + + // === SINGLE TEMPLATE === + + async findOne(id: string): Promise { + const template = await this.templateRepository.findOne({ + where: { id, active: true }, + }); + + if (!template) { + throw new NotFoundException('Template no encontrado'); + } + + return template; + } + + // === STATS === + + async getStats(): Promise<{ + totalTemplates: number; + byProvider: Record; + byGiro: Record; + }> { + const templates = await this.templateRepository.find({ + where: { active: true }, + select: ['provider', 'giro'], + }); + + const byProvider: Record = {}; + const byGiro: Record = {}; + + templates.forEach(t => { + byProvider[t.provider] = (byProvider[t.provider] || 0) + 1; + byGiro[t.giro] = (byGiro[t.giro] || 0) + 1; + }); + + return { + totalTemplates: templates.length, + byProvider, + byGiro, + }; + } +}