diff --git a/.env.example b/.env.example index 2a83b68..909ad06 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/bridge/bridge_1c.py b/bridge/bridge_1c.py index 1ea7365..51fa798 100644 --- a/bridge/bridge_1c.py +++ b/bridge/bridge_1c.py @@ -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): diff --git a/cmd/server/main.go b/cmd/server/main.go index 16ca929..d76512d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/deploy.env.example b/deploy.env.example index 5574914..900a1b7 100644 --- a/deploy.env.example +++ b/deploy.env.example @@ -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 diff --git a/internal/api/api.go b/internal/api/api.go index a3474f3..7d9fd75 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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: без токена в адресе они отклоняются. diff --git a/internal/api/booking_confirm.go b/internal/api/booking_confirm.go index 23d3a30..af1cfc0 100644 --- a/internal/api/booking_confirm.go +++ b/internal/api/booking_confirm.go @@ -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,65 +172,97 @@ 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()) + } 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 it.OnecID != "" { + if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil { + slog.Warn("booking confirm: не удалось связать 1С с Битриксом", "onec_id", it.OnecID, "lead", id, "err", err) + } else { + slog.Info("booking confirm: заявка в 1С связана с лидом Битрикса", "onec_id", it.OnecID, "lead", id) + } + } + } + } + + 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 + } + for _, it := range items { + if it.BitrixLeadID == "" { continue } - 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 it.OnecID != "" { - if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil { - slog.Warn("booking confirm: не удалось связать 1С с Битриксом", "onec_id", it.OnecID, "lead", id, "err", err) - } else { - slog.Info("booking confirm: заявка в 1С связана с лидом Битрикса", "onec_id", it.OnecID, "lead", id) + switch decideConfirm(sem[it.BitrixLeadID], it.CreatedAt, it.Start, now, cfg) { + case confirmOK: + a.finishConfirm(ctx, it, "confirmed") + case confirmMissed: + a.finishConfirm(ctx, it, "missed") + case confirmCancel: + if a.cancelUnconfirmed(ctx, it) { + a.finishConfirm(ctx, it, "cancelled") + a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.") + } + case confirmExpire: + if a.cancelUnconfirmed(ctx, it) { + a.finishConfirm(ctx, it, "expired") + if err := a.bitrix.FailLead(ctx, it.BitrixLeadID); err != nil { + slog.Warn("booking confirm: стадия лида не обновлена", "lead", it.BitrixLeadID, "err", err) + } + a.leadNote(ctx, it, "Запись не подтвердили вовремя — сайт отменил её в 1С и перевёл лид в «Клиент не записался». "+ + "Если пациент всё же придёт, запишите его заново.") } } } - ids = append(ids, it.BitrixLeadID) - } - if len(ids) == 0 { - return nil - } - - // 2. Стадии лидов. Без ответа Битрикса ничего не отменяем: вдруг оператор уже подтвердил. - sem, err := a.bitrix.LeadSemantics(ctx, ids) - if err != nil { - return err - } - for _, it := range items { - if it.BitrixLeadID == "" { - continue - } - switch decideConfirm(sem[it.BitrixLeadID], it.CreatedAt, it.Start, now, cfg) { - case confirmOK: - a.finishConfirm(ctx, it, "confirmed") - case confirmMissed: - a.finishConfirm(ctx, it, "missed") - case confirmCancel: - if a.cancelUnconfirmed(ctx, it) { - a.finishConfirm(ctx, it, "cancelled") - a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.") - } - case confirmExpire: - if a.cancelUnconfirmed(ctx, it) { - a.finishConfirm(ctx, it, "expired") - if err := a.bitrix.FailLead(ctx, it.BitrixLeadID); err != nil { - slog.Warn("booking confirm: стадия лида не обновлена", "lead", it.BitrixLeadID, "err", err) + } 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") } - a.leadNote(ctx, it, "Запись не подтвердили вовремя — сайт отменил её в 1С и перевёл лид в «Клиент не записался». "+ - "Если пациент всё же придёт, запишите его заново.") } } } @@ -243,17 +276,43 @@ 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)) - if err != nil { - slog.Warn("booking confirm: лид по заявке не создан", "appointment", it.ID, "err", err) - _ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error()) - continue + 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) + _ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error()) + } 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 err := a.store.LeadOnlySent(ctx, it.ID, id); err != nil { - return err + + 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) + } } - slog.Info("booking confirm: заявка передана в Битрикс", "appointment", it.ID, "lead", id) } 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) } diff --git a/internal/api/booking_confirm_test.go b/internal/api/booking_confirm_test.go index 634ac71..2fbdb6e 100644 --- a/internal/api/booking_confirm_test.go +++ b/internal/api/booking_confirm_test.go @@ -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) + } +} diff --git a/internal/api/handlers_leads.go b/internal/api/handlers_leads.go index 5d42e5b..f839115 100644 --- a/internal/api/handlers_leads.go +++ b/internal/api/handlers_leads.go @@ -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, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d2d70d9..851429b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -100,8 +105,11 @@ func Load() (Config, error) { OnecSyncSecs: envInt("ONEC_SYNC_SECONDS", 300), OnecLongPoll: os.Getenv("ONEC_LONGPOLL") == "1", - BitrixWebhookURL: os.Getenv("BITRIX_WEBHOOK_URL"), - SMSCLogin: os.Getenv("SMSC_LOGIN"), + 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"), TrustedProxies: os.Getenv("TRUSTED_PROXIES"), diff --git a/internal/crm/client.go b/internal/crm/client.go new file mode 100644 index 0000000..4297dd9 --- /dev/null +++ b/internal/crm/client.go @@ -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 +} diff --git a/internal/crm/client_test.go b/internal/crm/client_test.go new file mode 100644 index 0000000..86526b1 --- /dev/null +++ b/internal/crm/client_test.go @@ -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) + } +} diff --git a/internal/crm/types.go b/internal/crm/types.go new file mode 100644 index 0000000..be1584a --- /dev/null +++ b/internal/crm/types.go @@ -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"` +} diff --git a/internal/onec/client.go b/internal/onec/client.go index 3e8357b..2898f7a 100644 --- a/internal/onec/client.go +++ b/internal/onec/client.go @@ -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 принимает ответ и в виде массива, и в виде объекта {"": [...]}. 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) diff --git a/internal/storage/booking_confirm.go b/internal/storage/booking_confirm.go index b416973..972a10f 100644 --- a/internal/storage/booking_confirm.go +++ b/internal/storage/booking_confirm.go @@ -50,26 +50,30 @@ 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 - CreatedAt time.Time - Patient string - Phone string - DoctorOnec string - DoctorName string - Branch string - ServiceName string - OnecID string - BitrixLeadID string - Error string // почему запись не прошла (для заявок без окна в 1С) + ID int64 + Start time.Time + CreatedAt time.Time + Patient string + Phone string + DoctorOnec string + DoctorName string + Branch string + 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) diff --git a/internal/storage/leads.go b/internal/storage/leads.go index 0d79f93..16ae23c 100644 --- a/internal/storage/leads.go +++ b/internal/storage/leads.go @@ -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 +} diff --git a/internal/storage/migrations/0026_crm_deals.sql b/internal/storage/migrations/0026_crm_deals.sql new file mode 100644 index 0000000..570e65b --- /dev/null +++ b/internal/storage/migrations/0026_crm_deals.sql @@ -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 = ''; diff --git a/internal/storage/onec.go b/internal/storage/onec.go index 53e509e..6b34b6c 100644 --- a/internal/storage/onec.go +++ b/internal/storage/onec.go @@ -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)