File: /home/xedaptot/ai.naniguide.com/database/seeders/Demo/Stage14_AutomationsSeeder.php
<?php
namespace Database\Seeders\Demo;
use App\Domain\Automation\Enum\Branch;
use App\Domain\Automation\Enum\NodeType;
use App\Domain\Automation\Enum\TriggerKey;
use App\Domain\Automation\Flow;
use App\Domain\Automation\FlowMutator;
use App\Domain\Automation\Node;
use App\Domain\Automation\Runtime\Phase;
use App\Model\Automation2;
use App\Model\AutomationEmail;
use App\Model\AutoTrigger;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\Template;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Stage 14 — "My Sales Pipeline" automation under Emma + 500 trigger rows
* spread across every Phase / branch. Tuned for screenshot demos:
*
* Trigger(welcome-new-subscriber)
* → Send #1 (template: Welcome customers) ← welcome message
* → Wait(P3D)
* → Condition #1 (open Send #1, timeout P5D)
* ├─ Yes → Operation(tag: engaged-customer) [leaf]
* └─ No → Send #2 (template: Order Again) ← reminder
* → Condition #2 (open Send #2, timeout P5D)
* ├─ Yes → Operation(tag: engaged-customer) [leaf]
* └─ No → (leaf — silent drop-off)
*
* Bound to "Webinar Signups (Awesome Solutions)" (~2.3k subscribed) — a
* mid-sized list so the Subscribers page shows a healthy mix of involved /
* not-yet-involved members rather than the entire mailing list.
*
* Trigger rows: ~60% of the list's subscribed members get an auto_triggers
* row (the rest are "not in workflow"). Distribution covers all branches +
* all phases — see DIST_PCT for exact percentages:
* - 40% completed across 3 outcomes (Yes-fast / Yes-late / No-No drop-off)
* - 10% failed at random in-flow node
* - 50% in-flight at every wait / send / condition / operation node
*
* Tracking data: every subscriber past a Send node gets one tracking_logs
* row + (~45%) open_logs + (~12%) click_logs, IPs picked from the fixed
* 100-IP pool reused from Stage11 so geo charts stay coherent.
*
* Run order: AFTER Stage07 (lists), Stage08 (subscribers), Stage09 (templates),
* Stage11 (so IP_POOL is loaded).
*/
class Stage14_AutomationsSeeder extends AbstractDemoSeeder
{
protected int $fakerOffset = 14;
protected const TARGET_LIST_NAME = 'Webinar Signups (Awesome Solutions)';
protected const AUTOMATION_NAME = 'My Sales Pipeline';
/** Fraction of the target list's subscribed members to involve in the automation. */
protected const INVOLVE_FRACTION = 0.60;
protected const ENGAGED_TAG = 'engaged-customer';
protected const TRACKED_LINK_URL = 'https://acelle-demo.com/sales-pipeline-cta';
/** Tracking sim rates — match Stage11 distribution so charts read consistent. */
protected const OPEN_RATE = 0.45;
protected const CLICK_RATE = 0.12;
/**
* Phase distribution as PERCENTAGES (must sum to 1.0). Applied to the
* dynamic total computed from `INVOLVE_FRACTION × subscribed-count`.
*
* Buckets map to (phase, current_node) pairs and predict which Send nodes
* each subscriber has been past (drives tracking-log generation downstream).
*/
protected const DIST_PCT = [
// Terminal — reached a leaf (40% total)
'completed_yes_fast' => 0.20, // Send1 → Wait → Cond1(Yes) → Op1(engaged) → done
'completed_yes_late' => 0.10, // Send1 → Wait → Cond1(No) → Send2 → Cond2(Yes) → Op2(engaged) → done
'completed_no_no' => 0.10, // Send1 → Wait → Cond1(No) → Send2 → Cond2(No) → done (silent drop)
'failed' => 0.10, // failed somewhere along the way
// In-flight (50% total)
'send1_queued' => 0.08, // queued at Send #1, hasn't sent yet
'wait_after_welcome' => 0.12, // past Send1, waiting at Wait(P3D)
'cond1_waiting' => 0.08, // at Cond1, waiting for open timeout
'op1_ready' => 0.05, // at Op1 (Yes branch, just landed there)
'send2_queued' => 0.06, // at Send #2 (No branch from Cond1)
'cond2_waiting' => 0.06, // at Cond2, waiting for open timeout
'op2_ready' => 0.05, // at Op2 (Yes branch from Cond2)
];
public function run(): void
{
$emma = Stage03_CustomersSeeder::$created['primary'] ?? null;
if (!$emma) {
$this->log('emma customer not found — skipping');
return;
}
$list = MailList::where('customer_id', $emma->id)
->where('name', self::TARGET_LIST_NAME)
->first();
if (!$list) {
$this->log('target list "' . self::TARGET_LIST_NAME . '" not found — skipping');
return;
}
$templates = $this->resolveTemplates();
if (count($templates) < 2) {
$this->log('need 2 named templates (Welcome customers / Order Again) — skipping');
return;
}
$sendingServer = SendingServer::whereNull('customer_id')->first();
if (!$sendingServer) {
$this->log('no system sending server — skipping');
return;
}
// ── 1. Build flow + persist Automation2 ─────────────────────────────
[$flow, $nodes, $autoEmails] = $this->buildFlowAndEmails($emma->id, $templates);
$automation = new Automation2();
$automation->customer_id = $emma->id;
$automation->name = self::AUTOMATION_NAME;
$automation->mail_list_id = $list->id;
$automation->status = Automation2::STATUS_ACTIVE;
$automation->data = $flow->toJson();
$automation->save();
// Stitch each AutomationEmail row to its Send node id (so the runtime
// can resolve emailUid → email when a Send tick fires). Order matches
// resolveTemplates(): [0]=Welcome customers→send1, [1]=Order Again→send2.
$sendNodeIds = [$nodes['send1'], $nodes['send2']];
foreach ($autoEmails as $i => $autoEmail) {
$autoEmail->automation2_id = $automation->id;
$autoEmail->action_id = $sendNodeIds[$i];
$autoEmail->save();
}
// Re-write flow JSON now that AutomationEmail UIDs are known
// (Send node `data.emailUid` placeholders → real UIDs).
$automation->data = $this->bindEmailUids($flow, $autoEmails)->toJson();
$automation->save();
$this->log("created automation #{$automation->id} '{$automation->name}' on list '{$list->name}' (2 emails)");
// ── 2. Pick 60% of the list's subscribed members ─────────────────────
// The other 40% stay outside the automation so the Subscribers page
// shows a realistic "in workflow vs. not-yet-involved" split.
$subscribedTotal = (int) DB::table('subscribers')
->where('mail_list_id', $list->id)
->where('status', Subscriber::STATUS_SUBSCRIBED)
->count();
$involveCount = (int) round($subscribedTotal * self::INVOLVE_FRACTION);
$subscriberIds = DB::table('subscribers')
->where('mail_list_id', $list->id)
->where('status', Subscriber::STATUS_SUBSCRIBED)
->orderBy('id')
->limit($involveCount)
->pluck('id')
->all();
$this->log("list has {$subscribedTotal} subscribed members; involving "
. count($subscriberIds) . ' (~' . (int) (self::INVOLVE_FRACTION * 100) . '%)');
// ── 3. Insert AutoTrigger rows + tracking data ──────────────────────
$this->seedTriggerRowsAndTracking($automation, $autoEmails, $nodes, $subscriberIds, $sendingServer->id, $emma->id);
}
/**
* Build the flow graph + AutomationEmail rows. Returns [Flow, nodeIdMap, [AutomationEmail × 3]].
* The Send nodes get placeholder emailUids initially because AutomationEmail rows are inserted
* AFTER Automation2 (FK). We re-bind in the caller via bindEmailUids().
*/
protected function buildFlowAndEmails(int $customerId, array $templates): array
{
$flow = new Flow(
[new Node(Flow::TRIGGER_ID, NodeType::Trigger, ['key' => TriggerKey::WelcomeNewSubscriber->value])],
[]
);
// Trigger → Send #1 (welcome) → Wait(P3D) → Cond #1
[$flow, $send1] = FlowMutator::insertAfter($flow, Flow::TRIGGER_ID, null, NodeType::Send,
['emailUid' => '__send1__', 'emailSubject' => 'Welcome to the family']);
[$flow, $wait] = FlowMutator::insertAfter($flow, $send1->id, null, NodeType::Wait,
['duration' => 'P3D']);
[$flow, $cond1] = FlowMutator::insertAfter($flow, $wait->id, null, NodeType::Condition,
['kind' => 'open', 'emailUid' => '__send1__', 'timeout' => 'P5D']);
// Cond #1 Yes → Op #1 (engaged-customer tag) [leaf]
[$flow, $op1] = FlowMutator::insertAfter($flow, $cond1->id, Branch::Yes, NodeType::Operation,
['kind' => 'tag', 'tags' => [self::ENGAGED_TAG]]);
// Cond #1 No → Send #2 (reminder) → Cond #2
[$flow, $send2] = FlowMutator::insertAfter($flow, $cond1->id, Branch::No, NodeType::Send,
['emailUid' => '__send2__', 'emailSubject' => 'Just a friendly reminder']);
[$flow, $cond2] = FlowMutator::insertAfter($flow, $send2->id, null, NodeType::Condition,
['kind' => 'open', 'emailUid' => '__send2__', 'timeout' => 'P5D']);
// Cond #2 Yes → Op #2 (engaged-customer tag) [leaf]
// Cond #2 No → (no node — silent drop-off, leaf)
[$flow, $op2] = FlowMutator::insertAfter($flow, $cond2->id, Branch::Yes, NodeType::Operation,
['kind' => 'tag', 'tags' => [self::ENGAGED_TAG]]);
$nodes = [
'trigger' => Flow::TRIGGER_ID,
'send1' => $send1->id,
'wait' => $wait->id,
'cond1' => $cond1->id,
'op1' => $op1->id,
'send2' => $send2->id,
'cond2' => $cond2->id,
'op2' => $op2->id,
];
// 2 AutomationEmail rows (welcome + reminder)
$autoEmails = [];
$subjects = ['Welcome to the family', 'Just a friendly reminder'];
foreach ($templates as $i => $template) {
$ae = AutomationEmail::newDefault();
$ae->customer_id = $customerId;
$ae->subject = $subjects[$i];
$ae->from_email = '[email protected]';
$ae->from_name = 'Awesome Solutions Sales';
$ae->reply_to = '[email protected]';
$ae->action_id = '';
$ae->automation2_id = 0;
$autoEmails[] = $ae;
}
return [$flow, $nodes, $autoEmails];
}
/**
* Replace placeholder `__sendN__` emailUid strings in the flow (used in
* BOTH Send and Condition nodes) with the just-saved AutomationEmail UIDs.
* Returns a new Flow (immutable shape).
*
* Map: __send1__ → autoEmails[0]->uid, __send2__ → autoEmails[1]->uid.
*/
protected function bindEmailUids(Flow $flow, array $autoEmails): Flow
{
$arr = json_decode($flow->toJson(), true);
$map = ['__send1__' => $autoEmails[0]->uid, '__send2__' => $autoEmails[1]->uid];
foreach ($arr['nodes'] as &$n) {
if (isset($n['data']['emailUid']) && isset($map[$n['data']['emailUid']])) {
$n['data']['emailUid'] = $map[$n['data']['emailUid']];
}
}
return Flow::fromJson(json_encode($arr));
}
/**
* Resolve the 2 named templates: Welcome customers (welcome message) +
* Order Again (reminder). Pick the lowest-id master (system template,
* before customer copies) for each.
*/
protected function resolveTemplates(): array
{
$names = ['Welcome customers', 'Order Again'];
$out = [];
foreach ($names as $name) {
$tpl = Template::where('name', $name)->orderBy('id')->first();
if ($tpl) $out[] = $tpl;
}
return $out;
}
/**
* Stitch each AutomationEmail's Email body to a real Template's HTML, so
* preview / open / click pages render real content (not placeholder).
* Called after AutomationEmail->save() because we need email_id populated.
*/
protected function bindTemplatesToEmails(array $autoEmails, array $templates): void
{
foreach ($autoEmails as $i => $ae) {
if (!$ae->email || empty($templates[$i])) continue;
try {
$ae->email->setTemplate($templates[$i], $templates[$i]->name);
$ae->email->save();
} catch (\Throwable $e) {
$this->log("template bind failed for email #{$ae->id}: " . $e->getMessage());
}
}
}
/**
* Insert 500 AutoTrigger rows distributed across phases per self::DIST,
* plus tracking_logs / open_logs / click_logs for each subscriber that
* progressed past a Send node.
*/
protected function seedTriggerRowsAndTracking(
Automation2 $automation,
array $autoEmails, // [AutomationEmail × 3]
array $nodes, // node id map
array $subscriberIds,
int $sendingServerId,
int $customerId,
): void {
$f = $this->faker();
$available = count($subscriberIds);
// Convert DIST_PCT → integer counts proportional to $available, with
// the LAST bucket absorbing rounding error so the total still matches
// exactly. Iteration order is preserved because DIST_PCT is an array literal.
$bucketKeys = array_keys(self::DIST_PCT);
$lastKey = end($bucketKeys);
$counts = [];
$assigned = 0;
foreach (self::DIST_PCT as $bucket => $pct) {
if ($bucket === $lastKey) {
$counts[$bucket] = max(0, $available - $assigned);
} else {
$n = (int) round($available * $pct);
$counts[$bucket] = $n;
$assigned += $n;
}
}
// Slice the subscriber array into per-bucket allocations — no overlap.
$cursor = 0;
$alloc = [];
foreach ($counts as $bucket => $count) {
$take = min($count, max(0, $available - $cursor));
$alloc[$bucket] = array_slice($subscriberIds, $cursor, $take);
$cursor += $take;
}
// Bind templates AFTER autoEmails are saved (we need email_id). The
// caller saved them via the Automation2 FK loop, so this is safe now.
$this->bindTemplatesToEmails($autoEmails, $this->resolveTemplates());
$triggerRows = [];
$now = Carbon::now();
// ── Build trigger rows per bucket ────────────────────────────────────
foreach ($alloc as $bucket => $subs) {
foreach ($subs as $subId) {
// entryAt: spread last 30 days
$entryAt = $now->copy()->subDays($f->numberBetween(1, 30))->subHours($f->numberBetween(0, 23));
$row = $this->makeTriggerRow($bucket, $subId, $automation->id, $customerId, $nodes, $entryAt, $f);
$triggerRows[] = $row;
}
}
// ── Bulk insert ──────────────────────────────────────────────────────
foreach (array_chunk($triggerRows, 500) as $chunk) {
DB::table('auto_triggers')->insert($chunk);
}
// ── Tag subscribers whose run reached an Operation node ─────────────
// Both Operation nodes apply the same `engaged-customer` tag. We
// replicate the handler's side effect in DB so Subscribers-page tag
// chips + the "engaged-customer" segment look coherent.
//
// Reached Op1 (Yes-fast path): completed_yes_fast + op1_ready
// Reached Op2 (Yes-late path): completed_yes_late + op2_ready
$taggedBuckets = ['completed_yes_fast', 'op1_ready', 'completed_yes_late', 'op2_ready'];
$taggedIds = [];
foreach ($taggedBuckets as $b) {
foreach ($alloc[$b] ?? [] as $id) $taggedIds[] = $id;
}
if (!empty($taggedIds)) {
$tagJson = json_encode([self::ENGAGED_TAG]);
// JSON_MERGE_PRESERVE keeps any existing tags; coalesce NULL→'[]' first.
DB::table('subscribers')->whereIn('id', $taggedIds)->update([
'tags' => DB::raw("JSON_MERGE_PRESERVE(COALESCE(tags, JSON_ARRAY()), '{$tagJson}')"),
]);
}
// ── Tracking-log generation: which Sends has each bucket been past? ─
//
// Flow recap: Trigger → Send1 → Wait → Cond1 → (Yes: Op1) | (No: Send2 → Cond2 → Yes: Op2)
//
// 'completed_yes_fast' : Send1 delivered, Cond1 saw open → Op1 → done
// 'completed_yes_late' : Send1 delivered, Cond1 No → Send2 delivered, Cond2 Yes → Op2 → done
// 'completed_no_no' : Send1 delivered, Cond1 No → Send2 delivered, Cond2 No → done
// 'op1_ready' : past Send1 (at Op1)
// 'op2_ready' : past Send1 + Send2 (at Op2)
// 'cond1_waiting' : past Send1
// 'cond2_waiting' : past Send1 + Send2
// 'send2_queued' : past Send1 (Send2 not delivered yet)
// 'wait_after_welcome' : past Send1
// 'send1_queued' : Send1 not delivered yet
// 'failed' : assume past Send1 (most failures happen after the first send)
$pastSends = [
'completed_yes_fast' => ['send1'],
'completed_yes_late' => ['send1', 'send2'],
'completed_no_no' => ['send1', 'send2'],
'op1_ready' => ['send1'],
'op2_ready' => ['send1', 'send2'],
'cond1_waiting' => ['send1'],
'cond2_waiting' => ['send1', 'send2'],
'send2_queued' => ['send1'],
'wait_after_welcome' => ['send1'],
'send1_queued' => [],
'failed' => ['send1'],
];
$totalTracking = $totalOpens = $totalClicks = 0;
$ipPool = Stage11_TrackingLogsSeeder::IP_POOL;
foreach ($alloc as $bucket => $subs) {
$sends = $pastSends[$bucket] ?? [];
if (empty($sends) || empty($subs)) continue;
foreach ($sends as $sendKey) {
$autoEmail = $autoEmails[(int) substr($sendKey, -1) - 1];
$sentBase = $now->copy()->subDays($f->numberBetween(1, 30));
$trackingRows = [];
$openRows = [];
$clickRows = [];
foreach ($subs as $subId) {
$sentAt = $sentBase->copy()->addMinutes($f->numberBetween(0, 60 * 24));
if ($sentAt->isFuture()) $sentAt = $now;
$messageId = Str::uuid()->toString();
$trackingRows[] = [
'runtime_message_id' => Str::random(20),
'message_id' => $messageId,
'customer_id' => $customerId,
'sending_server_id' => $sendingServerId,
'campaign_id' => null,
'automation_email_id' => $autoEmail->id,
'subscriber_id' => $subId,
'status' => 'sent',
'created_at' => $sentAt,
'updated_at' => $sentAt,
];
if ($f->boolean(self::OPEN_RATE * 100)) {
$window = max(1, $sentAt->diffInMinutes($now));
$openedAt = $sentAt->copy()->addMinutes($f->numberBetween(1, min(60 * 24 * 7, $window)));
if ($openedAt->isFuture()) $openedAt = $now;
$ip = $f->randomElement($ipPool);
$openRows[] = [
'message_id' => $messageId,
'customer_id' => $customerId,
'tracking_log_id' => null, // backfilled after tracking insert
'ip_address' => $ip,
'user_agent' => $f->userAgent,
'created_at' => $openedAt,
'updated_at' => $openedAt,
];
if ($f->boolean(self::CLICK_RATE * 100)) {
$clickWindow = max(1, $openedAt->diffInMinutes($now));
$clickedAt = $openedAt->copy()->addMinutes($f->numberBetween(1, min(60, $clickWindow)));
if ($clickedAt->isFuture()) $clickedAt = $now;
$clickRows[] = [
'message_id' => $messageId,
'customer_id' => $customerId,
'tracking_log_id' => null, // backfilled after tracking insert
'url' => self::TRACKED_LINK_URL,
'ip_address' => $ip,
'user_agent' => $f->userAgent,
'created_at' => $clickedAt,
'updated_at' => $clickedAt,
];
}
}
}
foreach (array_chunk($trackingRows, 500) as $chunk) DB::table('tracking_logs')->insert($chunk);
// Backfill tracking_log_id on open/click rows (production code
// populates this FK; without it, queries that JOIN via
// tracking_log_id return 0 — same bug as Stage11 for campaigns).
if (!empty($openRows) || !empty($clickRows)) {
$messageIds = array_column($trackingRows, 'message_id');
$idMap = [];
foreach (array_chunk($messageIds, 1000) as $mChunk) {
foreach (DB::table('tracking_logs')->whereIn('message_id', $mChunk)->get(['id', 'message_id']) as $row) {
$idMap[$row->message_id] = $row->id;
}
}
foreach ($openRows as &$r) { $r['tracking_log_id'] = $idMap[$r['message_id']] ?? null; }
unset($r);
foreach ($clickRows as &$r) { $r['tracking_log_id'] = $idMap[$r['message_id']] ?? null; }
unset($r);
}
foreach (array_chunk($openRows, 500) as $chunk) DB::table('open_logs')->insert($chunk);
foreach (array_chunk($clickRows, 500) as $chunk) DB::table('click_logs')->insert($chunk);
$totalTracking += count($trackingRows);
$totalOpens += count($openRows);
$totalClicks += count($clickRows);
}
}
$this->log(sprintf(
'seeded %d trigger rows (%s); tracking=%d opens=%d clicks=%d',
array_sum(array_map('count', $alloc)),
implode(', ', array_map(fn ($k) => "$k=" . count($alloc[$k]), array_keys($alloc))),
$totalTracking,
$totalOpens,
$totalClicks,
));
}
/**
* Compose one auto_triggers DB row matching the bucket's intended phase /
* current_node / state shape. Bucket → (phase, current_node) mapping is
* documented in self::DIST.
*/
protected function makeTriggerRow(string $bucket, int $subId, int $autoId, int $customerId, array $nodes, Carbon $entryAt, \Faker\Generator $f): array
{
$base = [
'uid' => Str::uuid()->toString(),
'subscriber_id' => $subId,
'automation2_id' => $autoId,
'customer_id' => $customerId,
'created_at' => $entryAt,
'updated_at' => $entryAt->copy()->addMinutes($f->numberBetween(1, 60 * 24 * 7)),
'state' => json_encode([]),
'history' => json_encode([['outcome' => 'entry', 'at' => $entryAt->toIso8601String()]]),
'scheduled_at' => null,
'error' => null,
'held_at' => null,
'trigger_session_id' => null,
];
// Helper: standard "queued at Send" state (matches SendHandler 15-min safety net)
$queuedSendState = fn () => json_encode([
'queued' => true,
'queued_at' => Carbon::now()->subMinutes($f->numberBetween(1, 30))->toIso8601String(),
'job_id' => $f->numberBetween(1000, 9999),
]);
return match ($bucket) {
// ── Terminal ──────────────────────────────────────────────────────
'completed_yes_fast' => array_merge($base, [
'phase' => Phase::Completed->value,
'current_node_id' => $nodes['op1'],
'updated_at' => $entryAt->copy()->addDays($f->numberBetween(5, 12)),
'state' => json_encode(['sent_at' => $entryAt->copy()->addDays(1)->toIso8601String()]),
]),
'completed_yes_late' => array_merge($base, [
'phase' => Phase::Completed->value,
'current_node_id' => $nodes['op2'],
'updated_at' => $entryAt->copy()->addDays($f->numberBetween(10, 20)),
'state' => json_encode(['sent_at' => $entryAt->copy()->addDays(10)->toIso8601String()]),
]),
'completed_no_no' => array_merge($base, [
'phase' => Phase::Completed->value,
'current_node_id' => $nodes['cond2'], // Cond2 No branch is a leaf
'updated_at' => $entryAt->copy()->addDays($f->numberBetween(13, 20)),
'state' => json_encode(['startedAt' => $entryAt->copy()->addDays(8)->toIso8601String()]),
]),
'failed' => array_merge($base, [
'phase' => Phase::Failed->value,
'current_node_id' => $f->randomElement([$nodes['send1'], $nodes['cond1'], $nodes['send2'], $nodes['cond2']]),
'error' => $f->randomElement([
'OutOfCredits: customer has 0 sending credits',
'SMTP error 550: mailbox does not exist',
'NodeOptionsValidator: invalid email template binding',
'Sending server timeout after 30s',
]),
]),
// ── In-flight: at Send nodes (queued, 15-min safety-net wait) ────
'send1_queued' => array_merge($base, [
'phase' => Phase::Waiting->value,
'current_node_id' => $nodes['send1'],
'scheduled_at' => Carbon::now()->addMinutes($f->numberBetween(1, 15)),
'state' => $queuedSendState(),
]),
'send2_queued' => array_merge($base, [
'phase' => Phase::Waiting->value,
'current_node_id' => $nodes['send2'],
'scheduled_at' => Carbon::now()->addMinutes($f->numberBetween(1, 15)),
'state' => $queuedSendState(),
]),
// ── In-flight: at Wait node ──────────────────────────────────────
'wait_after_welcome' => array_merge($base, [
'phase' => Phase::Waiting->value,
'current_node_id' => $nodes['wait'],
'scheduled_at' => Carbon::now()->addDays($f->numberBetween(1, 3)),
'state' => json_encode(['scheduled' => true]),
]),
// ── In-flight: at Condition nodes (waiting on open timeout) ──────
'cond1_waiting' => array_merge($base, [
'phase' => Phase::Waiting->value,
'current_node_id' => $nodes['cond1'],
'scheduled_at' => Carbon::now()->addDays($f->numberBetween(1, 5)),
'state' => json_encode([
'startedAt' => Carbon::now()->subDays($f->numberBetween(0, 2))->toIso8601String(),
]),
]),
'cond2_waiting' => array_merge($base, [
'phase' => Phase::Waiting->value,
'current_node_id' => $nodes['cond2'],
'scheduled_at' => Carbon::now()->addDays($f->numberBetween(1, 5)),
'state' => json_encode([
'startedAt' => Carbon::now()->subDays($f->numberBetween(0, 2))->toIso8601String(),
]),
]),
// ── In-flight: at Operation nodes (just landed, ready to apply) ──
'op1_ready' => array_merge($base, [
'phase' => Phase::Running->value,
'current_node_id' => $nodes['op1'],
'state' => json_encode([]),
]),
'op2_ready' => array_merge($base, [
'phase' => Phase::Running->value,
'current_node_id' => $nodes['op2'],
'state' => json_encode([]),
]),
};
}
}