feat(crm): integrate website leads and online booking with new CRM and 1C
- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads - Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes - Add async lead intake dispatch to new CRM in handlers_leads.go - Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go - Add LinkCRM endpoint to bridge_1c.py and onec.Client - Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
This commit is contained in:
parent
b508d2dc09
commit
121f286016
@ -17,3 +17,11 @@ SITE_URL=http://127.0.0.1:8080
|
||||
SSR_RENDERER=./web/scripts/render-page.mjs
|
||||
NODE_BINARY=node
|
||||
# SSR_RENDERER=off — только снимки сборки, требующие ручной пересборки после правок.
|
||||
|
||||
# Битрикс24 (существующая CRM)
|
||||
BITRIX_WEBHOOK_URL=
|
||||
|
||||
# Новая CRM (dana-medccrm)
|
||||
CRM_API_URL=https://stagingcrm.dana-medical.ru
|
||||
CRM_LEAD_INTAKE_SECRET=157cd822ba7e45636a3af9479024a8847410a250e9bc1cc0268a127c8b3c3282
|
||||
CRM_MODE=both # both | crm_only | bitrix_only
|
||||
|
||||
@ -737,6 +737,78 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
self.send_json(200, {"status": "ok", "message": "Связано успешно"})
|
||||
return
|
||||
|
||||
if path == "/appointments/link-crm":
|
||||
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
||||
crm_deal_id = str(req_json.get("crm_deal_id", "")).strip()
|
||||
if not appointment_id or not crm_deal_id:
|
||||
self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"})
|
||||
return
|
||||
|
||||
code = f"""
|
||||
Попытка
|
||||
ДокСсылка = Неопределено;
|
||||
Попытка
|
||||
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
|
||||
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||
Попытка
|
||||
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
КонецЕсли;
|
||||
|
||||
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||
Запрос = Новый Запрос;
|
||||
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||
Выб = Запрос.Выполнить().Выбрать();
|
||||
Если Выб.Следующий() Тогда
|
||||
ДокСсылка = Выб.Ссылка;
|
||||
КонецЕсли;
|
||||
КонецЕсли;
|
||||
|
||||
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||
Запрос = Новый Запрос;
|
||||
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||
Выб = Запрос.Выполнить().Выбрать();
|
||||
Пока Выб.Следующий() Цикл
|
||||
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||
ДокСсылка = Выб.Ссылка;
|
||||
Прервать;
|
||||
КонецЕсли;
|
||||
КонецЦикла;
|
||||
КонецЕсли;
|
||||
|
||||
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||
Если ДокОбъект <> Неопределено Тогда
|
||||
Если Найти(ДокОбъект.Примечание, "{crm_deal_id}") = 0 Тогда
|
||||
ДокОбъект.Примечание = СокрЛП(ДокОбъект.Примечание) + " [CRM: {crm_deal_id}]";
|
||||
КонецЕсли;
|
||||
ДокОбъект.Записать(РежимЗаписиДокумента.Запись);
|
||||
Результат = "OK";
|
||||
Иначе
|
||||
Результат = "NOT_FOUND";
|
||||
КонецЕсли;
|
||||
Иначе
|
||||
Результат = "NOT_FOUND";
|
||||
КонецЕсли;
|
||||
Исключение
|
||||
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||
КонецПопытки;
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||
self.send_json(500, {"code": "link_failed", "message": err or data})
|
||||
elif data == "NOT_FOUND":
|
||||
self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"})
|
||||
else:
|
||||
self.send_json(200, {"status": "ok", "message": "Связано успешно с CRM"})
|
||||
return
|
||||
|
||||
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||
|
||||
def do_DELETE(self):
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"dana/internal/api"
|
||||
"dana/internal/bitrix"
|
||||
"dana/internal/config"
|
||||
"dana/internal/crm"
|
||||
"dana/internal/megafon"
|
||||
"dana/internal/notify"
|
||||
"dana/internal/onec"
|
||||
@ -84,6 +85,7 @@ func run() error {
|
||||
handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll)
|
||||
go handler.RunOneCSync(ctx)
|
||||
handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL))
|
||||
handler.WithCRM(crm.New(cfg.CrmAPIURL, cfg.CrmLeadIntakeSecret), cfg.CrmMode)
|
||||
go handler.RunBookingConfirm(ctx)
|
||||
handler.WithSMSC(cfg.SMSCLogin, cfg.SMSCPassword)
|
||||
handler.WithSMSCCallbackToken(cfg.SMSCCallbackToken)
|
||||
|
||||
@ -22,3 +22,11 @@ NOTIFY_TO=
|
||||
# данными, но автоматическая синхронизация отключена.
|
||||
MEGAFON_API_URL=vats970345.megapbx.ru
|
||||
MEGAFON_API_KEY=
|
||||
|
||||
# Битрикс24 (существующая CRM)
|
||||
BITRIX_WEBHOOK_URL=
|
||||
|
||||
# Новая CRM (dana-medccrm)
|
||||
CRM_API_URL=https://stagingcrm.dana-medical.ru
|
||||
CRM_LEAD_INTAKE_SECRET=157cd822ba7e45636a3af9479024a8847410a250e9bc1cc0268a127c8b3c3282
|
||||
CRM_MODE=both # both | crm_only | bitrix_only
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"dana/internal/bitrix"
|
||||
"dana/internal/crm"
|
||||
"dana/internal/megafon"
|
||||
"dana/internal/notify"
|
||||
"dana/internal/onec"
|
||||
@ -30,6 +31,8 @@ type API struct {
|
||||
onecEvery time.Duration
|
||||
onecLongPoll bool
|
||||
bitrix *bitrix.Client
|
||||
crm *crm.Client
|
||||
crmMode string
|
||||
smscLogin string
|
||||
smscPassword string
|
||||
smscCallbackToken string
|
||||
@ -43,6 +46,29 @@ type API struct {
|
||||
// Без вебхука кнопка в отчёте просто не предлагается.
|
||||
func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c }
|
||||
|
||||
// WithCRM подключает интеграцию с новой CRM (создание сделок и синхронизация).
|
||||
func (a *API) WithCRM(c *crm.Client, mode string) *API {
|
||||
a.crm = c
|
||||
a.crmMode = strings.ToLower(strings.TrimSpace(mode))
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *API) bitrixEnabled() bool {
|
||||
if a.bitrix == nil || !a.bitrix.Configured() {
|
||||
return false
|
||||
}
|
||||
m := a.crmMode
|
||||
return m == "" || m == "both" || m == "bitrix_only"
|
||||
}
|
||||
|
||||
func (a *API) crmEnabled() bool {
|
||||
if a.crm == nil || !a.crm.IsConfigured() {
|
||||
return false
|
||||
}
|
||||
m := a.crmMode
|
||||
return m == "" || m == "both" || m == "crm_only"
|
||||
}
|
||||
|
||||
func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password }
|
||||
|
||||
// WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются.
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"dana/internal/bitrix"
|
||||
"dana/internal/crm"
|
||||
"dana/internal/onec"
|
||||
"dana/internal/storage"
|
||||
)
|
||||
@ -82,7 +83,7 @@ func (a *API) bookingRiskReason(ctx context.Context, cfg storage.BookingSettings
|
||||
}
|
||||
|
||||
func (a *API) bookingConfirmEnabled() bool {
|
||||
return a.bitrix.Configured() && a.onec.Configured()
|
||||
return a.onec != nil && a.onec.Configured() && (a.bitrixEnabled() || a.crmEnabled())
|
||||
}
|
||||
|
||||
// kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты.
|
||||
@ -134,10 +135,10 @@ func decideConfirm(semantic string, created, start, now time.Time, cfg storage.B
|
||||
// RunBookingConfirm — фоновый цикл: заводит лиды и следит за их стадиями.
|
||||
func (a *API) RunBookingConfirm(ctx context.Context) {
|
||||
if !a.bookingConfirmEnabled() {
|
||||
slog.Info("booking confirm disabled: нужны и 1С, и BITRIX_WEBHOOK_URL")
|
||||
slog.Info("booking confirm disabled: нужны 1С и настроенный Bitrix или CRM")
|
||||
return
|
||||
}
|
||||
slog.Info("booking confirm enabled: онлайн-записи подтверждает оператор в Битрикс24")
|
||||
slog.Info("booking confirm enabled: онлайн-записи передаются в CRM/Битрикс24", "mode", a.crmMode)
|
||||
t := time.NewTicker(confirmEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@ -171,23 +172,21 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Лиды для новых записей. Не получилось — запись ждёт следующей попытки и автоматически
|
||||
// не отменяется: оператор её ещё не видел.
|
||||
var ids []string
|
||||
// 1. Лиды/сделки для новых записей.
|
||||
var bitrixIds []string
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
if it.BitrixLeadID == "" {
|
||||
if a.bitrixEnabled() && it.BitrixLeadID == "" {
|
||||
id, err := a.bitrix.AddLead(ctx, leadDraft(*it, cfg))
|
||||
if err != nil {
|
||||
slog.Warn("booking confirm: лид не создан", "appointment", it.ID, "err", err)
|
||||
slog.Warn("booking confirm: лид не создан в Битрикс", "appointment", it.ID, "err", err)
|
||||
_ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if err := a.store.SetBitrixLead(ctx, it.ID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
it.BitrixLeadID = id
|
||||
slog.Info("booking confirm: лид создан", "appointment", it.ID, "lead", id)
|
||||
slog.Info("booking confirm: лид создан в Битрикс", "appointment", it.ID, "lead", id)
|
||||
|
||||
if it.OnecID != "" {
|
||||
if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil {
|
||||
@ -197,14 +196,38 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
ids = append(ids, it.BitrixLeadID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Стадии лидов. Без ответа Битрикса ничего не отменяем: вдруг оператор уже подтвердил.
|
||||
sem, err := a.bitrix.LeadSemantics(ctx, ids)
|
||||
if a.crmEnabled() && it.CrmDealID == "" {
|
||||
dealID, err := a.crm.CreateLead(ctx, bookingConfirmToCRMDraft(*it, cfg), "")
|
||||
if err != nil {
|
||||
slog.Warn("booking confirm: сделка не создана в CRM", "appointment", it.ID, "err", err)
|
||||
_ = a.store.SetCrmError(ctx, it.ID, "CRM: "+err.Error())
|
||||
} else {
|
||||
if err := a.store.SetCrmDeal(ctx, it.ID, dealID); err != nil {
|
||||
return err
|
||||
}
|
||||
it.CrmDealID = dealID
|
||||
slog.Info("booking confirm: сделка создана в CRM", "appointment", it.ID, "deal", dealID)
|
||||
|
||||
if it.OnecID != "" {
|
||||
if err := a.onec.LinkCRM(ctx, it.OnecID, dealID); err != nil {
|
||||
slog.Warn("booking confirm: не удалось связать 1С со сделкой CRM", "onec_id", it.OnecID, "deal", dealID, "err", err)
|
||||
} else {
|
||||
slog.Info("booking confirm: заявка в 1С связана со сделкой CRM", "onec_id", it.OnecID, "deal", dealID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if it.BitrixLeadID != "" {
|
||||
bitrixIds = append(bitrixIds, it.BitrixLeadID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Стадии лидов в Битриксе (если Битрикс включен).
|
||||
if a.bitrixEnabled() && len(bitrixIds) > 0 {
|
||||
sem, err := a.bitrix.LeadSemantics(ctx, bitrixIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -233,6 +256,16 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !a.bitrixEnabled() && a.crmEnabled() {
|
||||
// В режиме CRM-only проверяем автоотмену по истечении срока подтверждения
|
||||
for _, it := range items {
|
||||
if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok && !now.Before(d) {
|
||||
if a.cancelUnconfirmed(ctx, it) {
|
||||
a.finishConfirm(ctx, it, "expired")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -243,18 +276,44 @@ func (a *API) sendLeadOnly(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(it))
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
if a.bitrixEnabled() && it.BitrixLeadID == "" {
|
||||
id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(*it))
|
||||
if err != nil {
|
||||
slog.Warn("booking confirm: лид по заявке не создан", "appointment", it.ID, "err", err)
|
||||
slog.Warn("booking confirm: лид по заявке не создан в Битрикс", "appointment", it.ID, "err", err)
|
||||
_ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if err := a.store.LeadOnlySent(ctx, it.ID, id); err != nil {
|
||||
} else {
|
||||
if err := a.store.SetBitrixLead(ctx, it.ID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
it.BitrixLeadID = id
|
||||
slog.Info("booking confirm: заявка передана в Битрикс", "appointment", it.ID, "lead", id)
|
||||
}
|
||||
}
|
||||
|
||||
if a.crmEnabled() && it.CrmDealID == "" {
|
||||
dealID, err := a.crm.CreateLead(ctx, leadOnlyToCRMDraft(*it), "")
|
||||
if err != nil {
|
||||
slog.Warn("booking confirm: заявка не создана в CRM", "appointment", it.ID, "err", err)
|
||||
_ = a.store.SetCrmError(ctx, it.ID, "CRM: "+err.Error())
|
||||
} else {
|
||||
if err := a.store.LeadOnlyCrmSent(ctx, it.ID, dealID); err != nil {
|
||||
return err
|
||||
}
|
||||
it.CrmDealID = dealID
|
||||
slog.Info("booking confirm: заявка передана в CRM", "appointment", it.ID, "deal", dealID)
|
||||
}
|
||||
}
|
||||
|
||||
bitrixDone := !a.bitrixEnabled() || it.BitrixLeadID != ""
|
||||
crmDone := !a.crmEnabled() || it.CrmDealID != ""
|
||||
if bitrixDone && crmDone {
|
||||
if err := a.store.FinishLeadOnly(ctx, it.ID); err != nil {
|
||||
slog.Warn("booking confirm: не удалось перевести в lead_sent", "appointment", it.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -357,6 +416,72 @@ func leadOnlyDraft(it storage.ConfirmItem) bitrix.LeadDraft {
|
||||
}
|
||||
}
|
||||
|
||||
// bookingConfirmToCRMDraft формирует черновик сделки для новой CRM при успешной записи в 1С.
|
||||
func bookingConfirmToCRMDraft(it storage.ConfirmItem, cfg storage.BookingSettings) crm.LeadDraft {
|
||||
phone := it.Phone
|
||||
if n := normalizeRuPhone(it.Phone); n != "" {
|
||||
phone = "+" + n
|
||||
}
|
||||
lines := []string{
|
||||
"Онлайн-запись с сайта, окно в 1С занято за пациентом.",
|
||||
"Врач: " + it.DoctorName,
|
||||
"Приём: " + formatSlotTime(it.Start),
|
||||
"Филиал: " + branchOrDash(it.Branch),
|
||||
}
|
||||
if it.ServiceName != "" {
|
||||
lines = append(lines, "Услуга: "+it.ServiceName)
|
||||
}
|
||||
if it.OnecID != "" {
|
||||
lines = append(lines, "№ записи в 1С: "+it.OnecID)
|
||||
}
|
||||
if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok {
|
||||
lines = append(lines, "Срок подтверждения: до "+d.In(moscow).Format("02.01 в 15:04")+".")
|
||||
}
|
||||
|
||||
return crm.LeadDraft{
|
||||
LocalLeadId: fmt.Sprintf("booking_%d", it.ID),
|
||||
Name: it.Patient,
|
||||
Phone: phone,
|
||||
Comment: strings.Join(lines, "\n"),
|
||||
SourceKey: "site_booking",
|
||||
SourceLabel: "Онлайн-запись на сайте",
|
||||
AttributionTimestamp: &it.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// leadOnlyToCRMDraft формирует черновик сделки для новой CRM при сбое записи в 1С.
|
||||
func leadOnlyToCRMDraft(it storage.ConfirmItem) crm.LeadDraft {
|
||||
phone := it.Phone
|
||||
if n := normalizeRuPhone(it.Phone); n != "" {
|
||||
phone = "+" + n
|
||||
}
|
||||
reason := "Пациент хотел записаться онлайн, но запись в 1С не прошла: 1С была недоступна или отклонила запись."
|
||||
if strings.HasPrefix(it.Error, errBookingLimit.Error()) {
|
||||
reason = "Пациент хотел записаться онлайн, но сработала защита от ботов (лимит записей)."
|
||||
}
|
||||
lines := []string{
|
||||
reason,
|
||||
"Окно за пациентом НЕ закреплено.",
|
||||
"Врач: " + it.DoctorName,
|
||||
"Выбранное время: " + formatSlotTime(it.Start),
|
||||
"Филиал: " + branchOrDash(it.Branch),
|
||||
}
|
||||
if it.ServiceName != "" {
|
||||
lines = append(lines, "Услуга: "+it.ServiceName)
|
||||
}
|
||||
lines = append(lines, "", "Позвоните пациенту и запишите его в 1С вручную.")
|
||||
|
||||
return crm.LeadDraft{
|
||||
LocalLeadId: fmt.Sprintf("booking_failed_%d", it.ID),
|
||||
Name: it.Patient,
|
||||
Phone: phone,
|
||||
Comment: strings.Join(lines, "\n"),
|
||||
SourceKey: "site_booking_fallback",
|
||||
SourceLabel: "Онлайн-запись на сайте (1С не записала)",
|
||||
AttributionTimestamp: &it.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Настройки в админке ----------
|
||||
|
||||
func (a *API) adminBookingSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||||
@ -368,7 +493,9 @@ func (a *API) adminBookingSettings(w http.ResponseWriter, r *http.Request, u *st
|
||||
ok(w, map[string]any{
|
||||
"settings": cfg,
|
||||
"confirm_enabled": a.bookingConfirmEnabled(),
|
||||
"bitrix_configured": a.bitrix.Configured(),
|
||||
"bitrix_configured": a.bitrix != nil && a.bitrix.Configured(),
|
||||
"crm_configured": a.crm != nil && a.crm.IsConfigured(),
|
||||
"crm_mode": a.crmMode,
|
||||
"smsc_configured": a.smscLogin != "",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
@ -104,3 +104,79 @@ func TestLeadDraftEscapesPatientInput(t *testing.T) {
|
||||
t.Errorf("источник/заголовок: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRMDraftsFormatting(t *testing.T) {
|
||||
it := storage.ConfirmItem{
|
||||
ID: 42,
|
||||
Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow),
|
||||
CreatedAt: time.Date(2026, 10, 9, 10, 0, 0, 0, moscow),
|
||||
Patient: "Иван Тестов",
|
||||
Phone: "8 (928) 123-45-67",
|
||||
DoctorName: "Доктор Тестов",
|
||||
Branch: "ул. Ленина, 10",
|
||||
ServiceName: "УЗИ брюшной полости",
|
||||
OnecID: "DOC-123",
|
||||
}
|
||||
|
||||
crmDraft := bookingConfirmToCRMDraft(it, storage.DefaultBookingSettings)
|
||||
if crmDraft.LocalLeadId != "booking_42" {
|
||||
t.Errorf("expected LocalLeadId booking_42, got %s", crmDraft.LocalLeadId)
|
||||
}
|
||||
if crmDraft.Phone != "+79281234567" {
|
||||
t.Errorf("expected normalized phone +79281234567, got %s", crmDraft.Phone)
|
||||
}
|
||||
if crmDraft.SourceKey != "site_booking" {
|
||||
t.Errorf("expected SourceKey site_booking, got %s", crmDraft.SourceKey)
|
||||
}
|
||||
for _, expectedText := range []string{"Онлайн-запись с сайта", "Доктор Тестов", "УЗИ брюшной полости", "DOC-123"} {
|
||||
if !strings.Contains(crmDraft.Comment, expectedText) {
|
||||
t.Errorf("expected comment to contain %q, got:\n%s", expectedText, crmDraft.Comment)
|
||||
}
|
||||
}
|
||||
|
||||
leadOnly := leadOnlyToCRMDraft(it)
|
||||
if leadOnly.LocalLeadId != "booking_failed_42" {
|
||||
t.Errorf("expected LocalLeadId booking_failed_42, got %s", leadOnly.LocalLeadId)
|
||||
}
|
||||
if leadOnly.SourceKey != "site_booking_fallback" {
|
||||
t.Errorf("expected SourceKey site_booking_fallback, got %s", leadOnly.SourceKey)
|
||||
}
|
||||
if !strings.Contains(leadOnly.Comment, "запишите его в 1С вручную") {
|
||||
t.Errorf("expected manual booking note in fallback comment, got:\n%s", leadOnly.Comment)
|
||||
}
|
||||
|
||||
leadItem := storage.LeadCRMItem{
|
||||
ID: 101,
|
||||
FormTitle: "Запись на консультацию",
|
||||
Name: "Мария",
|
||||
Phone: "89285554433",
|
||||
Email: "maria@example.com",
|
||||
Service: "Консультация кардиолога",
|
||||
Doctor: "Кардиолог К.К.",
|
||||
Branch: "Махачкала, ул. Ляхова 22",
|
||||
Message: "Хотелось бы попасть в субботу",
|
||||
PageURL: "https://danamedical.ru/doctors/kardiolog",
|
||||
UTM: map[string]string{
|
||||
"utm_source": "yandex",
|
||||
"utm_medium": "cpc",
|
||||
},
|
||||
CreatedAt: time.Date(2026, 10, 9, 11, 0, 0, 0, moscow),
|
||||
}
|
||||
|
||||
siteDraft := leadItemToCRMDraft(leadItem)
|
||||
if siteDraft.LocalLeadId != "site_lead_101" {
|
||||
t.Errorf("expected site_lead_101, got %s", siteDraft.LocalLeadId)
|
||||
}
|
||||
if siteDraft.Phone != "+79285554433" {
|
||||
t.Errorf("expected +79285554433, got %s", siteDraft.Phone)
|
||||
}
|
||||
if siteDraft.PagePath != "/doctors/kardiolog" {
|
||||
t.Errorf("expected /doctors/kardiolog, got %s", siteDraft.PagePath)
|
||||
}
|
||||
if siteDraft.UtmSource != "yandex" || siteDraft.UtmMedium != "cpc" {
|
||||
t.Errorf("UTM not properly preserved: %+v", siteDraft)
|
||||
}
|
||||
if !strings.Contains(siteDraft.Comment, "Форма: Запись на консультацию") {
|
||||
t.Errorf("Comment missing form title: %s", siteDraft.Comment)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"dana/internal/crm"
|
||||
"dana/internal/domain"
|
||||
"dana/internal/storage"
|
||||
)
|
||||
@ -148,6 +152,7 @@ func (a *API) createLead(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
a.notifyLead(id)
|
||||
a.sendLeadToCRM(id)
|
||||
|
||||
if successText == "" {
|
||||
successText = "Спасибо! Мы свяжемся с вами в ближайшее время."
|
||||
@ -205,3 +210,89 @@ func cleanUTM(in map[string]string) map[string]string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sendLeadToCRM отправляет заявку с сайта в новую CRM в фоновом режиме.
|
||||
func (a *API) sendLeadToCRM(id int64) {
|
||||
if !a.crmEnabled() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
it, err := a.store.LeadForCRM(ctx, id)
|
||||
if err != nil {
|
||||
slog.Error("crm lead fetch", "lead_id", id, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
draft := leadItemToCRMDraft(it)
|
||||
dealID, err := a.crm.CreateLead(ctx, draft, "")
|
||||
if err != nil {
|
||||
slog.Error("crm lead create failed", "lead_id", id, "err", err)
|
||||
_ = a.store.SetLeadCrmError(ctx, id, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.store.SetLeadCrmDeal(ctx, id, dealID); err != nil {
|
||||
slog.Error("crm save lead deal id", "lead_id", id, "deal_id", dealID, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("crm lead created", "lead_id", id, "deal_id", dealID)
|
||||
}()
|
||||
}
|
||||
|
||||
func leadItemToCRMDraft(it storage.LeadCRMItem) crm.LeadDraft {
|
||||
phone := it.Phone
|
||||
if n := normalizeRuPhone(it.Phone); n != "" {
|
||||
phone = "+" + n
|
||||
}
|
||||
|
||||
var commentLines []string
|
||||
if it.FormTitle != "" {
|
||||
commentLines = append(commentLines, "Форма: "+it.FormTitle)
|
||||
}
|
||||
if it.Service != "" {
|
||||
commentLines = append(commentLines, "Услуга: "+it.Service)
|
||||
}
|
||||
if it.Doctor != "" {
|
||||
commentLines = append(commentLines, "Врач: "+it.Doctor)
|
||||
}
|
||||
if it.Branch != "" {
|
||||
commentLines = append(commentLines, "Филиал: "+it.Branch)
|
||||
}
|
||||
if it.Message != "" {
|
||||
commentLines = append(commentLines, "Сообщение: "+it.Message)
|
||||
}
|
||||
|
||||
sourceLabel := "Заявка с сайта"
|
||||
if it.FormTitle != "" {
|
||||
sourceLabel = "Сайт: " + it.FormTitle
|
||||
}
|
||||
|
||||
pagePath := ""
|
||||
if it.PageURL != "" {
|
||||
if u, err := url.Parse(it.PageURL); err == nil && u.Path != "" {
|
||||
pagePath = u.Path
|
||||
}
|
||||
}
|
||||
|
||||
return crm.LeadDraft{
|
||||
LocalLeadId: fmt.Sprintf("site_lead_%d", it.ID),
|
||||
Name: it.Name,
|
||||
Phone: phone,
|
||||
Email: it.Email,
|
||||
Comment: strings.Join(commentLines, "\n"),
|
||||
SourceKey: "site",
|
||||
SourceLabel: sourceLabel,
|
||||
PagePath: pagePath,
|
||||
PageUrl: it.PageURL,
|
||||
Referer: it.UTM["referrer"],
|
||||
UtmSource: it.UTM["utm_source"],
|
||||
UtmMedium: it.UTM["utm_medium"],
|
||||
UtmCampaign: it.UTM["utm_campaign"],
|
||||
UtmTerm: it.UTM["utm_term"],
|
||||
UtmContent: it.UTM["utm_content"],
|
||||
AttributionTimestamp: &it.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,11 @@ type Config struct {
|
||||
// Битрикс24: входящий вебхук для разовой выгрузки номеров лидов/контактов в «Отчёт по каналам».
|
||||
BitrixWebhookURL string
|
||||
|
||||
// Новая CRM (dana-medccrm): шлюз создания сделок и режим работы.
|
||||
CrmAPIURL string
|
||||
CrmLeadIntakeSecret string
|
||||
CrmMode string // both (по умолчанию) | crm_only | bitrix_only
|
||||
|
||||
// SMSC.RU: подтверждение номера звонком (Wait-Call)
|
||||
SMSCLogin string
|
||||
SMSCPassword string
|
||||
@ -101,6 +106,9 @@ func Load() (Config, error) {
|
||||
OnecLongPoll: os.Getenv("ONEC_LONGPOLL") == "1",
|
||||
|
||||
BitrixWebhookURL: os.Getenv("BITRIX_WEBHOOK_URL"),
|
||||
CrmAPIURL: env("CRM_API_URL", "https://stagingcrm.dana-medical.ru"),
|
||||
CrmLeadIntakeSecret: os.Getenv("CRM_LEAD_INTAKE_SECRET"),
|
||||
CrmMode: env("CRM_MODE", "both"),
|
||||
SMSCLogin: os.Getenv("SMSC_LOGIN"),
|
||||
SMSCPassword: os.Getenv("SMSC_PASSWORD"),
|
||||
SMSCCallbackToken: os.Getenv("SMSC_CALLBACK_TOKEN"),
|
||||
|
||||
170
internal/crm/client.go
Normal file
170
internal/crm/client.go
Normal file
@ -0,0 +1,170 @@
|
||||
package crm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — клиент для передачи заявок в новую CRM (dana-medccrm).
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HMACSecret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New инициализирует клиент новой CRM.
|
||||
func New(baseURL, hmacSecret string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
HMACSecret: strings.TrimSpace(hmacSecret),
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured возвращает true, если адрес CRM и секрет настроены.
|
||||
func (c *Client) Configured() bool {
|
||||
return c != nil && c.BaseURL != "" && c.HMACSecret != ""
|
||||
}
|
||||
|
||||
// IsConfigured — алиас для Configured.
|
||||
func (c *Client) IsConfigured() bool {
|
||||
return c.Configured()
|
||||
}
|
||||
|
||||
// CanonicalJSON рекурсивно формирует каноническую строку JSON
|
||||
// с отсортированными ключами объектов (идентично реализации в dana-medccrm publicLeads).
|
||||
func CanonicalJSON(v any) (string, error) {
|
||||
if v == nil {
|
||||
return "null", nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
if val {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
case string:
|
||||
b, err := json.Marshal(val)
|
||||
return string(b), err
|
||||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
b, err := json.Marshal(val)
|
||||
return string(b), err
|
||||
case []any:
|
||||
parts := make([]string, len(val))
|
||||
for i, elem := range val {
|
||||
s, err := CanonicalJSON(elem)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts[i] = s
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]", nil
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(val))
|
||||
for k := range val {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
kb, _ := json.Marshal(k)
|
||||
vstr, err := CanonicalJSON(val[k])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts[i] = string(kb) + ":" + vstr
|
||||
}
|
||||
return "{" + strings.Join(parts, ",") + "}", nil
|
||||
default:
|
||||
// Для структур сначала приводим к generic типу через JSON round-trip
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var generic any
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return CanonicalJSON(generic)
|
||||
}
|
||||
}
|
||||
|
||||
// SignPayload рассчитывает SHA256-хэш тела и HMAC-SHA256 подпись по протоколу publicLeads.
|
||||
func SignPayload(secret, timestamp, idempotencyKey, canonicalBody string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(canonicalBody))
|
||||
payloadHash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
signInput := timestamp + "." + idempotencyKey + "." + payloadHash
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signInput))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// CreateLead отправляет лид в CRM через POST /api/public/leads.
|
||||
// При успехе возвращает ID созданной сделки (dealId).
|
||||
func (c *Client) CreateLead(ctx context.Context, draft LeadDraft, idempotencyKey string) (string, error) {
|
||||
if !c.Configured() {
|
||||
return "", errors.New("crm: клиент не настроен (отсутствует CRM_API_URL или CRM_LEAD_INTAKE_SECRET)")
|
||||
}
|
||||
|
||||
canonicalBody, err := CanonicalJSON(draft)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crm: ошибка сериализации канонического тела: %w", err)
|
||||
}
|
||||
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
if idempotencyKey == "" {
|
||||
idempotencyKey = fmt.Sprintf("site-%d-%d", time.Now().Unix(), time.Now().Nanosecond())
|
||||
}
|
||||
|
||||
sig := SignPayload(c.HMACSecret, timestamp, idempotencyKey, canonicalBody)
|
||||
|
||||
url := c.BaseURL + "/api/public/leads"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(canonicalBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crm: ошибка создания запроса: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-gt-timestamp", timestamp)
|
||||
req.Header.Set("x-gt-idempotency-key", idempotencyKey)
|
||||
req.Header.Set("x-gt-signature", sig)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crm: сетевая ошибка при вызове %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var errResp ErrorResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp.Error != "" {
|
||||
return "", fmt.Errorf("crm HTTP %d: %s", resp.StatusCode, errResp.Error)
|
||||
}
|
||||
return "", fmt.Errorf("crm HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var leadResp LeadResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&leadResp); err != nil {
|
||||
return "", fmt.Errorf("crm: ошибка разбора ответа: %w", err)
|
||||
}
|
||||
|
||||
if !leadResp.OK && leadResp.DealID == "" {
|
||||
return "", errors.New("crm: неожиданный ответ без dealId")
|
||||
}
|
||||
|
||||
return leadResp.DealID, nil
|
||||
}
|
||||
110
internal/crm/client_test.go
Normal file
110
internal/crm/client_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package crm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalJSONAndSignature(t *testing.T) {
|
||||
draft := LeadDraft{
|
||||
Name: "Иван Иванов",
|
||||
Phone: "+79991234567",
|
||||
Comment: "Тестовая запись",
|
||||
SourceLabel: "Сайт",
|
||||
}
|
||||
|
||||
canon, err := CanonicalJSON(draft)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalJSON failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that keys are sorted in canonical JSON
|
||||
expectedPrefix := `{"comment":"Тестовая запись","name":"Иван Иванов","phone":"+79991234567","sourceLabel":"Сайт"}`
|
||||
if canon != expectedPrefix {
|
||||
t.Errorf("CanonicalJSON got %s, want %s", canon, expectedPrefix)
|
||||
}
|
||||
|
||||
secret := "test-secret-key-123"
|
||||
timestamp := "1729080000000"
|
||||
idempotencyKey := "idemp-key-12345"
|
||||
|
||||
sig := SignPayload(secret, timestamp, idempotencyKey, canon)
|
||||
if len(sig) != 64 {
|
||||
t.Errorf("expected 64-char hex signature, got %s", sig)
|
||||
}
|
||||
|
||||
// Manual check
|
||||
h := sha256.Sum256([]byte(canon))
|
||||
ph := hex.EncodeToString(h[:])
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(timestamp + "." + idempotencyKey + "." + ph))
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if sig != expectedSig {
|
||||
t.Errorf("signature mismatch: got %s, want %s", sig, expectedSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateLeadSuccess(t *testing.T) {
|
||||
secret := "secret123"
|
||||
expectedDealID := "deal_xyz987"
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/public/leads" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected method: %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := r.Header.Get("x-gt-timestamp")
|
||||
idempotencyKey := r.Header.Get("x-gt-idempotency-key")
|
||||
sig := r.Header.Get("x-gt-signature")
|
||||
|
||||
if timestamp == "" || idempotencyKey == "" || sig == "" {
|
||||
t.Errorf("missing headers: timestamp=%s, idemp=%s, sig=%s", timestamp, idempotencyKey, sig)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("failed to decode body: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(LeadResponse{
|
||||
OK: true,
|
||||
DealID: expectedDealID,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := New(ts.URL, secret)
|
||||
if !client.Configured() {
|
||||
t.Fatal("client must be configured")
|
||||
}
|
||||
|
||||
dealID, err := client.CreateLead(context.Background(), LeadDraft{
|
||||
Name: "Анна Петрова",
|
||||
Phone: "+79281112233",
|
||||
}, "test-key-001")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLead failed: %v", err)
|
||||
}
|
||||
if dealID != expectedDealID {
|
||||
t.Errorf("got dealID %s, want %s", dealID, expectedDealID)
|
||||
}
|
||||
}
|
||||
39
internal/crm/types.go
Normal file
39
internal/crm/types.go
Normal file
@ -0,0 +1,39 @@
|
||||
package crm
|
||||
|
||||
import "time"
|
||||
|
||||
// LeadDraft — данные для отправки заявки или онлайн-записи в новую CRM.
|
||||
type LeadDraft struct {
|
||||
LocalLeadId string `json:"localLeadId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
SourceKey string `json:"sourceKey,omitempty"`
|
||||
SourceLabel string `json:"sourceLabel,omitempty"`
|
||||
PagePath string `json:"pagePath,omitempty"`
|
||||
PageUrl string `json:"pageUrl,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
UtmSource string `json:"utmSource,omitempty"`
|
||||
UtmMedium string `json:"utmMedium,omitempty"`
|
||||
UtmCampaign string `json:"utmCampaign,omitempty"`
|
||||
UtmTerm string `json:"utmTerm,omitempty"`
|
||||
UtmContent string `json:"utmContent,omitempty"`
|
||||
MetricaClientId string `json:"metricaClientId,omitempty"`
|
||||
Yclid string `json:"yclid,omitempty"`
|
||||
AttributionTimestamp *time.Time `json:"attributionTimestamp,omitempty"`
|
||||
}
|
||||
|
||||
// LeadResponse — успешный ответ от CRM шлюза /api/public/leads.
|
||||
type LeadResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
DealID string `json:"dealId"`
|
||||
Duplicate bool `json:"duplicate,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse — структура ошибки CRM API.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
@ -205,6 +205,18 @@ func (c *Client) LinkBitrix(ctx context.Context, appointmentID, bitrixLeadID str
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) LinkCRM(ctx context.Context, appointmentID, crmDealID string) error {
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"appointment_id": appointmentID,
|
||||
"crm_deal_id": crmDealID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.do(ctx, http.MethodPost, "/appointments/link-crm", nil, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
|
||||
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error {
|
||||
raw, err := c.do(ctx, http.MethodGet, path, q, nil)
|
||||
|
||||
@ -50,7 +50,7 @@ func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) err
|
||||
return s.AdminSetSettings(ctx, map[string]any{"booking": b})
|
||||
}
|
||||
|
||||
// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе.
|
||||
// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM.
|
||||
type ConfirmItem struct {
|
||||
ID int64
|
||||
Start time.Time
|
||||
@ -63,13 +63,17 @@ type ConfirmItem struct {
|
||||
ServiceName string
|
||||
OnecID string
|
||||
BitrixLeadID string
|
||||
CrmDealID string
|
||||
CrmConfirmState string
|
||||
CrmError string
|
||||
Error string // почему запись не прошла (для заявок без окна в 1С)
|
||||
}
|
||||
|
||||
const confirmItemSelect = `
|
||||
SELECT a.id, a.start_at, a.created_at, a.patient_name, a.patient_phone, a.doctor_onec_id,
|
||||
COALESCE(NULLIF(o.full_name, ''), a.doctor_onec_id), COALESCE(NULLIF(b.address, ''), b.name, ''),
|
||||
COALESCE(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error
|
||||
COALESCE(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error,
|
||||
a.crm_deal_id, a.crm_confirm_state, a.crm_error
|
||||
FROM onec_appointments a
|
||||
LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id
|
||||
LEFT JOIN onec_branches b ON b.onec_id = a.branch_onec_id
|
||||
@ -90,7 +94,7 @@ func (s *Storage) LeadOnlyQueue(ctx context.Context, limit int) ([]ConfirmItem,
|
||||
ORDER BY a.id LIMIT $1`, limit)
|
||||
}
|
||||
|
||||
// MarkLeadOnly ставит несостоявшуюся запись в очередь на отправку в Битрикс24.
|
||||
// MarkLeadOnly ставит несостоявшуюся запись в очередь на отправку в Битрикс24 / CRM.
|
||||
func (s *Storage) MarkLeadOnly(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_state = 'lead_only', updated_at = now()
|
||||
WHERE id = $1 AND status = 'failed' AND confirm_state = ''`, id)
|
||||
@ -104,6 +108,20 @@ func (s *Storage) LeadOnlySent(ctx context.Context, id int64, leadID string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// LeadOnlyCrmSent — сделка в новой CRM создана для несостоявшейся записи.
|
||||
func (s *Storage) LeadOnlyCrmSent(ctx context.Context, id int64, dealID string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET crm_deal_id = $2, crm_error = '', crm_sent_at = now(),
|
||||
updated_at = now() WHERE id = $1`, id, dealID)
|
||||
return err
|
||||
}
|
||||
|
||||
// FinishLeadOnly переводит запись в состояние 'lead_sent', когда все активные интеграции отработали.
|
||||
func (s *Storage) FinishLeadOnly(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_state = 'lead_sent',
|
||||
confirm_error = '', updated_at = now() WHERE id = $1 AND confirm_state = 'lead_only'`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]ConfirmItem, error) {
|
||||
rows, err := s.pool.Query(ctx, query, limit)
|
||||
if err != nil {
|
||||
@ -114,7 +132,8 @@ func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]
|
||||
for rows.Next() {
|
||||
var c ConfirmItem
|
||||
if err := rows.Scan(&c.ID, &c.Start, &c.CreatedAt, &c.Patient, &c.Phone, &c.DoctorOnec, &c.DoctorName,
|
||||
&c.Branch, &c.ServiceName, &c.OnecID, &c.BitrixLeadID, &c.Error); err != nil {
|
||||
&c.Branch, &c.ServiceName, &c.OnecID, &c.BitrixLeadID, &c.Error,
|
||||
&c.CrmDealID, &c.CrmConfirmState, &c.CrmError); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
@ -135,6 +154,19 @@ func (s *Storage) SetBitrixLead(ctx context.Context, id int64, leadID string) er
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCrmDeal привязывает ID сделки новой CRM к записи.
|
||||
func (s *Storage) SetCrmDeal(ctx context.Context, id int64, dealID string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET crm_deal_id = $2, crm_error = '', crm_sent_at = now(), updated_at = now()
|
||||
WHERE id = $1`, id, dealID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCrmError сохраняет ошибку отправки в новую CRM.
|
||||
func (s *Storage) SetCrmError(ctx context.Context, id int64, text string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET crm_error = $2, updated_at = now() WHERE id = $1`, id, text)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetConfirmError запоминает последнюю неудачу (Битрикс или 1С недоступны): попытка повторится.
|
||||
func (s *Storage) SetConfirmError(ctx context.Context, id int64, text string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_error = $2, updated_at = now() WHERE id = $1`, id, text)
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"dana/internal/domain"
|
||||
)
|
||||
@ -104,3 +105,64 @@ func (s *Storage) CreateLead(ctx context.Context, formID *int64, in domain.LeadI
|
||||
in.Message, extra, in.PageURL, utm, addr, userAgent).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// LeadCRMItem — данные заявки для передачи в CRM.
|
||||
type LeadCRMItem struct {
|
||||
ID int64
|
||||
FormCode string
|
||||
FormTitle string
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
Service string
|
||||
Doctor string
|
||||
Branch string
|
||||
Message string
|
||||
PageURL string
|
||||
UTM map[string]string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// LeadForCRM загружает полную информацию о заявке для передачи в новую CRM.
|
||||
func (s *Storage) LeadForCRM(ctx context.Context, id int64) (LeadCRMItem, error) {
|
||||
var (
|
||||
item LeadCRMItem
|
||||
utmRaw []byte
|
||||
)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT l.id,
|
||||
COALESCE(f.code, ''), COALESCE(f.title, ''),
|
||||
l.name, l.phone, l.email,
|
||||
COALESCE(sv.name, ''),
|
||||
concat_ws(' ', d.last_name, d.first_name, NULLIF(d.middle_name, '')),
|
||||
COALESCE(b.address, ''),
|
||||
l.message, l.page_url, l.utm, l.created_at
|
||||
FROM leads l
|
||||
LEFT JOIN forms f ON f.id = l.form_id
|
||||
LEFT JOIN services sv ON sv.id = l.service_id
|
||||
LEFT JOIN doctors d ON d.id = l.doctor_id
|
||||
LEFT JOIN branches b ON b.id = l.branch_id
|
||||
WHERE l.id = $1`, id).
|
||||
Scan(&item.ID, &item.FormCode, &item.FormTitle,
|
||||
&item.Name, &item.Phone, &item.Email,
|
||||
&item.Service, &item.Doctor, &item.Branch,
|
||||
&item.Message, &item.PageURL, &utmRaw, &item.CreatedAt)
|
||||
if err != nil {
|
||||
return item, wrapNotFound(err)
|
||||
}
|
||||
_ = json.Unmarshal(utmRaw, &item.UTM)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// SetLeadCrmDeal привязывает ID сделки новой CRM к заявке.
|
||||
func (s *Storage) SetLeadCrmDeal(ctx context.Context, id int64, dealID string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE leads SET crm_deal_id = $2, crm_error = '', crm_sent_at = now()
|
||||
WHERE id = $1`, id, dealID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLeadCrmError сохраняет ошибку отправки заявки в новую CRM.
|
||||
func (s *Storage) SetLeadCrmError(ctx context.Context, id int64, text string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE leads SET crm_error = $2 WHERE id = $1`, id, text)
|
||||
return err
|
||||
}
|
||||
|
||||
17
internal/storage/migrations/0026_crm_deals.sql
Normal file
17
internal/storage/migrations/0026_crm_deals.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Интеграция с новой CRM (dana-medccrm).
|
||||
-- Фиксация сделок по онлайн-записям и заявкам с сайта.
|
||||
|
||||
ALTER TABLE onec_appointments
|
||||
ADD COLUMN crm_deal_id text NOT NULL DEFAULT '',
|
||||
ADD COLUMN crm_confirm_state text NOT NULL DEFAULT '',
|
||||
ADD COLUMN crm_error text NOT NULL DEFAULT '',
|
||||
ADD COLUMN crm_sent_at timestamptz;
|
||||
|
||||
CREATE INDEX onec_appointments_crm_idx ON onec_appointments (id) WHERE crm_deal_id = '' AND status = 'booked';
|
||||
|
||||
ALTER TABLE leads
|
||||
ADD COLUMN crm_deal_id text NOT NULL DEFAULT '',
|
||||
ADD COLUMN crm_error text NOT NULL DEFAULT '',
|
||||
ADD COLUMN crm_sent_at timestamptz;
|
||||
|
||||
CREATE INDEX leads_crm_idx ON leads (id) WHERE crm_deal_id = '';
|
||||
@ -511,6 +511,10 @@ type OnecAppointment struct {
|
||||
ConfirmState string `json:"confirm_state"`
|
||||
BitrixLeadID string `json:"bitrix_lead_id"`
|
||||
ConfirmError string `json:"confirm_error"`
|
||||
// интеграция с новой CRM (см. booking_confirm.go)
|
||||
CrmDealID string `json:"crm_deal_id"`
|
||||
CrmConfirmState string `json:"crm_confirm_state"`
|
||||
CrmError string `json:"crm_error"`
|
||||
}
|
||||
|
||||
// BeginOnecAppointment создаёт запись в статусе pending. Если такой request_id уже был, возвращает прежнюю запись
|
||||
@ -553,7 +557,8 @@ func (s *Storage) OnecAppointments(ctx context.Context, limit int) ([]OnecAppoin
|
||||
SELECT a.id, a.request_id, a.lead_id, a.doctor_onec_id, COALESCE(NULLIF(o.full_name, ''), a.doctor_onec_id),
|
||||
COALESCE(NULLIF(b.address, ''), b.name, ''), a.start_at, a.end_at, a.patient_name, a.patient_phone,
|
||||
a.onec_appointment_id, a.status, a.error, a.created_at, COALESCE(sv.name, ''),
|
||||
a.confirm_state, a.bitrix_lead_id, a.confirm_error
|
||||
a.confirm_state, a.bitrix_lead_id, a.confirm_error,
|
||||
a.crm_deal_id, a.crm_confirm_state, a.crm_error
|
||||
FROM onec_appointments a
|
||||
LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id
|
||||
LEFT JOIN onec_branches b ON b.onec_id = a.branch_onec_id
|
||||
@ -568,7 +573,8 @@ func (s *Storage) OnecAppointments(ctx context.Context, limit int) ([]OnecAppoin
|
||||
var r OnecAppointment
|
||||
if err := rows.Scan(&r.ID, &r.RequestID, &r.LeadID, &r.DoctorOnec, &r.DoctorName, &r.Branch, &r.Start, &r.End,
|
||||
&r.Patient, &r.Phone, &r.OnecID, &r.Status, &r.Error, &r.CreatedAt, &r.ServiceName,
|
||||
&r.ConfirmState, &r.BitrixLeadID, &r.ConfirmError); err != nil {
|
||||
&r.ConfirmState, &r.BitrixLeadID, &r.ConfirmError,
|
||||
&r.CrmDealID, &r.CrmConfirmState, &r.CrmError); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user