Major Features: - CRM Lite: Pipeline, tasks, accounts, calendar, inbox - AI Copilot: Multi-provider support, brand voice, content rules - Marketing: Campaigns, templates, channels, broadcasts - Intelligence: Buyer analytics, market intelligence dashboard - Orchestrator: Sales & marketing automation with AI - Compliance: License tracking (minimal shell) - Conversations: Buyer-seller messaging with email/SMS routing Infrastructure: - Suites & Plans system for feature gating - 60+ new migrations - Module middleware for access control - Database seeders for production sync - Enhanced product management (varieties, inventory modes) Documentation: - V1 scope, launch checklist, QA scripts - Module current state audit - Feature matrix (standard vs premium)
229 lines
8.8 KiB
PHP
229 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Business;
|
|
use App\Models\MenuViewEvent;
|
|
use App\Models\OrchestratorTask;
|
|
use App\Models\SendMenuLog;
|
|
use Illuminate\Console\Command;
|
|
|
|
class GenerateMenuFollowups extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'orchestrator:generate-menu-followups
|
|
{--days-no-view=3 : Days after send with no view to trigger followup}
|
|
{--days-viewed-no-order=3 : Days after view with no order to trigger followup}
|
|
{--business= : Limit to specific business ID}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Generate Orchestrator followup tasks for menu sends without views or orders';
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*/
|
|
public function handle(): int
|
|
{
|
|
$daysNoView = (int) $this->option('days-no-view');
|
|
$daysViewedNoOrder = (int) $this->option('days-viewed-no-order');
|
|
$specificBusinessId = $this->option('business');
|
|
|
|
$this->info('Generating menu followup tasks...');
|
|
$this->info(" - No view threshold: {$daysNoView} days");
|
|
$this->info(" - Viewed no order threshold: {$daysViewedNoOrder} days");
|
|
|
|
$businessQuery = Business::query()
|
|
->where('type', '!=', 'buyer'); // Only process seller businesses
|
|
|
|
if ($specificBusinessId) {
|
|
$businessQuery->where('id', $specificBusinessId);
|
|
}
|
|
|
|
$businesses = $businessQuery->get();
|
|
$totalNoView = 0;
|
|
$totalViewedNoOrder = 0;
|
|
|
|
foreach ($businesses as $business) {
|
|
$this->line("Processing business: {$business->name} (ID: {$business->id})");
|
|
|
|
// Case A: No view after send
|
|
$noViewCount = $this->generateNoViewFollowups($business, $daysNoView);
|
|
$totalNoView += $noViewCount;
|
|
|
|
// Case B: Viewed but no order
|
|
$viewedNoOrderCount = $this->generateViewedNoOrderFollowups($business, $daysViewedNoOrder);
|
|
$totalViewedNoOrder += $viewedNoOrderCount;
|
|
|
|
$this->line(" - Created {$noViewCount} no-view followups");
|
|
$this->line(" - Created {$viewedNoOrderCount} viewed-no-order followups");
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info('Complete! Total tasks created:');
|
|
$this->info(" - No view followups: {$totalNoView}");
|
|
$this->info(" - Viewed no order followups: {$totalViewedNoOrder}");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Generate followup tasks for menus sent but never viewed.
|
|
*/
|
|
protected function generateNoViewFollowups(Business $business, int $daysThreshold): int
|
|
{
|
|
$count = 0;
|
|
|
|
// Find SendMenuLog rows where:
|
|
// - sent_at is {daysThreshold} to {daysThreshold + 2} days ago (window)
|
|
// - There is no MenuViewEvent for the same business_id, menu_id, customer_id after sent_at
|
|
// - There is no existing OrchestratorTask of type menu_followup_no_view in pending status
|
|
$cutoffStart = now()->subDays($daysThreshold + 2);
|
|
$cutoffEnd = now()->subDays($daysThreshold);
|
|
|
|
$sendLogs = SendMenuLog::query()
|
|
->where('business_id', $business->id)
|
|
->whereBetween('sent_at', [$cutoffStart, $cutoffEnd])
|
|
->whereNotNull('customer_id')
|
|
->whereNotNull('menu_id')
|
|
->get();
|
|
|
|
foreach ($sendLogs as $log) {
|
|
// Check if there's been a view after the send
|
|
$hasView = MenuViewEvent::hasViewAfter(
|
|
$business->id,
|
|
$log->menu_id,
|
|
$log->customer_id,
|
|
$log->sent_at
|
|
);
|
|
|
|
if ($hasView) {
|
|
continue; // Menu was viewed, skip
|
|
}
|
|
|
|
// Check if task already exists
|
|
$existingTask = OrchestratorTask::query()
|
|
->where('business_id', $business->id)
|
|
->where('customer_id', $log->customer_id)
|
|
->where('menu_id', $log->menu_id)
|
|
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW)
|
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
|
->exists();
|
|
|
|
if ($existingTask) {
|
|
continue; // Task already exists
|
|
}
|
|
|
|
// Create the followup task
|
|
OrchestratorTask::create([
|
|
'business_id' => $business->id,
|
|
'brand_id' => $log->brand_id,
|
|
'menu_id' => $log->menu_id,
|
|
'customer_id' => $log->customer_id,
|
|
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
|
|
'status' => OrchestratorTask::STATUS_PENDING,
|
|
'due_at' => now(),
|
|
'payload' => [
|
|
'send_menu_log_id' => $log->id,
|
|
'recipient_name' => $log->meta['recipient_name'] ?? 'Unknown',
|
|
'recipient_type' => $log->recipient_type,
|
|
'recipient_id' => $log->recipient_id,
|
|
'channel' => $log->channel,
|
|
'original_sent_at' => $log->sent_at->toIso8601String(),
|
|
'suggested_message' => 'Hi! Just checking if you had a chance to look at the menu I sent over. Let me know if you have any questions!',
|
|
],
|
|
]);
|
|
|
|
$count++;
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Generate followup tasks for menus viewed but no order placed.
|
|
*/
|
|
protected function generateViewedNoOrderFollowups(Business $business, int $daysThreshold): int
|
|
{
|
|
$count = 0;
|
|
|
|
// Find MenuViewEvent rows where:
|
|
// - viewed_at is {daysThreshold} to {daysThreshold + 2} days ago
|
|
// - There was a SendMenuLog for that menu/customer earlier
|
|
// - There is no order yet (simplified: we just check if task exists)
|
|
// - There is no existing OrchestratorTask of type menu_followup_viewed_no_order in pending status
|
|
$cutoffStart = now()->subDays($daysThreshold + 2);
|
|
$cutoffEnd = now()->subDays($daysThreshold);
|
|
|
|
$viewEvents = MenuViewEvent::query()
|
|
->where('business_id', $business->id)
|
|
->whereBetween('viewed_at', [$cutoffStart, $cutoffEnd])
|
|
->whereNotNull('customer_id')
|
|
->whereNotNull('menu_id')
|
|
->get();
|
|
|
|
foreach ($viewEvents as $viewEvent) {
|
|
// Check if there was a SendMenuLog for this menu/customer
|
|
$sendLog = SendMenuLog::query()
|
|
->where('business_id', $business->id)
|
|
->where('menu_id', $viewEvent->menu_id)
|
|
->where('customer_id', $viewEvent->customer_id)
|
|
->where('sent_at', '<', $viewEvent->viewed_at)
|
|
->orderByDesc('sent_at')
|
|
->first();
|
|
|
|
if (! $sendLog) {
|
|
continue; // No prior send, this was a direct view
|
|
}
|
|
|
|
// Check if task already exists
|
|
$existingTask = OrchestratorTask::query()
|
|
->where('business_id', $business->id)
|
|
->where('customer_id', $viewEvent->customer_id)
|
|
->where('menu_id', $viewEvent->menu_id)
|
|
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER)
|
|
->where('status', OrchestratorTask::STATUS_PENDING)
|
|
->exists();
|
|
|
|
if ($existingTask) {
|
|
continue;
|
|
}
|
|
|
|
// For V1.3, we skip order checking (would need to hook into orders table)
|
|
// In future: check Order::where('business_id', $customer_id)->where('created_at', '>', $viewEvent->viewed_at)->exists()
|
|
|
|
// Create the followup task
|
|
OrchestratorTask::create([
|
|
'business_id' => $business->id,
|
|
'brand_id' => $viewEvent->brand_id,
|
|
'menu_id' => $viewEvent->menu_id,
|
|
'customer_id' => $viewEvent->customer_id,
|
|
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
|
|
'status' => OrchestratorTask::STATUS_PENDING,
|
|
'due_at' => now(),
|
|
'payload' => [
|
|
'send_menu_log_id' => $sendLog->id,
|
|
'recipient_name' => $sendLog->meta['recipient_name'] ?? 'Unknown',
|
|
'recipient_type' => $sendLog->recipient_type,
|
|
'recipient_id' => $sendLog->recipient_id,
|
|
'channel' => $sendLog->channel,
|
|
'original_sent_at' => $sendLog->sent_at->toIso8601String(),
|
|
'viewed_at' => $viewEvent->viewed_at->toIso8601String(),
|
|
'suggested_message' => "I saw you checked out the menu I sent over. Is there anything specific you're looking for? Happy to help!",
|
|
],
|
|
]);
|
|
|
|
$count++;
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
}
|