dana-medical-site/internal/bitrix/client.go
2026-10-14 13:33:13 +00:00

135 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package bitrix — минимальный клиент REST API Битрикс24 через входящий вебхук.
// Используется для выгрузки лидов и контактов в «Отчёт по каналам» и для лидов онлайн-записи,
// которые подтверждает оператор (см. leads.go).
package bitrix
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type Client struct {
WebhookURL string
HTTP *http.Client
}
func New(webhookURL string) *Client {
return &Client{
WebhookURL: strings.TrimRight(strings.TrimSpace(webhookURL), "/"),
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) Configured() bool { return c != nil && c.WebhookURL != "" }
type phoneField struct {
Value string `json:"VALUE"`
}
type crmItem struct {
ID string `json:"ID"`
SourceID string `json:"SOURCE_ID"`
Phone []phoneField `json:"PHONE"`
}
type listResponse struct {
Result []crmItem `json:"result"`
Total int `json:"total"`
Error string `json:"error"`
Descr string `json:"error_description"`
}
// Record — лид или контакт CRM: нужные нам поля, без лишнего.
type Record struct {
ID string
SourceID string // у контактов всегда пусто — поле есть только у лидов
Phones []string
}
const pageSize = 50
// Progress — колбэк хода постраничной выгрузки: сколько записей забрано из скольких всего (по ответу API).
type Progress func(done, total int)
// list — одна выборка (лиды или контакты) целиком, со всеми страницами.
func (c *Client) list(ctx context.Context, method string, selectFields []string, filter map[string]any, onPage Progress) ([]Record, error) {
var out []Record
start := 0
for {
body := map[string]any{
"select": selectFields,
"order": map[string]string{"ID": "ASC"},
"start": start,
}
if len(filter) > 0 {
body["filter"] = filter
}
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.WebhookURL+"/"+method+".json", strings.NewReader(string(b)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("битрикс %s: %w", method, redactURL(err))
}
var lr listResponse
decErr := json.NewDecoder(resp.Body).Decode(&lr)
resp.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("битрикс %s: не разобрать ответ: %w", method, decErr)
}
if lr.Error != "" {
return nil, fmt.Errorf("битрикс %s: %s (%s)", method, lr.Error, lr.Descr)
}
for _, item := range lr.Result {
r := Record{ID: item.ID, SourceID: item.SourceID}
for _, p := range item.Phone {
if v := strings.TrimSpace(p.Value); v != "" {
r.Phones = append(r.Phones, v)
}
}
out = append(out, r)
}
if onPage != nil {
onPage(len(out), lr.Total)
}
start += pageSize
if start >= lr.Total || len(lr.Result) == 0 {
break
}
}
return out, nil
}
// dateFilter — ограничение по дате создания записи (DATE_CREATE), чтобы не тянуть всю историю CRM
// за годы. from/to нулевые — фильтр не применяется (выгружается всё, для старых мест вызова).
func dateFilter(from, to time.Time) map[string]any {
if from.IsZero() || to.IsZero() {
return nil
}
return map[string]any{
">=DATE_CREATE": from.Format(time.RFC3339),
"<DATE_CREATE": to.Format(time.RFC3339),
}
}
// Leads — лиды CRM за период [from, to) с их источником (SOURCE_ID) и номерами.
func (c *Client) Leads(ctx context.Context, from, to time.Time, onPage Progress) ([]Record, error) {
return c.list(ctx, "crm.lead.list", []string{"ID", "SOURCE_ID", "PHONE"}, dateFilter(from, to), onPage)
}
// Contacts — контакты CRM за период [from, to) с номерами. У контактов источника нет.
func (c *Client) Contacts(ctx context.Context, from, to time.Time, onPage Progress) ([]Record, error) {
return c.list(ctx, "crm.contact.list", []string{"ID", "PHONE"}, dateFilter(from, to), onPage)
}