- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads - Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes - Add async lead intake dispatch to new CRM in handlers_leads.go - Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go - Add LinkCRM endpoint to bridge_1c.py and onec.Client - Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
600 lines
24 KiB
Go
600 lines
24 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"dana/internal/onec"
|
||
)
|
||
|
||
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
|
||
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 EXISTS (SELECT 1 FROM onec_sync_log WHERE finished_at > now() - $1::interval AND error = '')`,
|
||
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:"-"`
|
||
// подтверждение оператором в Битрикс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) {
|
||
err = s.pool.QueryRow(ctx, `
|
||
INSERT INTO onec_appointments (request_id, doctor_onec_id, branch_onec_id, service_onec_id, start_at, patient_name, patient_phone)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (request_id) DO NOTHING RETURNING id`,
|
||
a.RequestID, a.DoctorOnec, a.BranchID, a.ServiceID, a.Start, a.Patient, a.Phone).Scan(&a.ID)
|
||
if err == nil {
|
||
a.Status = "pending"
|
||
return a, true, nil
|
||
}
|
||
if wrapNotFound(err) != ErrNotFound {
|
||
return a, false, err
|
||
}
|
||
rec, err = s.onecAppointmentByRequest(ctx, a.RequestID)
|
||
return rec, false, err
|
||
}
|
||
|
||
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) {
|
||
err = s.pool.QueryRow(ctx, `
|
||
UPDATE onec_appointments SET status=$2, updated_at=now()
|
||
WHERE onec_appointment_id=$1 AND status IN ('booked','arrived','cancelled')
|
||
RETURNING doctor_onec_id, start_at`, onecAppointmentID, status).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
|
||
}
|