fix(crm): clean person names; canonical project titles

Strip company annotations from lastName, derive names from email locals,
UpdatePerson on apply with companyId. Add persons fix-names, contacts
merge, projects create --country/--company (CC | Company | Title).
Fix project update (responsibleId) and company-person dedupe grouping.
This commit is contained in:
2026-07-26 13:11:54 +01:00
parent b334f9ff86
commit 179f30644d
6 changed files with 261 additions and 12 deletions
+129
View File
@@ -3,8 +3,10 @@ package main
import (
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/catalog"
"github.com/spf13/cobra"
)
@@ -39,12 +41,14 @@ func init() {
contactsCmd.AddCommand(contactsListCmd(nil))
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsMergeCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(personsFixNamesCmd())
personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
@@ -146,6 +150,26 @@ func contactsDeleteCmd() *cobra.Command {
}
}
func contactsMergeCmd() *cobra.Command {
return &cobra.Command{
Use: "merge FROM_ID INTO_ID",
Short: "Merge FROM contact into INTO (FROM is removed)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.MergeContacts(cmd.Context(), args[1], args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func contactsInfoAddCmd() *cobra.Command {
var infoType, value, category string
var isPrimary bool
@@ -217,6 +241,111 @@ func personsCreateCmd() *cobra.Command {
return cmd
}
func personsFixNamesCmd() *cobra.Command {
var companyID int
var dryRun bool
var orgHint string
cmd := &cobra.Command{
Use: "fix-names",
Short: "Strip company annotations from last names; keep companyId link",
Long: `Repairs CRM persons whose lastName embeds a company ("Thomsen (Acme)")
or whose firstName is an email. Company belongs on companyId, not in the name.
With --company-id, only persons linked to that company are scanned.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
var list []map[string]any
if companyID > 0 {
list, err = c.ListCompanyPersons(ctx, strconv.Itoa(companyID))
} else {
all, err2 := c.ListAllContacts(ctx)
err = err2
for _, row := range all {
if isCo, _ := row["isCompany"].(bool); !isCo {
list = append(list, row)
}
}
}
if err != nil {
return err
}
fixed, skipped := 0, 0
for _, p := range list {
id := fmt.Sprint(p["id"])
// List payloads often omit contactInfos; refresh when name looks like email.
fn := strings.TrimSpace(fmt.Sprint(p["firstName"]))
if strings.Contains(fn, "@") || fn == "" {
if full, gerr := c.GetContact(ctx, id); gerr == nil && full != nil {
p = full
}
}
fn = strings.TrimSpace(fmt.Sprint(p["firstName"]))
ln := strings.TrimSpace(fmt.Sprint(p["lastName"]))
dn := strings.TrimSpace(fmt.Sprint(p["displayName"]))
org := orgHint
if org == "" {
if co, ok := p["company"].(map[string]any); ok {
org = strings.TrimSpace(fmt.Sprint(co["displayName"]))
}
}
emails := contactEmailsFromMap(p)
for _, row := range onlyoffice.ContactInfoRows(p) {
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) == "email" {
if data := strings.TrimSpace(fmt.Sprint(row["data"])); data != "" {
emails = append(emails, data)
}
}
}
cf, cl := catalog.CleanPersonNames(fn, ln, dn, org, emails)
needName := cf != fn || cl != ln
linkID := companyID
if linkID == 0 {
if co, ok := p["company"].(map[string]any); ok {
linkID = int(flexIDFloat(co["id"]))
}
}
if !needName {
skipped++
continue
}
row := map[string]any{
"id": id, "from": fn + " / " + ln, "to": cf + " / " + cl, "companyId": linkID,
}
if dryRun {
printObject(row)
fixed++
continue
}
if _, err := c.UpdatePerson(ctx, id, cf, cl, linkID, "", ""); err != nil {
return fmt.Errorf("update %s: %w", id, err)
}
printObject(row)
fixed++
}
printObject(map[string]any{"fixed": fixed, "skipped": skipped, "dry_run": dryRun})
return nil
},
}
cmd.Flags().IntVar(&companyID, "company-id", 0, "limit to persons of this company")
cmd.Flags().StringVar(&orgHint, "org", "", "org name hint for stripping suffixes")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned renames only")
return cmd
}
func contactEmailsFromMap(p map[string]any) []string {
var out []string
for _, k := range []string{"email", "primaryEmail"} {
if v := strings.TrimSpace(fmt.Sprint(p[k])); v != "" && v != "<nil>" {
out = append(out, v)
}
}
return out
}
func companiesCreateCmd() *cobra.Command {
var name, email, website string
cmd := &cobra.Command{
+45 -2
View File
@@ -3,8 +3,10 @@ package main
import (
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/catalog"
"github.com/spf13/cobra"
)
@@ -116,17 +118,32 @@ func prjMilestonesCmd() *cobra.Command {
func prjCreateCmd() *cobra.Command {
var desc, resp string
var country, company string
cmd := &cobra.Command{
Use: "create TITLE",
Short: "Create a new project",
Args: cobra.ExactArgs(1),
Long: `Create a project. Prefer canonical titles:
CC | Company | Title
Examples:
oo projects create "Mapbender" --country DE --company "Stadt Mainz"
oo projects create "DE | Acme | Geo Engineer"
When --country and --company are set, TITLE is only the third segment and the
full title is composed as "CC | Company | Title".`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
title := args[0]
if country != "" || company != "" {
title = catalog.FormatProjectTitle(country, company, args[0])
}
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
Title: args[0],
Title: title,
Description: desc,
ResponsibleID: resp,
})
@@ -142,6 +159,8 @@ func prjCreateCmd() *cobra.Command {
}
cmd.Flags().StringVar(&desc, "description", "", "project description")
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)")
cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name")
return cmd
}
@@ -160,6 +179,25 @@ func prjUpdateCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
// OO update requires responsibleId; reuse current when not passed.
if resp == "" {
cur, gerr := c.GetProjectByID(cmd.Context(), args[0])
if gerr != nil {
return gerr
}
if r, ok := cur["responsible"].(map[string]any); ok {
resp = fmt.Sprint(r["id"])
}
if resp == "" || resp == "<nil>" {
resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"]))
}
if title == "" {
title = strings.TrimSpace(fmt.Sprint(cur["title"]))
}
if desc == "" {
desc = strings.TrimSpace(fmt.Sprint(cur["description"]))
}
}
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
@@ -173,6 +211,11 @@ func prjUpdateCmd() *cobra.Command {
"id": derefInt(p.ID),
"title": p.String(),
})
// Confirm via GET — some OO builds return an empty body on PUT.
got, gerr := c.GetProjectByID(cmd.Context(), args[0])
if gerr == nil && got != nil {
printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]})
}
return nil
},
}