├── .editorconfig ├── .eslintrc ├── .flake8 ├── .git-blame-ignore-revs ├── .github ├── CODEOWNERS ├── helper │ ├── install.sh │ └── site_config.json ├── labeler.yml └── workflows │ ├── ci.yml │ ├── labeller.yml │ ├── linters.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .releaserc ├── .semgrepignore ├── LICENSE ├── README.md ├── codecov.yml ├── ecommerce_integrations ├── __init__.py ├── amazon │ ├── __init__.py │ └── doctype │ │ ├── __init__.py │ │ ├── amazon_fields_map │ │ ├── __init__.py │ │ ├── amazon_fields_map.json │ │ └── amazon_fields_map.py │ │ └── amazon_sp_api_settings │ │ ├── __init__.py │ │ ├── amazon_repository.py │ │ ├── amazon_sp_api.py │ │ ├── amazon_sp_api_settings.js │ │ ├── amazon_sp_api_settings.json │ │ ├── amazon_sp_api_settings.py │ │ ├── test_amazon_sp_api_settings.py │ │ └── test_data.json ├── boot.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── controllers │ ├── __init__.py │ ├── customer.py │ ├── inventory.py │ ├── scheduling.py │ ├── setting.py │ └── tests │ │ └── test_setting.py ├── ecommerce_integrations │ ├── __init__.py │ └── doctype │ │ ├── __init__.py │ │ ├── ecommerce_integration_log │ │ ├── __init__.py │ │ ├── ecommerce_integration_log.js │ │ ├── ecommerce_integration_log.json │ │ ├── ecommerce_integration_log.py │ │ ├── ecommerce_integration_log_list.js │ │ └── test_ecommerce_integration_log.py │ │ ├── ecommerce_item │ │ ├── __init__.py │ │ ├── ecommerce_item.js │ │ ├── ecommerce_item.json │ │ ├── ecommerce_item.py │ │ ├── ecommerce_item_list.js │ │ └── test_ecommerce_item.py │ │ └── pick_list_sales_order_details │ │ ├── __init__.py │ │ ├── pick_list_sales_order_details.json │ │ └── pick_list_sales_order_details.py ├── hooks.py ├── modules.txt ├── patches.txt ├── patches │ ├── set_default_amazon_item_fields_map.py │ └── update_shopify_custom_fields.py ├── public │ └── js │ │ ├── common │ │ └── ecommerce_transactions.js │ │ ├── shopify │ │ └── old_settings.js │ │ └── unicommerce │ │ ├── item.js │ │ ├── pick_list.js │ │ ├── sales_invoice.js │ │ ├── sales_order.js │ │ └── stock_entry.js ├── shopify │ ├── __init__.py │ ├── connection.py │ ├── constants.py │ ├── customer.py │ ├── doctype │ │ ├── __init__.py │ │ ├── shopify_setting │ │ │ ├── __init__.py │ │ │ ├── shopify_setting.js │ │ │ ├── shopify_setting.json │ │ │ ├── shopify_setting.py │ │ │ └── test_shopify_setting.py │ │ ├── shopify_tax_account │ │ │ ├── __init__.py │ │ │ ├── shopify_tax_account.json │ │ │ └── shopify_tax_account.py │ │ ├── shopify_warehouse_mapping │ │ │ ├── __init__.py │ │ │ ├── shopify_warehouse_mapping.json │ │ │ └── shopify_warehouse_mapping.py │ │ └── shopify_webhooks │ │ │ ├── __init__.py │ │ │ ├── shopify_webhooks.json │ │ │ └── shopify_webhooks.py │ ├── fulfillment.py │ ├── inventory.py │ ├── invoice.py │ ├── order.py │ ├── page │ │ ├── __init__.py │ │ └── shopify_import_products │ │ │ ├── __init__.py │ │ │ ├── shopify_import_products.js │ │ │ ├── shopify_import_products.json │ │ │ ├── shopify_import_products.py │ │ │ └── test_shopify_import_products.py │ ├── product.py │ ├── tests │ │ ├── __init__.py │ │ ├── data │ │ │ ├── bulk_products.json │ │ │ ├── single_product.json │ │ │ └── variant_product.json │ │ ├── test_connection.py │ │ ├── test_order.py │ │ ├── test_product.py │ │ └── utils.py │ └── utils.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── tests │ └── __init__.py ├── unicommerce │ ├── __init__.py │ ├── api_client.py │ ├── cancellation_and_returns.py │ ├── constants.py │ ├── customer.py │ ├── delivery_note.py │ ├── doctype │ │ ├── __init__.py │ │ ├── pick_list_sales_order_details │ │ │ ├── __init__.py │ │ │ ├── pick_list_sales_order_details.json │ │ │ └── pick_list_sales_order_details.py │ │ ├── unicommerce_channel │ │ │ ├── __init__.py │ │ │ ├── test_records.json │ │ │ ├── test_unicommerce_channel.py │ │ │ ├── unicommerce_channel.js │ │ │ ├── unicommerce_channel.json │ │ │ └── unicommerce_channel.py │ │ ├── unicommerce_manifest_item │ │ │ ├── __init__.py │ │ │ ├── unicommerce_manifest_item.json │ │ │ └── unicommerce_manifest_item.py │ │ ├── unicommerce_package_type │ │ │ ├── __init__.py │ │ │ ├── test_records.json │ │ │ ├── test_unicommerce_package_type.py │ │ │ ├── unicommerce_package_type.js │ │ │ ├── unicommerce_package_type.json │ │ │ └── unicommerce_package_type.py │ │ ├── unicommerce_settings │ │ │ ├── __init__.py │ │ │ ├── test_unicommerce_settings.py │ │ │ ├── unicommerce_settings.js │ │ │ ├── unicommerce_settings.json │ │ │ └── unicommerce_settings.py │ │ ├── unicommerce_shipment_manifest │ │ │ ├── __init__.py │ │ │ ├── test_unicommerce_shipment_manifest.py │ │ │ ├── unicommerce_shipment_manifest.js │ │ │ ├── unicommerce_shipment_manifest.json │ │ │ └── unicommerce_shipment_manifest.py │ │ ├── unicommerce_shipping_method │ │ │ ├── __init__.py │ │ │ ├── test_unicommerce_shipping_method.py │ │ │ ├── unicommerce_shipping_method.js │ │ │ ├── unicommerce_shipping_method.json │ │ │ └── unicommerce_shipping_method.py │ │ ├── unicommerce_shipping_provider │ │ │ ├── __init__.py │ │ │ ├── test_unicommerce_shipping_provider.py │ │ │ ├── unicommerce_shipping_provider.js │ │ │ ├── unicommerce_shipping_provider.json │ │ │ └── unicommerce_shipping_provider.py │ │ └── unicommerce_warehouses │ │ │ ├── __init__.py │ │ │ ├── unicommerce_warehouses.json │ │ │ └── unicommerce_warehouses.py │ ├── grn.py │ ├── inventory.py │ ├── invoice.py │ ├── order.py │ ├── pick_list.py │ ├── product.py │ ├── status_updater.py │ ├── tests │ │ ├── __init__.py │ │ ├── fixtures │ │ │ ├── authentication.json │ │ │ ├── bulk_inventory_response.json │ │ │ ├── create_invoice_and_assign_shipper.json │ │ │ ├── invoice-SDU0010.json │ │ │ ├── invoice-SDU0013.json │ │ │ ├── invoice-SDU0014.json │ │ │ ├── invoice-SDU0019.json │ │ │ ├── invoice-SDU0026.json │ │ │ ├── invoice_label_response.json │ │ │ ├── missing_item.json │ │ │ ├── order-SO5841.json │ │ │ ├── order-SO5905.json │ │ │ ├── order-SO5906.json │ │ │ ├── order-SO5907.json │ │ │ ├── order-SO6008-order.json │ │ │ ├── order-SO6009-order.json │ │ │ ├── order-SO6022-order.json │ │ │ ├── product-MC-100.json │ │ │ ├── simple_item.json │ │ │ └── so_search_results.json │ │ ├── test_client.py │ │ ├── test_customer.py │ │ ├── test_delivery_note.py │ │ ├── test_inventory.py │ │ ├── test_invoice.py │ │ ├── test_order.py │ │ ├── test_product.py │ │ ├── test_status.py │ │ └── utils.py │ └── utils.py ├── uninstall.py ├── utils │ ├── __init__.py │ ├── before_test.py │ ├── naming_series.py │ ├── price_list.py │ └── taxation.py └── zenoti │ ├── __init__.py │ ├── doctype │ ├── __init__.py │ ├── zenoti_category │ │ ├── __init__.py │ │ ├── test_zenoti_category.py │ │ ├── zenoti_category.js │ │ ├── zenoti_category.json │ │ └── zenoti_category.py │ ├── zenoti_center │ │ ├── __init__.py │ │ ├── test_zenoti_center.py │ │ ├── zenoti_center.js │ │ ├── zenoti_center.json │ │ └── zenoti_center.py │ ├── zenoti_error_logs │ │ ├── __init__.py │ │ ├── test_zenoti_error_logs.py │ │ ├── zenoti_error_logs.js │ │ ├── zenoti_error_logs.json │ │ └── zenoti_error_logs.py │ └── zenoti_settings │ │ ├── __init__.py │ │ ├── test_zenoti_settings.py │ │ ├── zenoti_settings.js │ │ ├── zenoti_settings.json │ │ └── zenoti_settings.py │ ├── purchase_transactions.py │ ├── sales_transactions.py │ ├── stock_reconciliation.py │ └── utils.py └── pyproject.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js}] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es2022": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "extends": "eslint:recommended", 11 | "rules": { 12 | "indent": "off", 13 | "brace-style": "off", 14 | "no-mixed-spaces-and-tabs": "off", 15 | "no-useless-escape": "off", 16 | "space-unary-ops": ["error", { "words": true }], 17 | "linebreak-style": "off", 18 | "quotes": ["off"], 19 | "semi": "off", 20 | "camelcase": "off", 21 | "no-unused-vars": "off", 22 | "no-console": ["warn"], 23 | "no-extra-boolean-cast": ["off"], 24 | "no-control-regex": ["off"] 25 | }, 26 | "root": true, 27 | "globals": { 28 | "frappe": true, 29 | "Vue": true, 30 | "SetVueGlobals": true, 31 | "erpnext": true, 32 | "hub": true, 33 | "$": true, 34 | "jQuery": true, 35 | "moment": true, 36 | "hljs": true, 37 | "Awesomplete": true, 38 | "CalHeatMap": true, 39 | "Sortable": true, 40 | "Showdown": true, 41 | "Taggle": true, 42 | "Gantt": true, 43 | "Slick": true, 44 | "PhotoSwipe": true, 45 | "PhotoSwipeUI_Default": true, 46 | "fluxify": true, 47 | "io": true, 48 | "c3": true, 49 | "__": true, 50 | "_p": true, 51 | "_f": true, 52 | "repl": true, 53 | "Class": true, 54 | "locals": true, 55 | "cint": true, 56 | "cstr": true, 57 | "cur_frm": true, 58 | "cur_dialog": true, 59 | "cur_page": true, 60 | "cur_list": true, 61 | "cur_tree": true, 62 | "cur_pos": true, 63 | "msg_dialog": true, 64 | "is_null": true, 65 | "in_list": true, 66 | "has_common": true, 67 | "posthog": true, 68 | "has_words": true, 69 | "validate_email": true, 70 | "open_web_template_values_editor": true, 71 | "get_number_format": true, 72 | "format_number": true, 73 | "format_currency": true, 74 | "round_based_on_smallest_currency_fraction": true, 75 | "roundNumber": true, 76 | "comment_when": true, 77 | "replace_newlines": true, 78 | "open_url_post": true, 79 | "toTitle": true, 80 | "lstrip": true, 81 | "strip": true, 82 | "strip_html": true, 83 | "replace_all": true, 84 | "flt": true, 85 | "precision": true, 86 | "md5": true, 87 | "CREATE": true, 88 | "AMEND": true, 89 | "CANCEL": true, 90 | "copy_dict": true, 91 | "get_number_format_info": true, 92 | "print_table": true, 93 | "Layout": true, 94 | "web_form_settings": true, 95 | "$c": true, 96 | "$a": true, 97 | "$i": true, 98 | "$bg": true, 99 | "$y": true, 100 | "$c_obj": true, 101 | "$c_obj_csv": true, 102 | "refresh_many": true, 103 | "refresh_field": true, 104 | "toggle_field": true, 105 | "get_field_obj": true, 106 | "get_query_params": true, 107 | "unhide_field": true, 108 | "hide_field": true, 109 | "set_field_options": true, 110 | "getCookie": true, 111 | "getCookies": true, 112 | "get_url_arg": true, 113 | "get_server_fields": true, 114 | "set_multiple": true, 115 | "QUnit": true, 116 | "Chart": true, 117 | "Cypress": true, 118 | "cy": true, 119 | "describe": true, 120 | "expect": true, 121 | "it": true, 122 | "context": true, 123 | "before": true, 124 | "beforeEach": true, 125 | "onScan": true, 126 | "extend_cscript": true, 127 | "localforage": true, 128 | "Plaid": true 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | B007, 4 | B950, 5 | E101, 6 | E111, 7 | E114, 8 | E116, 9 | E117, 10 | E121, 11 | E122, 12 | E123, 13 | E124, 14 | E125, 15 | E126, 16 | E127, 17 | E128, 18 | E131, 19 | E201, 20 | E202, 21 | E203, 22 | E211, 23 | E221, 24 | E222, 25 | E223, 26 | E224, 27 | E225, 28 | E226, 29 | E228, 30 | E231, 31 | E241, 32 | E242, 33 | E251, 34 | E261, 35 | E262, 36 | E265, 37 | E266, 38 | E271, 39 | E272, 40 | E273, 41 | E274, 42 | E301, 43 | E302, 44 | E303, 45 | E305, 46 | E306, 47 | E401, 48 | E402, 49 | E501, 50 | E502, 51 | E701, 52 | E702, 53 | E703, 54 | E741, 55 | F401, 56 | F403, 57 | W191, 58 | W291, 59 | W292, 60 | W293, 61 | W391, 62 | W503, 63 | W504, 64 | 65 | 66 | max-line-length = 200 67 | exclude=.github/helper/semgrep_rules 68 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Since version 2.23 (released in August 2019), git-blame has a feature 2 | # to ignore or bypass certain commits. 3 | # 4 | # This file contains a list of commits that are not likely what you 5 | # are looking for in a blame, such as mass reformatting or renaming. 6 | # You can set this file as a default ignore file for blame by running 7 | # the following command. 8 | # 9 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 10 | 11 | # black formatting 12 | adf06c3a488149774d5ad6d88d80a2d461075f58 13 | deb9f9f61cfddc491e71a7cd243893a5a9f305dc 14 | 15 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence. 6 | 7 | * @ankush 8 | 9 | ecommerce_integrations/zenoti/ @AfshanKhan 10 | -------------------------------------------------------------------------------- /.github/helper/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd ~ || exit 6 | 7 | sudo apt update 8 | sudo apt remove mysql-server mysql-client 9 | sudo apt install libcups2-dev redis-server mariadb-client 10 | 11 | pip install frappe-bench 12 | 13 | githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} 14 | frappeuser=${FRAPPE_USER:-"frappe"} 15 | frappebranch=${FRAPPE_BRANCH:-$githubbranch} 16 | erpnextbranch=${ERPNEXT_BRANCH:-$githubbranch} 17 | paymentsbranch=${PAYMENTS_BRANCH:-${githubbranch%"-hotfix"}} 18 | 19 | git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1 20 | bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench 21 | 22 | mkdir ~/frappe-bench/sites/test_site 23 | cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ 24 | 25 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" 26 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 27 | 28 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" 29 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" 30 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" 31 | 32 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" 33 | 34 | install_whktml() { 35 | wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz 36 | tar -xf /tmp/wkhtmltox.tar.xz -C /tmp 37 | sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf 38 | sudo chmod o+x /usr/local/bin/wkhtmltopdf 39 | } 40 | install_whktml & 41 | 42 | cd ~/frappe-bench || exit 43 | 44 | sed -i 's/watch:/# watch:/g' Procfile 45 | sed -i 's/schedule:/# schedule:/g' Procfile 46 | sed -i 's/socketio:/# socketio:/g' Procfile 47 | sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile 48 | 49 | bench get-app "https://github.com/${frappeuser}/payments" --branch "$paymentsbranch" 50 | bench get-app "https://github.com/${frappeuser}/erpnext" --branch "$erpnextbranch" --resolve-deps 51 | bench get-app ecommerce_integrations "${GITHUB_WORKSPACE}" 52 | bench setup requirements --dev 53 | 54 | bench start &>> ~/frappe-bench/bench_start.log & 55 | CI=Yes bench build --app frappe & 56 | bench --site test_site reinstall --yes 57 | 58 | bench --verbose --site test_site install-app ecommerce_integrations 59 | -------------------------------------------------------------------------------- /.github/helper/site_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_host": "127.0.0.1", 3 | "db_port": 3306, 4 | "db_name": "test_frappe", 5 | "db_password": "test_frappe", 6 | "auto_email_id": "test@example.com", 7 | "mail_server": "smtp.example.com", 8 | "mail_login": "test@example.com", 9 | "mail_password": "test", 10 | "admin_password": "admin", 11 | "root_login": "root", 12 | "root_password": "root", 13 | "host_name": "http://test_site:8000", 14 | "install_apps": ["payments", "erpnext"], 15 | "throttle_user_limit": 100 16 | } 17 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # Any python files modifed but no test files modified 2 | needs-tests: 3 | - any: ['hrms/**/*.py'] 4 | all: ['!hrms/**/test*.py'] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.css" 7 | - "**.js" 8 | - "**.md" 9 | - "**.html" 10 | - "**.csv" 11 | schedule: 12 | # Run everday at midnight UTC / 5:30 IST 13 | - cron: "0 0 * * *" 14 | 15 | concurrency: 16 | group: develop-${{ github.event.number }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | tests: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 60 23 | env: 24 | NODE_ENV: "production" 25 | WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} 26 | 27 | strategy: 28 | fail-fast: false 29 | 30 | matrix: 31 | container: [1] 32 | 33 | name: Python Unit Tests 34 | 35 | services: 36 | mysql: 37 | image: mariadb:10.6 38 | env: 39 | MARIADB_ROOT_PASSWORD: 'root' 40 | ports: 41 | - 3306:3306 42 | options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 43 | 44 | steps: 45 | - name: Clone 46 | uses: actions/checkout@v2 47 | 48 | - name: Setup Python 49 | uses: actions/setup-python@v2 50 | with: 51 | python-version: '3.10' 52 | 53 | - name: Check for valid Python & Merge Conflicts 54 | run: | 55 | python -m compileall -f "${GITHUB_WORKSPACE}" 56 | if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" 57 | then echo "Found merge conflicts" 58 | exit 1 59 | fi 60 | 61 | - name: Setup Node 62 | uses: actions/setup-node@v2 63 | with: 64 | node-version: 18 65 | check-latest: true 66 | 67 | - name: Add to Hosts 68 | run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts 69 | 70 | - name: Cache pip 71 | uses: actions/cache@v4 72 | with: 73 | path: ~/.cache/pip 74 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} 75 | restore-keys: | 76 | ${{ runner.os }}-pip- 77 | ${{ runner.os }}- 78 | 79 | - name: Cache node modules 80 | uses: actions/cache@v4 81 | env: 82 | cache-name: cache-node-modules 83 | with: 84 | path: ~/.npm 85 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 86 | restore-keys: | 87 | ${{ runner.os }}-build-${{ env.cache-name }}- 88 | ${{ runner.os }}-build- 89 | ${{ runner.os }}- 90 | 91 | - name: Get yarn cache directory path 92 | id: yarn-cache-dir-path 93 | run: echo "::set-output name=dir::$(yarn cache dir)" 94 | 95 | - uses: actions/cache@v4 96 | id: yarn-cache 97 | with: 98 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 99 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 100 | restore-keys: | 101 | ${{ runner.os }}-yarn- 102 | 103 | - name: Install 104 | run: | 105 | bash ${GITHUB_WORKSPACE}/.github/helper/install.sh 106 | env: 107 | FRAPPE_USER: ${{ github.event.inputs.user }} 108 | FRAPPE_BRANCH: ${{ github.event.inputs.branch }} 109 | 110 | - name: Run Tests 111 | run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app ecommerce_integrations --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} 112 | env: 113 | TYPE: server 114 | CAPTURE_COVERAGE: ${{ github.event_name != 'pull_request' }} 115 | 116 | - name: Upload coverage data 117 | uses: actions/upload-artifact@v4 118 | if: github.event_name != 'pull_request' 119 | with: 120 | name: coverage-${{ matrix.container }} 121 | path: /home/runner/frappe-bench/sites/coverage.xml 122 | 123 | coverage: 124 | name: Coverage Wrap Up 125 | needs: tests 126 | runs-on: ubuntu-latest 127 | if: ${{ github.event_name != 'pull_request' }} 128 | steps: 129 | - name: Clone 130 | uses: actions/checkout@v4 131 | 132 | - name: Download artifacts 133 | uses: actions/download-artifact@v4 134 | 135 | - name: Upload coverage data 136 | uses: codecov/codecov-action@v4 137 | with: 138 | token: ${{ secrets.CODECOV_TOKEN }} 139 | fail_ci_if_error: true 140 | verbose: true 141 | -------------------------------------------------------------------------------- /.github/workflows/labeller.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | pull_request_target: 4 | types: [opened, reopened] 5 | 6 | jobs: 7 | triage: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/labeler@v4 11 | with: 12 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 13 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | pull_request: { } 5 | 6 | jobs: 7 | 8 | linters: 9 | name: linters 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | 19 | - name: Install and Run Pre-commit 20 | uses: pre-commit/action@v3.0.0 21 | 22 | - name: Download Semgrep rules 23 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 24 | 25 | - uses: returntocorp/semgrep-action@v1 26 | env: 27 | SEMGREP_TIMEOUT: 120 28 | with: 29 | config: >- 30 | r/python.lang.correctness 31 | ./frappe-semgrep-rules/rules 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Generate Semantic Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js v14 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 18 19 | - name: Setup dependencies 20 | run: | 21 | npm install @semantic-release/git @semantic-release/exec --no-save 22 | - name: Create Release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | GIT_AUTHOR_NAME: "Frappe PR Bot" 26 | GIT_AUTHOR_EMAIL: "developers@frappe.io" 27 | GIT_COMMITTER_NAME: "Frappe PR Bot" 28 | GIT_COMMITTER_EMAIL: "developers@frappe.io" 29 | run: npx semantic-release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | ecommerce_integrations/docs/current 7 | 8 | .aider* 9 | .helix -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'node_modules|.git' 2 | default_stages: [commit] 3 | fail_fast: false 4 | 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.3.0 9 | hooks: 10 | - id: trailing-whitespace 11 | files: "ecommerce_integrations.*" 12 | exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" 13 | - id: check-yaml 14 | - id: no-commit-to-branch 15 | args: ['--branch', 'develop'] 16 | - id: check-merge-conflict 17 | - id: check-ast 18 | - id: check-json 19 | - id: check-toml 20 | - id: check-yaml 21 | - id: debug-statements 22 | 23 | - repo: https://github.com/pre-commit/mirrors-prettier 24 | rev: v2.7.1 25 | hooks: 26 | - id: prettier 27 | types_or: [javascript, vue, scss] 28 | # Ignore any files that might contain jinja / bundles 29 | exclude: | 30 | (?x)^( 31 | ecommerce_integrations/public/dist/.*| 32 | cypress/.*| 33 | .*node_modules.*| 34 | .*boilerplate.*| 35 | ecommerce_integrations/templates/includes/.* 36 | )$ 37 | 38 | - repo: https://github.com/pre-commit/mirrors-eslint 39 | rev: v8.44.0 40 | hooks: 41 | - id: eslint 42 | types_or: [javascript] 43 | args: ['--quiet'] 44 | # Ignore any files that might contain jinja / bundles 45 | exclude: | 46 | (?x)^( 47 | ecommerce_integrations/public/dist/.*| 48 | cypress/.*| 49 | .*node_modules.*| 50 | .*boilerplate.*| 51 | ecommerce_integrations/templates/includes/.* 52 | )$ 53 | 54 | - repo: https://github.com/astral-sh/ruff-pre-commit 55 | rev: v0.2.0 56 | hooks: 57 | - id: ruff 58 | name: "Run ruff import sorter" 59 | args: ["--select=I", "--fix"] 60 | 61 | - id: ruff 62 | name: "Run ruff linter" 63 | 64 | - id: ruff-format 65 | name: "Run ruff formatter" 66 | 67 | ci: 68 | autoupdate_schedule: weekly 69 | skip: [] 70 | submodules: false 71 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", { 5 | "preset": "angular", 6 | "releaseRules": [ 7 | {"breaking": true, "release": false} 8 | ] 9 | }, 10 | "@semantic-release/release-notes-generator", 11 | [ 12 | "@semantic-release/exec", { 13 | "prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" ecommerce_integrations/__init__.py' 14 | } 15 | ], 16 | [ 17 | "@semantic-release/git", { 18 | "assets": ["ecommerce_integrations/__init__.py"], 19 | "message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}" 20 | } 21 | ], 22 | "@semantic-release/github" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | 2 | # ignore rules 3 | .github/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Ecommerce Integrations for ERPNext

4 | 5 | [![CI](https://github.com/frappe/ecommerce_integrations/actions/workflows/ci.yml/badge.svg)](https://github.com/frappe/ecommerce_integrations/actions/workflows/ci.yml) 6 | 7 |
8 | 9 | ### Currently supported integrations: 10 | 11 | - Shopify - [User documentation](https://docs.erpnext.com/docs/v13/user/manual/en/erpnext_integration/shopify_integration) 12 | - Unicommerce - [User Documentation](https://docs.erpnext.com/docs/v13/user/manual/en/erpnext_integration/unicommerce_integration) 13 | - Zenoti - [User documentation](https://docs.erpnext.com/docs/v13/user/manual/en/erpnext_integration/zenoti_integration) 14 | - Amazon - [User documentation](https://docs.erpnext.com/docs/v13/user/manual/en/erpnext_integration/amazon_integration) 15 | 16 | 17 | ### Installation 18 | 19 | - Frappe Cloud Users can install [from Marketplace](https://frappecloud.com/marketplace/apps/ecommerce_integrations). 20 | - Self Hosted users can install using Bench: 21 | 22 | ```bash 23 | # Production installation 24 | $ bench get-app ecommerce_integrations --branch main 25 | 26 | # OR development install 27 | $ bench get-app ecommerce_integrations --branch develop 28 | 29 | # install on site 30 | $ bench --site sitename install-app ecommerce_integrations 31 | ``` 32 | 33 | After installation follow user documentation for each integration to set it up. 34 | 35 | ### Contributing 36 | 37 | - Follow general [ERPNext contribution guideline](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines) 38 | - Send PRs to `develop` branch only. 39 | 40 | ### Development setup 41 | 42 | - Enable developer mode. 43 | - If you want to use a tunnel for local development. Set `localtunnel_url` parameter in your site_config file with ngrok / localtunnel URL. This will be used in most places to register webhooks. Likewise, use this parameter wherever you're sending current site URL to integrations in development mode. 44 | 45 | 46 | #### License 47 | 48 | GNU GPL v3.0 49 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: auto 9 | threshold: 0.5% 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.17.0" 2 | -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/amazon/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/amazon/doctype/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/amazon_fields_map/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/amazon/doctype/amazon_fields_map/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/amazon_fields_map/amazon_fields_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2023-07-28 21:59:03.809862", 5 | "default_view": "List", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "amazon_field", 11 | "column_break_eww7", 12 | "item_field", 13 | "use_to_find_item_code" 14 | ], 15 | "fields": [ 16 | { 17 | "fieldname": "amazon_field", 18 | "fieldtype": "Data", 19 | "in_list_view": 1, 20 | "label": "Amazon Field", 21 | "read_only": 1, 22 | "reqd": 1, 23 | "unique": 1 24 | }, 25 | { 26 | "fieldname": "item_field", 27 | "fieldtype": "Data", 28 | "in_list_view": 1, 29 | "label": "Item Field", 30 | "mandatory_depends_on": "eval: doc.use_to_find_item_code", 31 | "unique": 1 32 | }, 33 | { 34 | "fieldname": "column_break_eww7", 35 | "fieldtype": "Column Break" 36 | }, 37 | { 38 | "default": "0", 39 | "fieldname": "use_to_find_item_code", 40 | "fieldtype": "Check", 41 | "in_list_view": 1, 42 | "label": "Use To Find Item Code" 43 | } 44 | ], 45 | "index_web_pages_for_search": 1, 46 | "istable": 1, 47 | "links": [], 48 | "modified": "2023-07-29 09:31:42.507679", 49 | "modified_by": "Administrator", 50 | "module": "Amazon", 51 | "name": "Amazon Fields Map", 52 | "owner": "Administrator", 53 | "permissions": [], 54 | "sort_field": "modified", 55 | "sort_order": "DESC", 56 | "states": [] 57 | } -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/amazon_fields_map/amazon_fields_map.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class AmazonFieldsMap(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/amazon_sp_api_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/amazon/doctype/amazon_sp_api_settings/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/amazon/doctype/amazon_sp_api_settings/amazon_sp_api_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Amazon SP API Settings", { 5 | refresh(frm) { 6 | if (frm.doc.__islocal && !frm.doc.amazon_fields_map) { 7 | frm.trigger("set_default_fields_map"); 8 | } 9 | frm.trigger("set_queries"); 10 | frm.set_df_property("amazon_fields_map", "cannot_add_rows", true); 11 | frm.set_df_property("amazon_fields_map", "cannot_delete_rows", true); 12 | }, 13 | 14 | set_default_fields_map(frm) { 15 | frappe.call({ 16 | method: "set_default_fields_map", 17 | doc: frm.doc, 18 | callback: (r) => { 19 | if (!r.exc) refresh_field("amazon_fields_map"); 20 | }, 21 | }); 22 | }, 23 | 24 | set_queries(frm) { 25 | frm.set_query("warehouse", () => { 26 | return { 27 | filters: { 28 | is_group: 0, 29 | company: frm.doc.company, 30 | }, 31 | }; 32 | }); 33 | 34 | frm.set_query("market_place_account_group", () => { 35 | return { 36 | filters: { 37 | is_group: 1, 38 | company: frm.doc.company, 39 | }, 40 | }; 41 | }); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /ecommerce_integrations/boot.py: -------------------------------------------------------------------------------- 1 | from ecommerce_integrations.shopify.constants import OLD_SETTINGS_DOCTYPE 2 | 3 | 4 | def boot_session(bootinfo): 5 | """Don't show old doctypes after enabling new ones.""" 6 | try: 7 | bootinfo.single_types.remove(OLD_SETTINGS_DOCTYPE) 8 | except ValueError: 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/config/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | 4 | def get_data(): 5 | return [ 6 | { 7 | "module_name": "Ecommerce Integrations", 8 | "color": "grey", 9 | "icon": "octicon octicon-file-directory", 10 | "type": "module", 11 | "label": _("Ecommerce Integrations"), 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /ecommerce_integrations/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/ecommerce_integrations" 6 | # docs_base_url = "https://[org_name].github.io/ecommerce_integrations" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | 11 | def get_context(context): 12 | context.brand_html = "Ecommerce Integrations" 13 | -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/controllers/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/customer.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.utils.nestedset import get_root_of 4 | 5 | 6 | class EcommerceCustomer: 7 | def __init__(self, customer_id: str, customer_id_field: str, integration: str): 8 | self.customer_id = customer_id 9 | self.customer_id_field = customer_id_field 10 | self.integration = integration 11 | 12 | def is_synced(self) -> bool: 13 | """Check if customer on Ecommerce site is synced with ERPNext""" 14 | 15 | return bool(frappe.db.exists("Customer", {self.customer_id_field: self.customer_id})) 16 | 17 | def get_customer_doc(self): 18 | """Get ERPNext customer document.""" 19 | if self.is_synced(): 20 | return frappe.get_last_doc("Customer", {self.customer_id_field: self.customer_id}) 21 | else: 22 | raise frappe.DoesNotExistError() 23 | 24 | def sync_customer(self, customer_name: str, customer_group: str) -> None: 25 | """Create customer in ERPNext if one does not exist already.""" 26 | customer = frappe.get_doc( 27 | { 28 | "doctype": "Customer", 29 | "name": self.customer_id, 30 | self.customer_id_field: self.customer_id, 31 | "customer_name": customer_name, 32 | "customer_group": customer_group, 33 | "territory": get_root_of("Territory"), 34 | "customer_type": _("Individual"), 35 | } 36 | ) 37 | 38 | customer.flags.ignore_mandatory = True 39 | customer.insert(ignore_permissions=True) 40 | 41 | def get_customer_address_doc(self, address_type: str): 42 | try: 43 | customer = self.get_customer_doc().name 44 | addresses = frappe.get_all("Address", {"link_name": customer, "address_type": address_type}) 45 | if addresses: 46 | address = frappe.get_last_doc("Address", {"name": addresses[0].name}) 47 | return address 48 | except frappe.DoesNotExistError: 49 | return None 50 | 51 | def create_customer_address(self, address: dict[str, str]) -> None: 52 | """Create address from dictionary containing fields used in Address doctype of ERPNext.""" 53 | 54 | customer_doc = self.get_customer_doc() 55 | 56 | frappe.get_doc( 57 | { 58 | "doctype": "Address", 59 | **address, 60 | "links": [{"link_doctype": "Customer", "link_name": customer_doc.name}], 61 | } 62 | ).insert(ignore_mandatory=True) 63 | 64 | def create_customer_contact(self, contact: dict[str, str]) -> None: 65 | """Create contact from dictionary containing fields used in Address doctype of ERPNext.""" 66 | 67 | customer_doc = self.get_customer_doc() 68 | 69 | frappe.get_doc( 70 | { 71 | "doctype": "Contact", 72 | **contact, 73 | "links": [{"link_doctype": "Customer", "link_name": customer_doc.name}], 74 | } 75 | ).insert(ignore_mandatory=True) 76 | -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/inventory.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _dict 3 | from frappe.query_builder import DocType 4 | from frappe.query_builder.functions import Max, Sum 5 | from frappe.utils import now 6 | from frappe.utils.nestedset import get_descendants_of 7 | 8 | 9 | def get_inventory_levels(warehouses: tuple[str], integration: str) -> list[_dict]: 10 | """ 11 | Get list of dict containing items for which the inventory needs to be updated on Integeration. 12 | 13 | New inventory levels are identified by checking Bin modification timestamp, 14 | so ensure that if you sync the inventory with integration, you have also 15 | updated `inventory_synced_on` field in related Ecommerce Item. 16 | 17 | returns: list of _dict containing ecom_item, item_code, integration_item_code, variant_id, actual_qty, warehouse, reserved_qty 18 | """ 19 | EcommerceItem = DocType("Ecommerce Item") 20 | Bin = DocType("Bin") 21 | 22 | query = ( 23 | frappe.qb.from_(EcommerceItem) 24 | .join(Bin) 25 | .on(EcommerceItem.erpnext_item_code == Bin.item_code) 26 | .select( 27 | EcommerceItem.name.as_("ecom_item"), 28 | Bin.item_code.as_("item_code"), 29 | EcommerceItem.integration_item_code, 30 | EcommerceItem.variant_id, 31 | Bin.actual_qty, 32 | Bin.warehouse, 33 | Bin.reserved_qty, 34 | ) 35 | .where( 36 | (Bin.warehouse.isin(warehouses)) 37 | & (Bin.modified > EcommerceItem.inventory_synced_on) 38 | & (EcommerceItem.integration == integration) 39 | ) 40 | ) 41 | 42 | return query.run(as_dict=1) 43 | 44 | 45 | def get_inventory_levels_of_group_warehouse(warehouse: str, integration: str): 46 | """Get updated inventory for a single group warehouse. 47 | 48 | If warehouse mapping is done to a group warehouse then consolidation of all 49 | leaf warehouses is required""" 50 | 51 | child_warehouse = get_descendants_of("Warehouse", warehouse) 52 | all_warehouses = (*tuple(child_warehouse), warehouse) 53 | 54 | EcommerceItem = DocType("Ecommerce Item") 55 | Bin = DocType("Bin") 56 | 57 | query = ( 58 | frappe.qb.from_(EcommerceItem) 59 | .join(Bin) 60 | .on(EcommerceItem.erpnext_item_code == Bin.item_code) 61 | .select( 62 | EcommerceItem.name.as_("ecom_item"), 63 | Bin.item_code.as_("item_code"), 64 | EcommerceItem.integration_item_code, 65 | EcommerceItem.variant_id, 66 | Sum(Bin.actual_qty).as_("actual_qty"), 67 | Sum(Bin.reserved_qty).as_("reserved_qty"), 68 | Max(Bin.modified).as_("last_updated"), 69 | Max(EcommerceItem.inventory_synced_on).as_("last_synced"), 70 | ) 71 | .where((Bin.warehouse.isin(all_warehouses)) & (EcommerceItem.integration == integration)) 72 | .groupby(EcommerceItem.erpnext_item_code) 73 | .having(Max(Bin.modified) > Max(EcommerceItem.inventory_synced_on)) 74 | ) 75 | 76 | data = query.run(as_dict=1) 77 | 78 | # add warehouse as group warehouse for sending to integrations 79 | for item in data: 80 | item.warehouse = warehouse 81 | 82 | return data 83 | 84 | 85 | def update_inventory_sync_status(ecommerce_item, time=None): 86 | """Update `inventory_synced_on` timestamp to specified time or current time (if not specified). 87 | 88 | After updating inventory levels to any integration, the Ecommerce Item should know about when it was last updated. 89 | """ 90 | if time is None: 91 | time = now() 92 | 93 | frappe.db.set_value("Ecommerce Item", ecommerce_item, "inventory_synced_on", time) 94 | -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/scheduling.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import add_to_date, cint, get_datetime, now 3 | 4 | 5 | def need_to_run(setting, interval_field, timestamp_field) -> bool: 6 | """A utility function to make "configurable" scheduled events. 7 | 8 | If timestamp_field is older than current_time - inveterval_field then this function updates the timestamp_field to `now()` and returns True, 9 | otherwise False. 10 | This can be used to make "configurable" scheduled events. 11 | Assumptions: 12 | - interval_field is in minutes. 13 | - timestamp field is datetime field. 14 | - This function is called from scheuled job with less frequency than lowest interval_field. Ideally, every minute. 15 | """ 16 | interval = frappe.db.get_single_value(setting, interval_field, cache=True) 17 | last_run = frappe.db.get_single_value(setting, timestamp_field) 18 | 19 | if last_run and get_datetime() < get_datetime(add_to_date(last_run, minutes=cint(interval, default=10))): 20 | return False 21 | 22 | frappe.db.set_value(setting, None, timestamp_field, now(), update_modified=False) 23 | return True 24 | -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/setting.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | from frappe.model.document import Document 4 | 5 | ERPNextWarehouse = NewType("ERPNextWarehouse", str) 6 | IntegrationWarehouse = NewType("IntegrationWarehouse", str) 7 | 8 | 9 | class SettingController(Document): 10 | def is_enabled(self) -> bool: 11 | """Check if integration is enabled or not.""" 12 | raise NotImplementedError() 13 | 14 | def get_erpnext_warehouses(self) -> list[ERPNextWarehouse]: 15 | raise NotImplementedError() 16 | 17 | def get_erpnext_to_integration_wh_mapping(self) -> dict[ERPNextWarehouse, IntegrationWarehouse]: 18 | raise NotImplementedError() 19 | 20 | def get_integration_to_erpnext_wh_mapping(self) -> dict[IntegrationWarehouse, ERPNextWarehouse]: 21 | raise NotImplementedError() 22 | -------------------------------------------------------------------------------- /ecommerce_integrations/controllers/tests/test_setting.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestShopifySetting(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/ecommerce_integrations/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/ecommerce_integrations/doctype/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Ecommerce Integration Log", { 5 | refresh: function (frm) { 6 | if (frm.doc.request_data && frm.doc.status == "Error") { 7 | frm.add_custom_button(__("Retry"), function () { 8 | frappe.call({ 9 | method: "ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log.resync", 10 | args: { 11 | method: frm.doc.method, 12 | name: frm.doc.name, 13 | request_data: frm.doc.request_data, 14 | }, 15 | callback: function (r) { 16 | frappe.msgprint(__("Reattempting to sync")); 17 | }, 18 | }); 19 | }).addClass("btn-primary"); 20 | } 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-04-15 12:29:03.541492", 4 | "doctype": "DocType", 5 | "document_type": "System", 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "title", 9 | "integration", 10 | "status", 11 | "method", 12 | "message", 13 | "traceback", 14 | "request_data", 15 | "response_data" 16 | ], 17 | "fields": [ 18 | { 19 | "fieldname": "title", 20 | "fieldtype": "Data", 21 | "hidden": 1, 22 | "label": "Title", 23 | "read_only": 1 24 | }, 25 | { 26 | "fieldname": "integration", 27 | "fieldtype": "Link", 28 | "in_list_view": 1, 29 | "in_standard_filter": 1, 30 | "label": "Integration", 31 | "options": "Module Def" 32 | }, 33 | { 34 | "default": "Queued", 35 | "fieldname": "status", 36 | "fieldtype": "Data", 37 | "label": "Status", 38 | "read_only": 1 39 | }, 40 | { 41 | "fieldname": "method", 42 | "fieldtype": "Small Text", 43 | "label": "Method", 44 | "read_only": 1 45 | }, 46 | { 47 | "fieldname": "message", 48 | "fieldtype": "Code", 49 | "label": "Message", 50 | "read_only": 1 51 | }, 52 | { 53 | "fieldname": "traceback", 54 | "fieldtype": "Code", 55 | "label": "Traceback", 56 | "read_only": 1 57 | }, 58 | { 59 | "fieldname": "request_data", 60 | "fieldtype": "Code", 61 | "label": "Request Data", 62 | "read_only": 1 63 | }, 64 | { 65 | "fieldname": "response_data", 66 | "fieldtype": "Code", 67 | "label": "Response Data", 68 | "read_only": 1 69 | } 70 | ], 71 | "in_create": 1, 72 | "links": [], 73 | "modified": "2021-05-28 16:06:49.008875", 74 | "modified_by": "Administrator", 75 | "module": "Ecommerce Integrations", 76 | "name": "Ecommerce Integration Log", 77 | "owner": "Administrator", 78 | "permissions": [ 79 | { 80 | "create": 1, 81 | "delete": 1, 82 | "email": 1, 83 | "export": 1, 84 | "print": 1, 85 | "read": 1, 86 | "report": 1, 87 | "role": "Administrator", 88 | "share": 1, 89 | "write": 1 90 | }, 91 | { 92 | "create": 1, 93 | "delete": 1, 94 | "email": 1, 95 | "export": 1, 96 | "print": 1, 97 | "read": 1, 98 | "report": 1, 99 | "role": "System Manager", 100 | "share": 1, 101 | "write": 1 102 | } 103 | ], 104 | "sort_field": "modified", 105 | "sort_order": "DESC", 106 | "title_field": "title" 107 | } -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | import json 5 | 6 | import frappe 7 | from frappe import _ 8 | from frappe.model.document import Document 9 | from frappe.query_builder import Interval 10 | from frappe.query_builder.functions import Now 11 | from frappe.utils import strip_html 12 | from frappe.utils.data import cstr 13 | 14 | 15 | class EcommerceIntegrationLog(Document): 16 | def validate(self): 17 | self._set_title() 18 | 19 | def _set_title(self): 20 | title = None 21 | if self.message != "None": 22 | title = self.message 23 | 24 | if not title and self.method: 25 | method = self.method.split(".")[-1] 26 | title = method 27 | 28 | if title: 29 | title = strip_html(title) 30 | self.title = title if len(title) < 100 else title[:100] + "..." 31 | 32 | @staticmethod 33 | def clear_old_logs(days=90): 34 | table = frappe.qb.DocType("Ecommerce Integration Log") 35 | frappe.db.delete( 36 | table, filters=(table.modified < (Now() - Interval(days=days))) & (table.status == "Success") 37 | ) 38 | 39 | 40 | def create_log( 41 | module_def=None, 42 | status="Queued", 43 | response_data=None, 44 | request_data=None, 45 | exception=None, 46 | rollback=False, 47 | method=None, 48 | message=None, 49 | make_new=False, 50 | ): 51 | make_new = make_new or not bool(frappe.flags.request_id) 52 | 53 | if rollback: 54 | frappe.db.rollback() 55 | 56 | if make_new: 57 | log = frappe.get_doc({"doctype": "Ecommerce Integration Log", "integration": cstr(module_def)}) 58 | log.insert(ignore_permissions=True) 59 | else: 60 | log = frappe.get_doc("Ecommerce Integration Log", frappe.flags.request_id) 61 | 62 | if response_data and not isinstance(response_data, str): 63 | response_data = json.dumps(response_data, sort_keys=True, indent=4) 64 | 65 | if request_data and not isinstance(request_data, str): 66 | request_data = json.dumps(request_data, sort_keys=True, indent=4) 67 | 68 | log.message = message or _get_message(exception) 69 | log.method = log.method or method 70 | log.response_data = response_data or log.response_data 71 | log.request_data = request_data or log.request_data 72 | log.traceback = log.traceback or frappe.get_traceback() 73 | log.status = status 74 | log.save(ignore_permissions=True) 75 | 76 | frappe.db.commit() 77 | 78 | return log 79 | 80 | 81 | def _get_message(exception): 82 | if hasattr(exception, "message"): 83 | return strip_html(exception.message) 84 | elif hasattr(exception, "__str__"): 85 | return strip_html(exception.__str__()) 86 | else: 87 | return _("Something went wrong while syncing") 88 | 89 | 90 | @frappe.whitelist() 91 | def resync(method, name, request_data): 92 | _retry_job(name) 93 | 94 | 95 | def _retry_job(job: str): 96 | frappe.only_for("System Manager") 97 | 98 | doc = frappe.get_doc("Ecommerce Integration Log", job) 99 | if not doc.method.startswith("ecommerce_integrations.") or doc.status != "Error": 100 | return 101 | 102 | doc.db_set("status", "Queued", update_modified=False) 103 | doc.db_set("traceback", "", update_modified=False) 104 | 105 | frappe.enqueue( 106 | method=doc.method, 107 | queue="short", 108 | timeout=300, 109 | is_async=True, 110 | payload=json.loads(doc.request_data), 111 | request_id=doc.name, 112 | enqueue_after_commit=True, 113 | ) 114 | 115 | 116 | @frappe.whitelist() 117 | def bulk_retry(names): 118 | if isinstance(names, str): 119 | names = json.loads(names) 120 | for name in names: 121 | _retry_job(name) 122 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log_list.js: -------------------------------------------------------------------------------- 1 | frappe.listview_settings["Ecommerce Integration Log"] = { 2 | hide_name_column: true, 3 | add_fields: ["status"], 4 | get_indicator: function (doc) { 5 | if (doc.status === "Success") { 6 | return [__("Success"), "green", "status,=,Success"]; 7 | } else if (doc.status === "Error") { 8 | return [__("Error"), "red", "status,=,Error"]; 9 | } else if (doc.status === "Queued") { 10 | return [__("Queued"), "orange", "status,=,Queued"]; 11 | } 12 | }, 13 | 14 | onload: function (listview) { 15 | listview.page.add_action_item(__("Retry"), () => { 16 | listview.call_for_selected_items( 17 | "ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log.bulk_retry" 18 | ); 19 | }); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/test_ecommerce_integration_log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestEcommerceIntegrationLog(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Ecommerce Item", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-04-20 15:32:47.231689", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "integration", 9 | "erpnext_item_code", 10 | "integration_item_code", 11 | "sku", 12 | "column_break_5", 13 | "has_variants", 14 | "variant_id", 15 | "variant_of", 16 | "inventory_synced_on", 17 | "item_synced_on" 18 | ], 19 | "fields": [ 20 | { 21 | "fieldname": "erpnext_item_code", 22 | "fieldtype": "Link", 23 | "in_list_view": 1, 24 | "in_standard_filter": 1, 25 | "label": "ERPNext Item Code", 26 | "options": "Item", 27 | "read_only": 1, 28 | "reqd": 1, 29 | "search_index": 1 30 | }, 31 | { 32 | "fieldname": "integration", 33 | "fieldtype": "Link", 34 | "in_list_view": 1, 35 | "in_standard_filter": 1, 36 | "label": "Integration", 37 | "options": "Module Def", 38 | "read_only": 1, 39 | "reqd": 1, 40 | "search_index": 1 41 | }, 42 | { 43 | "fieldname": "integration_item_code", 44 | "fieldtype": "Data", 45 | "in_list_view": 1, 46 | "label": "Integration Item Code", 47 | "read_only": 1, 48 | "reqd": 1, 49 | "search_index": 1 50 | }, 51 | { 52 | "fieldname": "variant_id", 53 | "fieldtype": "Data", 54 | "label": "Variant ID", 55 | "read_only": 1 56 | }, 57 | { 58 | "default": "0", 59 | "fieldname": "has_variants", 60 | "fieldtype": "Check", 61 | "label": "Has Variants", 62 | "read_only": 1 63 | }, 64 | { 65 | "description": "ERPNext template Item ID", 66 | "fieldname": "variant_of", 67 | "fieldtype": "Link", 68 | "label": "Variant Of", 69 | "options": "Item", 70 | "read_only": 1 71 | }, 72 | { 73 | "fieldname": "sku", 74 | "fieldtype": "Data", 75 | "label": "SKU", 76 | "read_only": 1, 77 | "search_index": 1 78 | }, 79 | { 80 | "fieldname": "inventory_synced_on", 81 | "fieldtype": "Datetime", 82 | "label": "Inventory Synced On", 83 | "read_only": 1 84 | }, 85 | { 86 | "fieldname": "column_break_5", 87 | "fieldtype": "Column Break" 88 | }, 89 | { 90 | "fieldname": "item_synced_on", 91 | "fieldtype": "Datetime", 92 | "label": "Item Data Synced On", 93 | "read_only": 1 94 | } 95 | ], 96 | "index_web_pages_for_search": 1, 97 | "links": [], 98 | "modified": "2022-11-15 11:36:04.733227", 99 | "modified_by": "Administrator", 100 | "module": "Ecommerce Integrations", 101 | "name": "Ecommerce Item", 102 | "owner": "Administrator", 103 | "permissions": [ 104 | { 105 | "create": 1, 106 | "delete": 1, 107 | "email": 1, 108 | "export": 1, 109 | "print": 1, 110 | "read": 1, 111 | "report": 1, 112 | "role": "System Manager", 113 | "share": 1, 114 | "write": 1 115 | } 116 | ], 117 | "sort_field": "modified", 118 | "sort_order": "DESC", 119 | "states": [], 120 | "track_changes": 1 121 | } -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item_list.js: -------------------------------------------------------------------------------- 1 | frappe.listview_settings["Ecommerce Item"] = { 2 | hide_name_column: true, 3 | }; 4 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/test_ecommerce_item.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | import unittest 5 | 6 | import frappe 7 | 8 | from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item 9 | 10 | 11 | class TestEcommerceItem(unittest.TestCase): 12 | def tearDown(self): 13 | for d in frappe.get_list("Ecommerce Item"): 14 | frappe.get_doc("Ecommerce Item", d.name).delete() 15 | 16 | def test_duplicate(self): 17 | self._create_doc() 18 | self.assertRaises(frappe.DuplicateEntryError, self._create_doc) 19 | 20 | def test_duplicate_variants(self): 21 | self._create_variant_doc() 22 | self.assertRaises(frappe.DuplicateEntryError, self._create_variant_doc) 23 | 24 | def test_duplicate_sku(self): 25 | self._create_doc_with_sku() 26 | self.assertRaises(frappe.DuplicateEntryError, self._create_doc_with_sku) 27 | 28 | def test_is_synced(self): 29 | self._create_doc() 30 | self.assertTrue(ecommerce_item.is_synced("shopify", "T-SHIRT")) 31 | self.assertFalse(ecommerce_item.is_synced("shopify", "UNKNOWN ITEM")) 32 | 33 | def test_is_synced_variant(self): 34 | self._create_variant_doc() 35 | self.assertTrue(ecommerce_item.is_synced("shopify", "T-SHIRT", "T-SHIRT-RED")) 36 | self.assertFalse(ecommerce_item.is_synced("shopify", "T-SHIRT", "Unknown variant")) 37 | 38 | def test_is_synced_sku(self): 39 | self._create_doc_with_sku() 40 | self.assertTrue(ecommerce_item.is_synced("shopify", "T-SHIRT", sku="TEST_ITEM_1")) 41 | self.assertFalse(ecommerce_item.is_synced("shopify", "T-SHIRTX", sku="UNKNOWNSKU")) 42 | 43 | def test_get_erpnext_item(self): 44 | self._create_doc() 45 | a = ecommerce_item.get_erpnext_item("shopify", "T-SHIRT") 46 | b = frappe.get_doc("Item", "_Test Item") 47 | self.assertEqual(a.name, b.name) 48 | self.assertEqual(a.item_code, b.item_code) 49 | 50 | unknown = ecommerce_item.get_erpnext_item("shopify", "Unknown item") 51 | self.assertEqual(unknown, None) 52 | 53 | def test_get_erpnext_item_variant(self): 54 | self._create_variant_doc() 55 | a = ecommerce_item.get_erpnext_item("shopify", "T-SHIRT", "T-SHIRT-RED") 56 | b = frappe.get_doc("Item", "_Test Item 2") 57 | self.assertEqual(a.name, b.name) 58 | self.assertEqual(a.item_code, b.item_code) 59 | 60 | def test_get_erpnext_item_sku(self): 61 | self._create_doc_with_sku() 62 | a = ecommerce_item.get_erpnext_item("shopify", "T-SHIRT", sku="TEST_ITEM_1") 63 | b = frappe.get_doc("Item", "_Test Item") 64 | self.assertEqual(a.name, b.name) 65 | self.assertEqual(a.item_code, b.item_code) 66 | 67 | def _create_doc(self): 68 | """basic test for creation of ecommerce item""" 69 | frappe.get_doc( 70 | { 71 | "doctype": "Ecommerce Item", 72 | "integration": "shopify", 73 | "integration_item_code": "T-SHIRT", 74 | "erpnext_item_code": "_Test Item", 75 | } 76 | ).insert() 77 | 78 | def _create_variant_doc(self): 79 | """basic test for creation of ecommerce item""" 80 | frappe.get_doc( 81 | { 82 | "doctype": "Ecommerce Item", 83 | "integration": "shopify", 84 | "integration_item_code": "T-SHIRT", 85 | "erpnext_item_code": "_Test Item 2", 86 | "has_variants": 0, 87 | "variant_id": "T-SHIRT-RED", 88 | "variant_of": "_Test Variant Item", 89 | } 90 | ).insert() 91 | 92 | def _create_doc_with_sku(self): 93 | frappe.get_doc( 94 | { 95 | "doctype": "Ecommerce Item", 96 | "integration": "shopify", 97 | "integration_item_code": "T-SHIRT", 98 | "erpnext_item_code": "_Test Item", 99 | "sku": "TEST_ITEM_1", 100 | } 101 | ).insert() 102 | -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/pick_list_sales_order_details/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/ecommerce_integrations/doctype/pick_list_sales_order_details/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/pick_list_sales_order_details/pick_list_sales_order_details.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2023-04-19 11:57:15.149202", 4 | "default_view": "List", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "sales_order", 10 | "sales_invoice", 11 | "posting_date", 12 | "pick_status", 13 | "invoice_url", 14 | "invoice_pdf" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "sales_order", 19 | "fieldtype": "Link", 20 | "in_list_view": 1, 21 | "label": "Sales Order", 22 | "options": "Sales Order" 23 | }, 24 | { 25 | "fieldname": "sales_invoice", 26 | "fieldtype": "Link", 27 | "in_list_view": 1, 28 | "label": "Sales Invoice", 29 | "options": "Sales Invoice" 30 | }, 31 | { 32 | "fetch_from": "sales_invoice.posting_date", 33 | "fieldname": "posting_date", 34 | "fieldtype": "Date", 35 | "label": "Posting Date" 36 | }, 37 | { 38 | "fieldname": "pick_status", 39 | "fieldtype": "Select", 40 | "in_list_view": 1, 41 | "label": "Pick Status", 42 | "options": "\nPartially Picked\nFully Picked" 43 | }, 44 | { 45 | "fieldname": "invoice_url", 46 | "fieldtype": "Data", 47 | "hidden": 1, 48 | "label": "Invoice URL" 49 | }, 50 | { 51 | "fieldname": "invoice_pdf", 52 | "fieldtype": "Attach", 53 | "in_list_view": 1, 54 | "label": "Invoice Pdf" 55 | } 56 | ], 57 | "index_web_pages_for_search": 1, 58 | "istable": 1, 59 | "links": [], 60 | "modified": "2023-04-20 10:58:07.144994", 61 | "modified_by": "Administrator", 62 | "module": "Ecommerce Integrations", 63 | "name": "Pick List Sales Order Details", 64 | "owner": "Administrator", 65 | "permissions": [], 66 | "sort_field": "modified", 67 | "sort_order": "DESC", 68 | "states": [], 69 | "track_changes": 1 70 | } -------------------------------------------------------------------------------- /ecommerce_integrations/ecommerce_integrations/doctype/pick_list_sales_order_details/pick_list_sales_order_details.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class PickListSalesOrderDetails(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/modules.txt: -------------------------------------------------------------------------------- 1 | Ecommerce Integrations 2 | shopify 3 | Zenoti 4 | unicommerce 5 | Amazon -------------------------------------------------------------------------------- /ecommerce_integrations/patches.txt: -------------------------------------------------------------------------------- 1 | ecommerce_integrations.patches.update_shopify_custom_fields 2 | ecommerce_integrations.patches.set_default_amazon_item_fields_map 3 | -------------------------------------------------------------------------------- /ecommerce_integrations/patches/set_default_amazon_item_fields_map.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def execute(): 5 | frappe.reload_doc("amazon", "doctype", "amazon_sp_api_settings") 6 | 7 | default_fields_map = [ 8 | {"amazon_field": "ASIN", "item_field": "item_code", "use_to_find_item_code": 1}, 9 | { 10 | "amazon_field": "SellerSKU", 11 | "item_field": None, 12 | "use_to_find_item_code": 0, 13 | }, 14 | { 15 | "amazon_field": "Title", 16 | "item_field": None, 17 | "use_to_find_item_code": 0, 18 | }, 19 | ] 20 | amz_settings = frappe.db.get_all("Amazon SP API Settings", pluck="name") 21 | 22 | if amz_settings: 23 | for amz_setting in amz_settings: 24 | amz_setting_doc = frappe.get_doc("Amazon SP API Settings", amz_setting) 25 | 26 | for field_map in default_fields_map: 27 | amz_setting_doc.append("amazon_fields_map", field_map) 28 | 29 | amz_setting.flags.ignore_validate = True 30 | amz_setting_doc.save(ignore_version=True) 31 | -------------------------------------------------------------------------------- /ecommerce_integrations/patches/update_shopify_custom_fields.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from ecommerce_integrations.shopify.constants import SETTING_DOCTYPE 4 | from ecommerce_integrations.shopify.doctype.shopify_setting.shopify_setting import ( 5 | setup_custom_fields, 6 | ) 7 | 8 | 9 | def execute(): 10 | frappe.reload_doc("shopify", "doctype", "shopify_setting") 11 | 12 | settings = frappe.get_doc(SETTING_DOCTYPE) 13 | if settings.is_enabled(): 14 | setup_custom_fields() 15 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/common/ecommerce_transactions.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on(cur_frm.doctype, { 2 | refresh(frm) { 3 | if (frm.doc.amended_from) { 4 | // see if any taxes present 5 | if (frm.doc.taxes.find((t) => t.dont_recompute_tax)) { 6 | frappe.msgprint( 7 | __( 8 | "Amending document created via E-Commerce integrations will not re-compute taxes. Please check taxes before submitting." 9 | ), 10 | __("Warning About Taxes") 11 | ); 12 | } 13 | } 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/shopify/old_settings.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Shopify Settings", { 2 | onload_post_render: function (frm) { 3 | let msg = __("You have Ecommerce Integration app installed.") + " "; 4 | msg += __("This setting page refers to old Shopify connector."); 5 | frappe.msgprint(msg); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/unicommerce/item.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Item", { 2 | refresh(frm) { 3 | if (frm.doc.sync_with_unicommerce) { 4 | frm.add_custom_button( 5 | __("Open Unicommerce Item"), 6 | function () { 7 | frappe.call({ 8 | method: "ecommerce_integrations.unicommerce.utils.get_unicommerce_document_url", 9 | args: { 10 | code: frm.doc.item_code, 11 | doctype: frm.doc.doctype, 12 | }, 13 | callback: function (r) { 14 | if (!r.exc) { 15 | window.open(r.message, "_blank"); 16 | } 17 | }, 18 | }); 19 | }, 20 | __("Unicommerce") 21 | ); 22 | } 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/unicommerce/pick_list.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Pick List", { 2 | refresh(frm) { 3 | if (frm.doc.order_details) { 4 | frm.add_custom_button(__("Generate Invoice"), () => 5 | frm.trigger("generate_invoice") 6 | ); 7 | } 8 | }, 9 | generate_invoice(frm) { 10 | let selected_so = []; 11 | var tbl = frm.doc.order_details || []; 12 | for (var i = 0; i < tbl.length; i++) { 13 | selected_so.push(tbl[i].sales_order); 14 | } 15 | let sales_orders = []; 16 | let so_item_list = []; 17 | const warehouse_allocation = {}; 18 | selected_so.forEach(function (so) { 19 | const item_details = frm.doc.locations.map((item) => { 20 | if (item.sales_order == so && item.picked_qty > 0) { 21 | so_item_list.push({ 22 | so_item: item.sales_order_item, 23 | qty: item.qty, 24 | }); 25 | return { 26 | sales_order_row: item.sales_order_item, 27 | item_code: item.item_code, 28 | warehouse: item.warehouse, 29 | shelf: item.shelf, 30 | }; 31 | } else { 32 | return {}; 33 | } 34 | }); 35 | sales_orders.push(so); 36 | warehouse_allocation[so] = item_details.filter( 37 | (value) => Object.keys(value).length !== 0 38 | ); 39 | }); 40 | frappe.call({ 41 | method: "ecommerce_integrations.unicommerce.invoice.generate_unicommerce_invoices", 42 | args: { 43 | sales_orders: sales_orders, 44 | warehouse_allocation: warehouse_allocation, 45 | }, 46 | freeze: true, 47 | freeze_message: 48 | "Requesting Invoice generation. Once synced, invoice will appear in linked documents.", 49 | }); 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/unicommerce/sales_invoice.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Sales Invoice", { 2 | refresh(frm) { 3 | if (frm.doc.unicommerce_order_code) { 4 | frm.add_custom_button( 5 | __("Open Unicommerce Order"), 6 | function () { 7 | frappe.call({ 8 | method: "ecommerce_integrations.unicommerce.utils.get_unicommerce_document_url", 9 | args: { 10 | code: frm.doc.unicommerce_order_code, 11 | doctype: frm.doc.doctype, 12 | }, 13 | callback: function (r) { 14 | if (!r.exc) { 15 | window.open(r.message, "_blank"); 16 | } 17 | }, 18 | }); 19 | }, 20 | __("Unicommerce") 21 | ); 22 | } 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/unicommerce/sales_order.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Sales Order", { 2 | refresh(frm) { 3 | if (frm.doc.unicommerce_order_code) { 4 | // add button to open unicommerce order from SO page 5 | frm.add_custom_button( 6 | __("Open Unicommerce Order"), 7 | function () { 8 | frappe.call({ 9 | method: "ecommerce_integrations.unicommerce.utils.get_unicommerce_document_url", 10 | args: { 11 | code: frm.doc.unicommerce_order_code, 12 | doctype: frm.doc.doctype, 13 | }, 14 | callback: function (r) { 15 | if (!r.exc) { 16 | window.open(r.message, "_blank"); 17 | } 18 | }, 19 | }); 20 | }, 21 | __("Unicommerce") 22 | ); 23 | } 24 | if ( 25 | frm.doc.unicommerce_order_code && 26 | frm.doc.docstatus == 1 && 27 | flt(frm.doc.per_billed, 6) < 100 28 | ) { 29 | // remove default button 30 | frm.remove_custom_button("Sales Invoice", "Create"); 31 | const so_code = frm.doc.name; 32 | 33 | const item_details = frm.doc.items.map((item) => { 34 | // each row is assumed to be for 1 qty. 35 | return { 36 | sales_order_row: item.name, 37 | item_code: item.item_code, 38 | warehouse: item.warehouse, 39 | }; 40 | }); 41 | 42 | const warehouse_allocation = {}; 43 | warehouse_allocation[so_code] = item_details; 44 | 45 | frm.add_custom_button( 46 | __("Generate Invoice"), 47 | function () { 48 | frappe.call({ 49 | method: "ecommerce_integrations.unicommerce.invoice.generate_unicommerce_invoices", 50 | args: { 51 | sales_orders: [so_code], 52 | warehouse_allocation: warehouse_allocation, 53 | }, 54 | freeze: true, 55 | freeze_message: 56 | "Requesting Invoice generation. Once synced, invoice will appear in linked documents.", 57 | callback: function (r) { 58 | frm.reload_doc(); 59 | }, 60 | }); 61 | }, 62 | __("Unicommerce") 63 | ); 64 | } 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /ecommerce_integrations/public/js/unicommerce/stock_entry.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Stock Entry", { 2 | refresh(frm) { 3 | if (frm.doc.stock_entry_type == "GRN on Unicommerce") { 4 | frm.add_custom_button( 5 | __("Open GRNs"), 6 | function () { 7 | frappe.call({ 8 | method: "ecommerce_integrations.unicommerce.utils.get_unicommerce_document_url", 9 | args: { 10 | code: "", 11 | doctype: frm.doc.doctype, 12 | }, 13 | callback: function (r) { 14 | if (!r.exc) { 15 | window.open(r.message, "_blank"); 16 | } 17 | }, 18 | }); 19 | }, 20 | __("Unicommerce") 21 | ); 22 | } 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/connection.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import functools 3 | import hashlib 4 | import hmac 5 | import json 6 | 7 | import frappe 8 | from frappe import _ 9 | from shopify.resources import Webhook 10 | from shopify.session import Session 11 | 12 | from ecommerce_integrations.shopify.constants import ( 13 | API_VERSION, 14 | EVENT_MAPPER, 15 | SETTING_DOCTYPE, 16 | WEBHOOK_EVENTS, 17 | ) 18 | from ecommerce_integrations.shopify.utils import create_shopify_log 19 | 20 | 21 | def temp_shopify_session(func): 22 | """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns.""" 23 | 24 | @functools.wraps(func) 25 | def wrapper(*args, **kwargs): 26 | # no auth in testing 27 | if frappe.flags.in_test: 28 | return func(*args, **kwargs) 29 | 30 | setting = frappe.get_doc(SETTING_DOCTYPE) 31 | if setting.is_enabled(): 32 | auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) 33 | 34 | with Session.temp(*auth_details): 35 | return func(*args, **kwargs) 36 | 37 | return wrapper 38 | 39 | 40 | def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: 41 | """Register required webhooks with shopify and return registered webhooks.""" 42 | new_webhooks = [] 43 | 44 | # clear all stale webhooks matching current site url before registering new ones 45 | unregister_webhooks(shopify_url, password) 46 | 47 | with Session.temp(shopify_url, API_VERSION, password): 48 | for topic in WEBHOOK_EVENTS: 49 | webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"}) 50 | 51 | if webhook.is_valid(): 52 | new_webhooks.append(webhook) 53 | else: 54 | create_shopify_log( 55 | status="Error", 56 | response_data=webhook.to_dict(), 57 | exception=webhook.errors.full_messages(), 58 | ) 59 | 60 | return new_webhooks 61 | 62 | 63 | def unregister_webhooks(shopify_url: str, password: str) -> None: 64 | """Unregister all webhooks from shopify that correspond to current site url.""" 65 | url = get_current_domain_name() 66 | 67 | with Session.temp(shopify_url, API_VERSION, password): 68 | for webhook in Webhook.find(): 69 | if url in webhook.address: 70 | webhook.destroy() 71 | 72 | 73 | def get_current_domain_name() -> str: 74 | """Get current site domain name. E.g. test.erpnext.com 75 | 76 | If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url. 77 | """ 78 | if frappe.conf.developer_mode and frappe.conf.localtunnel_url: 79 | return frappe.conf.localtunnel_url 80 | else: 81 | return frappe.request.host 82 | 83 | 84 | def get_callback_url() -> str: 85 | """Shopify calls this url when new events occur to subscribed webhooks. 86 | 87 | If developer_mode is enabled and localtunnel_url is set in site config then callback url is set to localtunnel_url. 88 | """ 89 | url = get_current_domain_name() 90 | 91 | return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data" 92 | 93 | 94 | @frappe.whitelist(allow_guest=True) 95 | def store_request_data() -> None: 96 | if frappe.request: 97 | hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") 98 | 99 | _validate_request(frappe.request, hmac_header) 100 | 101 | data = json.loads(frappe.request.data) 102 | event = frappe.request.headers.get("X-Shopify-Topic") 103 | 104 | process_request(data, event) 105 | 106 | 107 | def process_request(data, event): 108 | # create log 109 | log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) 110 | 111 | # enqueue backround job 112 | frappe.enqueue( 113 | method=EVENT_MAPPER[event], 114 | queue="short", 115 | timeout=300, 116 | is_async=True, 117 | **{"payload": data, "request_id": log.name}, 118 | ) 119 | 120 | 121 | def _validate_request(req, hmac_header): 122 | settings = frappe.get_doc(SETTING_DOCTYPE) 123 | secret_key = settings.shared_secret 124 | 125 | sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) 126 | 127 | if sig != bytes(hmac_header.encode()): 128 | create_shopify_log(status="Error", request_data=req.data) 129 | frappe.throw(_("Unverified Webhook Data")) 130 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | 5 | MODULE_NAME = "shopify" 6 | SETTING_DOCTYPE = "Shopify Setting" 7 | OLD_SETTINGS_DOCTYPE = "Shopify Settings" 8 | 9 | API_VERSION = "2024-01" 10 | 11 | WEBHOOK_EVENTS = [ 12 | "orders/create", 13 | "orders/paid", 14 | "orders/fulfilled", 15 | "orders/cancelled", 16 | "orders/partially_fulfilled", 17 | ] 18 | 19 | EVENT_MAPPER = { 20 | "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order", 21 | "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice", 22 | "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", 23 | "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order", 24 | "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note", 25 | } 26 | 27 | SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"] 28 | 29 | # custom fields 30 | 31 | CUSTOMER_ID_FIELD = "shopify_customer_id" 32 | ORDER_ID_FIELD = "shopify_order_id" 33 | ORDER_NUMBER_FIELD = "shopify_order_number" 34 | ORDER_STATUS_FIELD = "shopify_order_status" 35 | FULLFILLMENT_ID_FIELD = "shopify_fulfillment_id" 36 | SUPPLIER_ID_FIELD = "shopify_supplier_id" 37 | ADDRESS_ID_FIELD = "shopify_address_id" 38 | ORDER_ITEM_DISCOUNT_FIELD = "shopify_item_discount" 39 | ITEM_SELLING_RATE_FIELD = "shopify_selling_rate" 40 | 41 | # ERPNext already defines the default UOMs from Shopify but names are different 42 | WEIGHT_TO_ERPNEXT_UOM_MAP = {"kg": "Kg", "g": "Gram", "oz": "Ounce", "lb": "Pound"} 43 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/doctype/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.provide("ecommerce_integrations.shopify.shopify_setting"); 5 | 6 | frappe.ui.form.on("Shopify Setting", { 7 | onload: function (frm) { 8 | frappe.call({ 9 | method: "ecommerce_integrations.utils.naming_series.get_series", 10 | callback: function (r) { 11 | $.each(r.message, (key, value) => { 12 | set_field_options(key, value); 13 | }); 14 | }, 15 | }); 16 | }, 17 | 18 | fetch_shopify_locations: function (frm) { 19 | frappe.call({ 20 | doc: frm.doc, 21 | method: "update_location_table", 22 | callback: (r) => { 23 | if (!r.exc) refresh_field("shopify_warehouse_mapping"); 24 | }, 25 | }); 26 | }, 27 | 28 | refresh: function (frm) { 29 | frm.add_custom_button(__("Import Products"), function () { 30 | frappe.set_route("shopify-import-products"); 31 | }); 32 | frm.add_custom_button(__("View Logs"), () => { 33 | frappe.set_route("List", "Ecommerce Integration Log", { 34 | integration: "Shopify", 35 | }); 36 | }); 37 | frm.trigger("setup_queries"); 38 | }, 39 | 40 | setup_queries: function (frm) { 41 | const warehouse_query = () => { 42 | return { 43 | filters: { 44 | company: frm.doc.company, 45 | is_group: 0, 46 | disabled: 0, 47 | }, 48 | }; 49 | }; 50 | frm.set_query("warehouse", warehouse_query); 51 | frm.set_query( 52 | "erpnext_warehouse", 53 | "shopify_warehouse_mapping", 54 | warehouse_query 55 | ); 56 | 57 | frm.set_query("price_list", () => { 58 | return { 59 | filters: { 60 | selling: 1, 61 | }, 62 | }; 63 | }); 64 | 65 | frm.set_query("cost_center", () => { 66 | return { 67 | filters: { 68 | company: frm.doc.company, 69 | is_group: "No", 70 | }, 71 | }; 72 | }); 73 | 74 | frm.set_query("cash_bank_account", () => { 75 | return { 76 | filters: [ 77 | ["Account", "account_type", "in", ["Cash", "Bank"]], 78 | ["Account", "root_type", "=", "Asset"], 79 | ["Account", "is_group", "=", 0], 80 | ["Account", "company", "=", frm.doc.company], 81 | ], 82 | }; 83 | }); 84 | 85 | const tax_query = () => { 86 | return { 87 | query: "erpnext.controllers.queries.tax_account_query", 88 | filters: { 89 | account_type: ["Tax", "Chargeable", "Expense Account"], 90 | company: frm.doc.company, 91 | }, 92 | }; 93 | }; 94 | 95 | frm.set_query("tax_account", "taxes", tax_query); 96 | frm.set_query("default_sales_tax_account", tax_query); 97 | frm.set_query("default_shipping_charges_account", tax_query); 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | import unittest 5 | 6 | import frappe 7 | 8 | from ecommerce_integrations.shopify.constants import ( 9 | ADDRESS_ID_FIELD, 10 | CUSTOMER_ID_FIELD, 11 | FULLFILLMENT_ID_FIELD, 12 | ITEM_SELLING_RATE_FIELD, 13 | ORDER_ID_FIELD, 14 | ORDER_ITEM_DISCOUNT_FIELD, 15 | ORDER_NUMBER_FIELD, 16 | ORDER_STATUS_FIELD, 17 | SUPPLIER_ID_FIELD, 18 | ) 19 | 20 | from .shopify_setting import setup_custom_fields 21 | 22 | 23 | class TestShopifySetting(unittest.TestCase): 24 | @classmethod 25 | def setUpClass(cls): 26 | frappe.db.sql( 27 | """delete from `tabCustom Field` 28 | where name like '%shopify%'""" 29 | ) 30 | 31 | def test_custom_field_creation(self): 32 | setup_custom_fields() 33 | 34 | created_fields = frappe.get_all( 35 | "Custom Field", 36 | filters={"fieldname": ["LIKE", "%shopify%"]}, 37 | fields="fieldName", 38 | as_list=True, 39 | order_by=None, 40 | ) 41 | 42 | required_fields = set( 43 | [ 44 | ADDRESS_ID_FIELD, 45 | CUSTOMER_ID_FIELD, 46 | FULLFILLMENT_ID_FIELD, 47 | ITEM_SELLING_RATE_FIELD, 48 | ORDER_ID_FIELD, 49 | ORDER_NUMBER_FIELD, 50 | ORDER_STATUS_FIELD, 51 | SUPPLIER_ID_FIELD, 52 | ORDER_ITEM_DISCOUNT_FIELD, 53 | ] 54 | ) 55 | 56 | self.assertGreaterEqual(len(created_fields), 13) 57 | created_fields_set = {d[0] for d in created_fields} 58 | 59 | self.assertEqual(created_fields_set, required_fields) 60 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_tax_account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/doctype/shopify_tax_account/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_tax_account/shopify_tax_account.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2015-10-05 16:55:20.455371", 4 | "doctype": "DocType", 5 | "engine": "InnoDB", 6 | "field_order": [ 7 | "shopify_tax", 8 | "column_break_2", 9 | "tax_account", 10 | "tax_description" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "shopify_tax", 15 | "fieldtype": "Data", 16 | "in_list_view": 1, 17 | "label": "Shopify Tax/Shipping Title", 18 | "reqd": 1 19 | }, 20 | { 21 | "fieldname": "column_break_2", 22 | "fieldtype": "Column Break" 23 | }, 24 | { 25 | "fieldname": "tax_account", 26 | "fieldtype": "Link", 27 | "in_list_view": 1, 28 | "label": "ERPNext Account", 29 | "options": "Account", 30 | "reqd": 1 31 | }, 32 | { 33 | "fieldname": "tax_description", 34 | "fieldtype": "Data", 35 | "in_list_view": 1, 36 | "label": "Tax Description", 37 | "translatable": 1 38 | } 39 | ], 40 | "istable": 1, 41 | "links": [], 42 | "modified": "2021-06-05 10:52:18.427400", 43 | "modified_by": "Administrator", 44 | "module": "shopify", 45 | "name": "Shopify Tax Account", 46 | "owner": "Administrator", 47 | "permissions": [], 48 | "sort_field": "modified", 49 | "sort_order": "DESC" 50 | } -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_tax_account/shopify_tax_account.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors 2 | # For license information, please see LICENSE 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ShopifyTaxAccount(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_warehouse_mapping/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/doctype/shopify_warehouse_mapping/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_warehouse_mapping/shopify_warehouse_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-05-04 19:01:15.171337", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "shopify_location_id", 9 | "shopify_location_name", 10 | "erpnext_warehouse" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "erpnext_warehouse", 15 | "fieldtype": "Link", 16 | "in_list_view": 1, 17 | "label": "ERPNext Warehouse", 18 | "options": "Warehouse" 19 | }, 20 | { 21 | "fieldname": "shopify_location_id", 22 | "fieldtype": "Data", 23 | "in_list_view": 1, 24 | "label": "Shopify Location ID", 25 | "read_only": 1, 26 | "reqd": 1 27 | }, 28 | { 29 | "fieldname": "shopify_location_name", 30 | "fieldtype": "Data", 31 | "in_list_view": 1, 32 | "label": "Shopify Location Name", 33 | "read_only": 1, 34 | "reqd": 1 35 | } 36 | ], 37 | "index_web_pages_for_search": 1, 38 | "istable": 1, 39 | "links": [], 40 | "modified": "2021-05-04 19:13:13.712708", 41 | "modified_by": "Administrator", 42 | "module": "Shopify", 43 | "name": "Shopify Warehouse Mapping", 44 | "owner": "Administrator", 45 | "permissions": [], 46 | "sort_field": "modified", 47 | "sort_order": "DESC", 48 | "track_changes": 1 49 | } -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_warehouse_mapping/shopify_warehouse_mapping.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ShopifyWarehouseMapping(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/doctype/shopify_webhooks/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_webhooks/shopify_webhooks.json: -------------------------------------------------------------------------------- 1 | { 2 | "allow_copy": 0, 3 | "allow_guest_to_view": 0, 4 | "allow_import": 0, 5 | "allow_rename": 0, 6 | "beta": 0, 7 | "creation": "2018-04-10 17:06:22.697427", 8 | "custom": 0, 9 | "docstatus": 0, 10 | "doctype": "DocType", 11 | "document_type": "", 12 | "editable_grid": 0, 13 | "engine": "InnoDB", 14 | "fields": [ 15 | { 16 | "allow_bulk_edit": 0, 17 | "allow_on_submit": 0, 18 | "bold": 0, 19 | "collapsible": 0, 20 | "columns": 0, 21 | "fieldname": "webhook_id", 22 | "fieldtype": "Data", 23 | "hidden": 0, 24 | "ignore_user_permissions": 0, 25 | "ignore_xss_filter": 0, 26 | "in_filter": 0, 27 | "in_global_search": 0, 28 | "in_list_view": 1, 29 | "in_standard_filter": 0, 30 | "label": "Webhook ID", 31 | "length": 0, 32 | "no_copy": 0, 33 | "permlevel": 0, 34 | "precision": "", 35 | "print_hide": 0, 36 | "print_hide_if_no_value": 0, 37 | "read_only": 1, 38 | "remember_last_selected_value": 0, 39 | "report_hide": 0, 40 | "reqd": 0, 41 | "search_index": 0, 42 | "set_only_once": 0, 43 | "translatable": 0, 44 | "unique": 0 45 | }, 46 | { 47 | "allow_bulk_edit": 0, 48 | "allow_on_submit": 0, 49 | "bold": 0, 50 | "collapsible": 0, 51 | "columns": 0, 52 | "fieldname": "method", 53 | "fieldtype": "Data", 54 | "hidden": 0, 55 | "ignore_user_permissions": 0, 56 | "ignore_xss_filter": 0, 57 | "in_filter": 0, 58 | "in_global_search": 0, 59 | "in_list_view": 1, 60 | "in_standard_filter": 0, 61 | "label": "Method", 62 | "length": 0, 63 | "no_copy": 0, 64 | "permlevel": 0, 65 | "precision": "", 66 | "print_hide": 0, 67 | "print_hide_if_no_value": 0, 68 | "read_only": 1, 69 | "remember_last_selected_value": 0, 70 | "report_hide": 0, 71 | "reqd": 0, 72 | "search_index": 0, 73 | "set_only_once": 0, 74 | "translatable": 0, 75 | "unique": 0 76 | } 77 | ], 78 | "has_web_view": 0, 79 | "hide_heading": 0, 80 | "hide_toolbar": 0, 81 | "idx": 0, 82 | "image_view": 0, 83 | "in_create": 0, 84 | "is_submittable": 0, 85 | "issingle": 0, 86 | "istable": 1, 87 | "max_attachments": 0, 88 | "modified": "2021-04-13 12:43:09.456449", 89 | "modified_by": "Administrator", 90 | "module": "Shopify", 91 | "name": "Shopify Webhooks", 92 | "name_case": "", 93 | "owner": "Administrator", 94 | "permissions": [], 95 | "quick_entry": 1, 96 | "read_only": 0, 97 | "read_only_onload": 0, 98 | "show_name_in_global_search": 0, 99 | "sort_field": "modified", 100 | "sort_order": "DESC", 101 | "track_changes": 1, 102 | "track_seen": 0 103 | } 104 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/doctype/shopify_webhooks/shopify_webhooks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ShopifyWebhooks(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/fulfillment.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import frappe 4 | from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note 5 | from frappe.utils import cint, cstr, getdate 6 | 7 | from ecommerce_integrations.shopify.constants import ( 8 | FULLFILLMENT_ID_FIELD, 9 | ORDER_ID_FIELD, 10 | ORDER_NUMBER_FIELD, 11 | SETTING_DOCTYPE, 12 | ) 13 | from ecommerce_integrations.shopify.order import get_sales_order 14 | from ecommerce_integrations.shopify.utils import create_shopify_log 15 | 16 | 17 | def prepare_delivery_note(payload, request_id=None): 18 | frappe.set_user("Administrator") 19 | setting = frappe.get_doc(SETTING_DOCTYPE) 20 | frappe.flags.request_id = request_id 21 | 22 | order = payload 23 | 24 | try: 25 | sales_order = get_sales_order(cstr(order["id"])) 26 | if sales_order: 27 | create_delivery_note(order, setting, sales_order) 28 | create_shopify_log(status="Success") 29 | else: 30 | create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.") 31 | except Exception as e: 32 | create_shopify_log(status="Error", exception=e, rollback=True) 33 | 34 | 35 | def create_delivery_note(shopify_order, setting, so): 36 | if not cint(setting.sync_delivery_note): 37 | return 38 | 39 | for fulfillment in shopify_order.get("fulfillments"): 40 | if ( 41 | not frappe.db.get_value("Delivery Note", {FULLFILLMENT_ID_FIELD: fulfillment.get("id")}, "name") 42 | and so.docstatus == 1 43 | ): 44 | dn = make_delivery_note(so.name) 45 | setattr(dn, ORDER_ID_FIELD, fulfillment.get("order_id")) 46 | setattr(dn, ORDER_NUMBER_FIELD, shopify_order.get("name")) 47 | setattr(dn, FULLFILLMENT_ID_FIELD, fulfillment.get("id")) 48 | dn.set_posting_time = 1 49 | dn.posting_date = getdate(fulfillment.get("created_at")) 50 | dn.naming_series = setting.delivery_note_series or "DN-Shopify-" 51 | dn.items = get_fulfillment_items( 52 | dn.items, fulfillment.get("line_items"), fulfillment.get("location_id") 53 | ) 54 | dn.flags.ignore_mandatory = True 55 | dn.save() 56 | dn.submit() 57 | 58 | if shopify_order.get("note"): 59 | dn.add_comment(text=f"Order Note: {shopify_order.get('note')}") 60 | 61 | 62 | def get_fulfillment_items(dn_items, fulfillment_items, location_id=None): 63 | # local import to avoid circular imports 64 | from ecommerce_integrations.shopify.product import get_item_code 65 | 66 | fulfillment_items = deepcopy(fulfillment_items) 67 | 68 | setting = frappe.get_cached_doc(SETTING_DOCTYPE) 69 | wh_map = setting.get_integration_to_erpnext_wh_mapping() 70 | warehouse = wh_map.get(str(location_id)) or setting.warehouse 71 | 72 | final_items = [] 73 | 74 | def find_matching_fullfilement_item(dn_item): 75 | nonlocal fulfillment_items 76 | 77 | for item in fulfillment_items: 78 | if get_item_code(item) == dn_item.item_code: 79 | fulfillment_items.remove(item) 80 | return item 81 | 82 | for dn_item in dn_items: 83 | if shopify_item := find_matching_fullfilement_item(dn_item): 84 | final_items.append(dn_item.update({"qty": shopify_item.get("quantity"), "warehouse": warehouse})) 85 | 86 | return final_items 87 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/inventory.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | import frappe 4 | from frappe.utils import cint, create_batch, now 5 | from pyactiveresource.connection import ResourceNotFound 6 | from shopify.resources import InventoryLevel, Variant 7 | 8 | from ecommerce_integrations.controllers.inventory import ( 9 | get_inventory_levels, 10 | update_inventory_sync_status, 11 | ) 12 | from ecommerce_integrations.controllers.scheduling import need_to_run 13 | from ecommerce_integrations.shopify.connection import temp_shopify_session 14 | from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE 15 | from ecommerce_integrations.shopify.utils import create_shopify_log 16 | 17 | 18 | def update_inventory_on_shopify() -> None: 19 | """Upload stock levels from ERPNext to Shopify. 20 | 21 | Called by scheduler on configured interval. 22 | """ 23 | setting = frappe.get_doc(SETTING_DOCTYPE) 24 | 25 | if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: 26 | return 27 | 28 | if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): 29 | return 30 | 31 | warehous_map = setting.get_erpnext_to_integration_wh_mapping() 32 | inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) 33 | 34 | if inventory_levels: 35 | upload_inventory_data_to_shopify(inventory_levels, warehous_map) 36 | 37 | 38 | @temp_shopify_session 39 | def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: 40 | synced_on = now() 41 | 42 | for inventory_sync_batch in create_batch(inventory_levels, 50): 43 | for d in inventory_sync_batch: 44 | d.shopify_location_id = warehous_map[d.warehouse] 45 | 46 | try: 47 | variant = Variant.find(d.variant_id) 48 | inventory_id = variant.inventory_item_id 49 | 50 | InventoryLevel.set( 51 | location_id=d.shopify_location_id, 52 | inventory_item_id=inventory_id, 53 | # shopify doesn't support fractional quantity 54 | available=cint(d.actual_qty) - cint(d.reserved_qty), 55 | ) 56 | update_inventory_sync_status(d.ecom_item, time=synced_on) 57 | d.status = "Success" 58 | except ResourceNotFound: 59 | # Variant or location is deleted, mark as last synced and ignore. 60 | update_inventory_sync_status(d.ecom_item, time=synced_on) 61 | d.status = "Not Found" 62 | except Exception as e: 63 | d.status = "Failed" 64 | d.failure_reason = str(e) 65 | 66 | frappe.db.commit() 67 | 68 | _log_inventory_update_status(inventory_sync_batch) 69 | 70 | 71 | def _log_inventory_update_status(inventory_levels) -> None: 72 | """Create log of inventory update.""" 73 | log_message = "variant_id,location_id,status,failure_reason\n" 74 | 75 | log_message += "\n".join( 76 | f"{d.variant_id},{d.shopify_location_id},{d.status},{d.failure_reason or ''}" 77 | for d in inventory_levels 78 | ) 79 | 80 | stats = Counter([d.status for d in inventory_levels]) 81 | 82 | percent_successful = stats["Success"] / len(inventory_levels) 83 | 84 | if percent_successful == 0: 85 | status = "Failed" 86 | elif percent_successful < 1: 87 | status = "Partial Success" 88 | else: 89 | status = "Success" 90 | 91 | log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message 92 | 93 | create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message) 94 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/invoice.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice 3 | from frappe.utils import cint, cstr, getdate, nowdate 4 | 5 | from ecommerce_integrations.shopify.constants import ( 6 | ORDER_ID_FIELD, 7 | ORDER_NUMBER_FIELD, 8 | SETTING_DOCTYPE, 9 | ) 10 | from ecommerce_integrations.shopify.utils import create_shopify_log 11 | 12 | 13 | def prepare_sales_invoice(payload, request_id=None): 14 | from ecommerce_integrations.shopify.order import get_sales_order 15 | 16 | order = payload 17 | 18 | frappe.set_user("Administrator") 19 | setting = frappe.get_doc(SETTING_DOCTYPE) 20 | frappe.flags.request_id = request_id 21 | 22 | try: 23 | sales_order = get_sales_order(cstr(order["id"])) 24 | if sales_order: 25 | create_sales_invoice(order, setting, sales_order) 26 | create_shopify_log(status="Success") 27 | else: 28 | create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.") 29 | except Exception as e: 30 | create_shopify_log(status="Error", exception=e, rollback=True) 31 | 32 | 33 | def create_sales_invoice(shopify_order, setting, so): 34 | if ( 35 | not frappe.db.get_value("Sales Invoice", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") 36 | and so.docstatus == 1 37 | and not so.per_billed 38 | and cint(setting.sync_sales_invoice) 39 | ): 40 | posting_date = getdate(shopify_order.get("created_at")) or nowdate() 41 | 42 | sales_invoice = make_sales_invoice(so.name, ignore_permissions=True) 43 | sales_invoice.set(ORDER_ID_FIELD, str(shopify_order.get("id"))) 44 | sales_invoice.set(ORDER_NUMBER_FIELD, shopify_order.get("name")) 45 | sales_invoice.set_posting_time = 1 46 | sales_invoice.posting_date = posting_date 47 | sales_invoice.due_date = posting_date 48 | sales_invoice.naming_series = setting.sales_invoice_series or "SI-Shopify-" 49 | sales_invoice.flags.ignore_mandatory = True 50 | set_cost_center(sales_invoice.items, setting.cost_center) 51 | sales_invoice.insert(ignore_mandatory=True) 52 | sales_invoice.submit() 53 | if sales_invoice.grand_total > 0: 54 | make_payament_entry_against_sales_invoice(sales_invoice, setting, posting_date) 55 | 56 | if shopify_order.get("note"): 57 | sales_invoice.add_comment(text=f"Order Note: {shopify_order.get('note')}") 58 | 59 | 60 | def set_cost_center(items, cost_center): 61 | for item in items: 62 | item.cost_center = cost_center 63 | 64 | 65 | def make_payament_entry_against_sales_invoice(doc, setting, posting_date=None): 66 | from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry 67 | 68 | payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=setting.cash_bank_account) 69 | payment_entry.flags.ignore_mandatory = True 70 | payment_entry.reference_no = doc.name 71 | payment_entry.posting_date = posting_date or nowdate() 72 | payment_entry.reference_date = posting_date or nowdate() 73 | payment_entry.insert(ignore_permissions=True) 74 | payment_entry.submit() 75 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/page/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/page/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/page/shopify_import_products/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/page/shopify_import_products/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": null, 3 | "creation": "2021-11-29 20:32:10.128244", 4 | "docstatus": 0, 5 | "doctype": "Page", 6 | "idx": 0, 7 | "modified": "2021-11-29 20:32:46.663356", 8 | "modified_by": "Administrator", 9 | "module": "shopify", 10 | "name": "shopify-import-products", 11 | "owner": "Administrator", 12 | "page_name": "shopify-import-products", 13 | "roles": [ 14 | { 15 | "role": "System Manager" 16 | } 17 | ], 18 | "script": null, 19 | "standard": "Yes", 20 | "style": null, 21 | "system_page": 0, 22 | "title": "Shopify Import Products" 23 | } -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/page/shopify_import_products/test_shopify_import_products.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import frappe 5 | import shopify 6 | 7 | from ecommerce_integrations.shopify.product import ShopifyProduct 8 | 9 | from ...tests.utils import TestCase 10 | from .shopify_import_products import queue_sync_all_products 11 | 12 | 13 | class TestShopifyImportProducts(TestCase): 14 | def __init__(self, obj): 15 | with open(os.path.join(os.path.dirname(__file__), "../../tests/data/bulk_products.json"), "rb") as f: 16 | products_json = json.loads(f.read()) 17 | self._products = products_json["products"] 18 | 19 | super().__init__(obj) 20 | 21 | def test_import_all_products(self): 22 | required_products = { 23 | "6808908169263": [ 24 | "40279118250031", 25 | "40279118282799", 26 | "40279118315567", 27 | "40279118348335", 28 | "40279118381103", 29 | "40279118413871", 30 | ], 31 | "6808928124975": [ 32 | "40279218028591", 33 | "40279218061359", 34 | "40279218094127", 35 | "40279218126895", 36 | ], 37 | "6808887689263": ["40279042883631", "40279042916399", "40279042949167"], 38 | "6808908955695": ["40279122673711", "40279122706479", "40279122739247"], 39 | "6808917737519": ["40279168221231", "40279168253999", "40279168286767"], 40 | "6808921735215": [ 41 | "40279189323823", 42 | "40279189356591", 43 | "40279189389359", 44 | "40279189422127", 45 | "40279189454895", 46 | ], 47 | "6808907317295": ["40279113826351", "40279113859119"], 48 | "6808873467951": [ 49 | "40278994944047", 50 | "40278994976815", 51 | "40278995009583", 52 | "40278995042351", 53 | "40278995075119", 54 | ], 55 | "6808929337391": ["40279220551727"], 56 | "6808929304623": ["40279220518959"], 57 | } 58 | 59 | # fake shopify endpoints 60 | self.fake("products", body=self.load_fixture("bulk_products"), extension="json?limit=100") 61 | self.fake("products/count", body='{"count": 10}') 62 | 63 | for product in required_products: 64 | self.fake_single_product_from_bulk(product) 65 | 66 | queue_sync_all_products() 67 | 68 | for product, required_variants in required_products.items(): 69 | # has_variants is needed to avoid get_erpnext_item() 70 | # fetching the variant instead of template because of 71 | # matching integration_item_code 72 | shopify_product = ShopifyProduct( 73 | product_id=product, has_variants=1 if bool(required_variants) else 0 74 | ) 75 | 76 | # product is synced 77 | self.assertTrue(shopify_product.is_synced()) 78 | 79 | item = shopify_product.get_erpnext_item() 80 | 81 | self.assertEqual(bool(item.has_variants), bool(required_variants)) 82 | # self.assertEqual(item.name, str(shopify_product.product_id)) 83 | 84 | variants = frappe.db.get_list("Item", filters={"variant_of": item.name}) 85 | ecom_variants = frappe.db.get_list( 86 | "Ecommerce Item", filters={"variant_of": item.name}, fields="erpnext_item_code" 87 | ) 88 | 89 | created_variants = [v.name for v in variants] 90 | created_ecom_variants = [e.erpnext_item_code for e in ecom_variants] 91 | 92 | # variants are created right 93 | self.assertEqual(sorted(required_variants), sorted(created_variants)) 94 | 95 | self.assertEqual(len(created_ecom_variants), len(required_variants)) 96 | self.assertEqual(sorted(required_variants), sorted(created_ecom_variants)) 97 | 98 | def fake_single_product_from_bulk(self, product): 99 | item = next(p for p in self._products if str(p["id"]) == product) 100 | 101 | product_json = json.dumps({"product": item}) 102 | 103 | self.fake(f"products/{product}", body=product_json) 104 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/shopify/tests/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/tests/data/single_product.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 6732194021530, 3 | "title": "Orange MePhone", 4 | "body_html": "Best Phone in the World", 5 | "vendor": "frappetest", 6 | "product_type": "", 7 | "created_at": "2021-04-29T18:12:13+05:30", 8 | "handle": "orange-mephone", 9 | "updated_at": "2021-04-29T18:12:15+05:30", 10 | "published_at": "2021-04-29T18:12:15+05:30", 11 | "template_suffix": "", 12 | "status": "active", 13 | "published_scope": "web", 14 | "tags": "", 15 | "admin_graphql_api_id": "gid://shopify/Product/6732194021530", 16 | "variants": [ 17 | { 18 | "id": 39933951901850, 19 | "title": "Default Title", 20 | "price": "44000.00", 21 | "sku": "MePHONE-002", 22 | "position": 1, 23 | "inventory_policy": "deny", 24 | "compare_at_price": "100.00", 25 | "fulfillment_service": "manual", 26 | "inventory_management": "shopify", 27 | "option1": "Default Title", 28 | "option2": null, 29 | "option3": null, 30 | "created_at": "2021-04-29T18:12:13+05:30", 31 | "updated_at": "2021-04-29T18:12:13+05:30", 32 | "taxable": true, 33 | "barcode": "", 34 | "grams": 200, 35 | "image_id": null, 36 | "weight": 200.0, 37 | "weight_unit": "g", 38 | "inventory_item_id": 42028371214489, 39 | "inventory_quantity": 99, 40 | "old_inventory_quantity": 99, 41 | "requires_shipping": true, 42 | "admin_graphql_api_id": "gid://shopify/ProductVariant/39933951901850" 43 | } 44 | ], 45 | "options": [ 46 | { 47 | "id": 8626779324569, 48 | "product_id": 6732194021530, 49 | "name": "Title", 50 | "position": 1, 51 | "values": ["Default Title"] 52 | } 53 | ], 54 | "images": [ 55 | { 56 | "id": 29972905001113, 57 | "position": 1, 58 | "created_at": "2021-04-29T18:12:15+05:30", 59 | "updated_at": "2021-04-29T18:12:15+05:30", 60 | "alt": null, 61 | "width": 4000, 62 | "height": 2666, 63 | "src": "https://cdn.shopify.com/s/files/1/0557/8804/4441/products/0_BheVxxvDhXo78Oo3.jpg?v=1619700135", 64 | "variant_ids": [], 65 | "admin_graphql_api_id": "gid://shopify/ProductImage/29972905001113" 66 | } 67 | ], 68 | "image": { 69 | "id": 29972905001113, 70 | "position": 1, 71 | "created_at": "2021-04-29T18:12:15+05:30", 72 | "updated_at": "2021-04-29T18:12:15+05:30", 73 | "alt": null, 74 | "width": 4000, 75 | "height": 2666, 76 | "src": "https://cdn.shopify.com/s/files/1/0557/8804/4441/products/0_BheVxxvDhXo78Oo3.jpg?v=1619700135", 77 | "variant_ids": [], 78 | "admin_graphql_api_id": "gid://shopify/ProductImage/29972905001113" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/tests/test_connection.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | import unittest 5 | 6 | import frappe 7 | from shopify.resources import Webhook 8 | from shopify.session import Session 9 | 10 | from ecommerce_integrations.shopify import connection 11 | from ecommerce_integrations.shopify.constants import API_VERSION, SETTING_DOCTYPE 12 | 13 | 14 | class TestShopifyConnection(unittest.TestCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | cls.setting = frappe.get_doc(SETTING_DOCTYPE) 18 | 19 | @unittest.skip("Can't run these tests in CI") 20 | def test_register_webhooks(self): 21 | webhooks = connection.register_webhooks( 22 | self.setting.shopify_url, self.setting.get_password("password") 23 | ) 24 | 25 | self.assertEqual(len(webhooks), len(connection.WEBHOOK_EVENTS)) 26 | 27 | wh_topics = [wh.topic for wh in webhooks] 28 | self.assertEqual(sorted(wh_topics), sorted(connection.WEBHOOK_EVENTS)) 29 | 30 | @unittest.skip("Can't run these tests in CI") 31 | def test_unregister_webhooks(self): 32 | connection.unregister_webhooks(self.setting.shopify_url, self.setting.get_password("password")) 33 | 34 | callback_url = connection.get_callback_url() 35 | 36 | with Session.temp(self.setting.shopify_url, API_VERSION, self.setting.get_password("password")): 37 | for wh in Webhook.find(): 38 | self.assertNotEqual(wh.address, callback_url) 39 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/tests/test_order.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | import json 5 | import unittest 6 | 7 | from ecommerce_integrations.shopify.order import sync_sales_order 8 | 9 | 10 | class TestOrder(unittest.TestCase): 11 | def test_sync_with_variants(self): 12 | pass 13 | -------------------------------------------------------------------------------- /ecommerce_integrations/shopify/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | import frappe 5 | from frappe import _, _dict 6 | 7 | from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log import ( 8 | create_log, 9 | ) 10 | from ecommerce_integrations.shopify.constants import ( 11 | MODULE_NAME, 12 | OLD_SETTINGS_DOCTYPE, 13 | SETTING_DOCTYPE, 14 | ) 15 | 16 | 17 | def create_shopify_log(**kwargs): 18 | return create_log(module_def=MODULE_NAME, **kwargs) 19 | 20 | 21 | def migrate_from_old_connector(payload=None, request_id=None): 22 | """This function is called to migrate data from old connector to new connector.""" 23 | 24 | if request_id: 25 | log = frappe.get_doc("Ecommerce Integration Log", request_id) 26 | else: 27 | log = create_shopify_log( 28 | status="Queued", 29 | method="ecommerce_integrations.shopify.utils.migrate_from_old_connector", 30 | ) 31 | 32 | frappe.enqueue( 33 | method=_migrate_items_to_ecommerce_item, 34 | queue="long", 35 | is_async=True, 36 | log=log, 37 | ) 38 | 39 | 40 | def ensure_old_connector_is_disabled(): 41 | try: 42 | old_setting = frappe.get_doc(OLD_SETTINGS_DOCTYPE) 43 | except Exception: 44 | frappe.clear_last_message() 45 | return 46 | 47 | if old_setting.enable_shopify: 48 | link = frappe.utils.get_link_to_form(OLD_SETTINGS_DOCTYPE, OLD_SETTINGS_DOCTYPE) 49 | msg = _("Please disable old Shopify integration from {0} to proceed.").format(link) 50 | frappe.throw(msg) 51 | 52 | 53 | def _migrate_items_to_ecommerce_item(log): 54 | shopify_fields = ["shopify_product_id", "shopify_variant_id"] 55 | 56 | for field in shopify_fields: 57 | if not frappe.db.exists({"doctype": "Custom Field", "fieldname": field}): 58 | return 59 | 60 | items = _get_items_to_migrate() 61 | 62 | try: 63 | _create_ecommerce_items(items) 64 | except Exception: 65 | log.status = "Error" 66 | log.traceback = frappe.get_traceback() 67 | log.save() 68 | return 69 | 70 | frappe.db.set_value(SETTING_DOCTYPE, SETTING_DOCTYPE, "is_old_data_migrated", 1) 71 | log.status = "Success" 72 | log.save() 73 | 74 | 75 | def _get_items_to_migrate() -> list[_dict]: 76 | """get all list of items that have shopify fields but do not have associated ecommerce item.""" 77 | 78 | old_data = frappe.db.sql( 79 | """SELECT item.name as erpnext_item_code, shopify_product_id, shopify_variant_id, item.variant_of, item.has_variants 80 | FROM tabItem item 81 | LEFT JOIN `tabEcommerce Item` ei on ei.erpnext_item_code = item.name 82 | WHERE ei.erpnext_item_code IS NULL AND shopify_product_id IS NOT NULL""", 83 | as_dict=True, 84 | ) 85 | 86 | return old_data or [] 87 | 88 | 89 | def _create_ecommerce_items(items: list[_dict]) -> None: 90 | for item in items: 91 | if not all((item.erpnext_item_code, item.shopify_product_id, item.shopify_variant_id)): 92 | continue 93 | 94 | ecommerce_item = frappe.get_doc( 95 | { 96 | "doctype": "Ecommerce Item", 97 | "integration": MODULE_NAME, 98 | "erpnext_item_code": item.erpnext_item_code, 99 | "integration_item_code": item.shopify_product_id, 100 | "variant_id": item.shopify_variant_id, 101 | "variant_of": item.variant_of, 102 | "has_variants": item.has_variants, 103 | } 104 | ) 105 | ecommerce_item.save() 106 | -------------------------------------------------------------------------------- /ecommerce_integrations/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/templates/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/templates/pages/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/tests/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/customer.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | import frappe 5 | from frappe import _ 6 | from frappe.utils.nestedset import get_root_of 7 | 8 | from ecommerce_integrations.unicommerce.constants import ( 9 | ADDRESS_JSON_FIELD, 10 | CUSTOMER_CODE_FIELD, 11 | SETTINGS_DOCTYPE, 12 | UNICOMMERCE_COUNTRY_MAPPING, 13 | UNICOMMERCE_INDIAN_STATES_MAPPING, 14 | ) 15 | 16 | 17 | def sync_customer(order): 18 | """Using order create a new customer. 19 | 20 | Note: Unicommerce doesn't deduplicate customer.""" 21 | customer = _create_new_customer(order) 22 | _create_customer_addresses(order.get("addresses") or [], customer) 23 | return customer 24 | 25 | 26 | def _create_new_customer(order): 27 | """Create a new customer from Sales Order address data""" 28 | 29 | address = order.get("billingAddress") or (order.get("addresses") and order.get("addresses")[0]) 30 | address.pop("id", None) # this is not important and can be different for same address 31 | customer_code = order.get("customerCode") 32 | 33 | customer = _check_if_customer_exists(address, customer_code) 34 | if customer: 35 | return customer 36 | 37 | setting = frappe.get_cached_doc(SETTINGS_DOCTYPE) 38 | customer_group = ( 39 | frappe.db.get_value( 40 | "Unicommerce Channel", {"channel_id": order["channel"]}, fieldname="customer_group" 41 | ) 42 | or setting.default_customer_group 43 | ) 44 | 45 | name = address.get("name") or order["channel"] + " customer" 46 | customer = frappe.get_doc( 47 | { 48 | "doctype": "Customer", 49 | "customer_name": name, 50 | "customer_group": customer_group, 51 | "territory": get_root_of("Territory"), 52 | "customer_type": "Individual", 53 | ADDRESS_JSON_FIELD: json.dumps(address), 54 | CUSTOMER_CODE_FIELD: customer_code, 55 | } 56 | ) 57 | 58 | customer.flags.ignore_mandatory = True 59 | customer.insert(ignore_permissions=True) 60 | 61 | return customer 62 | 63 | 64 | def _check_if_customer_exists(address, customer_code): 65 | """Very crude method to determine if same customer exists. 66 | 67 | If ALL address fields match then new customer is not created""" 68 | 69 | customer_name = None 70 | 71 | if customer_code: 72 | customer_name = frappe.db.get_value("Customer", {CUSTOMER_CODE_FIELD: customer_code}) 73 | 74 | if not customer_name: 75 | customer_name = frappe.db.get_value("Customer", {ADDRESS_JSON_FIELD: json.dumps(address)}) 76 | 77 | if customer_name: 78 | return frappe.get_doc("Customer", customer_name) 79 | 80 | 81 | def _create_customer_addresses(addresses: list[dict[str, Any]], customer) -> None: 82 | """Create address from dictionary containing fields used in Address doctype of ERPNext. 83 | 84 | Unicommerce orders contain address list, 85 | if there is only one address it's both shipping and billing, 86 | else first is billing and second is shipping""" 87 | 88 | if len(addresses) == 1: 89 | _create_customer_address(addresses[0], "Billing", customer, also_shipping=True) 90 | elif len(addresses) >= 2: 91 | _create_customer_address(addresses[0], "Billing", customer) 92 | _create_customer_address(addresses[1], "Shipping", customer) 93 | 94 | 95 | def _create_customer_address(uni_address, address_type, customer, also_shipping=False): 96 | country_code = uni_address.get("country") 97 | country = UNICOMMERCE_COUNTRY_MAPPING.get(country_code) 98 | 99 | state = uni_address.get("state") 100 | if country_code == "IN" and state in UNICOMMERCE_INDIAN_STATES_MAPPING: 101 | state = UNICOMMERCE_INDIAN_STATES_MAPPING.get(state) 102 | 103 | frappe.get_doc( 104 | { 105 | "address_line1": uni_address.get("addressLine1") or "Not provided", 106 | "address_line2": uni_address.get("addressLine2"), 107 | "address_type": address_type, 108 | "city": uni_address.get("city"), 109 | "country": country, 110 | "county": uni_address.get("district"), 111 | "doctype": "Address", 112 | "email_id": uni_address.get("email"), 113 | "phone": uni_address.get("phone"), 114 | "pincode": uni_address.get("pincode"), 115 | "state": state, 116 | "links": [{"link_doctype": "Customer", "link_name": customer.name}], 117 | "is_primary_address": int(address_type == "Billing"), 118 | "is_shipping_address": int(also_shipping or address_type == "Shipping"), 119 | } 120 | ).insert(ignore_mandatory=True) 121 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/delivery_note.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from ecommerce_integrations.unicommerce.api_client import UnicommerceAPIClient 4 | from ecommerce_integrations.unicommerce.constants import ORDER_CODE_FIELD, SETTINGS_DOCTYPE 5 | from ecommerce_integrations.unicommerce.utils import create_unicommerce_log 6 | 7 | 8 | @frappe.whitelist() 9 | def prepare_delivery_note(): 10 | try: 11 | settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) 12 | if not settings.delivery_note: 13 | return 14 | 15 | client = UnicommerceAPIClient() 16 | 17 | days_to_sync = min(settings.get("order_status_days") or 2, 14) 18 | minutes = days_to_sync * 24 * 60 19 | 20 | # find all Facilities 21 | enabled_facilities = list(settings.get_integration_to_erpnext_wh_mapping().keys()) 22 | enabled_channels = frappe.db.get_list( 23 | "Unicommerce Channel", filters={"enabled": 1}, pluck="channel_id" 24 | ) 25 | 26 | for facility in enabled_facilities: 27 | updated_packages = client.search_shipping_packages(updated_since=minutes, facility_code=facility) 28 | valid_packages = [p for p in updated_packages if p.get("channel") in enabled_channels] 29 | if not valid_packages: 30 | continue 31 | shipped_packages = [p for p in valid_packages if p["status"] in ["DISPATCHED"]] 32 | for order in shipped_packages: 33 | if not frappe.db.exists( 34 | "Delivery Note", {"unicommerce_shipment_id": order["code"]}, "name" 35 | ) and frappe.db.exists("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}): 36 | sales_order = frappe.get_doc("Sales Order", {ORDER_CODE_FIELD: order["saleOrderCode"]}) 37 | if frappe.db.exists( 38 | "Sales Invoice", {"unicommerce_order_code": sales_order.unicommerce_order_code} 39 | ): 40 | sales_invoice = frappe.get_doc( 41 | "Sales Invoice", {"unicommerce_order_code": sales_order.unicommerce_order_code} 42 | ) 43 | create_delivery_note(sales_order, sales_invoice) 44 | except Exception as e: 45 | create_unicommerce_log(status="Error", exception=e, rollback=True) 46 | 47 | 48 | def create_delivery_note(so, sales_invoice): 49 | # Create the delivery note 50 | from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note 51 | 52 | res = make_delivery_note(source_name=so.name) 53 | res.unicommerce_order_code = sales_invoice.unicommerce_order_code 54 | res.unicommerce_shipment_id = sales_invoice.unicommerce_shipping_package_code 55 | res.save() 56 | res.submit() 57 | log = create_unicommerce_log(method="create_delevery_note", make_new=True) 58 | frappe.flags.request_id = log.name 59 | create_unicommerce_log(status="Success") 60 | frappe.flags.request_id = None 61 | return res 62 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/pick_list_sales_order_details/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/pick_list_sales_order_details/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/pick_list_sales_order_details/pick_list_sales_order_details.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2023-04-19 11:57:15.149202", 4 | "default_view": "List", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "sales_order", 10 | "sales_invoice", 11 | "posting_date", 12 | "pick_status", 13 | "invoice_url", 14 | "invoice_pdf" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "sales_order", 19 | "fieldtype": "Link", 20 | "in_list_view": 1, 21 | "label": "Sales Order", 22 | "options": "Sales Order" 23 | }, 24 | { 25 | "fieldname": "sales_invoice", 26 | "fieldtype": "Link", 27 | "in_list_view": 1, 28 | "label": "Sales Invoice", 29 | "options": "Sales Invoice" 30 | }, 31 | { 32 | "fetch_from": "sales_invoice.posting_date", 33 | "fieldname": "posting_date", 34 | "fieldtype": "Date", 35 | "label": "Posting Date" 36 | }, 37 | { 38 | "fieldname": "pick_status", 39 | "fieldtype": "Select", 40 | "in_list_view": 1, 41 | "label": "Pick Status", 42 | "options": "\nPartially Picked\nFully Picked" 43 | }, 44 | { 45 | "fieldname": "invoice_url", 46 | "fieldtype": "Data", 47 | "hidden": 1, 48 | "label": "Invoice URL" 49 | }, 50 | { 51 | "fieldname": "invoice_pdf", 52 | "fieldtype": "Attach", 53 | "in_list_view": 1, 54 | "label": "Invoice Pdf" 55 | } 56 | ], 57 | "index_web_pages_for_search": 1, 58 | "istable": 1, 59 | "links": [], 60 | "modified": "2023-05-02 19:52:50.157639", 61 | "modified_by": "Administrator", 62 | "module": "unicommerce", 63 | "name": "Pick List Sales Order Details", 64 | "owner": "Administrator", 65 | "permissions": [], 66 | "sort_field": "modified", 67 | "sort_order": "DESC", 68 | "states": [], 69 | "track_changes": 1 70 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/pick_list_sales_order_details/pick_list_sales_order_details.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class PickListSalesOrderDetails(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_channel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_channel/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_channel/test_records.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "doctype": "Unicommerce Channel", 4 | "display_name": "amazon", 5 | "channel_id": "RAINFOREST", 6 | "enabled": 1, 7 | "company": "Wind Power LLC", 8 | "warehouse": "Stores - WP", 9 | "return_warehouse": "Stores - WP", 10 | "customer_group": "Individual", 11 | "fnf_account": "Freight and Forwarding Charges - WP", 12 | "cod_account": "Freight and Forwarding Charges - WP", 13 | "igst_account": "Output Tax GST - WP", 14 | "cgst_account": "Output Tax GST - WP", 15 | "sgst_account": "Output Tax GST - WP", 16 | "ugst_account": "Output Tax GST - WP", 17 | "tcs_account": "Output Tax GST - WP", 18 | "cost_center": "Main - WP", 19 | "cash_or_bank_account": "Cash - WP", 20 | "gift_wrap_account": "Miscellaneous Expenses - WP", 21 | "shipping_handled_by_marketplace": 0 22 | }, 23 | { 24 | "doctype": "Unicommerce Channel", 25 | "display_name": "custom", 26 | "channel_id": "CUSTOM_FF_TEST", 27 | "enabled": 1, 28 | "company": "Wind Power LLC", 29 | "warehouse": "Stores - WP", 30 | "return_warehouse": "Stores - WP", 31 | "customer_group": "Individual", 32 | "fnf_account": "Freight and Forwarding Charges - WP", 33 | "cod_account": "Freight and Forwarding Charges - WP", 34 | "igst_account": "Output Tax GST - WP", 35 | "cgst_account": "Output Tax GST - WP", 36 | "sgst_account": "Output Tax GST - WP", 37 | "ugst_account": "Output Tax GST - WP", 38 | "tcs_account": "Output Tax GST - WP", 39 | "cost_center": "Main - WP", 40 | "cash_or_bank_account": "Cash - WP", 41 | "gift_wrap_account": "Miscellaneous Expenses - WP" 42 | } 43 | ] 44 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_channel/test_unicommerce_channel.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestUnicommerceChannel(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_channel/unicommerce_channel.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Channel", { 5 | onload: function (frm) { 6 | frappe.call({ 7 | method: "ecommerce_integrations.utils.naming_series.get_series", 8 | callback: function (r) { 9 | $.each(r.message, (key, value) => { 10 | set_field_options(key, value); 11 | }); 12 | }, 13 | }); 14 | 15 | frm.set_query("cost_center", () => ({ 16 | filters: { company: frm.doc.company, is_group: 0 }, 17 | })); 18 | 19 | ["warehouse", "return_warehouse"].forEach((wh_field) => 20 | frm.set_query(wh_field, () => ({ 21 | filters: { 22 | company: frm.doc.company, 23 | is_group: 0, 24 | disabled: 0, 25 | }, 26 | })) 27 | ); 28 | 29 | const tax_accounts = [ 30 | "igst_account", 31 | "cgst_account", 32 | "sgst_account", 33 | "ugst_account", 34 | "tcs_account", 35 | ]; 36 | 37 | const misc_accounts = [ 38 | "fnf_account", 39 | "cod_account", 40 | "gift_wrap_account", 41 | ]; 42 | 43 | tax_accounts.forEach((field_name) => { 44 | frm.set_query(field_name, () => ({ 45 | query: "erpnext.controllers.queries.tax_account_query", 46 | filters: { 47 | account_type: ["Tax"], 48 | company: frm.doc.company, 49 | }, 50 | })); 51 | }); 52 | 53 | misc_accounts.forEach((field_name) => { 54 | frm.set_query(field_name, () => ({ 55 | query: "erpnext.controllers.queries.tax_account_query", 56 | filters: { 57 | account_type: ["Chargeable", "Expense Account"], 58 | company: frm.doc.company, 59 | }, 60 | })); 61 | }); 62 | 63 | frm.set_query("cash_or_bank_account", () => ({ 64 | filters: { 65 | company: frm.doc.company, 66 | is_group: 0, 67 | root_type: "Asset", 68 | account_type: ["in", ["Cash", "Bank"]], 69 | }, 70 | })); 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_channel/unicommerce_channel.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | import frappe 5 | from frappe import _ 6 | from frappe.model.document import Document 7 | 8 | 9 | class UnicommerceChannel(Document): 10 | def validate(self): 11 | self.__check_compnay() 12 | 13 | def __check_compnay(self): 14 | company_fields = { 15 | "warehouse": "Warehouse", 16 | "fnf_account": "Account", 17 | "cod_account": "Account", 18 | "gift_wrap_account": "Account", 19 | "igst_account": "Account", 20 | "cgst_account": "Account", 21 | "sgst_account": "Account", 22 | "ugst_account": "Account", 23 | "tcs_account": "Account", 24 | "cash_or_bank_account": "Account", 25 | "cost_center": "Cost Center", 26 | } 27 | 28 | for field, doctype in company_fields.items(): 29 | if self.company != frappe.db.get_value(doctype, self.get(field), "company", cache=True): 30 | frappe.throw( 31 | _("{}: {} does not belong to company {}").format(doctype, self.get(field), self.company) 32 | ) 33 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_manifest_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_manifest_item/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_manifest_item/unicommerce_manifest_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-09-20 19:30:34.709746", 4 | "doctype": "DocType", 5 | "engine": "InnoDB", 6 | "field_order": [ 7 | "sales_invoice", 8 | "shipping_package_code", 9 | "unicommerce_sales_order", 10 | "awb_no", 11 | "shipping_address", 12 | "item_list", 13 | "facility_code", 14 | "awb_barcode" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "shipping_package_code", 19 | "fieldtype": "Data", 20 | "in_list_view": 1, 21 | "label": "Shipping Package Code", 22 | "print_hide": 1, 23 | "read_only": 1 24 | }, 25 | { 26 | "fieldname": "sales_invoice", 27 | "fieldtype": "Link", 28 | "in_list_view": 1, 29 | "label": "Sales Invoice", 30 | "options": "Sales Invoice", 31 | "print_hide": 1, 32 | "reqd": 1 33 | }, 34 | { 35 | "fieldname": "unicommerce_sales_order", 36 | "fieldtype": "Data", 37 | "in_list_view": 1, 38 | "label": "Unicommerce Sales Order", 39 | "read_only": 1 40 | }, 41 | { 42 | "fieldname": "awb_no", 43 | "fieldtype": "Data", 44 | "label": "AWB Number", 45 | "read_only": 1 46 | }, 47 | { 48 | "fieldname": "shipping_address", 49 | "fieldtype": "Text", 50 | "label": "Shipping Address", 51 | "read_only": 1 52 | }, 53 | { 54 | "fieldname": "item_list", 55 | "fieldtype": "Small Text", 56 | "label": "Item List", 57 | "read_only": 1 58 | }, 59 | { 60 | "fieldname": "facility_code", 61 | "fieldtype": "Data", 62 | "label": "Facility Code", 63 | "read_only": 1 64 | }, 65 | { 66 | "fieldname": "awb_barcode", 67 | "fieldtype": "Barcode", 68 | "label": "AWB Barcode" 69 | } 70 | ], 71 | "index_web_pages_for_search": 1, 72 | "istable": 1, 73 | "links": [], 74 | "modified": "2021-11-08 12:57:33.054917", 75 | "modified_by": "Administrator", 76 | "module": "unicommerce", 77 | "name": "Unicommerce Manifest Item", 78 | "owner": "Administrator", 79 | "permissions": [], 80 | "sort_field": "modified", 81 | "sort_order": "DESC", 82 | "track_changes": 1 83 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_manifest_item/unicommerce_manifest_item.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class UnicommerceManifestItem(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/test_records.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "doctype": "Unicommerce Package Type", 4 | "package_type": "DEFAULT", 5 | "package_type_code": "DEFAULT", 6 | "length": 30, 7 | "width": 20, 8 | "height": 10 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/test_unicommerce_package_type.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestUnicommercePackageType(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/unicommerce_package_type.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Package Type", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/unicommerce_package_type.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:package_type", 4 | "creation": "2021-09-08 12:09:43.333415", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "title", 10 | "package_type", 11 | "package_type_code", 12 | "length", 13 | "width", 14 | "height" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "title", 19 | "fieldtype": "Data", 20 | "hidden": 1, 21 | "in_list_view": 1, 22 | "label": "Title" 23 | }, 24 | { 25 | "fieldname": "package_type", 26 | "fieldtype": "Data", 27 | "label": "Package Type", 28 | "reqd": 1, 29 | "unique": 1 30 | }, 31 | { 32 | "fieldname": "length", 33 | "fieldtype": "Int", 34 | "label": "Length (mm)", 35 | "reqd": 1 36 | }, 37 | { 38 | "fieldname": "width", 39 | "fieldtype": "Int", 40 | "label": "Width (mm)", 41 | "reqd": 1 42 | }, 43 | { 44 | "fieldname": "height", 45 | "fieldtype": "Int", 46 | "label": "Height (mm)", 47 | "reqd": 1 48 | }, 49 | { 50 | "default": "DEFAULT", 51 | "fieldname": "package_type_code", 52 | "fieldtype": "Data", 53 | "label": "Unicommerce Package Type Code", 54 | "reqd": 1 55 | } 56 | ], 57 | "index_web_pages_for_search": 1, 58 | "links": [], 59 | "modified": "2021-09-13 10:45:34.722118", 60 | "modified_by": "Administrator", 61 | "module": "unicommerce", 62 | "name": "Unicommerce Package Type", 63 | "owner": "Administrator", 64 | "permissions": [ 65 | { 66 | "create": 1, 67 | "delete": 1, 68 | "email": 1, 69 | "export": 1, 70 | "print": 1, 71 | "read": 1, 72 | "report": 1, 73 | "role": "System Manager", 74 | "share": 1, 75 | "write": 1 76 | } 77 | ], 78 | "sort_field": "modified", 79 | "sort_order": "DESC", 80 | "title_field": "title", 81 | "track_changes": 1 82 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_package_type/unicommerce_package_type.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | from frappe.utils import cint 7 | 8 | 9 | class UnicommercePackageType(Document): 10 | def validate(self): 11 | self.__update_title() 12 | self.__validate_sizes() 13 | 14 | def __update_title(self): 15 | self.title = f"{self.package_type}: {self.length}x{self.width}x{self.height}" 16 | 17 | def __validate_sizes(self): 18 | fields = ["length", "width", "height"] 19 | 20 | for field in fields: 21 | if cint(self.get(field)) <= 0: 22 | frappe.throw(frappe._("Positive value required for {}").format(field)) 23 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_settings/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_settings/test_unicommerce_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | import frappe 5 | import responses 6 | from frappe.utils import now, now_datetime 7 | 8 | from ecommerce_integrations.unicommerce.constants import SETTINGS_DOCTYPE 9 | from ecommerce_integrations.unicommerce.tests.utils import TestCase 10 | 11 | 12 | class TestUnicommerceSettings(TestCase): 13 | @classmethod 14 | def setUpClass(cls): 15 | super().setUpClass() 16 | settings = frappe.get_doc(SETTINGS_DOCTYPE) 17 | settings.unicommerce_site = "demostaging.unicommerce.com" 18 | settings.username = "frappe" 19 | settings.password = "hunter2" 20 | 21 | cls.settings = settings 22 | 23 | @responses.activate 24 | def test_authentication(self): 25 | """requirement: When saved the system get acess/refresh tokens from unicommerce.""" 26 | 27 | responses.add( 28 | responses.GET, 29 | "https://demostaging.unicommerce.com/oauth/token?grant_type=password&username=frappe&password=hunter2&client_id=my-trusted-client", 30 | json=self.load_fixture("authentication"), 31 | status=200, 32 | match_querystring=True, 33 | ) 34 | 35 | self.settings.update_tokens() 36 | 37 | self.assertEqual(self.settings.access_token, "1211cf66-d9b3-498b-a8a4-04c76578b72e") 38 | self.assertEqual(self.settings.refresh_token, "18f96b68-bdf4-4c5f-93f2-16e2c6e674c6") 39 | self.assertEqual(self.settings.token_type, "bearer") 40 | self.assertTrue(str(self.settings.expires_on) > now()) 41 | 42 | @responses.activate 43 | def test_failed_auth(self): 44 | """requirement: When improper credentials are provided, system throws error.""" 45 | 46 | # failure case 47 | responses.add(responses.GET, "https://demostaging.unicommerce.com/oauth/token", json={}, status=401) 48 | self.assertRaises(frappe.ValidationError, self.settings.update_tokens) 49 | 50 | @responses.activate 51 | def test_refresh_tokens(self): 52 | """requirement: The system has functionality to refresh token periodically. This is used by UnicommerceAPIClient to ensure that token is valid before using it.""" 53 | url = "https://demostaging.unicommerce.com/oauth/token?grant_type=refresh_token&client_id=my-trusted-client&refresh_token=REFRESH_TOKEN" 54 | responses.add( 55 | responses.GET, 56 | url, 57 | json=self.load_fixture("authentication"), 58 | status=200, 59 | match_querystring=True, 60 | ) 61 | 62 | self.settings.expires_on = now_datetime() # to trigger refresh 63 | self.settings.refresh_token = "REFRESH_TOKEN" 64 | self.settings.update_tokens(grant_type="refresh_token") 65 | 66 | self.assertEqual(self.settings.access_token, "1211cf66-d9b3-498b-a8a4-04c76578b72e") 67 | self.assertEqual(self.settings.refresh_token, "18f96b68-bdf4-4c5f-93f2-16e2c6e674c6") 68 | self.assertEqual(self.settings.token_type, "bearer") 69 | self.assertTrue(str(self.settings.expires_on) > now()) 70 | self.assertTrue(responses.assert_call_count(url, 1)) 71 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_settings/unicommerce_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Settings", { 5 | refresh(frm) { 6 | if (!frm.doc.enable_unicommerce) { 7 | return; 8 | } 9 | 10 | frm.add_custom_button(__("View Logs"), () => { 11 | frappe.set_route("List", "Ecommerce Integration Log", { 12 | integration: "Unicommerce", 13 | }); 14 | }); 15 | 16 | let sync_buttons = ["Items", "Orders", "Inventory"]; 17 | 18 | sync_buttons.forEach((action) => { 19 | frm.add_custom_button( 20 | action, 21 | () => { 22 | frappe.call({ 23 | method: "ecommerce_integrations.unicommerce.utils.force_sync", 24 | args: { 25 | document: action, 26 | }, 27 | callback: (r) => { 28 | if (!r.exc) { 29 | frappe.msgprint(__(`Intiated ${action} Sync.`)); 30 | } 31 | }, 32 | }); 33 | }, 34 | __("Sync Now") 35 | ); 36 | }); 37 | }, 38 | 39 | onload: function (frm) { 40 | // naming series options 41 | frappe.call({ 42 | method: "ecommerce_integrations.utils.naming_series.get_series", 43 | callback: function (r) { 44 | $.each(r.message, (key, value) => { 45 | set_field_options(key, value); 46 | }); 47 | }, 48 | }); 49 | 50 | frm.fields_dict["warehouse_mapping"].grid.get_field( 51 | "erpnext_warehouse" 52 | ).get_query = function (doc) { 53 | return { 54 | filters: { 55 | disabled: 0, 56 | }, 57 | }; 58 | }; 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipment_manifest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_shipment_manifest/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipment_manifest/test_unicommerce_shipment_manifest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestUnicommerceShipmentManifest(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipment_manifest/unicommerce_shipment_manifest.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Shipment Manifest", { 5 | refresh(frm) { 6 | if (frm.doc.unicommerce_manifest_code) { 7 | // add button to open unicommerce order from SO page 8 | frm.add_custom_button( 9 | __("Open on Unicommerce"), 10 | function () { 11 | frappe.call({ 12 | method: "ecommerce_integrations.unicommerce.utils.get_unicommerce_document_url", 13 | args: { 14 | code: frm.doc.unicommerce_manifest_code, 15 | doctype: frm.doc.doctype, 16 | }, 17 | callback: function (r) { 18 | if (!r.exc) { 19 | window.open(r.message, "_blank"); 20 | } 21 | }, 22 | }); 23 | }, 24 | __("Unicommerce") 25 | ); 26 | } 27 | if (frm.doc.docstatus != 0) { 28 | return; 29 | } 30 | frm.add_custom_button(__("Get Packages"), () => { 31 | if ( 32 | !( 33 | frm.doc.channel_id && 34 | frm.doc.shipping_method_code && 35 | frm.doc.shipping_provider_code 36 | ) 37 | ) { 38 | frappe.msgprint( 39 | __( 40 | "Please select Channel, Shipping method and Shipping provider first" 41 | ) 42 | ); 43 | return; 44 | } 45 | erpnext.utils.map_current_doc({ 46 | method: "ecommerce_integrations.unicommerce.doctype.unicommerce_shipment_manifest.unicommerce_shipment_manifest.get_shipping_package_list", 47 | source_doctype: "Sales Invoice", 48 | target: frm.doc, 49 | setters: [ 50 | { 51 | fieldtype: "Data", 52 | label: __("Shipping Package"), 53 | fieldname: "unicommerce_shipping_package_code", 54 | default: "", 55 | }, 56 | { 57 | fieldtype: "Data", 58 | label: __("Unicommerce Order"), 59 | fieldname: "unicommerce_order_code", 60 | default: "", 61 | }, 62 | 63 | { 64 | fieldtype: "Data", 65 | label: __("Tracking Code"), 66 | fieldname: "unicommerce_tracking_code", 67 | default: "", 68 | }, 69 | { 70 | fieldtype: "Data", 71 | label: __("Unicommerce Invoice"), 72 | fieldname: "unicommerce_invoice_code", 73 | default: "", 74 | }, 75 | ], 76 | get_query_filters: { 77 | docstatus: 1, 78 | unicommerce_shipping_method: frm.doc.shipping_method_code, 79 | unicommerce_shipping_provider: 80 | frm.doc.shipping_provider_code, 81 | unicommerce_channel_id: frm.doc.channel_id, 82 | unicommerce_manifest_generated: 0, 83 | }, 84 | }); 85 | }); 86 | }, 87 | 88 | scan_barcode: function (frm) { 89 | if (!frm.doc.scan_barcode) { 90 | return false; 91 | } 92 | 93 | frappe 94 | .xcall( 95 | "ecommerce_integrations.unicommerce.doctype.unicommerce_shipment_manifest.unicommerce_shipment_manifest.search_packages", 96 | { 97 | search_term: frm.doc.scan_barcode, 98 | shipper: frm.doc.shipping_provider_code, 99 | channel: frm.doc.channel_id, 100 | } 101 | ) 102 | .then((invoice) => { 103 | if (!invoice) { 104 | frappe.show_alert({ 105 | message: __("Could not find the package."), 106 | indicator: "red", 107 | }); 108 | return; 109 | } 110 | 111 | let cur_grid = frm.fields_dict.manifest_items.grid; 112 | 113 | const already_exists = frm.doc.manifest_items.find( 114 | (d) => d.sales_invoice === invoice 115 | ); 116 | if (already_exists) { 117 | frappe.show_alert({ 118 | message: __("Package already added in this manifest"), 119 | indicator: "red", 120 | }); 121 | return; 122 | } 123 | 124 | let new_row = frappe.model.add_child( 125 | frm.doc, 126 | cur_grid.doctype, 127 | "manifest_items" 128 | ); 129 | 130 | frappe.model.set_value( 131 | new_row.doctype, 132 | new_row.name, 133 | "sales_invoice", 134 | invoice 135 | ); 136 | }) 137 | .finally(() => { 138 | frm.fields_dict.scan_barcode.set_value(""); 139 | refresh_field("manifest_items"); 140 | }); 141 | }, 142 | }); 143 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipment_manifest/unicommerce_shipment_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "format:UNI-MNFST-{YY}-{#####}", 4 | "creation": "2021-09-20 12:36:12.691753", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "manifest_code", 10 | "amended_from", 11 | "channel_id", 12 | "shipping_provider_code", 13 | "column_break_5", 14 | "shipping_method_code", 15 | "third_party_shipping", 16 | "section_break_9", 17 | "scan_barcode", 18 | "section_break_8", 19 | "manifest_items", 20 | "unicommerce_manifest_code", 21 | "unicommerce_manifest_id" 22 | ], 23 | "fields": [ 24 | { 25 | "fieldname": "manifest_code", 26 | "fieldtype": "Data", 27 | "label": "Manifest Code", 28 | "read_only": 1 29 | }, 30 | { 31 | "fieldname": "amended_from", 32 | "fieldtype": "Link", 33 | "label": "Amended From", 34 | "no_copy": 1, 35 | "options": "Unicommerce Shipment Manifest", 36 | "print_hide": 1, 37 | "read_only": 1 38 | }, 39 | { 40 | "fieldname": "channel_id", 41 | "fieldtype": "Link", 42 | "in_list_view": 1, 43 | "label": "Channel ID", 44 | "options": "Unicommerce Channel", 45 | "reqd": 1 46 | }, 47 | { 48 | "fieldname": "shipping_provider_code", 49 | "fieldtype": "Link", 50 | "label": "Shipping Provider Code", 51 | "options": "Unicommerce Shipping Provider", 52 | "reqd": 1 53 | }, 54 | { 55 | "default": "0", 56 | "fieldname": "third_party_shipping", 57 | "fieldtype": "Check", 58 | "label": "Marketplace Shipping", 59 | "options": "1", 60 | "read_only": 1 61 | }, 62 | { 63 | "fieldname": "shipping_method_code", 64 | "fieldtype": "Link", 65 | "label": "Shipping Method", 66 | "options": "Unicommerce Shipping Method", 67 | "reqd": 1 68 | }, 69 | { 70 | "fieldname": "manifest_items", 71 | "fieldtype": "Table", 72 | "label": "Manifest Items", 73 | "options": "Unicommerce Manifest Item" 74 | }, 75 | { 76 | "fieldname": "column_break_5", 77 | "fieldtype": "Column Break" 78 | }, 79 | { 80 | "fieldname": "section_break_8", 81 | "fieldtype": "Section Break" 82 | }, 83 | { 84 | "fieldname": "unicommerce_manifest_code", 85 | "fieldtype": "Data", 86 | "label": "Unicommerce Manifest Code", 87 | "read_only": 1 88 | }, 89 | { 90 | "fieldname": "unicommerce_manifest_id", 91 | "fieldtype": "Data", 92 | "label": "Unicommerce Manifest Id", 93 | "read_only": 1 94 | }, 95 | { 96 | "fieldname": "section_break_9", 97 | "fieldtype": "Section Break" 98 | }, 99 | { 100 | "fieldname": "scan_barcode", 101 | "fieldtype": "Data", 102 | "label": "Scan AWB Code", 103 | "options": "Barcode" 104 | } 105 | ], 106 | "index_web_pages_for_search": 1, 107 | "is_submittable": 1, 108 | "links": [], 109 | "modified": "2021-10-07 15:39:49.456739", 110 | "modified_by": "Administrator", 111 | "module": "unicommerce", 112 | "name": "Unicommerce Shipment Manifest", 113 | "owner": "Administrator", 114 | "permissions": [ 115 | { 116 | "amend": 1, 117 | "cancel": 1, 118 | "create": 1, 119 | "delete": 1, 120 | "email": 1, 121 | "export": 1, 122 | "print": 1, 123 | "read": 1, 124 | "report": 1, 125 | "role": "System Manager", 126 | "share": 1, 127 | "submit": 1, 128 | "write": 1 129 | }, 130 | { 131 | "amend": 1, 132 | "cancel": 1, 133 | "create": 1, 134 | "delete": 1, 135 | "email": 1, 136 | "export": 1, 137 | "print": 1, 138 | "read": 1, 139 | "report": 1, 140 | "role": "Stock Manager", 141 | "share": 1, 142 | "submit": 1, 143 | "write": 1 144 | }, 145 | { 146 | "amend": 1, 147 | "cancel": 1, 148 | "create": 1, 149 | "delete": 1, 150 | "email": 1, 151 | "export": 1, 152 | "print": 1, 153 | "read": 1, 154 | "report": 1, 155 | "role": "Stock User", 156 | "share": 1, 157 | "submit": 1, 158 | "write": 1 159 | } 160 | ], 161 | "sort_field": "modified", 162 | "sort_order": "DESC", 163 | "title_field": "manifest_code", 164 | "track_changes": 1 165 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/test_unicommerce_shipping_method.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestUnicommerceShippingMethod(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/unicommerce_shipping_method.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Shipping Method", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/unicommerce_shipping_method.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:code", 4 | "creation": "2021-09-20 19:23:18.370140", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "code", 10 | "title" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "code", 15 | "fieldtype": "Data", 16 | "in_list_view": 1, 17 | "label": "Shipping Method Code", 18 | "reqd": 1, 19 | "unique": 1 20 | }, 21 | { 22 | "fieldname": "title", 23 | "fieldtype": "Data", 24 | "in_list_view": 1, 25 | "label": "Shipping Method Name", 26 | "reqd": 1 27 | } 28 | ], 29 | "index_web_pages_for_search": 1, 30 | "links": [], 31 | "modified": "2021-09-20 19:23:18.370140", 32 | "modified_by": "Administrator", 33 | "module": "unicommerce", 34 | "name": "Unicommerce Shipping Method", 35 | "owner": "Administrator", 36 | "permissions": [ 37 | { 38 | "create": 1, 39 | "delete": 1, 40 | "email": 1, 41 | "export": 1, 42 | "print": 1, 43 | "read": 1, 44 | "report": 1, 45 | "role": "System Manager", 46 | "share": 1, 47 | "write": 1 48 | } 49 | ], 50 | "sort_field": "modified", 51 | "sort_order": "DESC", 52 | "title_field": "title", 53 | "track_changes": 1 54 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_method/unicommerce_shipping_method.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class UnicommerceShippingMethod(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/test_unicommerce_shipping_provider.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestUnicommerceShippingProvider(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/unicommerce_shipping_provider.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Unicommerce Shipping Provider", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/unicommerce_shipping_provider.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:code", 4 | "creation": "2021-09-20 19:25:11.035169", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "code", 10 | "title" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "code", 15 | "fieldtype": "Data", 16 | "label": "Shipping Provider Code", 17 | "unique": 1 18 | }, 19 | { 20 | "fieldname": "title", 21 | "fieldtype": "Data", 22 | "label": "Shipping Provider Name" 23 | } 24 | ], 25 | "index_web_pages_for_search": 1, 26 | "links": [], 27 | "modified": "2021-09-20 19:25:11.035169", 28 | "modified_by": "Administrator", 29 | "module": "unicommerce", 30 | "name": "Unicommerce Shipping Provider", 31 | "owner": "Administrator", 32 | "permissions": [ 33 | { 34 | "create": 1, 35 | "delete": 1, 36 | "email": 1, 37 | "export": 1, 38 | "print": 1, 39 | "read": 1, 40 | "report": 1, 41 | "role": "System Manager", 42 | "share": 1, 43 | "write": 1 44 | } 45 | ], 46 | "sort_field": "modified", 47 | "sort_order": "DESC", 48 | "title_field": "title", 49 | "track_changes": 1 50 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_shipping_provider/unicommerce_shipping_provider.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class UnicommerceShippingProvider(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_warehouses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/doctype/unicommerce_warehouses/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_warehouses/unicommerce_warehouses.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-06-17 12:42:02.012702", 4 | "doctype": "DocType", 5 | "engine": "InnoDB", 6 | "field_order": [ 7 | "enabled", 8 | "unicommerce_facility_code", 9 | "erpnext_warehouse", 10 | "column_break_2", 11 | "return_warehouse", 12 | "company_address", 13 | "dispatch_address" 14 | ], 15 | "fields": [ 16 | { 17 | "fieldname": "unicommerce_facility_code", 18 | "fieldtype": "Data", 19 | "in_list_view": 1, 20 | "label": "Unicommerce Facility Code", 21 | "reqd": 1 22 | }, 23 | { 24 | "fieldname": "erpnext_warehouse", 25 | "fieldtype": "Link", 26 | "in_list_view": 1, 27 | "label": "ERPNext Warehouse", 28 | "options": "Warehouse", 29 | "reqd": 1 30 | }, 31 | { 32 | "default": "1", 33 | "fieldname": "enabled", 34 | "fieldtype": "Check", 35 | "in_list_view": 1, 36 | "label": "Enabled" 37 | }, 38 | { 39 | "fieldname": "column_break_2", 40 | "fieldtype": "Column Break" 41 | }, 42 | { 43 | "fieldname": "return_warehouse", 44 | "fieldtype": "Link", 45 | "label": "Return Warehouse", 46 | "options": "Warehouse", 47 | "reqd": 1 48 | }, 49 | { 50 | "fieldname": "company_address", 51 | "fieldtype": "Link", 52 | "label": "Company Address", 53 | "options": "Address" 54 | }, 55 | { 56 | "fieldname": "dispatch_address", 57 | "fieldtype": "Link", 58 | "label": "Dispatch Address", 59 | "options": "Address" 60 | } 61 | ], 62 | "index_web_pages_for_search": 1, 63 | "istable": 1, 64 | "links": [], 65 | "modified": "2023-05-12 14:06:13.182121", 66 | "modified_by": "Administrator", 67 | "module": "unicommerce", 68 | "name": "Unicommerce Warehouses", 69 | "owner": "Administrator", 70 | "permissions": [], 71 | "sort_field": "modified", 72 | "sort_order": "DESC", 73 | "states": [], 74 | "track_changes": 1 75 | } -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/doctype/unicommerce_warehouses/unicommerce_warehouses.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class UnicommerceWarehouses(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/inventory.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import frappe 4 | from frappe.utils import cint, now 5 | 6 | from ecommerce_integrations.controllers.inventory import ( 7 | get_inventory_levels, 8 | get_inventory_levels_of_group_warehouse, 9 | update_inventory_sync_status, 10 | ) 11 | from ecommerce_integrations.controllers.scheduling import need_to_run 12 | from ecommerce_integrations.unicommerce.api_client import UnicommerceAPIClient 13 | from ecommerce_integrations.unicommerce.constants import MODULE_NAME, SETTINGS_DOCTYPE 14 | 15 | # Note: Undocumented but currently handles ~1000 inventory changes in one request. 16 | # Remaining to be done in next interval. 17 | MAX_INVENTORY_UPDATE_IN_REQUEST = 1000 18 | 19 | 20 | def update_inventory_on_unicommerce(client=None, force=False): 21 | """Update ERPnext warehouse wise inventory to Unicommerce. 22 | 23 | This function gets called by scheduler every minute. The function 24 | decides whether to run or not based on configured sync frequency. 25 | 26 | force=True ignores the set frequency. 27 | """ 28 | settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) 29 | 30 | if not settings.is_enabled() or not settings.enable_inventory_sync: 31 | return 32 | 33 | # check if need to run based on configured sync frequency 34 | if not force and not need_to_run(SETTINGS_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): 35 | return 36 | 37 | # get configured warehouses 38 | warehouses = settings.get_erpnext_warehouses() 39 | wh_to_facility_map = settings.get_erpnext_to_integration_wh_mapping() 40 | 41 | if client is None: 42 | client = UnicommerceAPIClient() 43 | 44 | # track which ecommerce item was updated successfully 45 | success_map: dict[str, bool] = defaultdict(lambda: True) 46 | inventory_synced_on = now() 47 | 48 | for warehouse in warehouses: 49 | is_group_warehouse = cint(frappe.db.get_value("Warehouse", warehouse, "is_group")) 50 | 51 | if is_group_warehouse: 52 | erpnext_inventory = get_inventory_levels_of_group_warehouse( 53 | warehouse=warehouse, integration=MODULE_NAME 54 | ) 55 | else: 56 | erpnext_inventory = get_inventory_levels(warehouses=(warehouse,), integration=MODULE_NAME) 57 | 58 | if not erpnext_inventory: 59 | continue 60 | 61 | erpnext_inventory = erpnext_inventory[:MAX_INVENTORY_UPDATE_IN_REQUEST] 62 | 63 | # TODO: consider reserved qty on both platforms. 64 | inventory_map = {d.integration_item_code: cint(d.actual_qty) for d in erpnext_inventory} 65 | facility_code = wh_to_facility_map[warehouse] 66 | 67 | response, status = client.bulk_inventory_update( 68 | facility_code=facility_code, inventory_map=inventory_map 69 | ) 70 | 71 | if status: 72 | # update success_map 73 | sku_to_ecom_item_map = {d.integration_item_code: d.ecom_item for d in erpnext_inventory} 74 | for sku, status in response.items(): 75 | ecom_item = sku_to_ecom_item_map[sku] 76 | # Any one warehouse sync failure should be considered failure 77 | success_map[ecom_item] = success_map[ecom_item] and status 78 | 79 | _update_inventory_sync_status(success_map, inventory_synced_on) 80 | 81 | 82 | def _update_inventory_sync_status(ecom_item_success_map: dict[str, bool], timestamp: str) -> None: 83 | for ecom_item, status in ecom_item_success_map.items(): 84 | if status: 85 | update_inventory_sync_status(ecom_item, timestamp) 86 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/pick_list.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import frappe 4 | from frappe import _ 5 | 6 | from ecommerce_integrations.unicommerce.constants import SETTINGS_DOCTYPE 7 | 8 | 9 | def validate(self, method=None): 10 | settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) 11 | if not settings.is_enabled(): 12 | return 13 | 14 | sales_order = self.get("locations")[0].sales_order 15 | 16 | unicommerce_order_code = frappe.db.get_value("Sales Order", sales_order, "unicommerce_order_code") 17 | if unicommerce_order_code: 18 | if self.get("locations"): 19 | for pl in self.get("locations"): 20 | if pl.picked_qty and float(pl.picked_qty) > 0: 21 | if pl.picked_qty > pl.qty: 22 | pl.picked_qty = pl.qty 23 | 24 | frappe.throw( 25 | _("Row {0} Picked Qty cannot be more than Sales Order Qty").format(pl.idx) 26 | ) 27 | if pl.picked_qty == 0 and pl.docstatus == 1: 28 | frappe.throw( 29 | _("You have not picked {0} in row {1} . Pick the item to proceed!").format( 30 | pl.item_code, pl.idx 31 | ) 32 | ) 33 | item_so_list = [d.sales_order for d in self.get("locations")] 34 | unique_so_list = [] 35 | for i in item_so_list: 36 | if i not in unique_so_list: 37 | unique_so_list.append(i) 38 | so_list = [d.sales_order for d in self.get("order_details")] 39 | for so in unique_so_list: 40 | if so not in so_list: 41 | pl_so_child = self.append("order_details", {}) 42 | pl_so_child.sales_order = so 43 | total_item_count = 0 44 | fully_picked_item_count = 0 45 | partial_picked_item_count = 0 46 | for item in self.get("locations"): 47 | if item.sales_order == so: 48 | total_item_count = total_item_count + 1 49 | if item.picked_qty == item.qty: 50 | fully_picked_item_count = fully_picked_item_count + 1 51 | elif int(item.picked_qty) > 0: 52 | partial_picked_item_count = partial_picked_item_count + 1 53 | if fully_picked_item_count == total_item_count: 54 | for x in self.get("order_details"): 55 | if x.sales_order == so: 56 | x.pick_status = "Fully Picked" 57 | elif fully_picked_item_count == 0 and partial_picked_item_count == 0: 58 | for x in self.get("order_details"): 59 | if x.sales_order == so: 60 | x.pick_status = "" 61 | elif int(partial_picked_item_count) > 0: 62 | for x in self.get("order_details"): 63 | if x.sales_order == so: 64 | x.pick_status = "Partially Picked" 65 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/unicommerce/tests/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/authentication.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "1211cf66-d9b3-498b-a8a4-04c76578b72e", 3 | "token_type": "bearer", 4 | "refresh_token": "18f96b68-bdf4-4c5f-93f2-16e2c6e674c6", 5 | "expires_in": 41621, 6 | "scope": "read trust write" 7 | } 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/bulk_inventory_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": true, 3 | "message": null, 4 | "errors": null, 5 | "warnings": null, 6 | "inventoryAdjustmentResponses": [ 7 | { 8 | "facilityInventoryAdjustment": { 9 | "itemSKU": "A", 10 | "quantity": 1, 11 | "shelfCode": "DEFAULT", 12 | "inventoryType": "GOOD_INVENTORY", 13 | "transferToShelfCode": null, 14 | "sla": null, 15 | "batchCode": null, 16 | "adjustmentType": "REPLACE", 17 | "remarks": null, 18 | "forceAllocate": true, 19 | "facilityCode": "42" 20 | }, 21 | "successful": true, 22 | "errors": [] 23 | }, 24 | { 25 | "facilityInventoryAdjustment": { 26 | "itemSKU": "B", 27 | "quantity": 2, 28 | "shelfCode": "DEFAULT", 29 | "inventoryType": "GOOD_INVENTORY", 30 | "transferToShelfCode": null, 31 | "sla": null, 32 | "batchCode": null, 33 | "adjustmentType": "REPLACE", 34 | "remarks": null, 35 | "forceAllocate": true, 36 | "facilityCode": "42" 37 | }, 38 | "successful": true, 39 | "errors": [] 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/create_invoice_and_assign_shipper.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": true, 3 | "message": "string", 4 | "errors": [ 5 | { 6 | "code": 0, 7 | "fieldName": "string", 8 | "description": "string", 9 | "message": "string", 10 | "errorParams": { 11 | "additionalProp1": {}, 12 | "additionalProp2": {}, 13 | "additionalProp3": {} 14 | } 15 | } 16 | ], 17 | "warnings": [ 18 | { 19 | "code": 0, 20 | "message": "string", 21 | "description": "string" 22 | } 23 | ], 24 | "invoiceCode": "SICODE", 25 | "invoiceDisplayCode": "SICODE", 26 | "shippingPackageCode": "SPCODE", 27 | "shippingProviderCode": "SHIPPER", 28 | "trackingNumber": "123456789", 29 | "shippingLabelLink": "https://example.com" 30 | } 31 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/missing_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": false, 3 | "message": null, 4 | "errors": [ 5 | { 6 | "code": 10001, 7 | "fieldName": null, 8 | "description": "Invalid item type:MISSING", 9 | "message": "INVALID_ITEM_TYPE", 10 | "errorParams": null 11 | } 12 | ], 13 | "warnings": null, 14 | "itemTypeDTO": null 15 | } 16 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/order-SO6008-order.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "SO6008", 3 | "displayOrderCode": "SO6008", 4 | "channel": "RAINFOREST", 5 | "displayOrderDateTime": 1624991400000, 6 | "status": "CREATED", 7 | "created": 1625049926000, 8 | "updated": 1625049926000, 9 | "fulfillmentTat": 1625164200000, 10 | "notificationEmail": "", 11 | "notificationMobile": "", 12 | "customerGSTIN": null, 13 | "cod": false, 14 | "thirdPartyShipping": false, 15 | "priority": 0, 16 | "currencyCode": "INR", 17 | "customerCode": null, 18 | "billingAddress": { 19 | "id": "24005", 20 | "name": "Wren", 21 | "addressLine1": "ADdr 2", 22 | "addressLine2": "ada", 23 | "city": "Mumbai", 24 | "district": "", 25 | "state": "AR", 26 | "country": "IN", 27 | "pincode": "400097", 28 | "phone": "9999999999", 29 | "email": null, 30 | "type": null 31 | }, 32 | "addresses": [ 33 | { 34 | "id": "24005", 35 | "name": "Wren", 36 | "addressLine1": "ADdr 2", 37 | "addressLine2": "ada", 38 | "city": "Mumbai", 39 | "district": "", 40 | "state": "AR", 41 | "country": "IN", 42 | "pincode": "400097", 43 | "phone": "9999999999", 44 | "email": null, 45 | "type": null 46 | } 47 | ], 48 | "shippingPackages": [], 49 | "saleOrderItems": [ 50 | { 51 | "id": 86361, 52 | "shippingPackageCode": null, 53 | "shippingPackageStatus": null, 54 | "facilityCode": "Test-123", 55 | "facilityName": "JKLT", 56 | "alternateFacilityCode": null, 57 | "reversePickupCode": null, 58 | "shippingAddressId": 24005, 59 | "packetNumber": 1, 60 | "combinationIdentifier": null, 61 | "combinationDescription": null, 62 | "type": "NORMAL", 63 | "item": null, 64 | "shippingMethodCode": "STD", 65 | "itemName": "TITANIUM WATCH", 66 | "itemSku": "TITANIUM_WATCH", 67 | "sellerSkuCode": "TITANIUM_WATCH", 68 | "channelProductId": "TITANIUM_WATCH", 69 | "imageUrl": "https://user-images.githubusercontent.com/9079960/116586712-62c05780-a937-11eb-831f-650c52c07a0e.gif", 70 | "statusCode": "CREATED", 71 | "code": "TITANIUM_WATCH-0", 72 | "shelfCode": null, 73 | "totalPrice": 312000.0, 74 | "sellingPrice": 312000.0, 75 | "shippingCharges": 0.0, 76 | "shippingMethodCharges": 0.0, 77 | "cashOnDeliveryCharges": 0.0, 78 | "prepaidAmount": 0.0, 79 | "voucherCode": null, 80 | "voucherValue": 0.0, 81 | "storeCredit": 0.0, 82 | "discount": 0.0, 83 | "giftWrap": null, 84 | "giftWrapCharges": 0.0, 85 | "taxPercentage": null, 86 | "giftMessage": null, 87 | "cancellable": true, 88 | "editAddress": true, 89 | "reversePickable": false, 90 | "packetConfigurable": true, 91 | "created": 1625049926000, 92 | "updated": 1625049926000, 93 | "onHold": false, 94 | "saleOrderItemAlternateId": null, 95 | "cancellationReason": null, 96 | "pageUrl": null, 97 | "color": "Silver", 98 | "brand": "TITANIUM", 99 | "size": "M", 100 | "replacementSaleOrderCode": null, 101 | "bundleSkuCode": null, 102 | "itemDetailFieldDTOList": [], 103 | "hsnCode": "91011100", 104 | "totalIntegratedGst": 0, 105 | "integratedGstPercentage": 0, 106 | "totalUnionTerritoryGst": 0, 107 | "unionTerritoryGstPercentage": 0, 108 | "totalStateGst": 0, 109 | "stateGstPercentage": 0, 110 | "totalCentralGst": 0, 111 | "centralGstPercentage": 0, 112 | "maxRetailPrice": 312000.0, 113 | "sellingPriceWithoutTaxesAndDiscount": 312000.0, 114 | "batchDTO": null, 115 | "shippingChargeTaxPercentage": 0, 116 | "tcs": 0, 117 | "itemDetailFields": null, 118 | "channelSaleOrderItemCode": "TITANIUM_WATCH-0" 119 | } 120 | ], 121 | "returns": [], 122 | "cancellable": true, 123 | "reversePickable": false, 124 | "packetConfigurable": true, 125 | "cFormProvided": false, 126 | "totalDiscount": null, 127 | "totalShippingCharges": null, 128 | "additionalInfo": null, 129 | "paymentInstrument": null 130 | } 131 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/order-SO6009-order.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "SO6009", 3 | "displayOrderCode": "SO6009", 4 | "channel": "RAINFOREST", 5 | "displayOrderDateTime": 1624991400000, 6 | "status": "CREATED", 7 | "created": 1625050644000, 8 | "updated": 1625050643000, 9 | "fulfillmentTat": 1625164200000, 10 | "notificationEmail": "", 11 | "notificationMobile": "", 12 | "customerGSTIN": null, 13 | "cod": false, 14 | "thirdPartyShipping": false, 15 | "priority": 0, 16 | "currencyCode": "INR", 17 | "customerCode": null, 18 | "billingAddress": { 19 | "id": "24006", 20 | "name": "harold", 21 | "addressLine1": "addr", 22 | "addressLine2": "", 23 | "city": "mumbai", 24 | "district": "", 25 | "state": "AS", 26 | "country": "IN", 27 | "pincode": "400001", 28 | "phone": "999999999", 29 | "email": null, 30 | "type": null 31 | }, 32 | "addresses": [ 33 | { 34 | "id": "24006", 35 | "name": "harold", 36 | "addressLine1": "addr", 37 | "addressLine2": "", 38 | "city": "mumbai", 39 | "district": "", 40 | "state": "AS", 41 | "country": "IN", 42 | "pincode": "400001", 43 | "phone": "999999999", 44 | "email": null, 45 | "type": null 46 | } 47 | ], 48 | "shippingPackages": [], 49 | "saleOrderItems": [ 50 | { 51 | "id": 86362, 52 | "shippingPackageCode": null, 53 | "shippingPackageStatus": null, 54 | "facilityCode": "Test-123", 55 | "facilityName": "JKLT", 56 | "alternateFacilityCode": null, 57 | "reversePickupCode": null, 58 | "shippingAddressId": 24006, 59 | "packetNumber": 1, 60 | "combinationIdentifier": null, 61 | "combinationDescription": null, 62 | "type": "NORMAL", 63 | "item": null, 64 | "shippingMethodCode": "STD", 65 | "itemName": "TITANIUM WATCH", 66 | "itemSku": "TITANIUM_WATCH", 67 | "sellerSkuCode": "TITANIUM_WATCH", 68 | "channelProductId": "TITANIUM_WATCH", 69 | "imageUrl": "https://user-images.githubusercontent.com/9079960/116586712-62c05780-a937-11eb-831f-650c52c07a0e.gif", 70 | "statusCode": "CREATED", 71 | "code": "TITANIUM_WATCH-0", 72 | "shelfCode": null, 73 | "totalPrice": 311800.0, 74 | "sellingPrice": 311800.0, 75 | "shippingCharges": 0.0, 76 | "shippingMethodCharges": 0.0, 77 | "cashOnDeliveryCharges": 0.0, 78 | "prepaidAmount": 0.0, 79 | "voucherCode": null, 80 | "voucherValue": 0.0, 81 | "storeCredit": 0.0, 82 | "discount": 100.0, 83 | "giftWrap": null, 84 | "giftWrapCharges": 0.0, 85 | "taxPercentage": null, 86 | "giftMessage": null, 87 | "cancellable": true, 88 | "editAddress": true, 89 | "reversePickable": false, 90 | "packetConfigurable": true, 91 | "created": 1625050644000, 92 | "updated": 1625050643000, 93 | "onHold": false, 94 | "saleOrderItemAlternateId": null, 95 | "cancellationReason": null, 96 | "pageUrl": null, 97 | "color": "Silver", 98 | "brand": "TITANIUM", 99 | "size": "M", 100 | "replacementSaleOrderCode": null, 101 | "bundleSkuCode": null, 102 | "itemDetailFieldDTOList": [], 103 | "hsnCode": "91011100", 104 | "totalIntegratedGst": 0, 105 | "integratedGstPercentage": 0, 106 | "totalUnionTerritoryGst": 0, 107 | "unionTerritoryGstPercentage": 0, 108 | "totalStateGst": 0, 109 | "stateGstPercentage": 0, 110 | "totalCentralGst": 0, 111 | "centralGstPercentage": 0, 112 | "maxRetailPrice": 312000.0, 113 | "sellingPriceWithoutTaxesAndDiscount": 311900.0, 114 | "batchDTO": null, 115 | "shippingChargeTaxPercentage": 0, 116 | "tcs": 0, 117 | "itemDetailFields": null, 118 | "channelSaleOrderItemCode": "TITANIUM_WATCH-0" 119 | } 120 | ], 121 | "returns": [], 122 | "cancellable": true, 123 | "reversePickable": false, 124 | "packetConfigurable": true, 125 | "cFormProvided": false, 126 | "totalDiscount": null, 127 | "totalShippingCharges": null, 128 | "additionalInfo": null, 129 | "paymentInstrument": null 130 | } 131 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/product-MC-100.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": true, 3 | "message": null, 4 | "errors": [], 5 | "warnings": null, 6 | "itemTypeDTO": { 7 | "tat": null, 8 | "id": 120494, 9 | "skuCode": "MC-100", 10 | "categoryCode": "NTX", 11 | "name": "Microfiber Drying Cloth", 12 | "description": null, 13 | "scanIdentifier": "MC-100", 14 | "length": 1, 15 | "width": 1, 16 | "height": 1, 17 | "weight": 1, 18 | "color": "White", 19 | "size": null, 20 | "brand": "Microtex", 21 | "ean": "", 22 | "upc": "", 23 | "isbn": "", 24 | "maxRetailPrice": 659.0, 25 | "basePrice": 500.0, 26 | "batchGroupCode": null, 27 | "costPrice": 500.0, 28 | "taxTypeCode": "DEFAULT", 29 | "gstTaxTypeCode": "none", 30 | "hsnCode": "", 31 | "imageUrl": null, 32 | "productPageUrl": null, 33 | "type": "SIMPLE", 34 | "determineExpiryFrom": null, 35 | "taxCalculationType": null, 36 | "requiresCustomization": false, 37 | "itemDetailFieldsText": null, 38 | "enabled": true, 39 | "tags": [], 40 | "shelfLife": null, 41 | "expirable": false, 42 | "customFieldValues": [ 43 | { 44 | "fieldName": "Woollen", 45 | "fieldValue": "", 46 | "valueType": "text", 47 | "displayName": "Wollen", 48 | "required": false, 49 | "possibleValues": [""] 50 | }, 51 | { 52 | "fieldName": "Type", 53 | "fieldValue": "", 54 | "valueType": "text", 55 | "displayName": "Type", 56 | "required": false, 57 | "possibleValues": [""] 58 | }, 59 | { 60 | "fieldName": "SerialNumber", 61 | "fieldValue": "6", 62 | "valueType": "text", 63 | "displayName": "SerialNumber", 64 | "required": true, 65 | "possibleValues": null 66 | }, 67 | { 68 | "fieldName": "BOMComponentSKUs", 69 | "fieldValue": null, 70 | "valueType": "text", 71 | "displayName": "BOM-ComponentSKUs", 72 | "required": false, 73 | "possibleValues": null 74 | }, 75 | { 76 | "fieldName": "BWB", 77 | "fieldValue": null, 78 | "valueType": "select", 79 | "displayName": "BWB", 80 | "required": false, 81 | "possibleValues": ["True", "False"] 82 | }, 83 | { 84 | "fieldName": "TypeofSKU", 85 | "fieldValue": null, 86 | "valueType": "select", 87 | "displayName": "TypeofSKU", 88 | "required": false, 89 | "possibleValues": ["inbound", "outbound", "inbou"] 90 | }, 91 | { 92 | "fieldName": "Modelnumber", 93 | "fieldValue": null, 94 | "valueType": "text", 95 | "displayName": "Model Number", 96 | "required": false, 97 | "possibleValues": null 98 | }, 99 | { 100 | "fieldName": "Model_Number", 101 | "fieldValue": null, 102 | "valueType": "text", 103 | "displayName": "Model Number", 104 | "required": false, 105 | "possibleValues": null 106 | }, 107 | { 108 | "fieldName": "ActivityCode", 109 | "fieldValue": null, 110 | "valueType": "text", 111 | "displayName": "Activity Code", 112 | "required": false, 113 | "possibleValues": null 114 | }, 115 | { 116 | "fieldName": "Demo", 117 | "fieldValue": "DEFAULT", 118 | "valueType": "text", 119 | "displayName": "Article Code", 120 | "required": true, 121 | "possibleValues": null 122 | } 123 | ], 124 | "componentItemTypes": [], 125 | "grnExpiryTolerance": null, 126 | "dispatchExpiryTolerance": null, 127 | "returnExpiryTolerance": null, 128 | "minOrderSize": 1 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/simple_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": true, 3 | "message": null, 4 | "errors": [], 5 | "warnings": null, 6 | "itemTypeDTO": { 7 | "tat": 6, 8 | "id": 129851, 9 | "skuCode": "TITANIUM_WATCH", 10 | "categoryCode": "Products", 11 | "name": "TITANIUM WATCH", 12 | "description": "This is a watch.", 13 | "scanIdentifier": "73513537", 14 | "length": 100, 15 | "width": 100, 16 | "height": 50, 17 | "weight": 1000, 18 | "color": "Silver", 19 | "size": "M", 20 | "brand": "TITANIUM", 21 | "ean": "73513537", 22 | "upc": "065100004327", 23 | "isbn": "", 24 | "maxRetailPrice": 312000.0, 25 | "basePrice": 22000.0, 26 | "batchGroupCode": null, 27 | "costPrice": 15000.0, 28 | "taxTypeCode": "DEFAULT", 29 | "gstTaxTypeCode": "DEFAULT_GST", 30 | "hsnCode": "91011100", 31 | "imageUrl": "https://user-images.githubusercontent.com/9079960/131f-650c52c07a0e.gif", 32 | "productPageUrl": null, 33 | "type": "SIMPLE", 34 | "determineExpiryFrom": null, 35 | "taxCalculationType": null, 36 | "requiresCustomization": false, 37 | "itemDetailFieldsText": "", 38 | "enabled": true, 39 | "tags": [], 40 | "shelfLife": 0, 41 | "expirable": false, 42 | "customFieldValues": [ 43 | { 44 | "fieldName": "Woollen", 45 | "fieldValue": "", 46 | "valueType": "text", 47 | "displayName": "Wollen", 48 | "required": false, 49 | "possibleValues": [""] 50 | }, 51 | { 52 | "fieldName": "Type", 53 | "fieldValue": "", 54 | "valueType": "text", 55 | "displayName": "Type", 56 | "required": false, 57 | "possibleValues": [""] 58 | }, 59 | { 60 | "fieldName": "SerialNumber", 61 | "fieldValue": "100", 62 | "valueType": "text", 63 | "displayName": "SerialNumber", 64 | "required": true, 65 | "possibleValues": null 66 | }, 67 | { 68 | "fieldName": "BOMComponentSKUs", 69 | "fieldValue": null, 70 | "valueType": "text", 71 | "displayName": "BOM-ComponentSKUs", 72 | "required": false, 73 | "possibleValues": null 74 | }, 75 | { 76 | "fieldName": "BWB", 77 | "fieldValue": null, 78 | "valueType": "select", 79 | "displayName": "BWB", 80 | "required": false, 81 | "possibleValues": ["True", "False"] 82 | }, 83 | { 84 | "fieldName": "TypeofSKU", 85 | "fieldValue": "inbound", 86 | "valueType": "select", 87 | "displayName": "TypeofSKU", 88 | "required": false, 89 | "possibleValues": ["inbound", "outbound", "inbou"] 90 | }, 91 | { 92 | "fieldName": "Modelnumber", 93 | "fieldValue": null, 94 | "valueType": "text", 95 | "displayName": "Model Number", 96 | "required": false, 97 | "possibleValues": null 98 | }, 99 | { 100 | "fieldName": "Model_Number", 101 | "fieldValue": "100", 102 | "valueType": "text", 103 | "displayName": "Model Number", 104 | "required": false, 105 | "possibleValues": null 106 | }, 107 | { 108 | "fieldName": "ActivityCode", 109 | "fieldValue": null, 110 | "valueType": "text", 111 | "displayName": "Activity Code", 112 | "required": false, 113 | "possibleValues": null 114 | }, 115 | { 116 | "fieldName": "Demo", 117 | "fieldValue": "DEFAULT", 118 | "valueType": "text", 119 | "displayName": "Article Code", 120 | "required": true, 121 | "possibleValues": null 122 | } 123 | ], 124 | "componentItemTypes": [], 125 | "grnExpiryTolerance": 0, 126 | "dispatchExpiryTolerance": 0, 127 | "returnExpiryTolerance": 0, 128 | "minOrderSize": 1 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/fixtures/so_search_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "successful": true, 3 | "message": null, 4 | "errors": [], 5 | "warnings": null, 6 | "totalRecords": null, 7 | "elements": [ 8 | { 9 | "code": "SO5905", 10 | "displayOrderCode": "SINV-0002", 11 | "channel": "RAINFOREST", 12 | "displayOrderDateTime": 1624300200000, 13 | "status": "PROCESSING", 14 | "created": 1624372375000, 15 | "updated": 1624372503000, 16 | "fulfillmentTat": 1624473000000, 17 | "notificationEmail": "", 18 | "notificationMobile": "", 19 | "customerGSTIN": null 20 | }, 21 | { 22 | "code": "SO5906", 23 | "displayOrderCode": "SINV-00041", 24 | "channel": "RAINFOREST", 25 | "displayOrderDateTime": 1624300200000, 26 | "status": "PROCESSING", 27 | "created": 1624372534000, 28 | "updated": 1624372802000, 29 | "fulfillmentTat": 1624473000000, 30 | "notificationEmail": "", 31 | "notificationMobile": "", 32 | "customerGSTIN": null 33 | }, 34 | { 35 | "code": "SO5907", 36 | "displayOrderCode": "SINV-00069", 37 | "channel": "CUSTOM_FF_TEST", 38 | "displayOrderDateTime": 1624300200000, 39 | "status": "PROCESSING", 40 | "created": 1624372824000, 41 | "updated": 1624373103000, 42 | "fulfillmentTat": 1624473000000, 43 | "notificationEmail": "", 44 | "notificationMobile": "", 45 | "customerGSTIN": null 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_customer.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.test_runner import make_test_records 3 | 4 | from ecommerce_integrations.unicommerce.customer import ( 5 | _create_customer_addresses, 6 | _create_new_customer, 7 | sync_customer, 8 | ) 9 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 10 | 11 | 12 | class TestUnicommerceProduct(TestCaseApiClient): 13 | @classmethod 14 | def setUpClass(cls): 15 | super().setUpClass() 16 | make_test_records("Unicommerce Channel") 17 | 18 | def test_create_customer(self): 19 | order = self.load_fixture("order-SO5905")["saleOrderDTO"] 20 | 21 | _create_new_customer(order) 22 | 23 | customer = frappe.get_last_doc("Customer") 24 | self.assertEqual(customer.customer_group, "Individual") 25 | self.assertEqual(customer.customer_type, "Individual") 26 | self.assertEqual(customer.customer_name, "Ramesh Suresh") 27 | 28 | _create_customer_addresses(order.get("addresses", []), customer) 29 | 30 | new_addresses = frappe.get_all("Address", filters={"link_name": customer.name}, fields=["*"]) 31 | 32 | self.assertEqual(len(new_addresses), 2) 33 | addr_types = {d.address_type for d in new_addresses} 34 | self.assertEqual(addr_types, {"Shipping", "Billing"}) 35 | 36 | states = {d.state for d in new_addresses} 37 | self.assertEqual(states, {"Maharashtra"}) 38 | 39 | def test_deduplication(self): 40 | """requirement: Literally same order should not create duplicates.""" 41 | order = self.load_fixture("order-SO5841")["saleOrderDTO"] 42 | customer = sync_customer(order) 43 | same_customer = sync_customer(order) 44 | 45 | self.assertEqual(customer.name, same_customer.name) 46 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_delivery_note.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | 4 | import frappe 5 | import responses 6 | from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry 7 | 8 | from ecommerce_integrations.unicommerce.constants import ( 9 | FACILITY_CODE_FIELD, 10 | INVOICE_CODE_FIELD, 11 | ORDER_CODE_FIELD, 12 | SHIPPING_PACKAGE_CODE_FIELD, 13 | ) 14 | from ecommerce_integrations.unicommerce.delivery_note import create_delivery_note 15 | from ecommerce_integrations.unicommerce.invoice import bulk_generate_invoices, create_sales_invoice 16 | from ecommerce_integrations.unicommerce.order import create_order 17 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 18 | 19 | 20 | class TestDeliveryNote(TestCaseApiClient): 21 | @classmethod 22 | def setUpClass(cls): 23 | super().setUpClass() 24 | 25 | def test_create_invoice_and_delivery_note(self): 26 | """Use mocked invoice json to create and assert synced fields""" 27 | from ecommerce_integrations.unicommerce import invoice 28 | 29 | # HACK to allow invoicing test 30 | invoice.INVOICED_STATE.append("CREATED") 31 | self.responses.add( 32 | responses.POST, 33 | "https://demostaging.unicommerce.com/services/rest/v1/oms/shippingPackage/createInvoiceAndAllocateShippingProvider", 34 | status=200, 35 | json=self.load_fixture("create_invoice_and_assign_shipper"), 36 | match=[responses.json_params_matcher({"shippingPackageCode": "TEST00949"})], 37 | ) 38 | self.responses.add( 39 | responses.POST, 40 | "https://demostaging.unicommerce.com/services/rest/v1/invoice/details/get", 41 | status=200, 42 | json=self.load_fixture("invoice-SDU0026"), 43 | match=[responses.json_params_matcher({"shippingPackageCode": "TEST00949", "return": False})], 44 | ) 45 | self.responses.add( 46 | responses.GET, 47 | "https://example.com", 48 | status=200, 49 | body=base64.b64decode(self.load_fixture("invoice_label_response")["label"]), 50 | ) 51 | 52 | order = self.load_fixture("order-SO5906")["saleOrderDTO"] 53 | so = create_order(order, client=self.client) 54 | make_stock_entry(item_code="MC-100", qty=15, to_warehouse="Stores - WP", rate=42) 55 | 56 | bulk_generate_invoices(sales_orders=[so.name], client=self.client) 57 | 58 | sales_invoice_code = frappe.db.get_value("Sales Invoice", {INVOICE_CODE_FIELD: "SDU0026"}) 59 | 60 | if not sales_invoice_code: 61 | self.fail("Sales invoice not generated") 62 | 63 | si = frappe.get_doc("Sales Invoice", sales_invoice_code) 64 | dn = create_delivery_note(so, si) 65 | self.assertEqual(dn.unicommerce_order_code, so.unicommerce_order_code) 66 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_inventory.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import frappe 4 | import responses 5 | from erpnext.stock.doctype.item.test_item import make_item 6 | from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry 7 | from erpnext.stock.utils import get_stock_balance 8 | 9 | from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item 10 | from ecommerce_integrations.unicommerce.constants import MODULE_NAME 11 | from ecommerce_integrations.unicommerce.inventory import update_inventory_on_unicommerce 12 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 13 | 14 | 15 | class TestUnicommerceProduct(TestCaseApiClient): 16 | @classmethod 17 | def setUpClass(cls): 18 | super().setUpClass() 19 | cls.items = ["_TestInventoryItemA", "_TestInventoryItemB", "_TestInventoryItemC"] 20 | 21 | with patch("ecommerce_integrations.shopify.product.upload_erpnext_item"): 22 | for item in cls.items: 23 | make_item(item) 24 | 25 | cls.ecom_items = [make_ecommerce_item(item) for item in cls.items] 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | super().tearDownClass() 30 | for ecom_item in cls.ecom_items: 31 | frappe.delete_doc("Ecommerce Item", ecom_item) 32 | 33 | def test_inventory_sync(self): 34 | """requirement: When bin is changed the inventory sync should take place in next cycle""" 35 | 36 | # create stock entries for warehouses (warehouses are part of before_test hook in erpnext) 37 | make_stock_entry(item_code="_TestInventoryItemA", qty=10, to_warehouse="Stores - WP", rate=10) 38 | make_stock_entry(item_code="_TestInventoryItemB", qty=2, to_warehouse="Stores - WP", rate=10) 39 | make_stock_entry( 40 | item_code="_TestInventoryItemC", qty=42, to_warehouse="Work In Progress - WP", rate=10 41 | ) 42 | 43 | wh1_request = { 44 | "inventoryAdjustments": [ 45 | { 46 | "itemSKU": "_TestInventoryItemA", 47 | "quantity": get_stock_balance("_TestInventoryItemA", "Stores - WP"), 48 | "shelfCode": "DEFAULT", 49 | "inventoryType": "GOOD_INVENTORY", 50 | "adjustmentType": "REPLACE", 51 | "facilityCode": "A", 52 | }, 53 | { 54 | "itemSKU": "_TestInventoryItemB", 55 | "quantity": get_stock_balance("_TestInventoryItemB", "Stores - WP"), 56 | "shelfCode": "DEFAULT", 57 | "inventoryType": "GOOD_INVENTORY", 58 | "adjustmentType": "REPLACE", 59 | "facilityCode": "A", 60 | }, 61 | ] 62 | } 63 | wh2_request = { 64 | "inventoryAdjustments": [ 65 | { 66 | "itemSKU": "_TestInventoryItemC", 67 | "quantity": get_stock_balance("_TestInventoryItemC", "Work In Progress - WP"), 68 | "shelfCode": "DEFAULT", 69 | "inventoryType": "GOOD_INVENTORY", 70 | "adjustmentType": "REPLACE", 71 | "facilityCode": "B", 72 | }, 73 | ] 74 | } 75 | self.responses.add( 76 | responses.POST, 77 | "https://demostaging.unicommerce.com/services/rest/v1/inventory/adjust/bulk", 78 | status=200, 79 | json={"successful": True}, 80 | match=[responses.json_params_matcher(wh1_request)], 81 | ) 82 | self.responses.add( 83 | responses.POST, 84 | "https://demostaging.unicommerce.com/services/rest/v1/inventory/adjust/bulk", 85 | status=200, 86 | json={"successful": True}, 87 | match=[responses.json_params_matcher(wh2_request)], 88 | ) 89 | 90 | # There's nothing to test after this. 91 | # responses library should match the correct response and fail if not done so. 92 | update_inventory_on_unicommerce(client=self.client, force=True) 93 | 94 | 95 | def make_ecommerce_item(item_code): 96 | if ecommerce_item.is_synced(MODULE_NAME, item_code): 97 | return 98 | 99 | ecom_item = frappe.get_doc( 100 | doctype="Ecommerce Item", 101 | integration=MODULE_NAME, 102 | erpnext_item_code=item_code, 103 | integration_item_code=item_code, 104 | ).insert() 105 | return ecom_item.name 106 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_order.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from copy import deepcopy 3 | 4 | import frappe 5 | from frappe.test_runner import make_test_records 6 | 7 | from ecommerce_integrations.unicommerce.constants import ( 8 | CHANNEL_ID_FIELD, 9 | ORDER_CODE_FIELD, 10 | ORDER_STATUS_FIELD, 11 | ) 12 | from ecommerce_integrations.unicommerce.order import ( 13 | _get_facility_code, 14 | _get_line_items, 15 | _sync_order_items, 16 | create_order, 17 | ) 18 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 19 | 20 | 21 | class TestUnicommerceOrder(TestCaseApiClient): 22 | @classmethod 23 | def setUpClass(cls): 24 | super().setUpClass() 25 | make_test_records("Unicommerce Channel") 26 | 27 | def test_validate_item_list(self): 28 | order_files = ["order-SO5905", "order-SO5906", "order-SO5907"] 29 | items_list = [ 30 | {"MC-100", "TITANIUM_WATCH"}, 31 | { 32 | "MC-100", 33 | }, 34 | {"MC-100", "TITANIUM_WATCH"}, 35 | ] 36 | 37 | for order_file, items in zip(order_files, items_list, strict=False): 38 | order = self.load_fixture(order_file)["saleOrderDTO"] 39 | self.assertEqual(items, _sync_order_items(order, client=self.client)) 40 | 41 | def test_get_line_items(self): 42 | so_items = self.load_fixture("order-SO6008-order")["saleOrderItems"] 43 | items = _get_line_items(so_items) 44 | 45 | expected_item = { 46 | "item_code": "TITANIUM_WATCH", 47 | "rate": 312000.0, 48 | "qty": 1, 49 | "stock_uom": "Nos", 50 | "unicommerce_batch_code": None, 51 | "warehouse": "Stores - WP", 52 | "unicommerce_order_item_code": "TITANIUM_WATCH-0", 53 | } 54 | 55 | self.assertEqual(items[0], expected_item) 56 | 57 | def test_get_line_items_multiple(self): 58 | so_items = self.load_fixture("order-SO5906")["saleOrderDTO"]["saleOrderItems"] 59 | items = _get_line_items(so_items) 60 | 61 | item_to_qty = defaultdict(int) 62 | total_price = 0.0 63 | 64 | for item in items: 65 | item_to_qty[item["item_code"]] += item["qty"] 66 | total_price += item["rate"] * item["qty"] 67 | 68 | self.assertEqual(item_to_qty["MC-100"], 11) 69 | self.assertAlmostEqual(total_price, 7028.0) 70 | 71 | def test_get_taxes(self): 72 | pass 73 | 74 | def test_get_facility_code(self): 75 | line_items = self.load_fixture("order-SO6008-order")["saleOrderItems"] 76 | facility = _get_facility_code(line_items) 77 | 78 | self.assertEqual(facility, "Test-123") 79 | 80 | bad_line_item = deepcopy(line_items[0]) 81 | bad_line_item["facilityCode"] = "grrr" 82 | line_items.append(bad_line_item) 83 | 84 | self.assertRaises(frappe.ValidationError, _get_facility_code, line_items) 85 | 86 | def test_create_order(self): 87 | order = self.load_fixture("order-SO6008-order") 88 | 89 | so = create_order(order, client=self.client) 90 | 91 | customer_name = order["addresses"][0]["name"] 92 | self.assertTrue(customer_name in so.customer) 93 | self.assertEqual(so.get(CHANNEL_ID_FIELD), order["channel"]) 94 | self.assertEqual(so.get(ORDER_CODE_FIELD), order["code"]) 95 | self.assertEqual(so.get(ORDER_STATUS_FIELD), order["status"]) 96 | 97 | def test_create_order_multiple_items(self): 98 | order = self.load_fixture("order-SO5906")["saleOrderDTO"] 99 | 100 | so = create_order(order, client=self.client) 101 | 102 | customer_name = order["addresses"][0]["name"] 103 | self.assertTrue(customer_name in so.customer) 104 | self.assertEqual(so.get(CHANNEL_ID_FIELD), order["channel"]) 105 | self.assertEqual(so.get(ORDER_CODE_FIELD), order["code"]) 106 | self.assertEqual(so.get(ORDER_STATUS_FIELD), order["status"]) 107 | 108 | qty = sum(item.qty for item in so.items) 109 | amount = sum(item.amount for item in so.items) 110 | self.assertEqual(qty, 11) 111 | self.assertAlmostEqual(amount, 7028.0) 112 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_product.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | import responses 3 | 4 | from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item 5 | from ecommerce_integrations.unicommerce.constants import MODULE_NAME 6 | from ecommerce_integrations.unicommerce.product import ( 7 | _build_unicommerce_item, 8 | _get_barcode_data, 9 | _get_item_group, 10 | _validate_create_brand, 11 | _validate_field, 12 | import_product_from_unicommerce, 13 | ) 14 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 15 | 16 | 17 | class TestUnicommerceProduct(TestCaseApiClient): 18 | @classmethod 19 | def setUpClass(cls): 20 | super().setUpClass() 21 | 22 | def test_import_missing_item_raises_error(self): 23 | """requirement: when attempting to sync SKU that doesn't exist on Unicommerce system should throw error""" 24 | self.responses.add( 25 | responses.POST, 26 | "https://demostaging.unicommerce.com/services/rest/v1/catalog/itemType/get", 27 | status=200, 28 | json=self.load_fixture("missing_item"), 29 | match=[responses.json_params_matcher({"skuCode": "MISSING"})], 30 | ) 31 | self.assertRaises(frappe.ValidationError, import_product_from_unicommerce, "MISSING", self.client) 32 | 33 | log = frappe.get_last_doc("Ecommerce Integration Log", filters={"integration": "unicommerce"}) 34 | self.assertTrue("Failed to import" in log.message, "Logging for missing item not working") 35 | 36 | def test_import_item_from_unicommerce(self): 37 | """requirement: When syncing correct item system creates item in erpnext and Ecommerce item for it""" 38 | code = "TITANIUM_WATCH" 39 | 40 | import_product_from_unicommerce(code, self.client) 41 | 42 | self.assertTrue(bool(frappe.db.exists("Item", code))) 43 | self.assertTrue(ecommerce_item.is_synced(MODULE_NAME, code)) 44 | item = ecommerce_item.get_erpnext_item(MODULE_NAME, code) 45 | self.assertEqual(item.name, code) 46 | 47 | expected_item = { 48 | "item_code": "TITANIUM_WATCH", 49 | "item_group": "Products", 50 | "item_name": "TITANIUM WATCH", 51 | "description": "This is a watch.", 52 | "weight_per_unit": 1000, 53 | "weight_uom": "Gram", 54 | "brand": "TITANIUM", 55 | "shelf_life_in_days": 0, 56 | "disabled": 0, 57 | "image": "https://user-images.githubusercontent.com/9079960/131f-650c52c07a0e.gif", 58 | } 59 | for field, value in expected_item.items(): 60 | self.assertEqual(item.get(field), value) 61 | 62 | ean_barcode = item.barcodes[0] 63 | upc_barcode = item.barcodes[1] 64 | self.assertEqual(ean_barcode.barcode, "73513537") 65 | self.assertEqual(ean_barcode.barcode_type, "EAN") 66 | self.assertEqual(upc_barcode.barcode, "065100004327") 67 | self.assertEqual(upc_barcode.barcode_type, "UPC-A") 68 | 69 | def test_validate_brand(self): 70 | brand_name = "_Test Brand" 71 | frappe.db.sql("delete from tabBrand where name = %s", brand_name) 72 | 73 | _validate_create_brand(brand_name) 74 | 75 | brand = frappe.get_doc("Brand", brand_name) 76 | self.assertEqual(brand_name, brand.name) 77 | 78 | def test_validate_field(self): 79 | self.assertTrue(_validate_field("item_group", "Products")) 80 | self.assertTrue(_validate_field("item_name", "whatever")) # not a link field 81 | self.assertFalse(_validate_field("weight_uom", "whatever")) 82 | self.assertFalse(_validate_field("whatever", "whatever")) 83 | 84 | def test_get_barcode_data(self): 85 | item = {"upc": "065100004327", "ean": "73513537"} 86 | 87 | barcodes = _get_barcode_data(item) 88 | types = [bc["barcode_type"] for bc in barcodes] 89 | values = [bc["barcode"] for bc in barcodes] 90 | 91 | self.assertEqual(types, ["EAN", "UPC-A"]) 92 | self.assertEqual(values, ["73513537", "065100004327"]) 93 | 94 | def test_get_item_group(self): 95 | self.assertEqual(_get_item_group("TESTCAT"), "Test category") 96 | self.assertEqual(_get_item_group("Products"), "Products") 97 | self.assertEqual(_get_item_group("Whatever"), "All Item Groups") 98 | 99 | def test_build_unicommerce_item(self): 100 | """Build unicommerce item from recently synced uni item and compare if dicts are same""" 101 | 102 | code = "TITANIUM_WATCH" 103 | import_product_from_unicommerce(code, self.client) 104 | 105 | uni_item = _build_unicommerce_item("TITANIUM_WATCH") 106 | actual_item = self.load_fixture("simple_item")["itemTypeDTO"] 107 | 108 | for k, v in uni_item.items(): 109 | self.assertEqual(actual_item[k], v) 110 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/test_status.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from ecommerce_integrations.unicommerce.cancellation_and_returns import ( 4 | _delete_cancelled_items, 5 | _serialize_items, 6 | ) 7 | from ecommerce_integrations.unicommerce.constants import ORDER_ITEM_CODE_FIELD 8 | from ecommerce_integrations.unicommerce.tests.test_client import TestCaseApiClient 9 | 10 | 11 | class TestUnicommerceStatusUpdates(TestCaseApiClient): 12 | def test_serialization(self): 13 | si_item = frappe.new_doc("Sales Order Item") 14 | si_item._set_defaults() 15 | _serialize_items([si_item.as_dict()]) 16 | 17 | def test_delete_cancelled_items(self): 18 | item1 = frappe.new_doc("Sales Order Item").update({ORDER_ITEM_CODE_FIELD: "cancelled"}) 19 | item2 = frappe.new_doc("Sales Order Item").update({ORDER_ITEM_CODE_FIELD: "not cancelled"}) 20 | 21 | cancelled_items = ["cancelled"] 22 | 23 | items = _delete_cancelled_items([item1, item2], cancelled_items) 24 | self.assertEqual(len(items), 1) 25 | self.assertEqual("not cancelled", items[0].get(ORDER_ITEM_CODE_FIELD)) 26 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/tests/utils.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import os 4 | import unittest 5 | from typing import ClassVar 6 | 7 | import frappe 8 | 9 | from ecommerce_integrations.unicommerce.constants import PRODUCT_CATEGORY_FIELD, SETTINGS_DOCTYPE 10 | from ecommerce_integrations.unicommerce.doctype.unicommerce_settings.unicommerce_settings import ( 11 | setup_custom_fields, 12 | ) 13 | 14 | 15 | class TestCase(unittest.TestCase): 16 | config: ClassVar = { 17 | "is_enabled": 1, 18 | "enable_inventory_sync": 1, 19 | "use_stock_entry_for_grn": 1, 20 | "vendor_code": "ERP", 21 | "default_customer_group": "Individual", 22 | "warehouse_mapping": [ 23 | {"unicommerce_facility_code": "Test-123", "erpnext_warehouse": "Stores - WP", "enabled": 1}, 24 | {"unicommerce_facility_code": "B", "erpnext_warehouse": "Work In Progress - WP", "enabled": 1}, 25 | ], 26 | } 27 | 28 | @classmethod 29 | def setUpClass(cls): 30 | settings = frappe.get_doc(SETTINGS_DOCTYPE) 31 | 32 | # remember config 33 | cls.old_config = copy.deepcopy(cls.config) 34 | for key in cls.old_config: 35 | cls.old_config[key] = getattr(settings, key) 36 | 37 | cls.old_config["warehouse_mapping"] = [] 38 | for wh_map in settings.warehouse_mapping: 39 | keys_to_retain = ["unicommerce_facility_code", "erpnext_warehouse", "enabled"] 40 | cls.old_config["warehouse_mapping"].append({k: wh_map.get(k) for k in keys_to_retain}) 41 | 42 | # change config 43 | for key, value in cls.config.items(): 44 | setattr(settings, key, value) 45 | 46 | settings.warehouse_mapping = [] 47 | for wh_map in cls.config["warehouse_mapping"]: 48 | settings.append("warehouse_mapping", wh_map) 49 | 50 | settings.flags.ignore_validate = True # to prevent hitting the API 51 | settings.flags.ignore_mandatory = True 52 | settings.save() 53 | setup_custom_fields() 54 | _setup_test_item_categories() 55 | frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) 56 | 57 | @classmethod 58 | def tearDownClass(cls): 59 | # restore config 60 | settings = frappe.get_doc(SETTINGS_DOCTYPE) 61 | for key, value in cls.old_config.items(): 62 | setattr(settings, key, value) 63 | 64 | settings.warehouse_mapping = [] 65 | for wh_map in cls.old_config["warehouse_mapping"]: 66 | settings.append("warehouse_mapping", wh_map) 67 | 68 | settings.flags.ignore_validate = True # to prevent hitting the API 69 | settings.flags.ignore_mandatory = True 70 | settings.save() 71 | frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0) 72 | 73 | def load_fixture(self, name): 74 | with open(os.path.dirname(__file__) + f"/fixtures/{name}.json", "rb") as f: 75 | data = f.read() 76 | return json.loads(data) 77 | 78 | 79 | def _setup_test_item_categories(): 80 | frappe.get_doc( 81 | {"doctype": "Item Group", PRODUCT_CATEGORY_FIELD: "TESTCAT", "item_group_name": "Test category"} 82 | ).insert(ignore_if_duplicate=True) 83 | frappe.db.set_value("Item Group", "Products", PRODUCT_CATEGORY_FIELD, "Products") 84 | -------------------------------------------------------------------------------- /ecommerce_integrations/unicommerce/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import frappe 4 | 5 | from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log import ( 6 | create_log, 7 | ) 8 | from ecommerce_integrations.unicommerce.constants import MODULE_NAME 9 | 10 | SYNC_METHODS = { 11 | "Items": "ecommerce_integrations.unicommerce.product.upload_new_items", 12 | "Orders": "ecommerce_integrations.unicommerce.order.sync_new_orders", 13 | "Inventory": "ecommerce_integrations.unicommerce.inventory.update_inventory_on_unicommerce", 14 | } 15 | 16 | DOCUMENT_URL_FORMAT = { 17 | "Sales Order": "https://{site}/order/orderitems?orderCode={code}", 18 | "Sales Invoice": "https://{site}/order/orderitems?orderCode={code}", 19 | "Item": "https://{site}/products/edit?sku={code}", 20 | "Unicommerce Shipment Manifest": "https://{site}/manifests/edit?code={code}", 21 | "Stock Entry": "https://{site}/grns", 22 | } 23 | 24 | 25 | def create_unicommerce_log(**kwargs): 26 | return create_log(module_def=MODULE_NAME, **kwargs) 27 | 28 | 29 | @frappe.whitelist() 30 | def get_unicommerce_document_url(code: str, doctype: str) -> str: 31 | if not isinstance(code, str): 32 | frappe.throw(frappe._("Invalid Document code")) 33 | 34 | site = frappe.db.get_single_value("Unicommerce Settings", "unicommerce_site", cache=True) 35 | url = DOCUMENT_URL_FORMAT.get(doctype, "") 36 | 37 | return url.format(site=site, code=code) 38 | 39 | 40 | @frappe.whitelist() 41 | def force_sync(document) -> None: 42 | frappe.only_for("System Manager") 43 | 44 | method = SYNC_METHODS.get(document) 45 | if not method: 46 | frappe.throw(frappe._("Unknown method")) 47 | frappe.enqueue(method, queue="long", is_async=True, **{"force": True}) 48 | 49 | 50 | def get_unicommerce_date(timestamp: int) -> datetime.date: 51 | """Convert unicommerce ms timestamp to datetime.""" 52 | return datetime.date.fromtimestamp(timestamp // 1000) 53 | 54 | 55 | def remove_non_alphanumeric_chars(filename: str) -> str: 56 | return "".join(c for c in filename if c.isalpha() or c.isdigit()).strip() 57 | -------------------------------------------------------------------------------- /ecommerce_integrations/uninstall.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def before_uninstall(): 5 | # This large table is linked with "modules" hence gets deleted one by one 6 | frappe.db.delete("Ecommerce Integration Log") 7 | -------------------------------------------------------------------------------- /ecommerce_integrations/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/utils/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/utils/before_test.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.setup.utils import enable_all_roles_and_domains 3 | from frappe.utils import now_datetime 4 | 5 | 6 | def before_tests(): 7 | frappe.clear_cache() 8 | # complete setup if missing 9 | from frappe.desk.page.setup_wizard.setup_wizard import setup_complete 10 | 11 | year = now_datetime().year 12 | if not frappe.get_list("Company"): 13 | setup_complete( 14 | { 15 | "currency": "INR", 16 | "full_name": "Test User", 17 | "company_name": "Wind Power LLC", 18 | "timezone": "Asia/Kolkata", 19 | "company_abbr": "WP", 20 | "industry": "Manufacturing", 21 | "country": "India", 22 | "fy_start_date": f"{year}-01-01", 23 | "fy_end_date": f"{year}-12-31", 24 | "language": "english", 25 | "company_tagline": "Testing", 26 | "email": "test@erpnext.com", 27 | "password": "test", 28 | "chart_of_accounts": "Standard", 29 | "domains": ["Manufacturing"], 30 | } 31 | ) 32 | 33 | frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 0) 34 | enable_all_roles_and_domains() 35 | create_tax_account() 36 | 37 | frappe.db.commit() 38 | 39 | 40 | def create_tax_account(): 41 | company = "Wind Power LLC" 42 | account_name = "Output Tax GST" 43 | 44 | parent = ( 45 | frappe.db.get_value("Account", {"company": company, "account_type": "Tax", "is_group": 1}) 46 | or "Duties and Taxes - WP" 47 | ) 48 | 49 | frappe.get_doc( 50 | { 51 | "doctype": "Account", 52 | "account_name": account_name, 53 | "is_group": 0, 54 | "company": company, 55 | "root_type": "Liability", 56 | "report_type": "Balance Sheet", 57 | "account_currency": "INR", 58 | "parent_account": parent, 59 | "account_type": "Tax", 60 | "tax_rate": 18, 61 | } 62 | ).insert() 63 | -------------------------------------------------------------------------------- /ecommerce_integrations/utils/naming_series.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | @frappe.whitelist() 5 | def get_series(): 6 | return { 7 | "sales_order_series": frappe.get_meta("Sales Order").get_options("naming_series"), 8 | "sales_invoice_series": frappe.get_meta("Sales Invoice").get_options("naming_series"), 9 | "delivery_note_series": frappe.get_meta("Delivery Note").get_options("naming_series"), 10 | } 11 | -------------------------------------------------------------------------------- /ecommerce_integrations/utils/price_list.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | 4 | DUMMY_PRICE_LIST = "Ecommerce Integrations - Ignore" 5 | 6 | 7 | def get_dummy_price_list() -> str: 8 | """Get a dummy tax category used for ignoring tax templates. 9 | 10 | This is used for ensuring that no tax templates are applied on transaction.""" 11 | 12 | if not frappe.db.exists("Price List", DUMMY_PRICE_LIST): 13 | pl = frappe.get_doc(doctype="Price List", price_list_name=DUMMY_PRICE_LIST, selling=1).insert() 14 | pl.add_comment(text=_("This price list is used by integrations and should be left empty")) 15 | return DUMMY_PRICE_LIST 16 | 17 | 18 | def discard_item_prices(doc, method=None): 19 | """Discard any item prices added in dummy price list""" 20 | if doc.price_list == DUMMY_PRICE_LIST: 21 | frappe.enqueue(method=_delete_all_dummy_prices, queue="short", enqueue_after_commit=True) 22 | 23 | 24 | def _delete_all_dummy_prices(): 25 | frappe.db.delete("Item Price", {"price_list": DUMMY_PRICE_LIST, "selling": 1}) 26 | -------------------------------------------------------------------------------- /ecommerce_integrations/utils/taxation.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | 4 | DUMMY_TAX_CATEGORY = "Ecommerce Integrations - Ignore" 5 | 6 | 7 | def get_dummy_tax_category() -> str: 8 | """Get a dummy tax category used for ignoring tax templates. 9 | 10 | This is used for ensuring that no tax templates are applied on transaction.""" 11 | 12 | if not frappe.db.exists("Tax Category", DUMMY_TAX_CATEGORY): 13 | frappe.get_doc(doctype="Tax Category", title=DUMMY_TAX_CATEGORY).insert() 14 | return DUMMY_TAX_CATEGORY 15 | 16 | 17 | def validate_tax_template(doc, method=None): 18 | """Prevent users from using dummy tax category for any item tax templates""" 19 | item = doc 20 | 21 | for d in item.get("taxes", []): 22 | if d.get("tax_category") == DUMMY_TAX_CATEGORY: 23 | frappe.throw( 24 | _("Tax category: '{}' can not be used in any tax templates.").format(DUMMY_TAX_CATEGORY) 25 | ) 26 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/doctype/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_category/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/doctype/zenoti_category/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_category/test_zenoti_category.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestZenotiCategory(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_category/zenoti_category.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Zenoti Category", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_category/zenoti_category.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "format:ZEN-CAT-{######}", 5 | "creation": "2021-12-03 12:05:00.828334", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "category_id", 11 | "code", 12 | "category_name", 13 | "parent_category_id", 14 | "zenoti_center" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "code", 19 | "fieldtype": "Data", 20 | "in_list_view": 1, 21 | "label": "Code" 22 | }, 23 | { 24 | "fieldname": "category_name", 25 | "fieldtype": "Data", 26 | "in_list_view": 1, 27 | "label": "Category Name" 28 | }, 29 | { 30 | "fieldname": "parent_category_id", 31 | "fieldtype": "Link", 32 | "label": "parent_category_id", 33 | "options": "Zenoti Category" 34 | }, 35 | { 36 | "fieldname": "zenoti_center", 37 | "fieldtype": "Link", 38 | "label": "Zenoti Center", 39 | "options": "Zenoti Center" 40 | }, 41 | { 42 | "fieldname": "category_id", 43 | "fieldtype": "Data", 44 | "label": "Category Id" 45 | } 46 | ], 47 | "index_web_pages_for_search": 1, 48 | "links": [], 49 | "modified": "2021-12-09 20:00:09.732355", 50 | "modified_by": "Administrator", 51 | "module": "Zenoti", 52 | "name": "Zenoti Category", 53 | "owner": "Administrator", 54 | "permissions": [ 55 | { 56 | "delete": 1, 57 | "email": 1, 58 | "export": 1, 59 | "print": 1, 60 | "read": 1, 61 | "report": 1, 62 | "role": "System Manager", 63 | "share": 1, 64 | "write": 1 65 | }, 66 | { 67 | "email": 1, 68 | "export": 1, 69 | "print": 1, 70 | "read": 1, 71 | "report": 1, 72 | "role": "All", 73 | "select": 1, 74 | "share": 1 75 | } 76 | ], 77 | "sort_field": "modified", 78 | "sort_order": "DESC", 79 | "title_field": "category_name" 80 | } -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_category/zenoti_category.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ZenotiCategory(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_center/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/doctype/zenoti_center/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_center/test_zenoti_center.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestZenotiCenter(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_center/zenoti_center.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "field:id", 5 | "creation": "2021-12-06 11:15:29.407643", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "id", 11 | "code", 12 | "center_name", 13 | "column_break_3", 14 | "last_sync", 15 | "cost_center_and_warehouse_mapping_section", 16 | "erpnext_cost_center", 17 | "column_break_7", 18 | "erpnext_warehouse" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "id", 23 | "fieldtype": "Data", 24 | "label": "Id", 25 | "unique": 1 26 | }, 27 | { 28 | "fieldname": "code", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "Code", 32 | "read_only": 1 33 | }, 34 | { 35 | "fieldname": "column_break_3", 36 | "fieldtype": "Column Break" 37 | }, 38 | { 39 | "fieldname": "center_name", 40 | "fieldtype": "Data", 41 | "label": "Center Name", 42 | "read_only": 1 43 | }, 44 | { 45 | "fieldname": "cost_center_and_warehouse_mapping_section", 46 | "fieldtype": "Section Break", 47 | "label": "Cost Center and Warehouse Mapping" 48 | }, 49 | { 50 | "fieldname": "erpnext_cost_center", 51 | "fieldtype": "Link", 52 | "label": "ERPNext Cost Center", 53 | "options": "Cost Center" 54 | }, 55 | { 56 | "fieldname": "column_break_7", 57 | "fieldtype": "Column Break" 58 | }, 59 | { 60 | "fieldname": "erpnext_warehouse", 61 | "fieldtype": "Link", 62 | "label": "ERPNext Warehouse", 63 | "options": "Warehouse" 64 | }, 65 | { 66 | "fieldname": "last_sync", 67 | "fieldtype": "Datetime", 68 | "in_list_view": 1, 69 | "label": "Last Sync", 70 | "read_only": 1 71 | } 72 | ], 73 | "index_web_pages_for_search": 1, 74 | "links": [], 75 | "modified": "2021-12-06 12:39:22.869440", 76 | "modified_by": "Administrator", 77 | "module": "Zenoti", 78 | "name": "Zenoti Center", 79 | "owner": "Administrator", 80 | "permissions": [ 81 | { 82 | "delete": 1, 83 | "email": 1, 84 | "export": 1, 85 | "print": 1, 86 | "read": 1, 87 | "report": 1, 88 | "role": "System Manager", 89 | "share": 1, 90 | "write": 1 91 | } 92 | ], 93 | "search_fields": "center_name, code", 94 | "sort_field": "modified", 95 | "sort_order": "DESC", 96 | "title_field": "center_name" 97 | } -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_error_logs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/doctype/zenoti_error_logs/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_error_logs/test_zenoti_error_logs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestZenotiErrorLogs(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_error_logs/zenoti_error_logs.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Zenoti Error Logs", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_error_logs/zenoti_error_logs.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-29 14:35:21.036947", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "request_url", 9 | "status_code", 10 | "title", 11 | "error_message" 12 | ], 13 | "fields": [ 14 | { 15 | "fieldname": "error_message", 16 | "fieldtype": "Text Editor", 17 | "in_list_view": 1, 18 | "label": "Error Message", 19 | "read_only": 1 20 | }, 21 | { 22 | "fieldname": "title", 23 | "fieldtype": "Small Text", 24 | "in_list_view": 1, 25 | "label": "Error Title", 26 | "read_only": 1 27 | }, 28 | { 29 | "fieldname": "request_url", 30 | "fieldtype": "Small Text", 31 | "label": "Request URL", 32 | "read_only": 1 33 | }, 34 | { 35 | "fieldname": "status_code", 36 | "fieldtype": "Int", 37 | "label": "Status Code", 38 | "read_only": 1 39 | } 40 | ], 41 | "index_web_pages_for_search": 1, 42 | "links": [], 43 | "modified": "2021-11-25 12:17:13.764323", 44 | "modified_by": "Administrator", 45 | "module": "Zenoti", 46 | "name": "Zenoti Error Logs", 47 | "owner": "Administrator", 48 | "permissions": [ 49 | { 50 | "email": 1, 51 | "export": 1, 52 | "print": 1, 53 | "read": 1, 54 | "report": 1, 55 | "role": "System Manager", 56 | "share": 1 57 | } 58 | ], 59 | "sort_field": "modified", 60 | "sort_order": "DESC" 61 | } -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_error_logs/zenoti_error_logs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see LICENSE 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ZenotiErrorLogs(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/ecommerce_integrations/0ccf599f8ff1cdde6e9ab888ddd6fa8109bb108e/ecommerce_integrations/zenoti/doctype/zenoti_settings/__init__.py -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_settings/test_zenoti_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See LICENSE 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestZenotiSettings(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /ecommerce_integrations/zenoti/doctype/zenoti_settings/zenoti_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see LICENSE 3 | 4 | frappe.ui.form.on("Zenoti Settings", { 5 | setup: function (frm) { 6 | frm.set_query( 7 | "liability_income_account_for_gift_and_prepaid_cards", 8 | function () { 9 | if (!frm.doc.company) { 10 | frappe.throw(__("Please select company first")); 11 | } 12 | return { 13 | filters: { 14 | root_type: "Liability", 15 | is_group: 0, 16 | account_type: "Income Account", 17 | company: frm.doc.company, 18 | }, 19 | }; 20 | } 21 | ); 22 | 23 | frm.set_query("default_purchase_warehouse", function () { 24 | if (!frm.doc.company) { 25 | frappe.throw(__("Please select company first")); 26 | } 27 | return { 28 | filters: { 29 | is_group: 0, 30 | company: frm.doc.company, 31 | }, 32 | }; 33 | }); 34 | 35 | frm.set_query("default_buying_price_list", function () { 36 | return { 37 | filters: { 38 | buying: 1, 39 | }, 40 | }; 41 | }); 42 | 43 | frm.set_query("default_selling_price_list", function () { 44 | return { 45 | filters: { 46 | selling: 1, 47 | }, 48 | }; 49 | }); 50 | }, 51 | refresh(frm) { 52 | if (cint(frm.doc.enable_zenoti)) { 53 | frm.add_custom_button(__("Update Centers"), function () { 54 | frappe.call({ 55 | method: "ecommerce_integrations.zenoti.doctype.zenoti_settings.zenoti_settings.update_centers", 56 | freeze: true, 57 | freeze_message: __("Updating Centers..."), 58 | callback: function (r) { 59 | if (!r.exc) { 60 | frappe.show_alert(__("Centers Updated")); 61 | } 62 | }, 63 | }); 64 | }); 65 | } 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name='ecommerce_integrations' 3 | description='Ecommerce integrations for ERPNext' 4 | authors = [ 5 | { name = "Frappe Technologies Pvt Ltd", email = "developers@frappe.io"} 6 | ] 7 | requires-python = ">=3.10" 8 | readme = "./README.md" 9 | dynamic = ["version"] 10 | 11 | dependencies = [ 12 | "ShopifyAPI==12.4.0", # update after resolving pyjwt conflict in frappe 13 | "boto3~=1.28.10", 14 | ] 15 | 16 | [project.license] 17 | file = "./LICENSE" 18 | 19 | [build-system] 20 | requires = ["flit_core >=3.4,<4"] 21 | build-backend = "flit_core.buildapi" 22 | 23 | [tool.ruff] 24 | line-length = 110 25 | target-version = "py310" 26 | 27 | [tool.ruff.lint] 28 | select = [ 29 | "F", 30 | "E", 31 | "W", 32 | "I", 33 | "UP", 34 | "B", 35 | "RUF", 36 | ] 37 | ignore = [ 38 | "B017", # assertRaises(Exception) - should be more specific 39 | "B018", # useless expression, not assigned to anything 40 | "B023", # function doesn't bind loop variable - will have last iteration's value 41 | "B904", # raise inside except without from 42 | "E101", # indentation contains mixed spaces and tabs 43 | "E402", # module level import not at top of file 44 | "E501", # line too long 45 | "E741", # ambiguous variable name 46 | "F401", # "unused" imports 47 | "F403", # can't detect undefined names from * import 48 | "F405", # can't detect undefined names from * import 49 | "F722", # syntax error in forward type annotation 50 | "W191", # indentation contains tabs 51 | "RUF001", # string contains ambiguous unicode character 52 | ] 53 | typing-modules = ["frappe.types.DF"] 54 | 55 | [tool.ruff.format] 56 | quote-style = "double" 57 | indent-style = "tab" 58 | docstring-code-format = true 59 | --------------------------------------------------------------------------------