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 — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе. 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С) } 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 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. 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 } 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); 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 } // 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 }