feat(booking): automatically link 1C appointment to Bitrix24 lead ID and implement do_DELETE

This commit is contained in:
Dana Medical Developer 2026-10-15 10:35:12 +00:00
parent 2d803fcd28
commit 6597464ac3
3 changed files with 483 additions and 297 deletions

View File

@ -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():

View File

@ -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)
} }

View File

@ -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
}