dana-medical-site/internal/onec/client.go

310 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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