fix(crm): store contact addresses via ContactInfo Address type (#288)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 1m1s
Tests / Test (Go 1.25) (pull_request) Successful in 1m1s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 1m1s
Tests / Test (Go 1.25) (pull_request) Successful in 1m1s
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMergeAddressesDedup(t *testing.T) {
|
||||||
|
dst := []Address{{Street: "Weg 1", City: "Stadt", Zip: "1"}}
|
||||||
|
got := mergeAddresses(dst, []Address{
|
||||||
|
{Street: "weg 1", City: "stadt", Zip: "1"}, // duplicate (case-insensitive)
|
||||||
|
{Street: "Weg 2", City: "Stadt", Zip: "2"}, // new
|
||||||
|
})
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d addresses, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[1].Street != "Weg 2" {
|
||||||
|
t.Errorf("second = %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryAddressesYAML(t *testing.T) {
|
||||||
|
doc := &Document{Entries: []Entry{{
|
||||||
|
ID: "person:max maier", Kind: "person", Name: "Max Maier",
|
||||||
|
Addresses: []Address{{Street: "Weg 1", City: "Stadt", Zip: "12345", Category: "Billing", Primary: true}},
|
||||||
|
}}}
|
||||||
|
merged := MergeDocs(doc)
|
||||||
|
if len(merged.Entries) != 1 || len(merged.Entries[0].Addresses) != 1 {
|
||||||
|
t.Fatalf("merged = %+v", merged.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,5 +195,16 @@ func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactI
|
|||||||
return fmt.Errorf("add phone %s: %w", ph, err)
|
return fmt.Errorf("add phone %s: %w", ph, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, a := range e.Addresses {
|
||||||
|
if strings.TrimSpace(a.Street) == "" && strings.TrimSpace(a.City) == "" && strings.TrimSpace(a.Zip) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if onlyoffice.HasContactAddress(existing, a.Street, a.City, a.Zip, a.Category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := client.AddContactAddress(ctx, contactID, a.Street, a.City, a.State, a.Zip, a.Country, a.Category, a.Primary); err != nil {
|
||||||
|
return fmt.Errorf("add address %s: %w", strings.TrimSpace(a.Street), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Address is one postal address of a catalog row. Category is the OO
|
||||||
|
// AddressCategory label (Home|Postal|Office|Billing|Other|Work).
|
||||||
|
type Address struct {
|
||||||
|
Street string `yaml:"street,omitempty"`
|
||||||
|
City string `yaml:"city,omitempty"`
|
||||||
|
State string `yaml:"state,omitempty"`
|
||||||
|
Zip string `yaml:"zip,omitempty"`
|
||||||
|
Country string `yaml:"country,omitempty"`
|
||||||
|
Category string `yaml:"category,omitempty"`
|
||||||
|
Primary bool `yaml:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Entry is one catalog row (person or company).
|
// Entry is one catalog row (person or company).
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
@@ -21,6 +33,7 @@ type Entry struct {
|
|||||||
Last string `yaml:"last,omitempty"`
|
Last string `yaml:"last,omitempty"`
|
||||||
Emails []string `yaml:"emails,omitempty"`
|
Emails []string `yaml:"emails,omitempty"`
|
||||||
Phones []string `yaml:"phones,omitempty"`
|
Phones []string `yaml:"phones,omitempty"`
|
||||||
|
Addresses []Address `yaml:"addresses,omitempty"`
|
||||||
Org string `yaml:"org,omitempty"`
|
Org string `yaml:"org,omitempty"`
|
||||||
Sources []string `yaml:"sources,omitempty"`
|
Sources []string `yaml:"sources,omitempty"`
|
||||||
Zone string `yaml:"zone"`
|
Zone string `yaml:"zone"`
|
||||||
@@ -136,6 +149,7 @@ func mergeEntry(dst, src *Entry) {
|
|||||||
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
|
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
|
||||||
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
|
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
|
||||||
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
|
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
|
||||||
|
dst.Addresses = mergeAddresses(dst.Addresses, src.Addresses)
|
||||||
if dst.First == "" {
|
if dst.First == "" {
|
||||||
dst.First = src.First
|
dst.First = src.First
|
||||||
}
|
}
|
||||||
@@ -176,6 +190,25 @@ func mergeEntry(dst, src *Entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeAddresses appends src addresses not already present (by street/city/zip).
|
||||||
|
func mergeAddresses(dst, src []Address) []Address {
|
||||||
|
for _, a := range src {
|
||||||
|
exists := false
|
||||||
|
for _, b := range dst {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(a.Street), strings.TrimSpace(b.Street)) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(a.City), strings.TrimSpace(b.City)) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(a.Zip), strings.TrimSpace(b.Zip)) {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
dst = append(dst, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
func uniqueEmails(in []string) []string {
|
func uniqueEmails(in []string) []string {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addressCategoryCodes maps ASC.CRM.Core.AddressCategory names to their numeric
|
||||||
|
// codes. The OO API expects the code, the UI/docs use the label.
|
||||||
|
var addressCategoryCodes = map[string]int{
|
||||||
|
"home": 0,
|
||||||
|
"postal": 1,
|
||||||
|
"office": 2,
|
||||||
|
"billing": 3,
|
||||||
|
"other": 4,
|
||||||
|
"work": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressCategoryCode returns the numeric code for an AddressCategory label
|
||||||
|
// (Home|Postal|Office|Billing|Other|Work) or a numeric string. Unknown/empty
|
||||||
|
// labels fall back to Billing, the category `oo companies create` used.
|
||||||
|
func AddressCategoryCode(category string) int {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if s == "" {
|
||||||
|
return addressCategoryCodes["billing"]
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
if n >= 0 && n <= 5 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return addressCategoryCodes["billing"]
|
||||||
|
}
|
||||||
|
if n, ok := addressCategoryCodes[s]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return addressCategoryCodes["billing"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContactAddresses returns the postal address rows of a contact map.
|
||||||
|
func ContactAddresses(contact map[string]any) []map[string]any {
|
||||||
|
if rows, ok := contact["addresses"].([]any); ok {
|
||||||
|
return mapsFromAnySlice(rows)
|
||||||
|
}
|
||||||
|
if rows, ok := contact["addresses"].([]map[string]any); ok {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasContactAddress reports whether a contact already has the given postal
|
||||||
|
// address. street+city+zip+category identify it; comparison is normalized.
|
||||||
|
func HasContactAddress(contact map[string]any, street, city, zip, category string) bool {
|
||||||
|
wantStreet, wantCity, wantZip := normalizeAddressPart(street), normalizeAddressPart(city), normalizeAddressPart(zip)
|
||||||
|
wantCat := AddressCategoryCode(category)
|
||||||
|
for _, row := range ContactAddresses(contact) {
|
||||||
|
if normalizeAddressPart(fmt.Sprint(row["street"])) != wantStreet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizeAddressPart(fmt.Sprint(row["city"])) != wantCity {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizeAddressPart(fmt.Sprint(row["zip"])) != wantZip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if int(anyToFloat(row["category"])) != wantCat {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAddressPart(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyToFloat(v any) float64 {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case string:
|
||||||
|
f, _ := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAddressCategoryCode(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"Home": 0, "Postal": 1, "Office": 2, "Billing": 3, "Other": 4, "Work": 5,
|
||||||
|
"billing": 3, " work ": 5, "3": 3, "5": 5,
|
||||||
|
"": 3, "nonsense": 3, "99": 3,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := AddressCategoryCode(in); got != want {
|
||||||
|
t.Errorf("AddressCategoryCode(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasContactAddress(t *testing.T) {
|
||||||
|
contact := map[string]any{
|
||||||
|
"addresses": []any{
|
||||||
|
map[string]any{
|
||||||
|
"street": "Lubanas st. 125a-25", "city": "Riga",
|
||||||
|
"zip": "LV-1021", "country": "Latvia", "category": float64(3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !HasContactAddress(contact, " Lubanas St. 125a-25 ", "riga", "lv-1021", "Billing") {
|
||||||
|
t.Error("want match (normalized, case-insensitive)")
|
||||||
|
}
|
||||||
|
if HasContactAddress(contact, "Lubanas st. 125a-25", "Riga", "LV-1021", "Work") {
|
||||||
|
t.Error("different category must not match")
|
||||||
|
}
|
||||||
|
if HasContactAddress(contact, "Lubanas st. 125a-25", "Riga", "00000", "Billing") {
|
||||||
|
t.Error("different zip must not match")
|
||||||
|
}
|
||||||
|
if HasContactAddress(map[string]any{}, "x", "y", "z", "Billing") {
|
||||||
|
t.Error("empty contact must not match")
|
||||||
|
}
|
||||||
|
if got := ContactAddresses(contact); len(got) != 1 {
|
||||||
|
t.Fatalf("ContactAddresses = %d rows", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -443,21 +443,32 @@ func (c *Client) DeleteInvoiceItem(ctx context.Context, id string) (map[string]a
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoiceitem/%s.json", url.PathEscape(id)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoiceitem/%s.json", url.PathEscape(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contactAddress is the JSON payload OO stores in a ContactInfo row of type
|
||||||
|
// Address (ASC.Api.CRM.Wrappers.Address).
|
||||||
|
type contactAddress struct {
|
||||||
|
Street string `json:"street"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
// AddContactAddress attaches a postal address to a contact.
|
// AddContactAddress attaches a postal address to a contact.
|
||||||
// category: Home|Postal|Office|Billing|Other|Work (or numeric string).
|
// category: Home|Postal|Office|Billing|Other|Work.
|
||||||
|
//
|
||||||
|
// OO stores addresses as ContactInfo rows of infoType Address whose `data` is
|
||||||
|
// the Address object as JSON (the dedicated /addressdata endpoint binds the
|
||||||
|
// model from the body and is not accepted by all builds). The generic
|
||||||
|
// /contact/{id}/data endpoint is the one `oo contacts info-add` uses.
|
||||||
func (c *Client) AddContactAddress(ctx context.Context, contactID, street, city, state, zip, country, category string, isPrimary bool) (map[string]any, error) {
|
func (c *Client) AddContactAddress(ctx context.Context, contactID, street, city, state, zip, country, category string, isPrimary bool) (map[string]any, error) {
|
||||||
if category == "" {
|
if category == "" {
|
||||||
category = "Billing"
|
category = "Billing"
|
||||||
}
|
}
|
||||||
fields := url.Values{}
|
payload, err := json.Marshal(contactAddress{Street: street, City: city, State: state, Zip: zip, Country: country})
|
||||||
fields.Set("street", street)
|
if err != nil {
|
||||||
fields.Set("city", city)
|
return nil, err
|
||||||
fields.Set("state", state)
|
}
|
||||||
fields.Set("zip", zip)
|
return c.AddContactInfo(ctx, contactID, "Address", string(payload), category, isPrimary)
|
||||||
fields.Set("country", country)
|
|
||||||
fields.Set("category", category)
|
|
||||||
fields.Set("isPrimary", strconv.FormatBool(isPrimary))
|
|
||||||
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/address", url.PathEscape(contactID)), fields)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCompany updates company name and optional about text.
|
// UpdateCompany updates company name and optional about text.
|
||||||
|
|||||||
Reference in New Issue
Block a user