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:
Abdulkadyr Karagishiev 2026-10-16 18:01:49 +03:00
parent b508d2dc09
commit 121f286016
17 changed files with 947 additions and 81 deletions

View File

@ -17,3 +17,11 @@ SITE_URL=http://127.0.0.1:8080
SSR_RENDERER=./web/scripts/render-page.mjs SSR_RENDERER=./web/scripts/render-page.mjs
NODE_BINARY=node NODE_BINARY=node
# SSR_RENDERER=off — только снимки сборки, требующие ручной пересборки после правок. # 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

View File

@ -737,6 +737,78 @@ class BridgeHandler(BaseHTTPRequestHandler):
self.send_json(200, {"status": "ok", "message": "Связано успешно"}) self.send_json(200, {"status": "ok", "message": "Связано успешно"})
return 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"}) self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
def do_DELETE(self): def do_DELETE(self):

View File

@ -13,6 +13,7 @@ import (
"dana/internal/api" "dana/internal/api"
"dana/internal/bitrix" "dana/internal/bitrix"
"dana/internal/config" "dana/internal/config"
"dana/internal/crm"
"dana/internal/megafon" "dana/internal/megafon"
"dana/internal/notify" "dana/internal/notify"
"dana/internal/onec" "dana/internal/onec"
@ -84,6 +85,7 @@ func run() error {
handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll) handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll)
go handler.RunOneCSync(ctx) go handler.RunOneCSync(ctx)
handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL)) handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL))
handler.WithCRM(crm.New(cfg.CrmAPIURL, cfg.CrmLeadIntakeSecret), cfg.CrmMode)
go handler.RunBookingConfirm(ctx) go handler.RunBookingConfirm(ctx)
handler.WithSMSC(cfg.SMSCLogin, cfg.SMSCPassword) handler.WithSMSC(cfg.SMSCLogin, cfg.SMSCPassword)
handler.WithSMSCCallbackToken(cfg.SMSCCallbackToken) handler.WithSMSCCallbackToken(cfg.SMSCCallbackToken)

View File

@ -22,3 +22,11 @@ NOTIFY_TO=
# данными, но автоматическая синхронизация отключена. # данными, но автоматическая синхронизация отключена.
MEGAFON_API_URL=vats970345.megapbx.ru MEGAFON_API_URL=vats970345.megapbx.ru
MEGAFON_API_KEY= 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

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"dana/internal/bitrix" "dana/internal/bitrix"
"dana/internal/crm"
"dana/internal/megafon" "dana/internal/megafon"
"dana/internal/notify" "dana/internal/notify"
"dana/internal/onec" "dana/internal/onec"
@ -30,6 +31,8 @@ type API struct {
onecEvery time.Duration onecEvery time.Duration
onecLongPoll bool onecLongPoll bool
bitrix *bitrix.Client bitrix *bitrix.Client
crm *crm.Client
crmMode string
smscLogin string smscLogin string
smscPassword string smscPassword string
smscCallbackToken string smscCallbackToken string
@ -43,6 +46,29 @@ type API struct {
// Без вебхука кнопка в отчёте просто не предлагается. // Без вебхука кнопка в отчёте просто не предлагается.
func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c } 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 } func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password }
// WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются. // WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются.

View File

@ -12,6 +12,7 @@ import (
"time" "time"
"dana/internal/bitrix" "dana/internal/bitrix"
"dana/internal/crm"
"dana/internal/onec" "dana/internal/onec"
"dana/internal/storage" "dana/internal/storage"
) )
@ -82,7 +83,7 @@ func (a *API) bookingRiskReason(ctx context.Context, cfg storage.BookingSettings
} }
func (a *API) bookingConfirmEnabled() bool { func (a *API) bookingConfirmEnabled() bool {
return a.bitrix.Configured() && a.onec.Configured() return a.onec != nil && a.onec.Configured() && (a.bitrixEnabled() || a.crmEnabled())
} }
// kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты. // kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты.
@ -134,10 +135,10 @@ func decideConfirm(semantic string, created, start, now time.Time, cfg storage.B
// RunBookingConfirm — фоновый цикл: заводит лиды и следит за их стадиями. // RunBookingConfirm — фоновый цикл: заводит лиды и следит за их стадиями.
func (a *API) RunBookingConfirm(ctx context.Context) { func (a *API) RunBookingConfirm(ctx context.Context) {
if !a.bookingConfirmEnabled() { if !a.bookingConfirmEnabled() {
slog.Info("booking confirm disabled: нужны и 1С, и BITRIX_WEBHOOK_URL") slog.Info("booking confirm disabled: нужны 1С и настроенный Bitrix или CRM")
return return
} }
slog.Info("booking confirm enabled: онлайн-записи подтверждает оператор в Битрикс24") slog.Info("booking confirm enabled: онлайн-записи передаются в CRM/Битрикс24", "mode", a.crmMode)
t := time.NewTicker(confirmEvery) t := time.NewTicker(confirmEvery)
defer t.Stop() defer t.Stop()
for { for {
@ -171,65 +172,97 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
return nil return nil
} }
// 1. Лиды для новых записей. Не получилось — запись ждёт следующей попытки и автоматически // 1. Лиды/сделки для новых записей.
// не отменяется: оператор её ещё не видел. var bitrixIds []string
var ids []string
for i := range items { for i := range items {
it := &items[i] it := &items[i]
if it.BitrixLeadID == "" { if a.bitrixEnabled() && it.BitrixLeadID == "" {
id, err := a.bitrix.AddLead(ctx, leadDraft(*it, cfg)) id, err := a.bitrix.AddLead(ctx, leadDraft(*it, cfg))
if err != nil { 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()) _ = 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 continue
} }
if err := a.store.SetBitrixLead(ctx, it.ID, id); err != nil { switch decideConfirm(sem[it.BitrixLeadID], it.CreatedAt, it.Start, now, cfg) {
return err case confirmOK:
} a.finishConfirm(ctx, it, "confirmed")
it.BitrixLeadID = id case confirmMissed:
slog.Info("booking confirm: лид создан", "appointment", it.ID, "lead", id) a.finishConfirm(ctx, it, "missed")
case confirmCancel:
if it.OnecID != "" { if a.cancelUnconfirmed(ctx, it) {
if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil { a.finishConfirm(ctx, it, "cancelled")
slog.Warn("booking confirm: не удалось связать 1С с Битриксом", "onec_id", it.OnecID, "lead", id, "err", err) a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.")
} else { }
slog.Info("booking confirm: заявка в 1С связана с лидом Битрикса", "onec_id", it.OnecID, "lead", id) 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) } else if !a.bitrixEnabled() && a.crmEnabled() {
} // В режиме CRM-only проверяем автоотмену по истечении срока подтверждения
if len(ids) == 0 { for _, it := range items {
return nil if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok && !now.Before(d) {
} if a.cancelUnconfirmed(ctx, it) {
a.finishConfirm(ctx, it, "expired")
// 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)
} }
a.leadNote(ctx, it, "Запись не подтвердили вовремя — сайт отменил её в 1С и перевёл лид в «Клиент не записался». "+
"Если пациент всё же придёт, запишите его заново.")
} }
} }
} }
@ -243,17 +276,43 @@ func (a *API) sendLeadOnly(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
for _, it := range items { for i := range items {
id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(it)) it := &items[i]
if err != nil { if a.bitrixEnabled() && it.BitrixLeadID == "" {
slog.Warn("booking confirm: лид по заявке не создан", "appointment", it.ID, "err", err) id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(*it))
_ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error()) if err != nil {
continue 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 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) { 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{ ok(w, map[string]any{
"settings": cfg, "settings": cfg,
"confirm_enabled": a.bookingConfirmEnabled(), "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 != "", "smsc_configured": a.smscLogin != "",
}, nil) }, nil)
} }

View File

@ -104,3 +104,79 @@ func TestLeadDraftEscapesPatientInput(t *testing.T) {
t.Errorf("источник/заголовок: %+v", d) 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)
}
}

View File

@ -1,16 +1,20 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"dana/internal/crm"
"dana/internal/domain" "dana/internal/domain"
"dana/internal/storage" "dana/internal/storage"
) )
@ -148,6 +152,7 @@ func (a *API) createLead(w http.ResponseWriter, r *http.Request) {
} }
a.notifyLead(id) a.notifyLead(id)
a.sendLeadToCRM(id)
if successText == "" { if successText == "" {
successText = "Спасибо! Мы свяжемся с вами в ближайшее время." successText = "Спасибо! Мы свяжемся с вами в ближайшее время."
@ -205,3 +210,89 @@ func cleanUTM(in map[string]string) map[string]string {
} }
return out 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,
}
}

View File

@ -49,6 +49,11 @@ type Config struct {
// Битрикс24: входящий вебхук для разовой выгрузки номеров лидов/контактов в «Отчёт по каналам». // Битрикс24: входящий вебхук для разовой выгрузки номеров лидов/контактов в «Отчёт по каналам».
BitrixWebhookURL string BitrixWebhookURL string
// Новая CRM (dana-medccrm): шлюз создания сделок и режим работы.
CrmAPIURL string
CrmLeadIntakeSecret string
CrmMode string // both (по умолчанию) | crm_only | bitrix_only
// SMSC.RU: подтверждение номера звонком (Wait-Call) // SMSC.RU: подтверждение номера звонком (Wait-Call)
SMSCLogin string SMSCLogin string
SMSCPassword string SMSCPassword string
@ -100,8 +105,11 @@ func Load() (Config, error) {
OnecSyncSecs: envInt("ONEC_SYNC_SECONDS", 300), OnecSyncSecs: envInt("ONEC_SYNC_SECONDS", 300),
OnecLongPoll: os.Getenv("ONEC_LONGPOLL") == "1", OnecLongPoll: os.Getenv("ONEC_LONGPOLL") == "1",
BitrixWebhookURL: os.Getenv("BITRIX_WEBHOOK_URL"), BitrixWebhookURL: os.Getenv("BITRIX_WEBHOOK_URL"),
SMSCLogin: os.Getenv("SMSC_LOGIN"), 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"), SMSCPassword: os.Getenv("SMSC_PASSWORD"),
SMSCCallbackToken: os.Getenv("SMSC_CALLBACK_TOKEN"), SMSCCallbackToken: os.Getenv("SMSC_CALLBACK_TOKEN"),
TrustedProxies: os.Getenv("TRUSTED_PROXIES"), TrustedProxies: os.Getenv("TRUSTED_PROXIES"),

170
internal/crm/client.go Normal file
View 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
View 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
View 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"`
}

View File

@ -205,6 +205,18 @@ func (c *Client) LinkBitrix(ctx context.Context, appointmentID, bitrixLeadID str
return err 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>": [...]}. // getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error { 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) raw, err := c.do(ctx, http.MethodGet, path, q, nil)

View File

@ -50,26 +50,30 @@ func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) err
return s.AdminSetSettings(ctx, map[string]any{"booking": b}) return s.AdminSetSettings(ctx, map[string]any{"booking": b})
} }
// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе. // ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM.
type ConfirmItem struct { type ConfirmItem struct {
ID int64 ID int64
Start time.Time Start time.Time
CreatedAt time.Time CreatedAt time.Time
Patient string Patient string
Phone string Phone string
DoctorOnec string DoctorOnec string
DoctorName string DoctorName string
Branch string Branch string
ServiceName string ServiceName string
OnecID string OnecID string
BitrixLeadID string BitrixLeadID string
Error string // почему запись не прошла (для заявок без окна в 1С) CrmDealID string
CrmConfirmState string
CrmError string
Error string // почему запись не прошла (для заявок без окна в 1С)
} }
const confirmItemSelect = ` const confirmItemSelect = `
SELECT a.id, a.start_at, a.created_at, a.patient_name, a.patient_phone, a.doctor_onec_id, 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(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 FROM onec_appointments a
LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id 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 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) ORDER BY a.id LIMIT $1`, limit)
} }
// MarkLeadOnly ставит несостоявшуюся запись в очередь на отправку в Битрикс24. // MarkLeadOnly ставит несостоявшуюся запись в очередь на отправку в Битрикс24 / CRM.
func (s *Storage) MarkLeadOnly(ctx context.Context, id int64) error { 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() _, 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) 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 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) { func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]ConfirmItem, error) {
rows, err := s.pool.Query(ctx, query, limit) rows, err := s.pool.Query(ctx, query, limit)
if err != nil { if err != nil {
@ -114,7 +132,8 @@ func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]
for rows.Next() { for rows.Next() {
var c ConfirmItem var c ConfirmItem
if err := rows.Scan(&c.ID, &c.Start, &c.CreatedAt, &c.Patient, &c.Phone, &c.DoctorOnec, &c.DoctorName, 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 return nil, err
} }
out = append(out, c) out = append(out, c)
@ -135,6 +154,19 @@ func (s *Storage) SetBitrixLead(ctx context.Context, id int64, leadID string) er
return err 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С недоступны): попытка повторится. // SetConfirmError запоминает последнюю неудачу (Битрикс или 1С недоступны): попытка повторится.
func (s *Storage) SetConfirmError(ctx context.Context, id int64, text string) error { 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) _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_error = $2, updated_at = now() WHERE id = $1`, id, text)

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/netip" "net/netip"
"time"
"dana/internal/domain" "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) in.Message, extra, in.PageURL, utm, addr, userAgent).Scan(&id)
return id, err 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
}

View 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 = '';

View File

@ -511,6 +511,10 @@ type OnecAppointment struct {
ConfirmState string `json:"confirm_state"` ConfirmState string `json:"confirm_state"`
BitrixLeadID string `json:"bitrix_lead_id"` BitrixLeadID string `json:"bitrix_lead_id"`
ConfirmError string `json:"confirm_error"` 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 уже был, возвращает прежнюю запись // 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), 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, 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.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 FROM onec_appointments a
LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id 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 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 var r OnecAppointment
if err := rows.Scan(&r.ID, &r.RequestID, &r.LeadID, &r.DoctorOnec, &r.DoctorName, &r.Branch, &r.Start, &r.End, 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.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 return nil, err
} }
out = append(out, r) out = append(out, r)