@@ -1665,6 +1665,189 @@ public static function getMembershipRenewals($membershipTypeId, $startDate, $end
16651665 return (int ) $ memberCount ;
16661666 }
16671667
1668+ /**
1669+ * Get membership dashboard summary counts for many membership types in two queries.
1670+ *
1671+ * Replaces a per-type/per-window loop in CRM_Member_Page_DashBoard (~16 calls per
1672+ * membership type) with one grouped query over the signup/renewal activity join and
1673+ * one over current memberships. Clause differences between the originals (activity-type
1674+ * guard, deleted-contact exclusion) are preserved across the two queries.
1675+ *
1676+ * Result families:
1677+ * *_new -> getMembershipJoins (signup activity in window)
1678+ * *_renew -> getMembershipRenewals (renewal activity in window)
1679+ * *_total -> getMembershipStarts (either activity)
1680+ * *_owner -> getMembershipStarts, owner_membership_id IS NULL
1681+ * current_total / total_total -> getMembershipCount
1682+ * current_owner / total_owner -> getMembershipCount, owner_membership_id IS NULL
1683+ *
1684+ * @param array $membershipTypeIds
1685+ * @param string $preMonth Previous month start, Y-m-d.
1686+ * @param string $preMonthEnd Previous month end, Y-m-d.
1687+ * @param string $monthStart Reported month start, Y-m-d.
1688+ * @param string $yearStart Reported year start, Y-m-d.
1689+ * @param string $ymd End date for month/year/total windows, Y-m-d.
1690+ * @param string $current "As of" date for current_total/current_owner, Y-m-d.
1691+ * @param bool|int $isTest
1692+ *
1693+ * @return array
1694+ * [membershipTypeId => [family => int]], zero-filled for every requested type.
1695+ *
1696+ * @throws \CRM_Core_Exception
1697+ */
1698+ public static function getMembershipSummaryStats ($ membershipTypeIds , $ preMonth , $ preMonthEnd , $ monthStart , $ yearStart , $ ymd , $ current , $ isTest = 0 ) {
1699+ foreach ([$ preMonth , $ preMonthEnd , $ monthStart , $ yearStart , $ ymd , $ current ] as $ date ) {
1700+ if (!CRM_Utils_Rule::date ($ date )) {
1701+ throw new CRM_Core_Exception (ts ('Invalid date "%1" (must have form yyyy-mm-dd). ' , [1 => $ date ]));
1702+ }
1703+ }
1704+
1705+ $ activityFamilies = [
1706+ 'premonth_new ' , 'premonth_renew ' , 'premonth_total ' ,
1707+ 'month_new ' , 'month_renew ' , 'month_total ' ,
1708+ 'year_new ' , 'year_renew ' , 'year_total ' ,
1709+ 'premonth_owner ' , 'month_owner ' , 'year_owner ' ,
1710+ ];
1711+ $ countFamilies = ['current_total ' , 'total_total ' , 'current_owner ' , 'total_owner ' ];
1712+
1713+ // Pre-fill so callers can read every family without isset() guards; a GROUP BY
1714+ // query would otherwise omit types that have no matching rows.
1715+ $ stats = [];
1716+ $ typeIds = [];
1717+ foreach ($ membershipTypeIds as $ typeId ) {
1718+ $ typeId = (int ) $ typeId ;
1719+ $ typeIds [] = $ typeId ;
1720+ $ stats [$ typeId ] = array_fill_keys (array_merge ($ activityFamilies , $ countFamilies ), 0 );
1721+ }
1722+ if (empty ($ typeIds )) {
1723+ return $ stats ;
1724+ }
1725+ $ typeIdList = implode (', ' , $ typeIds );
1726+ $ isTest = $ isTest ? 1 : 0 ;
1727+
1728+ // Resolve activity type IDs once. Tests pre-set the statics to simulate a site
1729+ // where signup/renewal activity types are not configured; the legacy "refresh if
1730+ // either is falsy" check would overwrite that. Both NULL is the production
1731+ // initial state, where _getActTypes() populates both.
1732+ if (self ::$ _signupActType === NULL && self ::$ _renewalActType === NULL ) {
1733+ self ::_getActTypes ();
1734+ }
1735+ $ signupActType = self ::$ _signupActType ;
1736+ $ renewalActType = self ::$ _renewalActType ;
1737+ $ hasSignup = !empty ($ signupActType );
1738+ $ hasRenewal = !empty ($ renewalActType );
1739+
1740+ // Query 1: activity-based families. The scan bound on activity_date_time prunes
1741+ // the activity table to the window union before the per-CASE filters run.
1742+ if ($ hasSignup || $ hasRenewal ) {
1743+ $ availableActTypes = [];
1744+ if ($ hasSignup ) {
1745+ $ availableActTypes [] = (int ) $ signupActType ;
1746+ }
1747+ if ($ hasRenewal ) {
1748+ $ availableActTypes [] = (int ) $ renewalActType ;
1749+ }
1750+ $ actTypeList = implode (', ' , $ availableActTypes );
1751+
1752+ $ scanStart = min ($ preMonth , $ monthStart , $ yearStart );
1753+ $ scanEnd = max ($ preMonthEnd , $ ymd ) . ' 23:59:59 ' ;
1754+
1755+ $ preWindow = 'activity.activity_date_time >= %1 AND activity.activity_date_time <= %2 ' ;
1756+ $ monthWindow = 'activity.activity_date_time >= %3 AND activity.activity_date_time <= %4 ' ;
1757+ $ yearWindow = 'activity.activity_date_time >= %5 AND activity.activity_date_time <= %4 ' ;
1758+ $ isSignup = 'activity.activity_type_id = %6 ' ;
1759+ $ isRenewal = 'activity.activity_type_id = %7 ' ;
1760+ $ isOwner = 'membership.owner_membership_id IS NULL ' ;
1761+
1762+ $ activityQuery = "
1763+ SELECT membership.membership_type_id AS membership_type_id,
1764+ COUNT(DISTINCT CASE WHEN $ isSignup AND $ preWindow THEN membership.id END) AS premonth_new,
1765+ COUNT(DISTINCT CASE WHEN $ isRenewal AND $ preWindow THEN membership.id END) AS premonth_renew,
1766+ COUNT(DISTINCT CASE WHEN $ preWindow THEN membership.id END) AS premonth_total,
1767+ COUNT(DISTINCT CASE WHEN $ isSignup AND $ monthWindow THEN membership.id END) AS month_new,
1768+ COUNT(DISTINCT CASE WHEN $ isRenewal AND $ monthWindow THEN membership.id END) AS month_renew,
1769+ COUNT(DISTINCT CASE WHEN $ monthWindow THEN membership.id END) AS month_total,
1770+ COUNT(DISTINCT CASE WHEN $ isSignup AND $ yearWindow THEN membership.id END) AS year_new,
1771+ COUNT(DISTINCT CASE WHEN $ isRenewal AND $ yearWindow THEN membership.id END) AS year_renew,
1772+ COUNT(DISTINCT CASE WHEN $ yearWindow THEN membership.id END) AS year_total,
1773+ COUNT(DISTINCT CASE WHEN $ isOwner AND $ preWindow THEN membership.id END) AS premonth_owner,
1774+ COUNT(DISTINCT CASE WHEN $ isOwner AND $ monthWindow THEN membership.id END) AS month_owner,
1775+ COUNT(DISTINCT CASE WHEN $ isOwner AND $ yearWindow THEN membership.id END) AS year_owner
1776+ FROM civicrm_membership membership
1777+ INNER JOIN civicrm_activity activity ON (activity.source_record_id = membership.id AND activity.activity_type_id IN ( $ actTypeList))
1778+ INNER JOIN civicrm_membership_status status ON (membership.status_id = status.id AND status.is_current_member = 1)
1779+ INNER JOIN civicrm_contact contact ON (contact.id = membership.contact_id AND contact.is_deleted = 0)
1780+ WHERE membership.membership_type_id IN ( $ typeIdList)
1781+ AND membership.is_test = $ isTest
1782+ AND activity.activity_date_time >= %8 AND activity.activity_date_time <= %9
1783+ GROUP BY membership.membership_type_id
1784+ " ;
1785+ $ params = [
1786+ 1 => [$ preMonth , 'String ' ],
1787+ 2 => [$ preMonthEnd . ' 23:59:59 ' , 'String ' ],
1788+ 3 => [$ monthStart , 'String ' ],
1789+ 4 => [$ ymd . ' 23:59:59 ' , 'String ' ],
1790+ 5 => [$ yearStart , 'String ' ],
1791+ 6 => [(int ) $ signupActType , 'Integer ' ],
1792+ 7 => [(int ) $ renewalActType , 'Integer ' ],
1793+ 8 => [$ scanStart , 'String ' ],
1794+ 9 => [$ scanEnd , 'String ' ],
1795+ ];
1796+ foreach (CRM_Core_DAO::executeQuery ($ activityQuery , $ params )->fetchAll () as $ row ) {
1797+ $ typeId = (int ) $ row ['membership_type_id ' ];
1798+ foreach ($ activityFamilies as $ family ) {
1799+ $ stats [$ typeId ][$ family ] = (int ) $ row [$ family ];
1800+ }
1801+ }
1802+
1803+ // Per-function activity-type guard: joins needs signup, renewals needs renewal,
1804+ // starts (the *_total and *_owner families) needs both. Zero out the families the
1805+ // originals would return 0 for when a required type is missing.
1806+ if (!$ hasSignup || !$ hasRenewal ) {
1807+ foreach ($ stats as &$ row ) {
1808+ if (!$ hasSignup ) {
1809+ $ row ['premonth_new ' ] = $ row ['month_new ' ] = $ row ['year_new ' ] = 0 ;
1810+ }
1811+ if (!$ hasRenewal ) {
1812+ $ row ['premonth_renew ' ] = $ row ['month_renew ' ] = $ row ['year_renew ' ] = 0 ;
1813+ }
1814+ $ row ['premonth_total ' ] = $ row ['month_total ' ] = $ row ['year_total ' ] = 0 ;
1815+ $ row ['premonth_owner ' ] = $ row ['month_owner ' ] = $ row ['year_owner ' ] = 0 ;
1816+ }
1817+ unset($ row );
1818+ }
1819+ }
1820+
1821+ // Query 2: current-membership families. Deleted-contact exclusion uses a NOT IN
1822+ // subquery (not an inner join) to match getMembershipCount exactly.
1823+ $ countQuery = "
1824+ SELECT civicrm_membership.membership_type_id AS membership_type_id,
1825+ COUNT(CASE WHEN civicrm_membership.start_date <= %1 THEN civicrm_membership.id END) AS current_total,
1826+ COUNT(CASE WHEN civicrm_membership.start_date <= %2 THEN civicrm_membership.id END) AS total_total,
1827+ COUNT(CASE WHEN civicrm_membership.owner_membership_id IS NULL AND civicrm_membership.start_date <= %1 THEN civicrm_membership.id END) AS current_owner,
1828+ COUNT(CASE WHEN civicrm_membership.owner_membership_id IS NULL AND civicrm_membership.start_date <= %2 THEN civicrm_membership.id END) AS total_owner
1829+ FROM civicrm_membership
1830+ LEFT JOIN civicrm_membership_status ON (civicrm_membership.status_id = civicrm_membership_status.id)
1831+ WHERE civicrm_membership.membership_type_id IN ( $ typeIdList)
1832+ AND civicrm_membership.contact_id NOT IN (SELECT id FROM civicrm_contact WHERE is_deleted = 1)
1833+ AND civicrm_membership.is_test = $ isTest
1834+ AND civicrm_membership_status.is_current_member = 1
1835+ GROUP BY civicrm_membership.membership_type_id
1836+ " ;
1837+ $ countParams = [
1838+ 1 => [$ current , 'String ' ],
1839+ 2 => [$ ymd , 'String ' ],
1840+ ];
1841+ foreach (CRM_Core_DAO::executeQuery ($ countQuery , $ countParams )->fetchAll () as $ row ) {
1842+ $ typeId = (int ) $ row ['membership_type_id ' ];
1843+ foreach ($ countFamilies as $ family ) {
1844+ $ stats [$ typeId ][$ family ] = (int ) $ row [$ family ];
1845+ }
1846+ }
1847+
1848+ return $ stats ;
1849+ }
1850+
16681851 /**
16691852 * Get line items representing the default price set.
16701853 *
0 commit comments