Download core/Tracker/GoalManager.php from Leon4gr45/matomo: direct link, hf CLI and curl.
- Browser
- Download file 37.8 kB
-
https://huggingface.co/spaces/Leon4gr45/matomo/resolve/main/core/Tracker/GoalManager.php
- Command line
-
hf download hf://spaces/Leon4gr45/matomo/core/Tracker/GoalManager.php
-
curl -L -o GoalManager.php https://huggingface.co/spaces/Leon4gr45/matomo/resolve/main/core/Tracker/GoalManager.php
37.8 kB
| /** | |
| * Matomo - free/libre analytics platform | |
| * | |
| * @link https://matomo.org | |
| * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later | |
| */ | |
| namespace Piwik\Tracker; | |
| use Exception; | |
| use Piwik\Common; | |
| use Piwik\Container\StaticContainer; | |
| use Piwik\Date; | |
| use Piwik\Exception\InvalidRequestParameterException; | |
| use Piwik\Log\LoggerInterface; | |
| use Piwik\Piwik; | |
| use Piwik\Plugin\Dimension\ConversionDimension; | |
| use Piwik\Plugin\Dimension\VisitDimension; | |
| use Piwik\Plugin\Manager; | |
| use Piwik\Plugins\CustomVariables\CustomVariables; | |
| use Piwik\Plugins\Events\Actions\ActionEvent; | |
| use Piwik\Tracker\Visit\VisitProperties; | |
| class GoalManager | |
| { | |
| // log_visit.visit_goal_buyer | |
| public const TYPE_BUYER_OPEN_CART = 2; | |
| public const TYPE_BUYER_ORDERED_AND_OPEN_CART = 3; | |
| // log_conversion.idorder is NULLable, but not log_conversion_item which defaults to zero for carts | |
| public const ITEM_IDORDER_ABANDONED_CART = 0; | |
| // log_conversion.idgoal special values | |
| public const IDGOAL_CART = -1; | |
| public const IDGOAL_ORDER = 0; | |
| public const REVENUE_PRECISION = 2; | |
| /** | |
| * Upper sanity limit for a single tracked ecommerce money value (item price, order | |
| * revenue, tax, shipping, discount, subtotal). Values whose absolute value exceeds | |
| * this are rejected at tracking time. | |
| * | |
| * No legitimate single transaction is this large, and such values corrupt revenue | |
| * reports. Rejecting them also removes the archiving overflow vector: quantity is | |
| * bounded by its INT UNSIGNED column (<= ~4.29e9), so quantity * price can no longer | |
| * exceed the MySQL DOUBLE range and abort archiving with error 1690. | |
| */ | |
| public const MAX_ALLOWED_REVENUE = 1000000000000; | |
| public const MAXIMUM_PRODUCT_CATEGORIES = 5; | |
| // In the GET items parameter, each item has the following array of information | |
| public const INDEX_ITEM_SKU = 0; | |
| public const INDEX_ITEM_NAME = 1; | |
| public const INDEX_ITEM_CATEGORY = 2; | |
| public const INDEX_ITEM_PRICE = 3; | |
| public const INDEX_ITEM_QUANTITY = 4; | |
| // Used in the array of items, internally to this class | |
| public const INTERNAL_ITEM_SKU = 0; | |
| public const INTERNAL_ITEM_NAME = 1; | |
| public const INTERNAL_ITEM_CATEGORY = 2; | |
| public const INTERNAL_ITEM_CATEGORY2 = 3; | |
| public const INTERNAL_ITEM_CATEGORY3 = 4; | |
| public const INTERNAL_ITEM_CATEGORY4 = 5; | |
| public const INTERNAL_ITEM_CATEGORY5 = 6; | |
| public const INTERNAL_ITEM_PRICE = 7; | |
| public const INTERNAL_ITEM_QUANTITY = 8; | |
| public static $NUMERIC_MATCH_ATTRIBUTES = [ | |
| 'visit_duration', | |
| ]; | |
| /** | |
| * TODO: should remove this, but it is used by getGoalColumn which is used by dimensions. should replace w/ value object. | |
| * | |
| * @var array | |
| */ | |
| private $currentGoal = array(); | |
| public function detectIsThereExistingCartInVisit($visitInformation) | |
| { | |
| if (empty($visitInformation['visit_goal_buyer'])) { | |
| return false; | |
| } | |
| $goalBuyer = $visitInformation['visit_goal_buyer']; | |
| $types = array(GoalManager::TYPE_BUYER_OPEN_CART, GoalManager::TYPE_BUYER_ORDERED_AND_OPEN_CART); | |
| // Was there a Cart for this visit prior to the order? | |
| return in_array($goalBuyer, $types); | |
| } | |
| public static function getGoalDefinitions($idSite) | |
| { | |
| $websiteAttributes = Cache::getCacheWebsiteAttributes($idSite); | |
| if (isset($websiteAttributes['goals'])) { | |
| return $websiteAttributes['goals']; | |
| } | |
| return array(); | |
| } | |
| public static function getGoalDefinition($idSite, $idGoal) | |
| { | |
| $goals = self::getGoalDefinitions($idSite); | |
| foreach ($goals as $goal) { | |
| if ($goal['idgoal'] == $idGoal) { | |
| return $goal; | |
| } | |
| } | |
| throw new Exception('Goal not found'); | |
| } | |
| public static function getGoalIds($idSite) | |
| { | |
| $goals = self::getGoalDefinitions($idSite); | |
| $goalIds = array(); | |
| foreach ($goals as $goal) { | |
| $goalIds[] = $goal['idgoal']; | |
| } | |
| return $goalIds; | |
| } | |
| /** | |
| * Look at the URL or Page Title and sees if it matches any existing Goal definition | |
| * | |
| * @param int $idSite | |
| * @param Action $action | |
| * @throws Exception | |
| * @return array[] Goals matched | |
| */ | |
| public function detectGoalsMatchingUrl($idSite, $action, VisitProperties $visitor, Request $request) | |
| { | |
| if (!Common::isGoalPluginEnabled()) { | |
| return array(); | |
| } | |
| $goals = $this->getGoalDefinitions($idSite); | |
| $convertedGoals = array(); | |
| foreach ($goals as $goal) { | |
| $convertedUrl = $this->detectGoalMatch($goal, $action, $visitor, $request); | |
| if (!is_null($convertedUrl)) { | |
| $convertedGoals[] = array('url' => $convertedUrl) + $goal; | |
| } | |
| } | |
| return $convertedGoals; | |
| } | |
| /** | |
| * Detects if an Action matches a given goal. If it does, the URL that triggered the goal | |
| * is returned. Otherwise null is returned. | |
| * | |
| * @param array $goal | |
| * @return string|null The Action URL that triggered the goal, or null if no goal was matched | |
| */ | |
| public function detectGoalMatch($goal, Action $action, VisitProperties $visitor, Request $request) | |
| { | |
| $actionType = $action->getActionType(); | |
| $attribute = $goal['match_attribute']; | |
| // handle numeric match attributes specifically | |
| if (in_array($attribute, self::$NUMERIC_MATCH_ATTRIBUTES)) { | |
| return $this->detectNumericGoalMatch($goal, $action, $visitor, $request); | |
| } | |
| // if the attribute to match is not the type of the current action | |
| if ( | |
| (($attribute == 'url' || $attribute == 'title') && $actionType != Action::TYPE_PAGE_URL) | |
| || ($attribute == 'file' && $actionType != Action::TYPE_DOWNLOAD) | |
| || ($attribute == 'external_website' && $actionType != Action::TYPE_OUTLINK) | |
| || ($attribute == 'manually') | |
| || self::isEventMatchingGoal($goal) && $actionType != Action::TYPE_EVENT | |
| ) { | |
| return null; | |
| } | |
| switch ($attribute) { | |
| case 'title': | |
| // Matching on Page Title | |
| $actionToMatch = $action->getActionName(); | |
| break; | |
| case 'event_action': | |
| $actionToMatch = $action->getEventAction(); | |
| break; | |
| case 'event_name': | |
| $actionToMatch = $action->getEventName(); | |
| break; | |
| case 'event_category': | |
| $actionToMatch = $action->getEventCategory(); | |
| break; | |
| // url, external_website, file, manually... | |
| default: | |
| $actionToMatch = $action->getActionUrlRaw(); | |
| break; | |
| } | |
| $pattern_type = $goal['pattern_type']; | |
| $match = $this->isUrlMatchingGoal($goal, $pattern_type, $actionToMatch); | |
| if (!$match) { | |
| return null; | |
| } | |
| return $action->getActionUrl(); | |
| } | |
| private function detectNumericGoalMatch($goal, Action $action, VisitProperties $visitProperties, Request $request) | |
| { | |
| switch ($goal['match_attribute']) { | |
| case 'visit_duration': | |
| $firstActionTime = $visitProperties->getProperty('visit_first_action_time'); | |
| if (empty($firstActionTime)) { | |
| return null; | |
| } | |
| $visitDurationInSecs = $request->getCurrentTimestamp() - ((int) $firstActionTime); | |
| $valueToMatchAgainst = $visitDurationInSecs / 60; | |
| break; | |
| default: | |
| return null; | |
| } | |
| $pattern = (float) $goal['pattern']; | |
| Common::printDebug("Matching {$goal['match_attribute']} (current value = $valueToMatchAgainst, idGoal = {$goal['idgoal']}) {$goal['pattern_type']} $pattern."); | |
| switch ($goal['pattern_type']) { | |
| case 'greater_than': | |
| $matches = $valueToMatchAgainst > $pattern; | |
| break; | |
| default: | |
| return null; | |
| } | |
| if ($matches) { | |
| Common::printDebug("Conversion detected for idGoal = , idGoal = {$goal['idgoal']}."); | |
| return $action->getActionUrl(); | |
| } else { | |
| return null; | |
| } | |
| } | |
| public function detectGoalId($idSite, Request $request) | |
| { | |
| if (!Common::isGoalPluginEnabled()) { | |
| return null; | |
| } | |
| $idGoal = $request->getParam('idgoal'); | |
| $goals = $this->getGoalDefinitions($idSite); | |
| if (!isset($goals[$idGoal])) { | |
| throw new InvalidRequestParameterException('idGoal ' . $idGoal . ' does not exist'); | |
| } | |
| $goal = $goals[$idGoal]; | |
| $url = $request->getParam('url'); | |
| $goal['url'] = PageUrl::excludeQueryParametersFromUrl($url, $idSite); | |
| return $goal; | |
| } | |
| /** | |
| * Records one or several goals matched in this request. | |
| */ | |
| public function recordGoals(VisitProperties $visitProperties, Request $request) | |
| { | |
| $visitorInformation = $visitProperties->getProperties(); | |
| /** @var Action $action */ | |
| $action = $request->getMetadata('Actions', 'action'); | |
| $goal = $this->getGoalFromVisitor($visitProperties, $request, $action); | |
| if (Manager::getInstance()->isPluginActivated('CustomVariables')) { | |
| // @todo move this to CustomVariables plugin if possible | |
| // Copy Custom Variables from Visit row to the Goal conversion | |
| // Otherwise, set the Custom Variables found in the cookie sent with this request | |
| $visitCustomVariables = $request->getMetadata('CustomVariables', 'visitCustomVariables') ?: array(); | |
| $goal += $visitCustomVariables; | |
| $maxCustomVariables = CustomVariables::getNumUsableCustomVariables(); | |
| for ($i = 1; $i <= $maxCustomVariables; $i++) { | |
| if ( | |
| isset($visitorInformation['custom_var_k' . $i]) | |
| && strlen($visitorInformation['custom_var_k' . $i]) | |
| ) { | |
| $goal['custom_var_k' . $i] = $visitorInformation['custom_var_k' . $i]; | |
| } | |
| if ( | |
| isset($visitorInformation['custom_var_v' . $i]) | |
| && strlen($visitorInformation['custom_var_v' . $i]) | |
| ) { | |
| $goal['custom_var_v' . $i] = $visitorInformation['custom_var_v' . $i]; | |
| } | |
| } | |
| } | |
| // some goals are converted, so must be ecommerce Order or Cart Update | |
| $isRequestEcommerce = $request->getMetadata('Ecommerce', 'isRequestEcommerce'); | |
| if ($isRequestEcommerce) { | |
| $this->recordEcommerceGoal($visitProperties, $request, $goal, $action); | |
| } else { | |
| $this->recordStandardGoals($visitProperties, $request, $goal, $action); | |
| } | |
| } | |
| /** | |
| * Returns rounded decimal revenue, or if revenue is integer, then returns as is. | |
| * | |
| * @param int|float $revenue | |
| * @return int|float | |
| */ | |
| protected function getRevenue($revenue) | |
| { | |
| // Reject out-of-range values (see self::MAX_ALLOWED_REVENUE); treat as no revenue. | |
| if (abs((float) $revenue) > self::MAX_ALLOWED_REVENUE) { | |
| StaticContainer::get(LoggerInterface::class)->debug( | |
| "Ecommerce value ({$revenue}) exceeds the allowed maximum of " . self::MAX_ALLOWED_REVENUE . " and was rejected (treated as no revenue)." | |
| ); | |
| return 0; | |
| } | |
| if (round($revenue) != $revenue) { | |
| $revenue = round($revenue, self::REVENUE_PRECISION); | |
| } | |
| $revenue = Common::forceDotAsSeparatorForDecimalPoint($revenue); | |
| return $revenue; | |
| } | |
| /** | |
| * Records an Ecommerce conversion in the DB. Deals with Items found in the request. | |
| * Will deal with 2 types of conversions: Ecommerce Order and Ecommerce Cart update (Add to cart, Update Cart etc). | |
| * | |
| * @param array $conversion | |
| * @param Action|null $action | |
| */ | |
| protected function recordEcommerceGoal(VisitProperties $visitProperties, Request $request, $conversion, $action) | |
| { | |
| $isThereExistingCartInVisit = $request->getMetadata('Goals', 'isThereExistingCartInVisit'); | |
| if ($isThereExistingCartInVisit) { | |
| Common::printDebug("There is an existing cart for this visit"); | |
| } | |
| $visitor = Visitor::makeFromVisitProperties($visitProperties, $request); | |
| $isGoalAnOrder = $request->getMetadata('Ecommerce', 'isGoalAnOrder'); | |
| if ($isGoalAnOrder) { | |
| $debugMessage = 'The conversion is an Ecommerce order'; | |
| $orderId = $request->getParam('ec_id'); | |
| $conversion['idorder'] = $orderId; | |
| $conversion['idgoal'] = self::IDGOAL_ORDER; | |
| $conversion['buster'] = Common::hashStringToInt($orderId); | |
| $conversionDimensions = ConversionDimension::getAllDimensions(); | |
| $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onEcommerceOrderConversion', $visitor, $action, $conversion); | |
| } else { | |
| // If Cart update, select current items in the previous Cart | |
| $debugMessage = 'The conversion is an Ecommerce Cart Update'; | |
| $conversion['buster'] = 0; | |
| $conversion['idgoal'] = self::IDGOAL_CART; | |
| $conversionDimensions = ConversionDimension::getAllDimensions(); | |
| $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onEcommerceCartUpdateConversion', $visitor, $action, $conversion); | |
| } | |
| Common::printDebug($debugMessage . ':' . var_export($conversion, true)); | |
| // INSERT or Sync items in the Cart / Order for this visit & order | |
| $items = $this->getEcommerceItemsFromRequest($request); | |
| if (false === $items) { | |
| return; | |
| } | |
| $itemsCount = 0; | |
| foreach ($items as $item) { | |
| $itemsCount += $item[GoalManager::INTERNAL_ITEM_QUANTITY]; | |
| } | |
| $conversion['items'] = $itemsCount; | |
| if ($isThereExistingCartInVisit) { | |
| $recorded = $this->getModel()->updateConversion( | |
| $visitProperties->getProperty('idvisit'), | |
| self::IDGOAL_CART, | |
| $conversion | |
| ); | |
| } else { | |
| $recorded = $this->insertNewConversion($conversion, $visitProperties->getProperties(), $request, $action); | |
| } | |
| if ($recorded) { | |
| $this->recordEcommerceItems($conversion, $items); | |
| } | |
| } | |
| /** | |
| * Returns Items read from the request string | |
| * @return array|bool | |
| */ | |
| private function getEcommerceItemsFromRequest(Request $request) | |
| { | |
| $items = $request->getParam('ec_items'); | |
| if (empty($items)) { | |
| Common::printDebug("There are no Ecommerce items in the request"); | |
| // we still record an Ecommerce order without any item in it | |
| return array(); | |
| } | |
| if (!is_array($items)) { | |
| Common::printDebug("Error while json_decode the Ecommerce items = " . var_export($items, true)); | |
| return false; | |
| } | |
| $items = Common::unsanitizeInputValues($items); | |
| $cleanedItems = $this->getCleanedEcommerceItems($items); | |
| return $cleanedItems; | |
| } | |
| /** | |
| * Loads the Ecommerce items from the request and records them in the DB | |
| * | |
| * @param array $goal | |
| * @param array $items | |
| * @throws Exception | |
| */ | |
| protected function recordEcommerceItems($goal, $items) | |
| { | |
| $itemInCartBySku = array(); | |
| foreach ($items as $item) { | |
| $itemInCartBySku[$item[0]] = $item; | |
| } | |
| $itemsInDb = $this->getModel()->getAllItemsCurrentlyInTheCart($goal, self::ITEM_IDORDER_ABANDONED_CART); | |
| // Look at which items need to be deleted, which need to be added or updated, based on the SKU | |
| $skuFoundInDb = $itemsToUpdate = array(); | |
| foreach ($itemsInDb as $itemInDb) { | |
| $skuFoundInDb[] = $itemInDb['idaction_sku']; | |
| // Ensure price comparisons will have the same assumption | |
| $itemInDb['price'] = $this->getRevenue($itemInDb['price']); | |
| $itemInDbOriginal = $itemInDb; | |
| $itemInDb = array_values($itemInDb); | |
| // Cast all as string, because what comes out of the fetchAll() are strings | |
| $itemInDb = $this->getItemRowCast($itemInDb); | |
| //Item in the cart in the DB, but not anymore in the cart | |
| if (!isset($itemInCartBySku[$itemInDb[0]])) { | |
| $itemToUpdate = array_merge( | |
| $itemInDb, | |
| array('deleted' => 1, | |
| 'idorder_original_value' => $itemInDbOriginal['idorder_original_value'], | |
| ) | |
| ); | |
| $itemsToUpdate[] = $itemToUpdate; | |
| Common::printDebug("Item found in the previous Cart, but no in the current cart/order"); | |
| Common::printDebug($itemToUpdate); | |
| continue; | |
| } | |
| $newItem = $itemInCartBySku[$itemInDb[0]]; | |
| $newItem = $this->getItemRowCast($newItem); | |
| if (count($itemInDb) != count($newItem)) { | |
| Common::printDebug("ERROR: Different format in items from cart and DB"); | |
| throw new Exception(" Item in DB and Item in cart have a different format, this is not expected... " . var_export($itemInDb, true) . var_export($newItem, true)); | |
| } | |
| Common::printDebug("Item has changed since the last cart. Previous item stored in cart in database:"); | |
| Common::printDebug($itemInDb); | |
| Common::printDebug("New item to UPDATE the previous row:"); | |
| $newItem['idorder_original_value'] = $itemInDbOriginal['idorder_original_value']; | |
| Common::printDebug($newItem); | |
| $itemsToUpdate[] = $newItem; | |
| } | |
| // Items to UPDATE | |
| $this->updateEcommerceItems($goal, $itemsToUpdate); | |
| // Items to INSERT | |
| $itemsToInsert = array(); | |
| foreach ($items as $item) { | |
| if (!in_array($item[0], $skuFoundInDb)) { | |
| $itemsToInsert[] = $item; | |
| } | |
| } | |
| $this->insertEcommerceItems($goal, $itemsToInsert); | |
| } | |
| /** | |
| * Reads items from the request, then looks up the names from the lookup table | |
| * and returns a clean array of items ready for the database. | |
| * | |
| * @param array $items | |
| * @return array $cleanedItems | |
| */ | |
| private function getCleanedEcommerceItems($items) | |
| { | |
| // Clean up the items array | |
| $cleanedItems = array(); | |
| foreach ($items as $item) { | |
| $name = $category = $category2 = $category3 = $category4 = $category5 = false; | |
| $price = 0; | |
| $quantity = 1; | |
| // items are passed in the request as an array: ( $sku, $name, $category, $price, $quantity ) | |
| if (empty($item[self::INDEX_ITEM_SKU])) { | |
| continue; | |
| } | |
| $sku = $item[self::INDEX_ITEM_SKU]; | |
| if (!empty($item[self::INDEX_ITEM_NAME])) { | |
| $name = $item[self::INDEX_ITEM_NAME]; | |
| } | |
| if (!empty($item[self::INDEX_ITEM_CATEGORY])) { | |
| $category = $item[self::INDEX_ITEM_CATEGORY]; | |
| } | |
| if ( | |
| isset($item[self::INDEX_ITEM_PRICE]) | |
| && is_numeric($item[self::INDEX_ITEM_PRICE]) | |
| ) { | |
| $price = $this->getRevenue($item[self::INDEX_ITEM_PRICE]); | |
| } | |
| if ( | |
| !empty($item[self::INDEX_ITEM_QUANTITY]) | |
| && is_numeric($item[self::INDEX_ITEM_QUANTITY]) | |
| ) { | |
| $quantity = (int)$item[self::INDEX_ITEM_QUANTITY]; | |
| } | |
| // self::INDEX_ITEM_* are in order | |
| $cleanedItems[] = array( | |
| self::INTERNAL_ITEM_SKU => $sku, | |
| self::INTERNAL_ITEM_NAME => $name, | |
| self::INTERNAL_ITEM_CATEGORY => $category, | |
| self::INTERNAL_ITEM_CATEGORY2 => $category2, | |
| self::INTERNAL_ITEM_CATEGORY3 => $category3, | |
| self::INTERNAL_ITEM_CATEGORY4 => $category4, | |
| self::INTERNAL_ITEM_CATEGORY5 => $category5, | |
| self::INTERNAL_ITEM_PRICE => $price, | |
| self::INTERNAL_ITEM_QUANTITY => $quantity, | |
| ); | |
| } | |
| // Lookup Item SKUs, Names & Categories Ids | |
| $actionsToLookupAllItems = array(); | |
| // Each item has 7 potential "ids" to lookup in the lookup table | |
| $columnsInEachRow = 1 + 1 + self::MAXIMUM_PRODUCT_CATEGORIES; | |
| foreach ($cleanedItems as $item) { | |
| $actionsToLookup = array(); | |
| [$sku_check, $name_check, $category, $price, $quantity] = $item; | |
| $sku = is_array($sku_check) ? join(',', $sku_check) : $sku_check; | |
| $actionsToLookup[] = array(trim($sku), Action::TYPE_ECOMMERCE_ITEM_SKU); | |
| $name = is_array($name_check) ? join(',', $name_check) : $name_check; | |
| $actionsToLookup[] = array(trim($name), Action::TYPE_ECOMMERCE_ITEM_NAME); | |
| // Only one category | |
| if (!is_array($category)) { | |
| $actionsToLookup[] = array(trim($category), Action::TYPE_ECOMMERCE_ITEM_CATEGORY); | |
| } else { | |
| // Multiple categories | |
| $countCategories = 0; | |
| foreach ($category as $productCategory) { | |
| $productCategory = trim($productCategory); | |
| if (empty($productCategory)) { | |
| continue; | |
| } | |
| $countCategories++; | |
| if ($countCategories > self::MAXIMUM_PRODUCT_CATEGORIES) { | |
| break; | |
| } | |
| $actionsToLookup[] = array($productCategory, Action::TYPE_ECOMMERCE_ITEM_CATEGORY); | |
| } | |
| } | |
| // Ensure that each row has the same number of columns, fill in the blanks | |
| for ($i = count($actionsToLookup); $i < $columnsInEachRow; $i++) { | |
| $actionsToLookup[] = array(false, Action::TYPE_ECOMMERCE_ITEM_CATEGORY); | |
| } | |
| $actionsToLookupAllItems = array_merge($actionsToLookupAllItems, $actionsToLookup); | |
| } | |
| $actionsLookedUp = TableLogAction::loadIdsAction($actionsToLookupAllItems); | |
| // Replace SKU, name & category by their ID action | |
| foreach ($cleanedItems as $index => &$item) { | |
| // SKU | |
| $item[0] = $actionsLookedUp[$index * $columnsInEachRow + 0]; | |
| // Name | |
| $item[1] = $actionsLookedUp[$index * $columnsInEachRow + 1]; | |
| // Categories | |
| $item[2] = $actionsLookedUp[$index * $columnsInEachRow + 2]; | |
| $item[3] = $actionsLookedUp[$index * $columnsInEachRow + 3]; | |
| $item[4] = $actionsLookedUp[$index * $columnsInEachRow + 4]; | |
| $item[5] = $actionsLookedUp[$index * $columnsInEachRow + 5]; | |
| $item[6] = $actionsLookedUp[$index * $columnsInEachRow + 6]; | |
| } | |
| return $cleanedItems; | |
| } | |
| /** | |
| * Updates the cart items in the DB | |
| * that have been modified since the last cart update | |
| * | |
| * @param array $goal | |
| * @param array $itemsToUpdate | |
| * | |
| * @return void | |
| */ | |
| protected function updateEcommerceItems($goal, $itemsToUpdate) | |
| { | |
| if (empty($itemsToUpdate)) { | |
| return; | |
| } | |
| Common::printDebug("Goal data used to update ecommerce items:"); | |
| Common::printDebug($goal); | |
| foreach ($itemsToUpdate as $item) { | |
| $newRow = $this->getItemRowEnriched($goal, $item); | |
| Common::printDebug($newRow); | |
| $this->getModel()->updateEcommerceItem($item['idorder_original_value'], $newRow); | |
| } | |
| } | |
| private function getModel() | |
| { | |
| return new Model(); | |
| } | |
| /** | |
| * Inserts in the cart in the DB the new items | |
| * that were not previously in the cart | |
| * | |
| * @param array $goal | |
| * @param array $itemsToInsert | |
| * | |
| * @return void | |
| */ | |
| protected function insertEcommerceItems($goal, $itemsToInsert) | |
| { | |
| if (empty($itemsToInsert)) { | |
| return; | |
| } | |
| Common::printDebug("Ecommerce items that are added to the cart/order"); | |
| Common::printDebug($itemsToInsert); | |
| $items = array(); | |
| foreach ($itemsToInsert as $item) { | |
| $items[] = $this->getItemRowEnriched($goal, $item); | |
| } | |
| $this->getModel()->createEcommerceItems($items); | |
| } | |
| protected function getItemRowEnriched($goal, $item) | |
| { | |
| $newRow = array( | |
| 'idaction_sku' => (int)$item[self::INTERNAL_ITEM_SKU], | |
| 'idaction_name' => (int)$item[self::INTERNAL_ITEM_NAME], | |
| 'idaction_category' => (int)$item[self::INTERNAL_ITEM_CATEGORY], | |
| 'idaction_category2' => (int)$item[self::INTERNAL_ITEM_CATEGORY2], | |
| 'idaction_category3' => (int)$item[self::INTERNAL_ITEM_CATEGORY3], | |
| 'idaction_category4' => (int)$item[self::INTERNAL_ITEM_CATEGORY4], | |
| 'idaction_category5' => (int)$item[self::INTERNAL_ITEM_CATEGORY5], | |
| 'price' => Common::forceDotAsSeparatorForDecimalPoint($item[self::INTERNAL_ITEM_PRICE]), | |
| 'quantity' => $item[self::INTERNAL_ITEM_QUANTITY], | |
| 'deleted' => isset($item['deleted']) ? $item['deleted'] : 0, //deleted | |
| 'idorder' => isset($goal['idorder']) ? $goal['idorder'] : self::ITEM_IDORDER_ABANDONED_CART, //idorder = 0 in log_conversion_item for carts | |
| 'idsite' => $goal['idsite'], | |
| 'idvisitor' => $goal['idvisitor'], | |
| 'server_time' => $goal['server_time'], | |
| 'idvisit' => $goal['idvisit'], | |
| ); | |
| return $newRow; | |
| } | |
| public function getGoalColumn($column) | |
| { | |
| if (array_key_exists($column, $this->currentGoal)) { | |
| return $this->currentGoal[$column]; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Records a standard non-Ecommerce goal in the DB (URL/Title matching), | |
| * linking the conversion to the action that triggered it | |
| * @param array $goal | |
| * @param Action|null $action | |
| */ | |
| protected function recordStandardGoals(VisitProperties $visitProperties, Request $request, $goal, $action) | |
| { | |
| $visitor = Visitor::makeFromVisitProperties($visitProperties, $request); | |
| $convertedGoals = $request->getMetadata('Goals', 'goalsConverted') ?: array(); | |
| foreach ($convertedGoals as $convertedGoal) { | |
| $this->currentGoal = $convertedGoal; | |
| Common::printDebug("- Goal " . $convertedGoal['idgoal'] . " matched. Recording..."); | |
| $conversion = $goal; | |
| $conversion['idgoal'] = $convertedGoal['idgoal']; | |
| $conversion['url'] = $convertedGoal['url']; | |
| if (!is_null($action)) { | |
| $conversion['idaction_url'] = $action->getIdActionUrl(); | |
| $conversion['idlink_va'] = $action->getIdLinkVisitAction(); | |
| } | |
| // If multiple Goal conversions per visit, set a cache buster | |
| if ($convertedGoal['allow_multiple'] == 0) { | |
| $conversion['buster'] = 0; | |
| } else { | |
| $lastActionTime = $visitProperties->getProperty('visit_last_action_time'); | |
| if (empty($lastActionTime)) { | |
| $conversion['buster'] = $this->makeRandomMySqlUnsignedInt(10); | |
| } else { | |
| $conversion['buster'] = $this->makeRandomMySqlUnsignedInt(2) . mb_substr($visitProperties->getProperty('visit_last_action_time'), 2); | |
| } | |
| } | |
| $conversionDimensions = ConversionDimension::getAllDimensions(); | |
| $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onGoalConversion', $visitor, $action, $conversion); | |
| $this->insertNewConversion($conversion, $visitProperties->getProperties(), $request, $action, $convertedGoal); | |
| } | |
| } | |
| private function makeRandomMySqlUnsignedInt($length) | |
| { | |
| // mysql int unsgined max value is 4294967295 so we want to allow max 39999... | |
| $randomInt = Common::getRandomString(1, '123'); | |
| $randomInt .= Common::getRandomString($length - 1, '0123456789'); | |
| return $randomInt; | |
| } | |
| /** | |
| * Helper function used by other record* methods which will INSERT the conversion in the DB | |
| * | |
| * @param array $conversion | |
| * @param array $visitInformation | |
| * @param Action|null $action | |
| * @param array|null $convertedGoal | |
| * @return bool | |
| */ | |
| protected function insertNewConversion($conversion, $visitInformation, Request $request, $action, $convertedGoal = null) | |
| { | |
| /** | |
| * Triggered before persisting a new [conversion entity](/guides/persistence-and-the-mysql-backend#conversions). | |
| * | |
| * This event can be used to modify conversion information or to add new information to be persisted. | |
| * | |
| * This event is deprecated, use [Dimensions](https://developer.matomo.org/guides/dimensions) instead. | |
| * | |
| * @param array $conversion The conversion entity. Read [this](/guides/persistence-and-the-mysql-backend#conversions) | |
| * to see what it contains. | |
| * @param array $visitInformation The visit entity that we are tracking a conversion for. See what | |
| * information it contains [here](/guides/persistence-and-the-mysql-backend#visits). | |
| * @param \Piwik\Tracker\Request $request An object describing the tracking request being processed. | |
| * @param Action|null $action An action object like ActionPageView or ActionDownload, or null if no action is | |
| * supposed to be processed. | |
| * @deprecated | |
| * @ignore | |
| */ | |
| Piwik::postEvent('Tracker.newConversionInformation', array(&$conversion, $visitInformation, $request, $action)); | |
| if ( | |
| !empty($convertedGoal) | |
| && $this->isEventMatchingGoal($convertedGoal) | |
| && !empty($convertedGoal['event_value_as_revenue']) | |
| ) { | |
| $eventValue = ActionEvent::getEventValue($request); | |
| if ($eventValue != '') { | |
| $conversion['revenue'] = $eventValue; | |
| } | |
| } | |
| $newGoalDebug = $conversion; | |
| $newGoalDebug['idvisitor'] = bin2hex($newGoalDebug['idvisitor']); | |
| Common::printDebug($newGoalDebug); | |
| $idorder = $request->getParam('ec_id'); | |
| $wasInserted = $this->getModel()->createConversion($conversion); | |
| if ( | |
| !$wasInserted | |
| ) { | |
| if (!empty($idorder)) { | |
| $idSite = $request->getIdSite(); | |
| throw new InvalidRequestParameterException("Invalid non-unique idsite/idorder combination ($idSite, $idorder), conversion was not inserted."); | |
| } elseif ($conversion['buster'] > 0) { | |
| // Note: The buster is set to 0 for goals that can only be triggered once per visit. | |
| // It's expected behaviour that creating additional conversion fail, so we don't log failures in that case. | |
| StaticContainer::get(LoggerInterface::class)->warning("Failed to insert goal due to duplicate idvisit/idgoal/buster combination ({$conversion['idvisit']}, {$conversion['idgoal']}, {$conversion['buster']})"); | |
| } | |
| } | |
| return $wasInserted; | |
| } | |
| /** | |
| * Casts the item array so that array comparisons work nicely | |
| * @param array $row | |
| * @return array | |
| */ | |
| protected function getItemRowCast($row) | |
| { | |
| return array( | |
| (string)(int)$row[self::INTERNAL_ITEM_SKU], | |
| (string)(int)$row[self::INTERNAL_ITEM_NAME], | |
| (string)(int)$row[self::INTERNAL_ITEM_CATEGORY], | |
| (string)(int)$row[self::INTERNAL_ITEM_CATEGORY2], | |
| (string)(int)$row[self::INTERNAL_ITEM_CATEGORY3], | |
| (string)(int)$row[self::INTERNAL_ITEM_CATEGORY4], | |
| (string)(int)$row[self::INTERNAL_ITEM_CATEGORY5], | |
| (string)$row[self::INTERNAL_ITEM_PRICE], | |
| (string)$row[self::INTERNAL_ITEM_QUANTITY], | |
| ); | |
| } | |
| /** | |
| * @param $goal | |
| * @param $pattern_type | |
| * @param $url | |
| * @return bool | |
| * @throws \Exception | |
| */ | |
| protected function isUrlMatchingGoal($goal, $pattern_type, $url) | |
| { | |
| $url = Common::unsanitizeInputValue($url); | |
| $goal['pattern'] = Common::unsanitizeInputValue($goal['pattern']); | |
| $match = $this->isGoalPatternMatchingUrl($goal, $pattern_type, $url); | |
| if (!$match) { | |
| // Users may set Goal matching URL as URL encoded | |
| $goal['pattern'] = urldecode($goal['pattern']); | |
| $match = $this->isGoalPatternMatchingUrl($goal, $pattern_type, $url); | |
| } | |
| return $match; | |
| } | |
| /** | |
| * @param ConversionDimension[] $dimensions | |
| * @param string $hook | |
| * @param Visitor $visitor | |
| * @param Action|null $action | |
| * @param array|null $valuesToUpdate | |
| * | |
| * @return array|null The updated $valuesToUpdate or null if no $valuesToUpdate given | |
| */ | |
| private function triggerHookOnDimensions(Request $request, $dimensions, $hook, $visitor, $action, $valuesToUpdate) | |
| { | |
| foreach ($dimensions as $dimension) { | |
| $value = $dimension->$hook($request, $visitor, $action, $this); | |
| if (false !== $value) { | |
| if (is_float($value)) { | |
| $value = Common::forceDotAsSeparatorForDecimalPoint($value); | |
| } | |
| $fieldName = $dimension->getColumnName(); | |
| $visitor->setVisitorColumn($fieldName, $value); | |
| $valuesToUpdate[$fieldName] = $value; | |
| } | |
| } | |
| return $valuesToUpdate; | |
| } | |
| private function getGoalFromVisitor(VisitProperties $visitProperties, Request $request, $action) | |
| { | |
| $lastVisitTime = $visitProperties->getProperty('visit_last_action_time'); | |
| if (!$lastVisitTime) { | |
| $lastVisitTime = $request->getCurrentTimestamp(); // fallback in case visit_last_action_time is not set | |
| } | |
| if (!empty($lastVisitTime) && is_numeric($lastVisitTime)) { | |
| // visit last action time might be 2020-05-05 00:00:00 | |
| // we want it to prevent this being converted to a timestamp of 2020 | |
| // resulting in some day in 1970 | |
| $lastVisitTime = Date::getDatetimeFromTimestamp($lastVisitTime); | |
| } | |
| $goal = array( | |
| 'idvisit' => $visitProperties->getProperty('idvisit'), | |
| 'idvisitor' => $visitProperties->getProperty('idvisitor'), | |
| 'server_time' => $lastVisitTime, | |
| ); | |
| $visitDimensions = VisitDimension::getAllDimensions(); | |
| $visit = Visitor::makeFromVisitProperties($visitProperties, $request); | |
| foreach ($visitDimensions as $dimension) { | |
| $value = $dimension->onAnyGoalConversion($request, $visit, $action); | |
| if (false !== $value) { | |
| $goal[$dimension->getColumnName()] = $value; | |
| } | |
| } | |
| return $goal; | |
| } | |
| /** | |
| * @param $goal | |
| * @param $pattern_type | |
| * @param $url | |
| * @return bool | |
| */ | |
| protected function isGoalPatternMatchingUrl($goal, $pattern_type, $url) | |
| { | |
| switch ($pattern_type) { | |
| case 'regex': | |
| $pattern = self::formatRegex($goal['pattern']); | |
| if (!$goal['case_sensitive']) { | |
| $pattern .= 'i'; | |
| } | |
| $match = (@preg_match($pattern, $url) == 1); | |
| break; | |
| case 'contains': | |
| if ($goal['case_sensitive']) { | |
| $matched = strpos($url, $goal['pattern']); | |
| } else { | |
| $matched = stripos($url, $goal['pattern']); | |
| } | |
| $match = ($matched !== false); | |
| break; | |
| case 'exact': | |
| if ($goal['case_sensitive']) { | |
| $matched = strcmp($goal['pattern'], $url); | |
| } else { | |
| $matched = strcasecmp($goal['pattern'], $url); | |
| } | |
| $match = ($matched == 0); | |
| break; | |
| default: | |
| try { | |
| StaticContainer::get(LoggerInterface::class)->warning(Piwik::translate('General_ExceptionInvalidGoalPattern', array($pattern_type))); | |
| } catch (\Exception $e) { | |
| } | |
| $match = false; | |
| break; | |
| } | |
| return $match; | |
| } | |
| /** | |
| * Formats a goal regex pattern to a usable regex | |
| * | |
| * @param string $pattern | |
| * @return string | |
| */ | |
| public static function formatRegex($pattern) | |
| { | |
| if ( | |
| strpos($pattern, '/') !== false | |
| && strpos($pattern, '\\/') === false | |
| ) { | |
| $pattern = str_replace('/', '\\/', $pattern); | |
| } | |
| return '/' . $pattern . '/'; | |
| } | |
| public static function isEventMatchingGoal($goal) | |
| { | |
| return in_array($goal['match_attribute'], array('event_action', 'event_name', 'event_category')); | |
| } | |
| } | |