From 3eea5e05796d2048879a55c9d42d0f9afdf2f722 Mon Sep 17 00:00:00 2001 From: Ernest Litvinenko Date: Fri, 3 May 2024 19:02:41 +1000 Subject: [PATCH] add cdek --- CDEK/.gitignore | 12 ++ CDEK/CONTRIBUTING.md | 13 ++ CDEK/LICENSE | 21 +++ CDEK/README.md | 44 +++++++ CDEK/ROADMAP.md | 19 +++ CDEK/v2/auth.go | 70 ++++++++++ CDEK/v2/auth_test.go | 20 +++ CDEK/v2/calculator.go | 67 ++++++++++ CDEK/v2/calculator_test.go | 29 ++++ CDEK/v2/cities.go | 69 ++++++++++ CDEK/v2/cities_test.go | 24 ++++ CDEK/v2/client.go | 45 +++++++ CDEK/v2/client_test.go | 31 +++++ CDEK/v2/consts.go | 4 + CDEK/v2/deliveryPoints.go | 122 +++++++++++++++++ CDEK/v2/deliveryPoints_test.go | 26 ++++ CDEK/v2/helper.go | 38 ++++++ CDEK/v2/helper_test.go | 17 +++ CDEK/v2/orderDelete.go | 38 ++++++ CDEK/v2/orderDelete_test.go | 62 +++++++++ CDEK/v2/orderRegister.go | 78 +++++++++++ CDEK/v2/orderRegister_test.go | 61 +++++++++ CDEK/v2/orderStatus.go | 176 +++++++++++++++++++++++++ CDEK/v2/orderUpdate.go | 85 ++++++++++++ CDEK/v2/orderUpdate_test.go | 72 ++++++++++ CDEK/v2/readme.md | 3 + CDEK/v2/regions.go | 48 +++++++ CDEK/v2/regions_test.go | 24 ++++ CDEK/v2/types.go | 233 +++++++++++++++++++++++++++++++++ CDEK/v2/utils.go | 107 +++++++++++++++ 30 files changed, 1658 insertions(+) create mode 100644 CDEK/.gitignore create mode 100644 CDEK/CONTRIBUTING.md create mode 100644 CDEK/LICENSE create mode 100644 CDEK/README.md create mode 100644 CDEK/ROADMAP.md create mode 100644 CDEK/v2/auth.go create mode 100644 CDEK/v2/auth_test.go create mode 100644 CDEK/v2/calculator.go create mode 100644 CDEK/v2/calculator_test.go create mode 100644 CDEK/v2/cities.go create mode 100644 CDEK/v2/cities_test.go create mode 100644 CDEK/v2/client.go create mode 100644 CDEK/v2/client_test.go create mode 100644 CDEK/v2/consts.go create mode 100644 CDEK/v2/deliveryPoints.go create mode 100644 CDEK/v2/deliveryPoints_test.go create mode 100644 CDEK/v2/helper.go create mode 100644 CDEK/v2/helper_test.go create mode 100644 CDEK/v2/orderDelete.go create mode 100644 CDEK/v2/orderDelete_test.go create mode 100644 CDEK/v2/orderRegister.go create mode 100644 CDEK/v2/orderRegister_test.go create mode 100644 CDEK/v2/orderStatus.go create mode 100644 CDEK/v2/orderUpdate.go create mode 100644 CDEK/v2/orderUpdate_test.go create mode 100644 CDEK/v2/readme.md create mode 100644 CDEK/v2/regions.go create mode 100644 CDEK/v2/regions_test.go create mode 100644 CDEK/v2/types.go create mode 100644 CDEK/v2/utils.go diff --git a/CDEK/.gitignore b/CDEK/.gitignore new file mode 100644 index 0000000..3c66e46 --- /dev/null +++ b/CDEK/.gitignore @@ -0,0 +1,12 @@ +vendor +.idea/ + +# binary +cdek + +# go test -coverprofile cover.out +cover.out +# go tool cover -html=cover.out -o cover.html +cover.html + +v2/.env.local diff --git a/CDEK/CONTRIBUTING.md b/CDEK/CONTRIBUTING.md new file mode 100644 index 0000000..91aa1d7 --- /dev/null +++ b/CDEK/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +Feel free to browse the open issues and file new ones, all feedback welcome! + +We follow the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). + +We generally follow the standard [github pull request](https://help.github.com/articles/about-pull-requests/) process + +All changes must be code reviewed. +Expect reviewers to request that you avoid [common go style +mistakes](https://github.com/golang/go/wiki/CodeReviewComments) in your pull requests. + +New code must be covered with tests. Modified code should be checked by existing tests. diff --git a/CDEK/LICENSE b/CDEK/LICENSE new file mode 100644 index 0000000..b242a53 --- /dev/null +++ b/CDEK/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 vseinstrumenti.ru + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/CDEK/README.md b/CDEK/README.md new file mode 100644 index 0000000..c520ea5 --- /dev/null +++ b/CDEK/README.md @@ -0,0 +1,44 @@ +# GO SDK for CDEK API v2 +[![GoDoc reference](https://godoc.org/github.com/vseinstrumentiru/CDEK?status.svg)](https://godoc.org/github.com/vseinstrumentiru/CDEK) +[![Build Status](https://travis-ci.com/vseinstrumentiru/CDEK.svg?branch=master)](https://travis-ci.com/vseinstrumentiru/CDEK) +[![Coverage Status](https://coveralls.io/repos/github/vseinstrumentiru/CDEK/badge.svg?branch=travis)](https://coveralls.io/github/vseinstrumentiru/CDEK?branch=travis) +[![Go Report Card](https://goreportcard.com/badge/github.com/vseinstrumentiru/CDEK)](https://goreportcard.com/report/github.com/vseinstrumentiru/CDEK) +[![GitHub release](https://img.shields.io/github/release/vseinstrumentiru/cdek.svg)](https://github.com/vseinstrumentiru/CDEK/releases) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/2990/badge)](https://bestpractices.coreinfrastructure.org/projects/2990) + +The Go language implementation of SDK for [integration with CDEK](https://www.cdek.ru/clients/integrator.html) + +---- +Installation +------------ + +To install this package, you need to install Go and setup your Go workspace on +your computer. The simplest way to install the library is to run: + +``` +$ go get github.com/vseinstrumentiru/cdek +``` +With Go module support (Go 1.11+), simply `import "github.com/vseinstrumentiru/cdek"` in +your source code and `go [build|run|test]` will automatically download the +necessary dependencies ([Go modules +ref](https://github.com/golang/go/wiki/Modules)). + +Documentation +------------- +- See [godoc](https://godoc.org/github.com/vseinstrumentiru/CDEK) for package and API + descriptions and examples. + +Example +------------- +You cat get test `clientAccount` and `clientSecurePassword` from [the official CDEK documentation](https://confluence.cdek.ru/pages/viewpage.action?pageId=20264477#DataExchangeProtocol(v1.5)-TestAccount) +``` +import "github.com/vseinstrumentiru/cdek" +... + +client := cdek.NewClient("https://integration.edu.cdek.ru/"). + SetAuth(clientAccount, clientSecurePassword) + +cities, err := client.GetCities(map[cdek.CityFilter]string{ + cdek.CityFilterPage: "1", +}) +``` diff --git a/CDEK/ROADMAP.md b/CDEK/ROADMAP.md new file mode 100644 index 0000000..2250b27 --- /dev/null +++ b/CDEK/ROADMAP.md @@ -0,0 +1,19 @@ +- [ ] расчёт тарифов и обращения к справочникам + - [X] расчёт стоимости доставки по тарифам с приоритетом + - [ ] расчёт стоимости по тарифам без приоритета + - [X] получение списка пунктов выдачи заказов (ПВЗ) с фильтрацией + - [X] получение списка регионов-субъектов РФ + - [X] получение списка городов +- [ ] управление заказами + - [X] формирование новых заказов от ИМ + - [ ] оформление заказов на доставку + - [ ] получение квитанции в PDF + - [ ] получение почтовых этикеток в PDF + - [X] удаление заказов + - [X] изменение заказов + - [ ] получение информации по заказам (отчёт «Информация по заказам») + - [ ] трекинг заказов (отчёт «Статусы заказов») + - [ ] прозвон получателя + - [ ] вызов курьера + - [ ] создание преалерта + - [X] выбор базового URL интерфейса diff --git a/CDEK/v2/auth.go b/CDEK/v2/auth.go new file mode 100644 index 0000000..b7b642f --- /dev/null +++ b/CDEK/v2/auth.go @@ -0,0 +1,70 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type Credentials struct { + ClientID string + ClientSecret string +} + +func (c *Credentials) UrlValues() url.Values { + data := url.Values{} + + data.Set("grant_type", "client_credentials") + data.Set("client_id", c.ClientID) + data.Set("client_secret", c.ClientSecret) + + return data +} + +type AuthResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + Jti string `json:"jti"` +} + +func (c *clientImpl) getAccessToken(ctx context.Context) (string, error) { + d, err := time.ParseDuration(fmt.Sprintf("%ds", c.expireIn)) + if err != nil { + return "", err + } + isExpired := time.Now().After(time.Now().Add(d)) + + if len(c.accessToken) == 0 || isExpired { + resp, err := c.Auth(ctx) + if err != nil { + return "", err + } + c.accessToken = resp.AccessToken + c.expireIn = resp.ExpiresIn + } + + return c.accessToken, nil +} + +func (c *clientImpl) Auth(ctx context.Context) (*AuthResponse, error) { + if len(c.opts.Credentials.ClientID) == 0 || len(c.opts.Credentials.ClientSecret) == 0 { + return nil, fmt.Errorf("empty credentials") + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, + c.buildUri("/v2/oauth/token", nil), + strings.NewReader(c.opts.Credentials.UrlValues().Encode()), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return jsonReq[AuthResponse](req) +} diff --git a/CDEK/v2/auth_test.go b/CDEK/v2/auth_test.go new file mode 100644 index 0000000..e91ee0a --- /dev/null +++ b/CDEK/v2/auth_test.go @@ -0,0 +1,20 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_Auth(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.Auth(timedCtx) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/CDEK/v2/calculator.go b/CDEK/v2/calculator.go new file mode 100644 index 0000000..cb3c27b --- /dev/null +++ b/CDEK/v2/calculator.go @@ -0,0 +1,67 @@ +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type CalculatorTrafiffListRequest struct { + // Date Дата и время планируемой передачи заказа. По умолчанию - текущая + Date string `json:"date,omitempty"` + // Type Тип заказа: 1 - "интернет-магазин", 2 - "доставка". По умолчанию - 1 + Type string `json:"type,omitempty"` + // Валюта, в которой необходимо произвести расчет. По умолчанию - валюта договора + Currency int `json:"currency,omitempty"` + // Lang Локализация офиса. По умолчанию "rus" + Lang string `url:"lang,omitempty"` + // FromLocation Адрес отправления + FromLocation Location `json:"from_location,omitempty"` + // ToLocation Адрес получения + ToLocation Location `json:"to_location"` + // Packages Список информации по местам (упаковкам) + Packages []Package `json:"packages"` +} + +type Tariff struct { + TariffCode int `json:"tariff_code"` + TariffName string `json:"tariff_name"` + TariffDescription string `json:"tariff_description"` + DeliveryMode int `json:"delivery_mode"` + DeliverySum float64 `json:"delivery_sum"` + PeriodMin int `json:"period_min"` + PeriodMax int `json:"period_max"` +} + +type CalculatorTrafiffListResponse struct { + TariffCodes []Tariff `json:"tariff_codes"` +} + +func (c *clientImpl) CalculatorTrafiffList(ctx context.Context, input *CalculatorTrafiffListRequest) (*CalculatorTrafiffListResponse, error) { + payload, err := json.Marshal(input) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.buildUri("/v2/calculator/tarifflist", nil), + bytes.NewReader(payload), + ) + req.Header.Add("Content-Type", "application/json") + if err != nil { + return nil, err + } + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + return jsonReq[CalculatorTrafiffListResponse](req) +} diff --git a/CDEK/v2/calculator_test.go b/CDEK/v2/calculator_test.go new file mode 100644 index 0000000..7aa3ace --- /dev/null +++ b/CDEK/v2/calculator_test.go @@ -0,0 +1,29 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_CalculatorTrafiffList(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.CalculatorTrafiffList(timedCtx, &CalculatorTrafiffListRequest{ + Lang: "rus", + Currency: 1, + FromLocation: Location{Code: 44}, + ToLocation: Location{Code: 287}, + Packages: []Package{ + {Weight: 1}, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Greater(t, len(resp.TariffCodes), 0) +} diff --git a/CDEK/v2/cities.go b/CDEK/v2/cities.go new file mode 100644 index 0000000..429a4d4 --- /dev/null +++ b/CDEK/v2/cities.go @@ -0,0 +1,69 @@ +package v2 + +import ( + "context" + "fmt" + "github.com/pkg/errors" + "net/http" +) + +type CitiesRequest struct { + // CountryCodes Массив кодов стран в формате ISO_3166-1_alpha-2 + CountryCodes []string `url:"country_codes,omitempty"` + // RegionCode Код региона СДЭК + RegionCode int `url:"region_code,omitempty"` + // FiasGuid Уникальный идентификатор ФИАС населенного пункта UUID + FiasGuid string `url:"fias_guid,omitempty"` + // PostalCode Почтовый индекс + PostalCode string `url:"postal_code,omitempty"` + // Code Код населенного пункта СДЭК + Code string `url:"code,omitempty"` + // City Название населенного пункта. Должно соответствовать полностью + City string `url:"city,omitempty"` + // Size Ограничение выборки результата. По умолчанию 1000 + Size int `url:"size,omitempty"` + // Page Номер страницы выборки результата. По умолчанию 0 + Page int `url:"page,omitempty"` + // Lang Локализация офиса. По умолчанию "rus" + Lang string `url:"lang,omitempty"` +} + +type CitiesResponse []*City + +type City struct { + Code int `json:"code"` + City string `json:"city"` + CountryCode string `json:"country_code"` + Country string `json:"country"` + Region string `json:"region,omitempty"` + RegionCode int `json:"region_code"` + SubRegion string `json:"sub_region,omitempty"` + PostalCodes []string `json:"postal_codes,omitempty"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + TimeZone string `json:"time_zone"` + KladrCode string `json:"kladr_code,omitempty"` + PaymentLimit float64 `json:"payment_limit,omitempty"` + FiasGuid string `json:"fias_guid,omitempty"` +} + +func (c *clientImpl) Cities(ctx context.Context, input *CitiesRequest) (*CitiesResponse, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.buildUri("/v2/location/cities", input), + nil, + ) + if err != nil { + return nil, errors.Wrap(err, "http.NewRequestWithContext") + } + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, errors.Wrap(err, "getAccessToken") + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + return jsonReq[CitiesResponse](req) +} diff --git a/CDEK/v2/cities_test.go b/CDEK/v2/cities_test.go new file mode 100644 index 0000000..6d8813a --- /dev/null +++ b/CDEK/v2/cities_test.go @@ -0,0 +1,24 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_Cities(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.Cities(timedCtx, nil) + require.NoError(t, err) + require.NotNil(t, resp) + + resp, err = c.Cities(timedCtx, &CitiesRequest{Page: 1000, Size: 1000}) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/CDEK/v2/client.go b/CDEK/v2/client.go new file mode 100644 index 0000000..558fde1 --- /dev/null +++ b/CDEK/v2/client.go @@ -0,0 +1,45 @@ +package v2 + +import ( + "context" + "fmt" + "github.com/google/go-querystring/query" + "strings" +) + +type Client interface { + Auth(ctx context.Context) (*AuthResponse, error) + DeliveryPoints(ctx context.Context, input *DeliveryPointsRequest) (*DeliveryPointsResponse, error) + Regions(ctx context.Context, input *RegionsRequest) (*RegionsResponse, error) + Cities(ctx context.Context, input *CitiesRequest) (*CitiesResponse, error) + CalculatorTrafiffList(ctx context.Context, input *CalculatorTrafiffListRequest) (*CalculatorTrafiffListResponse, error) + OrderRegister(ctx context.Context, input *OrderRegisterRequest) (*Response, error) + OrderDelete(ctx context.Context, uuid string) (*Response, error) + OrderUpdate(ctx context.Context, input *OrderUpdateRequest) (*OrderUpdateResponse, error) + OrderStatus(ctx context.Context, uuid string) (*Response, error) +} + +type Options struct { + Endpoint string + Credentials *Credentials +} + +func NewClient(opts *Options) Client { + return &clientImpl{opts: opts} +} + +type clientImpl struct { + opts *Options + accessToken string + expireIn int +} + +func (c *clientImpl) buildUri(p string, values interface{}) string { + v, _ := query.Values(values) + return strings.TrimRight(fmt.Sprintf( + "%s/%s?%s", + strings.TrimRight(c.opts.Endpoint, "/"), + strings.TrimLeft(p, "/"), + v.Encode(), + ), "?") +} diff --git a/CDEK/v2/client_test.go b/CDEK/v2/client_test.go new file mode 100644 index 0000000..333bd24 --- /dev/null +++ b/CDEK/v2/client_test.go @@ -0,0 +1,31 @@ +package v2 + +import ( + "fmt" + "github.com/joho/godotenv" + "os" +) + +func createTestClient() Client { + wd, _ := os.Getwd() + godotenv.Load(fmt.Sprintf("%s/.env.local", wd)) + + clientId := os.Getenv("CDEK_CLIENT_ID") + clientSecretId := os.Getenv("CDEK_SECRET_ID") + + // public cdek test credentials + if clientId == "" { + clientId = "EMscd6r9JnFiQ3bLoyjJY6eM78JrJceI" + } + if clientSecretId == "" { + clientSecretId = "PjLZkKBHEiLK3YsjtNrt3TGNG0ahs3kG" + } + + return NewClient(&Options{ + Endpoint: EndpointTest, + Credentials: &Credentials{ + ClientID: clientId, + ClientSecret: clientSecretId, + }, + }) +} diff --git a/CDEK/v2/consts.go b/CDEK/v2/consts.go new file mode 100644 index 0000000..78a0fe8 --- /dev/null +++ b/CDEK/v2/consts.go @@ -0,0 +1,4 @@ +package v2 + +const EndpointTest = "https://api.edu.cdek.ru" +const EndpointProd = "https://api.cdek.ru" diff --git a/CDEK/v2/deliveryPoints.go b/CDEK/v2/deliveryPoints.go new file mode 100644 index 0000000..aee60a3 --- /dev/null +++ b/CDEK/v2/deliveryPoints.go @@ -0,0 +1,122 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" +) + +type DeliveryPointsResponse []DeliveryPoint + +type DeliveryPointWorkTime struct { + Day int `json:"day"` + Time string `json:"time"` +} + +type DeliveryPointWorkTimeExceptions struct { + Date string `json:"date"` + IsWorking bool `json:"is_working"` +} + +type DeliveryPointOfficeImage struct { + Url string `json:"url"` +} + +type DeliveryPointLocation struct { + CountryCode string `json:"country_code"` + RegionCode int `json:"region_code"` + Region string `json:"region,omitempty"` + CityCode int `json:"city_code"` + City string `json:"city,omitempty"` + FiasGuid string `json:"fias_guid,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Address string `json:"address"` + AddressFull string `json:"address_full,omitempty"` +} + +type DeliveryPoint struct { + Code string `json:"code"` + Name string `json:"name,omitempty"` + AddressComment string `json:"address_comment,omitempty"` + WorkTime string `json:"work_time,omitempty"` + Phones []Phone `json:"phones,omitempty"` + Email string `json:"email,omitempty"` + Note string `json:"note,omitempty"` + Type string `json:"type"` + OwnerCode string `json:"owner_code"` + TakeOnly bool `json:"take_only"` + IsHandout bool `json:"is_handout,omitempty"` + IsReception bool `json:"is_reception,omitempty"` + IsDressingRoom bool `json:"is_dressing_room,omitempty"` + HaveCashless bool `json:"have_cashless"` + HaveCash bool `json:"have_cash"` + AllowedCod bool `json:"allowed_cod"` + Site string `json:"site,omitempty"` + WorkTimeList []DeliveryPointWorkTime `json:"work_time_list,omitempty"` + WeightMin float64 `json:"weight_min,omitempty"` + WeightMax float64 `json:"weight_max,omitempty"` + Location DeliveryPointLocation `json:"location"` + Fulfillment bool `json:"fulfillment"` + NearestStation string `json:"nearest_station,omitempty"` + NearestMetroStation string `json:"nearest_metro_station,omitempty"` + OfficeImageList []DeliveryPointOfficeImage `json:"office_image_list,omitempty"` + WorkTimeExceptions []DeliveryPointWorkTimeExceptions `json:"work_time_exceptions,omitempty"` +} + +type DeliveryPointsRequest struct { + // PostalCode Почтовый индекс города, для которого необходим список офисов + PostalCode int `url:"postal_code,omitempty"` + // CityCode Код населенного пункта СДЭК (метод "Список населенных пунктов") + CityCode int `url:"city_code,omitempty"` + // Type Тип офиса, может принимать значения: «PVZ» - склады, «POSTAMAT» - постаматы, «ALL» - все. + Type string `url:"type,omitempty"` + // CountryCode Код страны в формате ISO_3166-1_alpha-2 (см. “Общероссийский классификатор стран мира”) + CountryCode string `url:"country_code,omitempty"` + // RegionCode Код региона по базе СДЭК + RegionCode int `url:"region_code,omitempty"` + // HaveCashless Наличие терминала оплаты + HaveCashless bool `url:"have_cashless,omitempty"` + // HaveCash Есть прием наличных + HaveCash bool `url:"have_cash,omitempty"` + // AllowedCod Разрешен наложенный платеж + AllowedCod bool `url:"allowed_cod,omitempty"` + // IsDressingRoom Наличие примерочной + IsDressingRoom bool `url:"is_dressing_room,omitempty"` + // WeightMax Максимальный вес в кг, который может принять офис (значения больше 0 - передаются офисы, которые принимают этот вес; 0 - офисы с нулевым весом не передаются; значение не указано - все офисы) + WeightMax bool `url:"weight_max,omitempty"` + // WeightMin Минимальный вес в кг, который принимает офис (при переданном значении будут выводиться офисы с минимальным весом до указанного значения) + WeightMin bool `url:"weight_min,omitempty"` + // Lang Локализация офиса. По умолчанию "rus" + Lang string `url:"lang,omitempty"` + // TakeOnly Является ли офис только пунктом выдачи + TakeOnly bool `url:"take_only,omitempty"` + // IsHandout Является пунктом выдачи, может принимать значения + IsHandout bool `url:"is_handout,omitempty"` + // IsReception Есть ли в офисе приём заказов + IsReception bool `url:"is_reception,omitempty"` + // FiasGuid Код города ФИАС UUID + FiasGuid string `url:"fias_guid,omitempty"` +} + +func (c *clientImpl) DeliveryPoints(ctx context.Context, input *DeliveryPointsRequest) (*DeliveryPointsResponse, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.buildUri("/v2/deliverypoints", input), + nil, + ) + if err != nil { + return nil, err + } + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + return jsonReq[DeliveryPointsResponse](req) +} diff --git a/CDEK/v2/deliveryPoints_test.go b/CDEK/v2/deliveryPoints_test.go new file mode 100644 index 0000000..2ff8cb2 --- /dev/null +++ b/CDEK/v2/deliveryPoints_test.go @@ -0,0 +1,26 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_DeliveryPoints(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.DeliveryPoints(timedCtx, nil) + require.NoError(t, err) + require.NotNil(t, resp) + + resp, err = c.DeliveryPoints(timedCtx, &DeliveryPointsRequest{ + CountryCode: "ru", + }) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/CDEK/v2/helper.go b/CDEK/v2/helper.go new file mode 100644 index 0000000..2f62a2a --- /dev/null +++ b/CDEK/v2/helper.go @@ -0,0 +1,38 @@ +package v2 + +import ( + "context" +) + +func HelperCitiesAll(ctx context.Context, c Client, input *CitiesRequest, first int) (*CitiesResponse, error) { + resp := &CitiesResponse{} + + if input == nil { + input = &CitiesRequest{Size: 500} + } + + if input.Size > 500 { + input.Size = 500 + } + + for { + chunk, err := c.Cities(ctx, input) + if err != nil { + return nil, err + } + + if len(*chunk) == 0 { + break + } + + *resp = append(*resp, *chunk...) + + if len(*resp) >= first { + break + } + + input.Page += 1 + } + + return resp, nil +} diff --git a/CDEK/v2/helper_test.go b/CDEK/v2/helper_test.go new file mode 100644 index 0000000..aabe538 --- /dev/null +++ b/CDEK/v2/helper_test.go @@ -0,0 +1,17 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" +) + +func TestClientImpl_CitiesAll(t *testing.T) { + ctx := context.Background() + + c := createTestClient() + + resp, err := HelperCitiesAll(ctx, c, nil, 100) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/CDEK/v2/orderDelete.go b/CDEK/v2/orderDelete.go new file mode 100644 index 0000000..7f8b0f3 --- /dev/null +++ b/CDEK/v2/orderDelete.go @@ -0,0 +1,38 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" +) + +func (c *clientImpl) OrderDelete(ctx context.Context, uuid string) (*Response, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodDelete, + c.buildUri(fmt.Sprintf("/v2/orders/%s", uuid), nil), + nil, + ) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err := jsonReq[Response](req) + if err != nil { + return nil, err + } + + if err := validateResponse(resp.Requests); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/CDEK/v2/orderDelete_test.go b/CDEK/v2/orderDelete_test.go new file mode 100644 index 0000000..2f5abc0 --- /dev/null +++ b/CDEK/v2/orderDelete_test.go @@ -0,0 +1,62 @@ +package v2 + +import ( + "context" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_OrderDelete(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.OrderRegister(timedCtx, nil) + require.Error(t, err) + require.Nil(t, resp) + + uid := uuid.NewString() + resp, err = c.OrderRegister(timedCtx, &OrderRegisterRequest{ + Type: 0, + Number: uid, + Comment: "test", + TariffCode: 62, + FromLocation: Location{Code: 44, Address: "qwe"}, + ToLocation: Location{Code: 287, Address: "qwe"}, + Sender: RecipientSender{ + Name: "test", + Company: "test", + Email: "test@test.com", + }, + Recipient: RecipientSender{ + Name: "test", + Phones: []Phone{ + {Number: "123"}, + }, + }, + Packages: []Package{ + { + Number: "test", + Weight: 1, + Comment: "test", + Items: []PackageItem{ + { + Name: "test", + WareKey: "test", + }, + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Greater(t, len(resp.Requests), 0) + + statusResp, err := c.OrderDelete(ctx, resp.Entity.Uuid) + require.NoError(t, err) + require.Equal(t, statusResp.Entity.Uuid, resp.Entity.Uuid) +} diff --git a/CDEK/v2/orderRegister.go b/CDEK/v2/orderRegister.go new file mode 100644 index 0000000..a30ef6a --- /dev/null +++ b/CDEK/v2/orderRegister.go @@ -0,0 +1,78 @@ +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type OrderRegisterRequest struct { + // Type Тип заказа: 1 - "интернет-магазин" (только для договора типа "Договор с ИМ"), 2 - "доставка" (для любого договора) + Type int `json:"type,omitempty"` + // Number Только для заказов "интернет-магазин". Номер заказа в ИС Клиента (если не передан, будет присвоен номер заказа в ИС СДЭК - uuid) + // Может содержать только цифры, буквы латинского алфавита или спецсимволы (формат ASCII) + Number string `json:"number"` + // Comment Комментарий к заказу + Comment string `json:"comment"` + // TariffCode Код тарифа (подробнее см. приложение 1) + TariffCode int `json:"tariff_code"` + // OrderDeliveryRecipientCost Доп. сбор за доставку, которую ИМ берет с получателя. Только для заказов "интернет-магазин". + DeliveryRecipientCost Payment `json:"delivery_recipient_cost"` + // DeliveryRecipientCostAdv Доп. сбор за доставку (которую ИМ берет с получателя) в зависимости от суммы заказа + // Только для заказов "интернет-магазин". Возможно указать несколько порогов. + DeliveryRecipientCostAdv Cost `json:"delivery_recipient_cost_adv"` + // FromLocation Адрес отправления. Не может использоваться одновременно с shipment_point + FromLocation Location `json:"from_location"` + // ToLocation Адрес получения. Не может использоваться одновременно с delivery_point + ToLocation Location `json:"to_location"` + // Packages Список информации по местам (упаковкам). Количество мест в заказе может быть от 1 до 255 + Packages []Package `json:"packages,omitempty"` + // Recipient Получатель + Recipient RecipientSender `json:"recipient"` + // Sender Отправитель. Обязателен если: + // нет, если заказ типа "интернет-магазин" + // да, если заказ типа "доставка" + Sender RecipientSender `json:"sender,omitempty"` + // Services Дополнительные услуги + Services []Service `json:"services,omitempty"` + // Seller Реквизиты истинного продавца. Только для заказов "интернет-магазин" + Seller Seller `json:"seller,omitempty"` +} + +func (c *clientImpl) OrderRegister(ctx context.Context, input *OrderRegisterRequest) (*Response, error) { + payload, err := json.Marshal(input) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.buildUri("/v2/orders", nil), + bytes.NewReader(payload), + ) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err := jsonReq[Response](req) + if err != nil { + return nil, err + } + + if err := validateResponse(resp.Requests); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/CDEK/v2/orderRegister_test.go b/CDEK/v2/orderRegister_test.go new file mode 100644 index 0000000..b40437d --- /dev/null +++ b/CDEK/v2/orderRegister_test.go @@ -0,0 +1,61 @@ +package v2 + +import ( + "context" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_OrderRegisterStatus(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.OrderRegister(timedCtx, nil) + require.Error(t, err) + require.Nil(t, resp) + + resp, err = c.OrderRegister(timedCtx, &OrderRegisterRequest{ + Type: 0, + Number: uuid.NewString(), + Comment: "test", + TariffCode: 62, + FromLocation: Location{Code: 44, Address: "qwe"}, + ToLocation: Location{Code: 287, Address: "qwe"}, + Sender: RecipientSender{ + Name: "test", + Company: "test", + Email: "test@test.com", + }, + Recipient: RecipientSender{ + Name: "test", + Phones: []Phone{ + {Number: "123"}, + }, + }, + Packages: []Package{ + { + Number: "test", + Weight: 1, + Comment: "test", + Items: []PackageItem{ + { + Name: "test", + WareKey: "test", + }, + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Greater(t, len(resp.Requests), 0) + + statusResp, err := c.OrderStatus(ctx, resp.Entity.Uuid) + require.NoError(t, err) + require.Equal(t, statusResp.Entity.Comment, "test") +} diff --git a/CDEK/v2/orderStatus.go b/CDEK/v2/orderStatus.go new file mode 100644 index 0000000..d74053d --- /dev/null +++ b/CDEK/v2/orderStatus.go @@ -0,0 +1,176 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" +) + +type OrderStatusFailedCall struct { + // DateTime Дата и время создания недозвона datetime + DateTime string `json:"date_time"` + // ReasonCode Причина недозвона (подробнее см. приложение 5) + ReasonCode int `json:"reason_code"` +} + +type OrderStatusRescheduledCall struct { + // DateTime Дата и время создания переноса прозвона + DateTime string `json:"date_time"` + // DateNext Дата, на которую согласован повторный прозвон + DateNext string `json:"date_next"` + // TimeNext Время, на которое согласован повторный прозвон + TimeNext string `json:"time_next"` + // Comment Комментарий к переносу прозвона + Comment string `json:"comment,omitempty"` +} + +type OrderStatusCall struct { + // FailedCalls Информация о неуспешных прозвонах (недозвонах) + FailedCalls OrderStatusFailedCall `json:"failed_calls,omitempty"` + // RescheduledCalls Информация о переносах прозвонов + RescheduledCalls OrderStatusRescheduledCall `json:"rescheduled_calls,omitempty"` +} + +type OrderStatusDeliveryProblem struct { + // Code Код проблемы (подробнее см. приложение 4) https://api-docs.cdek.ru/29923975.html + Code string `json:"code,omitempty"` + // CreateDate Дата создания проблемы + CreateDate string `json:"create_date,omitempty"` +} + +type OrderStatusPaymentInfo struct { + // Type Тип оплаты: CARD - картой, CASH - наличными + Type string `json:"type"` + // Sum Сумма в валюте страны получателя + Sum float64 `json:"sum"` + // DeliverySum Стоимость услуги доставки (по тарифу) + DeliverySum float64 `json:"delivery_sum"` + // TotalSum Итоговая стоимость заказа + TotalSum float64 `json:"total_sum"` +} + +type OrderStatusDeliveryDetail struct { + // Date Дата доставки + Date string `json:"date"` + // RecipientName получатель при доставке + RecipientName string `json:"recipient_name"` + // PaymentSum Сумма наложенного платежа, которую взяли с получателя, в валюте страны получателя с учетом частичной доставки + PaymentSum float64 `json:"payment_sum,omitempty"` + // PaymentInfo Тип оплаты наложенного платежа получателем + PaymentInfo []OrderStatusPaymentInfo `json:"payment_info,omitempty"` + // DeliverySum Стоимость услуги доставки (по тарифу) + DeliverySum float64 `json:"delivery_sum"` + TotalSum float64 `json:"total_sum"` +} + +type OrderStatusInfo struct { + // Code Код статуса (подробнее см. приложение 1) + Code string `json:"code"` + // Name Название статуса + Name string `json:"name"` + // DateTime Дата и время установки статуса (формат yyyy-MM-dd'T'HH:mm:ssZ) + DateTime string `json:"date_time"` + // ReasonCode Дополнительный код статуса (подробнее см. приложение 2) + ReasonCode string `json:"reason_code,omitempty"` + // City Наименование места возникновения статуса + City string `json:"city"` +} + +type OrderStatusEntity struct { + // Uuid Идентификатор заказа в ИС СДЭК + Uuid string `json:"uuid"` + // IsReturn Признак возвратного заказа: true - возвратный, false - прямой + IsReturn bool `json:"is_return"` + // IsReverse Признак реверсного заказа: true - реверсный, false - не реверсный + IsReverse bool `json:"is_reverse"` + // Type Тип заказа: 1 - "интернет-магазин" (только для договора типа "Договор с ИМ"), 2 - "доставка" (для любого договора) + Type int `json:"type"` + // CdekNumber Номер заказа СДЭК + CdekNumber string `json:"cdek_number,omitempty"` + // Number Номер заказа в ИС Клиента. При запросе информации по данному полю возможны варианты: + // - если не передан, будет присвоен номер заказа в ИС СДЭК - uuid; + // - если найдено больше 1, то выбирается созданный с самой последней датой. + // Может содержать только цифры, буквы латинского алфавита или спецсимволы (формат ASCII) + Number string `json:"number,omitempty"` + // DeliveryMode Истинный режим заказа: + // 1 - дверь-дверь + // 2 - дверь-склад + // 3 - склад-дверь + // 4 - склад-склад + // 6 - дверь-постамат + // 7 - склад-постамат + DeliveryMode string `json:"delivery_mode"` + //// TariffCode Код тарифа + //TariffCode int `json:"tariff_code"` + // Comment Комментарий к заказу + Comment string `json:"comment,omitempty"` + // DeveloperKey Ключ разработчика + DeveloperKey string `json:"developer_key,omitempty"` + // ShipmentPoint Код ПВЗ СДЭК, на который будет производиться самостоятельный привоз клиентом + ShipmentPoint string `json:"shipment_point,omitempty"` + // DeliveryPoint Код офиса СДЭК (ПВЗ/постамат), на который будет доставлена посылка + DeliveryPoint string `json:"delivery_point,omitempty"` + // DateInvoice Дата инвойса. Только для международных заказов. date (yyyy-MM-dd) + DateInvoice string `json:"date_invoice,omitempty"` + // ShipperName Грузоотправитель. Только для международных заказов + ShipperName string `json:"shipper_name,omitempty"` + // ShipperAddress Адрес грузоотправителя. Только для международных заказов + ShipperAddress string `json:"shipper_address,omitempty"` + // DeliveryRecipientCost Доп. сбор за доставку, которую ИМ берет с получателя. + DeliveryRecipientCost Payment `json:"delivery_recipient_cost,omitempty"` + // DeliveryRecipientCostAdv Доп. сбор за доставку (которую ИМ берет с получателя), в зависимости от суммы заказа + DeliveryRecipientCostAdv []Cost `json:"delivery_recipient_cost_adv,omitempty"` + // Sender Отправитель + Sender RecipientSender `json:"sender"` + // Seller Реквизиты истинного продавца + Seller Seller `json:"seller,omitempty"` + // Recipient Получатель + Recipient RecipientSender `json:"recipient,omitempty"` + // FromLocation Адрес отправления. Не может использоваться одновременно с shipment_point + FromLocation Location `json:"from_location"` + // ToLocation Адрес получения. Не может использоваться одновременно с delivery_point + ToLocation Location `json:"to_location"` + // ItemsCostCurrency TODO + ItemsCostCurrency string `json:"items_cost_currency"` + // RecipientCurrency TODO + RecipientCurrency string `json:"recipient_currency"` + // Services Дополнительные услуги + Services []Service `json:"services,omitempty"` + // Packages Список информации по местам (упаковкам) + Packages []Package `json:"packages"` + // DeliveryProblem Проблемы доставки, с которыми столкнулся курьер при доставке заказа "до двери" + DeliveryProblem []OrderStatusDeliveryProblem `json:"delivery_problem,omitempty"` + // DeliveryDetail Информация о вручении + DeliveryDetail OrderStatusDeliveryDetail `json:"delivery_detail,omitempty"` + // TransactedPayment Признак того, что по заказу была получена информация о переводе наложенного платежа интернет-магазину + TransactedPayment bool `json:"transacted_payment,omitempty"` + // Statuses Список статусов по заказу, отсортированных по дате и времени + Statuses []OrderStatusInfo `json:"statuses"` + // Calls Информация о прозвонах получателя + Calls []OrderStatusCall `json:"calls,omitempty"` + // @todo ticket SD-735298 - this is not documented but exists in example response https://api-docs.cdek.ru/29923975.html + DeliveryDate string `json:"delivery_date,omitempty"` + ShopSellerName string `json:"shop_seller_name,omitempty"` +} + +func (c *clientImpl) OrderStatus(ctx context.Context, uuid string) (*Response, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.buildUri(fmt.Sprintf("/v2/orders/%s", uuid), nil), + nil, + ) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + return jsonReq[Response](req) +} diff --git a/CDEK/v2/orderUpdate.go b/CDEK/v2/orderUpdate.go new file mode 100644 index 0000000..004ca94 --- /dev/null +++ b/CDEK/v2/orderUpdate.go @@ -0,0 +1,85 @@ +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type OrderUpdateRequest struct { + // UUID Идентификатор заказа в ИС СДЭК, который нужно изменить (да, если не заполнен cdek_number) + UUID string `json:"uuid,omitempty"` + // CdekNumber Номер заказа СДЭК, который нужно изменить (да, если не заполнен uuid) + CdekNumber string `json:"cdek_number,omitempty"` + // Код тарифа (режимы старого и нового тарифа должны совпадать) + TariffCode int `json:"tariff_code,omitempty"` + // Comment Комментарий к заказу + Comment string `json:"comment"` + // ShipmentPoint Код ПВЗ СДЭК, на который будет производится забор отправления либо самостоятельный привоз клиентом. Не может использоваться одновременно с from_location + ShipmentPoint string `json:"shipment_point,omitempty"` + // DeliveryPoint Код ПВЗ СДЭК, на который будет доставлена посылка. Не может использоваться одновременно с to_location + DeliveryPoint string `json:"delivery_point,omitempty"` + // OrderDeliveryRecipientCost Доп. сбор за доставку, которую ИМ берет с получателя. Валюта сбора должна совпадать с валютой наложенного платежа + DeliveryRecipientCost Payment `json:"delivery_recipient_cost"` + // DeliveryRecipientCostAdv Доп. сбор за доставку (которую ИМ берет с получателя) в зависимости от суммы заказа. Только для заказов "интернет-магазин". Возможно указать несколько порогов. + DeliveryRecipientCostAdv Cost `json:"delivery_recipient_cost_adv"` + // Sender Отправитель. Обязателен если: + // нет, если заказ типа "интернет-магазин" + // да, если заказ типа "доставка" + Sender RecipientSender `json:"sender,omitempty"` + // Seller Реквизиты истинного продавца + Seller Seller `json:"seller,omitempty"` + // Recipient Получатель + Recipient RecipientSender `json:"recipient,omitempty"` + // ToLocation Адрес получения. Не может использоваться одновременно с delivery_point + ToLocation Location `json:"to_location"` + // FromLocation Адрес отправления. Не может использоваться одновременно с shipment_point + FromLocation Location `json:"from_location"` + // Services Дополнительные услуги + Services []Service `json:"services,omitempty"` + // Packages Список информации по местам (упаковкам) + Packages []Package `json:"packages,omitempty"` +} + +type OrderUpdateResponse struct { + Entity ResponseEntity `json:"entity,omitempty"` + Requests []ResponseRequests `json:"requests"` +} + +func (c *clientImpl) OrderUpdate(ctx context.Context, input *OrderUpdateRequest) (*OrderUpdateResponse, error) { + payload, err := json.Marshal(input) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.buildUri("/v2/orders", nil), + bytes.NewReader(payload), + ) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err := jsonReq[OrderUpdateResponse](req) + if err != nil { + return nil, err + } + + if err := validateResponse(resp.Requests); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/CDEK/v2/orderUpdate_test.go b/CDEK/v2/orderUpdate_test.go new file mode 100644 index 0000000..54e4671 --- /dev/null +++ b/CDEK/v2/orderUpdate_test.go @@ -0,0 +1,72 @@ +package v2 + +import ( + "context" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_OrderUpdate(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.OrderRegister(timedCtx, nil) + require.Error(t, err) + require.Nil(t, resp) + + registerReq := &OrderRegisterRequest{ + Type: 0, + Number: uuid.NewString(), + Comment: "test", + TariffCode: 62, + FromLocation: Location{Code: 44, Address: "qwe"}, + ToLocation: Location{Code: 287, Address: "qwe"}, + Sender: RecipientSender{ + Name: "test", + Company: "test", + Email: "test@test.com", + }, + Recipient: RecipientSender{ + Name: "test", + Phones: []Phone{ + {Number: "123"}, + }, + }, + Packages: []Package{ + { + Number: "test", + Weight: 1, + Comment: "test", + Items: []PackageItem{ + { + Name: "test", + WareKey: "test", + }, + }, + }, + }, + } + resp, err = c.OrderRegister(timedCtx, registerReq) + require.NoError(t, err) + require.NotNil(t, resp) + require.Greater(t, len(resp.Requests), 0) + + updateResp, err := c.OrderUpdate(ctx, &OrderUpdateRequest{ + UUID: resp.Entity.Uuid, + Comment: "updated", + ToLocation: registerReq.ToLocation, + Recipient: registerReq.Recipient, + TariffCode: registerReq.TariffCode, + Packages: registerReq.Packages, + }) + require.NoError(t, err) + + statusResp, err := c.OrderStatus(ctx, updateResp.Entity.Uuid) + require.NoError(t, err) + require.Equal(t, statusResp.Entity.Comment, "updated") +} diff --git a/CDEK/v2/readme.md b/CDEK/v2/readme.md new file mode 100644 index 0000000..830452a --- /dev/null +++ b/CDEK/v2/readme.md @@ -0,0 +1,3 @@ +CDEK changelog: + +https://api-docs.cdek.ru/36967918.html diff --git a/CDEK/v2/regions.go b/CDEK/v2/regions.go new file mode 100644 index 0000000..1bfdf62 --- /dev/null +++ b/CDEK/v2/regions.go @@ -0,0 +1,48 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" +) + +type RegionsRequest struct { + // CountryCodes Массив кодов стран в формате ISO_3166-1_alpha-2 + CountryCodes []string `url:"country_codes,omitempty"` + // Size Ограничение выборки результата. По умолчанию 1000 + Size int `url:"size,omitempty"` + // Page Номер страницы выборки результата. По умолчанию 0 + Page int `url:"page,omitempty"` + // Lang Локализация офиса. По умолчанию "rus" + Lang string `url:"lang,omitempty"` +} + +type RegionsResponse []Region + +type Region struct { + CountryCode string `json:"country_code"` + Region string `json:"region"` + Country string `json:"country"` + RegionCode int `json:"region_code,omitempty"` +} + +func (c *clientImpl) Regions(ctx context.Context, input *RegionsRequest) (*RegionsResponse, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.buildUri("/v2/location/regions", input), + nil, + ) + if err != nil { + return nil, err + } + + accessToken, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + return jsonReq[RegionsResponse](req) +} diff --git a/CDEK/v2/regions_test.go b/CDEK/v2/regions_test.go new file mode 100644 index 0000000..e90b097 --- /dev/null +++ b/CDEK/v2/regions_test.go @@ -0,0 +1,24 @@ +package v2 + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestClientImpl_Regions(t *testing.T) { + ctx := context.Background() + timedCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + c := createTestClient() + + resp, err := c.Regions(timedCtx, nil) + require.NoError(t, err) + require.NotNil(t, resp) + + resp, err = c.Regions(timedCtx, &RegionsRequest{Page: 1000, Size: 1000}) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/CDEK/v2/types.go b/CDEK/v2/types.go new file mode 100644 index 0000000..ef48f53 --- /dev/null +++ b/CDEK/v2/types.go @@ -0,0 +1,233 @@ +package v2 + +// ResponseEntity Информация о заказе +type ResponseEntity struct { + // Uuid Идентификатор заказа в ИС СДЭК + Uuid string `json:"uuid,omitempty"` + // Comment комментарий + Comment string `json:"comment,omitempty"` +} + +type ResponseErr struct { + // Message Описание ошибки + Message string `json:"message"` + // Code string Код ошибки + Code string `json:"code"` +} + +// ResponseRequests Информация о запросе над заказом +type ResponseRequests struct { + // RequestUuid Идентификатор запроса в ИС СДЭК + RequestUuid string `json:"request_uuid,omitempty"` + // Type Тип запроса. Может принимать значения: CREATE, UPDATE, DELETE, AUTH, GET + Type string `json:"type"` + // State Текущее состояние запроса. Может принимать значения: + // ACCEPTED - пройдена предварительная валидация и запрос принят + // WAITING - запрос ожидает обработки (зависит от выполнения другого запроса) + // SUCCESSFUL - запрос обработан успешно + // INVALID - запрос обработался с ошибкой + State string `json:"state"` + // DateTime Дата и время установки текущего состояния запроса (формат yyyy-MM-dd'T'HH:mm:ssZ) + DateTime string `json:"date_time"` + // Errors Ошибки, возникшие в ходе выполнения запроса + Errors []ResponseErr `json:"errors,omitempty"` + // Warnings Предупреждения, возникшие в ходе выполнения запроса + Warnings []ResponseErr `json:"warnings,omitempty"` +} + +// ResponseRelatedEntities Связанные сущности (если в запросе был передан корректный print) +type ResponseRelatedEntities struct { + // Type Тип связанной сущности. Может принимать значения: waybill - квитанция к заказу, barcode - ШК места к заказу + Type string `json:"type"` + // Uuid Идентификатор сущности, связанной с заказом + Uuid string `json:"uuid"` + // Url Ссылка на скачивание печатной формы в статусе "Сформирован", только для type = waybill, barcode + Url string `json:"url,omitempty"` + // CdekNumber Номер заказа СДЭК. Может возвращаться для return_order, direct_order, reverse_order + CdekNumber string `json:"cdek_number,omitempty"` + // Date Дата доставки, согласованная с получателем. Только для типа delivery + Date string `json:"date,omitempty"` + // TimeFrom Время начала ожидания курьера (согласованное с получателем). Только для типа delivery + TimeFrom string `json:"time_from,omitempty"` + // Date Время окончания ожидания курьера (согласованное с получателем). Только для типа delivery + TimeTo string `json:"time_to,omitempty"` +} + +type Response struct { + Entity ResponseEntity `json:"entity,omitempty"` + Requests []ResponseRequests `json:"requests"` + RelatedEntities *ResponseRelatedEntities `json:"related_entities,omitempty"` +} + +type Location struct { + // Code Код населенного пункта СДЭК (метод "Список населенных пунктов") + Code int `json:"code,omitempty"` + // FiasGuid Уникальный идентификатор ФИАС UUID + FiasGuid string `json:"fias_guid,omitempty"` + // PostalCode Почтовый индекс + PostalCode string `json:"postal_code,omitempty"` + // Longitude Долгота + Longitude float64 `json:"longitude,omitempty"` + // Latitude Широта + Latitude float64 `json:"latitude,omitempty"` + // CountryCode + CountryCode string `json:"country_code,omitempty"` + // Region Название региона + Region string `json:"region,omitempty"` + // RegionCode Код региона СДЭК + RegionCode int `json:"region_code,omitempty"` + // SubRegion Название района региона + SubRegion string `json:"sub_region,omitempty"` + // City Название города + City string `json:"city,omitempty"` + // Address Строка адреса + Address string `json:"address"` +} + +type Package struct { + // Number Номер упаковки (можно использовать порядковый номер упаковки заказа или номер заказа), уникален в пределах заказа. Идентификатор заказа в ИС Клиента + Number string `json:"number"` + // Weight Общий вес (в граммах) + Weight int `json:"weight"` + // Comment Комментарий к упаковке. Обязательно и только для заказа типа "доставка" + Comment string `json:"comment,omitempty"` + // Height Габариты упаковки. Высота (в сантиметрах). Поле обязательно если: + // если указаны остальные габариты + // если заказ до постамата + // если общий вес >=100 гр + Height int `json:"height,omitempty"` + // Length Габариты упаковки. Длина (в сантиметрах). Поле обязательно если: + // если указаны остальные габариты + // если заказ до постамата + // если общий вес >=100 гр + Length int `json:"length,omitempty"` + // Width Габариты упаковки. Ширина (в сантиметрах). Поле обязательно если: + // если указаны остальные габариты + // если заказ до постамата + // если общий вес >=100 гр + Width int `json:"width,omitempty"` + // Items Позиции товаров в упаковке. Только для заказов "интернет-магазин". Максимум 126 уникальных позиций в заказе. Общее количество товаров в заказе может быть от 1 до 10000 + Items []PackageItem `json:"items,omitempty"` +} + +type PackageItem struct { + // Name Наименование товара (может также содержать описание товара: размер, цвет) + Name string `json:"name"` + // WareKey Идентификатор/артикул товара. Артикул товара может содержать только символы: [A-z А-я 0-9 ! @ " # № $ ; % ^ : & ? * () _ - + = ? < > , .{ } [ ] \ / , пробел] + WareKey string `json:"ware_key"` + // Marking Маркировка товара. Если для товара/вложения указана маркировка, Amount не может быть больше 1. + // Для корректного отображения маркировки товара в чеке требуется передавать НЕ РАЗОБРАННЫЙ тип маркировки, который может выглядеть следующим образом: + // 1) Код товара в формате GS1. Пример: 010468008549838921AAA0005255832GS91EE06GS92VTwGVc7wKCc2tqRncUZ1RU5LeUKSXjWbfNQOpQjKK+A + // 2) Последовательность допустимых символов общей длиной в 29 символов. Пример: 00000046198488X?io+qCABm8wAYa + // 3) Меховые изделия. Имеют собственный формат. Пример: RU-430302-AAA7582720 + Marking string `json:"marking,omitempty"` + // Payment Оплата за товар при получении (за единицу товара в валюте страны получателя, значение >=0) — наложенный платеж, в случае предоплаты значение = 0 + Payment Payment `json:"payment"` + // Cost Объявленная стоимость товара (за единицу товара в валюте взаиморасчетов, значение >=0). С данного значения рассчитывается страховка + Cost float64 `json:"cost"` + // Amount Количество единиц товара (в штуках). Количество одного товара в заказе может быть от 1 до 999 + Amount int `json:"amount"` + // NameI18N Наименование на иностранном языке. Только для международных заказов + NameI18N string `json:"name_i18n,omitempty"` + // Brand Бренд на иностранном языке. Только для международных заказов + Brand string `json:"brand,omitempty"` + // CountryCode Бренд на иностранном языке. Только для международных заказов + CountryCode string `json:"country_code,omitempty"` + // Weight Вес (за единицу товара, в граммах) + Weight int `json:"weight"` + // WeightGross Вес брутто. Только для международных заказов + WeightGross int `json:"weight_gross,omitempty"` + // Material Код материала (подробнее см. приложение 4). Только для международных заказов + Material string `json:"material,omitempty"` + // WifiGsm Содержит wifi/gsm. Только для международных заказов + WifiGsm bool `json:"wifi_gsm,omitempty"` + // Url Ссылка на сайт интернет-магазина с описанием товара. Только для международных заказов + Url string `json:"url,omitempty"` +} + +type Payment struct { + // Value Сумма наложенного платежа (в случае предоплаты = 0) + Value int `json:"value"` + // VatSum Сумма НДС + VatSum int `json:"vat_sum,omitempty"` + // VatRate Ставка НДС (значение - 0, 10, 20, null - нет НДС) + VatRate int `json:"vat_rate,omitempty"` +} + +type Cost struct { + // Sum Доп. сбор за доставку товаров, общая стоимость которых попадает в интервал + Sum int `json:"sum"` + // Threshold Порог стоимости товара (действует по условию меньше или равно) в целых единицах валюты + Threshold int `json:"threshold"` + // VatSum Сумма НДС + VatSum int `json:"vat_sum,omitempty"` + // VatRate Ставка НДС (значение - 0, 10, 20, null - нет НДС) + VatRate int `json:"vat_rate,omitempty"` +} + +type Phone struct { + // Number Номер телефона. Должен передаваться в международном формате: код страны (для России +7) и сам номер (10 и более цифр) + // Обязателен если: нет, если заказ типа "интернет-магазин". да, если заказ типа "доставка" + Number string `json:"number,omitempty"` + // Additional Дополнительная информация (добавочный номер) + Additional string `json:"additional,omitempty"` +} + +type RecipientSender struct { + // Name нет, если заказ типа "интернет-магазин"; да, если заказ типа "доставка" + Name string `json:"name,omitempty"` + // Company Название компании. нет, если заказ типа "интернет-магазин"; да, если заказ типа "доставка" + Company string `json:"company,omitempty"` + // Email Эл. адрес. нет, если заказ типа "интернет-магазин"; да, если заказ типа "доставка" + Email string `json:"email,omitempty"` + // PassportSeries Серия паспорта + PassportSeries string `json:"passport_series,omitempty"` + // PassportNumber Номер паспорта + PassportNumber string `json:"passport_number,omitempty"` + // PassportDateOfIssue Дата выдачи паспорта + PassportDateOfIssue string `json:"passport_date_of_issue,omitempty"` + // PassportOrganization Орган выдачи паспорта + PassportOrganization string `json:"passport_organization,omitempty"` + // Tin ИНН Может содержать 10, либо 12 символов + Tin string `json:"tin,omitempty"` + // PassportDateOfBirth Дата рождения (yyyy-MM-dd) + PassportDateOfBirth string `json:"passport_date_of_birth,omitempty"` + // PassportRequirementsSatisfied Требования по паспортным данным удовлетворены (актуально для + // международных заказов): + // true - паспортные данные собраны или не требуются + // false - паспортные данные требуются и не собраны + PassportRequirementsSatisfied bool `json:"passport_requirements_satisfied,omitempty"` + // Phones Список телефонов, Не более 10 номеров + Phones []Phone `json:"phones,omitempty"` +} + +type Seller struct { + // Name Наименование истинного продавца. Обязателен если заполнен inn + Name string `json:"name,omitempty"` + // INN ИНН истинного продавца. Может содержать 10, либо 12 символов + INN string `json:"inn,omitempty"` + // Phone Телефон истинного продавца. Обязателен если заполнен inn + Phone string `json:"phone,omitempty"` + // OwnershipForm Код формы собственности (подробнее см. приложение 2). Обязателен если заполнен inn + OwnershipForm int `json:"ownership_form,omitempty"` + // Address Адрес истинного продавца. Используется при печати инвойсов для отображения адреса настоящего + // продавца товара, либо торгового названия. Только для международных заказов "интернет-магазин". + // Обязателен если заказ - международный + Address string `json:"address,omitempty"` +} + +type Service struct { + // Code Тип дополнительной услуги (подробнее см. приложение 3) + Code string `json:"code"` + // Parameter Параметр дополнительной услуги: + // количество для услуг + // PACKAGE_1, COURIER_PACKAGE_A2, SECURE_PACKAGE_A2, SECURE_PACKAGE_A3, SECURE_PACKAGE_A4, + // SECURE_PACKAGE_A5, CARTON_BOX_XS, CARTON_BOX_S, CARTON_BOX_M, CARTON_BOX_L, CARTON_BOX_500GR, + // CARTON_BOX_1KG, CARTON_BOX_2KG, CARTON_BOX_3KG, CARTON_BOX_5KG, CARTON_BOX_10KG, CARTON_BOX_15KG, + // CARTON_BOX_20KG, CARTON_BOX_30KG, CARTON_FILLER (для всех типов заказа) + // объявленная стоимость заказа для услуги INSURANCE (только для заказов с типом "доставка") + // длина для услуг BUBBLE_WRAP, WASTE_PAPER (для всех типов заказа) + // номер телефона для услуги SMS + // код фотопроекта для услуги PHOTO_DOCUMENT + Parameter string `json:"parameter,omitempty"` +} diff --git a/CDEK/v2/utils.go b/CDEK/v2/utils.go new file mode 100644 index 0000000..617b46e --- /dev/null +++ b/CDEK/v2/utils.go @@ -0,0 +1,107 @@ +package v2 + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/ernesto-jimenez/httplogger" + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "io/ioutil" + "log" + "net/http" + "os" + "time" +) + +func validateResponse(requests []ResponseRequests) error { + var result error + for _, item := range requests { + if item.State == "INVALID" { + result = multierror.Append(result, fmt.Errorf("%+v", item)) + } + } + + return result +} + +var client = http.Client{ + Transport: httplogger.NewLoggedTransport(http.DefaultTransport, newLogger()), +} + +type RespErrors struct { + Errors []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"errors,omitempty"` +} + +func jsonReq[T any](req *http.Request) (*T, error) { + response, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "http.DefaultClient.Do") + } + defer response.Body.Close() + + var s T + payload, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, errors.Wrap(err, "ioutil.ReadAll") + } + + var respErr RespErrors + if err := json.Unmarshal(payload, &respErr); err == nil && len(respErr.Errors) > 0 { + return nil, fmt.Errorf("json error: %v", respErr) + } + + if err := json.Unmarshal(payload, &s); err != nil { + return nil, fmt.Errorf("json error: %s", payload) + } + + return &s, nil +} + +type httpLogger struct { + log *log.Logger +} + +func newLogger() *httpLogger { + return &httpLogger{ + log: log.New(os.Stderr, "log - ", log.LstdFlags), + } +} + +func (l *httpLogger) LogRequest(req *http.Request) { + if req.Body != nil { + body, _ := ioutil.ReadAll(req.Body) + req.Body = ioutil.NopCloser(bytes.NewReader(body)) + + l.log.Printf( + "Request %s %s %s", + req.Method, + req.URL.String(), + body, + ) + } else { + l.log.Printf( + "Request %s %s", + req.Method, + req.URL.String(), + ) + } +} + +func (l *httpLogger) LogResponse(req *http.Request, res *http.Response, err error, duration time.Duration) { + duration /= time.Millisecond + if err != nil { + l.log.Println(err) + } else { + l.log.Printf( + "Response method=%s status=%d durationMs=%d %s", + req.Method, + res.StatusCode, + duration, + req.URL.String(), + ) + } +}