1
0
قرینه از https://github.com/matomo-org/matomo.git synced 2025-08-21 22:47:43 +00:00
Files
matomo/plugins/Goals/Commands/CalculateConversionPages.php
Michal Kleiner 61be6ac642 Allow to pass a single string to write as a console message (#22648)
* Allow to pass a single string to write as a console message

* Remove union type

Co-authored-by: Stefan Giehl <stefan@matomo.org>

---------

Co-authored-by: Stefan Giehl <stefan@matomo.org>
2024-10-10 09:33:02 +02:00

379 خطوط
14 KiB
PHP

<?php
/**
* 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\Plugins\Goals\Commands;
use Piwik\Common;
use Piwik\Container\StaticContainer;
use Piwik\Date;
use Piwik\Db;
use Piwik\Plugin\ConsoleCommand;
use Piwik\Plugins\Goals\Model as GoalsModel;
use Piwik\Plugins\SitesManager\Model as SitesModel;
use Piwik\Site;
use Piwik\Timer;
use Piwik\Tracker\GoalManager;
use Piwik\Updater;
use Piwik\Updater\Migration\Factory as MigrationFactory;
/**
* Command to calculate the pages viewed before conversions and populate the log_conversion.pages_before field
*/
class CalculateConversionPages extends ConsoleCommand
{
protected function configure()
{
$this->setName('core:calculate-conversion-pages');
$this->setDescription('Calculate the pages before metric for historic conversions');
$this->addOptionalValueOption('dates', null, 'Calculate for conversions in this date range. Eg, 2012-01-01,2013-01-01', null);
$this->addOptionalValueOption('last-n', null, 'Calculate just the last n conversions', 0);
$this->addOptionalValueOption(
'idsite',
null,
'Calculate for conversions belonging to the site with this ID. Comma separated list of website id. Eg, 1, 2, 3, etc. By default conversions from all sites are calculated.',
null
);
$this->addOptionalValueOption('idgoal', null, 'Calculate conversions for this goal. A comma separated list of goal ids can be used only if a single site is specified. Eg, 1, 2, 3, etc. By default conversions for all goals are calculated.', null);
$this->addOptionalValueOption('force-recalc', null, 'Recalculate for conversions which already have a pages before value', 0);
}
protected function doExecute(): int
{
$dates = $this->getInput()->getOption('dates');
$lastN = $this->getInput()->getOption('last-n');
$forceRecalc = $this->getInput()->getOption('force-recalc');
$idSite = $this->getSitesToCalculate();
$idGoal = $this->getGoalsToCalculate();
if (!$lastN && !$dates) {
throw new \InvalidArgumentException("No date range or last N option supplied. Calculating pages before for all conversions by default is not allowed, you must specify a date range using the --dates option or a last N count using the --last-n option");
}
if ($lastN && $dates) {
throw new \InvalidArgumentException("The last N option cannot be used with a date range, please choose just one of these options");
}
if (!is_numeric($lastN)) {
throw new \InvalidArgumentException("The last N option must be a number");
}
$from = null;
$to = null;
if (!empty($dates)) {
[$from, $to] = $this->getDateRangeToCalculate($dates);
}
$output = $this->getOutput();
$output->writeln(sprintf(
"<info>Preparing to calculate the pages before metric for %s conversions belonging to %s %sfor %s.</info>",
$lastN ? "the last " . $lastN : 'all',
$idSite ? "website $idSite" : "ALL websites",
!empty($dates) ? "between " . $from . " and " . $to . " " : '',
$idGoal ? "goal id $idGoal" : "ALL goals"
));
$timer = new Timer();
$queries = $this->getQueries($from, $to, $lastN, $idSite, $idGoal, $forceRecalc);
$totalCalculated = 0;
foreach ($queries as $query) {
try {
$result = Db::query($query['sql'], $query['bind']);
} catch (\Exception $ex) {
$output->writeln("Exception executing query " . $query['sql'] . " with parameters " . json_encode($query['bind']));
throw $ex;
}
$calcCount = $result->rowCount();
$totalCalculated += $calcCount;
$output->write(".");
}
$this->writeSuccessMessage("Successfully calculated the pages before metric for $totalCalculated conversions. <comment>{$timer}</comment>");
return self::SUCCESS;
}
/**
* Static method to calculate conversion for today and yesterday, for all sites and goals.
* Called by the migration updater
*
* @return void
*/
public static function calculateYesterdayAndToday(): void
{
$migration = StaticContainer::get(MigrationFactory::class);
$queries = self::getQueries(
Date::factory('yesterday')->getDatetime(),
Date::factory('today')->getEndOfDay()->getDatetime()
);
$migrations = [];
foreach ($queries as $query) {
$migrations[] = $migration->db->boundSql($query['sql'], $query['bind']);
}
$updater = StaticContainer::get(Updater::class);
$updater->executeMigrations(__FILE__, $migrations);
}
/**
* Validate dates parameter
*
* @param string $dates
* @return Date[]
*/
private function getDateRangeToCalculate(string $dates): ?array
{
$parts = explode(',', $dates);
$parts = array_map('trim', $parts);
if (count($parts) !== 2) {
throw new \InvalidArgumentException("Invalid date range supplied: $dates");
}
[$start, $end] = $parts;
try {
/** @var Date[] $dateObjects */
$dateObjects = [Date::factory($start), Date::factory($end)->getEndOfDay()];
} catch (\Exception $ex) {
throw new \InvalidArgumentException("Invalid date range supplied: $dates (" . $ex->getMessage() . ")", $code = 0, $ex);
}
if ($dateObjects[0]->getTimestamp() > $dateObjects[1]->getTimestamp()) {
throw new \InvalidArgumentException("Invalid date range supplied: $dates (first date is older than the last date)");
}
$dateObjects = [$dateObjects[0]->getDatetime(), $dateObjects[1]->getDatetime()];
return $dateObjects;
}
/**
* Validate the sites parameter
*
* @return string|null
*/
private function getSitesToCalculate(): ?string
{
$idSite = $this->getInput()->getOption('idsite');
if (is_null($idSite)) {
return null;
}
$sites = explode(',', $idSite);
foreach ($sites as $id) {
// validate the site ID
try {
new Site($id);
} catch (\Exception $ex) {
throw new \InvalidArgumentException("Invalid site ID: $id", $code = 0, $ex);
}
}
return $idSite;
}
/**
* Validate the goals parameter
*
* @return string|null
*/
private function getGoalsToCalculate(): ?string
{
$idGoal = $this->getInput()->getOption('idgoal');
if (is_null($idGoal)) {
return null;
}
// Only allow the goals parameter to be used if a single site is specified
$idSite = $this->getInput()->getOption('idsite');
if (!is_numeric($idSite) || strpos($idSite, ',') !== false) {
throw new \InvalidArgumentException("The goals parameter can only be used when a single website is specified using the idsite parameter", $code = 0);
}
$goals = explode(',', $idGoal);
$goalsModel = new GoalsModel();
foreach ($goals as $id) {
// validate the goal id
if (!$goalsModel->doesGoalExist($id, $idSite) && $id != GoalManager::IDGOAL_ORDER) {
throw new \InvalidArgumentException("Invalid goal ID: $id", $code = 0);
}
}
return $idGoal;
}
/**
* Generates the queries to calculate the 'pages before' metric for conversions within the specified date range,
* belonging to the specified site (if any) and specific goals (only if a single site is specified).
*
* @param string|null $startDatetime A datetime string. Visits that occur at this time or after are deleted. If not supplied,
* visits from the beginning of time are deleted.
* @param string|null $endDatetime A datetime string. Visits that occur before this time are deleted. If not supplied,
* visits from the end of time are deleted.
* @param int|null $lastN Calculate the last N conversions, should not be used with a date range
* @param string|null $idSite The site for which to calculate, or list of comma separated sites
* @param string|null $idGoal The goal for which to calculate, or list of comma separated idgoals (only if single site)
* @param bool $forceRecalc If enabled then values will be recalculated for conversions that already have a
* 'pages before' value. By default only conversions with a null value will be calculated.
*
* @return array An array of queries and bind arrays [['sql' => QUERY1, 'bind' => [PARAM1 => VALUE], ...]
*/
private static function getQueries(
?string $startDatetime,
?string $endDatetime,
?int $lastN = null,
?string $idSite = null,
?string $idGoal = null,
?bool $forceRecalc = false
): array {
// Sites
if ($idSite === null) {
// All sites
$sitesModel = new SitesModel();
$sites = $sitesModel->getSitesId();
} else {
// Specific sites
$sites = explode(',', $idSite);
}
if ($lastN) {
// Since MySQL doesn't support multi-table updates with a LIMIT clause we will find the exact date time of
// the lastN record and use that as a date range start with the current date time as the date range end
/** @noinspection SqlResolve SqlUnused */
$sql = "
SELECT MIN(s.t) FROM (
SELECT c.server_time AS t
FROM " . Common::prefixTable('log_conversion') . " c
";
$where = '';
if (!$forceRecalc) {
$where .= " AND c.pageviews_before IS NULL";
}
$bind = [];
if ($idGoal !== null) {
$where .= ' AND c.idgoal = ? ';
$bind[] = $idGoal;
}
if ($idSite !== null) {
$where .= ' AND c.idsite = ? ';
$bind[] = $idSite;
}
if ($where !== '') {
$sql .= ' WHERE ' . ltrim($where, 'AND ');
}
$sql .= " ORDER BY c.server_time DESC LIMIT " . $lastN . ") AS s";
$result = Db::fetchOne($sql, $bind);
if (!$result) {
return [];
}
$startDatetime = $result;
$endDatetime = Date::factory('now')->getDatetime();
}
// When querying for visit actions that contributed to the conversion we can use a cut off 24hrs before the
// start of the conversion date range as visits cannot last more than 24hrs, this limits the number of rows
// addressed by the subquery
$startDateTimeForActions = Date::factory($startDatetime)->subDay(1)->getDatetime();
$queries = [];
foreach ($sites as $site) {
$timezone = Site::getTimezoneFor($site);
if ($idGoal === null) {
// All goals
$gids = Db::fetchAll("SELECT idgoal FROM " . Common::prefixTable('goal') . "
WHERE idsite = ? AND deleted = 0", [$site]);
$goals = array_column($gids, 'idgoal');
// Include ecommerce orders if enabled for the site
if (Site::isEcommerceEnabledFor($site)) {
$goals[] = GoalManager::IDGOAL_ORDER;
}
} else {
// Specific goals
$goals = explode(',', $idGoal);
}
foreach ($goals as $goal) {
$where = '';
if (!$forceRecalc) {
$where .= " AND c.pageviews_before IS NULL";
}
$conversionsStartDate = Date::factory($startDatetime, $timezone)->getDateTime();
$conversionsEndDate = Date::factory($endDatetime, $timezone)->getDateTime();
$bind = [
$site,
Date::factory($startDateTimeForActions, $timezone)->getDateTime(),
$conversionsEndDate,
$site,
$goal,
$conversionsStartDate,
$conversionsEndDate,
$site,
$goal,
$conversionsStartDate,
$conversionsEndDate,
];
$sql = "
UPDATE " . Common::prefixTable('log_conversion') . " c
LEFT JOIN (
SELECT c.idvisit, c.idgoal, COUNT(a.idvisit) AS pagesbefore, c.idlink_va, c.server_time
FROM " . Common::prefixTable('log_conversion') . " c
LEFT JOIN (
SELECT va.idvisit, va.server_time
FROM " . Common::prefixTable('log_link_visit_action') . " va
INNER JOIN " . Common::prefixTable('log_action') . " a ON a.idaction = va.idaction_url
WHERE a.type = 1
AND va.idsite = ?
AND va.server_time >= ?
AND va.server_time <= ?
ORDER BY NULL
) AS a ON a.idvisit = c.idvisit AND a.server_time <= c.server_time
WHERE c.idsite = ?
AND c.idgoal = ?
AND c.server_time >= ?
AND c.server_time <= ?
" . $where . "
GROUP BY a.idvisit
ORDER BY NULL
) AS s ON s.idvisit = c.idvisit AND s.server_time <= c.server_time
SET c.pageviews_before = s.pagesbefore
WHERE c.idsite = ?
AND c.idgoal = ?
AND c.server_time >= ?
AND c.server_time <= ?
" . $where;
$queries[] = ['sql' => $sql, 'bind' => $bind];
}
}
return $queries;
}
}