[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:
parent
16e5713c60
commit
0019ded690
@ -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 {}
|
||||
|
||||
124
src/modules/templates/dto/template.dto.ts
Normal file
124
src/modules/templates/dto/template.dto.ts
Normal 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;
|
||||
}
|
||||
80
src/modules/templates/entities/product-template.entity.ts
Normal file
80
src/modules/templates/entities/product-template.entity.ts
Normal 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;
|
||||
}
|
||||
46
src/modules/templates/entities/template-import.entity.ts
Normal file
46
src/modules/templates/entities/template-import.entity.ts
Normal 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;
|
||||
}
|
||||
89
src/modules/templates/templates.controller.ts
Normal file
89
src/modules/templates/templates.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
src/modules/templates/templates.module.ts
Normal file
19
src/modules/templates/templates.module.ts
Normal 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 {}
|
||||
412
src/modules/templates/templates.service.ts
Normal file
412
src/modules/templates/templates.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user