180 lines
9.0 KiB
Go
180 lines
9.0 KiB
Go
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
|
||
}
|