- 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
1555 lines
67 KiB
PHP
1555 lines
67 KiB
PHP
<?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;
|
||
}
|
||
}
|