File: /home/xedaptot/ai.naniguide.com/app/Model/AbTest.php
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
use App\Library\Traits\HasUid;
use App\Services\AbTest\AssignmentStrategy;
use App\Services\AbTest\ExactSplitStrategy;
use App\Services\AbTest\HashSplitStrategy;
class AbTest extends Model
{
use HasUid;
protected $table = 'ab_tests';
public const CRITERIA_OPEN_RATE = 'open_rate';
public const CRITERIA_CLICK_RATE = 'click_rate';
public const CRITERIA_MANUAL = 'manual';
public const STRATEGY_EXACT = 'exact';
public const STRATEGY_HASH = 'hash';
protected $fillable = [
'uid', 'campaign_id', 'winner_criteria',
'winner_variant_id', 'test_percentage', 'winner_wait_hours', 'winner_sent_at',
'split_strategy',
];
protected $casts = [
'winner_sent_at' => 'datetime',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<Campaign, $this>
*/
public function campaign(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Campaign::class);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany<AbTestVariant, $this>
*/
public function variants(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(AbTestVariant::class)->orderBy('id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<AbTestVariant, $this>
*/
public function winnerVariant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(AbTestVariant::class, 'winner_variant_id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany<AbTestAssignment, $this>
*/
public function assignments(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(AbTestAssignment::class);
}
public function isDraft(): bool
{
return $this->campaign->status === Campaign::STATUS_NEW;
}
public function isRunning(): bool
{
return $this->campaign->status === Campaign::STATUS_SENDING;
}
public function isAwaitingWinner(): bool
{
return $this->campaign->status === Campaign::STATUS_AWAITING_WINNER;
}
public function isSendingWinner(): bool
{
return $this->campaign->status === Campaign::STATUS_SENDING_WINNER;
}
public function isCompleted(): bool
{
return $this->campaign->status === Campaign::STATUS_DONE;
}
public function isPaused(): bool
{
return $this->campaign->isPaused();
}
public function isError(): bool
{
return $this->campaign->isError();
}
/** Test is active (testing, awaiting winner, or sending winner phase). */
public function isActive(): bool
{
return in_array($this->campaign->status, [
Campaign::STATUS_SENDING,
Campaign::STATUS_AWAITING_WINNER,
Campaign::STATUS_SENDING_WINNER,
]);
}
/** Campaign is in error state AND the error occurred during the test-send phase. */
public function isErrorDuringTestPhase(): bool
{
return $this->campaign->isError()
&& $this->campaign->status === Campaign::STATUS_SENDING;
}
/** Campaign is in error state AND the error occurred during winner rollout. */
public function isErrorDuringWinnerPhase(): bool
{
return $this->campaign->isError()
&& $this->campaign->status === Campaign::STATUS_SENDING_WINNER;
}
/** Test covers a subset of the list; remaining gets winner after evaluation */
public function hasRemainingPhase(): bool
{
return $this->test_percentage < 100;
}
public function resolveStrategy(): AssignmentStrategy
{
return match ($this->split_strategy) {
self::STRATEGY_HASH => new HashSplitStrategy(),
default => new ExactSplitStrategy(),
};
}
/**
* Declare winner and optionally send to remaining subscribers.
* Shared by EvaluateAbTestWinner job and manual choose-winner controller.
*/
public function declareWinner(AbTestVariant $winner): void
{
$this->winner_variant_id = $winner->id;
$this->save();
$campaign = $this->campaign;
if ($this->hasRemainingPhase()) {
$campaign->status = Campaign::STATUS_SENDING_WINNER;
$campaign->save();
$this->sendWinnerToRemaining($winner);
} else {
$campaign->setDone();
}
}
/**
* Assign remaining subscribers to the winner variant, then re-trigger campaign sending.
*/
private function sendWinnerToRemaining(AbTestVariant $winner): void
{
$campaign = $this->campaign;
$listIds = $campaign->listsSegments->pluck('mail_list_id')->unique()->toArray();
if (empty($listIds)) {
return;
}
$listPlaceholders = implode(',', array_fill(0, count($listIds), '?'));
\Illuminate\Support\Facades\DB::statement("
INSERT IGNORE INTO ab_test_assignments (ab_test_id, subscriber_id, variant_id, created_at)
SELECT ?, s.id, ?, NOW()
FROM subscribers s
WHERE s.mail_list_id IN ({$listPlaceholders})
AND s.status = 'subscribed'
AND s.email NOT IN (
SELECT sub2.email FROM tracking_logs tl
JOIN subscribers sub2 ON tl.subscriber_id = sub2.id
WHERE tl.campaign_id = ? AND tl.status != 'duplicate'
)
AND s.id NOT IN (
SELECT subscriber_id FROM ab_test_assignments WHERE ab_test_id = ?
)
", array_merge([$this->id, $winner->id], $listIds, [$campaign->id, $this->id]));
$this->winner_sent_at = now();
$this->save();
$campaign->run($check = false, ACM_QUEUE_TYPE_BATCH);
}
}