ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Timeline d'activité unifiée Salesforce

Combiner les Tasks et Events Salesforce en une seule timeline d'activité avec un nommage de colonnes cohérent et une résolution d'entités polymorphiques.

Planté
dbtbigquerydata modelingdata engineeringanalytics

Salesforce stocke les activités sur deux objets : Tasks (appels, emails, tâches à faire) et Events (réunions, rendez-vous). Une timeline d’activité unifiée combine les deux en une seule vue chronologique en utilisant un pattern UNION et la résolution polymorphique pour identifier les entités auxquelles chaque activité se rapporte.

Pourquoi Tasks et Events sont séparés

Dans le modèle de données Salesforce, Tasks et Events représentent des types d’activité différents avec des schémas différents :

  • Tasks ont ActivityDate (une date, pas un timestamp), Status (Open, Completed, etc.) et Priority. Elles représentent des actions discrètes : journaliser un appel, envoyer un email, créer une tâche à faire.
  • Events ont StartDateTime et EndDateTime (timestamps), Location et IsAllDayEvent. Ils représentent des activités limitées dans le temps : réunions, démos, visites sur site.

Les deux objets partagent les champs polymorphiques WhoId (référence à une personne) et WhatId (référence à un objet), plus des champs communs comme Subject, OwnerId et Description.

Le pattern UNION

Combiner les deux nécessite un mapping vers un ensemble de colonnes cohérent. La décision clé est de savoir comment normaliser les champs de date — Tasks ont une date, Events ont un datetime.

-- int__salesforce_unified_activity.sql
SELECT
id AS activity__id,
'task' AS activity__source,
subject AS activity__subject,
activity_date AS activity__occurred_at,
who_id AS activity__who_id,
what_id AS activity__what_id,
owner_id AS activity__owner_id
FROM {{ ref('base__salesforce__task') }}
UNION ALL
SELECT
id AS activity__id,
'event' AS activity__source,
subject AS activity__subject,
start_date_time AS activity__occurred_at,
who_id AS activity__who_id,
what_id AS activity__what_id,
owner_id AS activity__owner_id
FROM {{ ref('base__salesforce__event') }}

La colonne activity__source préserve le type d’objet original pour que les consommateurs en aval puissent filtrer dessus si nécessaire (« montre-moi uniquement les réunions » = WHERE activity__source = 'event').

Quelques détails à noter :

  • activity_date vs. start_date_time — Tasks utilisent un champ DATE, Events un TIMESTAMP. Le UNION coerce la date Task vers un timestamp (minuit). Si la précision compte, ajoutez CAST(activity_date AS TIMESTAMP) explicitement pour être clair sur la conversion.
  • UNION ALL, pas UNION — il n’y a aucune raison de dédupliquer ici. Les Tasks et Events ont des IDs globalement uniques au sein de chaque objet, et aucun ID de Task ne correspondra à un ID d’Event (ils ont des patterns de préfixe différents).
  • Alignement des colonnes — n’incluez que les colonnes qui existent sur les deux objets. Les champs spécifiques aux Tasks comme Status ou les champs spécifiques aux Events comme EndDateTime et Location peuvent être ajoutés comme colonnes nullable si nécessaire, mais gardez le UNION de base propre.

Couche de résolution polymorphique

Le modèle UNION ci-dessus vous donne une liste d’activités unifiées, mais activity__who_id et activity__what_id sont encore des IDs Salesforce opaques. Superposez le pattern de résolution polymorphique pour obtenir des références d’entités lisibles :

-- int__salesforce_activity_enriched.sql
WITH
activities AS (
SELECT * FROM {{ ref('int__salesforce_unified_activity') }}
),
contacts AS (
SELECT contact__id, contact__full_name, contact__email
FROM {{ ref('base__salesforce__contact') }}
),
leads AS (
SELECT lead__id, lead__full_name, lead__email
FROM {{ ref('base__salesforce__lead') }}
),
accounts AS (
SELECT account__id, account__name
FROM {{ ref('base__salesforce__account') }}
),
opportunities AS (
SELECT opportunity__id, opportunity__name, opportunity__stage
FROM {{ ref('base__salesforce__opportunity') }}
)
SELECT
activities.activity__id,
activities.activity__source,
activities.activity__subject,
activities.activity__occurred_at,
activities.activity__owner_id,
-- Résolution Who
CASE LEFT(activities.activity__who_id, 3)
WHEN '003' THEN 'Contact'
WHEN '00Q' THEN 'Lead'
END AS activity__who_type,
COALESCE(
contacts.contact__full_name,
leads.lead__full_name
) AS activity__who_name,
COALESCE(
contacts.contact__email,
leads.lead__email
) AS activity__who_email,
-- Résolution What
CASE LEFT(activities.activity__what_id, 3)
WHEN '001' THEN 'Account'
WHEN '006' THEN 'Opportunity'
END AS activity__what_type,
COALESCE(
accounts.account__name,
opportunities.opportunity__name
) AS activity__what_name
FROM activities
LEFT JOIN contacts
ON LEFT(activities.activity__who_id, 3) = '003'
AND activities.activity__who_id = contacts.contact__id
LEFT JOIN leads
ON LEFT(activities.activity__who_id, 3) = '00Q'
AND activities.activity__who_id = leads.lead__id
LEFT JOIN accounts
ON LEFT(activities.activity__what_id, 3) = '001'
AND activities.activity__what_id = accounts.account__id
LEFT JOIN opportunities
ON LEFT(activities.activity__what_id, 3) = '006'
AND activities.activity__what_id = opportunities.opportunity__id

La sortie est une timeline d’activité complète avec des références d’entités résolues : vous savez avec qui était l’activité (nom et email du contact ou du lead) et sur quoi elle portait (nom du compte ou nom et étape de l’opportunité).

Cas d’usage en aval

Dashboard d’activité par commercial

Groupez par activity__owner_id et activity__source pour voir le volume d’activité par commercial et type :

SELECT
activity__owner_id,
activity__source,
COUNT(*) AS activity_count,
COUNT(DISTINCT DATE(activity__occurred_at)) AS active_days
FROM {{ ref('int__salesforce_activity_enriched') }}
WHERE activity__occurred_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY 1, 2

Cela permet des décompositions du volume d’activité par commercial et type, et une corrélation avec la progression du pipeline.

Score d’engagement du compte

Comptez les activités par compte sur une fenêtre glissante pour un signal d’engagement :

SELECT
activity__what_name AS account__name,
COUNT(*) AS activities_30d,
COUNT(DISTINCT activity__owner_id) AS reps_engaged,
MAX(activity__occurred_at) AS last_activity_at
FROM {{ ref('int__salesforce_activity_enriched') }}
WHERE activity__what_type = 'Account'
AND activity__occurred_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY 1

La sortie met en évidence les comptes sans activité récente et les comptes avec plusieurs commerciaux impliqués.

Timeline d’activité d’une opportunité

Filtrez sur une opportunité spécifique pour voir l’historique complet des activités :

SELECT
activity__occurred_at,
activity__source,
activity__subject,
activity__who_name,
activity__who_type
FROM {{ ref('int__salesforce_activity_enriched') }}
WHERE activity__what_type = 'Opportunity'
AND activities.activity__what_id = '006...'
ORDER BY activity__occurred_at

Cela réplique la vue de timeline d’activité que les interfaces CRM fournissent nativement, pour utilisation dans les outils BI.

Matérialisation

Matérialisez le modèle UNION en vue ou modèle éphémère — c’est un simple UNION sans calcul. Matérialisez le modèle enrichi (avec les jointures polymorphiques) en table, puisqu’il implique plusieurs LEFT JOINs sur de grandes tables.

Pour les orgs Salesforce très actives (50 000+ activités par mois), envisagez une matérialisation incrémentale sur activity__occurred_at. Le UNION reste une vue, et le modèle enrichi devient incrémental, ne traitant que les nouvelles activités depuis la dernière exécution.

Le contraste HubSpot

HubSpot aussi track les engagements (appels, emails, réunions, notes, tâches), mais les stocke dans un seul objet engagement avec un champ type plutôt que de les diviser en objets séparés. Cela signifie que HubSpot n’a pas besoin de l’étape UNION — les données sont déjà unifiées dans la source. Cependant, les engagements HubSpot utilisent le modèle d’associations pour se lier aux contacts et aux deals, ce qui introduit sa propre complexité au niveau de la couche de jointure.

Les deux sources produisent une timeline unifiée avec des références d’entités résolues, via des patterns de jointure différents.