diff --git a/catalog/address_test.go b/catalog/address_test.go new file mode 100644 index 0000000..ca4a57a --- /dev/null +++ b/catalog/address_test.go @@ -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) + } +} diff --git a/catalog/apply.go b/catalog/apply.go index bed3990..2f5094e 100644 --- a/catalog/apply.go +++ b/catalog/apply.go @@ -195,5 +195,16 @@ func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactI 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 } diff --git a/catalog/types.go b/catalog/types.go index 0a5a243..5bc0e95 100644 --- a/catalog/types.go +++ b/catalog/types.go @@ -12,25 +12,38 @@ import ( "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). type Entry struct { - ID string `yaml:"id"` - Kind string `yaml:"kind"` // person | company - Name string `yaml:"name,omitempty"` - First string `yaml:"first,omitempty"` - Last string `yaml:"last,omitempty"` - Emails []string `yaml:"emails,omitempty"` - Phones []string `yaml:"phones,omitempty"` - Org string `yaml:"org,omitempty"` - Sources []string `yaml:"sources,omitempty"` - Zone string `yaml:"zone"` - Role string `yaml:"role"` - OOID string `yaml:"oo_id,omitempty"` - Approve bool `yaml:"approve"` - Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped - Notes string `yaml:"notes,omitempty"` - Remote string `yaml:"remote,omitempty"` - GitRoot string `yaml:"git_root,omitempty"` + ID string `yaml:"id"` + Kind string `yaml:"kind"` // person | company + Name string `yaml:"name,omitempty"` + First string `yaml:"first,omitempty"` + Last string `yaml:"last,omitempty"` + Emails []string `yaml:"emails,omitempty"` + Phones []string `yaml:"phones,omitempty"` + Addresses []Address `yaml:"addresses,omitempty"` + Org string `yaml:"org,omitempty"` + Sources []string `yaml:"sources,omitempty"` + Zone string `yaml:"zone"` + Role string `yaml:"role"` + OOID string `yaml:"oo_id,omitempty"` + Approve bool `yaml:"approve"` + Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped + Notes string `yaml:"notes,omitempty"` + Remote string `yaml:"remote,omitempty"` + GitRoot string `yaml:"git_root,omitempty"` } // Document is the on-disk catalog file. @@ -136,6 +149,7 @@ func mergeEntry(dst, src *Entry) { dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...)) dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...)) dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...)) + dst.Addresses = mergeAddresses(dst.Addresses, src.Addresses) if dst.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 { seen := map[string]struct{}{} var out []string diff --git a/crm_address.go b/crm_address.go new file mode 100644 index 0000000..4bdc3aa --- /dev/null +++ b/crm_address.go @@ -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 + } +} diff --git a/crm_address_test.go b/crm_address_test.go new file mode 100644 index 0000000..4327eed --- /dev/null +++ b/crm_address_test.go @@ -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)) + } +} diff --git a/invoices.go b/invoices.go index 55cfc34..1b1c8d7 100644 --- a/invoices.go +++ b/invoices.go @@ -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))) } +// 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. -// 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) { if category == "" { category = "Billing" } - fields := url.Values{} - fields.Set("street", street) - fields.Set("city", city) - fields.Set("state", state) - fields.Set("zip", zip) - 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) + payload, err := json.Marshal(contactAddress{Street: street, City: city, State: state, Zip: zip, Country: country}) + if err != nil { + return nil, err + } + return c.AddContactInfo(ctx, contactID, "Address", string(payload), category, isPrimary) } // UpdateCompany updates company name and optional about text.