package storage import ( "context" "encoding/json" "time" ) // BookingSettings — правила онлайн-записи, которые меняет администратор (ключ "booking" в settings). type BookingSettings struct { // PhoneCheck — требовать проверочный звонок от пациента перед записью. По умолчанию выключен: // запись подтверждает оператор в Битрикс24. PhoneCheck bool `json:"phone_check"` // AutoCancelHours — через сколько часов после записи отменить её, если оператор не подтвердил. AutoCancelHours int `json:"autocancel_hours"` // CancelBeforeHours — не позже чем за сколько часов до приёма отменить неподтверждённую запись. CancelBeforeHours int `json:"cancel_before_hours"` // Защита расписания от ботов (0 — правило выключено). Сверх лимита сайт просит проверочный // звонок: настоящий пациент записывается дальше, бот останавливается. Без SMSC — заявка оператору. MaxPerDoctorDay int `json:"max_per_doctor_day"` // неподтверждённых записей к врачу на один день MaxPerHour int `json:"max_per_hour"` // онлайн-записей с сайта за последний час MaxActivePerPhone int `json:"max_active_per_phone"` // будущих записей на один номер } // DefaultBookingSettings — то, что действует, пока администратор ничего не менял. // Поля, которых нет в сохранённых настройках, тоже берутся отсюда. var DefaultBookingSettings = BookingSettings{ AutoCancelHours: 24, CancelBeforeHours: 2, MaxPerDoctorDay: 3, MaxPerHour: 10, MaxActivePerPhone: 3, } func (s *Storage) BookingSettings(ctx context.Context) (BookingSettings, error) { out := DefaultBookingSettings var raw []byte err := s.pool.QueryRow(ctx, `SELECT value FROM settings WHERE key = 'booking'`).Scan(&raw) if wrapNotFound(err) == ErrNotFound { return out, nil } if err != nil { return out, err } if err := json.Unmarshal(raw, &out); err != nil { return DefaultBookingSettings, err } return out, nil } func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) error { return s.AdminSetSettings(ctx, map[string]any{"booking": b}) } // ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM. type ConfirmItem struct { Comment string 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, a.crm_deal_id, a.crm_confirm_state, a.crm_error, a.patient_comment 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 LEFT JOIN onec_services sv ON sv.doctor_onec_id = a.doctor_onec_id AND sv.onec_id = a.service_onec_id` // ConfirmQueue — записи, которые занимают окно в 1С и ещё ждут подтверждения. func (s *Storage) ConfirmQueue(ctx context.Context, limit int) ([]ConfirmItem, error) { return s.confirmItems(ctx, confirmItemSelect+` WHERE a.confirm_state = 'waiting' AND a.status = 'booked' ORDER BY a.id LIMIT $1`, limit) } // LeadOnlyQueue — заявки, которые не удалось записать в 1С: их надо передать оператору лидом, // чтобы он записал пациента вручную. confirm_state: lead_only — ждёт отправки, lead_sent — лид создан. func (s *Storage) LeadOnlyQueue(ctx context.Context, limit int) ([]ConfirmItem, error) { return s.confirmItems(ctx, confirmItemSelect+` WHERE a.confirm_state = 'lead_only' AND a.status = 'failed' ORDER BY a.id LIMIT $1`, limit) } // 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) return err } // LeadOnlySent — лид для несостоявшейся записи создан, дальше заявку ведёт оператор. func (s *Storage) LeadOnlySent(ctx context.Context, id int64, leadID string) error { _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET bitrix_lead_id = $2, confirm_state = 'lead_sent', confirm_error = '', updated_at = now() WHERE id = $1 AND confirm_state = 'lead_only'`, id, leadID) 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 { return nil, err } defer rows.Close() out := []ConfirmItem{} 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, &c.CrmDealID, &c.CrmConfirmState, &c.CrmError, &c.Comment); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // StartBookingConfirm ставит успешную запись в очередь на подтверждение оператором. func (s *Storage) StartBookingConfirm(ctx context.Context, id int64) error { _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET confirm_state = 'waiting', updated_at = now() WHERE id = $1 AND status = 'booked' AND confirm_state = ''`, id) return err } func (s *Storage) SetBitrixLead(ctx context.Context, id int64, leadID string) error { _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET bitrix_lead_id = $2, confirm_error = '', updated_at = now() WHERE id = $1`, id, leadID) 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) return err } // FinishBookingConfirm закрывает ожидание. Для отмен (cancelled, expired) запись помечается отменённой. // Условие confirm_state = 'waiting' защищает от гонки с вебхуком 1С и повторной обработки. func (s *Storage) FinishBookingConfirm(ctx context.Context, id int64, state string) error { _, err := s.pool.Exec(ctx, ` UPDATE onec_appointments SET confirm_state = $2::text, confirm_error = '', confirmed_at = CASE WHEN $2::text = 'confirmed' THEN now() ELSE confirmed_at END, status = CASE WHEN $2::text IN ('cancelled', 'expired') THEN 'cancelled' ELSE status END, updated_at = now() WHERE id = $1 AND confirm_state = 'waiting'`, id, state) return err } // WaitingForDoctor — неподтверждённые записи к врачу с приёмом в [from, to). func (s *Storage) WaitingForDoctor(ctx context.Context, doctorOnecID string, from, to time.Time) (int, error) { var n int err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments WHERE doctor_onec_id = $1 AND confirm_state = 'waiting' AND status = 'booked' AND start_at >= $2 AND start_at < $3`, doctorOnecID, from, to).Scan(&n) return n, err } // SiteBookingsSince — попытки онлайн-записи с сайта (любой исход) начиная с since: всплеск — признак бота. func (s *Storage) SiteBookingsSince(ctx context.Context, since time.Time) (int, error) { var n int err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments WHERE created_at >= $1`, since).Scan(&n) return n, err } // ActiveBookingsForPhone — будущие записи с сайта на номер (сравнение по последним 10 цифрам). func (s *Storage) ActiveBookingsForPhone(ctx context.Context, phone10 string) (int, error) { var n int err := s.pool.QueryRow(ctx, `SELECT count(*) FROM onec_appointments WHERE status = 'booked' AND start_at > now() AND right(regexp_replace(patient_phone, '[^0-9]', '', 'g'), 10) = $1`, phone10).Scan(&n) return n, err }