feat(crm): dedupe duplicates, fix deal titles, and add cleanup CLI

Merge duplicate companies/persons/deals by name, remove duplicate contact
info and opportunity members, and rename malformed " @ Company" titles.
Applications sync now avoids empty-position titles and duplicate members.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 12:34:14 +01:00
co-authored by Cursor
parent e03fd62200
commit c77519fac5
21 changed files with 1832 additions and 46 deletions
+20 -15
View File
@@ -254,10 +254,7 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
const stageInitial = 1
for _, app := range apps {
dealTitle := app.Position
if app.Company != "" {
dealTitle = app.Position + " @ " + app.Company
}
dealTitle := onlyoffice.DealTitleForApplication(app.Position, app.Company)
fmt.Println(strings.Repeat("─", 60))
fmt.Println(" ", app.Folder)
fmt.Println(" ", dealTitle)
@@ -344,10 +341,16 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
st.Deals++
if !dryRun && dealID != 0 {
if companyID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID))
cid := strconv.Itoa(companyID)
if ok, _ := client.IsOpportunityMember(ctx, strconv.Itoa(dealID), cid); !ok {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), cid)
}
}
if personID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID))
pid := strconv.Itoa(personID)
if ok, _ := client.IsOpportunityMember(ctx, strconv.Itoa(dealID), pid); !ok {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), pid)
}
}
}
if app.Summary != "" {
@@ -395,19 +398,21 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
}
func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} {
deals, total, _ := c.ListOpportunities(ctx, 100, 0)
for _, d := range deals {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
needle := strings.TrimSpace(title)
const page = 100
for start := 0; ; start += page {
deals, total, err := c.ListOpportunities(ctx, page, start)
if err != nil {
return nil
}
}
if total > 100 {
deals2, _, _ := c.ListOpportunities(ctx, 100, 100)
for _, d := range deals2 {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
for _, d := range deals {
if strings.TrimSpace(fmt.Sprint(d["title"])) == needle {
return d
}
}
if start+page >= total || len(deals) == 0 {
break
}
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package applications
import (
"testing"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func TestDealTitleForApplication(t *testing.T) {
if got := onlyoffice.DealTitleForApplication("", "711media"); got != "711media" {
t.Fatalf("got %q", got)
}
if got := onlyoffice.DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
t.Fatalf("got %q", got)
}
}
func TestHasContactInfoHelper(t *testing.T) {
contact := map[string]any{
"commonData": []any{
map[string]any{"infoType": "Email", "data": "a@b.com"},
},
}
if !onlyoffice.HasContactInfo(contact, "Email", "a@b.com") {
t.Fatal("expected match")
}
if onlyoffice.HasContactInfo(contact, "Email", "other@b.com") {
t.Fatal("unexpected match")
}
}