<?php
/**
* Created by PhpStorm.
* User: jibi
* Date: 19/04/2018
* Time: 15:46
*/
namespace App\Services;
use App\Entity\Main\Cart;
use App\Entity\Main\Site;
use App\Entity\Main\SiteABTest;
use App\Entity\Main\SitePackTemplate;
use App\Entity\Main\Tracking;
use App\Entity\Main\TrackingHasABTest;
use App\Entity\Main\User;
use App\EventListener\Main\DebugListener;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class ABTestManager
{
const
SESSION_IDENTIFIER = 'client_test'
;
private $activeTests;
private mixed $clientTests = null;
private ?Request $request;
private EntityRepository $siteABTestsRepo;
/**
* @var array|mixed
*/
private mixed $forcedABTests;
public function __construct(
private RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private $redisClient
) {
$this->request = $this->requestStack->getCurrentRequest();
if ($this->request) {
$this->clientTests = $this->requestStack->getSession()->get(self::SESSION_IDENTIFIER);
$this->forcedABTests = $this->requestStack->getSession()->get(DebugListener::DEBUG_DATA_SESSION)[DebugListener::DEBUG_FORCED_ABTESTS] ?? [];
}
// If some AB tests are forced, we use them here.
$this->siteABTestsRepo = $entityManager->getRepository(SiteABTest::class);
}
/**
* @param Tracking $tracking
* @return array|mixed|null
*/
public function getTestsForTracking(Tracking $tracking)
{
if (null == $this->clientTests) {
try {
$this->clientTests = $tracking->getAbTests();
} catch (\Exception $e) {
$this->logger->critical("Could not access tracking ABTESTS with the following exception : {$e->getMessage()}");
$this->clientTests = [];
}
}
return $this->clientTests;
}
/**
* @return ArrayCollection|null
*/
public function getActiveTests()
{
if (count($this->activeTests)) {
return $this->activeTests;
}
}
/**
* $registered will only create ab test for registered clients.
* it shouldn't be used for anonymous users.
*
* @param Tracking $tracking
* @param Site $site
* @param string $testType
* @param bool $save
* @throws \Exception
*/
public function generateTestsForTracking(Tracking $tracking, Site $site, $testType = SiteABTest::TYPE_GENERAL, $save = true)
{
if (count($tracking->getAbTests()) && !in_array($testType, [SiteABTest::TYPE_REGISTERED, SiteABTest::TYPE_CART_SWITCH])) {
$this->logger->critical("Trying to add new ab test to an existing tracking.");
return;
}
$this->loadActiveTests($site);
$needSave = false;
// create new tests for this tracking
if (count($this->activeTests) && !in_array($testType, [SiteABTest::TYPE_REGISTERED, SiteABTest::TYPE_CART_SWITCH])) {
/** @var SiteABTest $activeTest */
foreach ($this->activeTests as $activeTest) {
if ($activeTest->isActive() && $activeTest->isGeneral() && $this->isEligibleForTest($tracking, $activeTest)) {
$version = $this->getVersionForTest($activeTest);
// For the test for new design, Chris want us to keep the version of the test in the tracking o3 field.
if ($activeTest->getAlias() == SiteABTest::BRU_DESIGN_V_ALIAS) {
$tracking->setO3($version);
}
$newTest = new TrackingHasABTest();
$newTest->setSiteAbTest($activeTest);
$newTest->setTracking($tracking);
$newTest->setVersion($version);
$this->entityManager->persist($newTest);
$tracking->addAbTest($newTest);
$needSave = true;
}
}
if ($needSave && $save) {
$this->entityManager->flush();
}
return;
}
// if this is for registered user, we create only registered abTests
// We create those test ONLY if there is not already a registered user for this tracking.
if (count($this->activeTests) && $testType == SiteABTest::TYPE_REGISTERED && count($tracking->getUsers()) < 2) {
/** @var SiteABTest $activeTest */
foreach ($this->activeTests as $activeTest) {
if ($activeTest->isActive() && $activeTest->isForRegisteredUsers() && $this->isEligibleForTest($tracking, $activeTest)) {
$version = $this->getVersionForTest($activeTest);
$newTest = new TrackingHasABTest();
$newTest->setSiteAbTest($activeTest);
$newTest->setTracking($tracking);
$newTest->setVersion($version);
$this->entityManager->persist($newTest);
$tracking->addAbTest($newTest);
$needSave = true;
}
}
if ($needSave && $save) {
$this->entityManager->flush();
}
return;
}
// if this is for cart switch, we create only cartSwitch abTests
// We create those test ONLY if there is not already a registered user for this tracking.
if (count($this->activeTests) && $testType == SiteABTest::TYPE_CART_SWITCH && count($tracking->getUsers()) < 2) {
/** @var SiteABTest $activeTest */
foreach ($this->activeTests as $activeTest) {
if ($activeTest->isActive() && $activeTest->isForCartSwitch() && $this->isEligibleForTest($tracking, $activeTest)) {
$version = $this->getVersionForTest($activeTest);
$newTest = new TrackingHasABTest();
$newTest->setSiteAbTest($activeTest);
$newTest->setTracking($tracking);
$newTest->setVersion($version);
$this->entityManager->persist($newTest);
$tracking->addAbTest($newTest);
$needSave = true;
}
}
if ($needSave && $save) {
$this->entityManager->flush();
}
return;
}
}
/**
* Check if a cart can receive some switch abtest OR a specific one if a siteABtest is provided.
*
* @param Cart $cart
* @param Site $site
* @param SiteABTest|null $siteABTest
* @return bool
*/
public function cartCanReceiveSwitchTest(Cart $cart, Site $site, SiteABTest $siteABTest = null)
{
// if this cart has no user, he can't receive an ABtest.
if (!$cart->getUser() instanceof User) {
return false;
}
// Carts with custom packs don't receive any switch AB test.
if ($cart->isWithCustomPack()) {
return false;
}
// a user that already purchased, can't get a switch test
if ($cart->getUser() instanceof User && $cart->getUser()->getLastOrder() instanceof Cart) {
return false;
}
if ($siteABTest instanceof SiteABTest) {
if (!$siteABTest->isForCartSwitch() || !$siteABTest->isActive()) {
return false;
}
$abTests = [$siteABTest];
} else {
$this->loadActiveTests($site);
// If not active Test, then no :)
if (!count($this->activeTests)) {
return false;
}
$abTests = $this->activeTests;
}
// ok so if there is an ABtest for cartSwitch that was created before the registration of this user, we can apply it.
/** @var SiteABTest $activeTest */
foreach ($abTests as $activeTest) {
if ($activeTest->isActive() && $activeTest->isForCartSwitch() && $cart->getUser()->getCreationStamp() >= $activeTest->getDateStarted()) {
return true;
}
}
return false;
}
/**
* @param User $user
* @param Site $site
* @param SiteABTest|null $siteABTest
* @return bool
*/
public function userCanReceiveRegisterTest(User $user, Site $site, SiteABTest $siteABTest = null)
{
if ($siteABTest instanceof SiteABTest) {
if (!$siteABTest->isForRegisteredUsers() || !$siteABTest->isActive()) {
return false;
}
$abTests = [$siteABTest];
} else {
$this->loadActiveTests($site);
// If not active Test, then no :)
if (!count($this->activeTests)) {
return false;
}
$abTests = $this->activeTests;
}
// When a user is logged in, his tracking is ALWAYS the same than the current tracking, so we can use his tracking.
$tracking = $user->getTracking();
// ok so if there is an ABtest for register that was created before the registration of this user, we can apply it.
/** @var SiteABTest $activeTest */
foreach ($abTests as $activeTest) {
if ($activeTest->isActive() && $activeTest->isForRegisteredUsers() && $user->getCreationStamp() >= $activeTest->getDateStarted() && $this->userIsEligibleForTest($user, $activeTest)) {
return true;
}
}
return false;
}
/**
* @param Tracking $tracking
* @param $version
* @return bool
*/
public function hasTest(Tracking $tracking, $version)
{
$hasVersion = false;
$clientTests = $this->getTestsForTracking($tracking);
// If some forced test are provided in the session, we use only those.
$debugData = $this->requestStack->getSession()->get(DebugListener::DEBUG_DATA_SESSION);
if (!empty($debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = false;
if (is_array($version)) {
foreach ($version as $loopVersion) {
if (in_array($loopVersion, $debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = true;
break;
}
}
} else {
if (in_array($version, $debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = true;
}
}
return $hasTest;
}
if (count($this->clientTests) && is_string($version)) {
/** @var TrackingHasABTest $clientTest */
foreach ($clientTests as $clientTest) {
if ($clientTest->getVersion() == $version && $clientTest->getSiteABTest()->isActive()) {
$hasVersion = true;
}
}
} elseif (count($this->clientTests) && is_array($version) && count($version)) {
/** @var TrackingHasABTest $clientTest */
foreach ($clientTests as $clientTest) {
foreach ($version as $string) {
if ($clientTest->getVersion() == $string && $clientTest->getSiteABTest()->isActive()) {
$hasVersion = true;
}
}
if ($hasVersion) {
break;
}
}
}
return $hasVersion;
}
/**
* @param Cart $cart
* @param $version
* @return bool
*/
public function cartHasTest(Cart $cart, $version)
{
$hasVersion = false;
$cartTests = $cart->getAbTests();
// If some forced test are provided in the session, we use only those.
$debugData = $this->requestStack->getSession()->get(DebugListener::DEBUG_DATA_SESSION);
if (!empty($debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = false;
if (is_array($version)) {
foreach ($version as $loopVersion) {
if (in_array($loopVersion, $debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = true;
break;
}
}
} else {
if (in_array($version, $debugData[DebugListener::DEBUG_FORCED_ABTESTS])) {
$hasTest = true;
}
}
return $hasTest;
}
if (count($cartTests) && is_string($version)) {
/** @var TrackingHasABTest $cartTest */
foreach ($cartTests as $cartTest) {
if ($cartTest->getVersion() == $version && $cartTest->getSiteABTest()->isActive()) {
$hasVersion = true;
}
}
} elseif (count($cartTests) && is_array($version) && count($version)) {
/** @var TrackingHasABTest $clientTest */
foreach ($cartTests as $cartTest) {
foreach ($version as $string) {
if ($cartTest->getVersion() == $string && $cartTest->getSiteABTest()->isActive()) {
$hasVersion = true;
}
}
if ($hasVersion) {
break;
}
}
}
return $hasVersion;
}
/**
* This is used for the free shipping AB test.
* @return mixed
*/
public function getFreeShippingTestValues()
{
return json_decode('{"AT":{"0-+":0},"BE":{"0-+":0},"BL":{"0-+":0},"CA":{"0-+":0},"CH":{"0-+":0},"CZ":{"0-+":0},"DE":{"0-+":0},"DK":{"0-+":0},"ES":{"0-+":0},"FR":{"0-+":0},"GF":{"0-+":0},"GP":{"0-+":0},"GR":{"0-+":0},"HU":{"0-+":0},"IE":{"0-+":0},"IT":{"0-+":0},"LU":{"0-+":0},"MF":{"0-+":0},"MQ":{"0-+":0},"NC":{"0-+":0},"NL":{"0-+":0},"PF":{"0-+":0},"PL":{"0-+":0},"PM":{"0-+":0},"PT":{"0-+":0},"RE":{"0-+":0},"RO":{"0-+":0},"SE":{"0-+":0},"UK":{"0-+":0},"US":{"0-+":0},"WF":{"0-+":0},"YT":{"0-+":0}}', true);
}
/**
* @param Tracking $tracking
* @param array $packList
* @return array
*/
public function getPacksForTracking(Tracking $tracking, $packList = [])
{
$abTestsList = $this->getTestsForTracking($tracking);
$abTestVersionArray = $this->getABTestsVersionsFromList($abTestsList);
return $this->filterPacksWithABTests($abTestVersionArray, $packList);
}
/**
* Get an array of the versions of ab tests of a tracking
* @param Tracking $tracking
* @param false $checkActiveTests
* @return array
*/
public function getTrackingABTestsVersions(Tracking $tracking, $checkActiveTests = false)
{
$trackingABTestVersions = $tracking->getAbTests();
return $this->getABTestsVersionsFromList($trackingABTestVersions, $checkActiveTests);
}
/**
* @param $abTestAlias
* @return bool
*/
public function siteABTestIsActive($abTestAlias): bool
{
$siteABTest = $this->siteABTestsRepo->findOneBy(['alias' => $abTestAlias]);
if (!$siteABTest instanceof SiteABTest) {
return false;
}
if (!$siteABTest->isActive()) {
return false;
}
return true;
}
/**
* @param array $abTestsList
* @param array $packsList
* @return array
*/
protected function filterPacksWithABTests(array $abTestsList, array $packsList)
{
$allPacks = [];
$exclusivePacks = [];
/** @var SitePackTemplate $pack */
foreach ($packsList as $pack) {
$packAbTests = $pack->getAbTestVersion();
$found = false;
foreach ($packAbTests as $abTestName => $abTestData) {
if (in_array($abTestName, $abTestsList)) {
$found = true;
if (SitePackTemplate::AB_TEST_EXCLUSIVE === $abTestData['type']) {
$exclusivePacks[] = $pack;
} else {
$allPacks[] = $pack;
}
}
}
if (!$found && empty($packAbTests)) {
$allPacks[] = $pack;
}
}
// from here we choose what pack he will receive. If there is a single exclusive pack then we
// just take the exclusive ones.
if (count($exclusivePacks)) {
$packsToReturn = $exclusivePacks;
} else {
$packsToReturn = $allPacks;
}
return $packsToReturn;
}
/**
* @param Collection $abTestsList
* @param false $checkActiveTests
* @return array
*/
protected function getABTestsVersionsFromList(Collection $abTestsList, $checkActiveTests = false)
{
$abTestVersionsArray = [];
if (count($abTestsList)) {
foreach ($abTestsList as $abTest) {
if ($checkActiveTests) {
if ($abTest->getSiteABTest()->isActive()) {
$abTestVersionsArray[] = $abTest->getVersion();
}
} else {
$abTestVersionsArray[] = $abTest->getVersion();
}
}
}
return $abTestVersionsArray;
}
/**
* Choose and return the version for an AB test.
* this function update the test stats too.
*
* @param SiteABTest $siteABTest
* @return string
* @throws \Exception
*/
private function getVersionForTest(SiteABTest $siteABTest)
{
$alias = $siteABTest->getAlias();
$version = $this->selectVersion($siteABTest);
$this->addSelectedClient($siteABTest, $version);
return "{$alias}_{$version}";
}
/**
* This function will attribute a version for the test.
* There is two way to assign a version :
* 1 - random, we take the chances and we assign randomly
* 2 - deterministic, if the config is in "closing_mode" (put true in json config) we fill the missing options
* depending on the target %
*
* @param SiteABTest $siteABTest
* @return int|string
*/
private function selectVersion(SiteABTest $siteABTest)
{
$actualStats = $this->getStatsFromRedis($siteABTest);
$config = $siteABTest->getConfig();
if (!isset($config['closing_mode']) or !$config['closing_mode']) {
$selectedVersion = $this->getRandomVersion($actualStats, $siteABTest);
} else {
$selectedVersion = $this->getDeterministicVersion($actualStats, $siteABTest);
// if we couldn't determine a version, choose a random one.
if (null === $selectedVersion) {
$selectedVersion = $this->getRandomVersion($actualStats, $siteABTest);
}
}
return $selectedVersion;
}
/**
* @param SiteABTest $siteABTest
* @param $selectedVersion
* @param bool $save
* @throws \Exception
*/
private function addSelectedClient(SiteABTest $siteABTest, $selectedVersion, $save = true)
{
// get updated stats from redis.
$actualStats = $this->getStatsFromRedis($siteABTest);
$actualStats['total']++;
$actualStats['options'][$selectedVersion]['count']++;
foreach ($actualStats['options'] as $version => $data) {
if (0 == $data['count']) {
$actualStats['options'][$version]['percent'] = 0;
} else {
$percent = ($actualStats['options'][$version]['count'] / $actualStats['total']) * 100;
$actualStats['options'][$version]['percent'] = round($percent);
}
}
// save them back to redis just after.
$this->setStatsToRedis($actualStats, $siteABTest);
// save every 20 counter in the db. (to prevent db overload we use redis as main counter.)
// or if test must be ended.
if (($save && 0 == ($actualStats['total'] % 20)) or $this->checkTestIsEnded($actualStats, $siteABTest)) {
$siteABTest->setStats($actualStats);
$this->entityManager->flush();
}
}
/**
* @param $actualStats
* @param SiteABTest $siteABTest
* @return bool
* @throws \Exception
*/
private function checkTestIsEnded($actualStats, SiteABTest $siteABTest)
{
$now = new \DateTime();
$maxTest = $siteABTest->getConfig()['count_end'];
$endDate = $siteABTest->getConfig()['date_end'];
$ended = false;
if (null != $maxTest && $actualStats['total'] >= $maxTest) {
$ended = true;
} elseif (null != $endDate) {
$endDate = new \DateTime($endDate);
if ($now >= $endDate) {
$ended = true;
}
}
if (true === $ended) {
$siteABTest->setActive(false);
}
return $ended;
}
/**
* @param $actualStats
* @return mixed
* @throws \LogicException
*/
private function getRandomVersion($actualStats, SiteABTest $siteABTest)
{
$randomValue = mt_rand(1, 100);
$counter = 0;
$possibleVersions = $siteABTest->getPossibleVersions();
foreach ($actualStats['options'] as $option => $data) {
$counter += $data['target'];
if ($randomValue <= $counter) {
if (in_array($option, $possibleVersions)) {
$selectedVersion = $option;
}
break;
}
}
if (!isset($selectedVersion)) {
throw new \LogicException(
"Unable to assign version, there must be a problem in your configuration, are you sure you have assigned 100% of votes ?"
);
}
return $selectedVersion;
}
/**
* @param $actualStats
* @return null|string
*/
private function getDeterministicVersion($actualStats, SiteABTest $siteABTest)
{
$selectedVersion = null;
$possibleVersions = $siteABTest->getPossibleVersions();
foreach ($actualStats['options'] as $option => $data) {
if ($data['percent'] < $data['target']) {
if (in_array($option, $possibleVersions)) {
$selectedVersion = $option;
}
break;
}
}
if (!isset($selectedVersion)) {
throw new \LogicException(
"Unable to assign version, there must be a problem in your configuration, are you sure you have assigned 100% of votes ?"
);
}
return $selectedVersion;
}
/**
* Get stats for a splitTest from redis
*
* @param SiteABTest $siteABTest
* @return mixed
*/
private function getStatsFromRedis(SiteABTest $siteABTest)
{
$websiteIdentifier = $siteABTest->getSite()->getSiteId();
$splitTestIdentifier = $siteABTest->getRedisKey();
$key = $websiteIdentifier . "_" . $splitTestIdentifier;
$stats = $this->redisClient->get($key);
// no stats in redis, we need to initialise them
if (null == $stats) {
$stats = $siteABTest->getStats();
$this->setStatsToRedis($stats, $siteABTest);
} else {
$stats = json_decode($stats, true);
}
return $stats;
}
/**
* Set stats to redis.
*
* @param $stats
* @param SiteABTest $siteABTest
* @return mixed
*/
private function setStatsToRedis($stats, SiteABTest $siteABTest)
{
$websiteIdentifier = $siteABTest->getSite()->getSiteId();
$splitTestIdentifier = $siteABTest->getRedisKey();
$key = $websiteIdentifier . "_" . $splitTestIdentifier;
$stats = json_encode($stats);
$result = $this->redisClient->setex($key, 604800, $stats);
return $result;
}
/**
* @param Site $site
*/
private function loadActiveTests(Site $site)
{
$this->activeTests = $site->getAbTests();
}
/**
* Check if the tracking is eligible for this AB test.
*
* @param Tracking $tracking
* @param SiteABTest $siteABTest
* @return bool
*/
private function isEligibleForTest(Tracking $tracking, SiteABTest $siteABTest)
{
// If this tracking already have this ABTest, don't add it again.
if ($tracking->alreadyHaveABTest($siteABTest)) {
return false;
}
// Check if this ABtest should not be applied to some specific affiliates.
if (array_key_exists(SiteABTest::TYPE_EXCLUDE_AFF, $siteABTest->getConfig())) {
$affiliatesToExclude = $siteABTest->getConfig()[SiteABTest::TYPE_EXCLUDE_AFF];
if (in_array($tracking->getAff()->getAffiliateId(), $affiliatesToExclude)) {
return false;
}
}
// Check if this ABtest should be applied to some specific affiliates.
if (array_key_exists(SiteABTest::CONFIG_ALLOWED_AFF, $siteABTest->getConfig()) && count($siteABTest->getConfig()[SiteABTest::CONFIG_ALLOWED_AFF])) {
$allowedAffiliates = $siteABTest->getConfig()[SiteABTest::CONFIG_ALLOWED_AFF];
if (!in_array($tracking->getAff()->getAffiliateId(), $allowedAffiliates)) {
return false;
}
}
return true;
}
/**
* Check if User is eligible for this AB test.
*
* @param User $user
* @param SiteABTest $siteABTest
* @return bool
*/
private function userIsEligibleForTest(User $user, SiteABTest $siteABTest)
{
// If this User already have this ABTest, don't add it again.
if ($user->alreadyHaveABTest($siteABTest)) {
return false;
}
// Check if this ABtest should not be applied to some specific affiliates.
if (array_key_exists(SiteABTest::TYPE_EXCLUDE_AFF, $siteABTest->getConfig())) {
$affiliatesToExclude = $siteABTest->getConfig()[SiteABTest::TYPE_EXCLUDE_AFF];
if (in_array($user->getTracking()->getAff()->getAffiliateId(), $affiliatesToExclude)) {
return false;
}
}
return true;
}
}