Files
hub/app/Console/Commands/GenerateSalesOrchestratorTasks.php
kelly 5b78f8db0f feat: brand profile page rebuild, dev tooling, and CRM refactor
- Rebuild brand profile page with 3-zone architecture:
  Zone 1: Identity bar (logo, name, tagline, score, actions)
  Zone 2: Dashboard snapshot (8 KPI cards, insight banners, tab bar)
  Zone 3: Tabbed content panels (9 sections)

- Add dev:setup command for local environment setup
  - Runs migrations with optional --fresh flag
  - Prompts to seed dev fixtures
  - Displays test credentials on completion

- Add development seeders (not called from DatabaseSeeder):
  - ProductionSyncSeeder: users, businesses, brands
  - DevSuitesSeeder: suite and plan assignments
  - BrandProfilesSeeder: brand AI profiles

- Refactor CRM from Modules/Crm to app/ structure
  - Move entities to app/Models/Crm/
  - Move controllers to app/Http/Controllers/Crm/
  - Remove old modular structure

- Update CLAUDE.md with dev setup documentation
2025-12-01 19:53:54 -07:00

1555 lines
67 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Console\Commands;
use App\Models\AutomationRunLog;
use App\Models\Brand;
use App\Models\BrandOrchestratorProfile;
use App\Models\Business;
use App\Models\Menu;
use App\Models\MenuViewEvent;
use App\Models\OrchestratorMessageVariant;
use App\Models\OrchestratorPlaybookStatus;
use App\Models\OrchestratorRun;
use App\Models\OrchestratorSalesConfig;
use App\Models\OrchestratorTask;
use App\Models\Order;
use App\Models\SendMenuLog;
use App\Services\OrchestratorGate;
use App\Services\Promo\CrossBrandPromoHelper;
use App\Services\Promo\InBrandPromoHelper;
use App\Services\Promo\PromotionRecommendationEngine;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* ═══════════════════════════════════════════════════════════════════════════════
* SALES ORCHESTRATOR - "HEAD OF SALES" AUTOMATED PLAYBOOKS
* ═══════════════════════════════════════════════════════════════════════════════
*
* This command acts as an automated "Head of Sales" that continuously monitors
* buyer behavior and generates actionable OrchestratorTask records for sales reps.
*
* ┌─────────────────────────────────────────────────────────────────────────────┐
* │ PLAYBOOKS OVERVIEW │
* ├─────────────────────────────────────────────────────────────────────────────┤
* │ │
* │ Playbook 1: MENU_FOLLOWUP_NO_VIEW (playbook1MenuSentNoView) │
* │ Trigger: Menu sent 3-7 days ago, never opened/viewed │
* │ Target: Recipients who received but didn't engage │
* │ Action: Re-send or follow up via different channel │
* │ Throttle: Per-customer limit, cooldown check │
* │ Outcome: Measured by subsequent view/order │
* │ │
* │ Playbook 2: MENU_FOLLOWUP_VIEWED_NO_ORDER (playbook2ViewedNoOrder) │
* │ Trigger: Menu viewed 3-7 days ago, no order placed │
* │ Target: Interested buyers who didn't convert │
* │ Action: Personal follow-up, address objections, offer help │
* │ Priority: HIGH (these are warm leads) │
* │ Outcome: Measured by subsequent order │
* │ │
* │ Playbook 3: REACTIVATION_NO_ORDER_30D (playbook3Reactivation) │
* │ Trigger: Customer has ordered before, but 30-120 days inactive │
* │ Target: Lapsed customers at risk of churning │
* │ Action: Win-back outreach, send updated menu, offer promotion │
* │ Priority: HIGH │
* │ Outcome: Measured by re-engagement and new order │
* │ │
* │ Playbook 4: PROMOTION_BROADCAST_SUGGESTION (playbook4NewMenuPromotion) │
* │ Trigger: New menu created within 7 days │
* │ Target: Top N buyers by order volume │
* │ Action: Proactive menu share, highlight new products │
* │ Feature: Aggregates multiple menus per brand/customer │
* │ Throttle: Lookback window prevents duplicate tasks │
* │ │
* │ Playbook 5: HIGH_INTENT_BUYER (playbook5HighIntentBuyer) │
* │ Trigger: 2+ menu views in last 48 hours, no order │
* │ Target: "Hot" buyers showing strong purchase signals │
* │ Action: Strike while iron is hot - immediate outreach │
* │ Priority: HIGH (time-sensitive) │
* │ Approval: May require manager review (configurable) │
* │ │
* │ Playbook 6: VIP_BUYER (playbook6VipBuyer) │
* │ Trigger: Top 10% by order volume, 14+ days since last order │
* │ Target: High-value accounts that may be drifting │
* │ Action: White-glove outreach, exclusive offers │
* │ Throttle: 30-day cooldown between VIP tasks │
* │ Approval: May require manager review (configurable) │
* │ │
* │ Playbook 7: GHOSTED_BUYER_RESCUE (playbook7GhostedBuyerRescue) │
* │ Trigger: 2+ views + 2+ sends in 30 days, no response/order │
* │ Target: Buyers who are engaging but not converting │
* │ Action: Change approach, ask what's missing │
* │ Priority: NORMAL │
* │ Approval: May require manager review (configurable) │
* │ │
* │ Playbook 8: AT_RISK_ACCOUNT (playbook8AtRiskAccount) │
* │ Trigger: 3+ historical orders, now 45-90 days since last │
* │ Target: Previously frequent buyers now going dormant │
* │ Action: Urgent win-back before they churn completely │
* │ Priority: HIGH │
* │ Approval: May require manager review (configurable) │
* │ │
* └─────────────────────────────────────────────────────────────────────────────┘
*
* ┌─────────────────────────────────────────────────────────────────────────────┐
* │ SAFETY & CONTROL MECHANISMS │
* ├─────────────────────────────────────────────────────────────────────────────┤
* │ │
* │ 1. Per-Customer Throttling │
* │ - Max tasks per customer per run (default: 3) │
* │ - Max pending tasks per customer (default: 3) │
* │ - Prevents spamming individual buyers │
* │ │
* │ 2. Cooldown Window │
* │ - Hours since last orchestrator touch (default: 24h) │
* │ - Prevents over-contact even across different playbooks │
* │ │
* │ 3. Hidden Approval Gate (OrchestratorGate) │
* │ - Tasks can be auto-approved or held for manager review │
* │ - approval_state: 'auto', 'draft', 'approved', 'blocked' │
* │ - visible_to_reps: true/false controls what reps see │
* │ │
* │ 4. Per-Brand Behavior Profiles (BrandOrchestratorProfile) │
* │ - aggressive: lower cooldowns, more auto-approval │
* │ - balanced: default behavior │
* │ - conservative: higher cooldowns, more manager review │
* │ │
* │ 5. Playbook Quarantine (OrchestratorPlaybookStatus) │
* │ - Underperforming playbooks can be auto-disabled │
* │ - Based on order rate, dismissal rate metrics │
* │ │
* │ 6. A/B Message Variants (OrchestratorMessageVariant) │
* │ - Round-robin variant selection per playbook │
* │ - Stats tracked in OrchestratorMessageVariantStat │
* │ │
* └─────────────────────────────────────────────────────────────────────────────┘
*
* ┌─────────────────────────────────────────────────────────────────────────────┐
* │ LEARNING LOOP │
* ├─────────────────────────────────────────────────────────────────────────────┤
* │ │
* │ 1. Task created with suggested_message in payload │
* │ 2. Rep completes task (sends menu, makes call, etc.) │
* │ 3. orchestrator:evaluate-outcomes runs periodically │
* │ 4. Sets resulted_in_view / resulted_in_order on task │
* │ 5. orchestrator:evaluate-playbooks aggregates 30-day metrics │
* │ 6. Poor performers get quarantined │
* │ 7. A/B variant stats inform which messages work best │
* │ │
* └─────────────────────────────────────────────────────────────────────────────┘
*
* @see OrchestratorTask Task model with type constants
* @see OrchestratorSalesConfig Global config for thresholds
* @see BrandOrchestratorProfile Per-brand behavior profiles
* @see OrchestratorGate Approval/visibility gate
* @see OrchestratorPlaybookStatus Playbook health/quarantine tracking
* @see OrchestratorMessageVariant A/B test message templates
* @see OrchestratorEvaluateOutcomes Outcome evaluation command
* @see OrchestratorEvaluatePlaybooks Playbook performance evaluation
*/
class GenerateSalesOrchestratorTasks extends Command
{
protected $signature = 'orchestrator:generate-sales-tasks
{--business= : Limit to specific business ID}
{--playbook= : Run only specific playbook (no-view, viewed-no-order, reactivation, new-menu)}
{--dry-run : Show what would be created without creating}';
protected $description = 'Generate Sales Orchestrator tasks from automated playbooks (Head of Sales)';
private int $tasksCreated = 0;
private bool $dryRun = false;
/**
* Per-customer task counter for throttling.
* Tracks how many tasks have been created for each customer in this run.
*
* @var array<int, int>
*/
private array $customerTaskCount = [];
/**
* Per-playbook task counter for A/B testing variant selection (round-robin).
*
* @var array<string, int>
*/
private array $playbookTaskIndex = [];
/**
* Max tasks per customer per run (loaded from config).
*/
private int $maxTasksPerCustomer = 3;
/**
* Orchestrator Gate for approval decisions.
*/
private OrchestratorGate $gate;
/**
* Promotion recommendation engine for promo-enriched tasks.
*/
private PromotionRecommendationEngine $promoEngine;
/**
* In-brand promo helper for single-brand suggestions.
*/
private InBrandPromoHelper $inBrandHelper;
/**
* Cross-brand promo helper for portfolio opportunities.
*/
private CrossBrandPromoHelper $crossBrandHelper;
/**
* Current business being processed.
*/
private ?Business $currentBusiness = null;
/**
* Global config for the current business.
*/
private ?OrchestratorSalesConfig $globalConfig = null;
/**
* Current run for tracking and analytics.
*/
private ?OrchestratorRun $currentRun = null;
/**
* Playbook name mappings for run logging.
*/
private const PLAYBOOK_NAMES = [
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW => 'playbook_1_no_view',
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER => 'playbook_2_viewed_no_order',
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D => 'playbook_3_reactivation',
OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION => 'playbook_4_new_menu',
OrchestratorTask::TYPE_HIGH_INTENT_BUYER => 'playbook_5_high_intent',
OrchestratorTask::TYPE_VIP_BUYER => 'playbook_6_vip',
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE => 'playbook_7_ghosted',
OrchestratorTask::TYPE_AT_RISK_ACCOUNT => 'playbook_8_at_risk',
];
public function handle(): int
{
AutomationRunLog::recordStart(AutomationRunLog::CMD_GENERATE_SALES_TASKS);
$this->dryRun = $this->option('dry-run');
$specificBusinessId = $this->option('business');
$specificPlaybook = $this->option('playbook');
$this->info('🎯 Sales Orchestrator - Generating tasks...');
if ($this->dryRun) {
$this->warn(' (DRY RUN - no tasks will be created)');
}
$this->newLine();
// Load global config for throttling
try {
$this->globalConfig = OrchestratorSalesConfig::getGlobal();
$this->maxTasksPerCustomer = $this->globalConfig->getMaxTasksPerCustomerPerRun();
$this->gate = new OrchestratorGate($this->globalConfig);
} catch (\Exception $e) {
$this->globalConfig = new OrchestratorSalesConfig;
$this->maxTasksPerCustomer = 3; // Default fallback
$this->gate = new OrchestratorGate;
}
$this->line(" Throttle: max {$this->maxTasksPerCustomer} tasks/customer/run");
$this->line(' Gate: approval layer active (with per-brand profiles)');
// Initialize promo helpers for enriched task generation
$this->inBrandHelper = new InBrandPromoHelper;
$this->crossBrandHelper = new CrossBrandPromoHelper;
$this->promoEngine = new PromotionRecommendationEngine($this->inBrandHelper, $this->crossBrandHelper);
$this->line(' Promo: recommendation engine active');
$this->newLine();
// Get seller businesses
$businessQuery = Business::query()
->where('type', '!=', 'buyer');
if ($specificBusinessId) {
$businessQuery->where('id', $specificBusinessId);
}
$businesses = $businessQuery->get();
foreach ($businesses as $business) {
// Reset per-customer counter for each business
$this->customerTaskCount = [];
$this->currentBusiness = $business;
$this->line("📊 Processing: {$business->name}");
// Start a new run for this business (if runs table exists)
$this->startRun($business);
try {
// Run playbooks based on filter or all (respecting quarantine status)
if (! $specificPlaybook || $specificPlaybook === 'no-view') {
$this->executePlaybook(
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
fn () => $this->playbook1MenuSentNoView($business),
'Playbook 1 (No View)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'viewed-no-order') {
$this->executePlaybook(
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
fn () => $this->playbook2ViewedNoOrder($business),
'Playbook 2 (Viewed, No Order)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'reactivation') {
$this->executePlaybook(
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D,
fn () => $this->playbook3Reactivation($business),
'Playbook 3 (Reactivation)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'new-menu') {
$this->executePlaybook(
OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION,
fn () => $this->playbook4NewMenuPromotion($business),
'Playbook 4 (New Menu)',
$business
);
}
// V2 Advanced Playbooks
if (! $specificPlaybook || $specificPlaybook === 'high-intent') {
$this->executePlaybook(
OrchestratorTask::TYPE_HIGH_INTENT_BUYER,
fn () => $this->playbook5HighIntentBuyer($business),
'Playbook 5 (High Intent)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'vip') {
$this->executePlaybook(
OrchestratorTask::TYPE_VIP_BUYER,
fn () => $this->playbook6VipBuyer($business),
'Playbook 6 (VIP Buyer)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'ghosted') {
$this->executePlaybook(
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE,
fn () => $this->playbook7GhostedBuyerRescue($business),
'Playbook 7 (Ghosted Buyer)',
$business
);
}
if (! $specificPlaybook || $specificPlaybook === 'at-risk') {
$this->executePlaybook(
OrchestratorTask::TYPE_AT_RISK_ACCOUNT,
fn () => $this->playbook8AtRiskAccount($business),
'Playbook 8 (At-Risk)',
$business,
isLast: true
);
}
// Complete the run successfully
$this->completeRun();
} catch (\Throwable $e) {
// Mark run as failed
$this->failRun($e->getMessage());
$this->error(" ⚠️ Run failed: {$e->getMessage()}");
}
$this->newLine();
}
$this->info("✅ Complete! Total tasks created: {$this->tasksCreated}");
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_GENERATE_SALES_TASKS, [
'tasks_created' => $this->tasksCreated,
'businesses_processed' => $businesses->count(),
]);
return self::SUCCESS;
}
/**
* Execute a playbook with error tracking.
*/
private function executePlaybook(
string $playbookType,
callable $callback,
string $label,
Business $business,
bool $isLast = false
): void {
$prefix = $isLast ? '└─' : '├─';
if (! $this->canPlaybookGenerate($playbookType, $business->id)) {
$this->line(" {$prefix} {$label}: SKIPPED (quarantined/paused)");
return;
}
try {
$count = $callback();
$this->line(" {$prefix} {$label}: {$count} tasks");
} catch (\Throwable $e) {
$this->recordRunFailure($playbookType, $e->getMessage(), null, $e->getTraceAsString());
$this->error(" {$prefix} {$label}: ERROR - {$e->getMessage()}");
}
}
/**
* Start a new orchestrator run for tracking.
*/
private function startRun(Business $business): void
{
if ($this->dryRun || ! Schema::hasTable('orchestrator_runs')) {
$this->currentRun = null;
return;
}
$this->currentRun = OrchestratorRun::start($business->id, OrchestratorRun::TYPE_SALES);
}
/**
* Complete the current run successfully.
*/
private function completeRun(): void
{
if ($this->currentRun) {
$this->currentRun->complete();
}
}
/**
* Mark the current run as failed.
*/
private function failRun(string $reason): void
{
if ($this->currentRun) {
$this->currentRun->fail($reason);
}
}
/**
* Record a task created in the current run.
*/
private function recordRunTaskCreated(string $taskType): void
{
if ($this->currentRun) {
$playbookName = self::PLAYBOOK_NAMES[$taskType] ?? $taskType;
$this->currentRun->recordTaskCreated($playbookName);
}
}
/**
* Record a failure in the current run.
*/
private function recordRunFailure(string $playbookType, string $error, ?int $customerId = null, ?string $trace = null): void
{
if ($this->currentRun) {
$playbookName = self::PLAYBOOK_NAMES[$playbookType] ?? $playbookType;
$this->currentRun->recordFailure($playbookName, $error, $customerId, $trace);
}
}
/**
* Playbook 1: Menu sent but never viewed.
*
* Finds SendMenuLog entries 3-7 days old with no corresponding MenuViewEvent.
*/
private function playbook1MenuSentNoView(Business $business): int
{
$count = 0;
$startDate = now()->subDays(7);
$endDate = now()->subDays(3);
$sendLogs = SendMenuLog::query()
->where('business_id', $business->id)
->whereBetween('sent_at', [$startDate, $endDate])
->whereNotNull('customer_id')
->whereNotNull('menu_id')
->with('brand') // Eager load brand for profile lookup
->get();
foreach ($sendLogs as $log) {
// Check if menu was viewed after send
$hasView = MenuViewEvent::where('business_id', $business->id)
->where('menu_id', $log->menu_id)
->where('customer_id', $log->customer_id)
->where('viewed_at', '>', $log->sent_at)
->exists();
if ($hasView) {
continue;
}
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
$log->customer_id,
$log->menu_id
)) {
continue;
}
// Extract recipient info
$recipientName = $log->meta['recipient_name'] ?? 'there';
$recipientFirstName = explode(' ', $recipientName)[0];
$contactId = $log->recipient_type === 'contact' ? $log->recipient_id : null;
$buyerId = $log->recipient_type === 'buyer' ? $log->recipient_id : null;
$task = $this->createTaskWithApproval([
'business_id' => $business->id,
'brand_id' => $log->brand_id,
'menu_id' => $log->menu_id,
'customer_id' => $log->customer_id,
'contact_id' => $contactId,
'buyer_id' => $buyerId,
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_NORMAL,
'due_at' => now()->addDay(),
'payload' => [
'send_menu_log_id' => $log->id,
'recipient_name' => $recipientName,
'recipient_type' => $log->recipient_type,
'recipient_id' => $log->recipient_id,
'channel' => $log->channel,
'reason' => 'Menu sent but not opened/viewed',
'suggested_message' => "Hey {$recipientFirstName}, just checking if you had a chance to look at the menu I sent over. Happy to walk you through anything.",
],
], $log->brand); // Pass brand for profile lookup
if (! $task) {
continue; // Cooldown or disabled playbook
}
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 2: Menu viewed but no order placed.
*
* Finds MenuViewEvent entries 3-7 days old with a prior SendMenuLog but no subsequent order.
*/
private function playbook2ViewedNoOrder(Business $business): int
{
$count = 0;
$startDate = now()->subDays(7);
$endDate = now()->subDays(3);
$viewEvents = MenuViewEvent::query()
->where('business_id', $business->id)
->whereBetween('viewed_at', [$startDate, $endDate])
->whereNotNull('customer_id')
->whereNotNull('menu_id')
->with('brand') // Eager load brand for profile lookup
->get();
foreach ($viewEvents as $view) {
// Find prior send log
$sendLog = SendMenuLog::query()
->where('business_id', $business->id)
->where('menu_id', $view->menu_id)
->where('customer_id', $view->customer_id)
->where('sent_at', '<', $view->viewed_at)
->orderByDesc('sent_at')
->first();
if (! $sendLog) {
continue; // Direct view, not from send menu
}
// Check for orders after view (if orders table exists)
if (Schema::hasTable('orders')) {
$hasOrder = Order::where('business_id', $view->customer_id)
->where('created_at', '>', $view->viewed_at)
->exists();
if ($hasOrder) {
continue; // They ordered, no followup needed
}
}
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
$view->customer_id,
$view->menu_id
)) {
continue;
}
// Get recipient info from send log
$recipientName = $sendLog->meta['recipient_name'] ?? 'there';
$recipientFirstName = explode(' ', $recipientName)[0];
// Get promo suggestion for this playbook
$buyerBusiness = Business::find($view->customer_id);
$promoSuggestion = $buyerBusiness
? $this->promoEngine->getPromoSuggestionForPlaybook($business, $buyerBusiness, 'menu_viewed_no_order')
: ['has_suggestion' => false];
$payload = [
'menu_view_event_id' => $view->id,
'send_menu_log_id' => $sendLog->id,
'recipient_name' => $recipientName,
'recipient_type' => $sendLog->recipient_type,
'recipient_id' => $sendLog->recipient_id,
'channel' => $sendLog->channel,
'viewed_at' => $view->viewed_at->toIso8601String(),
'reason' => 'Menu viewed but no order placed',
'suggested_message' => "Hey {$recipientFirstName}, saw you checked out the menu the other day is there anything you were looking for that you didn't see?",
];
// Enrich with promo suggestion if available
if ($promoSuggestion['has_suggestion'] ?? false) {
$payload['suggested_promo_type'] = $promoSuggestion['suggested_promo_type'];
$payload['suggested_template_key'] = $promoSuggestion['suggested_template_key'];
$payload['suggested_products'] = $promoSuggestion['suggested_products'];
$payload['promo_score'] = $promoSuggestion['promo_score'];
$payload['expected_revenue_lift'] = $promoSuggestion['expected_revenue_lift'];
$payload['promo_reason'] = $promoSuggestion['reason'];
}
$task = $this->createTaskWithApproval([
'business_id' => $business->id,
'brand_id' => $view->brand_id,
'menu_id' => $view->menu_id,
'customer_id' => $view->customer_id,
'buyer_id' => $view->buyer_id,
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now(),
'payload' => $payload,
], $view->brand); // Pass brand for profile lookup
if (! $task) {
continue; // Cooldown or disabled playbook
}
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 3: Reactivation for customers with no orders in 30+ days.
*
* Finds customers who have ordered before but are now dormant (30-120 days).
*/
private function playbook3Reactivation(Business $business): int
{
$count = 0;
// Only run if orders table exists
if (! Schema::hasTable('orders')) {
$this->line(' (Skipped: orders table not found)');
return 0;
}
// Get brand IDs for this business
$brandIds = $business->brands()->pluck('id');
if ($brandIds->isEmpty()) {
return 0;
}
// Find customers with orders to these brands, grouped by last order date
$dormantCustomers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->whereIn('products.brand_id', $brandIds)
->select('orders.business_id as customer_id', DB::raw('MAX(orders.created_at) as last_order_at'))
->groupBy('orders.business_id')
->havingRaw('MAX(orders.created_at) < ?', [now()->subDays(30)])
->havingRaw('MAX(orders.created_at) > ?', [now()->subDays(120)])
->get();
foreach ($dormantCustomers as $dormant) {
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D,
$dormant->customer_id
)) {
continue;
}
// Get customer info
$customer = Business::find($dormant->customer_id);
if (! $customer) {
continue;
}
$daysSinceOrder = now()->diffInDays($dormant->last_order_at);
$buyerName = $customer->name;
// Get promo suggestion for reactivation
$promoSuggestion = $this->promoEngine->getPromoSuggestionForPlaybook($business, $customer, 'reactivation');
$payload = [
'last_order_at' => $dormant->last_order_at,
'days_since_order' => $daysSinceOrder,
'reason' => "No orders in {$daysSinceOrder} days",
'suggested_message' => "Hey {$buyerName}, it's been a while since your last order. Want me to send over our latest menu or help build a restock order?",
];
// Enrich with promo suggestion if available
if ($promoSuggestion['has_suggestion'] ?? false) {
$payload['suggested_promo_type'] = $promoSuggestion['suggested_promo_type'];
$payload['suggested_template_key'] = $promoSuggestion['suggested_template_key'];
$payload['suggested_products'] = $promoSuggestion['suggested_products'];
$payload['promo_score'] = $promoSuggestion['promo_score'];
$payload['expected_revenue_lift'] = $promoSuggestion['expected_revenue_lift'];
$payload['promo_reason'] = $promoSuggestion['reason'];
}
$this->createTaskWithApproval([
'business_id' => $business->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now()->addDay(),
'payload' => $payload,
]);
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 4: Promote new menus to top buyers (AGGREGATED).
*
* Creates ONE task per (brand, customer) with all new menus aggregated,
* instead of spamming with one task per menu.
*
* Features:
* - Aggregates all new menus for a brand into a single task
* - De-duplicates: won't create if existing task exists within lookback window
* - Respects per-customer throttling
* - Updates existing pending tasks with new menu IDs if found
*/
private function playbook4NewMenuPromotion(Business $business): int
{
$count = 0;
// Load config settings
try {
$config = OrchestratorSalesConfig::getForBusiness($business->id);
$settings = $config->getNewMenuSettings();
} catch (\Exception $e) {
$settings = [
'top_buyers_count' => 10,
'lookback_days' => 7,
'created_within_days' => 7,
];
}
// Get new menus created within the configured window, grouped by brand
$newMenus = Menu::whereHas('brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->where('created_at', '>=', now()->subDays($settings['created_within_days']))
->where('status', 'active')
->with('brand')
->get();
if ($newMenus->isEmpty()) {
return 0;
}
// Group menus by brand_id for aggregation
$menusByBrand = $newMenus->groupBy('brand_id');
// Get top buyers for this business
$topBuyers = $this->getTopBuyers($business, $settings['top_buyers_count']);
foreach ($menusByBrand as $brandId => $brandMenus) {
$brand = $brandMenus->first()->brand;
$menuIds = $brandMenus->pluck('id')->toArray();
$menuNames = $brandMenus->pluck('name')->toArray();
foreach ($topBuyers as $buyerData) {
$customerId = $buyerData['customer_id'];
// Throttle: check if we can create more tasks for this customer
if (! $this->canCreateTaskForCustomer($customerId)) {
continue;
}
// Check for existing task within lookback window (de-duplication)
$existingTask = OrchestratorTask::where('business_id', $business->id)
->where('type', OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION)
->where('customer_id', $customerId)
->where('brand_id', $brandId)
->whereIn('status', [
OrchestratorTask::STATUS_PENDING,
OrchestratorTask::STATUS_SNOOZED,
])
->where('created_at', '>=', now()->subDays($settings['lookback_days']))
->first();
if ($existingTask) {
// Update existing task's payload with any new menu IDs
$existingMenuIds = $existingTask->payload['menu_ids'] ?? [];
$existingMenuNames = $existingTask->payload['menu_names'] ?? [];
$newMenuIdsToAdd = array_diff($menuIds, $existingMenuIds);
if (! empty($newMenuIdsToAdd) && ! $this->dryRun) {
$updatedMenuIds = array_unique(array_merge($existingMenuIds, $menuIds));
$updatedMenuNames = array_unique(array_merge($existingMenuNames, $menuNames));
$existingTask->update([
'payload' => array_merge($existingTask->payload, [
'menu_ids' => array_values($updatedMenuIds),
'menu_names' => array_values($updatedMenuNames),
'updated_reason' => 'Additional menus added',
]),
]);
}
continue; // Don't create a new task
}
// Build aggregated suggested message
$menuListText = count($menuNames) > 1
? implode(', ', array_slice($menuNames, 0, -1)).' and '.end($menuNames)
: $menuNames[0];
$suggestedMessage = count($menuNames) > 1
? "Hey {$buyerData['name']}, we've just updated our {$brand->name} menus ({$menuListText}). Want me to walk you through what's new or help build an order together?"
: "Hey {$buyerData['name']}, we just updated our {$menuNames[0]} menu want me to walk you through what's new?";
$task = $this->createTaskWithApproval([
'business_id' => $business->id,
'brand_id' => $brandId,
'menu_id' => $brandMenus->first()->id,
'customer_id' => $customerId,
'type' => OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_NORMAL,
'due_at' => now()->addDays(2),
'payload' => [
'menu_ids' => $menuIds,
'menu_names' => $menuNames,
'menu_count' => count($menuIds),
'brand_name' => $brand->name ?? 'Unknown',
'buyer_name' => $buyerData['name'],
'buyer_rank' => $buyerData['rank'],
'reason' => count($menuIds) > 1
? "New menus available for {$brand->name}; buyer is top account"
: 'New menu available; high-value buyer',
'suggested_message' => $suggestedMessage,
],
], $brand); // Pass brand for profile lookup
if (! $task) {
continue; // Cooldown or disabled playbook
}
$this->recordTaskForCustomer($customerId);
$count++;
$this->tasksCreated++;
}
}
return $count;
}
/**
* Playbook 5: High Intent Buyers.
*
* Triggers when buyer shows high engagement signals:
* - 2+ menu views within 48 hours
* - Viewed menu within 24 hours without ordering
* - Multiple different menus viewed in a week
*/
private function playbook5HighIntentBuyer(Business $business): int
{
$count = 0;
// Find customers with 2+ views in the last 48 hours
$highIntentCustomers = MenuViewEvent::where('business_id', $business->id)
->where('viewed_at', '>=', now()->subHours(48))
->whereNotNull('customer_id')
->select('customer_id', DB::raw('COUNT(*) as view_count'))
->groupBy('customer_id')
->havingRaw('COUNT(*) >= ?', [2])
->get();
foreach ($highIntentCustomers as $viewer) {
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_HIGH_INTENT_BUYER,
$viewer->customer_id
)) {
continue;
}
// Check for recent orders (if they ordered, no need for follow-up)
if (Schema::hasTable('orders')) {
$hasRecentOrder = Order::where('business_id', $viewer->customer_id)
->where('created_at', '>=', now()->subHours(48))
->exists();
if ($hasRecentOrder) {
continue;
}
}
$customer = Business::find($viewer->customer_id);
if (! $customer) {
continue;
}
// Get the most recent menu they viewed
$recentView = MenuViewEvent::where('business_id', $business->id)
->where('customer_id', $viewer->customer_id)
->latest('viewed_at')
->with(['menu', 'brand']) // Eager load brand for profile lookup
->first();
$task = $this->createTaskWithApproval([
'business_id' => $business->id,
'brand_id' => $recentView?->brand_id,
'menu_id' => $recentView?->menu_id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_HIGH_INTENT_BUYER,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now(),
'payload' => [
'view_count_48h' => $viewer->view_count,
'reason' => "🔥 This buyer is hot! {$viewer->view_count} menu views in 48 hours.",
'suggested_message' => "Hey {$customer->name}, I noticed you've been checking out our menus. Would you like me to walk you through what's new or help put together an order?",
],
], $recentView?->brand); // Pass brand for profile lookup
if (! $task) {
continue; // Cooldown or disabled playbook
}
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 6: VIP Buyers.
*
* Identifies top 10% of buyers by order volume and suggests proactive engagement.
*/
private function playbook6VipBuyer(Business $business): int
{
$count = 0;
if (! Schema::hasTable('orders')) {
return 0;
}
$brandIds = $business->brands()->pluck('id');
if ($brandIds->isEmpty()) {
return 0;
}
// Get all buyers with orders to this business
$allBuyers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->whereIn('products.brand_id', $brandIds)
->select('orders.business_id as customer_id', DB::raw('SUM(order_items.quantity * order_items.unit_price) as total_value'))
->groupBy('orders.business_id')
->orderByDesc('total_value')
->get();
if ($allBuyers->isEmpty()) {
return 0;
}
// Calculate top 10% threshold
$top10PercentCount = max(1, (int) ceil($allBuyers->count() * 0.1));
$vipBuyers = $allBuyers->take($top10PercentCount);
foreach ($vipBuyers as $vip) {
// Check if already has a pending VIP task
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_VIP_BUYER,
$vip->customer_id
)) {
continue;
}
// Check when we last reached out (don't spam VIPs)
$recentTask = OrchestratorTask::where('business_id', $business->id)
->where('customer_id', $vip->customer_id)
->where('type', OrchestratorTask::TYPE_VIP_BUYER)
->where('created_at', '>=', now()->subDays(30))
->exists();
if ($recentTask) {
continue;
}
$customer = Business::find($vip->customer_id);
if (! $customer) {
continue;
}
// Check last order date
$lastOrderDate = Order::where('business_id', $vip->customer_id)
->whereHas('items.product.brand', fn ($q) => $q->whereIn('brand_id', $brandIds))
->max('created_at');
$daysSinceOrder = $lastOrderDate ? now()->diffInDays($lastOrderDate) : 999;
// Only create task if they haven't ordered in 14+ days
if ($daysSinceOrder < 14) {
continue;
}
$this->createTaskWithApproval([
'business_id' => $business->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_VIP_BUYER,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now()->addDay(),
'payload' => [
'total_value' => $vip->total_value,
'days_since_order' => $daysSinceOrder,
'reason' => "⭐ VIP buyer - top 10% by volume. Last order {$daysSinceOrder} days ago.",
'suggested_message' => "Hey {$customer->name}, as one of our valued partners, I wanted to personally check in. Any new products you'd like to see, or can I send over our latest menu?",
],
]);
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 7: Ghosted Buyer Rescue.
*
* Triggers when buyer viewed menus multiple times but:
* - Hasn't responded to last 2+ outbound messages
* - No recent order
*/
private function playbook7GhostedBuyerRescue(Business $business): int
{
$count = 0;
// Find customers who viewed multiple menus but have multiple sends with no response
$multiViewers = MenuViewEvent::where('business_id', $business->id)
->where('viewed_at', '>=', now()->subDays(30))
->whereNotNull('customer_id')
->select('customer_id', DB::raw('COUNT(*) as view_count'))
->groupBy('customer_id')
->havingRaw('COUNT(*) >= ?', [2])
->get();
foreach ($multiViewers as $viewer) {
// Check for multiple outbound sends without order
$sendCount = SendMenuLog::where('business_id', $business->id)
->where('customer_id', $viewer->customer_id)
->where('sent_at', '>=', now()->subDays(30))
->count();
if ($sendCount < 2) {
continue;
}
// Check for recent orders
if (Schema::hasTable('orders')) {
$hasRecentOrder = Order::where('business_id', $viewer->customer_id)
->where('created_at', '>=', now()->subDays(30))
->exists();
if ($hasRecentOrder) {
continue;
}
}
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE,
$viewer->customer_id
)) {
continue;
}
$customer = Business::find($viewer->customer_id);
if (! $customer) {
continue;
}
$this->createTaskWithApproval([
'business_id' => $business->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_NORMAL,
'due_at' => now()->addDay(),
'payload' => [
'view_count' => $viewer->view_count,
'send_count' => $sendCount,
'reason' => "👻 Ghosted buyer - {$viewer->view_count} views, {$sendCount} sends, no response.",
'suggested_message' => "Hey {$customer->name}, quick question - is there something specific you're looking for that you haven't found yet?",
],
]);
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Playbook 8: At-Risk Accounts.
*
* Triggers for customers who:
* - Historically ordered frequently (monthly or every 2-3 weeks)
* - Now 45-90 days since last order
*/
private function playbook8AtRiskAccount(Business $business): int
{
$count = 0;
if (! Schema::hasTable('orders')) {
return 0;
}
$brandIds = $business->brands()->pluck('id');
if ($brandIds->isEmpty()) {
return 0;
}
// Find customers with multiple historical orders (frequent buyers)
// who haven't ordered in 45-90 days
$atRiskCustomers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->whereIn('products.brand_id', $brandIds)
->select(
'orders.business_id as customer_id',
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
DB::raw('MAX(orders.created_at) as last_order_at'),
DB::raw('MIN(orders.created_at) as first_order_at')
)
->groupBy('orders.business_id')
->havingRaw('COUNT(DISTINCT orders.id) >= 3') // At least 3 orders historically
->havingRaw('MAX(orders.created_at) < ?', [now()->subDays(45)]) // No order in 45 days
->havingRaw('MAX(orders.created_at) > ?', [now()->subDays(90)]) // But within 90 days
->get();
foreach ($atRiskCustomers as $atRisk) {
// Check if task already exists
if (OrchestratorTask::existsPending(
$business->id,
OrchestratorTask::TYPE_AT_RISK_ACCOUNT,
$atRisk->customer_id
)) {
continue;
}
$customer = Business::find($atRisk->customer_id);
if (! $customer) {
continue;
}
$daysSinceOrder = now()->diffInDays($atRisk->last_order_at);
$avgOrderFrequency = $atRisk->first_order_at && $atRisk->order_count > 1
? round(now()->diffInDays($atRisk->first_order_at) / $atRisk->order_count)
: 30;
// Get promo suggestion for at-risk accounts
$promoSuggestion = $this->promoEngine->getPromoSuggestionForPlaybook($business, $customer, 'at_risk');
$payload = [
'order_count' => $atRisk->order_count,
'days_since_order' => $daysSinceOrder,
'avg_order_frequency' => $avgOrderFrequency,
'reason' => "At-risk: {$atRisk->order_count} historical orders, but {$daysSinceOrder} days since last order (avg was every {$avgOrderFrequency} days).",
'suggested_message' => "Hey {$customer->name}, it's been a minute! We've got some new items I think you'd be interested in. Want me to send over an updated menu or draft a restock order for you?",
];
// Enrich with promo suggestion if available (high priority for at-risk)
if ($promoSuggestion['has_suggestion'] ?? false) {
$payload['suggested_promo_type'] = $promoSuggestion['suggested_promo_type'];
$payload['suggested_template_key'] = $promoSuggestion['suggested_template_key'];
$payload['suggested_products'] = $promoSuggestion['suggested_products'];
$payload['promo_score'] = $promoSuggestion['promo_score'];
$payload['expected_revenue_lift'] = $promoSuggestion['expected_revenue_lift'];
$payload['promo_reason'] = $promoSuggestion['reason'];
}
$this->createTaskWithApproval([
'business_id' => $business->id,
'customer_id' => $customer->id,
'type' => OrchestratorTask::TYPE_AT_RISK_ACCOUNT,
'owner_role' => OrchestratorTask::ROLE_SALES,
'status' => OrchestratorTask::STATUS_PENDING,
'priority' => OrchestratorTask::PRIORITY_HIGH,
'due_at' => now(),
'payload' => $payload,
]);
$count++;
$this->tasksCreated++;
}
return $count;
}
/**
* Get top N buyers for a business based on order volume or engagement.
*/
private function getTopBuyers(Business $business, int $limit): array
{
$topBuyers = [];
$brandIds = $business->brands()->pluck('id');
// Try to get from orders first
if (Schema::hasTable('orders') && $brandIds->isNotEmpty()) {
$buyers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->whereIn('products.brand_id', $brandIds)
->where('orders.created_at', '>=', now()->subDays(60))
->select(
'orders.business_id as customer_id',
'businesses.name',
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
DB::raw('SUM(order_items.quantity * order_items.unit_price) as total_value')
)
->groupBy('orders.business_id', 'businesses.name')
->orderByDesc('total_value')
->limit($limit)
->get();
$rank = 1;
foreach ($buyers as $buyer) {
$topBuyers[] = [
'customer_id' => $buyer->customer_id,
'name' => $buyer->name,
'rank' => $rank++,
];
}
}
// Fallback: get from menu view events
if (empty($topBuyers)) {
$viewers = MenuViewEvent::where('business_id', $business->id)
->whereNotNull('customer_id')
->where('viewed_at', '>=', now()->subDays(30))
->select('customer_id', DB::raw('COUNT(*) as view_count'))
->groupBy('customer_id')
->orderByDesc('view_count')
->limit($limit)
->get();
$rank = 1;
foreach ($viewers as $viewer) {
$customer = Business::find($viewer->customer_id);
if ($customer) {
$topBuyers[] = [
'customer_id' => $customer->id,
'name' => $customer->name,
'rank' => $rank++,
];
}
}
}
return $topBuyers;
}
// ─────────────────────────────────────────────────────────────
// Throttling Helpers
// ─────────────────────────────────────────────────────────────
/**
* Check if we can create a task for this customer (throttling).
*/
private function canCreateTaskForCustomer(int $customerId): bool
{
$currentCount = $this->customerTaskCount[$customerId] ?? 0;
return $currentCount < $this->maxTasksPerCustomer;
}
/**
* Record that we created a task for this customer.
*/
private function recordTaskForCustomer(int $customerId): void
{
if (! isset($this->customerTaskCount[$customerId])) {
$this->customerTaskCount[$customerId] = 0;
}
$this->customerTaskCount[$customerId]++;
}
/**
* Get remaining task slots for a customer.
*/
private function getRemainingSlots(int $customerId): int
{
$currentCount = $this->customerTaskCount[$customerId] ?? 0;
return max(0, $this->maxTasksPerCustomer - $currentCount);
}
// ─────────────────────────────────────────────────────────────
// Task Creation with Approval Gate
// ─────────────────────────────────────────────────────────────
/**
* Create a task with approval state determined by the gate.
*
* This is the central method for creating tasks that respects
* the hidden approval layer, cooldown rules, per-brand profiles,
* and A/B message variant selection.
*/
private function createTaskWithApproval(array $taskData, ?Brand $brand = null): ?OrchestratorTask
{
$taskType = $taskData['type'];
$businessId = $taskData['business_id'] ?? null;
$customerId = $taskData['customer_id'] ?? null;
$brandId = $taskData['brand_id'] ?? $brand?->id;
// Configure gate with per-brand profile if available
if ($this->currentBusiness) {
$this->gate->withBrandProfile($this->currentBusiness, $brand);
}
// Get effective profile for this brand (for throttle settings)
$effectiveProfile = $this->gate->getBrandProfile();
// Check cooldown before creating task (uses brand-specific cooldown if set)
if ($customerId && $businessId && $this->gate->isWithinCooldown($businessId, $customerId)) {
// Customer was contacted recently, skip this task
return null;
}
// Check if this playbook is enabled for this brand
if ($effectiveProfile && ! $effectiveProfile->isPlaybookEnabled($taskType)) {
// Playbook disabled for this brand
return null;
}
// Ask the gate what approval state this task should have (uses brand-specific auto-approval)
$approval = $this->gate->determineApprovalState($taskType, $businessId, $customerId);
// Merge approval fields into task data
$taskData['approval_state'] = $approval['approval_state'];
$taskData['visible_to_reps'] = $approval['visible_to_reps'];
// Record scheduled_at for timing analytics
$taskData['scheduled_at'] = now();
// ──────────────────────────────────────────────────────────
// A/B Message Variant Selection
// ──────────────────────────────────────────────────────────
if ($businessId && Schema::hasTable('orchestrator_message_variants')) {
$taskData = $this->applyMessageVariant($taskData, $businessId, $brandId, $taskType);
}
// ──────────────────────────────────────────────────────────
// Run Tracking
// ──────────────────────────────────────────────────────────
if ($this->currentRun) {
$taskData['run_id'] = $this->currentRun->id;
}
if ($this->dryRun) {
return null;
}
$task = OrchestratorTask::create($taskData);
// Record task creation in run statistics
$this->recordRunTaskCreated($taskType);
return $task;
}
/**
* Get effective max tasks per customer for a brand.
*/
private function getMaxTasksForBrand(?Brand $brand): int
{
if (! $this->currentBusiness) {
return $this->maxTasksPerCustomer;
}
$profile = BrandOrchestratorProfile::effectiveFor($this->currentBusiness, $brand);
return $profile->getMaxTasksPerCustomerPerRun();
}
// ─────────────────────────────────────────────────────────────
// Playbook Status Check (Auto-Quarantine)
// ─────────────────────────────────────────────────────────────
/**
* Check if a playbook can generate tasks.
*
* Returns false if the playbook is paused or quarantined.
*/
private function canPlaybookGenerate(string $playbookType, ?int $businessId = null): bool
{
// Check if table exists (graceful handling for migrations)
if (! Schema::hasTable('orchestrator_playbook_statuses')) {
return true;
}
return OrchestratorPlaybookStatus::canPlaybookGenerate($playbookType, $businessId);
}
// ─────────────────────────────────────────────────────────────
// A/B Message Variant Helpers
// ─────────────────────────────────────────────────────────────
/**
* Apply a message variant to task data if variants exist.
*
* Uses round-robin selection based on playbook task index.
* Falls back to default message if no variants are configured.
*/
private function applyMessageVariant(array $taskData, int $businessId, ?int $brandId, string $taskType): array
{
// Get task index for round-robin (per playbook type)
$indexKey = "{$businessId}:{$brandId}:{$taskType}";
$taskIndex = $this->playbookTaskIndex[$indexKey] ?? 0;
// Select variant using round-robin
$variant = OrchestratorMessageVariant::selectVariant(
$businessId,
$brandId,
$taskType,
$taskIndex
);
if (! $variant) {
// No variants configured, keep default message
return $taskData;
}
// Increment task index for next selection
$this->playbookTaskIndex[$indexKey] = $taskIndex + 1;
// Build placeholders from task payload
$payload = $taskData['payload'] ?? [];
$placeholders = $this->buildPlaceholders($taskData, $payload);
// Render variant body with placeholders
$renderedMessage = $variant->renderBody($placeholders);
// Update payload with variant info
$payload['message_variant_key'] = $variant->variant_key;
$payload['message_variant_id'] = $variant->id;
$payload['suggested_message'] = $renderedMessage;
$payload['default_message'] = $payload['suggested_message'] ?? $renderedMessage; // Store original as fallback reference
$taskData['payload'] = $payload;
return $taskData;
}
/**
* Build placeholder values for message template rendering.
*/
private function buildPlaceholders(array $taskData, array $payload): array
{
$placeholders = [];
// Customer/buyer name
if (isset($taskData['customer_id'])) {
$customer = Business::find($taskData['customer_id']);
$placeholders['customer_name'] = $customer?->name ?? 'there';
$placeholders['customer_first_name'] = explode(' ', $placeholders['customer_name'])[0];
}
// Recipient name from payload
if (isset($payload['recipient_name'])) {
$placeholders['recipient_name'] = $payload['recipient_name'];
$placeholders['recipient_first_name'] = explode(' ', $payload['recipient_name'])[0];
}
// Brand name
if (isset($taskData['brand_id'])) {
$brand = Brand::find($taskData['brand_id']);
$placeholders['brand_name'] = $brand?->name ?? '';
}
if (isset($payload['brand_name'])) {
$placeholders['brand_name'] = $payload['brand_name'];
}
// Menu name
if (isset($taskData['menu_id'])) {
$menu = Menu::find($taskData['menu_id']);
$placeholders['menu_name'] = $menu?->name ?? '';
}
// Days values
if (isset($payload['days_since_order'])) {
$placeholders['days'] = $payload['days_since_order'];
}
// Product count (for new menu messages)
if (isset($payload['menu_count'])) {
$placeholders['product_count'] = $payload['menu_count'];
}
// Buyer name alias
if (isset($payload['buyer_name'])) {
$placeholders['buyer_name'] = $payload['buyer_name'];
}
return $placeholders;
}
}