[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 { MarketplaceModule } from './modules/marketplace/marketplace.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { DeliveryModule } from './modules/delivery/delivery.module';
|
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||||
|
import { TemplatesModule } from './modules/templates/templates.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -68,6 +69,7 @@ import { DeliveryModule } from './modules/delivery/delivery.module';
|
|||||||
MarketplaceModule,
|
MarketplaceModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
DeliveryModule,
|
DeliveryModule,
|
||||||
|
TemplatesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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