PageRenderTime 51ms CodeModel.GetById 18ms RepoModel.GetById 0ms app.codeStats 0ms

/core/Tracker/GoalManager.php

https://github.com/CodeYellowBV/piwik
PHP | 905 lines | 624 code | 88 blank | 193 comment | 94 complexity | f8eeacadbdb6d130cadb05067b684a03 MD5 | raw file
Possible License(s): LGPL-3.0, JSON, MIT, GPL-3.0, LGPL-2.1, GPL-2.0, AGPL-1.0, BSD-2-Clause, BSD-3-Clause
  1. <?php
  2. /**
  3. * Piwik - free/libre analytics platform
  4. *
  5. * @link http://piwik.org
  6. * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
  7. *
  8. */
  9. namespace Piwik\Tracker;
  10. use Exception;
  11. use Piwik\Common;
  12. use Piwik\Config;
  13. use Piwik\Piwik;
  14. use Piwik\Plugins\CustomVariables\CustomVariables;
  15. use Piwik\Tracker;
  16. /**
  17. */
  18. class GoalManager
  19. {
  20. // log_visit.visit_goal_buyer
  21. const TYPE_BUYER_NONE = 0;
  22. const TYPE_BUYER_ORDERED = 1;
  23. const TYPE_BUYER_OPEN_CART = 2;
  24. const TYPE_BUYER_ORDERED_AND_OPEN_CART = 3;
  25. // log_conversion.idorder is NULLable, but not log_conversion_item which defaults to zero for carts
  26. const ITEM_IDORDER_ABANDONED_CART = 0;
  27. // log_conversion.idgoal special values
  28. const IDGOAL_CART = -1;
  29. const IDGOAL_ORDER = 0;
  30. const REVENUE_PRECISION = 2;
  31. const MAXIMUM_PRODUCT_CATEGORIES = 5;
  32. public $idGoal;
  33. public $requestIsEcommerce;
  34. public $isGoalAnOrder;
  35. /**
  36. * @var Action
  37. */
  38. protected $action = null;
  39. protected $convertedGoals = array();
  40. protected $isThereExistingCartInVisit = false;
  41. /**
  42. * @var Request
  43. */
  44. protected $request;
  45. protected $orderId;
  46. /**
  47. * Constructor
  48. * @param Request $request
  49. */
  50. public function __construct(Request $request)
  51. {
  52. $this->request = $request;
  53. $this->init();
  54. }
  55. function init()
  56. {
  57. $this->orderId = $this->request->getParam('ec_id');
  58. $this->isGoalAnOrder = !empty($this->orderId);
  59. $this->idGoal = $this->request->getParam('idgoal');
  60. $this->requestIsEcommerce = ($this->idGoal == 0);
  61. }
  62. function getBuyerType($existingType = GoalManager::TYPE_BUYER_NONE)
  63. {
  64. // Was there a Cart for this visit prior to the order?
  65. $this->isThereExistingCartInVisit = in_array($existingType,
  66. array(GoalManager::TYPE_BUYER_OPEN_CART,
  67. GoalManager::TYPE_BUYER_ORDERED_AND_OPEN_CART));
  68. if (!$this->requestIsEcommerce) {
  69. return $existingType;
  70. }
  71. if ($this->isGoalAnOrder) {
  72. return self::TYPE_BUYER_ORDERED;
  73. }
  74. // request is Add to Cart
  75. if ($existingType == self::TYPE_BUYER_ORDERED
  76. || $existingType == self::TYPE_BUYER_ORDERED_AND_OPEN_CART
  77. ) {
  78. return self::TYPE_BUYER_ORDERED_AND_OPEN_CART;
  79. }
  80. return self::TYPE_BUYER_OPEN_CART;
  81. }
  82. static public function getGoalDefinitions($idSite)
  83. {
  84. $websiteAttributes = Cache::getCacheWebsiteAttributes($idSite);
  85. if (isset($websiteAttributes['goals'])) {
  86. return $websiteAttributes['goals'];
  87. }
  88. return array();
  89. }
  90. static public function getGoalDefinition($idSite, $idGoal)
  91. {
  92. $goals = self::getGoalDefinitions($idSite);
  93. foreach ($goals as $goal) {
  94. if ($goal['idgoal'] == $idGoal) {
  95. return $goal;
  96. }
  97. }
  98. throw new Exception('Goal not found');
  99. }
  100. static public function getGoalIds($idSite)
  101. {
  102. $goals = self::getGoalDefinitions($idSite);
  103. $goalIds = array();
  104. foreach ($goals as $goal) {
  105. $goalIds[] = $goal['idgoal'];
  106. }
  107. return $goalIds;
  108. }
  109. /**
  110. * Look at the URL or Page Title and sees if it matches any existing Goal definition
  111. *
  112. * @param int $idSite
  113. * @param Action $action
  114. * @throws Exception
  115. * @return int Number of goals matched
  116. */
  117. function detectGoalsMatchingUrl($idSite, $action)
  118. {
  119. if (!Common::isGoalPluginEnabled()) {
  120. return false;
  121. }
  122. $decodedActionUrl = $action->getActionUrl();
  123. $actionType = $action->getActionType();
  124. $goals = $this->getGoalDefinitions($idSite);
  125. foreach ($goals as $goal) {
  126. $attribute = $goal['match_attribute'];
  127. // if the attribute to match is not the type of the current action
  128. if ( (($attribute == 'url' || $attribute == 'title') && $actionType != Action::TYPE_PAGE_URL)
  129. || ($attribute == 'file' && $actionType != Action::TYPE_DOWNLOAD)
  130. || ($attribute == 'external_website' && $actionType != Action::TYPE_OUTLINK)
  131. || ($attribute == 'manually')
  132. ) {
  133. continue;
  134. }
  135. $url = $decodedActionUrl;
  136. // Matching on Page Title
  137. if ($attribute == 'title') {
  138. $url = $action->getActionName();
  139. }
  140. $pattern_type = $goal['pattern_type'];
  141. $match = $this->isUrlMatchingGoal($goal, $pattern_type, $url);
  142. if ($match) {
  143. $goal['url'] = $decodedActionUrl;
  144. $this->convertedGoals[] = $goal;
  145. }
  146. }
  147. return count($this->convertedGoals) > 0;
  148. }
  149. function detectGoalId($idSite)
  150. {
  151. if (!Common::isGoalPluginEnabled()) {
  152. return false;
  153. }
  154. $goals = $this->getGoalDefinitions($idSite);
  155. if (!isset($goals[$this->idGoal])) {
  156. return false;
  157. }
  158. $goal = $goals[$this->idGoal];
  159. $url = $this->request->getParam('url');
  160. $goal['url'] = PageUrl::excludeQueryParametersFromUrl($url, $idSite);
  161. $goal['revenue'] = $this->getRevenue($this->request->getGoalRevenue($goal['revenue']));
  162. $this->convertedGoals[] = $goal;
  163. return true;
  164. }
  165. /**
  166. * Records one or several goals matched in this request.
  167. *
  168. * @param int $idSite
  169. * @param array $visitorInformation
  170. * @param array $visitCustomVariables
  171. * @param Action $action
  172. */
  173. public function recordGoals($idSite, $visitorInformation, $visitCustomVariables, $action)
  174. {
  175. $referrerTimestamp = $this->request->getParam('_refts');
  176. $referrerUrl = $this->request->getParam('_ref');
  177. $referrerCampaignName = trim(urldecode($this->request->getParam('_rcn')));
  178. $referrerCampaignKeyword = trim(urldecode($this->request->getParam('_rck')));
  179. $browserLanguage = $this->request->getBrowserLanguage();
  180. $location_country = isset($visitorInformation['location_country'])
  181. ? $visitorInformation['location_country']
  182. : Common::getCountry(
  183. $browserLanguage,
  184. $enableLanguageToCountryGuess = Config::getInstance()->Tracker['enable_language_to_country_guess'],
  185. $visitorInformation['location_ip']
  186. );
  187. $goal = array(
  188. 'idvisit' => $visitorInformation['idvisit'],
  189. 'idsite' => $idSite,
  190. 'idvisitor' => $visitorInformation['idvisitor'],
  191. 'server_time' => Tracker::getDatetimeFromTimestamp($visitorInformation['visit_last_action_time']),
  192. 'location_country' => $location_country,
  193. 'visitor_returning' => $visitorInformation['visitor_returning'],
  194. 'visitor_days_since_first' => $visitorInformation['visitor_days_since_first'],
  195. 'visitor_days_since_order' => $visitorInformation['visitor_days_since_order'],
  196. 'visitor_count_visits' => $visitorInformation['visitor_count_visits'],
  197. );
  198. $extraLocationCols = array('location_region', 'location_city', 'location_latitude', 'location_longitude');
  199. foreach ($extraLocationCols as $col) {
  200. if (isset($visitorInformation[$col])) {
  201. $goal[$col] = $visitorInformation[$col];
  202. }
  203. }
  204. // Copy Custom Variables from Visit row to the Goal conversion
  205. // Otherwise, set the Custom Variables found in the cookie sent with this request
  206. $goal += $visitCustomVariables;
  207. $maxCustomVariables = CustomVariables::getMaxCustomVariables();
  208. for ($i = 1; $i <= $maxCustomVariables; $i++) {
  209. if (isset($visitorInformation['custom_var_k' . $i])
  210. && strlen($visitorInformation['custom_var_k' . $i])
  211. ) {
  212. $goal['custom_var_k' . $i] = $visitorInformation['custom_var_k' . $i];
  213. }
  214. if (isset($visitorInformation['custom_var_v' . $i])
  215. && strlen($visitorInformation['custom_var_v' . $i])
  216. ) {
  217. $goal['custom_var_v' . $i] = $visitorInformation['custom_var_v' . $i];
  218. }
  219. }
  220. // Attributing the correct Referrer to this conversion.
  221. // Priority order is as follows:
  222. // 0) In some cases, the campaign is not passed from the JS so we look it up from the current visit
  223. // 1) Campaign name/kwd parsed in the JS
  224. // 2) Referrer URL stored in the _ref cookie
  225. // 3) If no info from the cookie, attribute to the current visit referrer
  226. // 3) Default values: current referrer
  227. $type = $visitorInformation['referer_type'];
  228. $name = $visitorInformation['referer_name'];
  229. $keyword = $visitorInformation['referer_keyword'];
  230. $time = $visitorInformation['visit_first_action_time'];
  231. // 0) In some (unknown!?) cases the campaign is not found in the attribution cookie, but the URL ref was found.
  232. // In this case we look up if the current visit is credited to a campaign and will credit this campaign rather than the URL ref (since campaigns have higher priority)
  233. if (empty($referrerCampaignName)
  234. && $type == Common::REFERRER_TYPE_CAMPAIGN
  235. && !empty($name)
  236. ) {
  237. // Use default values per above
  238. } // 1) Campaigns from 1st party cookie
  239. elseif (!empty($referrerCampaignName)) {
  240. $type = Common::REFERRER_TYPE_CAMPAIGN;
  241. $name = $referrerCampaignName;
  242. $keyword = $referrerCampaignKeyword;
  243. $time = $referrerTimestamp;
  244. } // 2) Referrer URL parsing
  245. elseif (!empty($referrerUrl)) {
  246. $referrer = new Referrer();
  247. $referrer = $referrer->getReferrerInformation($referrerUrl, $currentUrl = '', $idSite);
  248. // if the parsed referrer is interesting enough, ie. website or search engine
  249. if (in_array($referrer['referer_type'], array(Common::REFERRER_TYPE_SEARCH_ENGINE, Common::REFERRER_TYPE_WEBSITE))) {
  250. $type = $referrer['referer_type'];
  251. $name = $referrer['referer_name'];
  252. $keyword = $referrer['referer_keyword'];
  253. $time = $referrerTimestamp;
  254. }
  255. }
  256. $this->setCampaignValuesToLowercase($type, $name, $keyword);
  257. $goal += array(
  258. 'referer_type' => $type,
  259. 'referer_name' => $name,
  260. 'referer_keyword' => $keyword,
  261. // this field is currently unused
  262. 'referer_visit_server_date' => date("Y-m-d", $time),
  263. );
  264. // some goals are converted, so must be ecommerce Order or Cart Update
  265. if ($this->requestIsEcommerce) {
  266. $this->recordEcommerceGoal($goal, $visitorInformation);
  267. } else {
  268. $this->recordStandardGoals($goal, $action, $visitorInformation);
  269. }
  270. }
  271. /**
  272. * Returns rounded decimal revenue, or if revenue is integer, then returns as is.
  273. *
  274. * @param int|float $revenue
  275. * @return int|float
  276. */
  277. protected function getRevenue($revenue)
  278. {
  279. if (round($revenue) == $revenue) {
  280. return $revenue;
  281. }
  282. return round($revenue, self::REVENUE_PRECISION);
  283. }
  284. /**
  285. * Records an Ecommerce conversion in the DB. Deals with Items found in the request.
  286. * Will deal with 2 types of conversions: Ecommerce Order and Ecommerce Cart update (Add to cart, Update Cart etc).
  287. *
  288. * @param array $conversion
  289. * @param array $visitInformation
  290. */
  291. protected function recordEcommerceGoal($conversion, $visitInformation)
  292. {
  293. if ($this->isThereExistingCartInVisit) {
  294. Common::printDebug("There is an existing cart for this visit");
  295. }
  296. if ($this->isGoalAnOrder) {
  297. $conversion['idgoal'] = self::IDGOAL_ORDER;
  298. $conversion['idorder'] = $this->orderId;
  299. $conversion['buster'] = Common::hashStringToInt($this->orderId);
  300. $conversion['revenue_subtotal'] = $this->getRevenue($this->request->getParam('ec_st'));
  301. $conversion['revenue_tax'] = $this->getRevenue($this->request->getParam('ec_tx'));
  302. $conversion['revenue_shipping'] = $this->getRevenue($this->request->getParam('ec_sh'));
  303. $conversion['revenue_discount'] = $this->getRevenue($this->request->getParam('ec_dt'));
  304. $debugMessage = 'The conversion is an Ecommerce order';
  305. } // If Cart update, select current items in the previous Cart
  306. else {
  307. $conversion['buster'] = 0;
  308. $conversion['idgoal'] = self::IDGOAL_CART;
  309. $debugMessage = 'The conversion is an Ecommerce Cart Update';
  310. }
  311. $conversion['revenue'] = $this->getRevenue($this->request->getGoalRevenue($defaultRevenue = 0));
  312. Common::printDebug($debugMessage . ':' . var_export($conversion, true));
  313. // INSERT or Sync items in the Cart / Order for this visit & order
  314. $items = $this->getEcommerceItemsFromRequest();
  315. if ($items === false) {
  316. return;
  317. }
  318. $itemsCount = 0;
  319. foreach ($items as $item) {
  320. $itemsCount += $item[self::INTERNAL_ITEM_QUANTITY];
  321. }
  322. $conversion['items'] = $itemsCount;
  323. if($this->isThereExistingCartInVisit) {
  324. $updateWhere = array(
  325. 'idvisit' => $visitInformation['idvisit'],
  326. 'idgoal' => self::IDGOAL_CART,
  327. 'buster' => 0,
  328. );
  329. $recorded = $this->updateExistingConversion($conversion, $updateWhere);
  330. } else {
  331. $recorded = $this->insertNewConversion($conversion, $visitInformation);
  332. }
  333. if ($recorded) {
  334. $this->recordEcommerceItems($conversion, $items, $visitInformation);
  335. }
  336. /**
  337. * Triggered after successfully persisting an ecommerce conversion.
  338. *
  339. * _Note: Subscribers should be wary of doing any expensive computation here as it may slow
  340. * the tracker down._
  341. *
  342. * @param array $conversion The conversion entity that was just persisted. See what information
  343. * it contains [here](/guides/persistence-and-the-mysql-backend#conversions).
  344. * @param array $visitInformation The visit entity that we are tracking a conversion for. See what
  345. * information it contains [here](/guides/persistence-and-the-mysql-backend#visits).
  346. */
  347. Piwik::postEvent('Tracker.recordEcommerceGoal', array($conversion, $visitInformation));
  348. }
  349. /**
  350. * Returns Items read from the request string
  351. * @return array|bool
  352. */
  353. protected function getEcommerceItemsFromRequest()
  354. {
  355. $items = Common::unsanitizeInputValue($this->request->getParam('ec_items'));
  356. if (empty($items)) {
  357. Common::printDebug("There are no Ecommerce items in the request");
  358. // we still record an Ecommerce order without any item in it
  359. return array();
  360. }
  361. $items = Common::json_decode($items, $assoc = true);
  362. if (!is_array($items)) {
  363. Common::printDebug("Error while json_decode the Ecommerce items = " . var_export($items, true));
  364. return false;
  365. }
  366. $cleanedItems = $this->getCleanedEcommerceItems($items);
  367. return $cleanedItems;
  368. }
  369. /**
  370. * Loads the Ecommerce items from the request and records them in the DB
  371. *
  372. * @param array $goal
  373. * @param array $items
  374. * @throws Exception
  375. * @return int Number of items in the cart
  376. */
  377. protected function recordEcommerceItems($goal, $items)
  378. {
  379. $itemInCartBySku = array();
  380. foreach ($items as $item) {
  381. $itemInCartBySku[$item[0]] = $item;
  382. }
  383. // Select all items currently in the Cart if any
  384. $sql = "SELECT idaction_sku, idaction_name, idaction_category, idaction_category2, idaction_category3, idaction_category4, idaction_category5, price, quantity, deleted, idorder as idorder_original_value
  385. FROM " . Common::prefixTable('log_conversion_item') . "
  386. WHERE idvisit = ?
  387. AND (idorder = ? OR idorder = ?)";
  388. $bind = array($goal['idvisit'],
  389. isset($goal['idorder']) ? $goal['idorder'] : self::ITEM_IDORDER_ABANDONED_CART,
  390. self::ITEM_IDORDER_ABANDONED_CART
  391. );
  392. $itemsInDb = Tracker::getDatabase()->fetchAll($sql, $bind);
  393. Common::printDebug("Items found in current cart, for conversion_item (visit,idorder)=" . var_export($bind, true));
  394. Common::printDebug($itemsInDb);
  395. // Look at which items need to be deleted, which need to be added or updated, based on the SKU
  396. $skuFoundInDb = $itemsToUpdate = array();
  397. foreach ($itemsInDb as $itemInDb) {
  398. $skuFoundInDb[] = $itemInDb['idaction_sku'];
  399. // Ensure price comparisons will have the same assumption
  400. $itemInDb['price'] = $this->getRevenue($itemInDb['price']);
  401. $itemInDbOriginal = $itemInDb;
  402. $itemInDb = array_values($itemInDb);
  403. // Cast all as string, because what comes out of the fetchAll() are strings
  404. $itemInDb = $this->getItemRowCast($itemInDb);
  405. //Item in the cart in the DB, but not anymore in the cart
  406. if (!isset($itemInCartBySku[$itemInDb[0]])) {
  407. $itemToUpdate = array_merge($itemInDb,
  408. array('deleted' => 1,
  409. 'idorder_original_value' => $itemInDbOriginal['idorder_original_value']
  410. )
  411. );
  412. $itemsToUpdate[] = $itemToUpdate;
  413. Common::printDebug("Item found in the previous Cart, but no in the current cart/order");
  414. Common::printDebug($itemToUpdate);
  415. continue;
  416. }
  417. $newItem = $itemInCartBySku[$itemInDb[0]];
  418. $newItem = $this->getItemRowCast($newItem);
  419. if (count($itemInDb) != count($newItem)) {
  420. Common::printDebug("ERROR: Different format in items from cart and DB");
  421. 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));
  422. }
  423. Common::printDebug("Item has changed since the last cart. Previous item stored in cart in database:");
  424. Common::printDebug($itemInDb);
  425. Common::printDebug("New item to UPDATE the previous row:");
  426. $newItem['idorder_original_value'] = $itemInDbOriginal['idorder_original_value'];
  427. Common::printDebug($newItem);
  428. $itemsToUpdate[] = $newItem;
  429. }
  430. // Items to UPDATE
  431. $this->updateEcommerceItems($goal, $itemsToUpdate);
  432. // Items to INSERT
  433. $itemsToInsert = array();
  434. foreach ($items as $item) {
  435. if (!in_array($item[0], $skuFoundInDb)) {
  436. $itemsToInsert[] = $item;
  437. }
  438. }
  439. $this->insertEcommerceItems($goal, $itemsToInsert);
  440. }
  441. // In the GET items parameter, each item has the following array of information
  442. const INDEX_ITEM_SKU = 0;
  443. const INDEX_ITEM_NAME = 1;
  444. const INDEX_ITEM_CATEGORY = 2;
  445. const INDEX_ITEM_PRICE = 3;
  446. const INDEX_ITEM_QUANTITY = 4;
  447. // Used in the array of items, internally to this class
  448. const INTERNAL_ITEM_SKU = 0;
  449. const INTERNAL_ITEM_NAME = 1;
  450. const INTERNAL_ITEM_CATEGORY = 2;
  451. const INTERNAL_ITEM_CATEGORY2 = 3;
  452. const INTERNAL_ITEM_CATEGORY3 = 4;
  453. const INTERNAL_ITEM_CATEGORY4 = 5;
  454. const INTERNAL_ITEM_CATEGORY5 = 6;
  455. const INTERNAL_ITEM_PRICE = 7;
  456. const INTERNAL_ITEM_QUANTITY = 8;
  457. /**
  458. * Reads items from the request, then looks up the names from the lookup table
  459. * and returns a clean array of items ready for the database.
  460. *
  461. * @param array $items
  462. * @return array $cleanedItems
  463. */
  464. protected function getCleanedEcommerceItems($items)
  465. {
  466. // Clean up the items array
  467. $cleanedItems = array();
  468. foreach ($items as $item) {
  469. $name = $category = $category2 = $category3 = $category4 = $category5 = false;
  470. $price = 0;
  471. $quantity = 1;
  472. // items are passed in the request as an array: ( $sku, $name, $category, $price, $quantity )
  473. if (empty($item[self::INDEX_ITEM_SKU])) {
  474. continue;
  475. }
  476. $sku = $item[self::INDEX_ITEM_SKU];
  477. if (!empty($item[self::INDEX_ITEM_NAME])) {
  478. $name = $item[self::INDEX_ITEM_NAME];
  479. }
  480. if (!empty($item[self::INDEX_ITEM_CATEGORY])) {
  481. $category = $item[self::INDEX_ITEM_CATEGORY];
  482. }
  483. if (isset($item[self::INDEX_ITEM_PRICE])
  484. && is_numeric($item[self::INDEX_ITEM_PRICE])
  485. ) {
  486. $price = $this->getRevenue($item[self::INDEX_ITEM_PRICE]);
  487. }
  488. if (!empty($item[self::INDEX_ITEM_QUANTITY])
  489. && is_numeric($item[self::INDEX_ITEM_QUANTITY])
  490. ) {
  491. $quantity = (int)$item[self::INDEX_ITEM_QUANTITY];
  492. }
  493. // self::INDEX_ITEM_* are in order
  494. $cleanedItems[] = array(
  495. self::INTERNAL_ITEM_SKU => $sku,
  496. self::INTERNAL_ITEM_NAME => $name,
  497. self::INTERNAL_ITEM_CATEGORY => $category,
  498. self::INTERNAL_ITEM_CATEGORY2 => $category2,
  499. self::INTERNAL_ITEM_CATEGORY3 => $category3,
  500. self::INTERNAL_ITEM_CATEGORY4 => $category4,
  501. self::INTERNAL_ITEM_CATEGORY5 => $category5,
  502. self::INTERNAL_ITEM_PRICE => $price,
  503. self::INTERNAL_ITEM_QUANTITY => $quantity
  504. );
  505. }
  506. // Lookup Item SKUs, Names & Categories Ids
  507. $actionsToLookupAllItems = array();
  508. // Each item has 7 potential "ids" to lookup in the lookup table
  509. $columnsInEachRow = 1 + 1 + self::MAXIMUM_PRODUCT_CATEGORIES;
  510. foreach ($cleanedItems as $item) {
  511. $actionsToLookup = array();
  512. list($sku, $name, $category, $price, $quantity) = $item;
  513. $actionsToLookup[] = array(trim($sku), Action::TYPE_ECOMMERCE_ITEM_SKU);
  514. $actionsToLookup[] = array(trim($name), Action::TYPE_ECOMMERCE_ITEM_NAME);
  515. // Only one category
  516. if (!is_array($category)) {
  517. $actionsToLookup[] = array(trim($category), Action::TYPE_ECOMMERCE_ITEM_CATEGORY);
  518. } // Multiple categories
  519. else {
  520. $countCategories = 0;
  521. foreach ($category as $productCategory) {
  522. $productCategory = trim($productCategory);
  523. if (empty($productCategory)) {
  524. continue;
  525. }
  526. $countCategories++;
  527. if ($countCategories > self::MAXIMUM_PRODUCT_CATEGORIES) {
  528. break;
  529. }
  530. $actionsToLookup[] = array($productCategory, Action::TYPE_ECOMMERCE_ITEM_CATEGORY);
  531. }
  532. }
  533. // Ensure that each row has the same number of columns, fill in the blanks
  534. for ($i = count($actionsToLookup); $i < $columnsInEachRow; $i++) {
  535. $actionsToLookup[] = array(false, Action::TYPE_ECOMMERCE_ITEM_CATEGORY);
  536. }
  537. $actionsToLookupAllItems = array_merge($actionsToLookupAllItems, $actionsToLookup);
  538. }
  539. $actionsLookedUp = TableLogAction::loadIdsAction($actionsToLookupAllItems);
  540. // Replace SKU, name & category by their ID action
  541. foreach ($cleanedItems as $index => &$item) {
  542. // SKU
  543. $item[0] = $actionsLookedUp[$index * $columnsInEachRow + 0];
  544. // Name
  545. $item[1] = $actionsLookedUp[$index * $columnsInEachRow + 1];
  546. // Categories
  547. $item[2] = $actionsLookedUp[$index * $columnsInEachRow + 2];
  548. $item[3] = $actionsLookedUp[$index * $columnsInEachRow + 3];
  549. $item[4] = $actionsLookedUp[$index * $columnsInEachRow + 4];
  550. $item[5] = $actionsLookedUp[$index * $columnsInEachRow + 5];
  551. $item[6] = $actionsLookedUp[$index * $columnsInEachRow + 6];
  552. }
  553. return $cleanedItems;
  554. }
  555. /**
  556. * Updates the cart items in the DB
  557. * that have been modified since the last cart update
  558. *
  559. * @param array $goal
  560. * @param array $itemsToUpdate
  561. *
  562. * @return void
  563. */
  564. protected function updateEcommerceItems($goal, $itemsToUpdate)
  565. {
  566. if (empty($itemsToUpdate)) {
  567. return;
  568. }
  569. Common::printDebug("Goal data used to update ecommerce items:");
  570. Common::printDebug($goal);
  571. foreach ($itemsToUpdate as $item) {
  572. $newRow = $this->getItemRowEnriched($goal, $item);
  573. Common::printDebug($newRow);
  574. $updateParts = $sqlBind = array();
  575. foreach ($newRow AS $name => $value) {
  576. $updateParts[] = $name . " = ?";
  577. $sqlBind[] = $value;
  578. }
  579. $sql = 'UPDATE ' . Common::prefixTable('log_conversion_item') . "
  580. SET " . implode($updateParts, ', ') . "
  581. WHERE idvisit = ?
  582. AND idorder = ?
  583. AND idaction_sku = ?";
  584. $sqlBind[] = $newRow['idvisit'];
  585. $sqlBind[] = $item['idorder_original_value'];
  586. $sqlBind[] = $newRow['idaction_sku'];
  587. Tracker::getDatabase()->query($sql, $sqlBind);
  588. }
  589. }
  590. /**
  591. * Inserts in the cart in the DB the new items
  592. * that were not previously in the cart
  593. *
  594. * @param array $goal
  595. * @param array $itemsToInsert
  596. *
  597. * @return void
  598. */
  599. protected function insertEcommerceItems($goal, $itemsToInsert)
  600. {
  601. if (empty($itemsToInsert)) {
  602. return;
  603. }
  604. Common::printDebug("Ecommerce items that are added to the cart/order");
  605. Common::printDebug($itemsToInsert);
  606. $sql = "INSERT INTO " . Common::prefixTable('log_conversion_item') . "
  607. (idaction_sku, idaction_name, idaction_category, idaction_category2, idaction_category3, idaction_category4, idaction_category5, price, quantity, deleted,
  608. idorder, idsite, idvisitor, server_time, idvisit)
  609. VALUES ";
  610. $i = 0;
  611. $bind = array();
  612. foreach ($itemsToInsert as $item) {
  613. if ($i > 0) {
  614. $sql .= ',';
  615. }
  616. $newRow = array_values($this->getItemRowEnriched($goal, $item));
  617. $sql .= " ( " . Common::getSqlStringFieldsArray($newRow) . " ) ";
  618. $i++;
  619. $bind = array_merge($bind, $newRow);
  620. }
  621. Tracker::getDatabase()->query($sql, $bind);
  622. Common::printDebug($sql);
  623. Common::printDebug($bind);
  624. }
  625. protected function getItemRowEnriched($goal, $item)
  626. {
  627. $newRow = array(
  628. 'idaction_sku' => (int)$item[self::INTERNAL_ITEM_SKU],
  629. 'idaction_name' => (int)$item[self::INTERNAL_ITEM_NAME],
  630. 'idaction_category' => (int)$item[self::INTERNAL_ITEM_CATEGORY],
  631. 'idaction_category2' => (int)$item[self::INTERNAL_ITEM_CATEGORY2],
  632. 'idaction_category3' => (int)$item[self::INTERNAL_ITEM_CATEGORY3],
  633. 'idaction_category4' => (int)$item[self::INTERNAL_ITEM_CATEGORY4],
  634. 'idaction_category5' => (int)$item[self::INTERNAL_ITEM_CATEGORY5],
  635. 'price' => $item[self::INTERNAL_ITEM_PRICE],
  636. 'quantity' => $item[self::INTERNAL_ITEM_QUANTITY],
  637. 'deleted' => isset($item['deleted']) ? $item['deleted'] : 0, //deleted
  638. 'idorder' => isset($goal['idorder']) ? $goal['idorder'] : self::ITEM_IDORDER_ABANDONED_CART, //idorder = 0 in log_conversion_item for carts
  639. 'idsite' => $goal['idsite'],
  640. 'idvisitor' => $goal['idvisitor'],
  641. 'server_time' => $goal['server_time'],
  642. 'idvisit' => $goal['idvisit']
  643. );
  644. return $newRow;
  645. }
  646. /**
  647. * Records a standard non-Ecommerce goal in the DB (URL/Title matching),
  648. * linking the conversion to the action that triggered it
  649. * @param $goal
  650. * @param Action $action
  651. * @param $visitorInformation
  652. */
  653. protected function recordStandardGoals($goal, $action, $visitorInformation)
  654. {
  655. foreach ($this->convertedGoals as $convertedGoal) {
  656. Common::printDebug("- Goal " . $convertedGoal['idgoal'] . " matched. Recording...");
  657. $conversion = $goal;
  658. $conversion['idgoal'] = $convertedGoal['idgoal'];
  659. $conversion['url'] = $convertedGoal['url'];
  660. $conversion['revenue'] = $this->getRevenue($convertedGoal['revenue']);
  661. if (!is_null($action)) {
  662. $conversion['idaction_url'] = $action->getIdActionUrl();
  663. $conversion['idlink_va'] = $action->getIdLinkVisitAction();
  664. }
  665. // If multiple Goal conversions per visit, set a cache buster
  666. $conversion['buster'] = $convertedGoal['allow_multiple'] == 0
  667. ? '0'
  668. : $visitorInformation['visit_last_action_time'];
  669. $this->insertNewConversion($conversion, $visitorInformation);
  670. /**
  671. * Triggered after successfully recording a non-ecommerce conversion.
  672. *
  673. * _Note: Subscribers should be wary of doing any expensive computation here as it may slow
  674. * the tracker down._
  675. *
  676. * @param array $conversion The conversion entity that was just persisted. See what information
  677. * it contains [here](/guides/persistence-and-the-mysql-backend#conversions).
  678. */
  679. Piwik::postEvent('Tracker.recordStandardGoals', array($conversion));
  680. }
  681. }
  682. /**
  683. * Helper function used by other record* methods which will INSERT or UPDATE the conversion in the DB
  684. *
  685. * @param array $conversion
  686. * @param array $visitInformation
  687. * @return bool
  688. */
  689. protected function insertNewConversion($conversion, $visitInformation)
  690. {
  691. /**
  692. * Triggered before persisting a new [conversion entity](/guides/persistence-and-the-mysql-backend#conversions).
  693. *
  694. * This event can be used to modify conversion information or to add new information to be persisted.
  695. *
  696. * @param array $conversion The conversion entity. Read [this](/guides/persistence-and-the-mysql-backend#conversions)
  697. * to see what it contains.
  698. * @param array $visitInformation The visit entity that we are tracking a conversion for. See what
  699. * information it contains [here](/guides/persistence-and-the-mysql-backend#visits).
  700. * @param \Piwik\Tracker\Request $request An object describing the tracking request being processed.
  701. */
  702. Piwik::postEvent('Tracker.newConversionInformation', array(&$conversion, $visitInformation, $this->request));
  703. $newGoalDebug = $conversion;
  704. $newGoalDebug['idvisitor'] = bin2hex($newGoalDebug['idvisitor']);
  705. Common::printDebug($newGoalDebug);
  706. $fields = implode(", ", array_keys($conversion));
  707. $bindFields = Common::getSqlStringFieldsArray($conversion);
  708. $sql = 'INSERT IGNORE INTO ' . Common::prefixTable('log_conversion') . "
  709. ($fields) VALUES ($bindFields) ";
  710. $bind = array_values($conversion);
  711. $result = Tracker::getDatabase()->query($sql, $bind);
  712. // If a record was inserted, we return true
  713. return Tracker::getDatabase()->rowCount($result) > 0;
  714. }
  715. /**
  716. * Casts the item array so that array comparisons work nicely
  717. * @param array $row
  718. * @return array
  719. */
  720. protected function getItemRowCast($row)
  721. {
  722. return array(
  723. (string)(int)$row[self::INTERNAL_ITEM_SKU],
  724. (string)(int)$row[self::INTERNAL_ITEM_NAME],
  725. (string)(int)$row[self::INTERNAL_ITEM_CATEGORY],
  726. (string)(int)$row[self::INTERNAL_ITEM_CATEGORY2],
  727. (string)(int)$row[self::INTERNAL_ITEM_CATEGORY3],
  728. (string)(int)$row[self::INTERNAL_ITEM_CATEGORY4],
  729. (string)(int)$row[self::INTERNAL_ITEM_CATEGORY5],
  730. (string)$row[self::INTERNAL_ITEM_PRICE],
  731. (string)$row[self::INTERNAL_ITEM_QUANTITY],
  732. );
  733. }
  734. protected function updateExistingConversion($newGoal, $updateWhere)
  735. {
  736. $updateParts = $sqlBind = $updateWhereParts = array();
  737. foreach ($newGoal AS $name => $value) {
  738. $updateParts[] = $name . " = ?";
  739. $sqlBind[] = $value;
  740. }
  741. foreach ($updateWhere as $name => $value) {
  742. $updateWhereParts[] = $name . " = ?";
  743. $sqlBind[] = $value;
  744. }
  745. $sql = 'UPDATE ' . Common::prefixTable('log_conversion') . "
  746. SET " . implode($updateParts, ', ') . "
  747. WHERE " . implode($updateWhereParts, ' AND ');
  748. try {
  749. Tracker::getDatabase()->query($sql, $sqlBind);
  750. } catch(Exception $e){
  751. Common::printDebug("There was an error while updating the Conversion: " . $e->getMessage());
  752. return false;
  753. }
  754. return true;
  755. }
  756. /**
  757. * @param $type
  758. * @param $name
  759. * @param $keyword
  760. */
  761. protected function setCampaignValuesToLowercase($type, &$name, &$keyword)
  762. {
  763. if ($type === Common::REFERRER_TYPE_CAMPAIGN) {
  764. if (!empty($name)) {
  765. $name = Common::mb_strtolower($name);
  766. }
  767. if (!empty($keyword)) {
  768. $keyword = Common::mb_strtolower($keyword);
  769. }
  770. }
  771. }
  772. /**
  773. * @param $goal
  774. * @param $pattern_type
  775. * @param $url
  776. * @return bool
  777. * @throws \Exception
  778. */
  779. protected function isUrlMatchingGoal($goal, $pattern_type, $url)
  780. {
  781. switch ($pattern_type) {
  782. case 'regex':
  783. $pattern = $goal['pattern'];
  784. if (strpos($pattern, '/') !== false
  785. && strpos($pattern, '\\/') === false
  786. ) {
  787. $pattern = str_replace('/', '\\/', $pattern);
  788. }
  789. $pattern = '/' . $pattern . '/';
  790. if (!$goal['case_sensitive']) {
  791. $pattern .= 'i';
  792. }
  793. $match = (@preg_match($pattern, $url) == 1);
  794. break;
  795. case 'contains':
  796. if ($goal['case_sensitive']) {
  797. $matched = strpos($url, $goal['pattern']);
  798. } else {
  799. $matched = stripos($url, $goal['pattern']);
  800. }
  801. $match = ($matched !== false);
  802. break;
  803. case 'exact':
  804. if ($goal['case_sensitive']) {
  805. $matched = strcmp($goal['pattern'], $url);
  806. } else {
  807. $matched = strcasecmp($goal['pattern'], $url);
  808. }
  809. $match = ($matched == 0);
  810. break;
  811. default:
  812. throw new Exception(Piwik::translate('General_ExceptionInvalidGoalPattern', array($pattern_type)));
  813. break;
  814. }
  815. return $match;
  816. }
  817. }