[MCH-007] feat: Implementar modulo templates con 85+ productos de 8 proveedores

- Crear entidades ProductTemplate y TemplateImport
- Implementar TemplatesService con CRUD y busqueda
- Crear TemplatesController con 9 endpoints
- Agregar soporte para giros (abarrotes, papeleria, farmacia, ferreteria)
- Agregar soporte para proveedores (Sabritas, Coca-Cola, Bimbo, etc)
- Implementar importacion masiva de templates a catalogo tenant

Sprint 5 - Inteligencia
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 04:11:35 -06:00
parent 16e5713c60
commit 0019ded690
7 changed files with 772 additions and 0 deletions

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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<string, any>;
@Column({ default: true })
active: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -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<string, any>;
@CreateDateColumn({ name: 'imported_at' })
importedAt: Date;
// Relations
@ManyToOne(() => ProductTemplate, { nullable: true })
@JoinColumn({ name: 'template_id' })
template: ProductTemplate;
}

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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<ProductTemplate>,
@InjectRepository(TemplateImport)
private readonly importRepository: Repository<TemplateImport>,
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
) {}
// === GIROS ===
async getGiros(): Promise<GiroInfoDto[]> {
const giroConfigs: Record<TemplateGiro, Omit<GiroInfoDto, 'productCount'>> = {
[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<ProductTemplate[]> {
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<ProviderInfoDto[]> {
const providerConfigs: Record<TemplateProvider, Omit<ProviderInfoDto, 'productCount' | 'categories'>> = {
[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<ProductTemplate[]> {
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<ProductTemplate[]> {
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<ImportResultDto> {
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<string>();
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<TemplateImport[]> {
return this.importRepository.find({
where: { tenantId },
order: { importedAt: 'DESC' },
take: 50,
});
}
// === SINGLE TEMPLATE ===
async findOne(id: string): Promise<ProductTemplate> {
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<string, number>;
byGiro: Record<string, number>;
}> {
const templates = await this.templateRepository.find({
where: { active: true },
select: ['provider', 'giro'],
});
const byProvider: Record<string, number> = {};
const byGiro: Record<string, number> = {};
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,
};
}
}