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:
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/catalog"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,12 +41,14 @@ func init() {
|
|||||||
contactsCmd.AddCommand(contactsListCmd(nil))
|
contactsCmd.AddCommand(contactsListCmd(nil))
|
||||||
contactsCmd.AddCommand(contactsGetCmd())
|
contactsCmd.AddCommand(contactsGetCmd())
|
||||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||||
|
contactsCmd.AddCommand(contactsMergeCmd())
|
||||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
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(personsFixNamesCmd())
|
||||||
personsCmd.AddCommand(contactsDeleteCmd())
|
personsCmd.AddCommand(contactsDeleteCmd())
|
||||||
personsCmd.AddCommand(personsDedupeCmd())
|
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 {
|
func contactsInfoAddCmd() *cobra.Command {
|
||||||
var infoType, value, category string
|
var infoType, value, category string
|
||||||
var isPrimary bool
|
var isPrimary bool
|
||||||
@@ -217,6 +241,111 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
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 {
|
func companiesCreateCmd() *cobra.Command {
|
||||||
var name, email, website string
|
var name, email, website string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
+44
-1
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/catalog"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,17 +118,32 @@ func prjMilestonesCmd() *cobra.Command {
|
|||||||
|
|
||||||
func prjCreateCmd() *cobra.Command {
|
func prjCreateCmd() *cobra.Command {
|
||||||
var desc, resp string
|
var desc, resp string
|
||||||
|
var country, company string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create TITLE",
|
Use: "create TITLE",
|
||||||
Short: "Create a new project",
|
Short: "Create a new project",
|
||||||
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
title := args[0]
|
||||||
|
if country != "" || company != "" {
|
||||||
|
title = catalog.FormatProjectTitle(country, company, args[0])
|
||||||
|
}
|
||||||
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
||||||
Title: args[0],
|
Title: title,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
ResponsibleID: resp,
|
ResponsibleID: resp,
|
||||||
})
|
})
|
||||||
@@ -142,6 +159,8 @@ func prjCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
||||||
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +179,25 @@ func prjUpdateCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("project id must be integer: %w", err)
|
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{
|
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -173,6 +211,11 @@ func prjUpdateCmd() *cobra.Command {
|
|||||||
"id": derefInt(p.ID),
|
"id": derefInt(p.ID),
|
||||||
"title": p.String(),
|
"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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,8 +144,7 @@ func contactEmailValues(p map[string]any) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, row := range ContactInfoRows(p) {
|
for _, row := range ContactInfoRows(p) {
|
||||||
t := strings.ToLower(fmt.Sprint(row["infoType"]))
|
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||||
if t != "email" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||||
@@ -188,6 +187,24 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
|
|||||||
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePerson updates first/last name and optional company link on a person.
|
||||||
|
// companyID == 0 leaves the company association unchanged.
|
||||||
|
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("firstName", first)
|
||||||
|
fields.Set("lastName", last)
|
||||||
|
if companyID != 0 {
|
||||||
|
fields.Set("companyId", strconv.Itoa(companyID))
|
||||||
|
}
|
||||||
|
if jobTitle != "" {
|
||||||
|
fields.Set("jobTitle", jobTitle)
|
||||||
|
}
|
||||||
|
if about != "" {
|
||||||
|
fields.Set("about", about)
|
||||||
|
}
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
||||||
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
||||||
if category == "" {
|
if category == "" {
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cid := flexInt(row["companyId"])
|
cid := flexInt(row["companyId"])
|
||||||
|
if cid == 0 {
|
||||||
|
if co, ok := row["company"].(map[string]any); ok {
|
||||||
|
cid = flexInt(co["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
if cid == 0 {
|
if cid == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-1
@@ -92,8 +92,53 @@ func FixDealTitle(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ContactInfoKey groups contact info rows by type and normalized value.
|
// ContactInfoKey groups contact info rows by type and normalized value.
|
||||||
|
// ContactInfoKey builds a dedupe key for a contact-info row.
|
||||||
func ContactInfoKey(infoType, value string) string {
|
func ContactInfoKey(infoType, value string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
|
return NormalizeContactInfoType(infoType) + "|" + strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeContactInfoType maps OO numeric infoType codes and string labels to
|
||||||
|
// a canonical lowercase label (email, phone, …).
|
||||||
|
func NormalizeContactInfoType(infoType string) string {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(infoType))
|
||||||
|
switch t {
|
||||||
|
case "1", "email":
|
||||||
|
return "email"
|
||||||
|
case "2", "phone":
|
||||||
|
return "phone"
|
||||||
|
case "3", "website":
|
||||||
|
return "website"
|
||||||
|
case "4", "skype":
|
||||||
|
return "skype"
|
||||||
|
case "5", "twitter":
|
||||||
|
return "twitter"
|
||||||
|
case "6", "linkedin":
|
||||||
|
return "linkedin"
|
||||||
|
case "7", "facebook":
|
||||||
|
return "facebook"
|
||||||
|
case "8", "address":
|
||||||
|
return "address"
|
||||||
|
case "9", "livejournal":
|
||||||
|
return "livejournal"
|
||||||
|
case "10", "myspace":
|
||||||
|
return "myspace"
|
||||||
|
case "11", "gmail":
|
||||||
|
return "gmail"
|
||||||
|
case "12", "blogger":
|
||||||
|
return "blogger"
|
||||||
|
case "13", "yahoo":
|
||||||
|
return "yahoo"
|
||||||
|
case "14", "msn":
|
||||||
|
return "msn"
|
||||||
|
case "15", "icq":
|
||||||
|
return "icq"
|
||||||
|
case "16", "jabber":
|
||||||
|
return "jabber"
|
||||||
|
case "17", "aim":
|
||||||
|
return "aim"
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
||||||
|
|||||||
+16
-6
@@ -193,16 +193,26 @@ func (c *Client) DeleteProject(id int) (*Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProject updates project fields.
|
// UpdateProject updates project fields.
|
||||||
|
// OnlyOffice requires responsibleId on PUT; callers should set ResponsibleID
|
||||||
|
// (the CLI fills it from the current project when omitted).
|
||||||
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
|
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
|
||||||
p := &Project{}
|
p := new(Project)
|
||||||
return p, c.Query(Request{
|
body := map[string]any{
|
||||||
|
"title": req.Title,
|
||||||
|
"description": req.Description,
|
||||||
|
"responsibleId": req.ResponsibleID,
|
||||||
|
}
|
||||||
|
err := c.Query(Request{
|
||||||
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
|
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
|
||||||
Method: "PUT",
|
Method: "PUT",
|
||||||
Body: req,
|
Body: body,
|
||||||
},
|
}, &struct {
|
||||||
&struct {
|
|
||||||
Response *Project `json:"response"`
|
Response *Project `json:"response"`
|
||||||
}{p})
|
}{Response: p})
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
|
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
|
||||||
|
|||||||
Reference in New Issue
Block a user