Files
hub/app/Console/Commands/GenerateMenuFollowups.php
kelly 3905f86d6a feat: V1 Release - Complete platform with all premium modules
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)
2025-12-01 09:48:40 -07:00

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;
}
}