[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:
parent
0019ded690
commit
29d68ec9b7
@ -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 {}
|
||||
|
||||
149
src/modules/onboarding/dto/onboarding.dto.ts
Normal file
149
src/modules/onboarding/dto/onboarding.dto.ts
Normal 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[];
|
||||
}
|
||||
89
src/modules/onboarding/entities/onboarding-session.entity.ts
Normal file
89
src/modules/onboarding/entities/onboarding-session.entity.ts
Normal 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;
|
||||
}
|
||||
103
src/modules/onboarding/entities/product-scan.entity.ts
Normal file
103
src/modules/onboarding/entities/product-scan.entity.ts
Normal 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;
|
||||
}
|
||||
113
src/modules/onboarding/onboarding.controller.ts
Normal file
113
src/modules/onboarding/onboarding.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
24
src/modules/onboarding/onboarding.module.ts
Normal file
24
src/modules/onboarding/onboarding.module.ts
Normal 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 {}
|
||||
469
src/modules/onboarding/onboarding.service.ts
Normal file
469
src/modules/onboarding/onboarding.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
170
src/modules/onboarding/services/ocr.service.ts
Normal file
170
src/modules/onboarding/services/ocr.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/modules/onboarding/services/whisper.service.ts
Normal file
202
src/modules/onboarding/services/whisper.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user