298 lines
9.4 KiB
Go
298 lines
9.4 KiB
Go
// 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
|
||
}
|