From 6597464ac3c91995b129a8a54e613b8e55dae547 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Thu, 15 Oct 2026 10:35:12 +0000 Subject: [PATCH] feat(booking): automatically link 1C appointment to Bitrix24 lead ID and implement do_DELETE --- bridge/bridge_1c.py | 166 +++++++++ internal/api/booking_confirm.go | 8 + internal/onec/client.go | 606 ++++++++++++++++---------------- 3 files changed, 483 insertions(+), 297 deletions(-) diff --git a/bridge/bridge_1c.py b/bridge/bridge_1c.py index 8a6c910..47f7459 100644 --- a/bridge/bridge_1c.py +++ b/bridge/bridge_1c.py @@ -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(): diff --git a/internal/api/booking_confirm.go b/internal/api/booking_confirm.go index 49e785a..23d3a30 100644 --- a/internal/api/booking_confirm.go +++ b/internal/api/booking_confirm.go @@ -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) } diff --git a/internal/onec/client.go b/internal/onec/client.go index eafb7af..3e8357b 100644 --- a/internal/onec/client.go +++ b/internal/onec/client.go @@ -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 принимает ответ и в виде массива, и в виде объекта {"": [...]}. -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 принимает ответ и в виде массива, и в виде объекта {"": [...]}. +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 +}