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

154 lines
5.2 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
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// Семантика стадии лида в Битрикс24 (поле STATUS_SEMANTIC_ID): по ней решаем судьбу записи,
// не завися от того, как в CRM названы и сколько заведено стадий.
const (
SemanticProcess = "P" // в работе
SemanticSuccess = "S" // успех — «Клиент записался»
SemanticFailure = "F" // любой провал — «Клиент не записался», «Спам», «Нет ответа»…
)
// FailedStatusID — стадия, в которую сайт сам переводит лид, отменив неподтверждённую запись.
const FailedStatusID = "JUNK"
// LeadDraft — новый лид из онлайн-записи.
type LeadDraft struct {
Title string
Name string
Phone string
SourceID string
SourceDescr string
Comments string
}
// call — один вызов метода REST. Ответ {"result": …} разбирается в out (если out не nil).
func (c *Client) call(ctx context.Context, method string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.WebhookURL+"/"+method+".json", bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("битрикс %s: %w", method, redactURL(err))
}
defer resp.Body.Close()
var r struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
Descr string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return fmt.Errorf("битрикс %s: не разобрать ответ (HTTP %d): %w", method, resp.StatusCode, err)
}
if r.Error != "" {
return fmt.Errorf("битрикс %s: %s (%s)", method, r.Error, r.Descr)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("битрикс %s: HTTP %d", method, resp.StatusCode)
}
if out != nil {
if err := json.Unmarshal(r.Result, out); err != nil {
return fmt.Errorf("битрикс %s: неожиданный ответ: %w", method, err)
}
}
return nil
}
// AddLead заводит лид в стадии «Не обработан» и возвращает его ID.
func (c *Client) AddLead(ctx context.Context, d LeadDraft) (string, error) {
fields := map[string]any{
"TITLE": d.Title,
"NAME": d.Name,
"STATUS_ID": "NEW",
"OPENED": "Y",
"COMMENTS": d.Comments,
}
if d.Phone != "" {
fields["PHONE"] = []map[string]string{{"VALUE": d.Phone, "VALUE_TYPE": "MOBILE"}}
}
if d.SourceID != "" {
fields["SOURCE_ID"] = d.SourceID
}
if d.SourceDescr != "" {
fields["SOURCE_DESCRIPTION"] = d.SourceDescr
}
// result — число, но на всякий случай принимаем и строку
var id json.Number
if err := c.call(ctx, "crm.lead.add", map[string]any{
"fields": fields,
"params": map[string]string{"REGISTER_SONET_EVENT": "Y"},
}, &id); err != nil {
return "", err
}
s := strings.Trim(id.String(), `"`)
if s == "" || s == "0" {
return "", fmt.Errorf("битрикс crm.lead.add: пустой ID лида")
}
return s, nil
}
// LeadSemantics — семантика стадии (P/S/F) для каждого найденного лида. Удалённых лидов в ответе нет.
func (c *Client) LeadSemantics(ctx context.Context, ids []string) (map[string]string, error) {
out := map[string]string{}
for len(ids) > 0 {
n := min(len(ids), pageSize)
chunk := ids[:n]
ids = ids[n:]
var items []struct {
ID string `json:"ID"`
Semantic string `json:"STATUS_SEMANTIC_ID"`
}
if err := c.call(ctx, "crm.lead.list", map[string]any{
"select": []string{"ID", "STATUS_SEMANTIC_ID"},
"filter": map[string]any{"@ID": chunk},
}, &items); err != nil {
return nil, err
}
for _, it := range items {
out[it.ID] = it.Semantic
}
}
return out, nil
}
// AddLeadComment оставляет комментарий в ленте лида — оператор видит, что сделал сайт.
func (c *Client) AddLeadComment(ctx context.Context, leadID, text string) error {
return c.call(ctx, "crm.timeline.comment.add", map[string]any{
"fields": map[string]any{"ENTITY_ID": leadID, "ENTITY_TYPE": "lead", "COMMENT": text},
}, nil)
}
// FailLead переводит лид в «Клиент не записался» — после автоматической отмены записи.
func (c *Client) FailLead(ctx context.Context, leadID string) error {
return c.call(ctx, "crm.lead.update", map[string]any{
"id": leadID,
"fields": map[string]any{"STATUS_ID": FailedStatusID},
}, nil)
}
// redactURL убирает адрес запроса из сетевой ошибки: в адресе вебхука секретный ключ,
// а текст ошибки попадает в лог, в базу и в админку.
func redactURL(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return fmt.Errorf("%s: %w", ue.Op, ue.Err)
}
return err
}