├── .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 | [](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 |
--------------------------------------------------------------------------------