lumnik. / scénarios Tous les scénarios · English

Un client dans deux systèmes sans identifiant commun

Objectif Rapprocher les clients de l'ERP et les contacts du CRM, sans jamais fusionner au hasard.

Un ERP, un CRM, aucun identifiant commun. L'email rapproche les fiches. Une boîte partagée désigne deux clients.

erp-accounts.csv
account_code,company_name,email,city
AC-100,Atelier Boréal,commandes@atelier-boreal.example,Lille
AC-200,Métal Azur,achats@metal-azur.example,Nice
AC-300,Rhône Filtration,compta@groupe-rhone.example,Lyon
AC-301,Rhône Levage,compta@groupe-rhone.example,Lyon
AC-400,Capteurs Armor,contact@capteurs-armor.example,Rennes
crm-contacts.csv
contact_code,display_name,email,phone
CT-9001,ATELIER BORÉAL,Commandes@Atelier-Boreal.example,+33 3 20 00 00 01
CT-9002,Métal Azur,achats@metal-azur.example,+33 4 93 00 00 02
CT-9003,Groupe Rhône,compta@groupe-rhone.example,+33 4 72 00 00 03
CT-9004,Transmission Nord,,+33 3 28 00 00 04
CT-9005,Pneumatique Atlas,ventes@pneumatique-atlas.example,+33 5 61 00 00 05
manifests/30-customer.yaml
kind: Entityname: CustomerOfRecorddescription: "One customer across an ERP account and a CRM contact that share no identifier."scopes: [entity-resolution]sources:  - table: t_scenario_resolution_erp    map:      account_code: account_code      company_name: name      email: email      city: city  - table: t_scenario_resolution_crm    map:      contact_code: crm_id      display_name: name      email: email      phone: phoneresolution:  identity:    t_scenario_resolution_erp: account_code    t_scenario_resolution_crm: crm_id  cascade:    - id: email      exact:        fields: [email]        normalize: [lower]
$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 8
ambiguous: 1
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 6
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 9

$ lm entity ambiguous CustomerOfRecord -o json
[
  {
    "identityRef": "2f7844dc-3739-457a-968a-e4a7e8c4b5f0",
    "source": "t_scenario_resolution_crm",
    "sourceId": "CT-9003",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "decidedBy": "automatic",
    "occurredAt": "2026-11-03T15:24:34.823862Z"
  }
]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9004 -o json
[
  {
    "account_code": null,
    "name": "Transmission Nord",
    "email": null,
    "city": null,
    "crm_id": "CT-9004",
    "phone": "+33 3 28 00 00 04",
    "_conflicts": "{}"
  }
]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9001 -o json
[
  {
    "account_code": "AC-100",
    "name": "Atelier Boréal",
    "email": "commandes@atelier-boreal.example",
    "city": "Lille",
    "crm_id": "CT-9001",
    "phone": "+33 3 20 00 00 01",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Atelier Boréal\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"ATELIER BORÉAL\"}], \"email\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"commandes@atelier-boreal.example\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Commandes@Atelier-Boreal.example\"}]}"
  }
]

$ lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9003 -o json
[
  {
    "ruleDeclaredId": "email",
    "ruleVersion": "ce968f5902d73417@engine-v1@hmac-k1@lower:1.0.0",
    "provider": "",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "AMBIGUOUS",
    "decidedBy": "automatic",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:34.823862Z",
    "assertedBy": null,
    "assertedByLogin": null,
    "reason": null
  }
]
$ lm entity assert-same CustomerOfRecord --left t_scenario_resolution_crm:CT-9003 --right t_scenario_resolution_erp:AC-300 --reason "The CRM billing contact belongs to Rhône Filtration"
recorded: t_scenario_resolution_crm:CT-9003 and t_scenario_resolution_erp:AC-300 are same

$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 9
ambiguous: 0
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 0
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 8

$ lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9003 -o json
[
  {
    "ruleDeclaredId": "email",
    "ruleVersion": "ce968f5902d73417@engine-v1@hmac-k1@lower:1.0.0",
    "provider": "",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "AMBIGUOUS",
    "decidedBy": "automatic",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:34.823862Z",
    "assertedBy": null,
    "assertedByLogin": null,
    "reason": null
  },
  {
    "ruleDeclaredId": "",
    "ruleVersion": "",
    "provider": "",
    "candidates": [
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "RESOLVED",
    "decidedBy": "asserted",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:35.05687Z",
    "assertedBy": "1",
    "assertedByLogin": "integrator",
    "reason": "The CRM billing contact belongs to Rhône Filtration"
  }
]

$ lm entity ambiguous CustomerOfRecord -o json
[]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9003 -o json
[
  {
    "account_code": "AC-300",
    "name": "Rhône Filtration",
    "email": "compta@groupe-rhone.example",
    "city": "Lyon",
    "crm_id": "CT-9003",
    "phone": "+33 4 72 00 00 03",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Rhône Filtration\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Groupe Rhône\"}]}"
  }
]
$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 9
ambiguous: 0
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 0
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 8

$ lm entity get CustomerOfRecord --filter crm_id=CT-9003 -o json
[
  {
    "account_code": "AC-300",
    "name": "Rhône Filtration",
    "email": "compta@groupe-rhone.example",
    "city": "Lyon",
    "crm_id": "CT-9003",
    "phone": "+33 4 72 00 00 03",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Rhône Filtration\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Groupe Rhône\"}]}"
  }
]
$ lm entity assert-same CustomerOfRecord --left t_scenario_resolution_crm:CT-9999 --right t_scenario_resolution_erp:AC-300
Error: API error 404: entity 'CustomerOfRecord' has no row t_scenario_resolution_crm:CT-9999 — check it with lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9999, or run lm entity resolve CustomerOfRecord if the row is new

Limites

  • L'email est un signal déclaré, pas une preuve d'identité.
  • Règles exactes seulement ; aucun fournisseur de candidats.
  • Données synthétiques.

Clients · ERP et CRM

Le même client vit dans deux systèmes.

Sans identifiant commun.

Cinq comptes, cinq contacts

Le problème

Qui est qui?

  • AC-300 côté ERP, CT-9003 côté CRM.
  • Aucun ne connaît l'autre.

Ce qui n'est pas modifié

L'ERP et le CRM restent tels quels.

  • Deux exports, lus tels quels.
  • Aucune clé à ajouter à la source.

Le piège

Une boîte partagée.

  • compta@groupe-rhone désigne deux comptes.
  • Fusionner au hasard, c'est se tromper.

Ce qu'il a fallu écrire

normalize: [lower]

Une ligne.

  • L'email rapproche, en minuscules.
  • Deux candidats: une ambiguïté.
  • Trois manifestes, 52 lignes.

La décision

Un humain tranche.

  • CT-9003 rejoint Rhône Filtration.
  • La raison est écrite, elle tient au rejeu.

La limite

L'email est un signal, pas une preuve.

  • Règles exactes seulement.

Essayer

Le scénario se rejoue en local.

Chaque étape est publiée avec sa sortie.

Ce scénario tourne sur lumnik Open, gratuit et open source.

Essayer lumnik Open →