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":
|
||||
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", "")
|
||||
patient_name = req_json.get("patient_name", "")
|
||||
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_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID));
|
||||
|
||||
Ответ = Новый Структура;
|
||||
Ответ.Вставить("appointment_id", ID);
|
||||
Ответ.Вставить("appointment_id_b64", ID_B64);
|
||||
Ответ.Вставить("number", Док.Номер);
|
||||
Ответ.Вставить("status", "booked");
|
||||
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=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)
|
||||
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("ОШИБКА")):
|
||||
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
||||
else:
|
||||
self.send_json(201, data)
|
||||
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"})
|
||||
|
||||
def run():
|
||||
|
||||
@ -188,6 +188,14 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
|
||||
}
|
||||
it.BitrixLeadID = 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)
|
||||
}
|
||||
|
||||
@ -1,297 +1,309 @@
|
||||
// Package onec — клиент HTTP-сервиса 1С: филиалы, врачи, расписание и запись на приём.
|
||||
// Контракт описан в ТЗ «расписание врачей и запись из 1С»; адреса методов в 1С могут отличаться —
|
||||
// тогда правится только этот файл.
|
||||
package onec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("1С не приняла ключ доступа")
|
||||
ErrSlotBusy = errors.New("окно уже занято")
|
||||
ErrDoctorNotFound = errors.New("врач не найден в 1С")
|
||||
ErrValidation = errors.New("1С отклонила данные записи")
|
||||
ErrUnavailable = errors.New("1С недоступна")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Base string
|
||||
Key string
|
||||
AuthHeader string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(base, key, authHeader string) *Client {
|
||||
if authHeader == "" {
|
||||
authHeader = "X-Api-Key"
|
||||
}
|
||||
return &Client{
|
||||
Base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
Key: strings.TrimSpace(key),
|
||||
AuthHeader: authHeader,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" }
|
||||
|
||||
// Host — адрес 1С для показа в админке (без ключа и пути).
|
||||
func (c *Client) Host() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(c.Base); err == nil {
|
||||
return u.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type Doctor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Specialties []string `json:"specialties"`
|
||||
BranchIDs []string `json:"branch_ids"`
|
||||
KidsFrom *int `json:"kids_from"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
func (d Doctor) IsActive() bool { return d.Active == nil || *d.Active }
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMin int `json:"duration_min"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
DoctorID string `json:"doctor_id"`
|
||||
BranchID string `json:"branch_id"`
|
||||
Cabinet string `json:"cabinet"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"` // free | busy
|
||||
SlotID string `json:"slot_id"`
|
||||
}
|
||||
|
||||
func (s Slot) Free() bool { return s.Status == "free" }
|
||||
|
||||
type AppointmentRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
DoctorID string `json:"doctor_id"`
|
||||
BranchID string `json:"branch_id"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
SlotID string `json:"slot_id,omitempty"`
|
||||
Start time.Time `json:"start"`
|
||||
Name string `json:"patient_name"`
|
||||
Phone string `json:"patient_phone"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type Appointment struct {
|
||||
ID string `json:"appointment_id"`
|
||||
Status string `json:"status"` // booked | arrived | cancelled
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
func (c *Client) Branches(ctx context.Context) ([]Branch, error) {
|
||||
var out []Branch
|
||||
return out, c.getList(ctx, "/branches", nil, "branches", &out)
|
||||
}
|
||||
|
||||
func (c *Client) Doctors(ctx context.Context) ([]Doctor, error) {
|
||||
var out []Doctor
|
||||
return out, c.getList(ctx, "/doctors", nil, "doctors", &out)
|
||||
}
|
||||
|
||||
func (c *Client) Services(ctx context.Context, doctorID string) ([]Service, error) {
|
||||
var out []Service
|
||||
return out, c.getList(ctx, "/doctors/"+url.PathEscape(doctorID)+"/services", nil, "services", &out)
|
||||
}
|
||||
|
||||
// Schedule — окна за период [from, to]; doctorID пустой — по всем врачам.
|
||||
func (c *Client) Schedule(ctx context.Context, from, to time.Time, doctorID string) ([]Slot, error) {
|
||||
q := url.Values{}
|
||||
q.Set("from", from.Format(time.RFC3339))
|
||||
q.Set("to", to.Format(time.RFC3339))
|
||||
if doctorID != "" {
|
||||
q.Set("doctor_id", doctorID)
|
||||
}
|
||||
var out []Slot
|
||||
return out, c.getList(ctx, "/schedule", q, "slots", &out)
|
||||
}
|
||||
|
||||
// Changes — long polling: 1С держит запрос до wait, пока версия расписания не станет больше since,
|
||||
// и отвечает {"version": N, "doctor_ids": [...]} (пустой doctor_ids — изменилось у многих).
|
||||
func (c *Client) Changes(ctx context.Context, since int64, wait time.Duration) (int64, []string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("since", fmt.Sprint(since))
|
||||
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)
|
||||
if err != nil {
|
||||
return since, nil, err
|
||||
}
|
||||
var out struct {
|
||||
Version int64 `json:"version"`
|
||||
DoctorIDs []string `json:"doctor_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return since, nil, fmt.Errorf("onec: /changes — ответ не разобран: %s", short(raw))
|
||||
}
|
||||
return out.Version, out.DoctorIDs, nil
|
||||
}
|
||||
|
||||
func (c *Client) Book(ctx context.Context, req AppointmentRequest) (*Appointment, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/appointments", nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a Appointment
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.ID == "" {
|
||||
return nil, fmt.Errorf("onec: ответ на запись не разобран: %s", short(raw))
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (c *Client) Appointment(ctx context.Context, id string) (*Appointment, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a Appointment
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("onec: статус записи не разобран: %s", short(raw))
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (c *Client) Cancel(ctx context.Context, id string) error {
|
||||
_, err := c.do(ctx, http.MethodDelete, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
|
||||
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error {
|
||||
raw, err := c.do(ctx, http.MethodGet, path, q, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, out)
|
||||
}
|
||||
var wrap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &wrap); err != nil {
|
||||
return fmt.Errorf("onec: %s — ответ не разобран: %s", path, short(raw))
|
||||
}
|
||||
part, found := wrap[key]
|
||||
if !found {
|
||||
return fmt.Errorf("onec: %s — в ответе нет поля %q", path, key)
|
||||
}
|
||||
return json.Unmarshal(part, 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)
|
||||
}
|
||||
|
||||
func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, q url.Values, body []byte) ([]byte, error) {
|
||||
if !c.Configured() {
|
||||
return nil, errors.New("onec: не задан адрес или ключ 1С")
|
||||
}
|
||||
u := c.Base + path
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(c.AuthHeader, c.Key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
if resp.StatusCode/100 == 2 {
|
||||
return raw, nil
|
||||
}
|
||||
return nil, apiError(resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
// apiError превращает ошибку 1С ({"code":"slot_busy","message":"…"} или {"error":{…}}) в типизированную.
|
||||
func apiError(status int, raw []byte) error {
|
||||
var e struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
code, msg := e.Code, e.Message
|
||||
if e.Error != nil {
|
||||
code, msg = e.Error.Code, e.Error.Message
|
||||
}
|
||||
var base error
|
||||
switch {
|
||||
case code == "slot_busy" || status == http.StatusConflict:
|
||||
base = ErrSlotBusy
|
||||
case code == "doctor_not_found" || status == http.StatusNotFound:
|
||||
base = ErrDoctorNotFound
|
||||
case code == "unauthorized" || status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
base = ErrUnauthorized
|
||||
case code == "validation" || status == http.StatusBadRequest:
|
||||
base = ErrValidation
|
||||
default:
|
||||
base = ErrUnavailable
|
||||
}
|
||||
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
|
||||
}
|
||||
// Package onec — клиент HTTP-сервиса 1С: филиалы, врачи, расписание и запись на приём.
|
||||
// Контракт описан в ТЗ «расписание врачей и запись из 1С»; адреса методов в 1С могут отличаться —
|
||||
// тогда правится только этот файл.
|
||||
package onec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("1С не приняла ключ доступа")
|
||||
ErrSlotBusy = errors.New("окно уже занято")
|
||||
ErrDoctorNotFound = errors.New("врач не найден в 1С")
|
||||
ErrValidation = errors.New("1С отклонила данные записи")
|
||||
ErrUnavailable = errors.New("1С недоступна")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Base string
|
||||
Key string
|
||||
AuthHeader string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(base, key, authHeader string) *Client {
|
||||
if authHeader == "" {
|
||||
authHeader = "X-Api-Key"
|
||||
}
|
||||
return &Client{
|
||||
Base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
Key: strings.TrimSpace(key),
|
||||
AuthHeader: authHeader,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" }
|
||||
|
||||
// Host — адрес 1С для показа в админке (без ключа и пути).
|
||||
func (c *Client) Host() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(c.Base); err == nil {
|
||||
return u.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type Doctor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Specialties []string `json:"specialties"`
|
||||
BranchIDs []string `json:"branch_ids"`
|
||||
KidsFrom *int `json:"kids_from"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
func (d Doctor) IsActive() bool { return d.Active == nil || *d.Active }
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMin int `json:"duration_min"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
DoctorID string `json:"doctor_id"`
|
||||
BranchID string `json:"branch_id"`
|
||||
Cabinet string `json:"cabinet"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"` // free | busy
|
||||
SlotID string `json:"slot_id"`
|
||||
}
|
||||
|
||||
func (s Slot) Free() bool { return s.Status == "free" }
|
||||
|
||||
type AppointmentRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
DoctorID string `json:"doctor_id"`
|
||||
BranchID string `json:"branch_id"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
SlotID string `json:"slot_id,omitempty"`
|
||||
Start time.Time `json:"start"`
|
||||
Name string `json:"patient_name"`
|
||||
Phone string `json:"patient_phone"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type Appointment struct {
|
||||
ID string `json:"appointment_id"`
|
||||
Status string `json:"status"` // booked | arrived | cancelled
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
func (c *Client) Branches(ctx context.Context) ([]Branch, error) {
|
||||
var out []Branch
|
||||
return out, c.getList(ctx, "/branches", nil, "branches", &out)
|
||||
}
|
||||
|
||||
func (c *Client) Doctors(ctx context.Context) ([]Doctor, error) {
|
||||
var out []Doctor
|
||||
return out, c.getList(ctx, "/doctors", nil, "doctors", &out)
|
||||
}
|
||||
|
||||
func (c *Client) Services(ctx context.Context, doctorID string) ([]Service, error) {
|
||||
var out []Service
|
||||
return out, c.getList(ctx, "/doctors/"+url.PathEscape(doctorID)+"/services", nil, "services", &out)
|
||||
}
|
||||
|
||||
// Schedule — окна за период [from, to]; doctorID пустой — по всем врачам.
|
||||
func (c *Client) Schedule(ctx context.Context, from, to time.Time, doctorID string) ([]Slot, error) {
|
||||
q := url.Values{}
|
||||
q.Set("from", from.Format(time.RFC3339))
|
||||
q.Set("to", to.Format(time.RFC3339))
|
||||
if doctorID != "" {
|
||||
q.Set("doctor_id", doctorID)
|
||||
}
|
||||
var out []Slot
|
||||
return out, c.getList(ctx, "/schedule", q, "slots", &out)
|
||||
}
|
||||
|
||||
// Changes — long polling: 1С держит запрос до wait, пока версия расписания не станет больше since,
|
||||
// и отвечает {"version": N, "doctor_ids": [...]} (пустой doctor_ids — изменилось у многих).
|
||||
func (c *Client) Changes(ctx context.Context, since int64, wait time.Duration) (int64, []string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("since", fmt.Sprint(since))
|
||||
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)
|
||||
if err != nil {
|
||||
return since, nil, err
|
||||
}
|
||||
var out struct {
|
||||
Version int64 `json:"version"`
|
||||
DoctorIDs []string `json:"doctor_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return since, nil, fmt.Errorf("onec: /changes — ответ не разобран: %s", short(raw))
|
||||
}
|
||||
return out.Version, out.DoctorIDs, nil
|
||||
}
|
||||
|
||||
func (c *Client) Book(ctx context.Context, req AppointmentRequest) (*Appointment, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/appointments", nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a Appointment
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.ID == "" {
|
||||
return nil, fmt.Errorf("onec: ответ на запись не разобран: %s", short(raw))
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (c *Client) Appointment(ctx context.Context, id string) (*Appointment, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a Appointment
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("onec: статус записи не разобран: %s", short(raw))
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (c *Client) Cancel(ctx context.Context, id string) error {
|
||||
_, err := c.do(ctx, http.MethodDelete, "/appointments/"+url.PathEscape(id), nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) LinkBitrix(ctx context.Context, appointmentID, bitrixLeadID string) error {
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"appointment_id": appointmentID,
|
||||
"bitrix_lead_id": bitrixLeadID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.do(ctx, http.MethodPost, "/appointments/link-bitrix", nil, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// getList принимает ответ и в виде массива, и в виде объекта {"<key>": [...]}.
|
||||
func (c *Client) getList(ctx context.Context, path string, q url.Values, key string, out any) error {
|
||||
raw, err := c.do(ctx, http.MethodGet, path, q, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, out)
|
||||
}
|
||||
var wrap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &wrap); err != nil {
|
||||
return fmt.Errorf("onec: %s — ответ не разобран: %s", path, short(raw))
|
||||
}
|
||||
part, found := wrap[key]
|
||||
if !found {
|
||||
return fmt.Errorf("onec: %s — в ответе нет поля %q", path, key)
|
||||
}
|
||||
return json.Unmarshal(part, 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)
|
||||
}
|
||||
|
||||
func (c *Client) doWith(ctx context.Context, hc *http.Client, method, path string, q url.Values, body []byte) ([]byte, error) {
|
||||
if !c.Configured() {
|
||||
return nil, errors.New("onec: не задан адрес или ключ 1С")
|
||||
}
|
||||
u := c.Base + path
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(c.AuthHeader, c.Key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
if resp.StatusCode/100 == 2 {
|
||||
return raw, nil
|
||||
}
|
||||
return nil, apiError(resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
// apiError превращает ошибку 1С ({"code":"slot_busy","message":"…"} или {"error":{…}}) в типизированную.
|
||||
func apiError(status int, raw []byte) error {
|
||||
var e struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
code, msg := e.Code, e.Message
|
||||
if e.Error != nil {
|
||||
code, msg = e.Error.Code, e.Error.Message
|
||||
}
|
||||
var base error
|
||||
switch {
|
||||
case code == "slot_busy" || status == http.StatusConflict:
|
||||
base = ErrSlotBusy
|
||||
case code == "doctor_not_found" || status == http.StatusNotFound:
|
||||
base = ErrDoctorNotFound
|
||||
case code == "unauthorized" || status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
base = ErrUnauthorized
|
||||
case code == "validation" || status == http.StatusBadRequest:
|
||||
base = ErrValidation
|
||||
default:
|
||||
base = ErrUnavailable
|
||||
}
|
||||
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