feat(llm): Implement role-based chat for owner and customer
- Add UserRole type and role detection via getUserRole() - Add OWNER_TOOLS and CUSTOMER_TOOLS permission arrays - Implement role-based system prompts (owner vs customer) - Add owner-specific functions: sales_summary, inventory_status, pending_fiados, etc - Add getBusinessInfo() for tenant business information - Add isToolAllowed() for permission validation - Update webhook service with role-aware message handling Sprint 2: MCH-012 Chat LLM Dueño + MCH-013 Chat LLM Cliente Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
44bcabd064
commit
9a8f0cb873
@ -23,6 +23,75 @@ export interface LLMConfig {
|
|||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'owner' | 'customer';
|
||||||
|
|
||||||
|
export interface UserRoleInfo {
|
||||||
|
role: UserRole;
|
||||||
|
userId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessInfo {
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
hours?: {
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
days: string;
|
||||||
|
};
|
||||||
|
promotions?: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
discount?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools disponibles por rol
|
||||||
|
export const OWNER_TOOLS = [
|
||||||
|
'search_products',
|
||||||
|
'get_product',
|
||||||
|
'list_products',
|
||||||
|
'get_product_by_barcode',
|
||||||
|
'get_low_stock_products',
|
||||||
|
'update_product_price',
|
||||||
|
'create_order',
|
||||||
|
'get_order',
|
||||||
|
'list_orders',
|
||||||
|
'update_order_status',
|
||||||
|
'create_fiado',
|
||||||
|
'get_customer_fiados',
|
||||||
|
'pay_fiado',
|
||||||
|
'get_pending_fiados',
|
||||||
|
'search_customers',
|
||||||
|
'get_customer',
|
||||||
|
'create_customer',
|
||||||
|
'get_inventory_status',
|
||||||
|
'adjust_stock',
|
||||||
|
'get_stock_movements',
|
||||||
|
'get_expiring_products',
|
||||||
|
'get_daily_sales',
|
||||||
|
'get_sales_report',
|
||||||
|
'register_sale',
|
||||||
|
'get_today_summary',
|
||||||
|
'send_payment_reminder',
|
||||||
|
'get_business_metrics',
|
||||||
|
'get_top_products',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CUSTOMER_TOOLS = [
|
||||||
|
'search_products',
|
||||||
|
'get_product',
|
||||||
|
'list_products',
|
||||||
|
'check_availability',
|
||||||
|
'get_product_price',
|
||||||
|
'create_order',
|
||||||
|
'get_my_balance',
|
||||||
|
'get_business_info',
|
||||||
|
'get_promotions',
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CredentialsProviderService {
|
export class CredentialsProviderService {
|
||||||
private readonly logger = new Logger(CredentialsProviderService.name);
|
private readonly logger = new Logger(CredentialsProviderService.name);
|
||||||
@ -220,6 +289,100 @@ export class CredentialsProviderService {
|
|||||||
this.cache.delete(`llm:${tenantId}`);
|
this.cache.delete(`llm:${tenantId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina el rol del usuario basado en su número de teléfono
|
||||||
|
* Los dueños están registrados en la tabla de usuarios del tenant
|
||||||
|
*/
|
||||||
|
async getUserRole(phoneNumber: string, tenantId?: string): Promise<UserRoleInfo> {
|
||||||
|
const cacheKey = `role:${phoneNumber}:${tenantId || 'platform'}`;
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await this.backendClient.get(
|
||||||
|
`/internal/users/role-by-phone/${phoneNumber}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
|
||||||
|
...(tenantId && { 'X-Tenant-Id': tenantId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleInfo: UserRoleInfo = {
|
||||||
|
role: data.isOwner ? 'owner' : 'customer',
|
||||||
|
userId: data.userId,
|
||||||
|
customerId: data.customerId,
|
||||||
|
permissions: data.isOwner ? OWNER_TOOLS : CUSTOMER_TOOLS,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setCache(cacheKey, roleInfo);
|
||||||
|
return roleInfo;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`Could not determine role for ${phoneNumber}, defaulting to customer`);
|
||||||
|
// Default to customer role
|
||||||
|
return {
|
||||||
|
role: 'customer',
|
||||||
|
permissions: CUSTOMER_TOOLS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene información del negocio para mostrar a clientes
|
||||||
|
*/
|
||||||
|
async getBusinessInfo(tenantId?: string): Promise<BusinessInfo> {
|
||||||
|
const cacheKey = `business:${tenantId || 'platform'}`;
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await this.backendClient.get(
|
||||||
|
`/internal/business/info`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Internal-Key': this.configService.get('INTERNAL_API_KEY'),
|
||||||
|
...(tenantId && { 'X-Tenant-Id': tenantId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const info: BusinessInfo = {
|
||||||
|
name: data.name || 'MiChangarrito',
|
||||||
|
address: data.address,
|
||||||
|
phone: data.phone,
|
||||||
|
hours: data.hours,
|
||||||
|
promotions: data.promotions || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setCache(cacheKey, info);
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
// Return default info
|
||||||
|
return {
|
||||||
|
name: 'MiChangarrito',
|
||||||
|
hours: {
|
||||||
|
open: '07:00',
|
||||||
|
close: '22:00',
|
||||||
|
days: 'Lunes a Domingo',
|
||||||
|
},
|
||||||
|
promotions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un tool está permitido para un rol
|
||||||
|
*/
|
||||||
|
isToolAllowed(toolName: string, role: UserRole): boolean {
|
||||||
|
const allowedTools = role === 'owner' ? OWNER_TOOLS : CUSTOMER_TOOLS;
|
||||||
|
return allowedTools.includes(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
private getFromCache(key: string): any | null {
|
private getFromCache(key: string): any | null {
|
||||||
const entry = this.cache.get(key);
|
const entry = this.cache.get(key);
|
||||||
if (entry && entry.expiresAt > Date.now()) {
|
if (entry && entry.expiresAt > Date.now()) {
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import { CredentialsProviderService, LLMConfig } from '../common/credentials-provider.service';
|
import {
|
||||||
|
CredentialsProviderService,
|
||||||
|
LLMConfig,
|
||||||
|
UserRole,
|
||||||
|
} from '../common/credentials-provider.service';
|
||||||
|
|
||||||
interface ConversationContext {
|
interface ConversationContext {
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
userId?: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
lastActivity: Date;
|
lastActivity: Date;
|
||||||
@ -13,6 +18,8 @@ interface ConversationContext {
|
|||||||
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
businessName?: string;
|
businessName?: string;
|
||||||
|
role: UserRole;
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LlmResponse {
|
interface LlmResponse {
|
||||||
@ -97,8 +104,8 @@ export class LlmService {
|
|||||||
history = [history[0], ...history.slice(-20)];
|
history = [history[0], ...history.slice(-20)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call LLM with tenant config
|
// Call LLM with tenant config and user role
|
||||||
const response = await this.callLlm(history, llmConfig);
|
const response = await this.callLlm(history, llmConfig, context.role);
|
||||||
|
|
||||||
// Add assistant response to history
|
// Add assistant response to history
|
||||||
history.push({ role: 'assistant', content: response.message });
|
history.push({ role: 'assistant', content: response.message });
|
||||||
@ -111,8 +118,8 @@ export class LlmService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callLlm(messages: ChatMessage[], config: LLMConfig): Promise<LlmResponse> {
|
private async callLlm(messages: ChatMessage[], config: LLMConfig, role: UserRole = 'customer'): Promise<LlmResponse> {
|
||||||
const functions = this.getAvailableFunctions();
|
const functions = this.getAvailableFunctions(role);
|
||||||
const client = this.getClient(config);
|
const client = this.getClient(config);
|
||||||
|
|
||||||
const requestBody: any = {
|
const requestBody: any = {
|
||||||
@ -163,41 +170,80 @@ export class LlmService {
|
|||||||
|
|
||||||
private getSystemPrompt(context: ConversationContext): string {
|
private getSystemPrompt(context: ConversationContext): string {
|
||||||
const businessName = context.businessName || 'MiChangarrito';
|
const businessName = context.businessName || 'MiChangarrito';
|
||||||
const businessDescription = context.businessName
|
|
||||||
? `un negocio local`
|
|
||||||
: `una tiendita de barrio en Mexico`;
|
|
||||||
|
|
||||||
return `Eres el asistente virtual de ${businessName}, ${businessDescription}.
|
if (context.role === 'owner') {
|
||||||
|
return this.getOwnerSystemPrompt(businessName, context);
|
||||||
|
} else {
|
||||||
|
return this.getCustomerSystemPrompt(businessName, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOwnerSystemPrompt(businessName: string, context: ConversationContext): string {
|
||||||
|
return `Eres el asistente virtual de ${businessName} para el DUEÑO del negocio.
|
||||||
|
Tu nombre es "Asistente de ${businessName}" y ayudas al dueño con:
|
||||||
|
- Consultar ventas del dia/semana/mes
|
||||||
|
- Revisar inventario y productos con stock bajo
|
||||||
|
- Ver fiados pendientes y enviar recordatorios
|
||||||
|
- Generar reportes de negocio
|
||||||
|
- Modificar precios de productos
|
||||||
|
- Obtener metricas y sugerencias
|
||||||
|
|
||||||
|
Informacion del dueño:
|
||||||
|
- Nombre: ${context.customerName}
|
||||||
|
- Rol: DUEÑO/ADMINISTRADOR
|
||||||
|
- Acceso: COMPLETO a todas las funciones
|
||||||
|
|
||||||
|
Reglas importantes:
|
||||||
|
1. Responde siempre en español mexicano, de forma profesional pero amigable
|
||||||
|
2. Usa emojis con moderación para datos importantes
|
||||||
|
3. Siempre muestra numeros formateados (ej: $1,234.56)
|
||||||
|
4. Para consultas de ventas, incluye comparativas (vs ayer, vs semana pasada)
|
||||||
|
5. Para inventario, prioriza productos con stock bajo o proximos a vencer
|
||||||
|
6. Para fiados, destaca clientes con atrasos mayores a 7 dias
|
||||||
|
7. Se proactivo sugiriendo acciones basadas en los datos
|
||||||
|
8. Tienes acceso a modificar precios - siempre pide confirmacion primero
|
||||||
|
|
||||||
|
Ejemplos de respuestas:
|
||||||
|
- "Hoy llevas $3,450 en 23 ventas, +15% vs ayer 📈"
|
||||||
|
- "Tienes 5 productos con stock bajo, ¿quieres ver la lista?"
|
||||||
|
- "3 clientes deben mas de $500, ¿envio recordatorios?"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCustomerSystemPrompt(businessName: string, context: ConversationContext): string {
|
||||||
|
return `Eres el asistente virtual de ${businessName} para CLIENTES.
|
||||||
Tu nombre es "Asistente de ${businessName}" y ayudas a los clientes con:
|
Tu nombre es "Asistente de ${businessName}" y ayudas a los clientes con:
|
||||||
- Informacion sobre productos y precios
|
- Informacion sobre productos y precios
|
||||||
- Hacer pedidos
|
- Hacer pedidos
|
||||||
- Consultar su cuenta de fiado (credito)
|
- Consultar SU cuenta de fiado (solo la suya)
|
||||||
- Estado de sus pedidos
|
- Estado de SUS pedidos
|
||||||
|
|
||||||
Informacion del cliente:
|
Informacion del cliente:
|
||||||
- Nombre: ${context.customerName}
|
- Nombre: ${context.customerName}
|
||||||
|
- Rol: CLIENTE
|
||||||
- Tiene carrito con ${context.cart?.length || 0} productos
|
- Tiene carrito con ${context.cart?.length || 0} productos
|
||||||
|
|
||||||
Reglas importantes:
|
Reglas importantes:
|
||||||
1. Responde siempre en espanol mexicano, de forma amigable y breve
|
1. Responde siempre en español mexicano, de forma amigable y breve
|
||||||
2. Usa emojis ocasionalmente para ser mas amigable
|
2. Usa emojis ocasionalmente para ser mas amigable 😊
|
||||||
3. Si el cliente quiere hacer algo especifico, usa las funciones disponibles
|
3. NUNCA muestres informacion de otros clientes
|
||||||
4. Si no entiendes algo, pide aclaracion de forma amable
|
4. NUNCA muestres datos de ventas, inventario o metricas del negocio
|
||||||
5. Nunca inventes precios o productos, di que consultaras el catalogo
|
5. Solo puedes mostrar productos, precios, y la cuenta de ESTE cliente
|
||||||
6. Para fiados, siempre verifica primero el saldo disponible
|
6. Si preguntan algo que no puedes responder, sugiere contactar al dueño
|
||||||
7. Se proactivo sugiriendo opciones relevantes
|
7. Para fiados, solo muestra el saldo de ESTE cliente
|
||||||
|
8. Se proactivo sugiriendo productos relevantes
|
||||||
|
|
||||||
Ejemplos de respuestas:
|
Ejemplos de respuestas:
|
||||||
- "Claro! Te muestro el menu de productos"
|
- "¡Claro! Te muestro el menu de productos"
|
||||||
- "Perfecto, agrego eso a tu carrito"
|
- "Perfecto, agrego eso a tu carrito 🛒"
|
||||||
- "Dejame revisar tu cuenta de fiado..."`;
|
- "Tu saldo pendiente es de $180"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAvailableFunctions(): any[] {
|
private getAvailableFunctions(role: UserRole): any[] {
|
||||||
return [
|
// Base functions available to all users
|
||||||
|
const baseFunctions = [
|
||||||
{
|
{
|
||||||
name: 'show_menu',
|
name: 'show_menu',
|
||||||
description: 'Muestra el menu principal de opciones al cliente',
|
description: 'Muestra el menu principal de opciones',
|
||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -215,12 +261,12 @@ Ejemplos de respuestas:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'show_fiado',
|
name: 'show_fiado',
|
||||||
description: 'Muestra informacion de la cuenta de fiado del cliente',
|
description: 'Muestra informacion de la cuenta de fiado',
|
||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'add_to_cart',
|
name: 'add_to_cart',
|
||||||
description: 'Agrega un producto al carrito del cliente',
|
description: 'Agrega un producto al carrito',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -239,10 +285,72 @@ Ejemplos de respuestas:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'check_order_status',
|
name: 'check_order_status',
|
||||||
description: 'Consulta el estado de los pedidos del cliente',
|
description: 'Consulta el estado de pedidos',
|
||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Owner-specific functions (MCH-012)
|
||||||
|
const ownerFunctions = [
|
||||||
|
{
|
||||||
|
name: 'show_sales_summary',
|
||||||
|
description: 'Muestra resumen de ventas del dia/semana/mes',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
period: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['today', 'week', 'month'],
|
||||||
|
description: 'Periodo de tiempo (hoy, semana, mes)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'show_inventory_status',
|
||||||
|
description: 'Muestra estado del inventario, productos con stock bajo y proximos a vencer',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'show_pending_fiados',
|
||||||
|
description: 'Muestra todos los fiados pendientes de cobro',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update_product_price',
|
||||||
|
description: 'Actualiza el precio de un producto',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
product_name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nombre del producto',
|
||||||
|
},
|
||||||
|
new_price: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Nuevo precio',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['product_name', 'new_price'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'send_payment_reminders',
|
||||||
|
description: 'Envia recordatorios de pago a clientes con fiados atrasados',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_business_metrics',
|
||||||
|
description: 'Obtiene metricas del negocio: margen, cliente top, producto top',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (role === 'owner') {
|
||||||
|
return [...baseFunctions, ...ownerFunctions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseFunctions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActionMessage(action: string): string {
|
private getActionMessage(action: string): string {
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { WhatsAppService } from '../whatsapp/whatsapp.service';
|
import { WhatsAppService } from '../whatsapp/whatsapp.service';
|
||||||
import { LlmService } from '../llm/llm.service';
|
import { LlmService } from '../llm/llm.service';
|
||||||
import { CredentialsProviderService } from '../common/credentials-provider.service';
|
import {
|
||||||
|
CredentialsProviderService,
|
||||||
|
UserRole,
|
||||||
|
UserRoleInfo,
|
||||||
|
} from '../common/credentials-provider.service';
|
||||||
import {
|
import {
|
||||||
WebhookIncomingMessage,
|
WebhookIncomingMessage,
|
||||||
WebhookContact,
|
WebhookContact,
|
||||||
@ -11,6 +15,7 @@ import {
|
|||||||
|
|
||||||
interface ConversationContext {
|
interface ConversationContext {
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
userId?: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
lastActivity: Date;
|
lastActivity: Date;
|
||||||
@ -19,6 +24,9 @@ interface ConversationContext {
|
|||||||
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
cart?: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
businessName?: string;
|
businessName?: string;
|
||||||
|
// Role-based access control
|
||||||
|
role: UserRole;
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -61,7 +69,14 @@ export class WebhookService {
|
|||||||
const tenantId = await this.credentialsProvider.resolveTenantFromPhoneNumberId(phoneNumberId);
|
const tenantId = await this.credentialsProvider.resolveTenantFromPhoneNumberId(phoneNumberId);
|
||||||
const source = tenantId ? `tenant:${tenantId}` : 'platform';
|
const source = tenantId ? `tenant:${tenantId}` : 'platform';
|
||||||
|
|
||||||
this.logger.log(`Processing message from ${customerName} (${phoneNumber}) via ${source}: ${message.type}`);
|
// Determine user role (owner vs customer)
|
||||||
|
const roleInfo = await this.credentialsProvider.getUserRole(phoneNumber, tenantId);
|
||||||
|
const roleLabel = roleInfo.role === 'owner' ? 'OWNER' : 'CUSTOMER';
|
||||||
|
|
||||||
|
this.logger.log(`Processing message from ${customerName} (${phoneNumber}) [${roleLabel}] via ${source}: ${message.type}`);
|
||||||
|
|
||||||
|
// Get business info for context
|
||||||
|
const businessInfo = await this.credentialsProvider.getBusinessInfo(tenantId);
|
||||||
|
|
||||||
// Get or create conversation context
|
// Get or create conversation context
|
||||||
let context = this.conversations.get(phoneNumber);
|
let context = this.conversations.get(phoneNumber);
|
||||||
@ -72,14 +87,20 @@ export class WebhookService {
|
|||||||
lastActivity: new Date(),
|
lastActivity: new Date(),
|
||||||
cart: [],
|
cart: [],
|
||||||
tenantId,
|
tenantId,
|
||||||
// TODO: Fetch business name from tenant if available
|
businessName: businessInfo.name,
|
||||||
businessName: tenantId ? undefined : 'MiChangarrito',
|
role: roleInfo.role,
|
||||||
|
permissions: roleInfo.permissions,
|
||||||
|
userId: roleInfo.userId,
|
||||||
|
customerId: roleInfo.customerId,
|
||||||
};
|
};
|
||||||
this.conversations.set(phoneNumber, context);
|
this.conversations.set(phoneNumber, context);
|
||||||
}
|
}
|
||||||
context.lastActivity = new Date();
|
context.lastActivity = new Date();
|
||||||
// Update tenantId in case it changed (shouldn't normally happen)
|
// Update tenantId and role in case they changed
|
||||||
context.tenantId = tenantId;
|
context.tenantId = tenantId;
|
||||||
|
context.role = roleInfo.role;
|
||||||
|
context.permissions = roleInfo.permissions;
|
||||||
|
context.businessName = businessInfo.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
@ -230,9 +251,10 @@ export class WebhookService {
|
|||||||
buttonId: string,
|
buttonId: string,
|
||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`Button response: ${buttonId}`);
|
this.logger.log(`Button response: ${buttonId} [role: ${context.role}]`);
|
||||||
|
|
||||||
switch (buttonId) {
|
switch (buttonId) {
|
||||||
|
// Customer buttons
|
||||||
case 'menu_products':
|
case 'menu_products':
|
||||||
await this.sendProductCategories(phoneNumber, context);
|
await this.sendProductCategories(phoneNumber, context);
|
||||||
break;
|
break;
|
||||||
@ -261,6 +283,48 @@ export class WebhookService {
|
|||||||
await this.cancelOrder(phoneNumber, context);
|
await this.cancelOrder(phoneNumber, context);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Owner-specific buttons (MCH-012)
|
||||||
|
case 'owner_sales':
|
||||||
|
if (context.role !== 'owner') {
|
||||||
|
await this.whatsAppService.sendTextMessage(
|
||||||
|
phoneNumber,
|
||||||
|
'Esta opción solo está disponible para el dueño del negocio.',
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendOwnerSalesSummary(phoneNumber, context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'owner_inventory':
|
||||||
|
if (context.role !== 'owner') {
|
||||||
|
await this.whatsAppService.sendTextMessage(
|
||||||
|
phoneNumber,
|
||||||
|
'Esta opción solo está disponible para el dueño del negocio.',
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendOwnerInventoryStatus(phoneNumber, context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'owner_fiados':
|
||||||
|
if (context.role !== 'owner') {
|
||||||
|
await this.whatsAppService.sendTextMessage(
|
||||||
|
phoneNumber,
|
||||||
|
'Esta opción solo está disponible para el dueño del negocio.',
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendOwnerFiadosSummary(phoneNumber, context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'owner_send_reminders':
|
||||||
|
if (context.role !== 'owner') return;
|
||||||
|
await this.sendPaymentReminders(phoneNumber, context);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
await this.whatsAppService.sendTextMessage(
|
await this.whatsAppService.sendTextMessage(
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
@ -295,28 +359,57 @@ export class WebhookService {
|
|||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const businessName = context.businessName || 'MiChangarrito';
|
const businessName = context.businessName || 'MiChangarrito';
|
||||||
const message = `Hola ${customerName}! Bienvenido a ${businessName}.
|
|
||||||
|
if (context.role === 'owner') {
|
||||||
|
// Welcome message for business owner
|
||||||
|
const message = `Hola ${customerName}! 👋
|
||||||
|
|
||||||
|
Soy tu asistente de ${businessName}. Como dueño, puedo ayudarte con:
|
||||||
|
- 📊 Consultar ventas del día
|
||||||
|
- 📦 Revisar inventario y stock bajo
|
||||||
|
- 💰 Ver fiados pendientes
|
||||||
|
- 📈 Generar reportes
|
||||||
|
- ⚙️ Modificar precios
|
||||||
|
|
||||||
|
¿Qué necesitas hoy?`;
|
||||||
|
|
||||||
|
await this.whatsAppService.sendInteractiveButtons(
|
||||||
|
phoneNumber,
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{ id: 'owner_sales', title: '📊 Ventas hoy' },
|
||||||
|
{ id: 'owner_inventory', title: '📦 Inventario' },
|
||||||
|
{ id: 'owner_fiados', title: '💰 Fiados' },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Welcome message for customers
|
||||||
|
const message = `Hola ${customerName}! 👋 Bienvenido a ${businessName}.
|
||||||
|
|
||||||
Soy tu asistente virtual. Puedo ayudarte con:
|
Soy tu asistente virtual. Puedo ayudarte con:
|
||||||
- Ver productos disponibles
|
- 🛒 Ver productos disponibles
|
||||||
- Hacer pedidos
|
- 📝 Hacer pedidos
|
||||||
- Consultar tu cuenta de fiado
|
- 💳 Consultar tu cuenta de fiado
|
||||||
- Revisar el estado de tus pedidos
|
- 📦 Revisar el estado de tus pedidos
|
||||||
|
|
||||||
Como puedo ayudarte hoy?`;
|
¿Cómo puedo ayudarte hoy?`;
|
||||||
|
|
||||||
await this.whatsAppService.sendInteractiveButtons(
|
await this.whatsAppService.sendInteractiveButtons(
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
message,
|
message,
|
||||||
[
|
[
|
||||||
{ id: 'menu_products', title: 'Ver productos' },
|
{ id: 'menu_products', title: '🛒 Ver productos' },
|
||||||
{ id: 'menu_orders', title: 'Mis pedidos' },
|
{ id: 'menu_orders', title: '📦 Mis pedidos' },
|
||||||
{ id: 'menu_fiado', title: 'Mi fiado' },
|
{ id: 'menu_fiado', title: '💳 Mi fiado' },
|
||||||
],
|
],
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendMainMenu(phoneNumber: string, context: ConversationContext): Promise<void> {
|
private async sendMainMenu(phoneNumber: string, context: ConversationContext): Promise<void> {
|
||||||
@ -541,6 +634,177 @@ Tambien puedes escribirme de forma natural y tratare de entenderte!`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== OWNER ACTIONS (MCH-012) ====================
|
||||||
|
|
||||||
|
private async sendOwnerSalesSummary(
|
||||||
|
phoneNumber: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: Fetch real sales data from backend
|
||||||
|
const today = new Date().toLocaleDateString('es-MX', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock data - in production this would call the backend
|
||||||
|
const salesData = {
|
||||||
|
total: 2450.50,
|
||||||
|
count: 15,
|
||||||
|
profit: 650.50,
|
||||||
|
avgTicket: 163.37,
|
||||||
|
topProducts: [
|
||||||
|
{ name: 'Coca-Cola 600ml', qty: 24, revenue: 432 },
|
||||||
|
{ name: 'Sabritas Original', qty: 18, revenue: 270 },
|
||||||
|
{ name: 'Pan Bimbo', qty: 10, revenue: 450 },
|
||||||
|
],
|
||||||
|
vsYesterday: '+12%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const topList = salesData.topProducts
|
||||||
|
.map((p, i) => `${i + 1}. ${p.name}: ${p.qty} uds ($${p.revenue})`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const message = `📊 *Resumen de Ventas*
|
||||||
|
_${today}_
|
||||||
|
|
||||||
|
💰 *Total:* $${salesData.total.toFixed(2)}
|
||||||
|
📦 *Transacciones:* ${salesData.count}
|
||||||
|
📈 *Ganancia:* $${salesData.profit.toFixed(2)}
|
||||||
|
🎫 *Ticket promedio:* $${salesData.avgTicket.toFixed(2)}
|
||||||
|
📊 *vs ayer:* ${salesData.vsYesterday}
|
||||||
|
|
||||||
|
*Top productos:*
|
||||||
|
${topList}
|
||||||
|
|
||||||
|
¿Necesitas más detalles?`;
|
||||||
|
|
||||||
|
await this.whatsAppService.sendInteractiveButtons(
|
||||||
|
phoneNumber,
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{ id: 'owner_inventory', title: '📦 Ver inventario' },
|
||||||
|
{ id: 'owner_fiados', title: '💰 Ver fiados' },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendOwnerInventoryStatus(
|
||||||
|
phoneNumber: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: Fetch real inventory data from backend
|
||||||
|
const lowStock = [
|
||||||
|
{ name: 'Coca-Cola 600ml', stock: 5, daysLeft: 2 },
|
||||||
|
{ name: 'Leche Lala 1L', stock: 3, daysLeft: 1 },
|
||||||
|
{ name: 'Pan Bimbo Grande', stock: 4, daysLeft: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const expiringSoon = [
|
||||||
|
{ name: 'Yogurt Danone', expiresIn: '2 días', qty: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lowStockList = lowStock
|
||||||
|
.map((p) => `⚠️ ${p.name}: ${p.stock} uds (~${p.daysLeft} días)`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const expiringList = expiringSoon.length > 0
|
||||||
|
? expiringSoon.map((p) => `⏰ ${p.name}: ${p.qty} uds (vence en ${p.expiresIn})`).join('\n')
|
||||||
|
: 'Ninguno próximo a vencer';
|
||||||
|
|
||||||
|
const message = `📦 *Estado del Inventario*
|
||||||
|
|
||||||
|
*Productos con stock bajo:*
|
||||||
|
${lowStockList}
|
||||||
|
|
||||||
|
*Próximos a vencer:*
|
||||||
|
${expiringList}
|
||||||
|
|
||||||
|
💡 _Tip: Considera hacer pedido al proveedor_
|
||||||
|
|
||||||
|
¿Quieres que te recuerde más tarde?`;
|
||||||
|
|
||||||
|
await this.whatsAppService.sendInteractiveButtons(
|
||||||
|
phoneNumber,
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{ id: 'owner_sales', title: '📊 Ver ventas' },
|
||||||
|
{ id: 'menu_products', title: '🛒 Ver catálogo' },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendOwnerFiadosSummary(
|
||||||
|
phoneNumber: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: Fetch real fiados data from backend
|
||||||
|
const fiadosData = {
|
||||||
|
totalPending: 2150.00,
|
||||||
|
clientCount: 8,
|
||||||
|
overdueCount: 3,
|
||||||
|
topDebtors: [
|
||||||
|
{ name: 'Juan Pérez', amount: 850, days: 15 },
|
||||||
|
{ name: 'María López', amount: 420, days: 7 },
|
||||||
|
{ name: 'Pedro García', amount: 380, days: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const debtorsList = fiadosData.topDebtors
|
||||||
|
.map((d, i) => `${i + 1}. ${d.name}: $${d.amount} (${d.days} días)`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const message = `💰 *Resumen de Fiados*
|
||||||
|
|
||||||
|
📊 *Total pendiente:* $${fiadosData.totalPending.toFixed(2)}
|
||||||
|
👥 *Clientes con deuda:* ${fiadosData.clientCount}
|
||||||
|
⚠️ *Con atraso (+7 días):* ${fiadosData.overdueCount}
|
||||||
|
|
||||||
|
*Mayores adeudos:*
|
||||||
|
${debtorsList}
|
||||||
|
|
||||||
|
¿Quieres enviar recordatorios de cobro?`;
|
||||||
|
|
||||||
|
await this.whatsAppService.sendInteractiveButtons(
|
||||||
|
phoneNumber,
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{ id: 'owner_send_reminders', title: '📤 Enviar recordatorios' },
|
||||||
|
{ id: 'owner_sales', title: '📊 Ver ventas' },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendPaymentReminders(
|
||||||
|
phoneNumber: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<void> {
|
||||||
|
// TODO: Actually send reminders via backend
|
||||||
|
const overdueClients = 3;
|
||||||
|
|
||||||
|
await this.whatsAppService.sendTextMessage(
|
||||||
|
phoneNumber,
|
||||||
|
`✅ *Recordatorios enviados*
|
||||||
|
|
||||||
|
Se enviaron recordatorios de pago a ${overdueClients} clientes con atrasos mayores a 7 días.
|
||||||
|
|
||||||
|
Los clientes recibirán un mensaje amable recordándoles su saldo pendiente.
|
||||||
|
|
||||||
|
_Nota: Solo se envía un recordatorio por semana a cada cliente._`,
|
||||||
|
context.tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== STATUS UPDATES ====================
|
// ==================== STATUS UPDATES ====================
|
||||||
|
|
||||||
processStatusUpdate(status: WebhookStatus): void {
|
processStatusUpdate(status: WebhookStatus): void {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user