feat(crm): merge company slogan variants in dedupe grouping
Treat names like "Affirm" and "Affirm — Fraud Engineering" as the same company for dedupe, deal matching, members, and applications sync lookup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
|
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
|
||||||
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
|
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
|
||||||
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
`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,
|
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
|
||||||
dedupe smoke tests.
|
dedupe smoke tests.
|
||||||
|
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) ma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, d := range deals {
|
for _, d := range deals {
|
||||||
if strings.TrimSpace(fmt.Sprint(d["title"])) == needle {
|
if onlyoffice.OpportunityTitlesMatch(fmt.Sprint(d["title"]), needle) {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
// FindCompany searches for a company contact with an exact (case-insensitive)
|
||||||
// displayName match. Returns nil when not found.
|
// displayName match. Returns nil when not found.
|
||||||
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
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
|
const page = 50
|
||||||
for start := 0; ; start += page {
|
for start := 0; ; start += page {
|
||||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, co := range items {
|
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
|
return co, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -38,7 +38,7 @@ func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any {
|
|||||||
if !isCompany(row) {
|
if !isCompany(row) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := NormalizeCompanyName(rowDisplayName(row))
|
key := CompanyGroupingKey(rowDisplayName(row))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
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.
|
// DealTitleKey returns the grouping key for an opportunity title.
|
||||||
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
|
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
if ignoreCompanySuffix {
|
if ignoreCompanySuffix {
|
||||||
return NormalizeOpportunityTitle(StripCompanySuffix(title))
|
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.
|
// GroupOpportunitiesByTitle buckets deals by title key.
|
||||||
|
|||||||
+31
-2
@@ -24,7 +24,7 @@ func TestGroupCompaniesByName(t *testing.T) {
|
|||||||
if len(groups) != 2 {
|
if len(groups) != 2 {
|
||||||
t.Fatalf("groups: %d", len(groups))
|
t.Fatalf("groups: %d", len(groups))
|
||||||
}
|
}
|
||||||
key := NormalizeCompanyName("711media")
|
key := CompanyGroupingKey("711media")
|
||||||
if len(groups[key]) != 2 {
|
if len(groups[key]) != 2 {
|
||||||
t.Fatalf("711media group: %d", len(groups[key]))
|
t.Fatalf("711media 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) {
|
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)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
|
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
|
||||||
t.Fatalf("got %q", got)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -7,6 +7,26 @@ import (
|
|||||||
|
|
||||||
var multiSpace = regexp.MustCompile(`\s+`)
|
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.
|
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
|
||||||
func NormalizeCompanyName(s string) string {
|
func NormalizeCompanyName(s string) string {
|
||||||
return collapseKey(s)
|
return collapseKey(s)
|
||||||
@@ -61,7 +81,7 @@ func ContactInfoKey(infoType, value string) string {
|
|||||||
|
|
||||||
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
||||||
func MemberDisplayKey(displayName string) string {
|
func MemberDisplayKey(displayName string) string {
|
||||||
return NormalizeCompanyName(displayName)
|
return CompanyGroupingKey(displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseKey(s string) string {
|
func collapseKey(s string) string {
|
||||||
@@ -70,7 +90,11 @@ func collapseKey(s string) string {
|
|||||||
return strings.ToLower(s)
|
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 {
|
func DealTitleForApplication(position, company string) string {
|
||||||
position = strings.TrimSpace(position)
|
position = strings.TrimSpace(position)
|
||||||
company = strings.TrimSpace(company)
|
company = strings.TrimSpace(company)
|
||||||
|
|||||||
@@ -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) {
|
func TestStripCompanySuffix(t *testing.T) {
|
||||||
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
|
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
|
|||||||
Reference in New Issue
Block a user