dana-medical-site/internal/storage/booking_confirm.go
2026-10-14 13:33:13 +00:00

180 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}