cdek 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ module Resources
5
+ # Базовый класс для high-level ресурсов CDEK API.
6
+ #
7
+ # Хранит ссылку на клиент. Конкретные ресурсы (Locations, Deliverypoints
8
+ # и т.п.) наследуются и проксируют вызовы в client.get/post/...
9
+ class Base
10
+ attr_reader :client
11
+
12
+ def initialize(client = Cdek.client)
13
+ @client = client
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ module Resources
5
+ # Список пунктов выдачи заказов (ПВЗ/Постамат) CDEK API v2.
6
+ # Эндпоинт: GET /deliverypoints
7
+ #
8
+ # Параметры (наиболее часто используемые):
9
+ # * city_code — код города CDEK (получается через Locations#cities)
10
+ # * postal_code — почтовый индекс
11
+ # * type — "PVZ" | "POSTAMAT" | "ALL"
12
+ # * country_code — двухбуквенный код страны (RU и т.п.)
13
+ # * region_code — код региона
14
+ # * code — код конкретного пункта (например MSK2181)
15
+ # * have_cashless / have_cash / allowed_cod / is_dressing_room —
16
+ # булевы фильтры
17
+ # * weight_min / weight_max — допустимый вес посылки
18
+ # * is_handout — выдаёт ли посылки
19
+ # * is_reception — принимает ли посылки
20
+ # * size / page — пагинация
21
+ #
22
+ # Возвращает массив пунктов в формате CDEK API (массив Hash).
23
+ #
24
+ # Примеры:
25
+ # Cdek.deliverypoints.list(city_code: 44, type: "PVZ", size: 100)
26
+ # Cdek.deliverypoints.pvz_for_city(44)
27
+ # Cdek.deliverypoints.find("MSK2181")
28
+ class Deliverypoints < Base
29
+ LIST_PATH = "/deliverypoints"
30
+ DEFAULT_TYPE = "PVZ"
31
+
32
+ def list(params = {})
33
+ client.get(LIST_PATH, params: params.compact)
34
+ end
35
+
36
+ # Шорткат: ПВЗ по коду города. extra — дополнительные фильтры (например,
37
+ # have_cashless: true), которые мерджатся к city_code и type.
38
+ def pvz_for_city(city_code, extra = {})
39
+ list({ city_code: city_code, type: DEFAULT_TYPE }.merge(extra))
40
+ end
41
+
42
+ # Найти конкретный пункт по его коду (CDEK ID, например MSK2181).
43
+ # Возвращает Hash пункта или nil.
44
+ def find(code)
45
+ result = list(code: code, size: 1)
46
+ result.is_a?(Array) ? result.first : nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ module Resources
5
+ # Справочники локаций CDEK API v2.
6
+ #
7
+ # Тонкая обёртка над эндпоинтами:
8
+ # * GET /location/cities — список городов
9
+ # * GET /location/regions — список регионов
10
+ # * GET /location/suggest/cities — подсказка городов по подстроке
11
+ #
12
+ # Параметры передаются как есть в query-string; обязательной нормализации
13
+ # или валидации не делаем — структура и набор параметров полностью
14
+ # соответствуют официальной документации CDEK. Значения nil автоматически
15
+ # отбрасываются, чтобы не попадать в URL пустыми ключами.
16
+ #
17
+ # Примеры:
18
+ # Cdek.locations.cities(country_codes: "RU", city: "Москва", size: 5)
19
+ # Cdek.locations.regions(country_codes: "RU", size: 10)
20
+ # Cdek.locations.suggest_cities(name: "Моск", country_code: "RU")
21
+ # Cdek.locations.find_city("Москва")
22
+ class Locations < Base
23
+ CITIES_PATH = "/location/cities"
24
+ REGIONS_PATH = "/location/regions"
25
+ SUGGEST_CITIES_PATH = "/location/suggest/cities"
26
+
27
+ def cities(params = {})
28
+ client.get(CITIES_PATH, params: params.compact)
29
+ end
30
+
31
+ def regions(params = {})
32
+ client.get(REGIONS_PATH, params: params.compact)
33
+ end
34
+
35
+ def suggest_cities(params = {})
36
+ client.get(SUGGEST_CITIES_PATH, params: params.compact)
37
+ end
38
+
39
+ # Удобный шорткат: найти первый город по точному названию (и стране).
40
+ # Возвращает Hash города или nil, если ничего не нашлось.
41
+ def find_city(name, country_codes: "RU", **extra)
42
+ list = cities({ city: name, country_codes: country_codes, size: 1 }.merge(extra))
43
+ list.is_a?(Array) ? list.first : nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ VERSION = "0.3.9"
5
+ end
data/lib/cdek.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cdek/version"
4
+ require "cdek/error"
5
+ require "cdek/configuration"
6
+ require "cdek/connection"
7
+ require "cdek/client"
8
+ require "cdek/resources/base"
9
+ require "cdek/resources/locations"
10
+ require "cdek/resources/deliverypoints"
11
+
12
+ module Cdek
13
+ CLIENT_MUTEX = Mutex.new
14
+ private_constant :CLIENT_MUTEX
15
+
16
+ class << self
17
+ # Возвращает текущий объект конфигурации (создавая его при первом обращении).
18
+ def configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ # Блочный синтаксис настройки.
23
+ #
24
+ # Cdek.configure do |config|
25
+ # config.account = ENV["CDEK_ACCOUNT"]
26
+ # config.secure_password = ENV["CDEK_SECURE_PASSWORD"]
27
+ # config.production_mode!
28
+ # end
29
+ def configure
30
+ yield configuration
31
+ end
32
+
33
+ # Шареный клиент. Потокобезопасная мемоизация.
34
+ def client
35
+ CLIENT_MUTEX.synchronize { @client ||= Client.new(configuration) }
36
+ end
37
+
38
+ # Сбрасывает мемоизированный клиент (полезно после изменения конфигурации
39
+ # или в тестах).
40
+ def reset_client!
41
+ CLIENT_MUTEX.synchronize { @client = nil }
42
+ end
43
+
44
+ # Полный сброс — и конфигурации, и клиента.
45
+ def reset!
46
+ CLIENT_MUTEX.synchronize do
47
+ @configuration = nil
48
+ @client = nil
49
+ end
50
+ end
51
+
52
+ # High-level ресурс «Локации» — справочники городов/регионов и suggest.
53
+ # По умолчанию использует шареный Cdek.client; можно передать кастомный
54
+ # клиент (например, в тестах с подменённым transport).
55
+ def locations(custom_client = client)
56
+ Resources::Locations.new(custom_client)
57
+ end
58
+
59
+ # High-level ресурс «Пункты выдачи» — список ПВЗ/постаматов по фильтрам.
60
+ def deliverypoints(custom_client = client)
61
+ Resources::Deliverypoints.new(custom_client)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Rails Engine — подключает прокси-эндпоинт, виджет, helper. Подгружаем только
67
+ # если хост — Rails-приложение. Версия гема без Rails (standalone скрипты,
68
+ # CLI-утилиты) продолжит работать как тонкий клиент API.
69
+ require "cdek/engine" if defined?(::Rails::Engine)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Cdek
6
+ module Generators
7
+ # bin/rails generate cdek:install
8
+ #
9
+ # Кладёт в хост-приложение:
10
+ # * config/initializers/cdek.rb — настройка гема
11
+ # * app/javascript/controllers/cdek_widget_controller.js — Stimulus-контроллер виджета ПВЗ
12
+ #
13
+ # Сам Engine монтируется отдельной строкой в config/routes.rb пользователя:
14
+ # mount Cdek::Engine, at: "/cdek"
15
+ class InstallGenerator < Rails::Generators::Base
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Создаёт config/initializers/cdek.rb и Stimulus-контроллер виджета ПВЗ."
19
+
20
+ def copy_initializer
21
+ template "cdek.rb", "config/initializers/cdek.rb"
22
+ end
23
+
24
+ def copy_stimulus_controller
25
+ copy_file "cdek_widget_controller.js",
26
+ "app/javascript/controllers/cdek_widget_controller.js"
27
+ end
28
+
29
+ def print_post_install
30
+ say "\n========================================================================", :green
31
+ say " Гем Cdek установлен.", :green
32
+ say "========================================================================", :green
33
+ say " 1) Добавьте маршрут в config/routes.rb:"
34
+ say " mount Cdek::Engine, at: \"/cdek\""
35
+ say ""
36
+ say " 2) Заполните .env (или ENV) переменными:"
37
+ say " CDEK_ACCOUNT=..."
38
+ say " CDEK_SECURE_PASSWORD=..."
39
+ say " YANDEX_MAPS_API_KEY=... # ключ Yandex Maps JS API для карты виджета"
40
+ say ""
41
+ say " 3) Вставьте виджет в любую view, например в модалку оформления заказа:"
42
+ say " = cdek_widget_tag api_key: ENV[\"YANDEX_MAPS_API_KEY\"],"
43
+ say " default_city: \"Москва\","
44
+ say " modal_id: \"cdek-points-modal\""
45
+ say ""
46
+ say " 4) Скрытые поля для приёма выбранного пункта (внутри order_form):"
47
+ say " order_cdek_point_code"
48
+ say " order_cdek_point_name"
49
+ say " order_cdek_point_address"
50
+ say " order_cdek_city_code"
51
+ say " (DOM-id можно переопределить аргументами field_* у cdek_widget_tag.)"
52
+ say "========================================================================", :green
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Настройка гема Cdek.
4
+ #
5
+ # Учётные данные удобно хранить в Rails encrypted credentials или ENV.
6
+ # В примере ниже используются ENV-переменные.
7
+ Cdek.configure do |config|
8
+ config.account = ENV["CDEK_ACCOUNT"]
9
+ config.secure_password = ENV["CDEK_SECURE_PASSWORD"]
10
+
11
+ # Контур CDEK API. По умолчанию — боевой (api.cdek.ru/v2). С боевыми
12
+ # учётными данными это правильный выбор и в development. Чтобы временно
13
+ # переключиться на песочницу (api.edu.cdek.ru/v2) — добавь в .env:
14
+ #
15
+ # CDEK_SANDBOX=1
16
+ #
17
+ # либо явно поменяй вызов на config.test_mode!.
18
+ if ENV["CDEK_SANDBOX"].to_s.match?(/\A(1|true|yes|on)\z/i)
19
+ config.test_mode!
20
+ else
21
+ config.production_mode!
22
+ end
23
+
24
+ # Опциональная тонкая настройка:
25
+ # config.timeout = 15
26
+ # config.open_timeout = 5
27
+ # config.user_agent = "MyApp/1.0 (+https://example.com)"
28
+ config.logger = Rails.logger
29
+
30
+ # Быстрый старт с публичными тестовыми кредами CDEK (только для песочницы):
31
+ # config.use_sandbox_credentials!
32
+ end
@@ -0,0 +1,184 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Stimulus-контроллер виджета ПВЗ СДЭК.
4
+ //
5
+ // КРИТИЧНО: @cdek-it/widget@3 (UMD) ожидает параметр `root` как СТРОКОВЫЙ
6
+ // ID DOM-элемента, не как сам DOM-объект. Внутри виджет вызывает
7
+ // `document.getElementById(params.root)`. Если передать туда HTMLElement —
8
+ // getElementById вернёт null, и виджет создаст «висячий» div в памяти,
9
+ // продолжит делать запросы офисов (мы это видим в server-логах), но в
10
+ // видимом DOM нашего root-таргета ничего не отрисует. Поэтому здесь мы
11
+ // гарантируем, что у root-элемента есть уникальный id, и передаём
12
+ // виджету именно строку.
13
+ //
14
+ // Про from / sender_city_code:
15
+ // Если в `from` передавать только { address: "Москва" }, CDEK API не
16
+ // возвращает склад-склад тарифы (только дверь-*), и виджет показывает
17
+ // «Выберите тариф». Чтобы получить полный набор тарифов, нужно передать
18
+ // CDEK-код города отправителя (см. справочник /location/cities). Если код
19
+ // задан через data-cdek-widget-sender-city-code-value — используем его,
20
+ // иначе fallback на address.
21
+ //
22
+ // Про goods:
23
+ // CDEK считает тарифы по габаритам и весу отправления. Значение `goods`
24
+ // должно приходить от хост-приложения через data-cdek-widget-goods-value.
25
+ // Если массив не передан или невалиден, не подставляем статичные габариты:
26
+ // виджет будет открыт без расчётных packages, чтобы не считать доставку
27
+ // по выдуманным размерам.
28
+
29
+ let _scriptPromise = null
30
+
31
+ function ensureWidgetScript(url) {
32
+ if (typeof window.CDEKWidget !== "undefined") {
33
+ return Promise.resolve()
34
+ }
35
+ if (_scriptPromise) return _scriptPromise
36
+ _scriptPromise = new Promise((resolve, reject) => {
37
+ const s = document.createElement("script")
38
+ s.src = url
39
+ s.async = true
40
+ s.onload = () => resolve()
41
+ s.onerror = () => { _scriptPromise = null; reject(new Error("Не удалось загрузить скрипт виджета СДЭК")) }
42
+ document.head.appendChild(s)
43
+ })
44
+ return _scriptPromise
45
+ }
46
+
47
+ function setFieldValue(id, value) {
48
+ if (!id) return
49
+ const el = document.getElementById(id)
50
+ if (el) {
51
+ el.value = value == null ? "" : String(value)
52
+ el.dispatchEvent(new Event("change", { bubbles: true }))
53
+ }
54
+ }
55
+
56
+ function setText(selector, value) {
57
+ if (!selector) return
58
+ document.querySelectorAll(selector).forEach((el) => { el.textContent = value })
59
+ }
60
+
61
+ function parseGoods(rawJson) {
62
+ const trimmed = (rawJson || "").trim()
63
+ if (trimmed === "") return []
64
+ try {
65
+ const parsed = JSON.parse(trimmed)
66
+ if (Array.isArray(parsed)) return parsed
67
+ return []
68
+ } catch (_) {
69
+ return []
70
+ }
71
+ }
72
+
73
+ export default class extends Controller {
74
+ static targets = ["root", "error"]
75
+ static values = {
76
+ servicePath: String,
77
+ scriptUrl: String,
78
+ apiKey: String,
79
+ defaultLocation: { type: String, default: "Москва" },
80
+ senderCity: { type: String, default: "Москва" },
81
+ senderCityCode: { type: String, default: "" },
82
+ goods: { type: String, default: "" },
83
+ modalId: { type: String, default: "" },
84
+ fieldCode: { type: String, default: "order_cdek_point_code" },
85
+ fieldName: { type: String, default: "order_cdek_point_name" },
86
+ fieldAddress: { type: String, default: "order_cdek_point_address" },
87
+ fieldCityCode: { type: String, default: "order_cdek_city_code" },
88
+ labelSelector: { type: String, default: "[data-cdek-widget-label]" },
89
+ addressSelector: { type: String, default: "#order_cdek_point_address_view" }
90
+ }
91
+
92
+ connect() {
93
+ ensureWidgetScript(this.scriptUrlValue)
94
+ .then(() => this._mountWidget())
95
+ .catch((err) => this._showError(err && err.message ? err.message : "ошибка"))
96
+ }
97
+
98
+ disconnect() {
99
+ if (this._widget) {
100
+ try {
101
+ if (typeof this._widget.destroy === "function") this._widget.destroy()
102
+ else if (typeof this._widget.close === "function") this._widget.close()
103
+ } catch (_) { /* no-op */ }
104
+ this._widget = null
105
+ }
106
+ }
107
+
108
+ _mountWidget() {
109
+ if (!this.hasRootTarget) return
110
+ if (typeof window.CDEKWidget !== "function") {
111
+ this._showError("CDEKWidget недоступен после загрузки скрипта")
112
+ return
113
+ }
114
+
115
+ // Гарантируем уникальный DOM-id у root'а и передаём его виджету строкой.
116
+ if (!this.rootTarget.id) {
117
+ this.rootTarget.id = "cdek-widget-root-" + Math.random().toString(36).slice(2, 10)
118
+ }
119
+
120
+ // Если задан CDEK-код города отправителя — передаём его, иначе fallback
121
+ // на address. С code CDEK вернёт склад-склад тарифы; с одним address —
122
+ // только дверь-* (тогда виджет покажет "Выберите тариф" для офиса).
123
+ const senderCodeRaw = this.senderCityCodeValue.trim()
124
+ const senderCodeNum = senderCodeRaw === "" ? null : Number(senderCodeRaw)
125
+ const from = (senderCodeNum !== null && Number.isFinite(senderCodeNum))
126
+ ? { code: senderCodeNum }
127
+ : { address: this.senderCityValue }
128
+
129
+ const goods = parseGoods(this.goodsValue)
130
+ const widgetOptions = {
131
+ root: this.rootTarget.id,
132
+ servicePath: this.servicePathValue,
133
+ apiKey: this.apiKeyValue,
134
+ defaultLocation: this.defaultLocationValue,
135
+ from: from,
136
+ hideDeliveryOptions: { door: true, office: false },
137
+ lang: "rus",
138
+ currency: "RUB",
139
+ onChoose: (mode, tariff, office) => this._handleChoose(office)
140
+ }
141
+
142
+ if (goods.length > 0) {
143
+ widgetOptions.goods = goods
144
+ }
145
+
146
+ try {
147
+ this._widget = new window.CDEKWidget(widgetOptions)
148
+ } catch (err) {
149
+ this._showError(err && err.message ? err.message : "ошибка инициализации виджета")
150
+ }
151
+ }
152
+
153
+ _handleChoose(office) {
154
+ if (!office) return
155
+ const code = office.code || ""
156
+ const name = office.name || ""
157
+ const address = office.address || office.address_full || ""
158
+ const cityCode = office.city_code || ""
159
+
160
+ setFieldValue(this.fieldCodeValue, code)
161
+ setFieldValue(this.fieldNameValue, name)
162
+ setFieldValue(this.fieldAddressValue, address)
163
+ setFieldValue(this.fieldCityCodeValue, cityCode)
164
+
165
+ const label = name ? (code ? `${name} (${code})` : name) : "не выбран"
166
+ setText(this.labelSelectorValue, label)
167
+ setText(this.addressSelectorValue, address)
168
+
169
+ this.dispatch("chosen", { detail: { office } })
170
+ if (this.modalIdValue) {
171
+ document.dispatchEvent(new CustomEvent("modal:close", { detail: { id: this.modalIdValue } }))
172
+ }
173
+ }
174
+
175
+ _showError(message) {
176
+ if (this.hasErrorTarget) {
177
+ this.errorTarget.textContent = `Не удалось открыть виджет СДЭК: ${message}.`
178
+ this.errorTarget.style.display = ""
179
+ }
180
+ if (this.hasRootTarget) {
181
+ this.rootTarget.style.display = "none"
182
+ }
183
+ }
184
+ }
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cdek
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.9
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ description: 'Тонкий клиент для CDEK API v2 без внешних рантайм-зависимостей: конфигурация,
27
+ иерархия ошибок, автоматическое управление OAuth2-токеном. Поверх клиента — монтируемый
28
+ Rails Engine с прокси-эндпоинтом, вендорным JS-виджетом ПВЗ и Stimulus-контроллером.'
29
+ email:
30
+ - you@example.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - app/assets/javascripts/cdek/widget.umd.js
39
+ - app/controllers/cdek/widget_service_controller.rb
40
+ - app/helpers/cdek/widget_helper.rb
41
+ - cdek.gemspec
42
+ - config/routes.rb
43
+ - lib/cdek.rb
44
+ - lib/cdek/client.rb
45
+ - lib/cdek/configuration.rb
46
+ - lib/cdek/connection.rb
47
+ - lib/cdek/engine.rb
48
+ - lib/cdek/error.rb
49
+ - lib/cdek/railtie.rb
50
+ - lib/cdek/resources/base.rb
51
+ - lib/cdek/resources/deliverypoints.rb
52
+ - lib/cdek/resources/locations.rb
53
+ - lib/cdek/version.rb
54
+ - lib/generators/cdek/install/install_generator.rb
55
+ - lib/generators/cdek/install/templates/cdek.rb
56
+ - lib/generators/cdek/install/templates/cdek_widget_controller.js
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.6
76
+ specification_version: 4
77
+ summary: Ruby/Rails клиент и Engine для CDEK API v2 с виджетом ПВЗ
78
+ test_files: []