contactcats/Civi/ContactCats/Processor.php
2025-02-28 10:01:57 +00:00

272 lines
10 KiB
PHP

<?php
namespace Civi\ContactCats;
use CRM_Contactcats_ExtensionUtil as E;
use Civi;
use Civi\Api4\Activity;
use Civi\Api4\ContactCategoryDefinition;
use Civi\Api4\Group;
use Civi\Api4\SavedSearch;
use CRM_Core_DAO;
use CRM_Core_Exception;
class Processor {
protected array $categories = [];
protected array $groupDetails = [];
protected array $searchDetails = [];
public function __construct() {
$this->categories = ContactCategoryDefinition::get(FALSE)
->addOrderBy('execution_order')
->execute()->indexBy('id')->getArrayCopy();
// Check the last item is the 'default' one.
if ((end($this->categories)['search_type'] ?? '') !== 'default') {
throw new CRM_Core_Exception("ContactCategoryDefinition unconfigured; no default category.");
}
// Identify groups and searches used by definitions.
$groupIDs = $searchIDs = [];
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
$groupIDs[] = (int) $cat['search_data']['group_id'];
}
elseif ($cat['search_type'] === 'search') {
$searchIDs[] = (int) $cat['search_data']['saved_search_id'];
}
}
// Load group details; ensure all groups still exist.
$this->groupDetails = [];
if ($groupIDs) {
$this->groupDetails = Group::get(FALSE)
->addWhere('id', 'IN', $groupIDs)
->execute()->indexBy('id')->getArrayCopy();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
if (!isset($this->groupDetails[$cat['search_data']['group_id'] ?? 0])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Group: " . $cat['search_data']['group_id']);
}
}
}
}
// Load all searches
$this->searchDetails = [];
if ($searchIDs) {
$this->searchDetails = SavedSearch::get(FALSE)
->addWhere('id', 'IN', $searchIDs)
->execute()->indexBy('id')->getArrayCopy();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'search') {
if (!isset($this->searchDetails[$cat['search_data']['saved_search_id'] ?? 0])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Search: " . $cat['search_data']['saved_search_id']);
}
$search = $this->searchDetails[$cat['search_data']['saved_search_id']];
if (!isset($search['api_params']) || !isset($search['api_entity'])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] uses search $search[id] which does not look appropriate.");
}
}
}
}
// TODO: check things like searches that group by contact_id
}
/**
* Main calling point.
*/
public function run() {
$this->refreshSmartGroups();
$summary = [];
\CRM_Core_Transaction::create()->run(function($tx) use (&$summary) {
$this->resetTable();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
$this->assignCategoryFromGroup($cat);
}
elseif ($cat['search_type'] === 'search') {
$this->assignCategoryFromSearch($cat);
}
elseif ($cat['search_type'] === 'default') {
$this->assignDefaultCategory($cat);
}
else {
throw new CRM_Core_DAO("Invalid search_type: $cat[search_type] for category $cat[id]");
}
// future...
}
$summary = $this->createChangeActivities();
});
return $summary;
}
protected function createChangeActivities() {
Civi::log()->debug("Calculate changes", ['=' => 'timed', '=start' => "changes"]);
$changes = CRM_Core_DAO::executeQuery(<<<SQL
SELECT id, category_definition_id, next_category
FROM civicrm_contact_category
WHERE next_category <> category_definition_id
ORDER BY category_definition_id, next_category
SQL);
$lastChange = [0, 0];
$batch = [];
$n = 0;
$domainContactID = \CRM_Core_BAO_Domain::getDomain()->contact_id;
$writeBatch = function() use (&$lastChange, &$batch, $domainContactID) {
if (empty($batch)) {
return;
}
$oldCategoryId = (int) ($lastChange[0] ?? 0);
$newCategoryId = (int) ($lastChange[1] ?? 0);
$oldName = $this->categories[$oldCategoryId]['label'] ?? E::ts('(Unknown)');
$subject = "$oldName → " . $this->categories[$newCategoryId]['label'];
// Create activity on the first contact
$a = Activity::create(FALSE)
->addValue('target_contact_id', $batch[0])
->addValue('activity_type_id:name', 'changed_contact_category')
->addValue('source_contact_id', $domainContactID)
->addValue('status_id:name', 'Completed')
->addValue('subject', $subject)
->addValue('Category_changes.previous_category_id', $oldCategoryId)
->addValue('Category_changes.new_category_id', $newCategoryId)
->execute()->first();
$activityID = (int) $a['id'];
// Join activity to all relevant contacts
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_activity_contact (contact_id, activity_id, record_type_id)
SELECT id contact_id, $activityID activity_id, 3 record_type_id
FROM civicrm_contact_category cc
WHERE cc.category_definition_id = $oldCategoryId
AND cc.next_category = $newCategoryId
AND cc.id <> $batch[0]
")->free();
Civi::log()->debug(count($batch) . " changes: $subject");
};
while ($changes->fetch()) {
$n++;
if ($lastChange[0] !== $changes->category_definition_id || $lastChange[1] !== $changes->next_category) {
$writeBatch();
$lastChange = [$changes->category_definition_id, $changes->next_category];
$batch = [];
}
$batch[] = $changes->id;
}
$changes->free();
$writeBatch();
Civi::log()->debug('Apply changes', ['=change' => 'applyChanges', '=timed' => 1]);
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category
SET category_definition_id = next_category
WHERE category_definition_id IS NULL OR category_definition_id <> next_category")->free();
Civi::log()->debug('', ['=pop' => 1]);
$summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category GROUP BY next_category")->fetchAll();
$summary['changes'] = $n;
$_ = memory_get_peak_usage(TRUE);
$summary['memory_use'] = @round($_ / pow(1024, ($i = floor(log($_, 1024)))), 2) . ' ' . ['b', 'kb', 'mb', 'gb', 'tb', 'pb'][$i];
return $summary;
}
protected function refreshSmartGroups() {
$smartGroups = [];
foreach ($this->groupDetails as $groupID => $group) {
if (!empty($group['saved_search_id'])) {
$smartGroups[] = $groupID;
}
}
if ($smartGroups) {
Civi::log()->debug('Refreshing smart groups', ['=' => 'timed,start', 'groups' => $smartGroups]);
\CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups);
Civi::log()->debug('', ['=' => 'pop']);
}
}
protected function resetTable() {
Civi::log()->debug('Resetting table', ['=' => 'start,timed']);
// clear out temp space.
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = 0;")->free();
Civi::log()->debug('Resetting table stage 2');
// ensure we have all our contacts covered.
// Q: is it quicker to do a WHERE NOT EXISTS? A: nope.
CRM_Core_DAO::executeQuery(<<<SQL
INSERT INTO civicrm_contact_category
SELECT id, NULL as category_definition_id, 0 next_category
FROM civicrm_contact
WHERE NOT EXISTS (SELECT id FROM civicrm_contact_category WHERE id = civicrm_contact.id)
SQL)->free();
Civi::log()->debug('', ['=' => 'pop']);
}
/**
*
*/
protected function assignCategoryFromGroup(array $cat) {
$groupID = (int) $cat['search_data']['group_id'];
$group = $this->groupDetails[$groupID];
// Put unclaimed contacts in this group into this category.
$isSmart = !empty($group['saved_search_id']);
Civi::log()->debug($group['title'] . ($isSmart ? '(smart)' : ''), ['=' => 'timed', '=start' => "group$groupID"]);
$table = $isSmart ? 'civicrm_group_contact_cache' : 'civicrm_group_contact';
$statusWhere = $isSmart ? '' : 'AND gc.status = "Added"';
$sql = <<<SQL
UPDATE civicrm_contact_category cc
INNER JOIN $table gc ON gc.contact_id = cc.id AND group_id = $groupID $statusWhere
SET next_category = {$cat['id']}
WHERE next_category = 0
SQL;
CRM_Core_DAO::executeQuery($sql)->free();
Civi::log()->debug('', ['=' => 'pop']);
}
/**
*
*/
protected function assignCategoryFromSearch(array $cat) {
$search = $this->searchDetails[$cat['search_data']['saved_search_id']];
Civi::log()->debug("Doing $cat[id] $cat[label]", ['=' => 'timed', '=start' => "ss" . $cat['search_data']['saved_search_id']]);
$apiParams = $search['api_params'];
if ($search['api_entity'] === 'Contact' && in_array('id', $apiParams['select'] ?? [])) {
$contactIdKey = 'id';
// We only need the ID.
$apiParams['select'] = ['id'];
}
else {
throw new CRM_Core_Exception("can't figure out contactID in search: " . json_encode($search));
}
// We don't need them ordered.
unset($apiParams['orderBy']);
$contactIDs = civicrm_api4($search['api_entity'], 'get', $apiParams)->column($contactIdKey);
// Unsure if this batching is needed
$batchSize = 10000;
while ($batch = array_splice($contactIDs, 0, $batchSize)) {
$batch = implode(',', $batch);
if (!preg_match('/^[0-9,]+$/', $batch)) {
// Surely this can never happen.
throw new CRM_Core_Exception("Erm, received non-integer contact IDs from Search $search[id]!");
}
$sql = <<<SQL
UPDATE civicrm_contact_category cc
SET next_category = {$cat['id']}
WHERE next_category = 0 AND id IN ($batch)
SQL;
CRM_Core_DAO::executeQuery($sql)->free();
}
Civi::log()->debug('', ['=' => 'pop']);
}
protected function assignDefaultCategory(array $cat) {
$id = (int) $cat['id'];
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = $id WHERE next_category = 0;")->free();
}
}