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