package storage import ( "context" "errors" "strings" "time" "github.com/jackc/pgx/v5" "dana/internal/onec" ) var ErrOnecSlotBusy = errors.New("slot already reserved") var ErrOnecTransition = errors.New("appointment state conflict") var moscow = time.FixedZone("Europe/Moscow", 3*60*60) // ---------- Справочники ---------- // normName приводит ФИО к виду для сравнения: без регистра, «ё» = «е», один пробел. func normName(s string) string { s = strings.ToLower(strings.ReplaceAll(s, "ё", "е")) return strings.Join(strings.Fields(s), " ") } // SaveOnecDirectory сохраняет филиалы и врачей из 1С и сам привязывает врачей к карточкам сайта, // когда ФИО совпало ровно с одной ещё не привязанной карточкой. Уже сделанную вручную привязку не трогает. // Врачей, которых 1С больше не отдаёт, помечает неактивными. func (s *Storage) SaveOnecDirectory(ctx context.Context, branches []onec.Branch, doctors []onec.Doctor) (linked int, err error) { tx, err := s.pool.Begin(ctx) if err != nil { return 0, err } defer tx.Rollback(ctx) for _, b := range branches { if _, err := tx.Exec(ctx, ` INSERT INTO onec_branches (onec_id, name, address, seen_at) VALUES ($1,$2,$3, now()) ON CONFLICT (onec_id) DO UPDATE SET name=EXCLUDED.name, address=EXCLUDED.address, seen_at=now()`, b.ID, b.Name, b.Address); err != nil { return 0, err } } seen := make([]string, 0, len(doctors)) for _, d := range doctors { seen = append(seen, d.ID) if _, err := tx.Exec(ctx, ` INSERT INTO onec_doctors (onec_id, full_name, specialties, branch_ids, kids_from, active, seen_at) VALUES ($1,$2,$3,$4,$5,$6, now()) ON CONFLICT (onec_id) DO UPDATE SET full_name=EXCLUDED.full_name, specialties=EXCLUDED.specialties, branch_ids=EXCLUDED.branch_ids, kids_from=EXCLUDED.kids_from, active=EXCLUDED.active, seen_at=now()`, d.ID, d.Name, nonNil(d.Specialties), nonNil(d.BranchIDs), d.KidsFrom, d.IsActive()); err != nil { return 0, err } } if len(seen) > 0 { if _, err := tx.Exec(ctx, `UPDATE onec_doctors SET active = false WHERE NOT (onec_id = ANY($1))`, seen); err != nil { return 0, err } } // автопривязка по ФИО rows, err := tx.Query(ctx, `SELECT id, last_name, first_name, middle_name FROM doctors WHERE deleted_at IS NULL AND id NOT IN (SELECT doctor_id FROM onec_doctors WHERE doctor_id IS NOT NULL)`) if err != nil { return 0, err } byName := map[string][]int64{} for rows.Next() { var id int64 var l, f, m string if err := rows.Scan(&id, &l, &f, &m); err != nil { rows.Close() return 0, err } k := normName(l + " " + f + " " + m) byName[k] = append(byName[k], id) } rows.Close() if err := rows.Err(); err != nil { return 0, err } un, err := tx.Query(ctx, `SELECT onec_id, full_name FROM onec_doctors WHERE doctor_id IS NULL`) if err != nil { return 0, err } type pair struct { onecID string id int64 } var pairs []pair used := map[int64]bool{} for un.Next() { var oid, name string if err := un.Scan(&oid, &name); err != nil { un.Close() return 0, err } if ids := byName[normName(name)]; len(ids) == 1 && !used[ids[0]] { used[ids[0]] = true pairs = append(pairs, pair{oid, ids[0]}) } } un.Close() if err := un.Err(); err != nil { return 0, err } for _, p := range pairs { if _, err := tx.Exec(ctx, `UPDATE onec_doctors SET doctor_id=$2 WHERE onec_id=$1`, p.onecID, p.id); err != nil { return 0, err } linked++ } return linked, tx.Commit(ctx) } func nonNil(a []string) []string { if a == nil { return []string{} } return a } // SaveOnecServices заменяет услуги приёма врача. func (s *Storage) SaveOnecServices(ctx context.Context, doctorOnecID string, list []onec.Service) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM onec_services WHERE doctor_onec_id=$1`, doctorOnecID); err != nil { return err } for _, sv := range list { if _, err := tx.Exec(ctx, `INSERT INTO onec_services (doctor_onec_id, onec_id, name, duration_min, price) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, doctorOnecID, sv.ID, sv.Name, sv.DurationMin, sv.Price); err != nil { return err } } return tx.Commit(ctx) } // LinkedOnecDoctorIDs — идентификаторы 1С у врачей, привязанных к карточкам сайта. func (s *Storage) LinkedOnecDoctorIDs(ctx context.Context) ([]string, error) { rows, err := s.pool.Query(ctx, `SELECT onec_id FROM onec_doctors WHERE doctor_id IS NOT NULL AND active ORDER BY onec_id`) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // ---------- Окна ---------- // ReplaceOnecSlots заменяет окна периода [from, to) тем, что отдала 1С. Всё вместе, в одной транзакции: // пациент не увидит расписание «наполовину обновлённым». Прошедшие окна старше суток удаляются. func (s *Storage) ReplaceOnecSlots(ctx context.Context, from, to time.Time, slots []onec.Slot) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM onec_slots WHERE (start_at >= $1 AND start_at < $2) OR start_at < now() - interval '1 day'`, from, to); err != nil { return err } if err := insertSlots(ctx, tx, from, to, slots); err != nil { return err } return tx.Commit(ctx) } // ReplaceOnecDoctorSlots обновляет окна одного врача на период (перед записью). func (s *Storage) ReplaceOnecDoctorSlots(ctx context.Context, doctorOnecID string, from, to time.Time, slots []onec.Slot) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM onec_slots WHERE doctor_onec_id=$1 AND start_at >= $2 AND start_at < $3`, doctorOnecID, from, to); err != nil { return err } if err := insertSlots(ctx, tx, from, to, slots); err != nil { return err } return tx.Commit(ctx) } func insertSlots(ctx context.Context, tx pgx.Tx, from, to time.Time, slots []onec.Slot) error { b := &pgx.Batch{} for _, sl := range slots { if sl.DoctorID == "" || sl.Start.Before(from) || !sl.Start.Before(to) || (sl.Status != "free" && sl.Status != "busy") { continue } b.Queue(`INSERT INTO onec_slots (doctor_onec_id, branch_onec_id, start_at, end_at, cabinet, status, slot_id) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (doctor_onec_id, branch_onec_id, start_at) DO UPDATE SET end_at=EXCLUDED.end_at, cabinet=EXCLUDED.cabinet, status=EXCLUDED.status, slot_id=EXCLUDED.slot_id`, sl.DoctorID, sl.BranchID, sl.Start, sl.End, sl.Cabinet, sl.Status, sl.SlotID) } if b.Len() == 0 { return nil } br := tx.SendBatch(ctx, b) for i := 0; i < b.Len(); i++ { if _, err := br.Exec(); err != nil { br.Close() return err } } return br.Close() } type OnecSlot struct { Start time.Time `json:"start"` End time.Time `json:"end"` Branch string `json:"branch"` BranchID string `json:"branch_id"` Cabinet string `json:"cabinet,omitempty"` Status string `json:"status,omitempty"` SlotID string `json:"-"` DoctorOnec string `json:"-"` } // OnecDoctorBySite возвращает идентификатор врача в 1С по карточке сайта; пусто — не привязан. func (s *Storage) OnecDoctorBySite(ctx context.Context, doctorID int64) (string, error) { var id string err := s.pool.QueryRow(ctx, `SELECT onec_id FROM onec_doctors WHERE doctor_id=$1 AND active`, doctorID).Scan(&id) if wrapNotFound(err) == ErrNotFound { return "", nil } return id, err } // OnecDoctorBySlug — то же по адресу карточки; возвращает и id карточки. func (s *Storage) OnecDoctorBySlug(ctx context.Context, slug string) (onecID string, doctorID int64, err error) { err = s.pool.QueryRow(ctx, ` SELECT o.onec_id, d.id FROM onec_doctors o JOIN doctors d ON d.id = o.doctor_id WHERE d.slug=$1 AND d.published AND d.deleted_at IS NULL AND d.booking_provider='native' AND o.active`, slug).Scan(&onecID, &doctorID) if wrapNotFound(err) == ErrNotFound { return "", 0, nil } return onecID, doctorID, err } // FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С. func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) { rows, err := s.pool.Query(ctx, ` SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3 AND NOT EXISTS ( SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at) ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to) if err != nil { return nil, err } defer rows.Close() var out []OnecSlot for rows.Next() { var x OnecSlot if err := rows.Scan(&x.Start, &x.End, &x.BranchID, &x.Branch, &x.Cabinet, &x.SlotID); err != nil { return nil, err } x.Start = x.Start.In(moscow) x.End = x.End.In(moscow) x.Status = "free" out = append(out, x) } return out, rows.Err() } // MarkOnecSlotBusy сразу гасит окно после записи, не дожидаясь следующей синхронизации. func (s *Storage) MarkOnecSlotBusy(ctx context.Context, doctorOnecID, branchOnecID string, start time.Time) error { _, err := s.pool.Exec(ctx, `UPDATE onec_slots SET status='busy' WHERE doctor_onec_id=$1 AND branch_onec_id=$2 AND start_at=$3`, doctorOnecID, branchOnecID, start) return err } // OnecBranchName — подпись филиала по идентификатору 1С. func (s *Storage) OnecBranchName(ctx context.Context, branchOnecID string) string { var n string _ = s.pool.QueryRow(ctx, `SELECT COALESCE(NULLIF(address, ''), name) FROM onec_branches WHERE onec_id=$1`, branchOnecID).Scan(&n) return n } // OnecServiceName — название услуги приёма. func (s *Storage) OnecServiceName(ctx context.Context, doctorOnecID, serviceOnecID string) string { var n string _ = s.pool.QueryRow(ctx, `SELECT name FROM onec_services WHERE doctor_onec_id=$1 AND onec_id=$2`, doctorOnecID, serviceOnecID).Scan(&n) return n } // OnecServices — услуги приёма врача. func (s *Storage) OnecServices(ctx context.Context, doctorOnecID string) ([]onec.Service, error) { rows, err := s.pool.Query(ctx, `SELECT onec_id, name, duration_min, price FROM onec_services WHERE doctor_onec_id=$1 ORDER BY name`, doctorOnecID) if err != nil { return nil, err } defer rows.Close() var out []onec.Service for rows.Next() { var x onec.Service if err := rows.Scan(&x.ID, &x.Name, &x.DurationMin, &x.Price); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } // ---------- Журнал синхронизации ---------- func (s *Storage) StartOnecSync(ctx context.Context) (int64, error) { var id int64 err := s.pool.QueryRow(ctx, `INSERT INTO onec_sync_log DEFAULT VALUES RETURNING id`).Scan(&id) return id, err } func (s *Storage) FinishOnecSync(ctx context.Context, id int64, doctors, slots int, errText string) error { _, err := s.pool.Exec(ctx, `UPDATE onec_sync_log SET finished_at=now(), doctors=$2, slots=$3, error=$4 WHERE id=$1`, id, doctors, slots, errText) if err != nil { return err } _, err = s.pool.Exec(ctx, `DELETE FROM onec_sync_log WHERE started_at < now() - interval '7 days'`) return err } type OnecSyncStatus struct { LastRun *time.Time `json:"last_run"` LastOK *time.Time `json:"last_ok"` LastError string `json:"last_error"` Doctors int `json:"doctors"` Linked int `json:"linked"` FreeSlots int `json:"free_slots"` BusySlots int `json:"busy_slots"` Appointment int `json:"appointments"` } func (s *Storage) OnecSyncStatus(ctx context.Context) (OnecSyncStatus, error) { var st OnecSyncStatus _ = s.pool.QueryRow(ctx, `SELECT started_at, error FROM onec_sync_log WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1`).Scan(&st.LastRun, &st.LastError) _ = s.pool.QueryRow(ctx, `SELECT finished_at FROM onec_sync_log WHERE finished_at IS NOT NULL AND error = '' ORDER BY id DESC LIMIT 1`).Scan(&st.LastOK) err := s.pool.QueryRow(ctx, ` SELECT (SELECT count(*) FROM onec_doctors WHERE active), (SELECT count(*) FROM onec_doctors WHERE active AND doctor_id IS NOT NULL), (SELECT count(*) FROM onec_slots WHERE status='free' AND start_at >= now()), (SELECT count(*) FROM onec_slots WHERE status='busy' AND start_at >= now()), (SELECT count(*) FROM onec_appointments WHERE status='booked')`). Scan(&st.Doctors, &st.Linked, &st.FreeSlots, &st.BusySlots, &st.Appointment) return st, err } // OnecFresh сообщает, была ли успешная синхронизация не раньше чем maxAge назад. func (s *Storage) OnecFresh(ctx context.Context, maxAge time.Duration) bool { var ok bool _ = s.pool.QueryRow(ctx, `SELECT last_snapshot_at > now() - $1::interval FROM onec_push_state WHERE singleton`, maxAge.String()).Scan(&ok) return ok } // ---------- Админка ---------- type OnecDoctorRow struct { OnecID string `json:"onec_id"` FullName string `json:"full_name"` Specialties []string `json:"specialties"` Active bool `json:"active"` DoctorID *int64 `json:"doctor_id"` DoctorName string `json:"doctor_name"` Booking string `json:"booking"` FreeSlots int `json:"free_slots"` } func (s *Storage) OnecDoctorsAdmin(ctx context.Context) ([]OnecDoctorRow, error) { rows, err := s.pool.Query(ctx, ` SELECT o.onec_id, o.full_name, o.specialties, o.active, o.doctor_id, COALESCE(d.last_name || ' ' || d.first_name || ' ' || d.middle_name, ''), COALESCE(d.booking_provider, ''), (SELECT count(*) FROM onec_slots sl WHERE sl.doctor_onec_id = o.onec_id AND sl.status='free' AND sl.start_at >= now()) FROM onec_doctors o LEFT JOIN doctors d ON d.id = o.doctor_id ORDER BY o.active DESC, (o.doctor_id IS NULL) DESC, o.full_name`) if err != nil { return nil, err } defer rows.Close() out := []OnecDoctorRow{} for rows.Next() { var r OnecDoctorRow if err := rows.Scan(&r.OnecID, &r.FullName, &r.Specialties, &r.Active, &r.DoctorID, &r.DoctorName, &r.Booking, &r.FreeSlots); err != nil { return nil, err } r.DoctorName = strings.TrimSpace(r.DoctorName) out = append(out, r) } return out, rows.Err() } // SetOnecDoctorLink привязывает врача 1С к карточке сайта (doctorID = nil — отвязывает). // Способ записи у карточки при этом не меняется: он включается отдельно (SetOnecBooking), // а при отвязке запись «через 1С» возвращается к обычной заявке. func (s *Storage) SetOnecDoctorLink(ctx context.Context, onecID string, doctorID *int64) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var prev *int64 if err := tx.QueryRow(ctx, `SELECT doctor_id FROM onec_doctors WHERE onec_id=$1 FOR UPDATE`, onecID).Scan(&prev); err != nil { return wrapNotFound(err) } if doctorID != nil { // карточка уже занята другим врачом 1С — освобождаем её if _, err := tx.Exec(ctx, `UPDATE onec_doctors SET doctor_id=NULL WHERE doctor_id=$1 AND onec_id<>$2`, *doctorID, onecID); err != nil { return err } } if _, err := tx.Exec(ctx, `UPDATE onec_doctors SET doctor_id=$2 WHERE onec_id=$1`, onecID, doctorID); err != nil { return err } if prev != nil && (doctorID == nil || *doctorID != *prev) { if _, err := tx.Exec(ctx, `UPDATE doctors SET booking_provider='none', updated_at=now() WHERE id=$1 AND booking_provider='native'`, *prev); err != nil { return err } } return tx.Commit(ctx) } // SetOnecBooking включает (native) или выключает (none) запись через 1С у привязанного врача. // Врач с Medflex при включении переходит на 1С; выключение возвращает к заявке. func (s *Storage) SetOnecBooking(ctx context.Context, onecID string, enabled bool) error { provider := "none" if enabled { provider = "native" } tag, err := s.pool.Exec(ctx, ` UPDATE doctors SET booking_provider=$2, updated_at=now() WHERE id = (SELECT doctor_id FROM onec_doctors WHERE onec_id=$1) AND booking_provider <> $2`, onecID, provider) if err != nil { return err } if tag.RowsAffected() == 0 { var linked bool if err := s.pool.QueryRow(ctx, `SELECT doctor_id IS NOT NULL FROM onec_doctors WHERE onec_id=$1`, onecID).Scan(&linked); err != nil { return wrapNotFound(err) } if !linked { return ErrNotFound } } return nil } type OnecGridRow struct { DoctorOnecID string `json:"doctor_onec_id"` DoctorName string `json:"doctor_name"` Branch string `json:"branch"` Start time.Time `json:"start"` End time.Time `json:"end"` Cabinet string `json:"cabinet"` Status string `json:"status"` } // OnecGrid — окна всех врачей за сутки [day, day+24ч) для сетки в админке. func (s *Storage) OnecGrid(ctx context.Context, day time.Time) ([]OnecGridRow, error) { rows, err := s.pool.Query(ctx, ` SELECT sl.doctor_onec_id, COALESCE(NULLIF(o.full_name, ''), sl.doctor_onec_id), COALESCE(NULLIF(b.address, ''), b.name, ''), sl.start_at, sl.end_at, sl.cabinet, sl.status FROM onec_slots sl LEFT JOIN onec_doctors o ON o.onec_id = sl.doctor_onec_id LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id WHERE sl.start_at >= $1 AND sl.start_at < $1 + interval '1 day' ORDER BY 2, sl.start_at`, day) if err != nil { return nil, err } defer rows.Close() out := []OnecGridRow{} for rows.Next() { var r OnecGridRow if err := rows.Scan(&r.DoctorOnecID, &r.DoctorName, &r.Branch, &r.Start, &r.End, &r.Cabinet, &r.Status); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // ---------- Записи с сайта ---------- type OnecAppointment struct { ID int64 `json:"id"` RequestID string `json:"request_id"` LeadID *int64 `json:"lead_id"` DoctorOnec string `json:"doctor_onec_id"` DoctorName string `json:"doctor_name"` Branch string `json:"branch"` Start time.Time `json:"start"` End *time.Time `json:"end"` Patient string `json:"patient_name"` Phone string `json:"patient_phone"` OnecID string `json:"onec_appointment_id"` Status string `json:"status"` Error string `json:"error"` CreatedAt time.Time `json:"created_at"` ServiceName string `json:"service_name"` ServiceID string `json:"-"` BranchID string `json:"-"` Comment string `json:"-"` BirthDate string `json:"-"` // подтверждение оператором в Битрикс24 (см. booking_confirm.go) ConfirmState string `json:"confirm_state"` BitrixLeadID string `json:"bitrix_lead_id"` 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 уже был, возвращает прежнюю запись // и created=false — так повтор запроса не создаёт вторую запись в 1С. func (s *Storage) BeginOnecAppointment(ctx context.Context, a OnecAppointment) (rec OnecAppointment, created bool, err error) { tx, err := s.pool.Begin(ctx) if err != nil { return a, false, err } defer tx.Rollback(ctx) // Request lock makes retries safe even if doctor/slot in the request changed. if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "onec-request:"+a.RequestID); err != nil { return a, false, err } var existing bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM onec_appointments WHERE request_id=$1)`, a.RequestID).Scan(&existing); err != nil { return a, false, err } if existing { rec, err = s.onecAppointmentByRequest(ctx, a.RequestID) return rec, false, err } if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "onec-doctor:"+a.DoctorOnec); err != nil { return a, false, err } var end time.Time if a.Status != "failed" { a.Status = "pending" } if a.Status == "pending" { err = tx.QueryRow(ctx, `SELECT sl.branch_onec_id,sl.end_at FROM onec_slots sl WHERE sl.doctor_onec_id=$1 AND sl.start_at=$2 AND ($3='' OR sl.branch_onec_id=$3) AND sl.status='free' AND NOT EXISTS ( SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at) ORDER BY sl.branch_onec_id LIMIT 1`, a.DoctorOnec, a.Start, a.BranchID).Scan(&a.BranchID, &end) if errors.Is(err, pgx.ErrNoRows) { return a, false, ErrOnecSlotBusy } if err != nil { return a, false, err } a.End = &end } err = tx.QueryRow(ctx, `INSERT INTO onec_appointments (request_id,doctor_onec_id,branch_onec_id,service_onec_id,start_at,end_at,patient_name,patient_phone,patient_comment,patient_birth_date,status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`, a.RequestID, a.DoctorOnec, a.BranchID, a.ServiceID, a.Start, a.End, a.Patient, a.Phone, a.Comment, a.BirthDate, a.Status).Scan(&a.ID) if err != nil { return a, false, err } if err = tx.Commit(ctx); err != nil { return a, false, err } return a, true, nil } func (s *Storage) onecAppointmentByRequest(ctx context.Context, requestID string) (OnecAppointment, error) { var r OnecAppointment err := s.pool.QueryRow(ctx, ` SELECT id, request_id, lead_id, doctor_onec_id, branch_onec_id, start_at, end_at, patient_name, patient_phone, onec_appointment_id, status, error, created_at FROM onec_appointments WHERE request_id=$1`, requestID). Scan(&r.ID, &r.RequestID, &r.LeadID, &r.DoctorOnec, &r.BranchID, &r.Start, &r.End, &r.Patient, &r.Phone, &r.OnecID, &r.Status, &r.Error, &r.CreatedAt) return r, wrapNotFound(err) } func (s *Storage) FinishOnecAppointment(ctx context.Context, id int64, status, onecID, errText string, end *time.Time, leadID *int64) error { _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status=$2, onec_appointment_id=$3, error=$4, end_at=$5, lead_id=COALESCE($6, lead_id), updated_at=now() WHERE id=$1`, id, status, onecID, errText, end, leadID) return err } func (s *Storage) OnecAppointments(ctx context.Context, limit int) ([]OnecAppointment, error) { rows, err := s.pool.Query(ctx, ` 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, 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.crm_deal_id, a.crm_confirm_state, a.crm_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 ORDER BY a.created_at DESC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() out := []OnecAppointment{} for rows.Next() { var r OnecAppointment 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.ConfirmState, &r.BitrixLeadID, &r.ConfirmError, &r.CrmDealID, &r.CrmConfirmState, &r.CrmError); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // SetOnecAppointmentStatus обновляет статус записи по номеру записи в 1С (вебхук 1С). // Возвращает врача и время приёма, чтобы пересчитать его окна. func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentID, status string) (doctorOnecID string, start time.Time, found bool, err error) { return s.ApplyOnecAppointmentEvent(ctx, onecAppointmentID, status, "", "", nil, nil) } func (s *Storage) ApplyOnecAppointmentEvent(ctx context.Context, onecAppointmentID, status, doctor, branch string, eventStart, eventEnd *time.Time) (doctorOnecID string, start time.Time, found bool, err error) { err = s.pool.QueryRow(ctx, ` UPDATE onec_appointments SET status=$2, updated_at=now(), doctor_onec_id=COALESCE(NULLIF($3,''),doctor_onec_id),branch_onec_id=COALESCE(NULLIF($4,''),branch_onec_id), start_at=COALESCE($5,start_at),end_at=COALESCE($6,end_at), confirm_state=CASE WHEN $2='cancelled' AND confirm_state<>'expired' THEN 'cancelled' ELSE confirm_state END WHERE onec_appointment_id=$1 AND status IN ('booked','arrived','cancelled') AND (status<>'cancelled' OR $2='cancelled') AND (status<>'arrived' OR $2<>'booked') RETURNING doctor_onec_id, start_at`, onecAppointmentID, status, doctor, branch, eventStart, eventEnd).Scan(&doctorOnecID, &start) if err != nil { if wrapNotFound(err) == ErrNotFound { return "", time.Time{}, false, nil } return "", time.Time{}, false, err } return doctorOnecID, start, true, nil } type OnecPendingAppointment struct { ID int64 `json:"id"` RequestID string `json:"request_id"` DoctorID string `json:"doctor_id"` BranchID string `json:"branch_id"` ServiceID string `json:"service_id"` Start time.Time `json:"start"` Patient string `json:"patient_name"` Phone string `json:"patient_phone"` CreatedAt time.Time `json:"created_at"` Comment string `json:"comment"` BirthDate string `json:"birth_date"` } func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]OnecPendingAppointment, error) { if limit <= 0 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT id, request_id, doctor_onec_id, branch_onec_id, COALESCE(service_onec_id, ''), start_at, patient_name, patient_phone, created_at, patient_comment, patient_birth_date FROM onec_appointments WHERE status = 'pending' ORDER BY id ASC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() out := []OnecPendingAppointment{} for rows.Next() { var a OnecPendingAppointment if err := rows.Scan(&a.ID, &a.RequestID, &a.DoctorID, &a.BranchID, &a.ServiceID, &a.Start, &a.Patient, &a.Phone, &a.CreatedAt, &a.Comment, &a.BirthDate); err != nil { return nil, err } a.Start = a.Start.In(moscow) out = append(out, a) } return out, rows.Err() } func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error { tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='booked',onec_appointment_id=$2, end_at=COALESCE($3,end_at),updated_at=now() WHERE id=$1 AND COALESCE($3,end_at)>start_at AND (status='pending' OR (status='booked' AND onec_appointment_id=$2 AND ($3::timestamptz IS NULL OR end_at=$3)))`, id, onecID, end) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrOnecTransition } return nil } func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error { tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='failed',error=$2,updated_at=now() WHERE id=$1 AND status IN ('pending','failed')`, id, reason) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrOnecTransition } return nil } func (s *Storage) AttachOnecLead(ctx context.Context, id int64, lead *int64) error { _, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET lead_id=COALESCE($2,lead_id) WHERE id=$1`, id, lead) return err }