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:
+6
-1
@@ -1,4 +1,4 @@
|
||||
# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself
|
||||
# OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
|
||||
# does not load .env files — only its CLI does, via godotenv).
|
||||
#
|
||||
# Primary names:
|
||||
@@ -10,6 +10,11 @@ ONLYOFFICE_PASS=
|
||||
# ONLYOFFICE_HOST=
|
||||
# ONLYOFFICE_NAME=
|
||||
# ONLYOFFICE_PASSWORD=
|
||||
#
|
||||
# produktor.io operator aliases (CLI-only):
|
||||
# OO_URL=
|
||||
# OO_USER=
|
||||
# OO_PASS=
|
||||
|
||||
# Optional defaults for calendar/project helpers:
|
||||
ONLYOFFICE_CALENDAR_ID=1
|
||||
|
||||
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — library
|
||||
|
||||
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
|
||||
merge duplicate companies/persons/deals by normalized name; remove duplicate
|
||||
contact-info rows and opportunity members (including same `displayName` with
|
||||
different ids); fix malformed deal titles (` @ Company` → `Company`).
|
||||
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
|
||||
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
|
||||
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
||||
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
|
||||
dedupe smoke tests.
|
||||
|
||||
### Added — CLI
|
||||
|
||||
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
|
||||
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
|
||||
|
||||
### Changed — CLI
|
||||
|
||||
- `oo applications sync` — empty position uses company-only deal title; skips
|
||||
duplicate opportunity members; paginated find-or-create lookups.
|
||||
- `oo` now loads OnlyOffice credentials only from `.env` in the current
|
||||
working directory.
|
||||
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
|
||||
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
|
||||
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
|
||||
when already set in the process environment.
|
||||
|
||||
## [0.6.0] - 2026-04-24
|
||||
|
||||
### Added — library
|
||||
|
||||
@@ -580,14 +580,23 @@ oo tasks files detach 208 12345
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
||||
| `persons` | `list` (filtered), `create`, `delete` |
|
||||
| `companies` | `list` (filtered), `create`, `delete` |
|
||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add` |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
|
||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||
| `crm` | `cleanup` |
|
||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||
| `applications` | `sync` |
|
||||
|
||||
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
|
||||
itself never loads dotfiles). Run `oo --help` or `oo <subject> --help` for
|
||||
the full command reference.
|
||||
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||
CLI-only concern — the library itself never loads dotfiles).
|
||||
|
||||
Canonical `ONLYOFFICE_*` variables win over aliases. For produktor.io operator
|
||||
files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for
|
||||
`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
||||
|
||||
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
||||
|
||||
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
|
||||
@@ -604,6 +613,7 @@ the full command reference.
|
||||
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootRegistersSubjects(t *testing.T) {
|
||||
want := []string{
|
||||
"calendar", "projects", "tasks", "users", "whoami",
|
||||
"contacts", "persons", "companies",
|
||||
"opportunities", "cases", "crm-tasks", "applications", "crm",
|
||||
}
|
||||
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||
for _, c := range rootCmd.Commands() {
|
||||
got[c.Name()] = true
|
||||
}
|
||||
for _, name := range want {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing root subcommand %q; have %v", name, rootCmd.Commands())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpListsSubjects(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
rootCmd.SetOut(out)
|
||||
rootCmd.SetErr(&bytes.Buffer{})
|
||||
rootCmd.SetArgs([]string{"--help"})
|
||||
t.Cleanup(func() {
|
||||
rootCmd.SetArgs(nil)
|
||||
rootCmd.SetOut(nil)
|
||||
rootCmd.SetErr(nil)
|
||||
})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
help := out.String()
|
||||
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
|
||||
if !strings.Contains(help, snippet) {
|
||||
t.Fatalf("help missing %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsAlias(t *testing.T) {
|
||||
cmd, _, err := rootCmd.Find([]string{"prj"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.Name() != "projects" {
|
||||
t.Fatalf("prj alias resolved to %q", cmd.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
|
||||
clearEnv(t,
|
||||
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
|
||||
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
|
||||
"OO_URL", "OO_USER", "OO_PASS",
|
||||
)
|
||||
dir := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
errBuf := &bytes.Buffer{}
|
||||
rootCmd.SetErr(errBuf)
|
||||
rootCmd.SetOut(&bytes.Buffer{})
|
||||
rootCmd.SetArgs([]string{"users", "list"})
|
||||
t.Cleanup(func() {
|
||||
rootCmd.SetArgs(nil)
|
||||
rootCmd.SetOut(nil)
|
||||
rootCmd.SetErr(nil)
|
||||
})
|
||||
|
||||
err = rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error without credentials")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -32,13 +32,13 @@ func init() {
|
||||
// execute runs the root command. Exported only to main.go in the same package.
|
||||
func execute() error { return rootCmd.Execute() }
|
||||
|
||||
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
|
||||
// newOO loads env (only .env in CWD) and returns an authenticated client.
|
||||
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
|
||||
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
|
||||
_ = godotenv.Load()
|
||||
loadOOEnv()
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
if creds.Url == "" || creds.User == "" || creds.Password == "" {
|
||||
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
|
||||
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
|
||||
}
|
||||
c := onlyoffice.NewClient(creds)
|
||||
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||
@@ -48,6 +48,26 @@ func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func loadOOEnv() {
|
||||
_ = godotenv.Load(".env")
|
||||
applyOOEnvAliases()
|
||||
}
|
||||
|
||||
func applyOOEnvAliases() {
|
||||
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
|
||||
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
|
||||
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
|
||||
}
|
||||
|
||||
func setEnvIfEmpty(dst, src string) {
|
||||
if strings.TrimSpace(os.Getenv(dst)) != "" {
|
||||
return
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
|
||||
_ = os.Setenv(dst, v)
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON dumps any value as indented JSON.
|
||||
func printJSON(v any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOOEnvFromCWDWithOOAliases(t *testing.T) {
|
||||
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
|
||||
dir := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
if err := os.WriteFile(".env", []byte("OO_URL=https://office.produktor.io\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loadOOEnv()
|
||||
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.produktor.io" {
|
||||
t.Fatalf("ONLYOFFICE_URL=%q", got)
|
||||
}
|
||||
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
|
||||
t.Fatalf("ONLYOFFICE_USER=%q", got)
|
||||
}
|
||||
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
|
||||
t.Fatalf("ONLYOFFICE_PASS=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOOEnvAliasesDoesNotOverrideCanonical(t *testing.T) {
|
||||
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
|
||||
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
|
||||
t.Setenv("OO_URL", "https://alias.example")
|
||||
|
||||
applyOOEnvAliases()
|
||||
|
||||
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
|
||||
t.Fatalf("ONLYOFFICE_URL=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtCell(t *testing.T) {
|
||||
tests := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{"hello", "hello"},
|
||||
{true, "true"},
|
||||
{false, "false"},
|
||||
{float64(42), "42"},
|
||||
{float64(1.5), "1.5"},
|
||||
{int64(7), "7"},
|
||||
{map[string]any{"a": 1}, `{"a":1}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := fmtCell(tc.in); got != tc.want {
|
||||
t.Fatalf("fmtCell(%#v)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
long := strings.Repeat("x", 100)
|
||||
got := fmtCell(long)
|
||||
if !strings.HasSuffix(got, "…") || len(got) > 82 {
|
||||
t.Fatalf("fmtCell(long)=%q len=%d", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("a\nb", 10); got != "a b" {
|
||||
t.Fatalf("truncate newline=%q", got)
|
||||
}
|
||||
got := truncate(strings.Repeat("z", 20), 10)
|
||||
if !strings.HasSuffix(got, "…") || len(got) >= 20 {
|
||||
t.Fatalf("truncate=%q len=%d", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedKeys(t *testing.T) {
|
||||
got := sortedKeys(map[string]any{"c": 1, "a": 2, "b": 3})
|
||||
want := []string{"a", "b", "c"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sortedKeys=%v want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlexIDFloat(t *testing.T) {
|
||||
if got := flexIDFloat(float64(12)); got != 12 {
|
||||
t.Fatalf("float64=%v", got)
|
||||
}
|
||||
if got := flexIDFloat(7); got != 7 {
|
||||
t.Fatalf("int=%v", got)
|
||||
}
|
||||
if got := flexIDFloat("3.5"); got != 3.5 {
|
||||
t.Fatalf("string=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdString(t *testing.T) {
|
||||
m := map[string]any{"id": float64(99), "s": "x", "i": 5}
|
||||
if got := idString(m, "id"); got != "99" {
|
||||
t.Fatalf("float id=%q", got)
|
||||
}
|
||||
if got := idString(m, "s"); got != "x" {
|
||||
t.Fatalf("string id=%q", got)
|
||||
}
|
||||
if got := idString(m, "i"); got != "5" {
|
||||
t.Fatalf("int id=%q", got)
|
||||
}
|
||||
if got := idString(m, "missing"); got != "" {
|
||||
t.Fatalf("missing=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTableJSON(t *testing.T) {
|
||||
defer func(old string) { outputFormat = old }(outputFormat)
|
||||
outputFormat = "json"
|
||||
out := captureStdout(t, func() {
|
||||
printTable([]string{"id", "name"}, []map[string]any{{"id": 1, "name": "alpha"}})
|
||||
})
|
||||
if !strings.Contains(out, `"id": 1`) || !strings.Contains(out, `"name": "alpha"`) {
|
||||
t.Fatalf("json output=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTableEmpty(t *testing.T) {
|
||||
defer func(old string) { outputFormat = old }(outputFormat)
|
||||
outputFormat = "table"
|
||||
out := captureStdout(t, func() { printTable([]string{"id"}, nil) })
|
||||
if strings.TrimSpace(out) != "(empty)" {
|
||||
t.Fatalf("empty table=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintObjectTable(t *testing.T) {
|
||||
defer func(old string) { outputFormat = old }(outputFormat)
|
||||
outputFormat = "table"
|
||||
out := captureStdout(t, func() {
|
||||
printObject(map[string]any{"id": float64(1), "title": "Demo"})
|
||||
})
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "Demo") {
|
||||
t.Fatalf("table object=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = r.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func clearEnv(t *testing.T, keys ...string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
old, ok := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if ok {
|
||||
_ = os.Setenv(key, old)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -39,16 +40,20 @@ func init() {
|
||||
contactsCmd.AddCommand(contactsGetCmd())
|
||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||
|
||||
only := true
|
||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||
personsCmd.AddCommand(personsCreateCmd())
|
||||
personsCmd.AddCommand(contactsDeleteCmd())
|
||||
personsCmd.AddCommand(personsDedupeCmd())
|
||||
|
||||
onlyCo := false
|
||||
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
|
||||
companiesCmd.AddCommand(companiesCreateCmd())
|
||||
companiesCmd.AddCommand(contactsDeleteCmd())
|
||||
companiesCmd.AddCommand(companiesDedupeCmd())
|
||||
companiesCmd.AddCommand(companiesDedupePersonsCmd())
|
||||
}
|
||||
|
||||
// contactsListCmd returns a `list` subcommand.
|
||||
@@ -246,3 +251,63 @@ func companiesCreateCmd() *cobra.Command {
|
||||
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func companiesDedupeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dedupe",
|
||||
Short: "Merge duplicate companies by normalized name",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupeCompanies(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func companiesDedupePersonsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dedupe-persons",
|
||||
Short: "Merge duplicate persons under each company",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupeCompanyPersons(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func personsDedupeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dedupe",
|
||||
Short: "Merge duplicate persons by normalized first+last name",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupePersons(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func contactsDedupeInfoCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dedupe-info",
|
||||
Short: "Remove duplicate contact info rows (email, phone, …)",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupeContactInfo(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var crmCmd = &cobra.Command{
|
||||
Use: "crm",
|
||||
Short: "CRM maintenance (dedupe, cleanup)",
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(crmCmd)
|
||||
crmCmd.AddCommand(crmCleanupCmd())
|
||||
}
|
||||
|
||||
func crmCleanupCmd() *cobra.Command {
|
||||
var ignoreCompanySuffix bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Run all CRM dedupe passes (companies, persons, associations, titles)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := onlyoffice.CleanupCRM(cmd.Context(), c, ignoreCompanySuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group deals by position only (strip ' @ Company')")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func dedupeRunE(fn func(cmd *cobra.Command, c *onlyoffice.Client) error) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(cmd, c)
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -6,12 +6,13 @@
|
||||
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||
// oo users list | self (alias: oo whoami)
|
||||
// oo contacts list | get | delete | info-add
|
||||
// oo persons list | create | delete
|
||||
// oo companies list | create | delete
|
||||
// oo opportunities list | get | create | delete | stages | member-add
|
||||
// oo contacts list | get | delete | info-add | dedupe-info
|
||||
// oo persons list | create | delete | dedupe
|
||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||
// oo cases list | create | delete | member-add
|
||||
// oo crm-tasks list | create | delete | categories
|
||||
// oo crm cleanup
|
||||
// oo applications sync
|
||||
//
|
||||
// Every list supports `--output/-o json|table` (table is the default).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,9 @@ func init() {
|
||||
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
||||
opportunitiesCmd.AddCommand(oppStagesCmd())
|
||||
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
||||
opportunitiesCmd.AddCommand(oppDedupeCmd())
|
||||
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
|
||||
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
|
||||
}
|
||||
|
||||
func oppListCmd() *cobra.Command {
|
||||
@@ -175,3 +179,51 @@ func oppMemberAddCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func oppDedupeCmd() *cobra.Command {
|
||||
var ignoreCompanySuffix bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "dedupe",
|
||||
Short: "Merge duplicate opportunities by title",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupeOpportunities(cmd.Context(), c, ignoreCompanySuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group by position only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func oppDedupeMembersCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dedupe-members",
|
||||
Short: "Remove duplicate contacts on each deal (by id and display name)",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.DedupeOpportunityMembers(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func oppFixTitlesCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "fix-titles",
|
||||
Short: "Fix malformed deal titles (leading @, spacing)",
|
||||
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
|
||||
res, err := onlyoffice.FixOpportunityTitles(cmd.Context(), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +47,20 @@ 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) {
|
||||
items, _, err := c.ListContacts(ctx, 50, 0, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, co := range items {
|
||||
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
|
||||
return co, nil
|
||||
const page = 50
|
||||
for start := 0; ; start += page {
|
||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, co := range items {
|
||||
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
|
||||
return co, nil
|
||||
}
|
||||
}
|
||||
if start+page >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
@@ -62,19 +68,25 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
|
||||
|
||||
// FindPerson searches for a person by first+last (case-insensitive).
|
||||
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
|
||||
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first = strings.ToLower(first)
|
||||
last = strings.ToLower(last)
|
||||
for _, p := range items {
|
||||
if isCompany(p) {
|
||||
continue
|
||||
firstNeedle := strings.ToLower(strings.TrimSpace(first))
|
||||
lastNeedle := strings.ToLower(strings.TrimSpace(last))
|
||||
const page = 50
|
||||
for start := 0; ; start += page {
|
||||
items, total, err := c.ListContacts(ctx, page, start, first+" "+last)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.ToLower(fmt.Sprint(p["firstName"])) == first &&
|
||||
strings.ToLower(fmt.Sprint(p["lastName"])) == last {
|
||||
return p, nil
|
||||
for _, p := range items {
|
||||
if isCompany(p) {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
|
||||
strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if start+page >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
@@ -127,6 +139,88 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// ListAllContacts paginates through every CRM contact.
|
||||
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
||||
const page = 100
|
||||
var all []map[string]any
|
||||
for start := 0; ; start += page {
|
||||
chunk, total, err := c.ListContacts(ctx, page, start, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, chunk...)
|
||||
if start+page >= total || len(chunk) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// MergeContacts merges secondary into primary (secondary is removed).
|
||||
func (c *Client) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
|
||||
fields := url.Values{}
|
||||
fields.Set("fromContactId", secondaryID)
|
||||
fields.Set("toContactId", primaryID)
|
||||
out, err := c.putFormObject(ctx, "/api/2.0/crm/contact/merge.json", fields)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
// Some instances expect JSON body with alternate field names.
|
||||
body := map[string]any{
|
||||
"fromContactId": secondaryID,
|
||||
"toContactId": primaryID,
|
||||
}
|
||||
return c.putJSONObject(ctx, "/api/2.0/crm/contact/merge.json", body)
|
||||
}
|
||||
|
||||
// ListCompanyPersons returns persons linked to a company.
|
||||
func (c *Client) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s/person.json", url.PathEscape(companyID)))
|
||||
}
|
||||
|
||||
// DeleteContactInfo removes one info row from a contact.
|
||||
func (c *Client) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data/%s.json", url.PathEscape(contactID), url.PathEscape(dataID)))
|
||||
}
|
||||
|
||||
// ContactInfoRows returns commonData/info rows from a contact map.
|
||||
func ContactInfoRows(contact map[string]any) []map[string]any {
|
||||
for _, key := range []string{"commonData", "data", "contactData"} {
|
||||
if rows, ok := contact[key].([]any); ok {
|
||||
return mapsFromAnySlice(rows)
|
||||
}
|
||||
if rows, ok := contact[key].([]map[string]any); ok {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasContactInfo reports whether a contact already has the given type+value.
|
||||
func HasContactInfo(contact map[string]any, infoType, value string) bool {
|
||||
key := ContactInfoKey(infoType, value)
|
||||
for _, row := range ContactInfoRows(contact) {
|
||||
v := fmt.Sprint(row["data"])
|
||||
if v == "" || v == "<nil>" {
|
||||
v = fmt.Sprint(row["value"])
|
||||
}
|
||||
if ContactInfoKey(fmt.Sprint(row["infoType"]), v) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mapsFromAnySlice(rows []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if m, ok := row.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListOpportunities returns a page of deals/opportunities and the total count.
|
||||
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
@@ -195,6 +289,147 @@ func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]a
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// ListAllOpportunities paginates through every opportunity.
|
||||
func (c *Client) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
|
||||
const page = 100
|
||||
var all []map[string]any
|
||||
for start := 0; ; start += page {
|
||||
chunk, total, err := c.ListOpportunities(ctx, page, start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, chunk...)
|
||||
if start+page >= total || len(chunk) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// OpportunityMembers extracts the members slice from a GetOpportunity response.
|
||||
func OpportunityMembers(opp map[string]any) []map[string]any {
|
||||
raw, ok := opp["members"].([]any)
|
||||
if !ok {
|
||||
if rows, ok := opp["members"].([]map[string]any); ok {
|
||||
return rows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return mapsFromAnySlice(raw)
|
||||
}
|
||||
|
||||
// ListOpportunityMembers returns contacts linked to an opportunity.
|
||||
func (c *Client) ListOpportunityMembers(ctx context.Context, oppID string) ([]map[string]any, error) {
|
||||
opp, err := c.GetOpportunity(ctx, oppID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if members := OpportunityMembers(opp); len(members) > 0 {
|
||||
return members, nil
|
||||
}
|
||||
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact.json", url.PathEscape(oppID)))
|
||||
}
|
||||
|
||||
// RemoveOpportunityMember detaches a contact from an opportunity.
|
||||
func (c *Client) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// IsOpportunityMember reports whether contactID is already on the opportunity.
|
||||
func (c *Client) IsOpportunityMember(ctx context.Context, oppID, contactID string) (bool, error) {
|
||||
members, err := c.ListOpportunityMembers(ctx, oppID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
want := flexInt(contactID)
|
||||
for _, m := range members {
|
||||
if flexInt(m["id"]) == want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// UpdateOpportunityTitle renames a deal; loads full record and PUTs it back.
|
||||
func (c *Client) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
|
||||
opp, err := c.GetOpportunity(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := opportunityUpdateBody(opp, newTitle)
|
||||
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
|
||||
}
|
||||
|
||||
func opportunityUpdateBody(opp map[string]any, title string) map[string]any {
|
||||
body := map[string]any{
|
||||
"opportunityid": flexInt(opp["id"]),
|
||||
"title": title,
|
||||
"description": stringField(opp, "description"),
|
||||
"isPrivate": boolField(opp, "isPrivate"),
|
||||
"isNotify": false,
|
||||
}
|
||||
if stage, ok := opp["stage"].(map[string]any); ok {
|
||||
body["stageid"] = flexInt(stage["id"])
|
||||
}
|
||||
if resp, ok := opp["responsible"].(map[string]any); ok {
|
||||
body["responsibleid"] = fmt.Sprint(resp["id"])
|
||||
}
|
||||
if cur, ok := opp["bidCurrency"].(map[string]any); ok {
|
||||
body["bidCurrencyAbbr"] = stringField(cur, "abbreviation")
|
||||
} else {
|
||||
body["bidCurrencyAbbr"] = "EUR"
|
||||
}
|
||||
body["bidValue"] = floatField(opp, "bidValue")
|
||||
body["bidType"] = 0
|
||||
body["perPeriodValue"] = 0
|
||||
body["successProbability"] = 1
|
||||
var memberIDs []int64
|
||||
seen := make(map[int64]bool)
|
||||
for _, m := range OpportunityMembers(opp) {
|
||||
id := flexInt(m["id"])
|
||||
if id == 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
memberIDs = append(memberIDs, id)
|
||||
}
|
||||
if len(memberIDs) > 0 {
|
||||
body["members"] = memberIDs
|
||||
body["contactid"] = memberIDs[0]
|
||||
}
|
||||
if al, ok := opp["accessList"].([]any); ok && len(al) > 0 {
|
||||
body["accessList"] = al
|
||||
} else {
|
||||
body["accessList"] = []any{}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func stringField(m map[string]any, key string) string {
|
||||
v := fmt.Sprint(m[key])
|
||||
if v == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boolField(m map[string]any, key string) bool {
|
||||
v, _ := m[key].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func floatField(m map[string]any, key string) float64 {
|
||||
switch x := m[key].(type) {
|
||||
case float64:
|
||||
return x
|
||||
case int:
|
||||
return float64(x)
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// ListCases returns a page of CRM cases and the total count.
|
||||
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DedupeResult summarizes a cleanup pass.
|
||||
type DedupeResult struct {
|
||||
Groups int `json:"groups"`
|
||||
Merged int `json:"merged"`
|
||||
Deleted int `json:"deleted"`
|
||||
Renamed int `json:"renamed"`
|
||||
Removed int `json:"removed"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func (r *DedupeResult) addErr(err error) {
|
||||
if err != nil {
|
||||
r.Errors = append(r.Errors, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type crmDedupeClient interface {
|
||||
ListAllContacts(ctx context.Context) ([]map[string]any, error)
|
||||
ListAllOpportunities(ctx context.Context) ([]map[string]any, error)
|
||||
GetContact(ctx context.Context, contactID string) (map[string]any, error)
|
||||
GetOpportunity(ctx context.Context, id string) (map[string]any, error)
|
||||
MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error)
|
||||
DeleteOpportunity(ctx context.Context, id string) (map[string]any, error)
|
||||
DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error)
|
||||
ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error)
|
||||
AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
|
||||
RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
|
||||
UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error)
|
||||
}
|
||||
|
||||
func executeMergePlans(ctx context.Context, client crmDedupeClient, plans []MergePlan, res *DedupeResult) {
|
||||
for _, plan := range plans {
|
||||
res.Groups++
|
||||
for _, sec := range plan.Secondary {
|
||||
_, err := client.MergeContacts(ctx, strconv.FormatInt(plan.Primary, 10), strconv.FormatInt(sec, 10))
|
||||
if err != nil {
|
||||
res.addErr(fmt.Errorf("merge %d into %d: %w", sec, plan.Primary, err))
|
||||
continue
|
||||
}
|
||||
res.Merged++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DedupeCompanies merges duplicate company contacts by normalized name.
|
||||
func DedupeCompanies(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
plans := BuildMergePlans(GroupCompaniesByName(items))
|
||||
executeMergePlans(ctx, client, plans, &res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DedupePersons merges duplicate person contacts by normalized first+last.
|
||||
func DedupePersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
plans := BuildMergePlans(GroupPersonsByKey(items))
|
||||
executeMergePlans(ctx, client, plans, &res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DedupeCompanyPersons merges same-name persons within each company.
|
||||
func DedupeCompanyPersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
grouped := GroupCompanyPersons(items)
|
||||
for companyID, byName := range grouped {
|
||||
plans := BuildMergePlans(byName)
|
||||
if len(plans) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = companyID
|
||||
executeMergePlans(ctx, client, plans, &res)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DedupeContactInfo removes duplicate email/phone/etc rows on all contacts.
|
||||
func DedupeContactInfo(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, row := range items {
|
||||
cid := strconv.FormatInt(rowID(row), 10)
|
||||
contact, err := client.GetContact(ctx, cid)
|
||||
if err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
rows := ContactInfoRows(contact)
|
||||
for _, dataID := range GroupContactInfoRows(rows) {
|
||||
_, err := client.DeleteContactInfo(ctx, cid, strconv.FormatInt(dataID, 10))
|
||||
if err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
res.Removed++
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DedupeOpportunities merges duplicate deals by title; relinks members first.
|
||||
func DedupeOpportunities(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllOpportunities(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
groups := GroupOpportunitiesByTitle(items, ignoreCompanySuffix)
|
||||
for _, rows := range groups {
|
||||
if len(rows) < 2 {
|
||||
continue
|
||||
}
|
||||
res.Groups++
|
||||
ids := make([]int64, len(rows))
|
||||
for i, row := range rows {
|
||||
ids[i] = rowID(row)
|
||||
}
|
||||
primary := PickCanonicalID(ids)
|
||||
primaryID := strconv.FormatInt(primary, 10)
|
||||
for _, row := range rows {
|
||||
sec := rowID(row)
|
||||
if sec == primary {
|
||||
continue
|
||||
}
|
||||
secID := strconv.FormatInt(sec, 10)
|
||||
opp, err := client.GetOpportunity(ctx, secID)
|
||||
if err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
for _, m := range OpportunityMembers(opp) {
|
||||
mid := strconv.FormatInt(rowID(m), 10)
|
||||
_, _ = client.AddOpportunityMember(ctx, primaryID, mid)
|
||||
}
|
||||
if _, err := client.DeleteOpportunity(ctx, secID); err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
res.Deleted++
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DedupeOpportunityMembers removes duplicate members on each deal (by id and displayName).
|
||||
func DedupeOpportunityMembers(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllOpportunities(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, row := range items {
|
||||
oppID := strconv.FormatInt(rowID(row), 10)
|
||||
opp, err := client.GetOpportunity(ctx, oppID)
|
||||
if err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
members := OpportunityMembers(opp)
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
var ids []int64
|
||||
for _, m := range members {
|
||||
ids = append(ids, rowID(m))
|
||||
}
|
||||
remove := append(DedupeMemberIDs(ids), DedupeMembersByDisplayName(members)...)
|
||||
seen := make(map[int64]bool)
|
||||
for _, contactID := range remove {
|
||||
if contactID == 0 || seen[contactID] {
|
||||
continue
|
||||
}
|
||||
seen[contactID] = true
|
||||
if _, err := client.RemoveOpportunityMember(ctx, oppID, strconv.FormatInt(contactID, 10)); err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
res.Removed++
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// FixOpportunityTitles renames deals with malformed titles.
|
||||
func FixOpportunityTitles(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
|
||||
var res DedupeResult
|
||||
items, err := client.ListAllOpportunities(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, row := range items {
|
||||
old := fmt.Sprint(row["title"])
|
||||
newTitle := FixDealTitle(old)
|
||||
if newTitle == old || newTitle == "" {
|
||||
continue
|
||||
}
|
||||
oppID := strconv.FormatInt(rowID(row), 10)
|
||||
if _, err := client.UpdateOpportunityTitle(ctx, oppID, newTitle); err != nil {
|
||||
res.addErr(err)
|
||||
continue
|
||||
}
|
||||
res.Renamed++
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// CleanupCRM runs all dedupe passes in dependency order.
|
||||
func CleanupCRM(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (map[string]DedupeResult, error) {
|
||||
out := make(map[string]DedupeResult)
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func(context.Context, crmDedupeClient) (DedupeResult, error)
|
||||
}{
|
||||
{"companies", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
|
||||
return DedupeCompanies(ctx, c)
|
||||
}},
|
||||
{"persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
|
||||
return DedupePersons(ctx, c)
|
||||
}},
|
||||
{"company-persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
|
||||
return DedupeCompanyPersons(ctx, c)
|
||||
}},
|
||||
{"contact-info", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
|
||||
return DedupeContactInfo(ctx, c)
|
||||
}},
|
||||
{"opportunity-members", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
|
||||
return DedupeOpportunityMembers(ctx, c)
|
||||
}},
|
||||
}
|
||||
for _, step := range steps {
|
||||
r, err := step.fn(ctx, client)
|
||||
out[step.name] = r
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
r, err := DedupeOpportunities(ctx, client, ignoreCompanySuffix)
|
||||
out["opportunities"] = r
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
r, err = FixOpportunityTitles(ctx, client)
|
||||
out["fix-titles"] = r
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ crmDedupeClient = (*Client)(nil)
|
||||
@@ -0,0 +1,232 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PickCanonicalID returns the lowest non-zero id, or 0 when empty.
|
||||
func PickCanonicalID(ids []int64) int64 {
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
func rowID(m map[string]any) int64 {
|
||||
return flexInt(m["id"])
|
||||
}
|
||||
|
||||
func rowDisplayName(m map[string]any) string {
|
||||
if v := strings.TrimSpace(fmt.Sprint(m["displayName"])); v != "" && v != "<nil>" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(fmt.Sprint(m["companyName"])); v != "" && v != "<nil>" {
|
||||
return v
|
||||
}
|
||||
first := strings.TrimSpace(fmt.Sprint(m["firstName"]))
|
||||
last := strings.TrimSpace(fmt.Sprint(m["lastName"]))
|
||||
return strings.TrimSpace(first + " " + last)
|
||||
}
|
||||
|
||||
// GroupCompaniesByName buckets company contacts by normalized display name.
|
||||
func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any {
|
||||
out := make(map[string][]map[string]any)
|
||||
for _, row := range items {
|
||||
if !isCompany(row) {
|
||||
continue
|
||||
}
|
||||
key := NormalizeCompanyName(rowDisplayName(row))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = append(out[key], row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupPersonsByKey buckets person contacts by normalized first+last.
|
||||
func GroupPersonsByKey(items []map[string]any) map[string][]map[string]any {
|
||||
out := make(map[string][]map[string]any)
|
||||
for _, row := range items {
|
||||
if isCompany(row) {
|
||||
continue
|
||||
}
|
||||
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = append(out[key], row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DealTitleKey returns the grouping key for an opportunity title.
|
||||
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
|
||||
if ignoreCompanySuffix {
|
||||
return NormalizeOpportunityTitle(StripCompanySuffix(title))
|
||||
}
|
||||
return NormalizeOpportunityTitle(title)
|
||||
}
|
||||
|
||||
// GroupOpportunitiesByTitle buckets deals by title key.
|
||||
func GroupOpportunitiesByTitle(items []map[string]any, ignoreCompanySuffix bool) map[string][]map[string]any {
|
||||
out := make(map[string][]map[string]any)
|
||||
for _, row := range items {
|
||||
title := fmt.Sprint(row["title"])
|
||||
key := DealTitleKey(title, ignoreCompanySuffix)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = append(out[key], row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergePlan lists secondary ids to merge into primary.
|
||||
type MergePlan struct {
|
||||
Primary int64
|
||||
Secondary []int64
|
||||
}
|
||||
|
||||
// BuildMergePlans creates merge plans from duplicate groups (lowest id wins).
|
||||
func BuildMergePlans(groups map[string][]map[string]any) []MergePlan {
|
||||
var plans []MergePlan
|
||||
for _, rows := range groups {
|
||||
if len(rows) < 2 {
|
||||
continue
|
||||
}
|
||||
ids := make([]int64, len(rows))
|
||||
for i, row := range rows {
|
||||
ids[i] = rowID(row)
|
||||
}
|
||||
primary := PickCanonicalID(ids)
|
||||
var secondary []int64
|
||||
for _, id := range ids {
|
||||
if id != primary {
|
||||
secondary = append(secondary, id)
|
||||
}
|
||||
}
|
||||
plans = append(plans, MergePlan{Primary: primary, Secondary: secondary})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
// DedupeMemberIDs returns duplicate member ids to remove (keep first occurrence).
|
||||
func DedupeMemberIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]int)
|
||||
var remove []int64
|
||||
for _, id := range ids {
|
||||
seen[id]++
|
||||
if seen[id] > 1 {
|
||||
remove = append(remove, id)
|
||||
}
|
||||
}
|
||||
return remove
|
||||
}
|
||||
|
||||
// DedupeMembersByDisplayName returns member ids to remove when the same
|
||||
// displayName appears with different ids (keep lowest id per name).
|
||||
func DedupeMembersByDisplayName(members []map[string]any) []int64 {
|
||||
type slot struct {
|
||||
id int64
|
||||
}
|
||||
byName := make(map[string][]slot)
|
||||
for _, m := range members {
|
||||
key := MemberDisplayKey(rowDisplayName(m))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
byName[key] = append(byName[key], slot{id: rowID(m)})
|
||||
}
|
||||
var remove []int64
|
||||
for _, slots := range byName {
|
||||
if len(slots) < 2 {
|
||||
continue
|
||||
}
|
||||
ids := make([]int64, len(slots))
|
||||
for i, s := range slots {
|
||||
ids[i] = s.id
|
||||
}
|
||||
keep := PickCanonicalID(ids)
|
||||
for _, s := range slots {
|
||||
if s.id != keep {
|
||||
remove = append(remove, s.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return remove
|
||||
}
|
||||
|
||||
// GroupContactInfoRows returns info row ids to delete (duplicates by type+value).
|
||||
func GroupContactInfoRows(rows []map[string]any) []int64 {
|
||||
type rowSlot struct {
|
||||
id int64
|
||||
isPrimary bool
|
||||
}
|
||||
byKey := make(map[string][]rowSlot)
|
||||
for _, row := range rows {
|
||||
infoType := fmt.Sprint(row["infoType"])
|
||||
value := fmt.Sprint(row["data"])
|
||||
if value == "" || value == "<nil>" {
|
||||
value = fmt.Sprint(row["value"])
|
||||
}
|
||||
key := ContactInfoKey(infoType, value)
|
||||
if key == "|" || value == "" || value == "<nil>" {
|
||||
continue
|
||||
}
|
||||
primary, _ := row["isPrimary"].(bool)
|
||||
byKey[key] = append(byKey[key], rowSlot{id: rowID(row), isPrimary: primary})
|
||||
}
|
||||
var remove []int64
|
||||
for _, slots := range byKey {
|
||||
if len(slots) < 2 {
|
||||
continue
|
||||
}
|
||||
keep := int64(0)
|
||||
for _, s := range slots {
|
||||
if s.isPrimary {
|
||||
keep = s.id
|
||||
break
|
||||
}
|
||||
}
|
||||
if keep == 0 {
|
||||
ids := make([]int64, len(slots))
|
||||
for i, s := range slots {
|
||||
ids[i] = s.id
|
||||
}
|
||||
keep = PickCanonicalID(ids)
|
||||
}
|
||||
for _, s := range slots {
|
||||
if s.id != keep {
|
||||
remove = append(remove, s.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return remove
|
||||
}
|
||||
|
||||
// GroupCompanyPersons groups persons by company id and person name key.
|
||||
func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[string]any {
|
||||
out := make(map[int64]map[string][]map[string]any)
|
||||
for _, row := range persons {
|
||||
if isCompany(row) {
|
||||
continue
|
||||
}
|
||||
cid := flexInt(row["companyId"])
|
||||
if cid == 0 {
|
||||
continue
|
||||
}
|
||||
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if out[cid] == nil {
|
||||
out[cid] = make(map[string][]map[string]any)
|
||||
}
|
||||
out[cid][key] = append(out[cid][key], row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPickCanonicalID(t *testing.T) {
|
||||
if got := PickCanonicalID([]int64{908, 857, 900}); got != 857 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
if got := PickCanonicalID(nil); got != 0 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupCompaniesByName(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"id": float64(857), "displayName": "711media", "isCompany": true},
|
||||
{"id": float64(908), "displayName": "711media", "isCompany": true},
|
||||
{"id": float64(1), "displayName": "Acme", "isCompany": true},
|
||||
}
|
||||
groups := GroupCompaniesByName(items)
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("groups: %d", len(groups))
|
||||
}
|
||||
key := NormalizeCompanyName("711media")
|
||||
if len(groups[key]) != 2 {
|
||||
t.Fatalf("711media group: %d", len(groups[key]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeMembersByDisplayName(t *testing.T) {
|
||||
members := []map[string]any{
|
||||
{"id": float64(857), "displayName": "711media"},
|
||||
{"id": float64(908), "displayName": "711media"},
|
||||
{"id": float64(10), "displayName": "Acme"},
|
||||
}
|
||||
remove := DedupeMembersByDisplayName(members)
|
||||
want := []int64{908}
|
||||
if !reflect.DeepEqual(remove, want) {
|
||||
t.Fatalf("remove %v want %v", remove, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeMemberIDs(t *testing.T) {
|
||||
remove := DedupeMemberIDs([]int64{1, 2, 2, 3, 1})
|
||||
if !reflect.DeepEqual(remove, []int64{2, 1}) {
|
||||
t.Fatalf("got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupContactInfoRows(t *testing.T) {
|
||||
rows := []map[string]any{
|
||||
{"id": float64(1), "infoType": "Email", "data": "a@b.com", "isPrimary": false},
|
||||
{"id": float64(2), "infoType": "Email", "data": "a@b.com", "isPrimary": true},
|
||||
}
|
||||
remove := GroupContactInfoRows(rows)
|
||||
if len(remove) != 1 || remove[0] != 1 {
|
||||
t.Fatalf("remove %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealTitleKey(t *testing.T) {
|
||||
if got := DealTitleKey("Dev @ Acme", false); got != NormalizeOpportunityTitle("Dev @ Acme") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeDedupeClient struct {
|
||||
contacts []map[string]any
|
||||
opportunities []map[string]any
|
||||
contactByID map[string]map[string]any
|
||||
oppByID map[string]map[string]any
|
||||
merged [][2]string
|
||||
deletedOpp []string
|
||||
deletedInfo [][2]string
|
||||
removedMember [][2]string
|
||||
renamed map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
||||
return f.contacts, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
|
||||
return f.opportunities, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
|
||||
return f.contactByID[contactID], nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
||||
return f.oppByID[id], nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
|
||||
f.merged = append(f.merged, [2]string{primaryID, secondaryID})
|
||||
return map[string]any{"id": primaryID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
||||
f.deletedOpp = append(f.deletedOpp, id)
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
|
||||
f.deletedInfo = append(f.deletedInfo, [2]string{contactID, dataID})
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
||||
f.removedMember = append(f.removedMember, [2]string{oppID, contactID})
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDedupeClient) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
|
||||
if f.renamed == nil {
|
||||
f.renamed = make(map[string]string)
|
||||
}
|
||||
f.renamed[id] = newTitle
|
||||
return map[string]any{"title": newTitle}, nil
|
||||
}
|
||||
|
||||
func TestDedupeCompaniesOrchestration(t *testing.T) {
|
||||
f := &fakeDedupeClient{
|
||||
contacts: []map[string]any{
|
||||
{"id": float64(857), "displayName": "711media", "isCompany": true},
|
||||
{"id": float64(908), "displayName": "711media", "isCompany": true},
|
||||
},
|
||||
}
|
||||
res, err := DedupeCompanies(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 1 || res.Groups != 1 {
|
||||
t.Fatalf("res %+v", res)
|
||||
}
|
||||
want := [][2]string{{"857", "908"}}
|
||||
if !reflect.DeepEqual(f.merged, want) {
|
||||
t.Fatalf("merged %v", f.merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeOpportunityMembersOrchestration(t *testing.T) {
|
||||
f := &fakeDedupeClient{
|
||||
opportunities: []map[string]any{{"id": float64(231), "title": " @ 711media"}},
|
||||
oppByID: map[string]map[string]any{
|
||||
"231": {
|
||||
"id": float64(231),
|
||||
"members": []any{
|
||||
map[string]any{"id": float64(857), "displayName": "711media"},
|
||||
map[string]any{"id": float64(908), "displayName": "711media"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
res, err := DedupeOpportunityMembers(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Removed != 1 {
|
||||
t.Fatalf("res %+v", res)
|
||||
}
|
||||
if len(f.removedMember) != 1 || f.removedMember[0][1] != "908" {
|
||||
t.Fatalf("removed %v", f.removedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixOpportunityTitlesOrchestration(t *testing.T) {
|
||||
f := &fakeDedupeClient{
|
||||
opportunities: []map[string]any{{"id": float64(231), "title": " @ 711media"}},
|
||||
oppByID: map[string]map[string]any{"231": {"id": float64(231), "title": " @ 711media"}},
|
||||
}
|
||||
res, err := FixOpportunityTitles(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Renamed != 1 {
|
||||
t.Fatalf("res %+v", res)
|
||||
}
|
||||
if f.renamed["231"] != "711media" {
|
||||
t.Fatalf("renamed %v", f.renamed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build integration
|
||||
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testCRMPrefix = "go-onlyoffice-test-"
|
||||
|
||||
func TestIntegrationMergeContacts(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx := context.Background()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
name := testCRMPrefix + "merge-" + suffix
|
||||
|
||||
a, err := c.CreateCompany(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCompany a: %v", err)
|
||||
}
|
||||
b, err := c.CreateCompany(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCompany b: %v", err)
|
||||
}
|
||||
aID := strconv.FormatInt(flexInt(a["id"]), 10)
|
||||
bID := strconv.FormatInt(flexInt(b["id"]), 10)
|
||||
t.Cleanup(func() { _, _ = c.DeleteContact(ctx, aID) })
|
||||
|
||||
if _, err := c.MergeContacts(ctx, aID, bID); err != nil {
|
||||
t.Fatalf("MergeContacts: %v", err)
|
||||
}
|
||||
got, err := c.GetContact(ctx, aID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("primary contact missing after merge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationUpdateOpportunityTitle(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx := context.Background()
|
||||
stages, err := c.ListDealStages(ctx)
|
||||
if err != nil || len(stages) == 0 {
|
||||
t.Fatalf("ListDealStages: %v", err)
|
||||
}
|
||||
stageID := int(flexInt(stages[0]["id"]))
|
||||
title := testCRMPrefix + "title-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
opp, err := c.CreateOpportunity(ctx, title, stageID, "", "EUR", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOpportunity: %v", err)
|
||||
}
|
||||
id := strconv.FormatInt(flexInt(opp["id"]), 10)
|
||||
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
|
||||
|
||||
newTitle := title + "-renamed"
|
||||
if _, err := c.UpdateOpportunityTitle(ctx, id, newTitle); err != nil {
|
||||
t.Fatalf("UpdateOpportunityTitle: %v", err)
|
||||
}
|
||||
got, err := c.GetOpportunity(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOpportunity: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(got["title"])) != newTitle {
|
||||
t.Fatalf("title %q want %q", got["title"], newTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDedupeCompaniesSmoke(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx := context.Background()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
name := testCRMPrefix + "dedupe-" + suffix
|
||||
co1, err := c.CreateCompany(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCompany: %v", err)
|
||||
}
|
||||
co2, err := c.CreateCompany(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCompany: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
id1 := strconv.FormatInt(flexInt(co1["id"]), 10)
|
||||
_, _ = c.DeleteContact(ctx, id1)
|
||||
})
|
||||
|
||||
res, err := DedupeCompanies(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatalf("DedupeCompanies: %v", err)
|
||||
}
|
||||
if res.Merged < 1 {
|
||||
t.Fatalf("expected merge, got %+v", res)
|
||||
}
|
||||
_ = co2
|
||||
}
|
||||
|
||||
func TestIntegrationFixOpportunityTitleDeal231Pattern(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx := context.Background()
|
||||
stages, err := c.ListDealStages(ctx)
|
||||
if err != nil || len(stages) == 0 {
|
||||
t.Fatalf("ListDealStages: %v", err)
|
||||
}
|
||||
stageID := int(flexInt(stages[0]["id"]))
|
||||
opp, err := c.CreateOpportunity(ctx, " @ 711media-test", stageID, "", "EUR", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOpportunity: %v", err)
|
||||
}
|
||||
id := strconv.FormatInt(flexInt(opp["id"]), 10)
|
||||
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
|
||||
|
||||
fixed := FixDealTitle(" @ 711media-test")
|
||||
if _, err := c.UpdateOpportunityTitle(ctx, id, fixed); err != nil {
|
||||
t.Fatalf("UpdateOpportunityTitle: %v", err)
|
||||
}
|
||||
got, err := c.GetOpportunity(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(got["title"]) != "711media-test" {
|
||||
t.Fatalf("title %q", got["title"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var multiSpace = regexp.MustCompile(`\s+`)
|
||||
|
||||
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
|
||||
func NormalizeCompanyName(s string) string {
|
||||
return collapseKey(s)
|
||||
}
|
||||
|
||||
// NormalizePersonKey builds a grouping key from first and last name.
|
||||
func NormalizePersonKey(first, last string) string {
|
||||
return collapseKey(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
|
||||
}
|
||||
|
||||
// NormalizeOpportunityTitle lowercases and trims a deal title for exact dedupe.
|
||||
func NormalizeOpportunityTitle(s string) string {
|
||||
return collapseKey(s)
|
||||
}
|
||||
|
||||
// StripCompanySuffix removes a trailing " @ Company" segment when present.
|
||||
func StripCompanySuffix(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if i := strings.LastIndex(title, " @ "); i >= 0 {
|
||||
return strings.TrimSpace(title[:i])
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
|
||||
// empty-position titles like " @ 711media" to "711media".
|
||||
func FixDealTitle(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for strings.HasPrefix(s, "@") {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
|
||||
}
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.Index(s, "@"); i >= 0 {
|
||||
left := strings.TrimSpace(s[:i])
|
||||
right := strings.TrimSpace(s[i+1:])
|
||||
if left == "" && right != "" {
|
||||
return right
|
||||
}
|
||||
if left != "" && right != "" {
|
||||
return left + " @ " + right
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ContactInfoKey groups contact info rows by type and normalized value.
|
||||
func ContactInfoKey(infoType, value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
||||
func MemberDisplayKey(displayName string) string {
|
||||
return NormalizeCompanyName(displayName)
|
||||
}
|
||||
|
||||
func collapseKey(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = multiSpace.ReplaceAllString(s, " ")
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// DealTitleForApplication builds the sync deal title from position and company.
|
||||
func DealTitleForApplication(position, company string) string {
|
||||
position = strings.TrimSpace(position)
|
||||
company = strings.TrimSpace(company)
|
||||
if company == "" {
|
||||
return position
|
||||
}
|
||||
if position == "" {
|
||||
return company
|
||||
}
|
||||
return position + " @ " + company
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package onlyoffice
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeCompanyName(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{" 711media ", "711media"},
|
||||
{"711Media", "711media"},
|
||||
{"Acme Corp", "acme corp"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := NormalizeCompanyName(tc.in); got != tc.want {
|
||||
t.Errorf("NormalizeCompanyName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePersonKey(t *testing.T) {
|
||||
if got := NormalizePersonKey(" Jane ", " Doe "); got != "jane doe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixDealTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{" @ 711media", "711media"},
|
||||
{"@ 711media", "711media"},
|
||||
{"@Acme", "Acme"},
|
||||
{"Dev@Acme", "Dev @ Acme"},
|
||||
{"Dev @ Acme", "Dev @ Acme"},
|
||||
{"Senior Dev @ Acme", "Senior Dev @ Acme"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := FixDealTitle(tc.in); got != tc.want {
|
||||
t.Errorf("FixDealTitle(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripCompanySuffix(t *testing.T) {
|
||||
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := StripCompanySuffix("Dev"); got != "Dev" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactInfoKey(t *testing.T) {
|
||||
if got := ContactInfoKey("Email", " A@B.COM "); got != "email|a@b.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberDisplayKey(t *testing.T) {
|
||||
if got := MemberDisplayKey(" 711media "); got != "711media" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealTitleForApplication(t *testing.T) {
|
||||
if got := DealTitleForApplication("", "711media"); got != "711media" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,15 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// putJSONObject issues an authenticated PUT with JSON body and decodes response.
|
||||
func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
||||
raw, err := c.putJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalResponseObject(raw)
|
||||
}
|
||||
|
||||
// putJSON issues an authenticated PUT with application/json body.
|
||||
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||
auth, err := c.authHeader()
|
||||
|
||||
Reference in New Issue
Block a user