feat(booking): automatically link 1C appointment to Bitrix24 lead ID and implement do_DELETE
This commit is contained in:
parent
2d803fcd28
commit
6597464ac3
@ -456,6 +456,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path == "/appointments":
|
if path == "/appointments":
|
||||||
doctor_id = req_json.get("doctor_id", "")
|
doctor_id = req_json.get("doctor_id", "")
|
||||||
|
bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
|
||||||
branch_id = req_json.get("branch_id", "")
|
branch_id = req_json.get("branch_id", "")
|
||||||
patient_name = req_json.get("patient_name", "")
|
patient_name = req_json.get("patient_name", "")
|
||||||
patient_phone = req_json.get("patient_phone", "")
|
patient_phone = req_json.get("patient_phone", "")
|
||||||
@ -620,11 +621,23 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
|
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
|
||||||
КонецЕсли;
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено("{bitrix_lead_id}") Тогда
|
||||||
|
Попытка
|
||||||
|
Док.бит_Б24_СозданИзБитрикс24 = Истина;
|
||||||
|
Док.бит_Б24_БитриксИД = "{bitrix_lead_id}";
|
||||||
|
Док.бит_НомерКлиентаБ24 = "{bitrix_lead_id}";
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
Док.Записать(РежимЗаписиДокумента.Запись);
|
Док.Записать(РежимЗаписиДокумента.Запись);
|
||||||
ID = НРег(XMLСтрока(Док.Ссылка));
|
ID = НРег(XMLСтрока(Док.Ссылка));
|
||||||
|
ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID));
|
||||||
|
|
||||||
Ответ = Новый Структура;
|
Ответ = Новый Структура;
|
||||||
Ответ.Вставить("appointment_id", ID);
|
Ответ.Вставить("appointment_id", ID);
|
||||||
|
Ответ.Вставить("appointment_id_b64", ID_B64);
|
||||||
|
Ответ.Вставить("number", Док.Номер);
|
||||||
Ответ.Вставить("status", "booked");
|
Ответ.Вставить("status", "booked");
|
||||||
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
||||||
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
||||||
@ -640,12 +653,165 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
КонецПопытки;
|
КонецПопытки;
|
||||||
"""
|
"""
|
||||||
data, err = call_1c(code)
|
data, err = call_1c(code)
|
||||||
|
if isinstance(data, dict) and "appointment_id_b64" in data:
|
||||||
|
import base64
|
||||||
|
try:
|
||||||
|
data["appointment_id"] = base64.b64decode(data["appointment_id_b64"]).decode("utf-8").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
||||||
else:
|
else:
|
||||||
self.send_json(201, data)
|
self.send_json(201, data)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/appointments/link-bitrix":
|
||||||
|
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
||||||
|
bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
|
||||||
|
if not appointment_id or not bitrix_lead_id:
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
code = f"""
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Неопределено;
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Если Выб.Следующий() Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Пока Выб.Следующий() Цикл
|
||||||
|
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||||
|
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
Прервать;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЦикла;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||||
|
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||||
|
Если ДокОбъект <> Неопределено Тогда
|
||||||
|
ДокОбъект.бит_Б24_СозданИзБитрикс24 = Истина;
|
||||||
|
ДокОбъект.бит_Б24_БитриксИД = "{bitrix_lead_id}";
|
||||||
|
ДокОбъект.бит_НомерКлиентаБ24 = "{bitrix_lead_id}";
|
||||||
|
ДокОбъект.Записать(РежимЗаписиДокумента.Запись);
|
||||||
|
Результат = "OK";
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Исключение
|
||||||
|
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||||
|
КонецПопытки;
|
||||||
|
"""
|
||||||
|
data, err = call_1c(code)
|
||||||
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
|
self.send_json(500, {"code": "link_failed", "message": err or data})
|
||||||
|
elif data == "NOT_FOUND":
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"})
|
||||||
|
else:
|
||||||
|
self.send_json(200, {"status": "ok", "message": "Связано успешно"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
parts = path.split("/")
|
||||||
|
if len(parts) == 3 and parts[1] == "appointments":
|
||||||
|
appointment_id = urllib.parse.unquote(parts[2]).strip()
|
||||||
|
code = f"""
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Неопределено;
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Если Выб.Следующий() Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Пока Выб.Следующий() Цикл
|
||||||
|
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||||
|
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
Прервать;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЦикла;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||||
|
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||||
|
Если ДокОбъект <> Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокОбъект.Состояние = Справочники.ВидыСостоянийЗаявок.Отменена;
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
ДокОбъект.УстановитьПометкуУдаления(Истина);
|
||||||
|
Результат = "OK";
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Исключение
|
||||||
|
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||||
|
КонецПопытки;
|
||||||
|
"""
|
||||||
|
data, err = call_1c(code)
|
||||||
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
|
self.send_json(500, {"code": "cancel_failed", "message": err or data})
|
||||||
|
elif data == "NOT_FOUND":
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Заявка в 1С не найдена"})
|
||||||
|
else:
|
||||||
|
self.send_json(200, {"status": "cancelled", "message": "Запись отменена в 1С"})
|
||||||
|
return
|
||||||
|
|
||||||
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@ -188,6 +188,14 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
|
|||||||
}
|
}
|
||||||
it.BitrixLeadID = id
|
it.BitrixLeadID = id
|
||||||
slog.Info("booking confirm: лид создан", "appointment", it.ID, "lead", id)
|
slog.Info("booking confirm: лид создан", "appointment", it.ID, "lead", id)
|
||||||
|
|
||||||
|
if it.OnecID != "" {
|
||||||
|
if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil {
|
||||||
|
slog.Warn("booking confirm: не удалось связать 1С с Битриксом", "onec_id", it.OnecID, "lead", id, "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("booking confirm: заявка в 1С связана с лидом Битрикса", "onec_id", it.OnecID, "lead", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ids = append(ids, it.BitrixLeadID)
|
ids = append(ids, it.BitrixLeadID)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,297 +1,309 @@
|
|||||||
// Package onec — клиент HTTP-сервиса 1С: филиалы, врачи, расписание и запись на приём.
|
// Package onec — клиент HTTP-сервиса 1С: филиалы, врачи, расписание и запись на приём.
|
||||||
// Контракт описан в ТЗ «расписание врачей и запись из 1С»; адреса методов в 1С могут отличаться —
|
// Контракт описан в ТЗ «расписание врачей и запись из 1С»; адреса методов в 1С могут отличаться —
|
||||||
// тогда правится только этот файл.
|
// тогда правится только этот файл.
|
||||||
package onec
|
package onec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnauthorized = errors.New("1С не приняла ключ доступа")
|
ErrUnauthorized = errors.New("1С не приняла ключ доступа")
|
||||||
ErrSlotBusy = errors.New("окно уже занято")
|
ErrSlotBusy = errors.New("окно уже занято")
|
||||||
ErrDoctorNotFound = errors.New("врач не найден в 1С")
|
ErrDoctorNotFound = errors.New("врач не найден в 1С")
|
||||||
ErrValidation = errors.New("1С отклонила данные записи")
|
ErrValidation = errors.New("1С отклонила данные записи")
|
||||||
ErrUnavailable = errors.New("1С недоступна")
|
ErrUnavailable = errors.New("1С недоступна")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Base string
|
Base string
|
||||||
Key string
|
Key string
|
||||||
AuthHeader string
|
AuthHeader string
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(base, key, authHeader string) *Client {
|
func New(base, key, authHeader string) *Client {
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
authHeader = "X-Api-Key"
|
authHeader = "X-Api-Key"
|
||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
Base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
Base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||||
Key: strings.TrimSpace(key),
|
Key: strings.TrimSpace(key),
|
||||||
AuthHeader: authHeader,
|
AuthHeader: authHeader,
|
||||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" }
|
func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" }
|
||||||
|
|
||||||
// Host — адрес 1С для показа в админке (без ключа и пути).
|
// Host — адрес 1С для показа в админке (без ключа и пути).
|
||||||
func (c *Client) Host() string {
|
func (c *Client) Host() string {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if u, err := url.Parse(c.Base); err == nil {
|
if u, err := url.Parse(c.Base); err == nil {
|
||||||
return u.Host
|
return u.Host
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Doctor struct {
|
type Doctor struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Specialties []string `json:"specialties"`
|
Specialties []string `json:"specialties"`
|
||||||
BranchIDs []string `json:"branch_ids"`
|
BranchIDs []string `json:"branch_ids"`
|
||||||
KidsFrom *int `json:"kids_from"`
|
KidsFrom *int `json:"kids_from"`
|
||||||
Active *bool `json:"active"`
|
Active *bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Doctor) IsActive() bool { return d.Active == nil || *d.Active }
|
func (d Doctor) IsActive() bool { return d.Active == nil || *d.Active }
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DurationMin int `json:"duration_min"`
|
DurationMin int `json:"duration_min"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Slot struct {
|
type Slot struct {
|
||||||
DoctorID string `json:"doctor_id"`
|
DoctorID string `json:"doctor_id"`
|
||||||
BranchID string `json:"branch_id"`
|
BranchID string `json:"branch_id"`
|
||||||
Cabinet string `json:"cabinet"`
|
Cabinet string `json:"cabinet"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Status string `json:"status"` // free | busy
|
Status string `json:"status"` // free | busy
|
||||||
SlotID string `json:"slot_id"`
|
SlotID string `json:"slot_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Slot) Free() bool { return s.Status == "free" }
|
func (s Slot) Free() bool { return s.Status == "free" }
|
||||||
|
|
||||||
type AppointmentRequest struct {
|
type AppointmentRequest struct {
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
DoctorID string `json:"doctor_id"`
|
DoctorID string `json:"doctor_id"`
|
||||||
BranchID string `json:"branch_id"`
|
BranchID string `json:"branch_id"`
|
||||||
ServiceID string `json:"service_id,omitempty"`
|
ServiceID string `json:"service_id,omitempty"`
|
||||||
SlotID string `json:"slot_id,omitempty"`
|
SlotID string `json:"slot_id,omitempty"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
Name string `json:"patient_name"`
|
Name string `json:"patient_name"`
|
||||||
Phone string `json:"patient_phone"`
|
Phone string `json:"patient_phone"`
|
||||||
BirthDate string `json:"birth_date,omitempty"`
|
BirthDate string `json:"birth_date,omitempty"`
|
||||||
Comment string `json:"comment,omitempty"`
|
Comment string `json:"comment,omitempty"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Appointment struct {
|
type Appointment struct {
|
||||||
ID string `json:"appointment_id"`
|
ID string `json:"appointment_id"`
|
||||||
Status string `json:"status"` // booked | arrived | cancelled
|
Status string `json:"status"` // booked | arrived | cancelled
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Branches(ctx context.Context) ([]Branch, error) {
|
func (c *Client) Branches(ctx context.Context) ([]Branch, error) {
|
||||||
var out []Branch
|
var out []Branch
|
||||||
return out, c.getList(ctx, "/branches", nil, "branches", &out)
|
return out, c.getList(ctx, "/branches", nil, "branches", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Doctors(ctx context.Context) ([]Doctor, error) {
|
func (c *Client) Doctors(ctx context.Context) ([]Doctor, error) {
|
||||||
var out []Doctor
|
var out []Doctor
|
||||||
return out, c.getList(ctx, "/doctors", nil, "doctors", &out)
|
return out, c.getList(ctx, "/doctors", nil, "doctors", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Services(ctx context.Context, doctorID string) ([]Service, error) {
|
func (c *Client) Services(ctx context.Context, doctorID string) ([]Service, error) {
|
||||||
var out []Service
|
var out []Service
|
||||||
return out, c.getList(ctx, "/doctors/"+url.PathEscape(doctorID)+"/services", nil, "services", &out)
|
return out, c.getList(ctx, "/doctors/"+url.PathEscape(doctorID)+"/services", nil, "services", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule — окна за период [from, to]; doctorID пустой — по всем врачам.
|
// Schedule — окна за период [from, to]; doctorID пустой — по всем врачам.
|
||||||
func (c *Client) Schedule(ctx context.Context, from, to time.Time, doctorID string) ([]Slot, error) {
|
func (c *Client) Schedule(ctx context.Context, from, to time.Time, doctorID string) ([]Slot, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("from", from.Format(time.RFC3339))
|
q.Set("from", from.Format(time.RFC3339))
|
||||||
q.Set("to", to.Format(time.RFC3339))
|
q.Set("to", to.Format(time.RFC3339))
|
||||||
if doctorID != "" {
|
if doctorID != "" {
|
||||||
q.Set("doctor_id", doctorID)
|
q.Set("doctor_id", doctorID)
|
||||||
}
|
}
|
||||||
var out []Slot
|
var out []Slot
|
||||||
return out, c.getList(ctx, "/schedule", q, "slots", &out)
|
return out, c.getList(ctx, "/schedule", q, "slots", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changes — long polling: 1С держит запрос до wait, пока версия расписания не станет больше since,
|
// Changes — long polling: 1С держит запрос до wait, пока версия расписания не станет больше since,
|
||||||
// и отвечает {"version": N, "doctor_ids": [...]} (пустой doctor_ids — изменилось у многих).
|
// и отвечает {"version": N, "doctor_ids": [...]} (пустой doctor_ids — изменилось у многих).
|
||||||
func (c *Client) Changes(ctx context.Context, since int64, wait time.Duration) (int64, []string, error) {
|
func (c *Client) Changes(ctx context.Context, since int64, wait time.Duration) (int64, []string, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("since", fmt.Sprint(since))
|
q.Set("since", fmt.Sprint(since))
|
||||||
q.Set("wait", fmt.Sprint(int(wait.Seconds())))
|
q.Set("wait", fmt.Sprint(int(wait.Seconds())))
|
||||||
raw, err := c.doWith(ctx, &http.Client{Timeout: wait + 10*time.Second}, http.MethodGet, "/changes", q, nil)
|
raw, err := c.doWith(ctx, &http.Client{Timeout: wait + 10*time.Second}, http.MethodGet, "/changes", q, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return since, nil, err
|
return since, nil, err
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Version int64 `json:"version"`
|
Version int64 `json:"version"`
|
||||||
DoctorIDs []string `json:"doctor_ids"`
|
DoctorIDs []string `json:"doctor_ids"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return since, nil, fmt.Errorf("onec: /changes — ответ не разобран: %s", short(raw))
|
return since, nil, fmt.Errorf("onec: /changes — ответ не разобран: %s", short(raw))
|
||||||
}
|
}
|
||||||
return out.Version, out.DoctorIDs, nil
|
return out.Version, out.DoctorIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Book(ctx context.Context, req AppointmentRequest) (*Appointment, error) {
|
func (c *Client) Book(ctx context.Context, req AppointmentRequest) (*Appointment, error) {
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := c.do(ctx, http.MethodPost, "/appointments", nil, body)
|
raw, err := c.do(ctx, http.MethodPost, "/appointments", nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var a Appointment
|
var a Appointment
|
||||||
if err := json.Unmarshal(raw, &a); err != nil || a.ID == "" {
|
if err := json.Unmarshal(raw, &a); err != nil || a.ID == "" {
|
||||||
return nil, fmt.Errorf("onec: ответ на запись не разобран: %s", short(raw))
|
return nil, fmt.Errorf("onec: ответ на запись не разобран: %s", short(raw))
|
||||||
}
|
}
|
||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Appointment(ctx context.Context, id string) (*Appointment, error) {
|
func (c *Client) Appointment(ctx context.Context, id string) (*Appointment, error) {
|
||||||
raw, err := c.do(ctx, http.MethodGet, "/appointments/"+url.PathEscape(id), nil, nil)
|
raw, err := c.do(ctx, http.MethodGet, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var a Appointment
|
var a Appointment
|
||||||
if err := json.Unmarshal(raw, &a); err != nil {
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
return nil, fmt.Errorf("onec: статус записи не разобран: %s", short(raw))
|
return nil, fmt.Errorf("onec: статус записи не разобран: %s", short(raw))
|
||||||
}
|
}
|
||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Cancel(ctx context.Context, id string) error {
|
func (c *Client) Cancel(ctx context.Context, id string) error {
|
||||||
_, err := c.do(ctx, http.MethodDelete, "/appointments/"+url.PathEscape(id), nil, nil)
|
_, err := c.do(ctx, http.MethodDelete, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
|
func (c *Client) LinkBitrix(ctx context.Context, appointmentID, bitrixLeadID string) error {
|
||||||
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error {
|
body, err := json.Marshal(map[string]string{
|
||||||
raw, err := c.do(ctx, http.MethodGet, path, q, nil)
|
"appointment_id": appointmentID,
|
||||||
if err != nil {
|
"bitrix_lead_id": bitrixLeadID,
|
||||||
return err
|
})
|
||||||
}
|
if err != nil {
|
||||||
trimmed := bytes.TrimSpace(raw)
|
return err
|
||||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
}
|
||||||
return json.Unmarshal(trimmed, out)
|
_, err = c.do(ctx, http.MethodPost, "/appointments/link-bitrix", nil, body)
|
||||||
}
|
return err
|
||||||
var wrap map[string]json.RawMessage
|
}
|
||||||
if err := json.Unmarshal(trimmed, &wrap); err != nil {
|
|
||||||
return fmt.Errorf("onec: %s — ответ не разобран: %s", path, short(raw))
|
// getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
|
||||||
}
|
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error {
|
||||||
part, found := wrap[key]
|
raw, err := c.do(ctx, http.MethodGet, path, q, nil)
|
||||||
if !found {
|
if err != nil {
|
||||||
return fmt.Errorf("onec: %s — в ответе нет поля %q", path, key)
|
return err
|
||||||
}
|
}
|
||||||
return json.Unmarshal(part, out)
|
trimmed := bytes.TrimSpace(raw)
|
||||||
}
|
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||||
|
return json.Unmarshal(trimmed, out)
|
||||||
func (c *Client) do(ctx context.Context, method, path string, q url.Values, body []byte) ([]byte, error) {
|
}
|
||||||
return c.doWith(ctx, c.HTTP, method, path, q, body)
|
var wrap map[string]json.RawMessage
|
||||||
}
|
if err := json.Unmarshal(trimmed, &wrap); err != nil {
|
||||||
|
return fmt.Errorf("onec: %s — ответ не разобран: %s", path, short(raw))
|
||||||
func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, q url.Values, body []byte) ([]byte, error) {
|
}
|
||||||
if !c.Configured() {
|
part, found := wrap[key]
|
||||||
return nil, errors.New("onec: не задан адрес или ключ 1С")
|
if !found {
|
||||||
}
|
return fmt.Errorf("onec: %s — в ответе нет поля %q", path, key)
|
||||||
u := c.Base + path
|
}
|
||||||
if len(q) > 0 {
|
return json.Unmarshal(part, out)
|
||||||
u += "?" + q.Encode()
|
}
|
||||||
}
|
|
||||||
var rd io.Reader
|
func (c *Client) do(ctx context.Context, method, path string, q url.Values, body []byte) ([]byte, error) {
|
||||||
if body != nil {
|
return c.doWith(ctx, c.HTTP, method, path, q, body)
|
||||||
rd = bytes.NewReader(body)
|
}
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, q url.Values, body []byte) ([]byte, error) {
|
||||||
if err != nil {
|
if !c.Configured() {
|
||||||
return nil, err
|
return nil, errors.New("onec: не задан адрес или ключ 1С")
|
||||||
}
|
}
|
||||||
req.Header.Set(c.AuthHeader, c.Key)
|
u := c.Base + path
|
||||||
req.Header.Set("Accept", "application/json")
|
if len(q) > 0 {
|
||||||
if body != nil {
|
u += "?" + q.Encode()
|
||||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
}
|
||||||
}
|
var rd io.Reader
|
||||||
resp, err := hc.Do(req)
|
if body != nil {
|
||||||
if err != nil {
|
rd = bytes.NewReader(body)
|
||||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
}
|
||||||
}
|
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||||
defer resp.Body.Close()
|
if err != nil {
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
return nil, err
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
req.Header.Set(c.AuthHeader, c.Key)
|
||||||
}
|
req.Header.Set("Accept", "application/json")
|
||||||
if resp.StatusCode/100 == 2 {
|
if body != nil {
|
||||||
return raw, nil
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
}
|
}
|
||||||
return nil, apiError(resp.StatusCode, raw)
|
resp, err := hc.Do(req)
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
// apiError превращает ошибку 1С ({"code":"slot_busy","message":"…"} или {"error":{…}}) в типизированную.
|
}
|
||||||
func apiError(status int, raw []byte) error {
|
defer resp.Body.Close()
|
||||||
var e struct {
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
Code string `json:"code"`
|
if err != nil {
|
||||||
Message string `json:"message"`
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
Error *struct {
|
}
|
||||||
Code string `json:"code"`
|
if resp.StatusCode/100 == 2 {
|
||||||
Message string `json:"message"`
|
return raw, nil
|
||||||
} `json:"error"`
|
}
|
||||||
}
|
return nil, apiError(resp.StatusCode, raw)
|
||||||
_ = json.Unmarshal(raw, &e)
|
}
|
||||||
code, msg := e.Code, e.Message
|
|
||||||
if e.Error != nil {
|
// apiError превращает ошибку 1С ({"code":"slot_busy","message":"…"} или {"error":{…}}) в типизированную.
|
||||||
code, msg = e.Error.Code, e.Error.Message
|
func apiError(status int, raw []byte) error {
|
||||||
}
|
var e struct {
|
||||||
var base error
|
Code string `json:"code"`
|
||||||
switch {
|
Message string `json:"message"`
|
||||||
case code == "slot_busy" || status == http.StatusConflict:
|
Error *struct {
|
||||||
base = ErrSlotBusy
|
Code string `json:"code"`
|
||||||
case code == "doctor_not_found" || status == http.StatusNotFound:
|
Message string `json:"message"`
|
||||||
base = ErrDoctorNotFound
|
} `json:"error"`
|
||||||
case code == "unauthorized" || status == http.StatusUnauthorized || status == http.StatusForbidden:
|
}
|
||||||
base = ErrUnauthorized
|
_ = json.Unmarshal(raw, &e)
|
||||||
case code == "validation" || status == http.StatusBadRequest:
|
code, msg := e.Code, e.Message
|
||||||
base = ErrValidation
|
if e.Error != nil {
|
||||||
default:
|
code, msg = e.Error.Code, e.Error.Message
|
||||||
base = ErrUnavailable
|
}
|
||||||
}
|
var base error
|
||||||
if msg == "" {
|
switch {
|
||||||
msg = short(raw)
|
case code == "slot_busy" || status == http.StatusConflict:
|
||||||
}
|
base = ErrSlotBusy
|
||||||
return fmt.Errorf("%w (HTTP %d): %s", base, status, msg)
|
case code == "doctor_not_found" || status == http.StatusNotFound:
|
||||||
}
|
base = ErrDoctorNotFound
|
||||||
|
case code == "unauthorized" || status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||||
func short(b []byte) string {
|
base = ErrUnauthorized
|
||||||
s := strings.TrimSpace(string(b))
|
case code == "validation" || status == http.StatusBadRequest:
|
||||||
if r := []rune(s); len(r) > 200 {
|
base = ErrValidation
|
||||||
s = string(r[:200]) + "…"
|
default:
|
||||||
}
|
base = ErrUnavailable
|
||||||
return s
|
}
|
||||||
}
|
if msg == "" {
|
||||||
|
msg = short(raw)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w (HTTP %d): %s", base, status, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(b []byte) string {
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if r := []rune(s); len(r) > 200 {
|
||||||
|
s = string(r[:200]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user