Analytics & the reporting pipeline
The analytics dashboards do not query the CRM live. A scheduled ETL pulls lead / meeting / sale
records out of the CRM (through crms-middleware) once a day and writes them into local
analytics-mirror tables — analytics_broker_* and analytics_salesman_* — keyed to a shared
date-dimension table, dates_dm. The broker, sales-man and sales-manager dashboards then read those
mirror tables at request time. The shopper-admin surface is the exception: it reads the shopper
platform’s own operational tables plus live Google Analytics (GA4), never the CRM mirror.
The four surfaces at a glance
Section titled “The four surfaces at a glance”| Surface | Endpoint prefix | Reads from | Query layer |
|---|---|---|---|
| Broker | api/broker/analytics/* (routes/api/broker.php:161-185) |
AnalyticsBrokerLead / AnalyticsBrokerMeeting / AnalyticsBrokerSale + local SalesInvoice (commissions) |
app/Actions/Analytics/{Lead,Sale,Filters}/* |
| Sales-Manager | api/sales-manager/analytics/* (routes/api/sales-manager.php:22-46) |
The same broker mirror — AnalyticsBrokerSale etc. (rollup / rankings view) |
the shared app/Actions/Analytics/Sale/* actions |
| Sales-Man | api/sales-man/analytics/* (routes/api/sales-man.php:230-267) |
AnalyticsSalesManLead / AnalyticsSalesManMeeting / AnalyticsSalesManSale / AnalyticsSalesManSaleSplit |
app/Actions/SalesMan/Analytics/{Sales,Lead,Meetings,Filters}/* |
| Shopper-Admin | api/shopper-admin/analytics/* (routes/api/shopper-admin.php:16-126) |
No CRM mirror — local UnitSale / UserRequest / Shopper / SearchableLog plus live GA4 for the ggl-* endpoints |
app/Actions/Analytics/{UnitSale,UserRequest,User,Unit,GoogleAnalytics}/* |
All prefixes are mounted in app/Providers/RouteServiceProvider.php:142-165 (api/broker, api/sales-man,
api/sales-manager, api/shopper-admin), each behind user-provider:<persona> + auth:sanctum.
The whole pipeline, in one diagram
Section titled “The whole pipeline, in one diagram”sequenceDiagram participant SCH as Scheduler (Kernel) participant Q as Queue worker participant ETL as ETL actions (Extract / Transform / Load) participant MW as crms-middleware to CRM participant DB as Portal DB (analytics_* mirror + dates_dm) participant GA as Google Analytics (GA4) actor CRMD as Broker / Sales-Man / Sales-Manager dashboards actor SA as Shopper-Admin dashboard Note over SCH,DB: monthly app:seed-dates-dimension pre-seeds dates_dm calendar rows SCH->>Q: daily dispatch broker-leads / broker-meetings / salesman-* sync Q->>ETL: run the matching Sync*Crm handle ETL->>MW: GET /api/portals/leads/sync with OData filter on createdon or modifiedon MW-->>ETL: raw CRM rows (a broker lead carries a nested sales array) ETL->>DB: resolve dates via dates_dm plus broker / unit / currency by source_id ETL->>DB: updateOrCreate keyed by (source_id, country_id) so replays are idempotent Note over DB: analytics_broker_* and analytics_salesman_* now populated CRMD->>DB: read the mirror tables at request time (never the live CRM) SA->>DB: read local unit_sale / user_requests / searchable_logs SA->>GA: ggl-* endpoints fetch live GA4 page views on every request
The date dimension: dates_dm
Section titled “The date dimension: dates_dm”Every analytics-mirror table stores its dates as integer foreign keys to dates_dm — columns like
submitted_at_dm, verified_at_dm, reserved_at_dm, canceled_at_dm, closed_at_dm, meeting_at_dm.
DateDM (app/Models/DateDM.php:10-14, table dates_dm, no timestamps, non-incrementing) is a classic
date-dimension: one row per calendar day with id in Ymd form (e.g. 20260701), plus date,
year, month, day, month_name.
It is filled ahead of time by the monthly command app:seed-dates-dimension
(app/Console/Commands/analytics/SeedDatesDimension.php:29-75), which runs a recursive-CTE
INSERT into dates_dm. On an empty table it seeds 2018-01-01 → end of the current year; on
subsequent runs it appends the next ~30 days after the last row. The mirror models point at it via
belongsTo(DateDM::class, '<column>_dm') (e.g. AnalyticsBrokerSale.php:40-58), and date-range filters
run against these integer *_at_dm columns (e.g. AnalyticsBrokerLead::whereBetween($attrDate, [from, to]),
app/Actions/Analytics/Lead/Count.php:23-25).
The sync mechanism (Extract → Transform → Load)
Section titled “The sync mechanism (Extract → Transform → Load)”-
The scheduler fires the sync commands daily.
app/Console/Kernel.php:28-35schedulesapp:seed-dates-dimensionmonthly, thenbroker-leads:sync,broker-meetings:sync,salesman-leads:sync,salesman-meetings:sync,salesman-sales:syncandsalesman-sales:sync-splitdaily. (Two commands exist but are not scheduled — see Gotchas.) -
Each command decides backfill vs incremental, then dispatches queued jobs. Take
broker-leads:sync(app/Console/Commands/analytics/Broker/SyncLeads.php:33-69): it loops the target countries (Country::whereIn('slug', CountryEnums::values()); the sales-man commands are Egypt-only, e.g.SalesMan/SyncLeads.php:36). For each country it checks the mirror —AnalyticsBrokerLead::where('country_id', ...)->orderByDesc('created_at')->first(). If no row exists, it dispatches a first-time backfill: one job per half-month across a hard-coded year list (2019–2023for broker leads,2023–2025for broker meetings,2024–2025for sales-man). If a row does exist, it dispatches a single incremental job from that row’screated_atonward with an empty end date (:63). -
The queued job hands off to the ETL action.
SyncLeadsCrmJob(app/Jobs/Broker/SyncLeadsCrmJob.php:43-54,implements ShouldQueue) just callsSyncLeadsCrm::handle($country, $start, $end). Sales-man has a parallel job/action tree underapp/Actions/SalesMan/Analytics/SyncData/{Leads,Meetings,Sales,SalesSplit,SystemUsers}/. -
Extract — pull raw rows from the CRM via the middleware.
ExtractLeadsCrm.handle(app/Actions/Analytics/SyncData/ExtractLeadsCrm.php:15-51) does aGET{CRMS_MIDDLEWARE_BASE_URL}/api/portals/leads/syncthroughCrmMiddlewareClient(OAuth token, cached;authenticate()hitsservices.crms-middleware.auth_url). The country is passed as acountryheader and the window as an OData-style filter:createdon ge <start>T00:00:00Z, plusor modifiedon ge <start>...when the end is empty (so an incremental run also catches updated rows), elseand createdon le <end>T23:59:59Z. -
Transform — map raw CRM JSON into mirror rows, and fan leads out into sales.
TransformLeadsCrm.handle(app/Actions/Analytics/SyncData/TransformLeadsCrm.php:16-135) builds oneanalytics_broker_leadsrow per lead and explodes each lead’s nestedsales[]array intoanalytics_broker_salesrows (transformSales,:63-116). It converts every date to anYmdint (Util::formatDate), resolves thebroker_idby country + CRM source id, resolvesunit/unit_type/destinationfrom local tables bysource_id, resolves currency, and computesis_internationalfrom the buyer’s country code. Per-rowtry/catch— a single bad lead or sale just increments a failed counter and is logged. -
Load — idempotent upsert into the mirror.
LoadLeadsCrm.handle(app/Actions/Analytics/SyncData/LoadLeadsCrm.php:12-35) callsAnalyticsBrokerLead::updateOrCreate(['source_id' => ..., 'country_id' => ...], $row), andLoadSalesCrmdoes the same for sales. The(source_id, country_id)key makes the whole pipeline safe to replay — re-running a window overwrites, never duplicates. Each loader also wraps every row intry/catchand returns afailedCountsoSyncLeadsCrm::handleResponse(SyncData/SyncLeadsCrm.php:53-97) can log “synced N successfully” per batch.
How each surface queries the data
Section titled “How each surface queries the data”-
Broker — controllers under
app/Http/Controllers/Api/Broker/Analytics/delegate to single-purpose actions that query the broker mirror directly: leads viaApp\Actions\Analytics\Lead\Count→AnalyticsBrokerLead::whereBetween(...)(Lead/Count.php:23), sales viaApp\Actions\Analytics\Sale\*(CountPerDestination,BrokersRankingData,GetInternationalSalesStats,CountDailyPerUnitType, …) →AnalyticsBrokerSale. Commissions read the separate localSalesInvoicetable (joined toAnalyticsBrokerSale). -
Sales-Manager — controllers under
.../SalesManager/Analytics/reuse the sameApp\Actions\Analytics\Sale\*actions (BrokeragesRanking,SalesManagersRanking,BrokersRankingData,GetInternationalSalesStats,CountPerDestination) plusSalesInvoicesControllerfor broker-commission rollups. SameAnalyticsBrokerSalemirror, a manager-level lens. -
Sales-Man — controllers under
.../SalesMan/Analytics/delegate toapp/Actions/SalesMan/Analytics/{Sales,Lead,Meetings,Filters}/*, which query the sales-man mirror:AnalyticsSalesManSale(overview, deals-vs-closed, sale-source / pipeline / category breakdowns),AnalyticsSalesManSaleSplit(agent ranking via theanalytics_salesman_sale_splitspivot,AnalyticsSalesManSale.php:36-44),AnalyticsSalesManLeadandAnalyticsSalesManMeeting. -
Shopper-Admin — does not touch the CRM mirror. Its controllers under
.../ShopperAdmin/split two ways:- Local operational tables —
UnitSale(the portal’s own closed sales, e.g.App\Actions\Analytics\UnitSale\CountSoldUnitsreadsunit_sale.closed_at_dm,UnitSale/CountSoldUnits.php:14),UserRequest(leads / service requests,App\Actions\Analytics\UserRequest\CountRequests),Shopper(user counts), andSearchableLog/UnitsFilterLog(search behaviour). - Live GA4 — every
ggl-*/ demographics / sessions endpoint callsApp\Actions\Analytics\GoogleAnalytics\*, which useSpatie\Analytics\Facades\Analytics(FetchDestinationPageViews.php:23→Analytics::fetchVisitorsAndPageViews(...)) to hit the live GA4 Data API configured inconfig/analytics.php(property_id+ service account). There is no local mirror for these — they are fetched on every request.
- Local operational tables —
Gotchas
Section titled “Gotchas”-
Numbers are only as fresh as the last sync run. All three CRM-backed surfaces read the mirror, and the mirror is refreshed daily by the scheduled commands. If the scheduler or the queue worker isn’t running, the commands dispatch nothing / the jobs never execute and every broker / sales-man / sales-manager dashboard silently shows stale data with no error. Check both the cron (
schedule:run) andqueue:work. -
Sales-man analytics is Egypt-only by construction.
salesman-leads:sync,salesman-sales:sync,salesman-sales:sync-splitall resolve countries viaCountry::whereIn('slug', [CountryEnums::EGYPT->value])(SalesMan/SyncLeads.php:36). Broker sync spans allCountryEnums::values(). A non-Egypt sales-man dashboard will be empty because nothing is ever synced for it. -
Two sync commands exist but are NOT scheduled.
broker-leads:sync-all(Broker/SyncAllLeads.php:18, a full multi-year backfill) andsalesman-systemusers:sync(SalesMan/SyncSystemUsers.php:19) are not wired intoKernel::schedule()— they only run when invoked manually. Don’t assume system-users or the wide backfill self-heal on the daily cron. -
First-time backfill is bounded by hard-coded year lists. The “no mirror row yet” branch iterates literal year arrays (
2019–2023for broker leads,2023–2025for broker meetings,2024–2025for sales-man,SyncLeads.php:38). Records outside those windows are never backfilled by the daily command; a genuinely full historical load needsbroker-leads:sync-all. -
The broker “leads” endpoint feeds two mirrors at once. One CRM extract populates both
analytics_broker_leadsandanalytics_broker_sales, because each lead carries a nestedsales[](TransformLeadsCrm.php:63-116). A lead that fails to transform takes its sales with it (they share the sametry/catch), so a spike in “failed leads” can also silently depress sales counts. -
Load failures are swallowed per row. Both loaders catch
TypeError | Exceptionper record, log, and continue (LoadLeadsCrm.php:27-31). This keeps a bad row from aborting the batch — but it also means a systematically broken mapping (e.g. an unresolvedbroker_id) shows up only as a risingfailedCountin the logs, never as a hard failure. Greplaravel.logforFailed to load lead with source_id/Failed to transformwhen a surface looks under-counted. -
Shopper-admin is a genuinely different data source. It is the one surface that never reads the CRM analytics mirror: local
unit_sale/user_requests/searchable_logsfor the transactional cards, and live GA4 for theggl-*cards. Don’t debug a shopper-admin discrepancy against the sync commands — they don’t touch it.
