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).
|
# does not load .env files — only its CLI does, via godotenv).
|
||||||
#
|
#
|
||||||
# Primary names:
|
# Primary names:
|
||||||
@@ -10,6 +10,11 @@ ONLYOFFICE_PASS=
|
|||||||
# ONLYOFFICE_HOST=
|
# ONLYOFFICE_HOST=
|
||||||
# ONLYOFFICE_NAME=
|
# ONLYOFFICE_NAME=
|
||||||
# ONLYOFFICE_PASSWORD=
|
# ONLYOFFICE_PASSWORD=
|
||||||
|
#
|
||||||
|
# produktor.io operator aliases (CLI-only):
|
||||||
|
# OO_URL=
|
||||||
|
# OO_USER=
|
||||||
|
# OO_PASS=
|
||||||
|
|
||||||
# Optional defaults for calendar/project helpers:
|
# Optional defaults for calendar/project helpers:
|
||||||
ONLYOFFICE_CALENDAR_ID=1
|
ONLYOFFICE_CALENDAR_ID=1
|
||||||
|
|||||||
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.0] - 2026-04-24
|
||||||
|
|
||||||
### Added — library
|
### Added — library
|
||||||
|
|||||||
@@ -580,14 +580,23 @@ oo tasks files detach 208 12345
|
|||||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
||||||
| `persons` | `list` (filtered), `create`, `delete` |
|
| `persons` | `list` (filtered), `create`, `delete` |
|
||||||
| `companies` | `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` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||||
| `applications` | `sync` |
|
| `applications` | `sync` |
|
||||||
|
|
||||||
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
|
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||||
itself never loads dotfiles). Run `oo --help` or `oo <subject> --help` for
|
CLI-only concern — the library itself never loads dotfiles).
|
||||||
the full command reference.
|
|
||||||
|
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
|
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||||
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
|
> 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_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
| `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
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// execute runs the root command. Exported only to main.go in the same package.
|
||||||
func execute() error { return rootCmd.Execute() }
|
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.
|
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
|
||||||
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
|
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
|
||||||
_ = godotenv.Load()
|
loadOOEnv()
|
||||||
creds := onlyoffice.GetEnvironmentCredentials()
|
creds := onlyoffice.GetEnvironmentCredentials()
|
||||||
if creds.Url == "" || creds.User == "" || creds.Password == "" {
|
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 := onlyoffice.NewClient(creds)
|
||||||
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||||
@@ -48,6 +48,26 @@ func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
|
|||||||
return c, nil
|
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.
|
// printJSON dumps any value as indented JSON.
|
||||||
func printJSON(v any) {
|
func printJSON(v any) {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
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.example.com\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.example.com" {
|
||||||
|
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"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,16 +40,20 @@ func init() {
|
|||||||
contactsCmd.AddCommand(contactsGetCmd())
|
contactsCmd.AddCommand(contactsGetCmd())
|
||||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||||
|
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||||
|
|
||||||
only := true
|
only := true
|
||||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||||
personsCmd.AddCommand(personsCreateCmd())
|
personsCmd.AddCommand(personsCreateCmd())
|
||||||
personsCmd.AddCommand(contactsDeleteCmd())
|
personsCmd.AddCommand(contactsDeleteCmd())
|
||||||
|
personsCmd.AddCommand(personsDedupeCmd())
|
||||||
|
|
||||||
onlyCo := false
|
onlyCo := false
|
||||||
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
|
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
|
||||||
companiesCmd.AddCommand(companiesCreateCmd())
|
companiesCmd.AddCommand(companiesCreateCmd())
|
||||||
companiesCmd.AddCommand(contactsDeleteCmd())
|
companiesCmd.AddCommand(contactsDeleteCmd())
|
||||||
|
companiesCmd.AddCommand(companiesDedupeCmd())
|
||||||
|
companiesCmd.AddCommand(companiesDedupePersonsCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// contactsListCmd returns a `list` subcommand.
|
// contactsListCmd returns a `list` subcommand.
|
||||||
@@ -246,3 +251,63 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
||||||
return cmd
|
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 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 tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||||
// oo users list | self (alias: oo whoami)
|
// oo users list | self (alias: oo whoami)
|
||||||
// oo contacts list | get | delete | info-add
|
// oo contacts list | get | delete | info-add | dedupe-info
|
||||||
// oo persons list | create | delete
|
// oo persons list | create | delete | dedupe
|
||||||
// oo companies list | create | delete
|
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||||
// oo opportunities list | get | create | delete | stages | member-add
|
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||||
// oo cases list | create | delete | member-add
|
// oo cases list | create | delete | member-add
|
||||||
// oo crm-tasks list | create | delete | categories
|
// oo crm-tasks list | create | delete | categories
|
||||||
|
// oo crm cleanup
|
||||||
// oo applications sync
|
// oo applications sync
|
||||||
//
|
//
|
||||||
// Every list supports `--output/-o json|table` (table is the default).
|
// Every list supports `--output/-o json|table` (table is the default).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ func init() {
|
|||||||
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
||||||
opportunitiesCmd.AddCommand(oppStagesCmd())
|
opportunitiesCmd.AddCommand(oppStagesCmd())
|
||||||
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
||||||
|
opportunitiesCmd.AddCommand(oppDedupeCmd())
|
||||||
|
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
|
||||||
|
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
func oppListCmd() *cobra.Command {
|
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)
|
// 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) {
|
||||||
items, _, err := c.ListContacts(ctx, 50, 0, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
needle := strings.ToLower(strings.TrimSpace(name))
|
needle := strings.ToLower(strings.TrimSpace(name))
|
||||||
for _, co := range items {
|
const page = 50
|
||||||
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
|
for start := 0; ; start += page {
|
||||||
return co, nil
|
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
|
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).
|
// FindPerson searches for a person by first+last (case-insensitive).
|
||||||
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
|
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
|
||||||
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last)
|
firstNeedle := strings.ToLower(strings.TrimSpace(first))
|
||||||
if err != nil {
|
lastNeedle := strings.ToLower(strings.TrimSpace(last))
|
||||||
return nil, err
|
const page = 50
|
||||||
}
|
for start := 0; ; start += page {
|
||||||
first = strings.ToLower(first)
|
items, total, err := c.ListContacts(ctx, page, start, first+" "+last)
|
||||||
last = strings.ToLower(last)
|
if err != nil {
|
||||||
for _, p := range items {
|
return nil, err
|
||||||
if isCompany(p) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if strings.ToLower(fmt.Sprint(p["firstName"])) == first &&
|
for _, p := range items {
|
||||||
strings.ToLower(fmt.Sprint(p["lastName"])) == last {
|
if isCompany(p) {
|
||||||
return p, nil
|
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
|
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)))
|
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.
|
// 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) {
|
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
q := url.Values{}
|
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)))
|
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.
|
// 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) {
|
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
q := url.Values{}
|
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": "contoso", "isCompany": true},
|
||||||
|
{"id": float64(908), "displayName": "contoso", "isCompany": true},
|
||||||
|
{"id": float64(1), "displayName": "Acme", "isCompany": true},
|
||||||
|
}
|
||||||
|
groups := GroupCompaniesByName(items)
|
||||||
|
if len(groups) != 2 {
|
||||||
|
t.Fatalf("groups: %d", len(groups))
|
||||||
|
}
|
||||||
|
key := NormalizeCompanyName("contoso")
|
||||||
|
if len(groups[key]) != 2 {
|
||||||
|
t.Fatalf("contoso group: %d", len(groups[key]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupeMembersByDisplayName(t *testing.T) {
|
||||||
|
members := []map[string]any{
|
||||||
|
{"id": float64(857), "displayName": "contoso"},
|
||||||
|
{"id": float64(908), "displayName": "contoso"},
|
||||||
|
{"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": "contoso", "isCompany": true},
|
||||||
|
{"id": float64(908), "displayName": "contoso", "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": " @ contoso"}},
|
||||||
|
oppByID: map[string]map[string]any{
|
||||||
|
"231": {
|
||||||
|
"id": float64(231),
|
||||||
|
"members": []any{
|
||||||
|
map[string]any{"id": float64(857), "displayName": "contoso"},
|
||||||
|
map[string]any{"id": float64(908), "displayName": "contoso"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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": " @ contoso"}},
|
||||||
|
oppByID: map[string]map[string]any{"231": {"id": float64(231), "title": " @ contoso"}},
|
||||||
|
}
|
||||||
|
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"] != "contoso" {
|
||||||
|
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, " @ contoso-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(" @ contoso-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"]) != "contoso-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 " @ contoso" to "contoso".
|
||||||
|
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
|
||||||
|
}{
|
||||||
|
{" contoso ", "contoso"},
|
||||||
|
{"Contoso", "contoso"},
|
||||||
|
{"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
|
||||||
|
}{
|
||||||
|
{" @ contoso", "contoso"},
|
||||||
|
{"@ contoso", "contoso"},
|
||||||
|
{"@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(" contoso "); got != "contoso" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDealTitleForApplication(t *testing.T) {
|
||||||
|
if got := DealTitleForApplication("", "contoso"); got != "contoso" {
|
||||||
|
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
|
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.
|
// putJSON issues an authenticated PUT with application/json body.
|
||||||
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
auth, err := c.authHeader()
|
auth, err := c.authHeader()
|
||||||
|
|||||||
Reference in New Issue
Block a user