diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb9e88..2f3b46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`, `ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`, `IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`. +- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names + like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members). - [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename, dedupe smoke tests. diff --git a/crm.go b/crm.go index 9fe2024..ae7c349 100644 --- a/crm.go +++ b/crm.go @@ -47,7 +47,7 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a // FindCompany searches for a company contact with an exact (case-insensitive) // displayName match. Returns nil when not found. func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) { - needle := strings.ToLower(strings.TrimSpace(name)) + needle := CompanyGroupingKey(name) const page = 50 for start := 0; ; start += page { items, total, err := c.ListContacts(ctx, page, start, name) @@ -55,7 +55,10 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, return nil, err } for _, co := range items { - if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle { + if !isCompany(co) { + continue + } + if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle { return co, nil } } diff --git a/crm_dedupe_plan.go b/crm_dedupe_plan.go index 608cc3a..46a1b46 100644 --- a/crm_dedupe_plan.go +++ b/crm_dedupe_plan.go @@ -38,7 +38,7 @@ func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any { if !isCompany(row) { continue } - key := NormalizeCompanyName(rowDisplayName(row)) + key := CompanyGroupingKey(rowDisplayName(row)) if key == "" { continue } @@ -65,10 +65,21 @@ func GroupPersonsByKey(items []map[string]any) map[string][]map[string]any { // DealTitleKey returns the grouping key for an opportunity title. func DealTitleKey(title string, ignoreCompanySuffix bool) string { + title = strings.TrimSpace(title) if ignoreCompanySuffix { return NormalizeOpportunityTitle(StripCompanySuffix(title)) } - return NormalizeOpportunityTitle(title) + if i := strings.LastIndex(title, " @ "); i >= 0 { + pos := strings.TrimSpace(title[:i]) + co := StripSloganSuffix(strings.TrimSpace(title[i+len(" @ "):])) + if pos == "" && co != "" { + return NormalizeCompanyName(co) + } + if pos != "" && co != "" { + return collapseKey(pos + " @ " + co) + } + } + return NormalizeCompanyName(StripSloganSuffix(title)) } // GroupOpportunitiesByTitle buckets deals by title key. diff --git a/crm_dedupe_plan_test.go b/crm_dedupe_plan_test.go index b48f633..ea270f2 100644 --- a/crm_dedupe_plan_test.go +++ b/crm_dedupe_plan_test.go @@ -24,7 +24,7 @@ func TestGroupCompaniesByName(t *testing.T) { if len(groups) != 2 { t.Fatalf("groups: %d", len(groups)) } - key := NormalizeCompanyName("contoso") + key := CompanyGroupingKey("contoso") if len(groups[key]) != 2 { t.Fatalf("contoso group: %d", len(groups[key])) } @@ -61,11 +61,40 @@ func TestGroupContactInfoRows(t *testing.T) { } } +func TestGroupCompaniesBySlogan(t *testing.T) { + items := []map[string]any{ + {"id": float64(1), "displayName": "Affirm", "isCompany": true}, + {"id": float64(2), "displayName": "Affirm — Fraud Engineering", "isCompany": true}, + } + groups := GroupCompaniesByName(items) + if len(groups) != 1 { + t.Fatalf("groups: %d", len(groups)) + } + key := CompanyGroupingKey("Affirm") + if len(groups[key]) != 2 { + t.Fatalf("affirm group: %d", len(groups[key])) + } +} + +func TestDedupeMembersBySloganDisplayName(t *testing.T) { + members := []map[string]any{ + {"id": float64(1), "displayName": "Affirm"}, + {"id": float64(2), "displayName": "Affirm — Fraud Engineering"}, + } + remove := DedupeMembersByDisplayName(members) + if !reflect.DeepEqual(remove, []int64{2}) { + t.Fatalf("remove %v", remove) + } +} + func TestDealTitleKey(t *testing.T) { - if got := DealTitleKey("Dev @ Acme", false); got != NormalizeOpportunityTitle("Dev @ Acme") { + if got := DealTitleKey("Dev @ Acme", false); got != collapseKey("Dev @ Acme") { t.Fatalf("got %q", got) } if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") { t.Fatalf("got %q", got) } + if got := DealTitleKey("Dev @ Affirm — Fraud Engineering", false); got != DealTitleKey("Dev @ Affirm", false) { + t.Fatalf("slogan keys differ: %q vs %q", got, DealTitleKey("Dev @ Affirm", false)) + } } diff --git a/crm_normalize.go b/crm_normalize.go index 1907d90..82d4af8 100644 --- a/crm_normalize.go +++ b/crm_normalize.go @@ -7,6 +7,26 @@ import ( var multiSpace = regexp.MustCompile(`\s+`) +// sloganSeparators split a company name from a trailing tagline/slogan. +var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"} + +// StripSloganSuffix returns the part before an em/en dash tagline, e.g. +// "Affirm — Fraud Engineering" → "Affirm". +func StripSloganSuffix(s string) string { + s = strings.TrimSpace(s) + for _, sep := range sloganSeparators { + if i := strings.Index(s, sep); i > 0 { + return strings.TrimSpace(s[:i]) + } + } + return s +} + +// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans). +func CompanyGroupingKey(s string) string { + return NormalizeCompanyName(StripSloganSuffix(s)) +} + // NormalizeCompanyName lowercases and collapses whitespace for grouping. func NormalizeCompanyName(s string) string { return collapseKey(s) @@ -61,7 +81,7 @@ func ContactInfoKey(infoType, value string) string { // MemberDisplayKey normalizes a member displayName for duplicate detection. func MemberDisplayKey(displayName string) string { - return NormalizeCompanyName(displayName) + return CompanyGroupingKey(displayName) } func collapseKey(s string) string { @@ -70,7 +90,11 @@ func collapseKey(s string) string { return strings.ToLower(s) } -// DealTitleForApplication builds the sync deal title from position and company. +// OpportunityTitlesMatch reports whether two deal titles refer to the same role+company. +func OpportunityTitlesMatch(a, b string) bool { + return DealTitleKey(a, false) == DealTitleKey(b, false) +} + func DealTitleForApplication(position, company string) string { position = strings.TrimSpace(position) company = strings.TrimSpace(company) diff --git a/crm_normalize_test.go b/crm_normalize_test.go index 60eb080..f02c3a6 100644 --- a/crm_normalize_test.go +++ b/crm_normalize_test.go @@ -42,6 +42,37 @@ func TestFixDealTitle(t *testing.T) { } } +func TestStripSloganSuffix(t *testing.T) { + tests := []struct { + in, want string + }{ + {"Affirm — Fraud Engineering", "Affirm"}, + {"Affirm - Fraud Engineering", "Affirm"}, + {"Affirm – Fraud Engineering", "Affirm"}, + {"Affirm", "Affirm"}, + {"— leading", "— leading"}, + } + for _, tc := range tests { + if got := StripSloganSuffix(tc.in); got != tc.want { + t.Errorf("StripSloganSuffix(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCompanyGroupingKey(t *testing.T) { + a := CompanyGroupingKey("Affirm") + b := CompanyGroupingKey("Affirm — Fraud Engineering") + if a != b { + t.Fatalf("%q != %q", a, b) + } +} + +func TestOpportunityTitlesMatchSlogan(t *testing.T) { + if !OpportunityTitlesMatch("Dev @ Affirm — Fraud Engineering", "Dev @ Affirm") { + t.Fatal("expected match") + } +} + func TestStripCompanySuffix(t *testing.T) { if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" { t.Fatalf("got %q", got)