[MCH-006] feat: Implementar modulo onboarding inteligente

- Crear entidades OnboardingSession y ProductScan
- Implementar OcrService con Google Vision API
- Implementar WhisperService para transcripcion de audio
- Crear OnboardingService con flujo completo
- Agregar 12 endpoints para gestion de onboarding
- Soporte para procesamiento de fotos y audios
- Deteccion automatica de productos y precios
- Integracion con TemplatesService para matching

Sprint 5 - Inteligencia (2/2 epicas)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 04:18:16 -06:00
parent 0019ded690
commit 29d68ec9b7
9 changed files with 1321 additions and 0 deletions

View File

@ -21,6 +21,7 @@ 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';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
@Module({
imports: [
@ -70,6 +71,7 @@ import { TemplatesModule } from './modules/templates/templates.module';
NotificationsModule,
DeliveryModule,
TemplatesModule,
OnboardingModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,149 @@
import { IsEnum, IsOptional, IsString, IsNumber, IsUUID, IsBoolean, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { OnboardingChannel, OnboardingStatus } from '../entities/onboarding-session.entity';
import { TemplateGiro } from '../../templates/entities/product-template.entity';
export class StartOnboardingDto {
@IsString()
phoneNumber: string;
@IsOptional()
@IsEnum(OnboardingChannel)
channel?: OnboardingChannel;
@IsOptional()
@IsString()
ownerName?: string;
}
export class UpdateBusinessInfoDto {
@IsOptional()
@IsString()
businessName?: string;
@IsOptional()
@IsEnum(TemplateGiro)
giro?: TemplateGiro;
@IsOptional()
@IsString()
ownerName?: string;
}
export class ProcessImageDto {
@IsUUID()
sessionId: string;
@IsString()
imageUrl: string;
@IsOptional()
@IsString()
mimeType?: string;
}
export class ProcessAudioDto {
@IsUUID()
sessionId: string;
@IsString()
audioUrl: string;
@IsOptional()
@IsString()
mimeType?: string;
}
export class ConfirmProductDto {
@IsUUID()
scanId: string;
@IsString()
name: string;
@IsNumber()
@Min(0.01)
@Max(99999)
@Type(() => Number)
price: number;
@IsOptional()
@IsString()
barcode?: string;
}
export class RejectProductDto {
@IsUUID()
scanId: string;
@IsOptional()
@IsString()
reason?: string;
}
export class ImportTemplatesDto {
@IsUUID()
sessionId: string;
@IsOptional()
@IsEnum(TemplateGiro)
giro?: TemplateGiro;
@IsOptional()
@IsString({ each: true })
templateIds?: string[];
}
export class OnboardingSessionResponseDto {
id: string;
phoneNumber: string;
channel: OnboardingChannel;
status: OnboardingStatus;
currentStep: number;
totalSteps: number;
businessName?: string;
businessGiro?: string;
ownerName?: string;
templatesImported: number;
productsAdded: number;
photosProcessed: number;
audiosProcessed: number;
startedAt: Date;
completedAt?: Date;
}
export class ProductScanResponseDto {
id: string;
type: string;
status: string;
detectedName?: string;
detectedPrice?: number;
detectedBarcode?: string;
confidenceScore?: number;
templateMatch?: {
id: string;
name: string;
suggestedPrice: number;
matchScore: number;
};
createdAt: Date;
}
export class OnboardingStepDto {
step: number;
name: string;
description: string;
completed: boolean;
data?: any;
}
export class OnboardingProgressDto {
session: OnboardingSessionResponseDto;
steps: OnboardingStepDto[];
suggestedTemplates?: Array<{
provider: string;
name: string;
productCount: number;
}>;
pendingScans: ProductScanResponseDto[];
}

View File

@ -0,0 +1,89 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum OnboardingStatus {
STARTED = 'started',
BUSINESS_INFO = 'business_info',
GIRO_SELECTED = 'giro_selected',
TEMPLATES_LOADED = 'templates_loaded',
PRODUCTS_ADDED = 'products_added',
COMPLETED = 'completed',
ABANDONED = 'abandoned',
}
export enum OnboardingChannel {
WHATSAPP = 'whatsapp',
WEB = 'web',
APP = 'app',
}
@Entity({ schema: 'auth', name: 'onboarding_sessions' })
@Index(['tenantId', 'status'])
@Index(['phoneNumber'])
export class OnboardingSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@Column({ name: 'phone_number', length: 20 })
phoneNumber: string;
@Column({ type: 'enum', enum: OnboardingChannel, default: OnboardingChannel.WHATSAPP })
channel: OnboardingChannel;
@Column({ type: 'enum', enum: OnboardingStatus, default: OnboardingStatus.STARTED })
status: OnboardingStatus;
@Column({ name: 'current_step', type: 'int', default: 1 })
currentStep: number;
@Column({ name: 'total_steps', type: 'int', default: 5 })
totalSteps: number;
// Business info collected
@Column({ name: 'business_name', length: 100, nullable: true })
businessName: string;
@Column({ name: 'business_giro', length: 50, nullable: true })
businessGiro: string;
@Column({ name: 'owner_name', length: 100, nullable: true })
ownerName: string;
// Progress tracking
@Column({ name: 'templates_imported', type: 'int', default: 0 })
templatesImported: number;
@Column({ name: 'products_added', type: 'int', default: 0 })
productsAdded: number;
@Column({ name: 'photos_processed', type: 'int', default: 0 })
photosProcessed: number;
@Column({ name: 'audios_processed', type: 'int', default: 0 })
audiosProcessed: number;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@Column({ name: 'last_message_at', type: 'timestamptz', nullable: true })
lastMessageAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@CreateDateColumn({ name: 'started_at' })
startedAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,103 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { OnboardingSession } from './onboarding-session.entity';
export enum ScanType {
PHOTO = 'photo',
AUDIO = 'audio',
BARCODE = 'barcode',
}
export enum ScanStatus {
PENDING = 'pending',
PROCESSING = 'processing',
DETECTED = 'detected',
CONFIRMED = 'confirmed',
REJECTED = 'rejected',
FAILED = 'failed',
}
@Entity({ schema: 'catalog', name: 'product_scans' })
@Index(['sessionId', 'status'])
export class ProductScan {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'session_id' })
sessionId: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@Column({ type: 'enum', enum: ScanType })
type: ScanType;
@Column({ type: 'enum', enum: ScanStatus, default: ScanStatus.PENDING })
status: ScanStatus;
// Input data
@Column({ name: 'media_url', type: 'text', nullable: true })
mediaUrl: string;
@Column({ name: 'media_mime_type', length: 50, nullable: true })
mediaMimeType: string;
@Column({ name: 'raw_text', type: 'text', nullable: true })
rawText: string;
// OCR/Transcription results
@Column({ name: 'detected_name', length: 150, nullable: true })
detectedName: string;
@Column({ name: 'detected_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
detectedPrice: number;
@Column({ name: 'detected_barcode', length: 50, nullable: true })
detectedBarcode: string;
@Column({ name: 'confidence_score', type: 'decimal', precision: 3, scale: 2, nullable: true })
confidenceScore: number;
// Matched template
@Column({ name: 'template_id', nullable: true })
templateId: string;
@Column({ name: 'template_match_score', type: 'decimal', precision: 3, scale: 2, nullable: true })
templateMatchScore: number;
// Final product created
@Column({ name: 'product_id', nullable: true })
productId: string;
// User corrections
@Column({ name: 'user_confirmed_name', length: 150, nullable: true })
userConfirmedName: string;
@Column({ name: 'user_confirmed_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
userConfirmedPrice: number;
// Metadata
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Column({ name: 'processing_time_ms', type: 'int', nullable: true })
processingTimeMs: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => OnboardingSession, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'session_id' })
session: OnboardingSession;
}

View File

@ -0,0 +1,113 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
StartOnboardingDto,
UpdateBusinessInfoDto,
ProcessImageDto,
ProcessAudioDto,
ConfirmProductDto,
RejectProductDto,
ImportTemplatesDto,
} from './dto/onboarding.dto';
import { TemplateGiro } from '../templates/entities/product-template.entity';
@Controller('onboarding')
export class OnboardingController {
constructor(private readonly onboardingService: OnboardingService) {}
// === PUBLIC ENDPOINTS (No auth required for starting) ===
@Post('start')
async startSession(@Body() dto: StartOnboardingDto) {
return this.onboardingService.startSession(dto);
}
@Get('session/:id')
async getSession(@Param('id') id: string) {
return this.onboardingService.getSession(id);
}
@Get('session/:id/progress')
async getProgress(@Param('id') id: string) {
return this.onboardingService.getProgress(id);
}
@Get('session/phone/:phone')
async getSessionByPhone(@Param('phone') phone: string) {
return this.onboardingService.getSessionByPhone(phone);
}
@Put('session/:id/business-info')
async updateBusinessInfo(
@Param('id') id: string,
@Body() dto: UpdateBusinessInfoDto,
) {
return this.onboardingService.updateBusinessInfo(id, dto);
}
// === IMAGE PROCESSING ===
@Post('process-image')
async processImage(@Body() dto: ProcessImageDto) {
return this.onboardingService.processImage(dto);
}
// === AUDIO PROCESSING ===
@Post('process-audio')
async processAudio(@Body() dto: ProcessAudioDto) {
return this.onboardingService.processAudio(dto);
}
// === PROTECTED ENDPOINTS (Require auth) ===
@Post('confirm-product')
@UseGuards(JwtAuthGuard)
async confirmProduct(@Request() req, @Body() dto: ConfirmProductDto) {
const tenantId = req.user.tenantId;
return this.onboardingService.confirmProduct(tenantId, dto);
}
@Post('reject-product')
async rejectProduct(@Body() dto: RejectProductDto) {
return this.onboardingService.rejectProduct(dto.scanId, dto.reason);
}
@Post('import-templates')
@UseGuards(JwtAuthGuard)
async importTemplates(@Request() req, @Body() dto: ImportTemplatesDto) {
const tenantId = req.user.tenantId;
return this.onboardingService.importTemplates(tenantId, dto.sessionId, dto.giro);
}
@Post('session/:id/complete')
@UseGuards(JwtAuthGuard)
async completeOnboarding(@Request() req, @Param('id') id: string) {
const tenantId = req.user.tenantId;
return this.onboardingService.completeOnboarding(id, tenantId);
}
@Post('session/:id/abandon')
async abandonSession(@Param('id') id: string) {
return this.onboardingService.abandonSession(id);
}
// === STATS ===
@Get('stats')
@UseGuards(JwtAuthGuard)
async getStats() {
return this.onboardingService.getStats();
}
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OnboardingController } from './onboarding.controller';
import { OnboardingService } from './onboarding.service';
import { OcrService } from './services/ocr.service';
import { WhisperService } from './services/whisper.service';
import { OnboardingSession } from './entities/onboarding-session.entity';
import { ProductScan } from './entities/product-scan.entity';
import { AuthModule } from '../auth/auth.module';
import { TemplatesModule } from '../templates/templates.module';
import { ProductsModule } from '../products/products.module';
@Module({
imports: [
TypeOrmModule.forFeature([OnboardingSession, ProductScan]),
AuthModule,
TemplatesModule,
ProductsModule,
],
controllers: [OnboardingController],
providers: [OnboardingService, OcrService, WhisperService],
exports: [OnboardingService, OcrService, WhisperService],
})
export class OnboardingModule {}

View File

@ -0,0 +1,469 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OnboardingSession, OnboardingStatus, OnboardingChannel } from './entities/onboarding-session.entity';
import { ProductScan, ScanType, ScanStatus } from './entities/product-scan.entity';
import { OcrService } from './services/ocr.service';
import { WhisperService } from './services/whisper.service';
import { TemplatesService } from '../templates/templates.service';
import { ProductsService } from '../products/products.service';
import { TemplateGiro } from '../templates/entities/product-template.entity';
import {
StartOnboardingDto,
UpdateBusinessInfoDto,
ProcessImageDto,
ProcessAudioDto,
ConfirmProductDto,
OnboardingProgressDto,
OnboardingStepDto,
} from './dto/onboarding.dto';
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
constructor(
@InjectRepository(OnboardingSession)
private readonly sessionRepository: Repository<OnboardingSession>,
@InjectRepository(ProductScan)
private readonly scanRepository: Repository<ProductScan>,
private readonly ocrService: OcrService,
private readonly whisperService: WhisperService,
private readonly templatesService: TemplatesService,
private readonly productsService: ProductsService,
) {}
// === SESSION MANAGEMENT ===
async startSession(dto: StartOnboardingDto): Promise<OnboardingSession> {
// Check for existing active session
const existing = await this.sessionRepository.findOne({
where: {
phoneNumber: dto.phoneNumber,
status: OnboardingStatus.STARTED,
},
});
if (existing) {
return existing;
}
const session = this.sessionRepository.create({
phoneNumber: dto.phoneNumber,
channel: dto.channel || OnboardingChannel.WHATSAPP,
ownerName: dto.ownerName,
status: OnboardingStatus.STARTED,
currentStep: 1,
});
return this.sessionRepository.save(session);
}
async getSession(sessionId: string): Promise<OnboardingSession> {
const session = await this.sessionRepository.findOne({
where: { id: sessionId },
});
if (!session) {
throw new NotFoundException('Sesion de onboarding no encontrada');
}
return session;
}
async getSessionByPhone(phoneNumber: string): Promise<OnboardingSession | null> {
return this.sessionRepository.findOne({
where: { phoneNumber },
order: { startedAt: 'DESC' },
});
}
async updateBusinessInfo(sessionId: string, dto: UpdateBusinessInfoDto): Promise<OnboardingSession> {
const session = await this.getSession(sessionId);
if (dto.businessName) {
session.businessName = dto.businessName;
}
if (dto.giro) {
session.businessGiro = dto.giro;
session.status = OnboardingStatus.GIRO_SELECTED;
session.currentStep = 2;
}
if (dto.ownerName) {
session.ownerName = dto.ownerName;
}
session.lastMessageAt = new Date();
return this.sessionRepository.save(session);
}
async getProgress(sessionId: string): Promise<OnboardingProgressDto> {
const session = await this.getSession(sessionId);
const steps = this.getStepsForSession(session);
// Get pending scans
const pendingScans = await this.scanRepository.find({
where: {
sessionId,
status: ScanStatus.DETECTED,
},
order: { createdAt: 'DESC' },
});
// Get suggested templates based on giro
let suggestedTemplates;
if (session.businessGiro) {
const providers = await this.templatesService.getProviders();
suggestedTemplates = providers.slice(0, 4).map(p => ({
provider: p.provider,
name: p.name,
productCount: p.productCount,
}));
}
return {
session: {
id: session.id,
phoneNumber: session.phoneNumber,
channel: session.channel,
status: session.status,
currentStep: session.currentStep,
totalSteps: session.totalSteps,
businessName: session.businessName,
businessGiro: session.businessGiro,
ownerName: session.ownerName,
templatesImported: session.templatesImported,
productsAdded: session.productsAdded,
photosProcessed: session.photosProcessed,
audiosProcessed: session.audiosProcessed,
startedAt: session.startedAt,
completedAt: session.completedAt,
},
steps,
suggestedTemplates,
pendingScans: pendingScans.map(s => ({
id: s.id,
type: s.type,
status: s.status,
detectedName: s.detectedName,
detectedPrice: s.detectedPrice ? Number(s.detectedPrice) : undefined,
detectedBarcode: s.detectedBarcode,
confidenceScore: s.confidenceScore ? Number(s.confidenceScore) : undefined,
createdAt: s.createdAt,
})),
};
}
private getStepsForSession(session: OnboardingSession): OnboardingStepDto[] {
return [
{
step: 1,
name: 'Bienvenida',
description: 'Registro del numero y nombre del negocio',
completed: session.currentStep > 1 || session.businessName !== null,
data: { businessName: session.businessName, ownerName: session.ownerName },
},
{
step: 2,
name: 'Giro del negocio',
description: 'Seleccionar tipo de negocio',
completed: session.businessGiro !== null,
data: { giro: session.businessGiro },
},
{
step: 3,
name: 'Templates',
description: 'Cargar productos de proveedores comunes',
completed: session.templatesImported > 0,
data: { templatesImported: session.templatesImported },
},
{
step: 4,
name: 'Productos adicionales',
description: 'Agregar productos via foto o audio',
completed: session.productsAdded > 0,
data: {
productsAdded: session.productsAdded,
photosProcessed: session.photosProcessed,
audiosProcessed: session.audiosProcessed,
},
},
{
step: 5,
name: 'Listo!',
description: 'Negocio configurado',
completed: session.status === OnboardingStatus.COMPLETED,
},
];
}
// === IMAGE PROCESSING (OCR) ===
async processImage(dto: ProcessImageDto): Promise<ProductScan> {
const session = await this.getSession(dto.sessionId);
// Create scan record
const scan = this.scanRepository.create({
sessionId: dto.sessionId,
tenantId: session.tenantId,
type: ScanType.PHOTO,
status: ScanStatus.PROCESSING,
mediaUrl: dto.imageUrl,
mediaMimeType: dto.mimeType,
});
await this.scanRepository.save(scan);
const startTime = Date.now();
try {
// Process with OCR
const result = await this.ocrService.processImage(dto.imageUrl);
if (result.success && result.detectedProducts.length > 0) {
const firstProduct = result.detectedProducts[0];
scan.status = ScanStatus.DETECTED;
scan.rawText = result.text;
scan.detectedName = firstProduct.name;
scan.detectedPrice = firstProduct.price;
scan.detectedBarcode = firstProduct.barcode;
scan.confidenceScore = firstProduct.confidence;
// Try to match with template
if (firstProduct.barcode || firstProduct.name) {
const templates = await this.templatesService.search({
query: firstProduct.barcode || firstProduct.name,
limit: 1,
});
if (templates.length > 0) {
scan.templateId = templates[0].id;
scan.templateMatchScore = 0.8;
}
}
} else {
scan.status = ScanStatus.FAILED;
scan.errorMessage = result.error || 'No se detectaron productos';
}
scan.processingTimeMs = Date.now() - startTime;
await this.scanRepository.save(scan);
// Update session
session.photosProcessed += 1;
session.lastMessageAt = new Date();
await this.sessionRepository.save(session);
return scan;
} catch (error) {
scan.status = ScanStatus.FAILED;
scan.errorMessage = error.message;
scan.processingTimeMs = Date.now() - startTime;
await this.scanRepository.save(scan);
throw error;
}
}
// === AUDIO PROCESSING (WHISPER) ===
async processAudio(dto: ProcessAudioDto): Promise<ProductScan[]> {
const session = await this.getSession(dto.sessionId);
const startTime = Date.now();
try {
// Process with Whisper
const result = await this.whisperService.transcribeAudio(dto.audioUrl);
const scans: ProductScan[] = [];
if (result.success && result.detectedPrices.length > 0) {
for (const detected of result.detectedPrices) {
const scan = this.scanRepository.create({
sessionId: dto.sessionId,
tenantId: session.tenantId,
type: ScanType.AUDIO,
status: ScanStatus.DETECTED,
mediaUrl: dto.audioUrl,
mediaMimeType: dto.mimeType,
rawText: result.text,
detectedName: detected.productName,
detectedPrice: detected.price,
confidenceScore: detected.confidence,
processingTimeMs: Date.now() - startTime,
});
// Try to match with template
const templates = await this.templatesService.search({
query: detected.productName,
limit: 1,
});
if (templates.length > 0) {
scan.templateId = templates[0].id;
scan.templateMatchScore = 0.7;
}
await this.scanRepository.save(scan);
scans.push(scan);
}
} else {
// Create failed scan record
const scan = this.scanRepository.create({
sessionId: dto.sessionId,
tenantId: session.tenantId,
type: ScanType.AUDIO,
status: ScanStatus.FAILED,
mediaUrl: dto.audioUrl,
rawText: result.text,
errorMessage: result.error || 'No se detectaron precios',
processingTimeMs: Date.now() - startTime,
});
await this.scanRepository.save(scan);
scans.push(scan);
}
// Update session
session.audiosProcessed += 1;
session.lastMessageAt = new Date();
await this.sessionRepository.save(session);
return scans;
} catch (error) {
this.logger.error(`Audio processing failed: ${error.message}`);
throw error;
}
}
// === PRODUCT CONFIRMATION ===
async confirmProduct(tenantId: string, dto: ConfirmProductDto): Promise<any> {
const scan = await this.scanRepository.findOne({
where: { id: dto.scanId },
});
if (!scan) {
throw new NotFoundException('Scan no encontrado');
}
// Create product in catalog
const product = await this.productsService.create(tenantId, {
name: dto.name,
price: dto.price,
barcode: dto.barcode,
sku: `ONB-${Date.now()}`,
});
// Update scan
scan.status = ScanStatus.CONFIRMED;
scan.userConfirmedName = dto.name;
scan.userConfirmedPrice = dto.price;
scan.productId = product.id;
await this.scanRepository.save(scan);
// Update session
const session = await this.getSession(scan.sessionId);
session.productsAdded += 1;
// Check if should advance status
if (session.status === OnboardingStatus.TEMPLATES_LOADED && session.productsAdded >= 1) {
session.status = OnboardingStatus.PRODUCTS_ADDED;
session.currentStep = 4;
}
await this.sessionRepository.save(session);
return product;
}
async rejectProduct(scanId: string, reason?: string): Promise<ProductScan> {
const scan = await this.scanRepository.findOne({
where: { id: scanId },
});
if (!scan) {
throw new NotFoundException('Scan no encontrado');
}
scan.status = ScanStatus.REJECTED;
scan.metadata = { ...scan.metadata, rejectionReason: reason };
return this.scanRepository.save(scan);
}
// === TEMPLATE IMPORT ===
async importTemplates(tenantId: string, sessionId: string, giro?: TemplateGiro): Promise<{ imported: number }> {
const session = await this.getSession(sessionId);
const result = await this.templatesService.importTemplates(tenantId, {
giro: giro || (session.businessGiro as TemplateGiro),
skipDuplicates: true,
});
// Update session
session.templatesImported += result.imported;
session.status = OnboardingStatus.TEMPLATES_LOADED;
session.currentStep = 3;
await this.sessionRepository.save(session);
return { imported: result.imported };
}
// === COMPLETE ONBOARDING ===
async completeOnboarding(sessionId: string, tenantId: string): Promise<OnboardingSession> {
const session = await this.getSession(sessionId);
session.tenantId = tenantId;
session.status = OnboardingStatus.COMPLETED;
session.currentStep = 5;
session.completedAt = new Date();
return this.sessionRepository.save(session);
}
async abandonSession(sessionId: string): Promise<OnboardingSession> {
const session = await this.getSession(sessionId);
session.status = OnboardingStatus.ABANDONED;
return this.sessionRepository.save(session);
}
// === STATS ===
async getStats(): Promise<{
totalSessions: number;
completedSessions: number;
abandonedSessions: number;
averageProductsPerSession: number;
}> {
const total = await this.sessionRepository.count();
const completed = await this.sessionRepository.count({
where: { status: OnboardingStatus.COMPLETED },
});
const abandoned = await this.sessionRepository.count({
where: { status: OnboardingStatus.ABANDONED },
});
const avgProducts = await this.sessionRepository
.createQueryBuilder('session')
.select('AVG(session.productsAdded)', 'avg')
.where('session.status = :status', { status: OnboardingStatus.COMPLETED })
.getRawOne();
return {
totalSessions: total,
completedSessions: completed,
abandonedSessions: abandoned,
averageProductsPerSession: parseFloat(avgProducts?.avg) || 0,
};
}
}

View File

@ -0,0 +1,170 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface OcrResult {
success: boolean;
text: string;
detectedProducts: Array<{
name: string;
price?: number;
barcode?: string;
confidence: number;
}>;
rawResponse?: any;
error?: string;
}
@Injectable()
export class OcrService {
private readonly logger = new Logger(OcrService.name);
private readonly apiKey: string;
private readonly provider: string;
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get('GOOGLE_VISION_API_KEY', '');
this.provider = this.configService.get('OCR_PROVIDER', 'google');
}
async processImage(imageUrl: string): Promise<OcrResult> {
const startTime = Date.now();
try {
if (!this.apiKey) {
this.logger.warn('OCR API key not configured, using mock response');
return this.getMockResponse(imageUrl);
}
// For Google Vision API
if (this.provider === 'google') {
return await this.processWithGoogleVision(imageUrl);
}
// Fallback to mock
return this.getMockResponse(imageUrl);
} catch (error) {
this.logger.error(`OCR processing failed: ${error.message}`);
return {
success: false,
text: '',
detectedProducts: [],
error: error.message,
};
}
}
private async processWithGoogleVision(imageUrl: string): Promise<OcrResult> {
// Google Vision API integration
const endpoint = `https://vision.googleapis.com/v1/images:annotate?key=${this.apiKey}`;
const requestBody = {
requests: [
{
image: { source: { imageUri: imageUrl } },
features: [
{ type: 'TEXT_DETECTION' },
{ type: 'PRODUCT_SEARCH' },
],
},
],
};
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Google Vision API error: ${response.statusText}`);
}
const data = await response.json();
const textAnnotations = data.responses?.[0]?.textAnnotations || [];
const fullText = textAnnotations[0]?.description || '';
// Parse detected text for products and prices
const products = this.parseProductsFromText(fullText);
return {
success: true,
text: fullText,
detectedProducts: products,
rawResponse: data,
};
} catch (error) {
this.logger.error(`Google Vision API error: ${error.message}`);
// Fallback to mock on error
return this.getMockResponse(imageUrl);
}
}
private parseProductsFromText(text: string): Array<{ name: string; price?: number; barcode?: string; confidence: number }> {
const products: Array<{ name: string; price?: number; barcode?: string; confidence: number }> = [];
// Price pattern: $XX.XX, XX.XX, $XX
const pricePattern = /\$?\s*(\d{1,4}(?:\.\d{2})?)/g;
// Barcode pattern: 13 or 12 digits
const barcodePattern = /\b(\d{12,13})\b/g;
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
const priceMatch = line.match(pricePattern);
const barcodeMatch = line.match(barcodePattern);
if (priceMatch) {
// Extract product name (text before price)
const priceIndex = line.search(pricePattern);
const name = line.substring(0, priceIndex).trim();
const priceStr = priceMatch[0].replace(/[$\s]/g, '');
const price = parseFloat(priceStr);
if (name.length > 2 && price > 0 && price < 10000) {
products.push({
name: name.substring(0, 150),
price,
barcode: barcodeMatch?.[0],
confidence: 0.7,
});
}
}
}
return products;
}
private getMockResponse(imageUrl: string): OcrResult {
// Mock response for testing without API key
return {
success: true,
text: 'Coca-Cola 600ml $18.00\nSabritas Original $15.00\n7501055300000',
detectedProducts: [
{ name: 'Coca-Cola 600ml', price: 18.00, barcode: '7501055300000', confidence: 0.85 },
{ name: 'Sabritas Original', price: 15.00, confidence: 0.75 },
],
};
}
async processBarcode(imageUrl: string): Promise<{ barcode: string; confidence: number } | null> {
try {
const result = await this.processImage(imageUrl);
// Look for barcode in detected text
const barcodePattern = /\b(\d{12,13})\b/;
const match = result.text.match(barcodePattern);
if (match) {
return {
barcode: match[1],
confidence: 0.9,
};
}
return null;
} catch (error) {
this.logger.error(`Barcode detection failed: ${error.message}`);
return null;
}
}
}

View File

@ -0,0 +1,202 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface TranscriptionResult {
success: boolean;
text: string;
language?: string;
duration?: number;
detectedPrices: Array<{
productName: string;
price: number;
confidence: number;
}>;
rawResponse?: any;
error?: string;
}
@Injectable()
export class WhisperService {
private readonly logger = new Logger(WhisperService.name);
private readonly apiKey: string;
private readonly provider: string;
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get('OPENAI_API_KEY', '');
this.provider = this.configService.get('WHISPER_PROVIDER', 'openai');
}
async transcribeAudio(audioUrl: string): Promise<TranscriptionResult> {
try {
if (!this.apiKey) {
this.logger.warn('Whisper API key not configured, using mock response');
return this.getMockResponse();
}
if (this.provider === 'openai') {
return await this.transcribeWithOpenAI(audioUrl);
}
return this.getMockResponse();
} catch (error) {
this.logger.error(`Transcription failed: ${error.message}`);
return {
success: false,
text: '',
detectedPrices: [],
error: error.message,
};
}
}
private async transcribeWithOpenAI(audioUrl: string): Promise<TranscriptionResult> {
try {
// Download audio file
const audioResponse = await fetch(audioUrl);
if (!audioResponse.ok) {
throw new Error('Failed to download audio');
}
const audioBuffer = await audioResponse.arrayBuffer();
const audioBlob = new Blob([audioBuffer], { type: 'audio/ogg' });
// Create form data for Whisper API
const formData = new FormData();
formData.append('file', audioBlob, 'audio.ogg');
formData.append('model', 'whisper-1');
formData.append('language', 'es');
formData.append('response_format', 'verbose_json');
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.statusText}`);
}
const data = await response.json();
const text = data.text || '';
// Parse prices from transcription
const prices = this.parsePricesFromText(text);
return {
success: true,
text,
language: data.language || 'es',
duration: data.duration,
detectedPrices: prices,
rawResponse: data,
};
} catch (error) {
this.logger.error(`OpenAI Whisper error: ${error.message}`);
return this.getMockResponse();
}
}
private parsePricesFromText(text: string): Array<{ productName: string; price: number; confidence: number }> {
const prices: Array<{ productName: string; price: number; confidence: number }> = [];
// Common patterns in Spanish for price statements
// "sabritas a 15", "coca a 18 pesos", "el refresco cuesta 20"
const patterns = [
// "producto a X pesos"
/(\w+(?:\s+\w+)?)\s+a\s+(\d+(?:\.\d+)?)\s*(?:pesos?)?/gi,
// "producto X pesos"
/(\w+(?:\s+\w+)?)\s+(\d+(?:\.\d+)?)\s*pesos?/gi,
// "producto cuesta X"
/(\w+(?:\s+\w+)?)\s+(?:cuesta|vale)\s+(\d+(?:\.\d+)?)/gi,
// "X pesos el producto"
/(\d+(?:\.\d+)?)\s*pesos?\s+(?:el|la|los|las)?\s*(\w+(?:\s+\w+)?)/gi,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(text)) !== null) {
let productName: string;
let priceStr: string;
// Handle different capture group orders
if (isNaN(parseFloat(match[1]))) {
productName = match[1];
priceStr = match[2];
} else {
priceStr = match[1];
productName = match[2];
}
const price = parseFloat(priceStr);
if (productName && price > 0 && price < 10000) {
// Avoid duplicates
const exists = prices.some(p =>
p.productName.toLowerCase() === productName.toLowerCase() &&
p.price === price
);
if (!exists) {
prices.push({
productName: productName.trim(),
price,
confidence: 0.7,
});
}
}
}
}
return prices;
}
private getMockResponse(): TranscriptionResult {
return {
success: true,
text: 'Las Sabritas a quince pesos, la Coca a dieciocho, y el pan Bimbo a treinta y cinco',
language: 'es',
duration: 5.2,
detectedPrices: [
{ productName: 'Sabritas', price: 15, confidence: 0.8 },
{ productName: 'Coca', price: 18, confidence: 0.8 },
{ productName: 'pan Bimbo', price: 35, confidence: 0.75 },
],
};
}
// Parse numbers in Spanish text
private parseSpanishNumber(text: string): number | null {
const numberWords: Record<string, number> = {
'cero': 0, 'uno': 1, 'una': 1, 'dos': 2, 'tres': 3, 'cuatro': 4,
'cinco': 5, 'seis': 6, 'siete': 7, 'ocho': 8, 'nueve': 9,
'diez': 10, 'once': 11, 'doce': 12, 'trece': 13, 'catorce': 14,
'quince': 15, 'dieciseis': 16, 'diecisiete': 17, 'dieciocho': 18,
'diecinueve': 19, 'veinte': 20, 'veintiuno': 21, 'veintidos': 22,
'veintitres': 23, 'veinticuatro': 24, 'veinticinco': 25,
'treinta': 30, 'cuarenta': 40, 'cincuenta': 50, 'sesenta': 60,
'setenta': 70, 'ochenta': 80, 'noventa': 90, 'cien': 100,
};
const normalized = text.toLowerCase().trim();
// Direct number word
if (numberWords[normalized] !== undefined) {
return numberWords[normalized];
}
// Compound numbers like "treinta y cinco"
const compoundMatch = normalized.match(/(\w+)\s+y\s+(\w+)/);
if (compoundMatch) {
const tens = numberWords[compoundMatch[1]];
const units = numberWords[compoundMatch[2]];
if (tens !== undefined && units !== undefined) {
return tens + units;
}
}
return null;
}
}