3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
17 class CRM_Report_Form_Contribute_Lybunt
extends CRM_Report_Form
{
19 protected $_charts = [
21 'barChart' => 'Bar Chart',
22 'pieChart' => 'Pie Chart',
26 * This is the report that links will lead to.
28 * It is a bit problematic to use contribute/detail for anything other than a single contact
29 * as the filtering from this report does not carry over to that report.
33 public $_drilldownReport = ['contribute/detail' => 'Link to Detail Report'];
35 protected $lifeTime_from = NULL;
36 protected $lifeTime_where = NULL;
37 protected $_customGroupExtends = [
45 * Table containing list of contact IDs.
49 protected $contactTempTable = '';
52 * This report has been optimised for group filtering.
58 protected $groupFilterNotOptimised = FALSE;
63 public function __construct() {
64 $this->optimisedForOnlyFullGroupBy
= FALSE;
65 $this->_rollup
= 'WITH ROLLUP';
66 $this->_autoIncludeIndexedFieldsAsOrderBys
= 1;
69 $date = CRM_Core_SelectValues
::date('custom', NULL, $yearsInPast, $yearsInFuture);
70 $count = $date['maxYear'];
71 while ($date['minYear'] <= $count) {
72 $optionYear[$date['minYear']] = $date['minYear'];
77 'civicrm_contact' => [
78 'dao' => 'CRM_Contact_DAO_Contact',
79 'grouping' => 'contact-field',
80 'fields' => $this->getBasicContactFields(),
83 'title' => ts('Last Name, First Name'),
85 'default_order' => 'ASC',
88 'name' => 'first_name',
89 'title' => ts('First Name'),
92 'name' => 'gender_id',
93 'title' => ts('Gender'),
96 'name' => 'birth_date',
97 'title' => ts('Birth Date'),
100 'title' => ts('Contact Type'),
102 'contact_sub_type' => [
103 'title' => ts('Contact Subtype'),
108 'title' => ts('Donor Name'),
109 'operator' => 'like',
112 'title' => ts('Contact ID'),
113 'no_display' => TRUE,
116 'title' => ts('Gender'),
117 'operatorType' => CRM_Report_Form
::OP_MULTISELECT
,
118 'options' => CRM_Core_PseudoConstant
::get('CRM_Contact_DAO_Contact', 'gender_id'),
121 'title' => ts('Birth Date'),
122 'operatorType' => CRM_Report_Form
::OP_DATE
,
125 'title' => ts('Contact Type'),
127 'contact_sub_type' => [
128 'title' => ts('Contact Subtype'),
131 'do_not_phone' => [],
132 'do_not_email' => [],
138 'civicrm_line_item' => [
139 'dao' => 'CRM_Price_DAO_LineItem',
142 'dao' => 'CRM_Core_DAO_Email',
143 'grouping' => 'contact-field',
146 'title' => ts('Email'),
150 'title' => ts('Email on hold'),
155 'dao' => 'CRM_Core_DAO_Phone',
156 'grouping' => 'contact-field',
159 'title' => ts('Phone'),
165 $this->_columns +
= $this->addAddressFields(FALSE);
167 'civicrm_contribution' => [
168 'dao' => 'CRM_Contribute_DAO_Contribution',
171 'title' => ts('contactId'),
172 'no_display' => TRUE,
177 'title' => ts('Year'),
178 'no_display' => TRUE,
182 'last_year_total_amount' => [
183 'title' => ts('Last Year Total'),
185 'type' => CRM_Utils_Type
::T_MONEY
,
188 'civicrm_life_time_total' => [
189 'title' => ts('Lifetime Total'),
191 'type' => CRM_Utils_Type
::T_MONEY
,
192 'statistics' => ['sum' => ts('Lifetime total')],
198 'name' => 'receive_date',
199 'title' => ts('This Year'),
200 'operatorType' => CRM_Report_Form
::OP_SELECT
,
201 'options' => $optionYear,
202 'default' => date('Y'),
203 'type' => CRM_Utils_Type
::T_INT
,
205 'financial_type_id' => [
206 'title' => ts('Financial Type'),
207 'type' => CRM_Utils_Type
::T_INT
,
208 'operatorType' => CRM_Report_Form
::OP_MULTISELECT
,
209 'options' => CRM_Financial_BAO_FinancialType
::getAvailableFinancialTypes(),
211 'contribution_status_id' => [
212 'title' => ts('Contribution Status'),
213 'operatorType' => CRM_Report_Form
::OP_MULTISELECT
,
214 'options' => CRM_Contribute_BAO_Contribution
::buildOptions('contribution_status_id', 'search'),
219 'last_year_total_amount' => [
220 'title' => ts('Total amount last year'),
222 'default_weight' => '0',
223 'default_order' => 'DESC',
229 'civicrm_financial_trxn' => [
230 'dao' => 'CRM_Financial_DAO_FinancialTrxn',
233 'title' => ts('Credit Card Type'),
234 'dbAlias' => 'GROUP_CONCAT(financial_trxn_civireport.card_type_id SEPARATOR ",")',
239 'title' => ts('Credit Card Type'),
240 'operatorType' => CRM_Report_Form
::OP_MULTISELECT
,
241 'options' => CRM_Financial_DAO_FinancialTrxn
::buildOptions('card_type_id'),
243 'type' => CRM_Utils_Type
::T_STRING
,
249 // If we have a campaign, build out the relevant elements
250 $this->addCampaignFields('civicrm_contribution');
252 $this->_groupFilter
= TRUE;
253 $this->_tagFilter
= TRUE;
254 parent
::__construct();
258 * Build select clause for a single field.
260 * @param string $tableName
261 * @param string $tableKey
262 * @param string $fieldName
263 * @param string $field
267 public function selectClause(&$tableName, $tableKey, &$fieldName, &$field) {
268 if ($fieldName == 'last_year_total_amount') {
269 $this->_columnHeaders
["{$tableName}_{$fieldName}"] = $field;
270 $this->_columnHeaders
["{$tableName}_{$fieldName}"]['title'] = $this->getLastYearColumnTitle();
271 $this->_statFields
[$this->getLastYearColumnTitle()] = "{$tableName}_{$fieldName}";
272 return "SUM(IF(" . $this->whereClauseLastYear('contribution_civireport.receive_date') . ", contribution_civireport.total_amount, 0)) as {$tableName}_{$fieldName}";
274 if ($fieldName == 'civicrm_life_time_total') {
275 $this->_columnHeaders
["{$tableName}_{$fieldName}"] = $field;
276 $this->_statFields
[$field['title']] = "{$tableName}_{$fieldName}";
277 return "SUM({$this->_aliases[$tableName]}.total_amount) as {$tableName}_{$fieldName}";
279 if ($fieldName == 'receive_date') {
280 return self
::fiscalYearOffset($field['dbAlias']) .
281 " as {$tableName}_{$fieldName} ";
287 * Get the title for the last year column.
289 public function getLastYearColumnTitle() {
290 if ($this->getYearFilterType() == 'calendar') {
291 return ts('Total for ') . ($this->getCurrentYear() - 1);
293 return ts('Total for Fiscal Year ') . ($this->getCurrentYear() - 1) . '-' . ($this->getCurrentYear());
297 * Construct from clause.
299 * On the first run we are creating a table of contacts to include in the report.
301 * Once contactTempTable is populated we should avoid using any further filters that affect
302 * the contacts that should be visible.
304 public function from() {
305 if (!empty($this->contactTempTable
)) {
307 FROM civicrm_contribution {$this->_aliases['civicrm_contribution']}
308 INNER JOIN $this->contactTempTable restricted_contacts
309 ON restricted_contacts.cid = {$this->_aliases['civicrm_contribution']}.contact_id
310 AND {$this->_aliases['civicrm_contribution']}.is_test = 0
311 INNER JOIN civicrm_contact {$this->_aliases['civicrm_contact']}
312 ON restricted_contacts.cid = {$this->_aliases['civicrm_contact']}.id";
314 $this->joinAddressFromContact();
315 $this->joinPhoneFromContact();
316 $this->joinEmailFromContact();
319 $this->setFromBase('civicrm_contact');
321 $this->_from
.= " INNER JOIN civicrm_contribution {$this->_aliases['civicrm_contribution']} ";
322 if (!$this->groupTempTable
) {
323 // The received_date index is better than the contribution_status_id index (fairly substantially).
324 // But if we have already pre-filtered down to a group of contacts then we want that to be the
325 // primary filter and the index hint will block that.
326 $this->_from
.= "USE index (received_date)";
328 $this->_from
.= " ON {$this->_aliases['civicrm_contribution']}.contact_id = {$this->_aliases['civicrm_contact']}.id
329 AND {$this->_aliases['civicrm_contribution']}.is_test = 0
330 AND " . $this->whereClauseLastYear("{$this->_aliases['civicrm_contribution']}.receive_date") . "
332 $this->selectivelyAddLocationTablesJoinsToFilterQuery();
335 // for credit card type
336 $this->addFinancialTrxnFromClause();
340 * Generate where clause.
342 * We are overriding this primarily for 'before-after' handling of the receive_date placeholder field.
344 * We call this twice. The first time we are generating a temp table and we want to do an IS NULL on the
345 * join that draws in contributions from this year. The second time we are filtering elsewhere (contacts via
346 * the temp table & contributions via selective addition of contributions in the select function).
348 * If lifetime total is NOT selected we can add a further filter here to possibly improve performance
349 * but the benefit if unproven as yet.
350 * $clause = $this->whereClauseLastYear("{$this->_aliases['civicrm_contribution']}.receive_date");
352 * @param array $field Field specifications
353 * @param string $op Query operator (not an exact match to sql)
354 * @param mixed $value
358 * @return null|string
360 public function whereClause(&$field, $op, $value, $min, $max) {
361 if ($field['name'] == 'receive_date') {
363 if (empty($this->contactTempTable
)) {
364 $clause = "{$this->_aliases['civicrm_contact']}.id NOT IN (
365 SELECT cont_exclude.contact_id
366 FROM civicrm_contribution cont_exclude
367 WHERE " . $this->whereClauseThisYear('cont_exclude.receive_date')
371 // Group filtering is already done so skip.
372 elseif (!empty($field['group']) && $this->contactTempTable
) {
376 $clause = parent
::whereClause($field, $op, $value, $min, $max);
382 * Generate where clause for last calendar year or fiscal year.
384 * @todo must be possible to re-use relative dates stuff.
386 * @param string $fieldName
390 public function whereClauseLastYear($fieldName) {
391 return "$fieldName BETWEEN '" . $this->getFirstDateOfPriorRange() . "' AND '" . $this->getLastDateOfPriorRange() . "'";
395 * Generate where clause for last calendar year or fiscal year.
397 * @todo must be possible to re-use relative dates stuff.
399 * @param string $fieldName
401 * @param int $current_year
402 * @return null|string
404 public function whereClauseThisYear($fieldName, $current_year = NULL) {
405 return "$fieldName BETWEEN '" . $this->getFirstDateOfCurrentRange() . "' AND '" . $this->getLastDateOfCurrentRange() . "'";
409 * Get the year value for the current year.
413 public function getCurrentYear() {
414 return $this->_params
['yid_value'];
418 * Get the date time of the first date in the 'this year' range.
422 public function getFirstDateOfCurrentRange() {
423 $current_year = $this->getCurrentYear();
424 if ($this->getYearFilterType() == 'calendar') {
425 return "{$current_year }-01-01";
428 $fiscalYear = CRM_Core_Config
::singleton()->fiscalYearStart
;
429 return "{$current_year}-{$fiscalYear['M']}-{$fiscalYear['d']}";
434 * Get the year value for the current year.
438 public function getYearFilterType() {
439 return CRM_Utils_Array
::value('yid_op', $this->_params
, 'calendar');
443 * Get the date time of the last date in the 'this year' range.
447 public function getLastDateOfCurrentRange() {
448 return date('YmdHis', strtotime('+ 1 year - 1 second', strtotime($this->getFirstDateOfCurrentRange())));
452 * Get the date time of the first date in the 'last year' range.
456 public function getFirstDateOfPriorRange() {
457 return date('YmdHis', strtotime('- 1 year', strtotime($this->getFirstDateOfCurrentRange())));
461 * Get the date time of the last date in the 'last year' range.
465 public function getLastDateOfPriorRange() {
466 return date('YmdHis', strtotime('+ 1 year - 1 second', strtotime($this->getFirstDateOfPriorRange())));
469 public function groupBy() {
470 $this->_groupBy
= "GROUP BY {$this->_aliases['civicrm_contribution']}.contact_id ";
471 $this->_select
= CRM_Contact_BAO_Query
::appendAnyValueToSelect($this->_selectClauses
, "{$this->_aliases['civicrm_contribution']}.contact_id");
472 $this->assign('chartSupported', TRUE);
480 public function statistics(&$rows) {
482 $statistics = parent
::statistics($rows);
483 // The parent class does something odd where it adds an extra row to the count for the grand total.
484 // Perhaps that works on some other report? But here it just seems odd.
485 $this->countStat($statistics, count($rows));
487 if (!empty($this->rollupRow
) && !empty($this->rollupRow
['civicrm_contribution_last_year_total_amount'])) {
488 $statistics['counts']['civicrm_contribution_last_year_total_amount'] = [
489 'value' => $this->rollupRow
['civicrm_contribution_last_year_total_amount'],
490 'title' => $this->getLastYearColumnTitle(),
491 'type' => CRM_Utils_Type
::T_MONEY
,
495 if (!empty($this->rollupRow
) && !empty($this->rollupRow
['civicrm_contribution_civicrm_life_time_total'])) {
496 $statistics['counts']['civicrm_contribution_civicrm_life_time_total'] = [
497 'value' => $this->rollupRow
['civicrm_contribution_civicrm_life_time_total'],
498 'title' => ts('Total LifeTime'),
499 'type' => CRM_Utils_Type
::T_MONEY
,
503 $select = "SELECT SUM({$this->_aliases['civicrm_contribution']}.total_amount) as amount,
504 SUM(IF( " . $this->whereClauseLastYear('contribution_civireport.receive_date') . ", contribution_civireport.total_amount, 0)) as last_year
506 $sql = "{$select} {$this->_from} {$this->_where}";
507 $dao = CRM_Core_DAO
::executeQuery($sql);
509 $statistics['counts']['amount'] = [
510 'value' => $dao->amount
,
511 'title' => ts('Total LifeTime'),
512 'type' => CRM_Utils_Type
::T_MONEY
,
514 $statistics['counts']['last_year'] = [
515 'value' => $dao->last_year
,
516 'title' => $this->getLastYearColumnTitle(),
517 'type' => CRM_Utils_Type
::T_MONEY
,
527 * This function is called by both the api (tests) and the UI.
529 public function beginPostProcessCommon() {
531 // @todo this acl has no test coverage and is very hard to test manually so could be fragile.
532 $this->resetFormSqlAndWhereHavingClauses();
534 $this->contactTempTable
= $this->createTemporaryTable('rptlybunt', "
535 SELECT SQL_CALC_FOUND_ROWS {$this->_aliases['civicrm_contact']}.id as cid {$this->_from}
537 GROUP BY {$this->_aliases['civicrm_contact']}.id"
540 if (empty($this->_params
['charts'])) {
544 // Reset where clauses to be regenerated in postProcess.
545 $this->_whereClauses
= [];
549 * Build the report query.
551 * The issue we are hitting is that if we want to do group by & then ORDER BY we have to
552 * wrap the query in an outer query with the order by - otherwise the group by takes precedent.
553 * This is an issue when we want to group by contact but order by the maximum aggregate donation.
555 * @param bool $applyLimit
559 public function buildQuery($applyLimit = TRUE) {
560 $this->buildGroupTempTable();
561 $this->buildPermissionClause();
562 // Calling where & select before FROM allows us to build temp tables to use in from.
566 $this->customDataFrom(empty($this->contactTempTable
));
572 // order_by columns not selected for display need to be included in SELECT
573 // This differs from parent in that we are getting those not in order by rather than not in
574 // sections, as we need to adapt to our contact group by.
575 $unselectedSectionColumns = array_diff_key($this->_orderByFields
, $this->getSelectColumns());
576 foreach ($unselectedSectionColumns as $alias => $section) {
577 $this->_select
.= ", {$section['dbAlias']} as {$alias}";
580 if ($applyLimit && empty($this->_params
['charts'])) {
584 $sql = "{$this->_select} {$this->_from} {$this->_where} {$limitFilter} {$this->_groupBy} {$this->_having} {$this->_rollup}";
586 if (!empty($this->_orderByArray
)) {
587 $this->_orderBy
= str_replace('contact_civireport.', 'civicrm_contact_', "ORDER BY ISNULL(civicrm_contribution_contact_id), " . implode(', ', $this->_orderByArray
));
588 $this->_orderBy
= str_replace('contribution_civireport.', 'civicrm_contribution_', $this->_orderBy
);
589 foreach ($this->_orderByFields
as $field) {
590 $this->_orderBy
= str_replace($field['dbAlias'], $field['tplField'], $this->_orderBy
);
592 $sql = str_replace('SQL_CALC_FOUND_ROWS', '', $sql);
593 $sql = "SELECT SQL_CALC_FOUND_ROWS * FROM ( $sql ) as inner_query {$this->_orderBy} $this->_limit";
596 CRM_Utils_Hook
::alterReportVar('sql', $this, $this);
597 $this->addToDeveloperTab($sql);
603 * Reset the form sql and where / having clause arrays.
605 * We do an early iteration of the report queries to generate the temp table.
607 * However, that iteration populates the sql for the developer tab,
608 * the whereClauses & the havingClauses and they are populated again in the normal
609 * report flow. This is harmless but confusing - ie. the where clause winds up repeating
610 * the same filters and the dev tab shows the query twice, so we rest them.
612 protected function resetFormSqlAndWhereHavingClauses() {
614 $this->_havingClauses
= [];
615 $this->_whereClauses
= [];
616 $this->sqlArray
= [];
622 public function buildChart(&$rows) {
628 $current_year = $this->_params
['yid_value'];
629 $previous_year = $current_year - 1;
630 $interval[$previous_year] = $previous_year;
631 $interval['life_time'] = 'Life Time';
633 foreach ($rows as $key => $row) {
634 // The final row contains the totals so we don't need to include it here.
635 if (!empty($row['civicrm_contribution_contact_id'])) {
636 $display['life_time'] = CRM_Utils_Array
::value('life_time', $display) +
637 $row['civicrm_contribution_civicrm_life_time_total'];
638 $display[$previous_year] = CRM_Utils_Array
::value($previous_year, $display) +
$row['civicrm_contribution_last_year_total_amount'];
642 $config = CRM_Core_Config
::Singleton();
643 $graphRows['value'] = $display;
645 'legend' => ts('Lybunt Report'),
646 'xname' => ts('Year'),
647 'yname' => ts('Amount (%1)', [1 => $config->defaultCurrency
]),
649 if ($this->_params
['charts']) {
651 CRM_Utils_Chart
::reportChart($graphRows, $this->_params
['charts'], $interval, $chartInfo);
652 $this->assign('chartType', $this->_params
['charts']);
657 * Alter display of rows.
659 * Iterate through the rows retrieved via SQL and make changes for display purposes,
660 * such as rendering contacts as links.
663 * Rows generated by SQL, with an array for each row.
665 public function alterDisplay(&$rows) {
668 foreach ($rows as $rowNum => $row) {
669 //Convert Display name into link
670 if (array_key_exists('civicrm_contact_sort_name', $row) &&
671 array_key_exists('civicrm_contribution_contact_id', $row)
673 $url = CRM_Report_Utils_Report
::getNextUrl('contribute/detail',
674 'reset=1&force=1&id_op=eq&id_value=' .
675 $row['civicrm_contribution_contact_id'],
676 $this->_absoluteUrl
, $this->_id
, $this->_drilldownReport
678 $rows[$rowNum]['civicrm_contact_sort_name_link'] = $url;
679 $rows[$rowNum]['civicrm_contact_sort_name_hover'] = ts("View Contribution Details for this Contact.");
683 // convert campaign_id to campaign title
684 if (array_key_exists('civicrm_contribution_campaign_id', $row)) {
685 if ($value = $row['civicrm_contribution_campaign_id']) {
686 $rows[$rowNum]['civicrm_contribution_campaign_id'] = $this->campaigns
[$value];
690 // Display 'Yes' if the email is on hold (leave blank for no so it stands out better).
691 if (array_key_exists('civicrm_email_on_hold', $row)) {
692 $rows[$rowNum]['civicrm_email_on_hold'] = $row['civicrm_email_on_hold'] ?
ts('Yes') : '';
696 $entryFound = $this->alterDisplayAddressFields($row, $rows, $rowNum, NULL, 'List all contribution(s)') ?
TRUE : $entryFound;
697 $entryFound = $this->alterDisplayContactFields($row, $rows, $rowNum, NULL, 'List all contribution(s)') ?
TRUE : $entryFound;
699 if (!empty($row['civicrm_financial_trxn_card_type_id'])) {
700 $rows[$rowNum]['civicrm_financial_trxn_card_type_id'] = $this->getLabels($row['civicrm_financial_trxn_card_type_id'], 'CRM_Financial_DAO_FinancialTrxn', 'card_type_id');
704 // skip looking further in rows, if first row itself doesn't
705 // have the column we need
713 * Override "This Year" $op options
714 * @param string $type
715 * @param null $fieldName
719 public function getOperationPair($type = "string", $fieldName = NULL) {
720 if ($fieldName == 'yid') {
722 'calendar' => ts('Is Calendar Year'),
723 'fiscal' => ts('Fiscal Year Starting'),
726 return parent
::getOperationPair($type, $fieldName);