├── cdek ├── __init__.py ├── __version__.py ├── utils.py ├── entities.py └── api.py ├── tests ├── __init__.py ├── conftest.py └── test_api.py ├── MANIFEST.in ├── scripts ├── test.sh └── build.sh ├── requirements.txt ├── pytest.ini ├── requirements.dev.txt ├── .isort.cfg ├── .coveragerc ├── tox.ini ├── LICENSE ├── .travis.yml ├── .gitignore ├── setup.py ├── README.md └── .pylintrc /cdek/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pytest --cov=cdek -v tests/ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.22.0,<3 2 | boltons>=19.1.0,<20 -------------------------------------------------------------------------------- /cdek/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 4, 1) 2 | 3 | __version__ = '.'.join(map(str, VERSION)) 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -p no:warnings 3 | python_files = tests.py test_*.py *_tests.py 4 | testpaths = tests -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest>=5.0,<5.1 4 | pytest-cov<=3 5 | pytest-xdist>=1.29.0,<2 6 | 7 | isort==4.3.21 8 | 9 | flake8==3.7.7 -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=80 3 | multi_line_output=3 4 | force_sort_within_sections=True 5 | lines_after_imports=2 6 | balanced_wrapping=True 7 | include_trailing_comma=True 8 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=cdek 3 | omit = 4 | *__.py, 5 | *apps.py, 6 | *migrations/*, 7 | *settings/*, 8 | *tests/*, 9 | *conftest.py, 10 | 11 | [report] 12 | exclude_lines = 13 | pragma: no cover 14 | def __repr__ 15 | def __str__ 16 | if self.debug: 17 | raise AssertionError 18 | raise NotImplementedError 19 | if 0: 20 | if __name__ == .__main__.: -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | pylint 5 | py{36,37,38} 6 | skip_missing_interpreters = true 7 | 8 | [testenv] 9 | commands = 10 | flake8: flake8 11 | pylint: pylint cdek 12 | readme: bash -c \'python setup.py -q sdist && twine check dist/*\' 13 | py{36,37,38}: pytest --cov=cdek tests/ -v 14 | clean: find . -type f -name '*.pyc' -delete 15 | clean: find . -type d -name __pycache__ -delete 16 | clean: rm -rf build/ .cache/ dist/ .eggs/ cdek.egg-info/ .tox/ 17 | deps = 18 | flake8: flake8 19 | pylint: pylint 20 | readme: twine 21 | setenv = 22 | PIP_DISABLE_PIP_VERSION_CHECK = 1 23 | PYTHONPATH = . 24 | whitelist_externals = 25 | readme: bash 26 | py{36,37,38}: pytest 27 | clean: find 28 | clean: rm 29 | 30 | [flake8] 31 | max-line-length = 80 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fogstream 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cdek/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | from typing import Dict, Union 4 | from xml.etree import ElementTree 5 | from xml.etree.ElementTree import tostring 6 | 7 | from boltons.iterutils import remap 8 | 9 | 10 | ARRAY_TAGS = {'State', 'Delay', 'Good', 'Fail', 'Item', 'Package'} 11 | 12 | 13 | def xml_to_dict(xml: ElementTree) -> Dict: 14 | result = xml.attrib 15 | 16 | for child in xml: 17 | if child.tag in ARRAY_TAGS: 18 | result[child.tag] = result.get(child.tag, []) 19 | result[child.tag].append(xml_to_dict(child)) 20 | else: 21 | result[child.tag] = xml_to_dict(child) 22 | 23 | return result 24 | 25 | 26 | def xml_to_string(xml: ElementTree) -> str: 27 | tree = ElementTree.ElementTree(xml) 28 | 29 | for elem in tree.iter(): 30 | elem.attrib = prepare_xml(elem.attrib) 31 | 32 | return tostring(tree.getroot(), encoding='UTF-8') 33 | 34 | 35 | def clean_dict(data: Dict) -> Dict: 36 | """Очистка словаря от ключей со значением None. 37 | 38 | :param dict data: Словарь со значениями 39 | :return: Очищенный словарь 40 | :rtype: dict 41 | """ 42 | return remap(data, lambda p, k, v: v is not None) 43 | 44 | 45 | def prepare_xml(data: Dict) -> Dict: 46 | data = clean_dict(data) 47 | data = remap(data, lambda p, k, v: (k, str(v))) 48 | 49 | return data 50 | 51 | 52 | def get_secure(secure_password: str, 53 | date: Union[datetime.datetime, datetime.date, str]) -> str: 54 | """Генерация секретного кода для запросов требующих авторизацию. 55 | 56 | :param str secure_password: Пароль для интеграции СДЭК 57 | :param date: дата документа 58 | :return: Секретный код 59 | :rtype: str 60 | """ 61 | code = f'{date}&{secure_password}'.encode('utf-8') 62 | return hashlib.md5(code).hexdigest() 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8-dev" 7 | stages: 8 | - test 9 | - build_and_package_sanity 10 | matrix: 11 | include: 12 | - { stage: test, python: 3.7, env: TOXENV=flake8 } 13 | - { stage: test, python: 3.7, env: TOXENV=pylint } 14 | - { stage: test, python: 3.7, env: TOXENV=readme } 15 | - { stage: build_and_package_sanity, python: 3.7, env: SANITY_CHECK=1 } 16 | before_install: 17 | - chmod +x ./scripts/build.sh 18 | - python --version 19 | - uname -a 20 | - lsb_release -a 21 | install: 22 | - pip install tox-travis 23 | - pip install -e .[for_tests] 24 | - pip install -U setuptools 25 | - virtualenv --version 26 | - easy_install --version 27 | - pip --version 28 | - tox --version 29 | - coverage --version 30 | script: 31 | - | 32 | if [ -z "$SANITY_CHECK" ]; then 33 | tox 34 | else 35 | ./scripts/build.sh 36 | fi 37 | after_success: 38 | - | 39 | if [ -z "$SANITY_CHECK" ]; then 40 | pip install coveralls 41 | coveralls 42 | fi 43 | notifications: 44 | email: 45 | on_failure: change 46 | on_success: never 47 | deploy: 48 | provider: pypi 49 | user: "fogstream" 50 | password: 51 | secure: "kx2W9MVDhre3vt1Ek89Gna7Ga3UwMQftMzFMiR9INEkzi5ooxB8P35lnaRgGt0hd19aYdR0ddn9R4fdsR6Ps5cOP+Ob+72YFnKJ+b5GQeta2IMpT2S1n8/Fb/YjN1/JmUw/dye4pjlZVM4wcmFC66tdSplBs4rH+9Fi75SWqicGR0vtmS04ez6dVpiwgOWPEMxgKhIf0KFTOGn/oUiW1MoFd/69ao1VGxUkVITK4i67coO58VA3mrJ7U/llhkAEgPWxiK25zTssuvFpDihkS3NrW40w3m/Wbe3O9E83ajT4TEYJvcUXhkL4gjhS0b3YkvgLsJsEdCdYr7iNnIXIzzh4UMn/686Siaq1tYGFhO0mlp1l1w5W0I/7Jpq/3LR53oI42sfHQV+OnyR6u3/zv2qqKGP3JF9eG0gzrNMylPt3bgqcZMIAKO05zo3Xie0ethxgkimYKL7+OBHjPyDQX8zZTys+pmnv3fzvy+6SVgseNeKaYyKSad8zmVbLjTtuYYKX0JGLPbKkydMGzgcEsIuxiLPhw3+aCeHAV3YxuWTSrsM3upyth/5lx/fzsvwwUukWaZ9A1LwrSSchl0WK1hez8d3VUR8mea2NxNOFIdqQ85bbAKXSLPVi1PhM8PJBG9EaYBMA0U8FkPOZPum5yEua8TAP7t+Dq/mUoocs0JMg=" 52 | on: 53 | tags: true 54 | condition: "$SANITY_CHECK = 1" 55 | distributions: sdist bdist_wheel 56 | skip_existing: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | ### VirtualEnv template 107 | # Virtualenv 108 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 109 | [Bb]in 110 | [Ii]nclude 111 | [Ll]ib 112 | [Ll]ib64 113 | [Ll]ocal 114 | pyvenv.cfg 115 | pip-selfcheck.json 116 | 117 | .idea/ -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from random import randint 3 | 4 | import pytest 5 | 6 | from cdek.api import CDEKClient 7 | from cdek.entities import DeliveryRequest 8 | 9 | 10 | delivery_type_list = [ 11 | { 12 | 'recipient_address': {'pvz_code': 'XAB1'}, 13 | 'tariff_type_code': 138, 14 | 'shipping_price': 300.0, 15 | }, 16 | { 17 | 'recipient_address': {'street': 'Ленина', 'house': '50', 'flat': '31'}, 18 | 'tariff_type_code': 139, 19 | 'shipping_price': 0, 20 | }, 21 | ] 22 | 23 | 24 | @pytest.fixture(params=delivery_type_list, ids=['PVZ', 'DOOR']) 25 | def delivery_type(request): 26 | return request.param 27 | 28 | 29 | @pytest.fixture 30 | def cdek_client(): 31 | return CDEKClient( 32 | account='z9GRRu7FxmO53CQ9cFfI6qiy32wpfTkd', 33 | secure_password='w24JTCv4MnAcuRTx0oHjHLDtyt3I6IBq', 34 | api_url='http://integration.edu.cdek.ru', 35 | test=True, 36 | ) 37 | 38 | 39 | # pylint: disable=redefined-outer-name 40 | @pytest.fixture 41 | def delivery_request(delivery_type): 42 | delivery_request_obj = DeliveryRequest(number=randint(100000, 1000000)) 43 | order = delivery_request_obj.add_order( 44 | number=randint(100000, 1000000), 45 | recipient_name='Иванов Иван Иванович', 46 | phone='+79999999999', 47 | send_city_post_code='680000', 48 | rec_city_post_code='680000', 49 | seller_name='Магазин', 50 | comment='Духи', 51 | tariff_type_code=delivery_type['tariff_type_code'], 52 | shipping_price=delivery_type['shipping_price'], 53 | ) 54 | delivery_request_obj.add_address( 55 | order, **delivery_type['recipient_address']) 56 | package = delivery_request_obj.add_package( 57 | order_element=order, 58 | size_a=10, 59 | size_b=10, 60 | size_c=10, 61 | number=str(randint(100000, 1000000)), 62 | barcode=randint(100000, 1000000), 63 | weight=600, 64 | ) 65 | delivery_request_obj.add_item( 66 | package_element=package, 67 | weight=500, 68 | cost=Decimal(1000), 69 | ware_key=str(randint(100000, 1000000)), 70 | comment='Духи', 71 | ) 72 | 73 | return delivery_request_obj 74 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build packages for distribution on PyPI 4 | # and execute some sanity scripts on them 5 | # 6 | # note: must be executed from the root directory of the project 7 | 8 | # first clean up the local environment 9 | echo "..... Clean up first" 10 | find . -type f -name '*.pyc' -delete 11 | find . -type d -name __pycache__ -delete 12 | find . -type d -name '*.egg-info' | xargs rm -rf 13 | rm -rf build/ .cache/ dist/ .eggs/ .tox/ 14 | 15 | # check rst formatting of README/CHANGELOG before building the package 16 | echo "..... Check md formatting for PyPI" 17 | tox -e readme 18 | 19 | # then build the packages 20 | echo "..... Building PyPI packages" 21 | set -e 22 | python setup.py sdist >/dev/null 23 | python setup.py bdist_wheel >/dev/null 24 | set +e 25 | 26 | # then run some sanity tests 27 | echo "..... Searching for .pyc files inside the built packages" 28 | matched_files=`tar -tvf dist/*.tar.gz | grep -c "\.pyc"` 29 | if [[ "$matched_files" -gt "0" ]]; then 30 | echo "ERROR: .pyc files found in .tar.gz package" 31 | exit 1 32 | fi 33 | matched_files=`unzip -t dist/*.whl | grep -c "\.pyc"` 34 | if [[ "$matched_files" -gt "0" ]]; then 35 | echo "ERROR: .pyc files found in wheel package" 36 | exit 1 37 | fi 38 | 39 | echo "..... Trying to verify that all source files are present" 40 | # remove cdek/*.egg-info/ generated during build 41 | find . -type d -name '*.egg-info' | xargs rm -rf 42 | 43 | source_files=`find ./cdek/ -type f | sed 's|./||'` 44 | 45 | # verify for .tar.gz package 46 | package_files=`tar -tvf dist/*.tar.gz` 47 | for src_file in ${source_files}; do 48 | echo "$package_files" | grep ${src_file} >/dev/null 49 | if [[ "$?" -ne 0 ]]; then 50 | echo "ERROR: $src_file not found inside tar.gz package" 51 | exit 1 52 | fi 53 | done 54 | 55 | # verify for wheel package 56 | package_files=`unzip -t dist/*.whl` 57 | for src_file in ${source_files}; do 58 | echo "$package_files" | grep ${src_file} >/dev/null 59 | if [[ "$?" -ne 0 ]]; then 60 | echo "ERROR: $src_file not found inside wheel package" 61 | exit 1 62 | fi 63 | done 64 | 65 | # exit on error from now on 66 | set -e 67 | 68 | echo "..... Trying to install the new tarball inside a virtualenv" 69 | virtualenv .venv/test-tarball 70 | source .venv/test-tarball/bin/activate 71 | pip install --no-binary :all: -f dist/ fs-cdek-api 72 | deactivate 73 | rm -rf .venv/ 74 | 75 | echo "..... Trying to install the new wheel inside a virtualenv" 76 | virtualenv .venv/test-wheel 77 | source .venv/test-wheel/bin/activate 78 | pip install --only-binary :all: -f dist/ fs-cdek-api 79 | deactivate 80 | rm -rf .venv/ 81 | 82 | echo "..... PASS" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from shutil import rmtree 4 | import sys 5 | 6 | from setuptools import Command, find_packages, setup 7 | 8 | # Package meta-data. 9 | NAME = 'fs-cdek-api' 10 | DESCRIPTION = 'CDEK API wrapper' 11 | URL = 'https://github.com/fogstream/fs-cdek-api' 12 | EMAIL = 'fadeddexofan@gmail.com' 13 | MAINTAINER = 'fadedDexofan' 14 | REQUIRES_PYTHON = '>=3.6.0' 15 | VERSION = None 16 | 17 | REQUIRED = ['requests>=2.22.0,<3', 'boltons>=19.1.0,<20'] 18 | 19 | EXTRAS = { 20 | 'for_tests': [ 21 | 'pytest>=5.0,<5.1', 22 | 'pytest-cov<=3', 23 | 'pytest-xdist>=1.29.0,<2', 24 | ], 25 | } 26 | 27 | # ------------------------------------------------ 28 | 29 | here = os.path.abspath(os.path.dirname(__file__)) 30 | 31 | try: 32 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 33 | long_description = '\n' + f.read() 34 | except FileNotFoundError: 35 | long_description = DESCRIPTION 36 | 37 | about = {} 38 | 39 | if not VERSION: 40 | package_name = 'cdek' 41 | with open(os.path.join(here, package_name, '__version__.py')) as f: 42 | exec(f.read(), about) 43 | else: 44 | about['__version__'] = VERSION 45 | 46 | 47 | class UploadCommand(Command): 48 | """Support setup.py upload.""" 49 | 50 | description = 'Build and publish the package.' 51 | user_options = [] 52 | 53 | @staticmethod 54 | def status(s): 55 | """Prints things in bold.""" 56 | print('\033[1m{0}\033[0m'.format(s)) 57 | 58 | def initialize_options(self): 59 | pass 60 | 61 | def finalize_options(self): 62 | pass 63 | 64 | def run(self): 65 | try: 66 | self.status('Removing previous builds…') 67 | rmtree(os.path.join(here, 'dist')) 68 | except OSError: 69 | pass 70 | 71 | self.status('Building Source and Wheel (universal) distribution…') 72 | os.system(f'{sys.executable} setup.py sdist bdist_wheel --universal') 73 | 74 | self.status('Uploading the package to PyPI via Twine…') 75 | os.system('twine upload dist/*') 76 | 77 | self.status('Pushing git tags…') 78 | os.system(f'git tag v{about["__version__"]}') 79 | os.system('git push --tags') 80 | 81 | sys.exit() 82 | 83 | 84 | setup( 85 | name=NAME, 86 | version=about['__version__'], 87 | description=DESCRIPTION, 88 | long_description=long_description, 89 | long_description_content_type='text/markdown', 90 | maintainer=MAINTAINER, 91 | maintainer_email=EMAIL, 92 | python_requires=REQUIRES_PYTHON, 93 | url=URL, 94 | packages=find_packages(exclude=['tests']), 95 | install_requires=REQUIRED, 96 | extras_require=EXTRAS, 97 | include_package_data=True, 98 | license='MIT', 99 | classifiers=[ 100 | 'License :: OSI Approved :: MIT License', 101 | 'Development Status :: 5 - Production/Stable', 102 | 'Intended Audience :: Developers', 103 | 'Operating System :: Unix', 104 | 'Topic :: Software Development :: Libraries', 105 | 'Programming Language :: Python :: 3', 106 | 'Programming Language :: Python :: 3.6', 107 | 'Programming Language :: Python :: 3.7', 108 | 'Programming Language :: Python :: Implementation :: CPython', 109 | ], 110 | keywords=['cdek', 'api', 'cdek', 'fs-cdek-api', 'sdk', 'integration', 111 | 'fogstream', ], 112 | zip_safe=False, 113 | cmdclass={ 114 | 'upload': UploadCommand, 115 | }, 116 | ) 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CDEK-API 2 | =========== 3 | 4 | [![Build Status](https://travis-ci.org/fogstream/fs-cdek-api.svg?branch=dev)](https://travis-ci.org/fogstream/fs-cdek-api) 5 | [![Coverage Status](https://coveralls.io/repos/github/fogstream/fs-cdek-api/badge.svg?branch=dev)](https://coveralls.io/github/fogstream/fs-cdek-api?branch=dev) 6 | [![PyPI Version](https://img.shields.io/pypi/v/fs-cdek-api.svg)](https://pypi.python.org/pypi/fs-cdek-api) 7 | 8 | 9 | Описание 10 | ------------ 11 | Библиотека упрощающая работу с API службы доставки [СДЭК](https://www.cdek.ru/). 12 | 13 | Установка 14 | ------------ 15 | Для работы требуется Python 3.6+ 16 | Для установки используйте [pipenv](http://pipenv.org/) (или pip): 17 | 18 | ```bash 19 | $ pipenv install fs-cdek-api 20 | $ pip install fs-cdek-api 21 | ``` 22 | 23 | Примеры 24 | ------------- 25 | 26 | ### Запрос доставки 27 | ```python 28 | delivery_request = DeliveryRequest(number='12345678') 29 | order = delivery_request.add_order( 30 | number=randint(100000, 1000000), 31 | recipient_name='Иванов Иван Иванович', 32 | phone='+79999999999', 33 | send_city_post_code='680000', 34 | rec_city_post_code='680000', 35 | seller_name='Магазин', 36 | comment='Товар', 37 | tariff_type_code=138, 38 | shipping_price=300.0, 39 | ) 40 | delivery_request.add_address(order, pvz_code='XAB1') 41 | package = delivery_request.add_package( 42 | order_element=order, 43 | size_a=10, 44 | size_b=10, 45 | size_c=10, 46 | number=randint(100000, 1000000), 47 | barcode=randint(100000, 1000000), 48 | weight=600, 49 | ) 50 | delivery_request.add_item( 51 | package_element=package, 52 | weight=500, 53 | cost=1000, 54 | ware_key='12345678', 55 | comment='Товар', 56 | ) 57 | 58 | cdek_client = CDEKClient('login', 'pass') 59 | delivery_orders = cdek_client.create_orders(delivery_request) 60 | ``` 61 | 62 | ### Удаление заказа 63 | Условием возможности удаления заказа является отсутствие движения груза на 64 | складе СДЭК (статус заказа «Создан»). 65 | ```python 66 | delete_requests = cdek_client.delete_orders( 67 | act_number=act_number, 68 | dispatch_numbers=[dispatch_number], 69 | ) 70 | ``` 71 | 72 | ### Вызов курьера для забора груза ИМ 73 | ```python 74 | dispatch_number = order['DispatchNumber'] 75 | 76 | next_day = datetime.date.today() + datetime.timedelta(days=1) 77 | 78 | call_courier = CallCourier() 79 | call_request = call_courier.add_call( 80 | date=next_day, 81 | dispatch_number=dispatch_number, 82 | sender_phone='+79999999999', 83 | time_begin=datetime.time(hour=10), 84 | time_end=datetime.time(hour=17), 85 | lunch_begin=datetime.time(hour=13), 86 | lunch_end=datetime.time(hour=14), 87 | ) 88 | call_courier.add_address( 89 | call_element=call_request, 90 | address_street='Пушкина', 91 | address_house='50', 92 | address_flat='1', 93 | ) 94 | 95 | call = cdek_client.call_courier(call_courier) 96 | ``` 97 | 98 | ### Информация о заказах 99 | ```python 100 | dispatch_number = order['DispatchNumber'] 101 | info = cdek_client.get_orders_info([dispatch_number]) 102 | ``` 103 | 104 | ### Статусы заказов 105 | ```python 106 | dispatch_number = order['DispatchNumber'] 107 | info = cdek_client.get_orders_statuses([dispatch_number]) 108 | ``` 109 | 110 | ### Печать накладной 111 | Возврщает `pdf` документ в случае успеха. 112 | ```python 113 | order_print = cdek_client.get_orders_print([dispatch_number]) 114 | ``` 115 | 116 | ### Печать ШК-мест 117 | Возврщает `pdf` документ в случае успеха. 118 | ```python 119 | barcode_print = cdek_client.get_barcode_print([dispatch_number]) 120 | ``` 121 | 122 | ### Список регионов 123 | ```python 124 | regions = cdek_client.get_regions(region_code_ext=27) 125 | ``` 126 | 127 | ### Список городов 128 | ```python 129 | cities = cdek_client.get_cities(region_code_ext=27) 130 | ``` 131 | 132 | ### Список ПВЗ 133 | ```python 134 | pvz_list = cdek_client.get_delivery_points(city_post_code=680000)['pvz'] 135 | ``` 136 | 137 | ### Расчет стоимости доставки 138 | ```python 139 | shipping_costs = cdek_client.get_shipping_cost( 140 | sender_city_id=270, 141 | receiver_city_id=44, 142 | goods=[ 143 | {'weight': 0.3, 'length': 10, 'width': 7, 'height': 5}, 144 | {'weight': 0.1, 'volume': 0.1}, 145 | ], 146 | tariff_id=3, 147 | ) 148 | ``` 149 | 150 | ### Создание преалерта 151 | ```python 152 | next_day = datetime.date.today() + datetime.timedelta(days=1) 153 | 154 | pre_alert_element = PreAlert(planned_meeting_date=next_day, pvz_code='XAB1') 155 | pre_alert_element.add_order(dispatch_number=dispatch_number) 156 | pre_alerts = cdek_client.create_prealerts(pre_alert_element) 157 | ``` -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from contextlib import ExitStack as does_not_raise 2 | import datetime 3 | from typing import Dict 4 | 5 | import pytest 6 | 7 | from cdek.api import CDEKClient 8 | from cdek.entities import CallCourier, PreAlert 9 | 10 | 11 | def test_get_regions(cdek_client: CDEKClient): 12 | regions = cdek_client.get_regions(region_code_ext=27) 13 | 14 | assert regions 15 | assert regions[0]['countryCode'] == 'RU' 16 | assert regions[0]['regionName'] == 'Хабаровский' 17 | 18 | 19 | def test_get_cities(cdek_client: CDEKClient): 20 | cities = cdek_client.get_cities(region_code_ext=27) 21 | 22 | assert cities 23 | assert cities[0]['countryCode'] == 'RU' 24 | assert cities[0]['region'] == 'Хабаровский' 25 | 26 | 27 | def test_get_pvz_list(cdek_client: CDEKClient): 28 | response = cdek_client.get_delivery_points(city_post_code=680000) 29 | 30 | assert response 31 | assert 'pvz' in response 32 | pvz_list = response['pvz'] 33 | assert pvz_list 34 | assert pvz_list[0]['city'] == 'Хабаровск' 35 | 36 | 37 | def test_order_creation(cdek_client: CDEKClient, delivery_request): 38 | send_orders = cdek_client.create_orders(delivery_request) 39 | 40 | assert send_orders 41 | assert len(send_orders) == 1 42 | order = send_orders[0] 43 | assert 'DispatchNumber' in order 44 | assert 'Number' in order 45 | 46 | 47 | def test_order_info(cdek_client: CDEKClient, delivery_request): 48 | send_orders = cdek_client.create_orders(delivery_request) 49 | 50 | assert send_orders 51 | assert len(send_orders) == 1 52 | order = send_orders[0] 53 | assert 'DispatchNumber' in order 54 | assert 'Number' in order 55 | 56 | dispatch_number = order['DispatchNumber'] 57 | 58 | info = cdek_client.get_orders_info([dispatch_number]) 59 | 60 | assert info 61 | assert len(info) == 1 62 | order_info = info[0] 63 | assert 'ErrorCode' not in order_info 64 | assert order_info['DispatchNumber'] == dispatch_number 65 | 66 | 67 | def test_order_status_info(cdek_client: CDEKClient, delivery_request): 68 | send_orders = cdek_client.create_orders(delivery_request) 69 | 70 | assert send_orders 71 | assert len(send_orders) == 1 72 | order = send_orders[0] 73 | assert 'DispatchNumber' in order 74 | assert 'Number' in order 75 | 76 | dispatch_number = order['DispatchNumber'] 77 | 78 | statuses = cdek_client.get_orders_statuses([dispatch_number]) 79 | 80 | assert statuses 81 | assert len(statuses) == 1 82 | status_info = statuses[0] 83 | assert 'ErrorCode' not in status_info 84 | assert status_info['DispatchNumber'] == dispatch_number 85 | assert status_info['Status']['Code'] == '1' # Создан 86 | 87 | 88 | def test_courier_call(cdek_client: CDEKClient, delivery_request): 89 | send_orders = cdek_client.create_orders(delivery_request) 90 | 91 | assert send_orders 92 | assert len(send_orders) == 1 93 | order = send_orders[0] 94 | assert 'DispatchNumber' in order 95 | assert 'Number' in order 96 | 97 | dispatch_number = order['DispatchNumber'] 98 | 99 | next_day = datetime.date.today() + datetime.timedelta(days=1) 100 | 101 | call_courier = CallCourier() 102 | call_request = call_courier.add_call( 103 | date=next_day, 104 | dispatch_number=dispatch_number, 105 | sender_phone='+79999999999', 106 | time_begin=datetime.time(hour=10), 107 | time_end=datetime.time(hour=17), 108 | ) 109 | call_courier.add_address( 110 | call_element=call_request, 111 | address_street='Пушкина', 112 | address_house='50', 113 | address_flat='1', 114 | ) 115 | 116 | call = cdek_client.call_courier(call_courier) 117 | 118 | assert call 119 | assert 'Number' in call 120 | 121 | 122 | def test_courier_call_with_lunch(cdek_client: CDEKClient, delivery_request): 123 | send_orders = cdek_client.create_orders(delivery_request) 124 | 125 | assert send_orders 126 | assert len(send_orders) == 1 127 | order = send_orders[0] 128 | assert 'ErrorCode' not in order 129 | assert 'DispatchNumber' in order 130 | assert 'Number' in order 131 | 132 | dispatch_number = order['DispatchNumber'] 133 | 134 | next_day = datetime.date.today() + datetime.timedelta(days=1) 135 | 136 | call_courier = CallCourier() 137 | call_request = call_courier.add_call( 138 | date=next_day, 139 | dispatch_number=dispatch_number, 140 | sender_phone='+79999999999', 141 | time_begin=datetime.time(hour=10), 142 | time_end=datetime.time(hour=17), 143 | lunch_begin=datetime.time(hour=13), 144 | lunch_end=datetime.time(hour=14), 145 | ) 146 | call_courier.add_address( 147 | call_element=call_request, 148 | address_street='Пушкина', 149 | address_house='50', 150 | address_flat='1', 151 | ) 152 | 153 | call = cdek_client.call_courier(call_courier) 154 | 155 | assert call 156 | assert 'Number' in call 157 | 158 | 159 | def test_print_orders(cdek_client: CDEKClient, delivery_request): 160 | send_orders = cdek_client.create_orders(delivery_request) 161 | 162 | assert send_orders 163 | assert len(send_orders) == 1 164 | order = send_orders[0] 165 | assert 'ErrorCode' not in order 166 | assert 'DispatchNumber' in order 167 | assert 'Number' in order 168 | 169 | dispatch_number = order['DispatchNumber'] 170 | 171 | order_print = cdek_client.get_orders_print([dispatch_number]) 172 | 173 | assert order_print is not None 174 | 175 | 176 | @pytest.mark.skip( 177 | reason="The cause if the error isn't clear: " 178 | "fail creating pdf on the cdek side." 179 | ) 180 | def test_print_barcode(cdek_client: CDEKClient, delivery_request): 181 | send_orders = cdek_client.create_orders(delivery_request) 182 | 183 | assert send_orders 184 | assert len(send_orders) == 1 185 | order = send_orders[0] 186 | assert 'ErrorCode' not in order 187 | assert 'DispatchNumber' in order 188 | assert 'Number' in order 189 | 190 | dispatch_number = order['DispatchNumber'] 191 | 192 | barcode_print = cdek_client.get_barcode_print([dispatch_number]) 193 | 194 | assert barcode_print is not None 195 | 196 | 197 | @pytest.mark.parametrize('tariff,expectation', [ 198 | pytest.param({'tariff_id': 3}, does_not_raise(), id='Single tariff'), 199 | pytest.param({'tariffs': [1, 3]}, does_not_raise(), id='Multiple tariffs'), 200 | pytest.param({}, pytest.raises(AttributeError), marks=pytest.mark.xfail, 201 | id='Without tariffs')]) 202 | def test_shipping_cost_calculator(cdek_client: CDEKClient, tariff: Dict, 203 | expectation): 204 | with expectation: 205 | shipping_costs = cdek_client.get_shipping_cost( 206 | sender_city_id=270, 207 | receiver_city_id=44, 208 | goods=[ 209 | {'weight': 0.3, 'length': 10, 'width': 7, 'height': 5}, 210 | {'weight': 0.1, 'volume': 0.1}, 211 | ], 212 | services=[{'id': 2, 'param': 1000}], 213 | **tariff, 214 | ) 215 | 216 | assert shipping_costs 217 | assert 'error' not in shipping_costs 218 | assert 'result' in shipping_costs 219 | result = shipping_costs['result'] 220 | assert result['tariffId'] == 3 221 | 222 | 223 | def test_shipping_cost_with_auth_data(cdek_client: CDEKClient): 224 | cdek_client._test = False 225 | 226 | shipping_costs = cdek_client.get_shipping_cost( 227 | sender_city_id=270, 228 | receiver_city_id=44, 229 | goods=[ 230 | {'weight': 0.3, 'length': 10, 'width': 7, 'height': 5}, 231 | {'weight': 0.1, 'volume': 0.1}, 232 | ], 233 | tariff_id=136, # Для тарифов ИМ требуются валидные данные входа ИМ 234 | ) 235 | 236 | assert 'error' in shipping_costs 237 | 238 | 239 | def test_order_delete(cdek_client: CDEKClient, delivery_request): 240 | send_orders = cdek_client.create_orders(delivery_request) 241 | 242 | assert send_orders 243 | assert len(send_orders) == 1 244 | order = send_orders[0] 245 | assert 'DispatchNumber' in order 246 | assert 'Number' in order 247 | 248 | dispatch_number = order['DispatchNumber'] 249 | 250 | statuses = cdek_client.get_orders_statuses([dispatch_number]) 251 | 252 | assert statuses 253 | assert len(statuses) == 1 254 | status_info = statuses[0] 255 | assert 'ErrorCode' not in order 256 | assert status_info['DispatchNumber'] == dispatch_number 257 | assert status_info['Status']['Code'] == '1' # Создан 258 | assert 'ActNumber' in status_info 259 | act_number = status_info['ActNumber'] 260 | 261 | delete_requests = cdek_client.delete_orders( 262 | act_number=act_number, 263 | dispatch_numbers=[dispatch_number], 264 | ) 265 | 266 | assert delete_requests 267 | assert len(delete_requests) == 1 268 | deleted_order = delete_requests[0] 269 | assert 'DispatchNumber' in deleted_order 270 | assert deleted_order['DispatchNumber'] == dispatch_number 271 | 272 | 273 | @pytest.mark.skip( 274 | reason="The cause if the error isn't clear: " 275 | "500 from CDEK" 276 | ) 277 | def test_create_prealerts(cdek_client: CDEKClient, delivery_request): 278 | send_orders = cdek_client.create_orders(delivery_request) 279 | 280 | assert send_orders 281 | assert len(send_orders) == 1 282 | order = send_orders[0] 283 | assert 'DispatchNumber' in order 284 | assert 'Number' in order 285 | 286 | dispatch_number = order['DispatchNumber'] 287 | 288 | next_day = datetime.date.today() + datetime.timedelta(days=1) 289 | 290 | pre_alert_element = PreAlert(planned_meeting_date=next_day, pvz_code='XAB1') 291 | pre_alert_element.add_order(dispatch_number=dispatch_number) 292 | pre_alerts = cdek_client.create_prealerts(pre_alert_element) 293 | 294 | assert pre_alerts 295 | assert len(pre_alerts) == 1 296 | assert 'ErrorCode' in pre_alerts[0] 297 | # Проверить метод можно только на валидных данных авторизации 298 | assert pre_alerts[0]['ErrorCode'] == 'W_PA_17' 299 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=2 23 | 24 | # Allow loading of arbitrary C extensions. Extensions are imported into the 25 | # active Python interpreter and may run arbitrary code. 26 | unsafe-load-any-extension=no 27 | 28 | # A comma-separated list of package or module names from where C extensions may 29 | # be loaded. Extensions are loading into the active Python interpreter and may 30 | # run arbitrary code 31 | extension-pkg-whitelist= 32 | 33 | 34 | [MESSAGES CONTROL] 35 | 36 | # Only show warnings with the listed confidence levels. Leave empty to show 37 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 38 | confidence= 39 | 40 | # Enable the message, report, category or checker with the given id(s). You can 41 | # either give multiple identifier separated by comma (,) or put this option 42 | # multiple time. See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | 55 | disable= 56 | attribute-defined-outside-init, 57 | duplicate-code, 58 | fixme, 59 | invalid-name, 60 | missing-docstring, 61 | protected-access, 62 | too-few-public-methods, 63 | # handled by black 64 | format, 65 | too-many-arguments 66 | 67 | 68 | [REPORTS] 69 | 70 | # Set the output format. Available formats are text, parseable, colorized, msvs 71 | # (visual studio) and html. You can also give a reporter class, eg 72 | # mypackage.mymodule.MyReporterClass. 73 | output-format=text 74 | 75 | # Put messages in a separate file for each module / package specified on the 76 | # command line instead of printing them on stdout. Reports (if any) will be 77 | # written in a file name "pylint_global.[txt|html]". 78 | files-output=no 79 | 80 | # Tells whether to display a full report or only the messages 81 | reports=no 82 | 83 | # Python expression which should return a note less than 10 (10 is the highest 84 | # note). You have access to the variables errors warning, statement which 85 | # respectively contain the number of errors / warnings messages and the total 86 | # number of statements analyzed. This is used by the global evaluation report 87 | # (RP0004). 88 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 89 | 90 | # Template used to display messages. This is a python new-style format string 91 | # used to format the message information. See doc for all details 92 | #msg-template= 93 | 94 | 95 | [LOGGING] 96 | 97 | # Logging modules to check that the string format arguments are in logging 98 | # function parameter format 99 | logging-modules=logging 100 | 101 | 102 | [MISCELLANEOUS] 103 | 104 | # List of note tags to take in consideration, separated by a comma. 105 | notes=FIXME,XXX,TODO 106 | 107 | 108 | [SIMILARITIES] 109 | 110 | # Minimum lines number of a similarity. 111 | min-similarity-lines=4 112 | 113 | # Ignore comments when computing similarities. 114 | ignore-comments=yes 115 | 116 | # Ignore docstrings when computing similarities. 117 | ignore-docstrings=yes 118 | 119 | # Ignore imports when computing similarities. 120 | ignore-imports=no 121 | 122 | 123 | [VARIABLES] 124 | 125 | # Tells whether we should check for unused import in __init__ files. 126 | init-import=no 127 | 128 | # A regular expression matching the name of dummy variables (i.e. expectedly 129 | # not used). 130 | dummy-variables-rgx=_$|dummy 131 | 132 | # List of additional names supposed to be defined in builtins. Remember that 133 | # you should avoid defining new builtins when possible. 134 | additional-builtins= 135 | 136 | # List of strings which can identify a callback function by name. A callback 137 | # name must start or end with one of those strings. 138 | callbacks=cb_,_cb 139 | 140 | 141 | [FORMAT] 142 | 143 | # Maximum number of characters on a single line. 144 | max-line-length=80 145 | 146 | # Regexp for a line that is allowed to be longer than the limit. 147 | ignore-long-lines=^\s*(# )??$ 148 | 149 | # Allow the body of an if to be on the same line as the test if there is no 150 | # else. 151 | single-line-if-stmt=no 152 | 153 | # List of optional constructs for which whitespace checking is disabled 154 | no-space-check=trailing-comma,dict-separator 155 | 156 | # Maximum number of lines in a module 157 | max-module-lines=2000 158 | 159 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 160 | # tab). 161 | indent-string=' ' 162 | 163 | # Number of spaces of indent required inside a hanging or continued line. 164 | indent-after-paren=4 165 | 166 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 167 | expected-line-ending-format= 168 | 169 | 170 | [BASIC] 171 | 172 | # List of builtins function names that should not be used, separated by a comma 173 | bad-functions=map,filter,input 174 | 175 | # Good variable names which should always be accepted, separated by a comma 176 | good-names=i,j,k,ex,Run,_ 177 | 178 | # Bad variable names which should always be refused, separated by a comma 179 | bad-names=foo,bar,baz,toto,tutu,tata 180 | 181 | # Colon-delimited sets of names that determine each other's naming style when 182 | # the name regexes allow several styles. 183 | name-group= 184 | 185 | # Include a hint for the correct naming format with invalid-name 186 | include-naming-hint=no 187 | 188 | # Regular expression matching correct function names 189 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 190 | 191 | # Naming hint for function names 192 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 193 | 194 | # Regular expression matching correct variable names 195 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 196 | 197 | # Naming hint for variable names 198 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 199 | 200 | # Regular expression matching correct constant names 201 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 202 | 203 | # Naming hint for constant names 204 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 205 | 206 | # Regular expression matching correct attribute names 207 | attr-rgx=[a-z_][a-z0-9_]{2,}$ 208 | 209 | # Naming hint for attribute names 210 | attr-name-hint=[a-z_][a-z0-9_]{2,}$ 211 | 212 | # Regular expression matching correct argument names 213 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 214 | 215 | # Naming hint for argument names 216 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 217 | 218 | # Regular expression matching correct class attribute names 219 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 220 | 221 | # Naming hint for class attribute names 222 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 223 | 224 | # Regular expression matching correct inline iteration names 225 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 226 | 227 | # Naming hint for inline iteration names 228 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 229 | 230 | # Regular expression matching correct class names 231 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 232 | 233 | # Naming hint for class names 234 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 235 | 236 | # Regular expression matching correct module names 237 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 238 | 239 | # Naming hint for module names 240 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 241 | 242 | # Regular expression matching correct method names 243 | method-rgx=[a-z_][a-z0-9_]{2,}$ 244 | 245 | # Naming hint for method names 246 | method-name-hint=[a-z_][a-z0-9_]{2,}$ 247 | 248 | # Regular expression which should only match function or class names that do 249 | # not require a docstring. 250 | no-docstring-rgx=__.*__ 251 | 252 | # Minimum line length for functions/classes that require docstrings, shorter 253 | # ones are exempt. 254 | docstring-min-length=-1 255 | 256 | # List of decorators that define properties, such as abc.abstractproperty. 257 | property-classes=abc.abstractproperty 258 | 259 | 260 | [TYPECHECK] 261 | 262 | # Tells whether missing members accessed in mixin class should be ignored. A 263 | # mixin class is detected if its name ends with "mixin" (case insensitive). 264 | ignore-mixin-members=yes 265 | 266 | # List of module names for which member attributes should not be checked 267 | # (useful for modules/projects where namespaces are manipulated during runtime 268 | # and thus existing member attributes cannot be deduced by static analysis 269 | ignored-modules= 270 | 271 | # List of classes names for which member attributes should not be checked 272 | # (useful for classes with attributes dynamically set). 273 | ignored-classes=SQLObject, optparse.Values, thread._local, _thread._local 274 | 275 | # List of members which are set dynamically and missed by pylint inference 276 | # system, and so shouldn't trigger E1101 when accessed. Python regular 277 | # expressions are accepted. 278 | generated-members=REQUEST,acl_users,aq_parent 279 | 280 | # List of decorators that create context managers from functions, such as 281 | # contextlib.contextmanager. 282 | contextmanager-decorators=contextlib.contextmanager 283 | 284 | 285 | [SPELLING] 286 | 287 | # Spelling dictionary name. Available dictionaries: none. To make it working 288 | # install python-enchant package. 289 | spelling-dict= 290 | 291 | # List of comma separated words that should not be checked. 292 | spelling-ignore-words= 293 | 294 | # A path to a file that contains private dictionary; one word per line. 295 | spelling-private-dict-file= 296 | 297 | # Tells whether to store unknown words to indicated private dictionary in 298 | # --spelling-private-dict-file option instead of raising a message. 299 | spelling-store-unknown-words=no 300 | 301 | 302 | [DESIGN] 303 | 304 | # Maximum number of arguments for function / method 305 | max-args=10 306 | 307 | # Argument names that match this expression will be ignored. Default to name 308 | # with leading underscore 309 | ignored-argument-names=_.* 310 | 311 | # Maximum number of locals for function / method body 312 | max-locals=25 313 | 314 | # Maximum number of return / yield for function / method body 315 | max-returns=11 316 | 317 | # Maximum number of branch for function / method body 318 | max-branches=26 319 | 320 | # Maximum number of statements in function / method body 321 | max-statements=100 322 | 323 | # Maximum number of parents for a class (see R0901). 324 | max-parents=7 325 | 326 | # Maximum number of attributes for a class (see R0902). 327 | max-attributes=11 328 | 329 | # Minimum number of public methods for a class (see R0903). 330 | min-public-methods=2 331 | 332 | # Maximum number of public methods for a class (see R0904). 333 | max-public-methods=25 334 | 335 | 336 | [CLASSES] 337 | 338 | # List of method names used to declare (i.e. assign) instance attributes. 339 | defining-attr-methods=__init__,__new__,setUp 340 | 341 | # List of valid names for the first argument in a class method. 342 | valid-classmethod-first-arg=cls 343 | 344 | # List of valid names for the first argument in a metaclass class method. 345 | valid-metaclass-classmethod-first-arg=mcs 346 | 347 | # List of member names, which should be excluded from the protected access 348 | # warning. 349 | exclude-protected=_asdict,_fields,_replace,_source,_make 350 | 351 | 352 | [IMPORTS] 353 | 354 | # Deprecated modules which should not be used, separated by a comma 355 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 356 | 357 | # Create a graph of every (i.e. internal and external) dependencies in the 358 | # given file (report RP0402 must not be disabled) 359 | import-graph= 360 | 361 | # Create a graph of external dependencies in the given file (report RP0402 must 362 | # not be disabled) 363 | ext-import-graph= 364 | 365 | # Create a graph of internal dependencies in the given file (report RP0402 must 366 | # not be disabled) 367 | int-import-graph= 368 | 369 | 370 | [EXCEPTIONS] 371 | 372 | # Exceptions that will emit a warning when being caught. Defaults to 373 | # "Exception" 374 | overgeneral-exceptions=Exception -------------------------------------------------------------------------------- /cdek/entities.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import datetime 3 | from decimal import Decimal 4 | from typing import Optional, Union 5 | from xml.etree import ElementTree 6 | from xml.etree.ElementTree import Element, SubElement 7 | 8 | Date = Union[datetime.datetime, datetime.date] 9 | 10 | 11 | class AbstractElement(ABC): 12 | @abstractmethod 13 | def to_xml(self) -> Element: 14 | raise NotImplementedError 15 | 16 | 17 | class PreAlert(AbstractElement): 18 | pre_alert_element = None 19 | orders = [] 20 | 21 | def __init__(self, planned_meeting_date: Date, pvz_code: str): 22 | """ 23 | Инициализация создания сводного реестра 24 | :param planned_meeting_date: Дата планируемой передачи. 25 | :param pvz_code: Офис-получатель 26 | """ 27 | self.pre_alert_element = Element( 28 | 'PreAlert', 29 | PlannedMeetingDate=planned_meeting_date.isoformat(), 30 | PvzCode=pvz_code, 31 | ) 32 | 33 | def add_order(self, dispatch_number: Optional[str], 34 | number: Optional[str] = None) -> SubElement: 35 | """ 36 | Добавление заказа к преалерту 37 | :param str dispatch_number: Номер заказа в системе СДЭК 38 | :param str number: Номер заказа в системе ИМ 39 | :return: Элемент заказа 40 | :rtype: SubElement 41 | """ 42 | order_element = SubElement( 43 | self.pre_alert_element, 44 | 'Order', 45 | DispatchNumber=dispatch_number, 46 | Number=number, 47 | ) 48 | self.orders.append(order_element) 49 | 50 | return order_element 51 | 52 | def to_xml(self) -> Element: 53 | return self.pre_alert_element 54 | 55 | 56 | class CallCourier(AbstractElement): 57 | call_courier_element = None 58 | calls = [] 59 | 60 | def __init__(self, call_count: int = 1): 61 | """ 62 | Инициализация вызова курьера для забора груза 63 | :param int call_count: Количество заявок для вызова курьера в документе 64 | """ 65 | self.call_courier_element = ElementTree.Element( 66 | 'CallCourier', 67 | CallCount=call_count, 68 | ) 69 | 70 | def add_call(self, date: datetime.date, time_begin: datetime.time, 71 | time_end: datetime.time, 72 | dispatch_number: Optional[int] = None, 73 | sender_city_id: Optional[int] = None, 74 | sender_phone: Optional[str] = None, 75 | sender_name: Optional[str] = None, 76 | weight: Optional[int] = None, 77 | comment: Optional[str] = None, 78 | lunch_begin: Optional[datetime.time] = None, 79 | lunch_end: Optional[datetime.time] = None, 80 | ignore_time: bool = False) -> SubElement: 81 | """ 82 | Добавление вызова курьера 83 | :param date: дата ожидания курьера 84 | :param time_begin: время начала ожидания 85 | :param time_end: время окончания ожидания 86 | :param int dispatch_number: Номер привязанного заказа 87 | :param int sender_city_id: ID города отправителя по базе СДЭК 88 | :param str sender_phone: телефон оправителя 89 | :param str sender_name: ФИО оправителя 90 | :param int weight: общий вес в граммах 91 | :param str comment: комментарий 92 | :param lunch_begin: время начала обеда 93 | :param lunch_end: время окончания обеда 94 | :param bool ignore_time: Не выполнять проверки времени приезда курьера 95 | :return: Объект вызова 96 | """ 97 | 98 | call_element = ElementTree.SubElement( 99 | self.call_courier_element, 100 | 'Call', 101 | Date=date.isoformat(), 102 | TimeBeg=time_begin.isoformat(), 103 | TimeEnd=time_end.isoformat(), 104 | ) 105 | 106 | call_element.attrib['DispatchNumber'] = dispatch_number 107 | call_element.attrib['SendCityCode'] = sender_city_id 108 | call_element.attrib['SendPhone'] = sender_phone 109 | call_element.attrib['SenderName'] = sender_name 110 | call_element.attrib['Weight'] = weight 111 | call_element.attrib['Comment'] = comment 112 | call_element.attrib['IgnoreTime'] = ignore_time 113 | 114 | if lunch_begin: 115 | call_element.attrib['LunchBeg'] = lunch_begin.isoformat() 116 | if lunch_end: 117 | call_element.attrib['LunchEnd'] = lunch_end.isoformat() 118 | 119 | self.calls.append(call_element) 120 | 121 | return call_element 122 | 123 | @staticmethod 124 | def add_address(call_element: SubElement, address_street: str, 125 | address_house: str, address_flat: str) -> SubElement: 126 | """Добавление адреса забора посылки. 127 | 128 | :param call_element: Объект вызова курьера 129 | :param address_street: Улица отправителя 130 | :param address_house: Дом, корпус, строение отправителя 131 | :param address_flat: Квартира/Офис отправителя 132 | :return: Объект адреса вызова 133 | """ 134 | address_element = ElementTree.SubElement( 135 | call_element, 136 | 'Address', 137 | Street=address_street, 138 | House=address_house, 139 | Flat=address_flat, 140 | ) 141 | 142 | return address_element 143 | 144 | def to_xml(self) -> Element: 145 | return self.call_courier_element 146 | 147 | 148 | class DeliveryRequest(AbstractElement): 149 | delivery_request_element = None 150 | orders = [] 151 | 152 | def __init__(self, number: str, order_count: int = 1): 153 | """Инициализация запроса на доставку. 154 | 155 | :param number: Номер заказа 156 | :param order_count: Количество заказов в документе 157 | """ 158 | self.number = number 159 | self.delivery_request_element = ElementTree.Element( 160 | 'DeliveryRequest', 161 | Number=number, 162 | OrderCount=order_count, 163 | ) 164 | 165 | def add_order(self, number: str, tariff_type_code: int, 166 | recipient_name: str, phone: str, 167 | send_city_code: Optional[int] = None, 168 | send_city_post_code: Optional[str] = None, 169 | rec_city_code: Optional[int] = None, 170 | rec_city_post_code: Optional[str] = None, 171 | shipping_price: Optional[Union[Decimal, float]] = None, 172 | comment: Optional[str] = None, 173 | seller_name: Optional[str] = None) -> SubElement: 174 | """Добавление запроса на доставку. 175 | 176 | :param str number: Номер отправления клиента (уникален в пределах 177 | заказов одного клиента). Идентификатор заказа в ИС Клиента. 178 | :param int send_city_code: Код города отправителя из базы СДЭК 179 | :param str send_city_post_code: Почтовый индекс города отправителя 180 | :param int rec_city_code: Код города получателя из базы СДЭК 181 | :param str rec_city_post_code: Почтовый индекс города получателя 182 | :param str recipient_name: Получатель (ФИО) 183 | :param int tariff_type_code: Код типа тарифа 184 | :param shipping_price: Доп. сбор ИМ за доставку 185 | :param str phone: Телефон получателя 186 | :param str comment: Комментарий особые отметки по заказу 187 | :param str seller_name: Истинный продавец. Используется при печати 188 | заказов для отображения настоящего продавца товара, 189 | торгового названия. 190 | :return: Объект заказа 191 | """ 192 | order_element = ElementTree.SubElement( 193 | self.delivery_request_element, 194 | 'Order', 195 | ) 196 | 197 | order_element.attrib['Number'] = number 198 | order_element.attrib['SendCityCode'] = send_city_code 199 | order_element.attrib['SendCityPostCode'] = send_city_post_code 200 | order_element.attrib['RecCityCode'] = rec_city_code 201 | order_element.attrib['RecCityPostCode'] = rec_city_post_code 202 | order_element.attrib['RecipientName'] = recipient_name 203 | order_element.attrib['TariffTypeCode'] = tariff_type_code 204 | order_element.attrib['DeliveryRecipientCost'] = shipping_price 205 | order_element.attrib['Phone'] = phone 206 | order_element.attrib['Comment'] = comment 207 | order_element.attrib['SellerName'] = seller_name 208 | 209 | self.orders.append(order_element) 210 | return order_element 211 | 212 | @staticmethod 213 | def add_address( 214 | order_element: SubElement, 215 | street: Optional[str] = None, 216 | house: Optional[str] = None, 217 | flat: Optional[str] = None, 218 | pvz_code: Optional[str] = None 219 | ) -> SubElement: 220 | """Добавление адреса доставки. 221 | 222 | :param order_element: Объект заказа 223 | :param str street: Улица получателя 224 | :param str house: Дом, корпус, строение получателя 225 | :param str flat: Квартира/Офис получателя 226 | :param str pvz_code: Код ПВЗ. Атрибут необходим только 227 | для заказов с режимом доставки «до склада» 228 | :return: Объект адреса 229 | """ 230 | address_element = ElementTree.SubElement(order_element, 'Address') 231 | 232 | if pvz_code: 233 | address_element.attrib['PvzCode'] = pvz_code 234 | else: 235 | address_element.attrib['Street'] = street 236 | address_element.attrib['House'] = house 237 | address_element.attrib['Flat'] = flat 238 | 239 | return address_element 240 | 241 | @staticmethod 242 | def add_package( 243 | order_element: SubElement, 244 | size_a: Optional[int] = None, 245 | size_b: Optional[int] = None, 246 | size_c: Optional[int] = None, 247 | number: Optional[str] = None, 248 | barcode: Optional[str] = None, 249 | weight: Optional[int] = None 250 | ) -> SubElement: 251 | """Добавление посылки. 252 | 253 | Габариты упаковки заполняются только если указаны все три значения. 254 | 255 | :param order_element: Объект заказа 256 | :param int size_a: Габариты упаковки. Длина (в сантиметрах) 257 | :param int size_b: Габариты упаковки. Ширина (в сантиметрах) 258 | :param int size_c: Габариты упаковки. Высота (в сантиметрах) 259 | :param number: Номер упаковки (можно использовать порядковый номер 260 | упаковки заказа или номер заказа), уникален в пределах заказа. 261 | Идентификатор заказа в ИС Клиента. 262 | 263 | :param barcode: Штрих-код упаковки, идентификатор грузоместа. 264 | Параметр используется для оперирования грузом на складах СДЭК), 265 | уникален в пределах заказа. Идентификатор грузоместа в ИС Клиента. 266 | :param int weight: Общий вес (в граммах) 267 | """ 268 | 269 | order_number = order_element.attrib['Number'] 270 | package_number = number or order_number 271 | barcode = barcode or order_number 272 | 273 | if not (size_a and size_b and size_c): 274 | size_a = size_b = size_c = None 275 | 276 | package_element = ElementTree.SubElement( 277 | order_element, 278 | 'Package', 279 | Number=package_number, 280 | BarCode=barcode, 281 | SizeA=size_a, 282 | SizeB=size_b, 283 | SizeC=size_c, 284 | Weight=weight 285 | ) 286 | 287 | return package_element 288 | 289 | @staticmethod 290 | def add_item( 291 | package_element: SubElement, 292 | weight: int, 293 | ware_key: str, 294 | cost: Union[Decimal, float], 295 | payment: Union[Decimal, float] = 0, 296 | amount: int = 1, 297 | comment: str = '' 298 | ) -> SubElement: 299 | """Добавление товара в посылку. 300 | 301 | :param package_element: Объект посылки 302 | :param weight: Вес (за единицу товара, в граммах) 303 | :param ware_key: Идентификатор/артикул товара/вложения 304 | :param cost: Объявленная стоимость товара 305 | :param payment: Оплата за товар при получении 306 | :param amount: Количество единиц одноименного товара (в штуках). 307 | :param comment: Наименование товара 308 | (может также содержать описание товара: размер, цвет) 309 | """ 310 | item_element = ElementTree.SubElement( 311 | package_element, 312 | 'Item', 313 | Amount=amount, 314 | ) 315 | item_element.attrib['Weight'] = weight 316 | item_element.attrib['WareKey'] = ware_key 317 | item_element.attrib['Cost'] = cost 318 | item_element.attrib['Payment'] = payment 319 | item_element.attrib['Comment'] = comment 320 | 321 | return item_element 322 | 323 | @staticmethod 324 | def add_service(order_element: SubElement, 325 | code: int, count: Optional[int] = None) -> SubElement: 326 | """Добавление дополнительной услуги к заказу. 327 | 328 | :param order_element: Объект заказа 329 | :param code: Тип дополнительной услуги 330 | :param count: Количество упаковок 331 | """ 332 | 333 | add_service_element = ElementTree.SubElement( 334 | order_element, 335 | 'AddService', 336 | ServiceCode=code, 337 | Count=count, 338 | ) 339 | 340 | return add_service_element 341 | 342 | def to_xml(self) -> Element: 343 | return self.delivery_request_element 344 | -------------------------------------------------------------------------------- /cdek/api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Dict, List, Optional, Union 4 | from urllib.parse import urlencode 5 | from xml.etree import ElementTree 6 | from xml.etree.ElementTree import Element 7 | 8 | import requests 9 | 10 | from .entities import CallCourier, DeliveryRequest, PreAlert 11 | from .utils import clean_dict, get_secure, xml_to_dict, xml_to_string 12 | 13 | 14 | class CDEKClient: 15 | # Калькулятор стоимости доставки 16 | CALCULATOR_URL = 'http://api.cdek.ru/calculator/calculate_price_by_json.php' 17 | # Список регионов 18 | REGIONS_URL = '/v1/location/regions/json' 19 | # Список городов 20 | CITIES_URL = '/v1/location/cities/json' 21 | # Создание заказа 22 | CREATE_ORDER_URL = '/new_orders.php' 23 | # Удаление заказа 24 | DELETE_ORDER_URL = '/delete_orders.php' 25 | # Создание преалерта 26 | PREALERT_URL = '/addPreAlert' 27 | # Статус заказа 28 | ORDER_STATUS_URL = '/status_report_h.php' 29 | # Печать ШК-мест 30 | BARCODE_PRINT_URL = '/ordersPackagesPrint' 31 | # Информация о заказе 32 | ORDER_INFO_URL = '/info_report.php' 33 | # Печать квитанции к заказу 34 | ORDER_PRINT_URL = '/orders_print.php' 35 | # Точки выдачи 36 | DELIVERY_POINTS_URL = '/pvzlist/v1/json' 37 | # Вызов курьера 38 | CALL_COURIER_URL = '/call_courier.php' 39 | 40 | def __init__(self, account: str, secure_password: str, 41 | api_url: str = 'http://integration.cdek.ru', 42 | test: bool = False): 43 | self._account = account 44 | self._secure_password = secure_password 45 | self._api_url = api_url 46 | self._test = test 47 | 48 | def _exec_request(self, url: str, json_data: Dict, method: str = 'GET', 49 | stream: bool = False, **kwargs) -> requests.Response: 50 | if isinstance(json_data, dict): 51 | json_data = clean_dict(json_data) 52 | 53 | url = self._api_url + url 54 | 55 | if method == 'GET': 56 | response = requests.get( 57 | f'{url}?{urlencode(json_data)}', stream=stream, **kwargs, 58 | ) 59 | elif method == 'POST': 60 | response = requests.post(url, json=json_data, stream=stream, **kwargs) 61 | else: 62 | raise NotImplementedError(f'Unknown method "{method}"') 63 | 64 | response.raise_for_status() 65 | 66 | return response 67 | 68 | def _exec_xml_request(self, url: str, xml_element: Element, 69 | parse: bool = True) -> ElementTree: 70 | now = datetime.date.today().isoformat() 71 | xml_element.attrib['Date'] = now 72 | xml_element.attrib['Account'] = self._account 73 | xml_element.attrib['Secure'] = get_secure(self._secure_password, now) 74 | 75 | new_url = self._api_url + url 76 | data = {'xml_request': xml_to_string(xml_element)} 77 | response = requests.post(new_url, data=data) 78 | if parse: 79 | response = ElementTree.fromstring(response.text) 80 | 81 | return response 82 | 83 | def get_shipping_cost( 84 | self, 85 | goods: List[Dict], 86 | sender_city_id: Optional[int] = None, 87 | receiver_city_id: Optional[int] = None, 88 | sender_city_post_code: Optional[str] = None, 89 | receiver_city_post_code: Optional[str] = None, 90 | tariff_id: Optional[int] = None, 91 | tariffs: Optional[List[int]] = None, 92 | services: List[dict] = None, 93 | ) -> Dict: 94 | """Расчет стоимости и сроков доставки. 95 | 96 | Для отправителя и получателя обязателен один из параметров: 97 | *_city_id или *_city_postcode внутри *_city_data 98 | 99 | :param receiver_city_post_code: Почтовый индекс города получателя 100 | :param sender_city_post_code: Почтовый индекс города отправителя 101 | :param tariff_id: ID тарифа 102 | :param sender_city_id: ID города отправителя по базе СДЭК 103 | :param receiver_city_id: ID города получателя по базе СДЭК 104 | :param tariffs: список тарифов 105 | :param goods: список товаров 106 | :param services: список дополнительных услуг 107 | :return: стоимость доставки 108 | :rtype: dict 109 | """ 110 | today = datetime.date.today().isoformat() 111 | 112 | json_data = { 113 | 'version': '1.0', 114 | 'dateExecute': today, 115 | 'senderCityId': sender_city_id, 116 | 'receiverCityId': receiver_city_id, 117 | 'senderCityPostCode': sender_city_post_code, 118 | 'receiverCityPostCode': receiver_city_post_code, 119 | 'goods': goods, 120 | 'services': services, 121 | } 122 | 123 | if not self._test: 124 | json_data['authLogin'] = self._account 125 | json_data['secure'] = get_secure(self._secure_password, today) 126 | 127 | if tariff_id: 128 | json_data['tariffId'] = tariff_id 129 | elif tariffs: 130 | tariff_list = [ 131 | {'priority': -i, 'id': tariff} 132 | for i, tariff in enumerate(tariffs, 1) 133 | ] 134 | json_data['tariffList'] = tariff_list 135 | else: 136 | raise AttributeError('Tariff required') 137 | 138 | response = requests.post( 139 | self.CALCULATOR_URL, 140 | json=json_data, 141 | ) 142 | response.raise_for_status() 143 | 144 | return response.json() 145 | 146 | def get_delivery_points( 147 | self, city_post_code: Optional[Union[int, str]] = None, 148 | city_id: Optional[Union[str, int]] = None, 149 | point_type: str = 'PVZ', 150 | have_cash_less: Optional[bool] = None, 151 | allowed_cod: Optional[bool] = None) -> Dict[str, List]: 152 | """Список ПВЗ. 153 | 154 | Возвращает списков пунктов самовывоза для указанного города, 155 | либо для всех если город не указан 156 | 157 | :param str city_post_code: Почтовый индекс города 158 | :param str city_id: Код города по базе СДЭК 159 | :param str point_type: Тип пункта выдачи ['PVZ', 'POSTOMAT', 'ALL'] 160 | :param bool have_cash_less: Наличие терминала оплаты 161 | :param bool allowed_cod: Разрешен наложенный платеж 162 | :return: Список точек выдачи 163 | :rtype: list 164 | """ 165 | response = self._exec_request( 166 | url=self.DELIVERY_POINTS_URL, 167 | json_data={ 168 | 'citypostcode': city_post_code, 169 | 'cityid': city_id, 170 | 'type': point_type, 171 | 'havecashless': have_cash_less, 172 | 'allowedcode': allowed_cod, 173 | }, 174 | timeout=60, 175 | ).json() 176 | 177 | return response 178 | 179 | def get_regions(self, region_code_ext: Optional[int] = None, 180 | region_code: Optional[int] = None, 181 | page: int = 0, size: int = 1000) -> List[Dict]: 182 | """Список регионов. 183 | 184 | Метод используется для получения детальной информации о регионах. 185 | 186 | :param region_code_ext: Код региона 187 | :param region_code: Код региона в ИС СДЭК 188 | :param int page: Номер страницы выборки 189 | :param int size: Ограничение выборки 190 | :return: Список регионов по заданным параметрам 191 | :rtype: list 192 | """ 193 | response = self._exec_request( 194 | url=self.REGIONS_URL, 195 | json_data={ 196 | 'regionCodeExt': region_code_ext, 197 | 'regionCode': region_code, 198 | 'countryCode': 'RU', 199 | 'page': page, 200 | 'size': size, 201 | }, 202 | timeout=60, 203 | ).json() 204 | 205 | return response 206 | 207 | def get_cities(self, region_code_ext: Optional[int] = None, 208 | region_code: Optional[int] = None, 209 | page: int = 0, size: int = 1000) -> List[Dict]: 210 | """Список городов. 211 | 212 | Метод используется для получения детальной информации о городах. 213 | 214 | :param region_code_ext: Код региона 215 | :param region_code: Код региона в ИС СДЭК 216 | :param page: Номер страницы выборки 217 | :param size: Ограничение выборки 218 | :return: Список городов по заданным параметрам 219 | :rtype: list 220 | """ 221 | response = self._exec_request( 222 | url=self.CITIES_URL, 223 | json_data={ 224 | 'regionCodeExt': region_code_ext, 225 | 'regionCode': region_code, 226 | 'countryCode': 'RU', 227 | 'page': page, 228 | 'size': size, 229 | }, 230 | timeout=60, 231 | ).json() 232 | 233 | return response 234 | 235 | def create_orders(self, delivery_request: DeliveryRequest): 236 | """Создание заказа. 237 | 238 | :param DeliveryRequest delivery_request: Запрос доставки 239 | :return: Информация о созданном заказе 240 | :rtype: dict 241 | """ 242 | xml = self._exec_xml_request( 243 | url=self.CREATE_ORDER_URL, 244 | xml_element=delivery_request.to_xml(), 245 | ) 246 | 247 | return [xml_to_dict(order) for order in 248 | xml.findall('*[@DispatchNumber]')] 249 | 250 | def delete_orders( 251 | self, act_number: str, dispatch_numbers: List[str]) -> List[Dict]: 252 | """Удаление заказа. 253 | 254 | :param str act_number: Номера акта приема-передачи. 255 | Идентификатор заказа в ИС клиента СДЭК. 256 | :param list dispatch_numbers: Номера заказов СДЭК 257 | :return: Удаленные заказы 258 | :rtype: dict 259 | """ 260 | delete_request_element = ElementTree.Element( 261 | 'DeleteRequest', 262 | Number=act_number, 263 | OrderCount=1, 264 | ) 265 | 266 | for dispatch_number in dispatch_numbers: 267 | ElementTree.SubElement( 268 | delete_request_element, 269 | 'Order', 270 | DispatchNumber=dispatch_number, 271 | ) 272 | 273 | xml = self._exec_xml_request( 274 | url=self.DELETE_ORDER_URL, 275 | xml_element=delete_request_element, 276 | ) 277 | 278 | return [xml_to_dict(order) for order in 279 | xml.findall('*[@DispatchNumber]')] 280 | 281 | def call_courier(self, call_courier: CallCourier) -> Dict: 282 | """Вызов курьера. 283 | 284 | Вызов курьера для забора посылки у ИМ 285 | 286 | :param CallCourier call_courier: Запрос вызова 287 | :return: Объект вызова 288 | :rtype: dict 289 | """ 290 | xml = self._exec_xml_request( 291 | url=self.CALL_COURIER_URL, 292 | xml_element=call_courier.to_xml(), 293 | ) 294 | 295 | return xml_to_dict(xml.find('Call')) 296 | 297 | def create_prealerts(self, pre_alert: PreAlert): 298 | """Создание преалерта. 299 | 300 | Метод для создания сводного реестра (преалерта), содержащего 301 | все накладные, товары по которым передаются в СДЭК на доставку. 302 | 303 | :return: Результат создания преалерта 304 | """ 305 | xml = self._exec_xml_request( 306 | self.PREALERT_URL, 307 | xml_element=pre_alert.to_xml(), 308 | ) 309 | 310 | return [xml_to_dict(order) for order in xml.findall('Order')] 311 | 312 | def get_orders_info(self, orders_dispatch_numbers: List[int]) -> List[Dict]: 313 | """Информация по заказам. 314 | 315 | :param orders_dispatch_numbers: список номеров отправлений СДЭК 316 | :returns list 317 | """ 318 | info_request = ElementTree.Element('InfoRequest') 319 | for dispatch_number in orders_dispatch_numbers: 320 | ElementTree.SubElement( 321 | info_request, 322 | 'Order', 323 | DispatchNumber=dispatch_number, 324 | ) 325 | 326 | xml = self._exec_xml_request(self.ORDER_INFO_URL, info_request) 327 | 328 | return [xml_to_dict(order) for order in xml.findall('Order')] 329 | 330 | def get_orders_statuses( 331 | self, 332 | orders_dispatch_numbers: List[int], 333 | show_history: bool = True 334 | ) -> List[Dict]: 335 | """ 336 | Статусы заказов 337 | :param orders_dispatch_numbers: список номеров отправлений СДЭК 338 | :param show_history: получать историю статусов 339 | :returns list 340 | """ 341 | status_report_element = ElementTree.Element( 342 | 'StatusReport', 343 | ShowHistory=show_history, 344 | ) 345 | 346 | for dispatch_number in orders_dispatch_numbers: 347 | ElementTree.SubElement( 348 | status_report_element, 349 | 'Order', 350 | DispatchNumber=dispatch_number, 351 | ) 352 | 353 | xml = self._exec_xml_request( 354 | url=self.ORDER_STATUS_URL, 355 | xml_element=status_report_element, 356 | ) 357 | 358 | return [xml_to_dict(order) for order in xml.findall('Order')] 359 | 360 | def get_orders_print( 361 | self, 362 | orders_dispatch_numbers: List[int], 363 | copy_count: int = 1 364 | ) -> Optional[requests.Response]: 365 | """Печатная форма квитанции к заказу. 366 | 367 | :param orders_dispatch_numbers: Список номеров отправлений СДЭК 368 | :param copy_count: Количество копий 369 | """ 370 | orders_print_element = ElementTree.Element( 371 | 'OrdersPrint', 372 | OrderCount=len(orders_dispatch_numbers), 373 | CopyCount=copy_count, 374 | ) 375 | 376 | for dispatch_number in orders_dispatch_numbers: 377 | ElementTree.SubElement( 378 | orders_print_element, 379 | 'Order', 380 | DispatchNumber=dispatch_number, 381 | ) 382 | 383 | response = self._exec_xml_request( 384 | url=self.ORDER_PRINT_URL, 385 | xml_element=orders_print_element, 386 | parse=False, 387 | ) 388 | 389 | return response if not response.content.startswith(b' Optional[requests.Response]: 396 | """Печать этикетки. 397 | 398 | Метод используется для формирования печатной формы 399 | этикетки для упаковки в формате pdf. 400 | 401 | :param list orders_dispatch_numbers: Список номеров отправлений СДЭК 402 | :param int copy_count: Количество копий 403 | """ 404 | orders_packages_print_element = ElementTree.Element( 405 | 'OrdersPackagesPrint', 406 | OrderCount=len(orders_dispatch_numbers), 407 | CopyCount=copy_count, 408 | ) 409 | 410 | for dispatch_number in orders_dispatch_numbers: 411 | ElementTree.SubElement( 412 | orders_packages_print_element, 413 | 'Order', 414 | DispatchNumber=dispatch_number, 415 | ) 416 | 417 | response = self._exec_xml_request( 418 | url=self.BARCODE_PRINT_URL, 419 | xml_element=orders_packages_print_element, 420 | parse=False, 421 | ) 422 | 423 | return response if not response.content.startswith(b'