├── .flake8_strict ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── helper │ ├── install.sh │ └── site_config.json └── workflows │ ├── ci.yml │ ├── deploy_docs.yml │ ├── linters.yml │ └── semantic-commits.yml ├── .gitignore ├── .pre-commit-config.yaml ├── MANIFEST.in ├── README.md ├── commitlint.config.js ├── docs ├── .gitignore ├── book.toml └── src │ ├── README.md │ ├── SUMMARY.md │ ├── configure.md │ ├── features │ ├── item-prices.md │ ├── item-stock-levels.md │ ├── items.md │ ├── sales-order.md │ └── woocommerce-plugins.md │ └── images │ ├── add-wc-server.png │ ├── item-fields-mapping-2.png │ ├── item-link.png │ ├── item-prices.png │ ├── item-stock-levels.png │ ├── items-tab.png │ ├── new-wc-server.png │ ├── sales-order-item-fields-mapping.png │ ├── so-order-status.png │ ├── so-shipping-rule-2.png │ ├── so-tab-mandatory.png │ └── wc-api-settings.png ├── license.txt ├── pyproject.toml ├── setup.py └── woocommerce_fusion ├── __init__.py ├── change_log ├── v0 │ ├── v0_1_0.md │ ├── v0_1_2.md │ ├── v0_2_0.md │ ├── v0_2_1.md │ ├── v0_2_2.md │ ├── v0_2_3.md │ ├── v0_2_8.md │ ├── v0_2_9.md │ ├── v0_3_0.md │ ├── v0_4_0.md │ ├── v0_5_0.md │ ├── v0_6_0.md │ └── v0_7_0.md └── v1 │ ├── v1_0_0.md │ ├── v1_10_0.md │ ├── v1_11_0.md │ ├── v1_12_0.md │ ├── v1_13_2.md │ ├── v1_13_3.md │ ├── v1_13_4.md │ ├── v1_3_0.md │ ├── v1_3_1.md │ ├── v1_4_0.md │ ├── v1_4_1.md │ ├── v1_5_0.md │ ├── v1_5_3.md │ ├── v1_6_0.md │ ├── v1_7_3.md │ ├── v1_7_4.md │ ├── v1_7_6.md │ ├── v1_8_0.md │ └── v1_9_0.md ├── config ├── __init__.py ├── desktop.py └── docs.py ├── exceptions.py ├── fixtures └── custom_field.json ├── hooks.py ├── modules.txt ├── overrides ├── __init__.py └── selling │ ├── __init__.py │ ├── sales_order.py │ └── test_sales_order.py ├── patches.txt ├── patches ├── v0 │ ├── change_woocommerce_site_to_link_field.py │ ├── update_log_settings.py │ ├── update_sales_order_woocommerce_payment_method_field.py │ └── update_woocommerce_email_ids.py └── v1 │ ├── migrate_woocommerce_settings.py │ ├── migrate_woocommerce_settings_v1_4.py │ ├── remove_old_settings_doctypes.py │ ├── update_woocommerce_identifiers.py │ └── update_woocommerce_server_item_map.py ├── public ├── .gitkeep └── js │ ├── selling │ ├── sales_order.js │ └── sales_order_list.js │ └── stock │ └── item.js ├── setup ├── __init__.py └── utils.py ├── tasks ├── __init__.py ├── stock_update.py ├── sync.py ├── sync_item_prices.py ├── sync_items.py ├── sync_sales_orders.py ├── test_integration_helpers.py ├── test_integration_item_price_sync.py ├── test_integration_items_sync.py ├── test_integration_so_sync.py ├── test_integration_stock_update.py ├── test_stock_update.py ├── test_sync_items.py ├── test_sync_sales_orders.py ├── test_utils.py └── utils.py ├── templates ├── __init__.py └── pages │ ├── __init__.py │ └── __pycache__ │ └── __init__.py ├── translations └── de.csv ├── woocommerce ├── __init__.py ├── doctype │ ├── __init__.py │ ├── item_woocommerce_server │ │ ├── __init__.py │ │ ├── item_woocommerce_server.json │ │ └── item_woocommerce_server.py │ ├── woocommerce_integration_settings │ │ ├── __init__.py │ │ ├── test_woocommerce_integration_settings.py │ │ ├── woocommerce_integration_settings.js │ │ ├── woocommerce_integration_settings.json │ │ └── woocommerce_integration_settings.py │ ├── woocommerce_order │ │ ├── __init__.py │ │ ├── test_woocommerce_order.py │ │ ├── woocommerce_order.js │ │ ├── woocommerce_order.json │ │ └── woocommerce_order.py │ ├── woocommerce_product │ │ ├── __init__.py │ │ ├── test_woocommerce_product.py │ │ ├── woocommerce_product.js │ │ ├── woocommerce_product.json │ │ └── woocommerce_product.py │ ├── woocommerce_request_log │ │ ├── __init__.py │ │ ├── test_woocommerce_request_log.py │ │ ├── woocommerce_request_log.js │ │ ├── woocommerce_request_log.json │ │ └── woocommerce_request_log.py │ ├── woocommerce_server │ │ ├── __init__.py │ │ ├── test_woocommerce_server.py │ │ ├── woocommerce_server.js │ │ ├── woocommerce_server.json │ │ └── woocommerce_server.py │ ├── woocommerce_server_item_field │ │ ├── __init__.py │ │ ├── woocommerce_server_item_field.json │ │ └── woocommerce_server_item_field.py │ ├── woocommerce_server_order_item_field │ │ ├── __init__.py │ │ ├── woocommerce_server_order_item_field.json │ │ └── woocommerce_server_order_item_field.py │ ├── woocommerce_server_order_status │ │ ├── __init__.py │ │ ├── woocommerce_server_order_status.json │ │ └── woocommerce_server_order_status.py │ ├── woocommerce_server_shipping_rule │ │ ├── __init__.py │ │ ├── woocommerce_server_shipping_rule.json │ │ └── woocommerce_server_shipping_rule.py │ └── woocommerce_server_warehouse │ │ ├── __init__.py │ │ ├── woocommerce_server_warehouse.json │ │ └── woocommerce_server_warehouse.py └── woocommerce_api.py └── woocommerce_endpoint.py /.flake8_strict: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | B007, 4 | B009, 5 | B010, 6 | B950, 7 | E101, 8 | E111, 9 | E114, 10 | E116, 11 | E117, 12 | E121, 13 | E122, 14 | E123, 15 | E124, 16 | E125, 17 | E126, 18 | E127, 19 | E128, 20 | E131, 21 | E201, 22 | E202, 23 | E203, 24 | E211, 25 | E221, 26 | E222, 27 | E223, 28 | E224, 29 | E225, 30 | E226, 31 | E228, 32 | E231, 33 | E241, 34 | E242, 35 | E251, 36 | E261, 37 | E262, 38 | E265, 39 | E266, 40 | E271, 41 | E272, 42 | E273, 43 | E274, 44 | E301, 45 | E302, 46 | E303, 47 | E305, 48 | E306, 49 | E402, 50 | E501, 51 | E502, 52 | E701, 53 | E702, 54 | E703, 55 | E741, 56 | F403, 57 | W191, 58 | W291, 59 | W292, 60 | W293, 61 | W391, 62 | W503, 63 | W504, 64 | E711, 65 | E129, 66 | F841, 67 | E713, 68 | E712, 69 | B023, 70 | B028 71 | 72 | 73 | max-line-length = 200 74 | exclude=.github/helper/semgrep_rules,test_*.py -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: File a bug/issue 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Current Behavior 9 | description: A concise description of what the bug is. 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Steps To Reproduce 15 | description: Steps to reproduce the behavior. 16 | placeholder: | 17 | 1. Go to '...' 18 | 1. Click on '....' 19 | 1. Scroll down to '....' 20 | 1. See error 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Expected Behavior 26 | description: A concise description of what you expected to happen. 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: Anything else? 32 | description: | 33 | Screenshots? Links? References? Anything that will give us more context about the issue you are encountering! 34 | 35 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 36 | validations: 37 | required: false 38 | - type: textarea 39 | attributes: 40 | label: Environment and Versions 41 | description: | 42 | examples: 43 | - **Frappe Version**: v14.6.9 44 | - **ERPNext Version**: v14.6.9 45 | - **Custom App Version**: v0.6.2 46 | value: | 47 | - Frappe Version: 48 | - ERPNext Version: 49 | - Custom App Version: 50 | render: markdown 51 | validations: 52 | required: false 53 | - type: dropdown 54 | id: version 55 | attributes: 56 | label: Operating System 57 | description: On what OS are you seeing the problem on? 58 | multiple: true 59 | options: 60 | - Windows 61 | - MacOS 62 | - Android 63 | - iOS 64 | - Other 65 | validations: 66 | required: true 67 | - type: dropdown 68 | id: browsers 69 | attributes: 70 | label: What browsers are you seeing the problem on? 71 | multiple: true 72 | options: 73 | - Chrome 74 | - Microsoft Edge 75 | - Firefox 76 | - Safari 77 | - Other 78 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Finfoot Tech 4 | url: https://finfoot.tech 5 | about: More information on Finfoot Tech. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Request a new feature, improvement or enhancement 3 | title: "<title>" 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: | 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | validations: 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Describe the solution you'd like 16 | description: A clear and concise description of what you want to happen. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe alternatives you've considered 22 | description: A clear and concise description of any alternative solutions or features you've considered. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Add any other context or screenshots about the feature request here. 30 | 31 | Tip: You can attach images by clicking this area to highlight it and then dragging files in. 32 | validations: 33 | required: false 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | >*This text should be replaced* 4 | > 5 | >*Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.* 6 | 7 | ## Type of change 8 | 9 | - ⚪ Bug fix (change which fixes an issue) 10 | - 🟢 New feature (change which adds functionality) 11 | - ⚪ Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | 13 | 14 | ## Tests 15 | 16 | - [ ] [Unit Tests](https://frappeframework.com/docs/user/en/guides/automated-testing/unit-testing) have been updated or added, as required 17 | - [ ] [UI Tests](https://frappeframework.com/docs/user/en/ui-testing) have been updated or added, as required 18 | 19 | ## Checklist: 20 | 21 | - [ ] My code follows [Naming Guidelines](https://github.com/frappe/erpnext/wiki/Naming-Guidelines) (DocType, Field and Variable naming) 22 | - [ ] No Form changes */* My code follows the [Form Design Guidelines](https://github.com/frappe/erpnext/wiki/Form-Design-Guidelines) 23 | - [ ] My code follows the [Coding Standards](https://github.com/frappe/erpnext/wiki/Coding-Standards) of this project 24 | - [ ] My code follows the [Code Security Guidelines](https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines) of this project 25 | - [ ] I have performed a self-review of my own code 26 | - [ ] I have commented my code, particularly in hard-to-understand areas */* No comments necessary 27 | - [ ] I have made corresponding additions/changes to the documentation 28 | - [ ] All business logic and validations are on the server-side */* No business logic or validation changes 29 | - [ ] No patches are necessary */* Migration Patches have been added to the correct subdirectory of `/patches` and `patches.txt` have been updated 30 | 31 | 32 | ## User Experience: 33 | 34 | >*This text can be deleted* 35 | > 36 | >*If your change involves user experience, add a screenshot/animated GIF. An animated GIF guarantees that you have tested your change and there are no unintended errors.* 37 | -------------------------------------------------------------------------------- /.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 | # Dependencies for cypress: https://docs.cypress.io/guides/continuous-integration/introduction#UbuntuDebian 11 | sudo apt-get install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb 12 | 13 | pip install frappe-bench 14 | 15 | bench init --skip-assets --python "$(which python)" --frappe-branch "$TEST_AGAINST_FRAPPE_VERSION" ~/frappe-bench 16 | 17 | mkdir ~/frappe-bench/sites/test_site 18 | cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ 19 | 20 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" 21 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 22 | 23 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" 24 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" 25 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" 26 | 27 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" 28 | 29 | install_whktml() { 30 | wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz 31 | tar -xf /tmp/wkhtmltox.tar.xz -C /tmp 32 | sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf 33 | sudo chmod o+x /usr/local/bin/wkhtmltopdf 34 | } 35 | install_whktml & 36 | 37 | cd ~/frappe-bench || exit 38 | 39 | sed -i 's/watch:/# watch:/g' Procfile 40 | sed -i 's/schedule:/# schedule:/g' Procfile 41 | sed -i 's/socketio:/# socketio:/g' Procfile 42 | sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile 43 | 44 | bench get-app https://github.com/frappe/erpnext --branch $TEST_AGAINST_ERPNEXT_VERSION --resolve-deps 45 | bench get-app --overwrite woocommerce_fusion "${GITHUB_WORKSPACE}" 46 | bench --verbose setup env --python python3.10 47 | bench --verbose setup requirements --dev 48 | 49 | bench start &>> ~/frappe-bench/bench_start.log & 50 | CI=Yes bench build --app frappe & 51 | bench --site test_site reinstall --yes 52 | 53 | bench --verbose --site test_site install-app erpnext 54 | bench --verbose --site test_site install-app woocommerce_fusion -------------------------------------------------------------------------------- /.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 | "throttle_user_limit": 100 15 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - version-15 8 | pull_request: 9 | 10 | concurrency: 11 | group: develop-woocommerce_fusion-${{ github.event.number }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | TEST_AGAINST_FRAPPE_VERSION: v15.47.1 16 | TEST_AGAINST_ERPNEXT_VERSION: v15.41.1 17 | 18 | jobs: 19 | tests: 20 | runs-on: ubuntu-22.04 21 | strategy: 22 | fail-fast: false 23 | name: Backend Unit Tests & UI Tests 24 | 25 | services: 26 | mariadb: 27 | image: mariadb:10.11 28 | env: 29 | MYSQL_ROOT_PASSWORD: root 30 | ports: 31 | - 3306:3306 32 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 33 | 34 | steps: 35 | - name: Clone 36 | uses: actions/checkout@v4 37 | 38 | - name: Setup Python 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: | 42 | 3.10 43 | 44 | - name: Setup Node 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: 18 48 | check-latest: true 49 | 50 | - name: Cache pip 51 | uses: actions/cache@v3 52 | with: 53 | path: ~/.cache/pip 54 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} 55 | restore-keys: | 56 | ${{ runner.os }}-pip- 57 | ${{ runner.os }}- 58 | 59 | - name: Get yarn cache directory path 60 | id: yarn-cache-dir-path 61 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 62 | 63 | - name: Cache yarn 64 | uses: actions/cache@v3 65 | id: yarn-cache 66 | with: 67 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 68 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 69 | restore-keys: | 70 | ${{ runner.os }}-yarn- 71 | 72 | name: InstaWP WordPress Testing 73 | 74 | - name: Create InstaWP instance 75 | uses: instawp/wordpress-testing-automation@main 76 | id: create-instawp 77 | continue-on-error: true # To avoid "Error: Resource not accessible by integration" 78 | with: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | INSTAWP_TOKEN: ${{ secrets.INSTAWP_TOKEN }} 81 | INSTAWP_TEMPLATE_SLUG: woocommerce-with-tax-and-variants 82 | REPO_ID: 291 83 | INSTAWP_ACTION: create-site-template 84 | 85 | - name: Extract InstaWP domain 86 | id: extract-instawp-domain 87 | run: | 88 | # Check step to ensure instawp_url is not empty 89 | if [[ -z "${{ steps.create-instawp.outputs.instawp_url }}" ]]; then 90 | echo "instawp_url is empty. Failing the job." 91 | exit 1 92 | else 93 | echo "instawp_url exists: ${{ steps.create-instawp.outputs.instawp_url }}" 94 | fi 95 | 96 | instawp_domain="$(echo "${{ steps.create-instawp.outputs.instawp_url }}" | sed -e s#https://##)" 97 | echo "instawp-domain=$(echo $instawp_domain)" >> $GITHUB_OUTPUT 98 | 99 | - name: Install 100 | run: | 101 | bash ${GITHUB_WORKSPACE}/.github/helper/install.sh 102 | 103 | - name: Run Tests 104 | working-directory: /home/runner/frappe-bench 105 | run: | 106 | bench --site test_site set-config allow_tests true 107 | bench --site test_site run-tests --app woocommerce_fusion --coverage 108 | env: 109 | TYPE: server 110 | WOO_INTEGRATION_TESTS_WEBSERVER: ${{ steps.create-instawp.outputs.instawp_url }} 111 | WOO_API_CONSUMER_KEY: ${{ secrets.WOO_API_CONSUMER_KEY }} 112 | WOO_API_CONSUMER_SECRET: ${{ secrets.WOO_API_CONSUMER_SECRET }} 113 | 114 | - name: Destroy InstaWP instance 115 | uses: instawp/wordpress-testing-automation@main 116 | id: destroy-instawp 117 | if: ${{ always() }} 118 | with: 119 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 120 | INSTAWP_TOKEN: ${{ secrets.INSTAWP_TOKEN }} 121 | INSTAWP_TEMPLATE_SLUG: woocommerce-with-tax-and-variants 122 | REPO_ID: 291 123 | INSTAWP_ACTION: destroy-site 124 | 125 | - name: Upload coverage data 126 | uses: codecov/codecov-action@v3 127 | with: 128 | name: Backend 129 | token: ${{ secrets.CODECOV_TOKEN }} 130 | # fail_ci_if_error: true 131 | files: /home/runner/frappe-bench/sites/coverage.xml 132 | verbose: true 133 | -------------------------------------------------------------------------------- /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - version-15 6 | 7 | jobs: 8 | deploy: 9 | name: Build Docs and Deploy to GitHub Pages 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write # To push a branch 13 | pages: write # To push to a GitHub Pages site 14 | id-token: write # To update the deployment status 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Install latest mdbook 20 | run: | 21 | tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name') 22 | url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz" 23 | mkdir mdbook 24 | curl -sSL $url | tar -xz --directory=./mdbook 25 | echo `pwd`/mdbook >> $GITHUB_PATH 26 | - name: Build Book 27 | run: | 28 | cd docs 29 | mdbook build 30 | - name: Setup Pages 31 | uses: actions/configure-pages@v4 32 | - name: Upload artifact 33 | uses: actions/upload-pages-artifact@v3 34 | with: 35 | # Upload entire repository 36 | path: 'docs/book' 37 | - name: Deploy to GitHub Pages 38 | id: deployment 39 | uses: actions/deploy-pages@v4 40 | -------------------------------------------------------------------------------- /.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@v3 13 | 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 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 | - name: Download semgrep 26 | run: pip install semgrep==1.31.1 27 | 28 | - name: Run Semgrep rules 29 | run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness -------------------------------------------------------------------------------- /.github/workflows/semantic-commits.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Commits 2 | 3 | on: 4 | pull_request: {} 5 | 6 | jobs: 7 | commitlint: 8 | name: Check Commit Titles 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 200 14 | 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | check-latest: true 19 | 20 | - name: Check commit titles 21 | run: | 22 | npm install @commitlint/cli @commitlint/config-conventional 23 | npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | woocommerce_fusion/docs/current 7 | node_modules/ -------------------------------------------------------------------------------- /.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.0.1 9 | hooks: 10 | - id: trailing-whitespace 11 | files: "woocommerce_fusion.*" 12 | exclude: ".*json$|.*txt$|.*csv|.*md" 13 | - id: check-yaml 14 | - id: no-commit-to-branch 15 | args: ['--branch', 'develop'] 16 | - id: check-merge-conflict 17 | - id: check-ast 18 | 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 5.0.4 21 | hooks: 22 | - id: flake8 23 | additional_dependencies: [ 24 | 'flake8-bugbear', 25 | ] 26 | args: ['--config', '.flake8_strict'] 27 | exclude: ".*setup.py$" 28 | 29 | - repo: https://github.com/adityahase/black 30 | rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119 31 | hooks: 32 | - id: black 33 | additional_dependencies: ['click==8.0.4'] 34 | 35 | - repo: https://github.com/PyCQA/isort 36 | rev: 5.12.0 37 | hooks: 38 | - id: isort 39 | exclude: ".*setup.py$" 40 | 41 | 42 | ci: 43 | autoupdate_schedule: weekly 44 | skip: [] 45 | submodules: false -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include woocommerce_fusion *.css 8 | recursive-include woocommerce_fusion *.csv 9 | recursive-include woocommerce_fusion *.html 10 | recursive-include woocommerce_fusion *.ico 11 | recursive-include woocommerce_fusion *.js 12 | recursive-include woocommerce_fusion *.json 13 | recursive-include woocommerce_fusion *.md 14 | recursive-include woocommerce_fusion *.png 15 | recursive-include woocommerce_fusion *.py 16 | recursive-include woocommerce_fusion *.svg 17 | recursive-include woocommerce_fusion *.txt 18 | recursive-exclude woocommerce_fusion *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## WooCommerce Fusion 2 | 3 | ![CI workflow](https://github.com/dvdl16/woocommerce_fusion/actions/workflows/ci.yml/badge.svg?branch=version-15) 4 | [![codecov](https://codecov.io/gh/dvdl16/woocommerce_fusion/graph/badge.svg?token=A5OR5QIOUX)](https://codecov.io/gh/dvdl16/woocommerce_fusion) 5 | 6 | WooCommerce connector for ERPNext v15 7 | 8 | This app allows you to synchronise your ERPNext site with **multiple** WooCommerce websites 9 | 10 | ### Features 11 | 12 | - [Sales Order Synchronisation](https://woocommerce-fusion-docs.finfoot.tech/features/sales-order) 13 | - [Item Synchronisation](https://woocommerce-fusion-docs.finfoot.tech/features/items) 14 | - [Sync Item Stock Levels](https://woocommerce-fusion-docs.finfoot.tech/features/item-stock-levels) 15 | - [Sync Item Prices](https://woocommerce-fusion-docs.finfoot.tech/features/item-prices) 16 | - [Integration with WooCommerce Plugins](https://woocommerce-fusion-docs.finfoot.tech/features/woocommerce-plugins) 17 | 18 | ### User documentation 19 | 20 | User documentation is hosted at [woocommerce-fusion-docs.finfoot.tech](https://woocommerce-fusion-docs.finfoot.tech) 21 | 22 | ### Manual Installation 23 | 24 | 1. [Install bench](https://github.com/frappe/bench). 25 | 2. [Install ERPNext](https://github.com/frappe/erpnext#installation). 26 | 3. Once ERPNext is installed, add the woocommerce_fusion app to your bench by running 27 | 28 | ```sh 29 | $ bench get-app https://github.com/dvdl16/woocommerce_fusion 30 | ``` 31 | 4. After that, you can install the woocommerce_fusion app on the required site by running 32 | ```sh 33 | $ bench --site sitename install-app woocommerce_fusion 34 | ``` 35 | 36 | 37 | ### Tests 38 | 39 | To run unit and integration tests: 40 | 41 | ```shell 42 | bench --site test_site run-tests --app woocommerce_fusion --coverage 43 | ``` 44 | 45 | #### InstaWP Requirement 46 | For integration tests, we use InstaWP to spin up temporary Wordpress websites. 47 | 48 | *TBD - steps to create a new site and template* 49 | 50 | ### Development 51 | 52 | We use [pre-commit](https://pre-commit.com/) for linting. First time setup may be required: 53 | ```shell 54 | # Install pre-commit 55 | pip install pre-commit 56 | 57 | # Install the git hook scripts 58 | pre-commit install 59 | 60 | #(optional) Run against all the files 61 | pre-commit run --all-files 62 | ``` 63 | 64 | We use [Semgrep](https://semgrep.dev/docs/getting-started/) rules specific to [Frappe Framework](https://github.com/frappe/frappe) 65 | ```shell 66 | # Install semgrep 67 | python3 -m pip install semgrep 68 | 69 | # Clone the rules repository 70 | git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 71 | 72 | # Run semgrep specifying rules folder as config 73 | semgrep --config=/workspace/development/frappe-semgrep-rules/rules apps/woocommerce_fusion 74 | ``` 75 | 76 | If you use VS Code, you can specify the `.flake8` config file in your `settings.json` file: 77 | ```shell 78 | "python.linting.flake8Args": ["--config=frappe-bench-v15/apps/woocommerce_fusion/.flake8_strict"] 79 | ``` 80 | 81 | 82 | The documentation has been generated using [mdBook](https://rust-lang.github.io/mdBook/guide/creating.html) 83 | 84 | Make sure you have [mdbook](https://rust-lang.github.io/mdBook/guide/installation.html) installed/downloaded. To modify and test locally: 85 | ```shell 86 | cd docs 87 | mdbook serve --open 88 | ``` 89 | 90 | ### License 91 | 92 | GNU GPL V3 93 | 94 | The code is licensed as GNU General Public License (v3) and the copyright is owned by Finfoot Tech (Pty) Ltd and Contributors. 95 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserPreset: 'conventional-changelog-conventionalcommits', 3 | rules: { 4 | 'subject-empty': [2, 'never'], 5 | 'type-case': [2, 'always', 'lower-case'], 6 | 'type-empty': [2, 'never'], 7 | 'type-enum': [ 8 | 2, 9 | 'always', 10 | [ 11 | 'build', 12 | 'chore', 13 | 'ci', 14 | 'docs', 15 | 'feat', 16 | 'fix', 17 | 'perf', 18 | 'refactor', 19 | 'revert', 20 | 'style', 21 | 'test', 22 | ], 23 | ], 24 | }, 25 | }; -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Dirk van der Laarse"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "WooCommerce Fusion Documentation" 7 | -------------------------------------------------------------------------------- /docs/src/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is an [Frappe](https://frappeframework.com/) custom app, intended to add WooCommerce-specific features to [ERPNext](https://erpnext.com/) 4 | 5 | This app allows you to synchronise your ERPNext site with **multiple** WooCommerce websites 6 | 7 | # Set up and configuration 8 | 1. [Configure WooCommerce Fusion](configure.md) 9 | 10 | # Features 11 | 12 | 1. [Two-way Sales Order Synchronisation](features/sales-order.md): Create Sales Orders in ERPNext automatically and keep it synchronised with WooCommerce orders 13 | 2. [Two-way Item Synchronisation](features/items.md) 14 | 3. [Sync Item Stock Levels from ERPNext to WooCommerce](features/item-stock-levels.md) 15 | 4. [Sync Item Prices from ERPNext to WooCommerce](features/item-prices.md) 16 | 5. [Integration with WooCommerce Plugins](features/woocommerce-plugins.md) 17 | 18 | 19 | # Feedback 20 | 21 | Please raise issues for any bugs or enhancements here: [https://github.com/dvdl16/woocommerce_fusion](https://github.com/dvdl16/woocommerce_fusion) 22 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Features](README.md) 4 | 5 | [⚙️ Configure WooCommerce Fusion](configure.md) 6 | - [Sales Order Synchronisation](features/sales-order.md) 7 | - [Item Synchronisation](features/items.md) 8 | - [Sync Item Stock Levels](features/item-stock-levels.md) 9 | - [Sync Item Prices](features/item-prices.md) 10 | - [Integration with WooCommerce Plugins](features/woocommerce-plugins.md) -------------------------------------------------------------------------------- /docs/src/configure.md: -------------------------------------------------------------------------------- 1 | # Configure WooCommerce Fusion 2 | 3 | --- 4 | 5 | The first step is to create a **WooCommerce Server** document, representing your WooCommerce website. 6 | 7 | ![click on Add WooCommerce Server](images/add-wc-server.png) 8 | 9 | Complete the "WooCommerce Server URL", "API consumer key" and "API consumer secret" fields. To find your API consumer key and secret, go to your WordPress admin panel and navigate to WooCommerce > Settings > Advanced > REST API, and click on "Add key". Make sure to add Read/Write permissions to the API key. 10 | 11 | ![WooCommerce API Settings](images/wc-api-settings.png) 12 | 13 | ![New WooCommerce Server](images/new-wc-server.png) 14 | 15 | --- 16 | 17 | Click on the "Sales Orders" tab and complete the mandatory fields 18 | 19 | !["Sales Orders" tab](images/so-tab-mandatory.png) 20 | 21 | **Settings**: 22 | - Synchronise Sales Order Line changes back 23 | 24 | When set, adding/removing/changing Sales Order **Lines** will be synchronised back to the WooCommerce Order (Note: Sales Orders will always be synchronised, this setting is for sync'ing changed Sales Order **Lines** *back* to WooCommerce) 25 | 26 | - Enable Payments Sync 27 | 28 | Let the app create Payment Entries for paid Sales Orders. A mapping of Payment Method to Bank Account is required: 29 | 30 | A **Payment Entry** will only be created if the following conditions are true: 31 | 32 | - `WooCommerce Order` > `Payment Method` is set 33 | **and** 34 | - `WooCommerce Order` > `Date Paid` is set (unless `Ignore empty 'Date Paid' field on WooCommerce Orders` is set on `WooCommerce Server` 35 | 36 | *When the payment method is "Cash on Delivery" (cod), the `Date Paid` field would usually be blank, so creation of a *Payment Entry* won't happen. If you want to be sure, you can add `cod` in the mapping: 37 | 38 | ```json 39 | { 40 | "bacs": "1000-000 Bank Account", 41 | "cheque": "1000-100 Other Bank Account", 42 | "cod": "" 43 | } 44 | ``` 45 | 46 | --- 47 | 48 | Click on the "Items" tab if you want to turn on Stock Level Synchronisation 49 | 50 | !["Items" tab](images/items-tab.png) 51 | 52 | **Settings**: 53 | - Default Item Code Naming Basis 54 | - How the item code should be determined when an item is created, either "WooCommerce ID" or "Product SKU". 55 | - Enable Stock Level Synchronisation 56 | - Turns on Syncrhonisation of Item Stock Levels to WooCommerce 57 | - Warehouses 58 | - Select the Warehouses that should be taken into account when synchronising Item Stock Levels 59 | 60 | --- 61 | 62 | Click on the "Save" - and you are ready to go! 63 | -------------------------------------------------------------------------------- /docs/src/features/item-prices.md: -------------------------------------------------------------------------------- 1 | # Sync Item Prices from ERPNext to WooCommerce 2 | 3 | ![Sync item prices](../images/item-prices.png) 4 | 5 | ## Background Job 6 | 7 | If *Price List Sync* is enabled, every day, a background task runs that performs the following steps: 8 | 1. Get list of ERPNext Item Prices to synchronise, based on the *Price List* setting 9 | 2. Synchronise Item Prices with WooCommerce Products 10 | 11 | ## Hooks 12 | 13 | If *Price List Sync* is enabled, a product update API request will be made when the following documents are updated: 14 | - Item Price 15 | 16 | ## Manual Trigger 17 | Price List Synchronisation can also be triggered from an **Item**, by clicking on *Actions* > *Sync this Item's Price to WooCommerce* 18 | 19 | ## Troubleshooting 20 | - You can look at the list of **WooCommerce Products** from within ERPNext by opening the **WooCommerce Product** doctype. This is a [Virtual DocType](https://frappeframework.com/docs/v15/user/en/basics/doctypes/virtual-doctype) that interacts directly with your WooCommerce site's API interface 21 | - Any errors during this process can be found under **Error Log**. 22 | - You can also check the **Scheduled Job Log** for the `sync_item_prices.run_item_price_sync` Scheduled Job. 23 | - A history of all API calls made to your Wordpress Site can be found under **WooCommerce Request Log** (*Enable WooCommerce Request Logs* needs to be turned on on **WooCommerce Server** > *Logs*) 24 | -------------------------------------------------------------------------------- /docs/src/features/item-stock-levels.md: -------------------------------------------------------------------------------- 1 | # Sync Item Stock Levels from ERPNext to WooCommerce 2 | 3 | ![Sync item stock level](../images/item-stock-levels.png) 4 | 5 | ## Background Job 6 | 7 | If *Stock Level Sync* is enabled, every day, a background task runs that performs the following steps: 8 | 1. Get all *enabled* items 9 | 2. For every WooCommerce-linked item, sum all quantities from all warehouses and round the total down (WooCommerce API doesn't accept float values) 10 | 3. For every item post the new stock level to WooCommerce 11 | 12 | ## Hooks 13 | 14 | If *Stock Level Sync* is enabled, a stock level API post will be made when the following documents are submitted or cancelled: 15 | - Stock Entry 16 | - Stock Reconciliation 17 | - Sales Invoice 18 | - Delivery Note 19 | 20 | ## Manual Trigger 21 | Stock Level Synchronisation can also be triggered from an **Item**, by clicking on *Actions* > *Sync this Item's Stock Levels to WooCommerce* 22 | 23 | ## Reserved Stock 24 | 25 | By default only the actual stock levels will be synchronised. You can ensure that only available stock is synced (by subtracting reserved stock), by enabling **WooCommerce Server** > *Items* > *Reserved Stock Adjustment*. 26 | 27 | 28 | ## Troubleshooting 29 | - You can look at the list of **WooCommerce Products** from within ERPNext by opening the **WooCommerce Product** doctype. This is a [Virtual DocType](https://frappeframework.com/docs/v15/user/en/basics/doctypes/virtual-doctype) that interacts directly with your WooCommerce site's API interface 30 | - Any errors during this process can be found under **Error Log**. 31 | - You can also check the **Scheduled Job Log** for the `stock_update.update_stock_levels_for_all_enabled_items_in_background` Scheduled Job. 32 | - A history of all API calls made to your Wordpress Site can be found under **WooCommerce Request Log** (*Enable WooCommerce Request Logs* needs to be turned on on **WooCommerce Server** > *Logs*) 33 | -------------------------------------------------------------------------------- /docs/src/features/items.md: -------------------------------------------------------------------------------- 1 | # Items Sync 2 | 3 | ## Setup 4 | 5 | To link your ERPNext Item to a WooCommerce Product: 6 | - If the WooCommerce Product already exists, specify the WooCommerce ID and WooCommerce Server 7 | - If you want the item to be created in WooCommerce, specify only the WooCommerce Server 8 | 9 | ![Linking an item](../images/item-link.png) 10 | 11 | ## Hooks 12 | 13 | - Every time an Item is updated or created, a synchronisation will take place for the item if: 14 | - A row exists in the **Item's** *WooCommerce Servers* child table with a blank/empty *WooCommerce ID* and *Enable Sync* is ticked: A linked WooCommerce Product will be created, **OR** 15 | - A row exists in the **Item's** *WooCommerce Servers* child table with a value set in *WooCommerce ID* and *Enable Sync* is ticked: The existing WooCommerce Product will be updated 16 | 17 | ## Manual Trigger 18 | - Item Synchronisation can also be triggered from an **Item**, by clicking on *Actions* > *Sync this Item with WooCommerce* 19 | - Item Synchronisation can also be triggered from a **WooCommerce Item**, by clicking on *Actions* > *Sync this Product with ERPNext* 20 | 21 | ## Background Job 22 | 23 | Every hour, a background task runs that performs the following steps: 24 | 1. Retrieve a list of **WooCommerce Products** that have been modified since the *Last Syncronisation Date* (on **WooCommerce Integration Settings**) 25 | 2. Compare each **WooCommerce Product** with its ERPNext **Item** counterpart, creating an **Item** if it doesn't exist or updating the relevant **Item** 26 | 27 | ## Synchronisation Logic 28 | When comparing a **WooCommerce Item** with it's counterpart ERPNext **Item**, the `date_modified` field on **WooCommerce Item** is compared with the `modified` field of ERPNext **Item**. The last modified document will be used as master when syncronising 29 | 30 | ## Fields Mapping 31 | 32 | | WooCommerce | ERPNext | Note | 33 | | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------- | 34 | | `id` | *Item Code* | Only if *Default Item Code Naming Basis* is set to *WooCommerce ID* on **WooCommerce Server** | 35 | | `sku` | *Item Code* | Only if *Default Item Code Naming Basis* is set to *Product SKU* on **WooCommerce Server** | 36 | | `name` | *Item Name* | | 37 | | `type` | | `simple` ≡ Normal **Item** | 38 | | | | `variable` ≡ Template **Item** (*Has Variants* is checked). | 39 | | | | `variant` ≡ **Item** Variant (*Variant Of* is set) | 40 | | `attributes` | *Attributes* | Missing **Item Attributes* will automatically be created in both systems | 41 | | `images[0]` | *Image* | One way sync - the URL of the first image on WooCommerce will be saved in the Image field. Setting needs to be turned on. | 42 | 43 | ## Custom Fields Mapping 44 | 45 | You can use [JSONPath](https://pypi.org/project/jsonpath-ng/) to map **Item** fields to specific **WooCommerce Product** fields. 46 | 47 | Here are a few examples: 48 | - `$.short_description` retrieves the content of the 'Short Description' WooCommerce Product field. 49 | - `$.meta_data[0].id` retrieves the content of the first item's `id` field in the WooCommerce Product Metadata. 50 | - `$.meta_data[?(@.key=='main_product_max_quantity_to_all')].value` retrieves the value of a Metadata entry with a `key` of `main_product_max_quantity_to_all` 51 | 52 | where `$` refers to the **WooCommerce Product** object 53 | 54 | 55 | ![Item Fields Mapping](../images/item-fields-mapping-2.png) 56 | 57 | To figure out the correct JSONPath expression, you can: 58 | 1. Go to any **WooCommerce Request Log** and filter for the `products` endpoints 59 | 2. Open [JSONPath Online Validator](https://jsonpath.com/) and copy the relevant object from the **WooCommerce Request Log** *Response* field to the *Document* text box. 60 | 3. Play around to get the JSONPath Query to return what you need. LLM's can be a big help here. 61 | 62 | 63 | 64 | 65 | **Note that this is recommended for advanced users only. This is a very basic functionality - there are no field type conversions possible as of yet. 66 | 67 | ## Troubleshooting 68 | - You can look at the list of **WooCommerce Products** from within ERPNext by opening the **WooCommerce Product** doctype. This is a [Virtual DocType](https://frappeframework.com/docs/v15/user/en/basics/doctypes/virtual-doctype) that interacts directly with your WooCommerce site's API interface 69 | - Any errors during this process can be found under **Error Log**. 70 | - You can also check the **Scheduled Job Log** for the `sync_items.run_items_sync` Scheduled Job. 71 | - A history of all API calls made to your Wordpress Site can be found under **WooCommerce Request Log** (*Enable WooCommerce Request Logs* needs to be turned on on **WooCommerce Server** > *Logs*) 72 | 73 | -------------------------------------------------------------------------------- /docs/src/features/sales-order.md: -------------------------------------------------------------------------------- 1 | # Sales Order Sync 2 | 3 | ## Background Job 4 | 5 | Every hour, a background task runs that performs the following steps: 6 | 1. Retrieve a list of **WooCommerce Orders** that have been modified since the *Last Syncronisation Date* (on **WooCommerce Integration Settings**) 7 | 2. Retrieve a list of ERPNext **Sales Orders** that are already linked to the **WooCommerce Orders** from Step 1 8 | 3. Retrieve a list of ERPNext **Sales Orders** that have been modified since the *Last Syncronisation Date* (on **WooCommerce Integration Settings**) 9 | 4. If necessary, retrieve a list of **WooCommerce Orders** that are already linked to the ERPNext **Sales Orders** from Step 3 10 | 5. Compare each **WooCommerce Order** with its ERPNext **Sales Orders** counterpart, creating an order if it doesn't exist 11 | 12 | ## Hooks 13 | 14 | - Every time a Sales Order is submitted, a synchronisation will take place for the Sales Order if: 15 | - A valid *WooCommerce Server* and *WooCommerce ID* is specified on **Sales Order** 16 | 17 | In order to make this work you need to configure the webhook in both, ERPNext and WooCommerce: 18 | 1. From ERPNext you need to get the access keys from the Woocommerce server configuration, in the WooCommerce Webhook Settings. 19 | 2. Create the webhook inside WooCommerce using the "Order created" topic and the rest of the data obtained on step 1. 20 | 21 | ## Manual Trigger 22 | - Sales Order Synchronisation can also be triggered from an **Sales Order**, by changing the field *WooCommerce Status* 23 | - Sales Order Synchronisation can also be triggered from an **Sales Order**, by clicking on *Actions* > *Sync this Item with WooCommerce* 24 | - Sales Order Synchronisation can also be triggered from a **WooCommerce Order**, by clicking on *Actions* > *Sync this Order with ERPNext* 25 | 26 | ## Background Job 27 | 28 | Every hour, a background task runs that performs the following steps: 29 | 1. Retrieve a list of **WooCommerce Orders** that have been modified since the *Last Syncronisation Date* (on **WooCommerce Integration Settings**) 30 | 2. Compare each **WooCommerce Order** with its ERPNext **Sales Order** counterpart, creating a **Sales Order** if it doesn't exist or updating the relevant **Sales Order** 31 | 32 | ## Synchronisation Logic 33 | When comparing a **WooCommerce Order** with it's counterpart ERPNext **Sales Order**, the `date_modified` field on **WooCommerce Order** is compared with the `modified` field of ERPNext **Sales Order**. The last modified document will be used as master when syncronising 34 | 35 | Note that if sync for an **Item** is disabled (i.e. the "Enabled" checkbox on the Item's WooCommerce Server row is unchecked) and an **WooCommerce Order** is placed for this item, synchronisation will be re-enabled for this item. 36 | 37 | ## Fields Mapping 38 | 39 | | WooCommerce | ERPNext | Note | 40 | | ------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 41 | | billing | **Address** with type *Billing* | See **Customer Synchronisation** below. Checks if the `billing.email` field matches an existing **Customer's** `woocommerce_identifier` field. If not, a new **Customer** is created. | 42 | | | **Contact** | | 43 | | shipping | **Adress** with type *Shipping* | See **Address Synchronsation** below | 44 | | line_items | **Item** | Checks if a linked **Item** exists, else a new Item is created | 45 | | id | **Sales Order** > *Customer's Purchase Order* | | 46 | | | **Sales Order** > *Woocommerce ID* | | 47 | | currency | **Sales Order** > *Currency* | | 48 | | customer_note | **Sales Order** > *WooCommerce Customer Note* | | 49 | 50 | 51 | ## Custom Fields Mapping for Sales Order Items 52 | 53 | You can use [JSONPath](https://pypi.org/project/jsonpath-ng/) to map **Sales Order Item** fields to specific **WooCommerce Order Line Item** fields. 54 | 55 | Here are a few examples: 56 | - `$.tax_class` retrieves the content of the 'Tax Class' WooCommerce Order Line Item field. 57 | - `$.meta_data[?(@.key=='my_metadata_field')].value` retrieves the value of a Metadata entry with a `key` of `my_metadata_field` 58 | 59 | where `$` refers to the **WooCommerce Order Line** object 60 | 61 | 62 | ![Sales Order Item Fields Mapping](../images/sales-order-item-fields-mapping.png) 63 | 64 | To figure out the correct JSONPath expression, you can: 65 | 1. Go to any **WooCommerce Order** and look at the 'Line Items' data 66 | 2. Open [JSONPath Online Validator](https://jsonpath.com/) and copy the relevant 'Line Items' data from the **WooCommerce Order** to the *Document* text box. 67 | 3. Play around to get the JSONPath Query to return what you need. LLM's can be a big help here. 68 | 69 | 70 | 71 | 72 | **Note that this is recommended for advanced users only. This is a very basic functionality - there are no field type conversions possible as of yet. 73 | 74 | 75 | ## Customer Synchronisation 76 | 77 | Each **Customer** record has a `woocommerce_identifier` custom field. This identifier is set depending on if the **WooCommerce Order** is from a guest or not: 78 | 79 | | Case | `woocommerce_identifier` | 80 | | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | 81 | | Guest (`customer_id` on **WooCommerce Order** is empty or 0) | `Guest-{order_id}` | 82 | | Company (`billing.company` on **WooCommerce Order** is set), **Only if *Enable Dual Accounts for Same Email (Private/Company)* is checked** | `{billing.email}-{company}` | 83 | | Individual (`billing.company` on **WooCommerce Order** is not set) | `billing.email` | 84 | 85 | ## Address Synchronisation 86 | - If the billing and shipping address on the **WooCommerce Order** is the same, a single **Address** will be created with both the *Preferred Billing Address* and *Preferred Shipping Address* checkboxes ticked. 87 | - If an address with *Preferred Billing Address*/*Preferred Shipping Address* ticked aleady exists, this address will be updated 88 | 89 | ## Shipping Rule Synchronisation 90 | - You can enable the synchronisation of WooCommerce Shipping Methods to ERPNext Shipping Rules on Sales Orders 91 | - For this to work, you have to map WooCommerce Shipping Methods to ERPNext Shipping Rules 92 | - You can find the *WooCommerce Shipping Method Title* fields by looking at the `method_title` values in **WooCommerce Order** > *Shipping Lines* 93 | 94 | ![Sales Order Sync Shipping Rule Map](../images/so-shipping-rule-2.png) 95 | 96 | 97 | ## Automatic Order Status Synchronisation 98 | 99 | ⚠️ This setting is Experimental. Monitor your Error Log after enabling this setting 100 | - You can enable the synchronisation of ERPNext Order Status to WooCommerce Order Status by checking the "Keep the Status of ERPNext Sales Orders and WooCommerce Orders in sync" checkbox 101 | - For this to work, you have to map **ERPNext Sales Order Statuses** to **WooCommerce Sales Order Statuses** 102 | - For example, if you map `On Hold` (ERPNext Sales Order Status) to `on-hold` (WooCommerce Sales Order Status), if you change a Sales Order's status to `On Hold`, it'll automatically attempt to set the WooCommerce Order's status to `On Hold` 103 | 104 | ![Sales Order Status Sync](../images/so-order-status.png) 105 | 106 | 107 | ## Troubleshooting 108 | - You can look at the list of **WooCommerce Orders** from within ERPNext by opening the **WooCommerce Order** doctype. This is a [Virtual DocType](https://frappeframework.com/docs/v15/user/en/basics/doctypes/virtual-doctype) that interacts directly with your WooCommerce site's API interface 109 | - Any errors during this process can be found under **Error Log**. 110 | - You can also check the **Scheduled Job Log** for the `sync_sales_orders.run_sales_orders_sync` Scheduled Job. 111 | - A history of all API calls made to your Wordpress Site can be found under **WooCommerce Request Log** (*Enable WooCommerce Request Logs* needs to be turned on on **WooCommerce Server** > *Logs*) 112 | 113 | -------------------------------------------------------------------------------- /docs/src/features/woocommerce-plugins.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Plugins integration 2 | 3 | ## Advanced Shipment Tracking 4 | 5 | https://woocommerce.com/document/advanced-shipment-tracking-pro/ 6 | 7 | 🏗️ *Documentation in progress* 🏗️ -------------------------------------------------------------------------------- /docs/src/images/add-wc-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/add-wc-server.png -------------------------------------------------------------------------------- /docs/src/images/item-fields-mapping-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/item-fields-mapping-2.png -------------------------------------------------------------------------------- /docs/src/images/item-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/item-link.png -------------------------------------------------------------------------------- /docs/src/images/item-prices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/item-prices.png -------------------------------------------------------------------------------- /docs/src/images/item-stock-levels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/item-stock-levels.png -------------------------------------------------------------------------------- /docs/src/images/items-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/items-tab.png -------------------------------------------------------------------------------- /docs/src/images/new-wc-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/new-wc-server.png -------------------------------------------------------------------------------- /docs/src/images/sales-order-item-fields-mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/sales-order-item-fields-mapping.png -------------------------------------------------------------------------------- /docs/src/images/so-order-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/so-order-status.png -------------------------------------------------------------------------------- /docs/src/images/so-shipping-rule-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/so-shipping-rule-2.png -------------------------------------------------------------------------------- /docs/src/images/so-tab-mandatory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/so-tab-mandatory.png -------------------------------------------------------------------------------- /docs/src/images/wc-api-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/docs/src/images/wc-api-settings.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "woocommerce_fusion" 3 | authors = [ 4 | { name = "Dirk van der Laarse", email = "dirk@laarse.co.za"} 5 | ] 6 | description = "WooCommerce connector for ERPNext v14+" 7 | requires-python = ">=3.9" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [ 11 | "woocommerce~=3.0.0", 12 | "jsonpath-ng~=1.7.0" 13 | ] 14 | 15 | [build-system] 16 | requires = ["flit_core >=3.4,<4"] 17 | build-backend = "flit_core.buildapi" 18 | 19 | [tool.bench.dev-dependencies] 20 | hypothesis = "~=6.31.0" 21 | parameterized = "~=0.9.0 " 22 | 23 | [tool.black] 24 | line-length = 99 25 | 26 | [tool.isort] 27 | line_length = 99 28 | multi_line_output = 3 29 | include_trailing_comma = true 30 | force_grid_wrap = 0 31 | use_parentheses = true 32 | ensure_newline_before_comments = true 33 | indent = "\t" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("requirements.txt") as f: 4 | install_requires = f.read().strip().split("\n") 5 | 6 | # get version from __version__ variable in woocommerce_fusion/__init__.py 7 | from woocommerce_fusion import __version__ as version 8 | 9 | setup( 10 | name="woocommerce_fusion", 11 | version=version, 12 | description="WooCommerce connector for ERPNext v14+", 13 | author="Dirk van der Laarse", 14 | author_email="dirk@finfoot.work", 15 | packages=find_packages(), 16 | zip_safe=False, 17 | include_package_data=True, 18 | install_requires=install_requires, 19 | ) 20 | -------------------------------------------------------------------------------- /woocommerce_fusion/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.14.1" 2 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_1_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.1.0 Release Notes 2 | 3 | 4 | ### WooCommerce 5 | 1. Hooks to update stock levels in woocommerce 6 | 2. Show and allow edit of woocommerce shipment trackings on Sales Orders 7 | 3. Support for woocommerce multi-server order sync 8 | 4. WooCommerce Order virtual doctype 9 | 10 | 11 | --- 12 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_1_2.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.1.2 Release Notes 2 | 3 | 4 | ### WooCommerce 5 | 1. Fix for Order Status not showing correctly for Sales Orders 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.0 Release Notes 2 | 3 | 4 | ### Backend 5 | 1. Test compatibility with frappe v14.45.0 and erpnext to v14.37.1 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_1.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.1 Release Notes 2 | 3 | 4 | ### Stock 5 | 1. Remove multi-warehouse logic (remove support for ATUM Mult-inventory) 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_2.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.2 Release Notes 2 | 3 | 4 | ### Stock 5 | 1. Fix 'update_stock' error when submitting Delivery Note 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_3.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.3 Release Notes 2 | 3 | 4 | ### WooCommerce sync 5 | 1. Handle edge case with delivery address names 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_8.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.8 Release Notes 2 | 3 | 4 | ### WooCommerce sync 5 | 1. Add "Quote Sent" Order status 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_2_9.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.2.9 Release Notes 2 | 3 | 4 | ### WooCommerce sync 5 | 1. Improved error logging 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_3_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.3.0 Release Notes 2 | 3 | ### WooCommerce sync 4 | 1. Synchronise payments from WooCommerce 5 | 1. Only show trackings table for WooCommerce linked orders 6 | 1. Fix incorrect WooCommerce shipped date 7 | 8 | ### Backend 9 | 1. Use InstaWP for Wordpress+WooCommerce integration tests 10 | 1. Improve logging for stock sync errors 11 | 1. Only update the 'status' and 'shipment_trackings' fields 12 | 13 | 14 | --- 15 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_4_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.4.0 Release Notes 2 | 3 | ### WooCommerce sync 4 | 1. When creating Payment Entries, use reference number from external payment gateway as reference 5 | 1. Handle null value in Bank Account mapping (e.g. to not create Payment Entries for EFT payments) 6 | 1. Add a minimum creation date to ignore old WooCommerce orders 7 | 1. Round down stock qty before posting to WooCommerce (WC API does not support decimal values) 8 | 1. Show both ERPNext and WooCommerce status in Sales Order list view 9 | 10 | ### Backend 11 | 1. Split unit and integration tests 12 | 13 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_5_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.5.0 Release Notes 2 | 3 | ### WooCommerce sync 4 | 1. Name WEB Sales Orders with the WooCommerce Order ID instead of a sequential number 5 | 1. In Payment Entries, synchronise the Payment Method from WooCommerce 6 | 1. Add scheduled sync of all stock items 7 | 1. Fix status for orders with advance payments 8 | 9 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_6_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.6.0 Release Notes 2 | 3 | ### WooCommerce sync 4 | 1. Woocommerce Site is now a link field 5 | 1. Fix the retrieval of payment gateway transaction IDs 6 | 1. Fix missing statuses 7 | 1. Get payment entry total from woocommerce order instead of ERPNext Sales Order 8 | 9 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v0/v0_7_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 0.7.0 Release Notes 2 | 3 | ### WooCommerce sync 4 | 1. Improved logging of requests to WooCommerce Server 5 | 1. Fix the editing of Shipment Trackings 6 | 1. Add setting to not submit Sales Orders on creation 7 | 8 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_0_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.0.0 Release Notes 2 | 3 | ### Backend 4 | 1. Compatibility with Frappe and ERPNext v15 5 | 6 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_10_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.10.0 Release Notes 2 | 3 | ### WooCommerce Sales Order Sync 4 | 1. Added the option to set up automatic status sync to a WooCommerce Order 5 | 6 | 7 | ### WooCommerce Items Sync 8 | 1. Fixed an issue where price and stock sync fails for items with no WooCommerce ID 9 | 10 | --- 11 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_11_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.11.0 Release Notes 2 | 3 | ### WooCommerce Sales Order Sync 4 | 1. Add ability to sync the WooCommerce Image to te ERPNext Item 5 | 6 | 7 | ### WooCommerce Items Sync 8 | 1. Syncrhonise the Customer Note on a WooCommerce Order to a custom field "WooCommerce Customer Note" on Sales Order 9 | 10 | --- -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_12_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.12.0 Release Notes 2 | 3 | ### WooCommerce Items Sync 4 | 1. Add support for JSONPath to map any WooCommerce field to an ERPNext Item field 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_13_2.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.13.2 Release Notes 2 | 3 | ### WooCommerce Items Sync 4 | 1. Fixes for custom item field map 5 | 6 | ### Shipping Rules 7 | 2. Revert to manual mapping of shipping methods 8 | 9 | 10 | ### WooCommerce Sales Order Sync 11 | - Fix issue with tax templates that exclude tax in rate 12 | - Move company customer handling to feature flag 13 | - Handle linked but deleted WC orders 14 | 15 | --- 16 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_13_3.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.13.3 Release Notes 2 | 3 | 4 | ### WooCommerce Sales Order Sync 5 | - Fix issue where Shipping charges were duplicated on a Sales Order with a *Shipping Rule* and *Sales Tax Template* set 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_13_4.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.13.3 Release Notes 2 | 3 | 4 | ### WooCommerce Stock Level Sync 5 | - Add setting to take Reserved Stock into account 6 | 7 | --- 8 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_3_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.3.0 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Fix issue that caused payment entries to not be created if a Sales Invoice is created before the order is synchronised again. 5 | 1. The integration will not sync prices of disabled items anymore. 6 | 1. Fix incoorect shipped date in shipment trackings. Also improved Sales Order "Actions" button load time 7 | 1. The integration will not sync stock for disabled items or items with maintain stock set to 0 (e.g. Gift Cards) 8 | 9 | --- 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_3_1.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.3.1 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Fix issue that caused the sync job failure caused by linked Sales Orders that are cancelled in ERPNext but not in WooCommerce 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_4_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.4.0 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Refactor settings from "WooCommerce Integration Settings" to "WooCommerce Server" 5 | 2. Add support for Synchronisation between "Item" and "WooCommerce Product" 6 | 3. Add user documentation 7 | 4. Add support for Item Variants/WooCommerce Variants 8 | 9 | --- 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_4_1.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.4.1 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Move WooCommerce child table on **Item** to its own Tab 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_5_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.5.0 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Add setting to enable/disable Sales Order lines sync 5 | 2. Fixes for Item Synchronisation of variants, handle attributes 6 | 3. Setting to select warehouses for stock levels sync 7 | 4. Handle sync error for items with price set to 0.0 8 | 5. Fix sync issue if item on Sales Order not linked to WooCommerce 9 | 6. Validate server URL when setting up new WooCommerce Server 10 | 11 | --- 12 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_5_3.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.5.3 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Add Standard Permissions 5 | 2. Price sync improvements 6 | 3. Set currency on order 7 | 4. Performance improvements - fewer requests to WooCommerce Servers 8 | 5. Add traceback and duration to request logs 9 | 10 | --- 11 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_6_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.6.0 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Create your own mapping of ERPNext Item fields to WooCommerce Item fields 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_7_3.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.7.3 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Fix: Handle trashed WooCommerce Products 5 | 2. Improve documentation for Payments Synchronisation 6 | 3. Fix endpoint for Sales Order creation, to allow near-realtime Order Synchronisation 7 | 4. Improved hiding of fields on WooCommerce Server 8 | 9 | --- 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_7_4.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.7.4 Release Notes 2 | 3 | ### Sync with WooCommerce 4 | 1. Fix: Handle Guest orders correctly 5 | 1. Guest orders: Resolved the business/individual ordering problem. 6 | 1. Handling of Guest Orders: If no email exists, an order-based guest user is created. 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_7_6.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.7.6 Release Notes 2 | 3 | ### Customer Address Sync with WooCommerce 4 | 1. Create single address if billing and shipping addresses on WooCommerce are the same 5 | 1. Update address if it already exists 6 | 1. New address title naming convention setting 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_8_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.8.0 Release Notes 2 | 3 | ### Various fixes for WooCommerce 4 | 1. Fix issue where some payment providers has HTML in the Payment Method Title, causing a 'Value too big' error 5 | 1. Fix issue where synced Woocommerce item title of variant items do not contain the template name 6 | 1. Fix issue where linked but not synced product will be duplicated for new orders 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /woocommerce_fusion/change_log/v1/v1_9_0.md: -------------------------------------------------------------------------------- 1 | # WooCommerce Fusion Version 1.9.0 Release Notes 2 | 3 | ### WooCommerce Sales Order Sync 4 | 1. Added the option to link a Shipping Rule on a Sales Order based on WooCommerce Shipping Methods 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /woocommerce_fusion/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/config/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | 4 | def get_data(): 5 | return [{"module_name": "WooCommerce Fusion", "type": "module", "label": _("WooCommerce Fusion")}] 6 | -------------------------------------------------------------------------------- /woocommerce_fusion/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/woocommerce_fusion" 6 | # headline = "App that does everything" 7 | # sub_heading = "Yes, you got that right the first time, everything" 8 | 9 | 10 | def get_context(context): 11 | context.brand_html = "WooCommerce Fusion" 12 | -------------------------------------------------------------------------------- /woocommerce_fusion/exceptions.py: -------------------------------------------------------------------------------- 1 | from frappe.exceptions import ValidationError 2 | 3 | 4 | class SyncDisabledError(ValidationError): 5 | pass 6 | 7 | 8 | class WooCommerceOrderNotFoundError(ValidationError): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/hooks.py: -------------------------------------------------------------------------------- 1 | app_name = "woocommerce_fusion" 2 | app_title = "WooCommerce Fusion" 3 | app_publisher = "Dirk van der Laarse" 4 | app_description = "WooCommerce connector for ERPNext v14+" 5 | app_email = "dirk@finfoot.work" 6 | app_license = "GNU GPLv3" 7 | 8 | # Includes in <head> 9 | # ------------------ 10 | 11 | # include js, css files in header of desk.html 12 | # app_include_css = "/assets/woocommerce_fusion/css/woocommerce_fusion.css" 13 | # app_include_js = "/assets/woocommerce_fusion/js/woocommerce_fusion.js" 14 | 15 | # include js, css files in header of web template 16 | # web_include_css = "/assets/woocommerce_fusion/css/woocommerce_fusion.css" 17 | # web_include_js = "/assets/woocommerce_fusion/js/woocommerce_fusion.js" 18 | 19 | # include custom scss in every website theme (without file extension ".scss") 20 | # website_theme_scss = "woocommerce_fusion/public/scss/website" 21 | 22 | # include js, css files in header of web form 23 | # webform_include_js = {"doctype": "public/js/doctype.js"} 24 | # webform_include_css = {"doctype": "public/css/doctype.css"} 25 | 26 | # include js in page 27 | # page_js = {"page" : "public/js/file.js"} 28 | 29 | # include js in doctype views 30 | doctype_js = {"Sales Order": "public/js/selling/sales_order.js", "Item": "public/js/stock/item.js"} 31 | doctype_list_js = {"Sales Order": "public/js/selling/sales_order_list.js"} 32 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 33 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 34 | 35 | # Home Pages 36 | # ---------- 37 | 38 | # application home page (will override Website Settings) 39 | # home_page = "login" 40 | 41 | # website user home page (by Role) 42 | # role_home_page = { 43 | # "Role": "home_page" 44 | # } 45 | 46 | # Generators 47 | # ---------- 48 | 49 | # automatically create page for each record of this doctype 50 | # website_generators = ["Web Page"] 51 | 52 | # Jinja 53 | # ---------- 54 | 55 | # add methods and filters to jinja environment 56 | # jinja = { 57 | # "methods": "woocommerce_fusion.utils.jinja_methods", 58 | # "filters": "woocommerce_fusion.utils.jinja_filters" 59 | # } 60 | 61 | # Installation 62 | # ------------ 63 | 64 | # before_install = "woocommerce_fusion.install.before_install" 65 | # after_install = "woocommerce_fusion.install.after_install" 66 | 67 | # Uninstallation 68 | # ------------ 69 | 70 | # before_uninstall = "woocommerce_fusion.uninstall.before_uninstall" 71 | # after_uninstall = "woocommerce_fusion.uninstall.after_uninstall" 72 | 73 | # Desk Notifications 74 | # ------------------ 75 | # See frappe.core.notifications.get_notification_config 76 | 77 | # notification_config = "woocommerce_fusion.notifications.get_notification_config" 78 | 79 | # Permissions 80 | # ----------- 81 | # Permissions evaluated in scripted ways 82 | 83 | # permission_query_conditions = { 84 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 85 | # } 86 | # 87 | # has_permission = { 88 | # "Event": "frappe.desk.doctype.event.event.has_permission", 89 | # } 90 | 91 | # DocType Class 92 | # --------------- 93 | # Override standard doctype classes 94 | 95 | override_doctype_class = { 96 | "Sales Order": "woocommerce_fusion.overrides.selling.sales_order.CustomSalesOrder", 97 | } 98 | 99 | # Document Events 100 | # --------------- 101 | # Hook on document methods and events 102 | 103 | # doc_events = { 104 | # "*": { 105 | # "on_update": "method", 106 | # "on_cancel": "method", 107 | # "on_trash": "method" 108 | # } 109 | # } 110 | doc_events = { 111 | "Stock Entry": { 112 | "on_submit": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 113 | "on_cancel": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 114 | }, 115 | "Stock Reconciliation": { 116 | "on_submit": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 117 | "on_cancel": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 118 | }, 119 | "Sales Invoice": { 120 | "on_submit": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 121 | "on_cancel": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 122 | }, 123 | "Delivery Note": { 124 | "on_submit": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 125 | "on_cancel": "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_woocommerce_item", 126 | }, 127 | "Item Price": { 128 | "on_update": "woocommerce_fusion.tasks.sync_item_prices.update_item_price_for_woocommerce_item_from_hook" 129 | }, 130 | "Sales Order": { 131 | "on_submit": "woocommerce_fusion.tasks.sync_sales_orders.run_sales_order_sync_from_hook" 132 | }, 133 | "Item": { 134 | "on_update": "woocommerce_fusion.tasks.sync_items.run_item_sync_from_hook", 135 | "after_insert": "woocommerce_fusion.tasks.sync_items.run_item_sync_from_hook", 136 | }, 137 | } 138 | 139 | # Scheduled Tasks 140 | # --------------- 141 | 142 | scheduler_events = { 143 | # "all": [ 144 | # "woocommerce_fusion.tasks.all" 145 | # ], 146 | # "weekly": [ 147 | # "woocommerce_fusion.tasks.daily" 148 | # ], 149 | "hourly_long": [ 150 | "woocommerce_fusion.tasks.sync_sales_orders.sync_woocommerce_orders_modified_since", 151 | "woocommerce_fusion.tasks.sync_items.sync_woocommerce_products_modified_since", 152 | ], 153 | "daily_long": [ 154 | "woocommerce_fusion.tasks.stock_update.update_stock_levels_for_all_enabled_items_in_background", 155 | "woocommerce_fusion.tasks.sync_item_prices.run_item_price_sync_in_background", 156 | ], 157 | # "monthly": [ 158 | # "woocommerce_fusion.tasks.monthly" 159 | # ], 160 | } 161 | 162 | # Testing 163 | # ------- 164 | 165 | before_tests = "woocommerce_fusion.setup.utils.before_tests" 166 | 167 | # Overriding Methods 168 | # ------------------------------ 169 | # 170 | # override_whitelisted_methods = { 171 | # } 172 | # 173 | # each overriding function accepts a `data` argument; 174 | # generated from the base implementation of the doctype dashboard, 175 | # along with any modifications made in other Frappe apps 176 | # override_doctype_dashboards = { 177 | # "Task": "woocommerce_fusion.task.get_dashboard_data" 178 | # } 179 | 180 | # exempt linked doctypes from being automatically cancelled 181 | # 182 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 183 | 184 | # Ignore links to specified DocTypes when deleting documents 185 | # ----------------------------------------------------------- 186 | 187 | ignore_links_on_delete = [ 188 | "WooCommerce Request Log", 189 | ] 190 | 191 | # Request Events 192 | # ---------------- 193 | # before_request = ["woocommerce_fusion.utils.before_request"] 194 | # after_request = ["woocommerce_fusion.utils.after_request"] 195 | 196 | # Job Events 197 | # ---------- 198 | # before_job = ["woocommerce_fusion.utils.before_job"] 199 | # after_job = ["woocommerce_fusion.utils.after_job"] 200 | 201 | # User Data Protection 202 | # -------------------- 203 | 204 | # user_data_fields = [ 205 | # { 206 | # "doctype": "{doctype_1}", 207 | # "filter_by": "{filter_by}", 208 | # "redact_fields": ["{field_1}", "{field_2}"], 209 | # "partial": 1, 210 | # }, 211 | # { 212 | # "doctype": "{doctype_2}", 213 | # "filter_by": "{filter_by}", 214 | # "partial": 1, 215 | # }, 216 | # { 217 | # "doctype": "{doctype_3}", 218 | # "strict": False, 219 | # }, 220 | # { 221 | # "doctype": "{doctype_4}" 222 | # } 223 | # ] 224 | 225 | # Authentication and authorization 226 | # -------------------------------- 227 | 228 | # auth_hooks = [ 229 | # "woocommerce_fusion.auth.validate" 230 | # ] 231 | 232 | 233 | fixtures = [ 234 | { 235 | "dt": "Custom Field", 236 | "filters": [ 237 | [ 238 | "name", 239 | "in", 240 | ( 241 | "Customer-woocommerce_server", 242 | "Customer-woocommerce_identifier", 243 | "Customer-woocommerce_is_guest", 244 | "Sales Order-woocommerce_id", 245 | "Sales Order-woocommerce_server", 246 | "Sales Order-woocommerce_status", 247 | "Sales Order-woocommerce_payment_method", 248 | "Sales Order-woocommerce_shipment_tracking_html", 249 | "Sales Order-woocommerce_payment_entry", 250 | "Sales Order-custom_attempted_woocommerce_auto_payment_entry", 251 | "Sales Order-custom_woocommerce_last_sync_hash", 252 | "Sales Order-custom_woocommerce_customer_note", 253 | "Address-woocommerce_identifier", 254 | "Item-woocommerce_servers", 255 | "Item-custom_woocommerce_tab", 256 | ), 257 | ] 258 | ], 259 | } 260 | ] 261 | 262 | default_log_clearing_doctypes = { 263 | "WooCommerce Request Log": 7, 264 | } 265 | -------------------------------------------------------------------------------- /woocommerce_fusion/modules.txt: -------------------------------------------------------------------------------- 1 | WooCommerce -------------------------------------------------------------------------------- /woocommerce_fusion/overrides/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/overrides/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/overrides/selling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/overrides/selling/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/overrides/selling/sales_order.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import frappe 4 | from erpnext.selling.doctype.sales_order.sales_order import SalesOrder 5 | from frappe import _ 6 | from frappe.model.naming import get_default_naming_series, make_autoname 7 | 8 | from woocommerce_fusion.tasks.sync_sales_orders import run_sales_order_sync 9 | from woocommerce_fusion.woocommerce.woocommerce_api import ( 10 | generate_woocommerce_record_name_from_domain_and_id, 11 | ) 12 | 13 | 14 | class CustomSalesOrder(SalesOrder): 15 | """ 16 | This class extends ERPNext's Sales Order doctype to override the autoname method 17 | This allows us to name the Sales Order conditionally. 18 | 19 | We also add logic to set the WooCommerce Status field on validate. 20 | """ 21 | 22 | def autoname(self): 23 | """ 24 | If this is a WooCommerce-linked order, use the naming series defined in "WooCommerce Server" 25 | or default to WEB[WooCommerce Order ID], e.g. WEB012142. 26 | Else, name it normally. 27 | """ 28 | if self.woocommerce_id and self.woocommerce_server: 29 | wc_server = frappe.get_cached_doc("WooCommerce Server", self.woocommerce_server) 30 | if wc_server.sales_order_series: 31 | self.name = make_autoname(key=wc_server.sales_order_series) 32 | else: 33 | # Get idx of site 34 | wc_servers = frappe.get_all("WooCommerce Server", fields=["name", "creation"]) 35 | sorted_list = sorted(wc_servers, key=lambda server: server.creation) 36 | idx = next( 37 | (index for (index, d) in enumerate(sorted_list) if d["name"] == self.woocommerce_server), None 38 | ) 39 | self.name = "WEB{}-{:06}".format( 40 | idx + 1, int(self.woocommerce_id) 41 | ) # Format with leading zeros to make it 6 digits 42 | else: 43 | naming_series = get_default_naming_series("Sales Order") 44 | self.name = make_autoname(key=naming_series) 45 | 46 | def on_change(self): 47 | """ 48 | This is called when a document's values has been changed (including db_set). 49 | """ 50 | # If Sales Order Status Sync is enabled, update the WooCommerce status of the Sales Order 51 | if self.woocommerce_id and self.woocommerce_server: 52 | wc_server = frappe.get_cached_doc("WooCommerce Server", self.woocommerce_server) 53 | if wc_server.enable_so_status_sync: 54 | mapping = next( 55 | ( 56 | row 57 | for row in wc_server.sales_order_status_map 58 | if row.erpnext_sales_order_status == self.status 59 | ), 60 | None, 61 | ) 62 | if mapping: 63 | if self.woocommerce_status != mapping.woocommerce_sales_order_status: 64 | frappe.db.set_value( 65 | "Sales Order", self.name, "woocommerce_status", mapping.woocommerce_sales_order_status 66 | ) 67 | frappe.enqueue(run_sales_order_sync, queue="long", sales_order_name=self.name) 68 | 69 | 70 | @frappe.whitelist() 71 | def get_woocommerce_order_shipment_trackings(doc): 72 | """ 73 | Fetches shipment tracking details from a WooCommerce order. 74 | """ 75 | doc = frappe._dict(json.loads(doc)) 76 | if doc.woocommerce_server and doc.woocommerce_id: 77 | wc_order = get_woocommerce_order(doc.woocommerce_server, doc.woocommerce_id) 78 | if wc_order.shipment_trackings: 79 | return json.loads(wc_order.shipment_trackings) 80 | 81 | return [] 82 | 83 | 84 | @frappe.whitelist() 85 | def update_woocommerce_order_shipment_trackings(doc, shipment_trackings): 86 | """ 87 | Updates the shipment tracking details of a specific WooCommerce order. 88 | """ 89 | doc = frappe._dict(json.loads(doc)) 90 | if doc.woocommerce_server and doc.woocommerce_id: 91 | wc_order = get_woocommerce_order(doc.woocommerce_server, doc.woocommerce_id) 92 | wc_order.shipment_trackings = shipment_trackings 93 | wc_order.save() 94 | return wc_order.shipment_trackings 95 | 96 | 97 | def get_woocommerce_order(woocommerce_server, woocommerce_id): 98 | """ 99 | Retrieves a specific WooCommerce order based on its site and ID. 100 | """ 101 | # First verify if the WooCommerce site exits, and it sync is enabled 102 | wc_order_name = generate_woocommerce_record_name_from_domain_and_id( 103 | woocommerce_server, woocommerce_id 104 | ) 105 | wc_server = frappe.get_cached_doc("WooCommerce Server", woocommerce_server) 106 | 107 | if not wc_server: 108 | frappe.throw( 109 | _( 110 | "This Sales Order is linked to WooCommerce site '{0}', but this site can not be found in 'WooCommerce Servers'" 111 | ).format(woocommerce_server) 112 | ) 113 | 114 | if not wc_server.enable_sync: 115 | frappe.throw( 116 | _( 117 | "This Sales Order is linked to WooCommerce site '{0}', but Synchronisation for this site is disabled in 'WooCommerce Server'" 118 | ).format(woocommerce_server) 119 | ) 120 | 121 | wc_order = frappe.get_doc({"doctype": "WooCommerce Order", "name": wc_order_name}) 122 | wc_order.load_from_db() 123 | return wc_order 124 | -------------------------------------------------------------------------------- /woocommerce_fusion/overrides/selling/test_sales_order.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date 3 | from unittest.mock import Mock, patch 4 | 5 | import frappe 6 | from frappe.model.naming import get_default_naming_series 7 | from frappe.tests.utils import FrappeTestCase 8 | 9 | from woocommerce_fusion.overrides.selling.sales_order import ( 10 | get_woocommerce_order_shipment_trackings, 11 | update_woocommerce_order_shipment_trackings, 12 | ) 13 | 14 | test_dependencies = ["Company", "Customer", "Warehouse"] 15 | 16 | 17 | @patch("woocommerce_fusion.overrides.selling.sales_order.get_woocommerce_order") 18 | class TestCustomSalesOrder(FrappeTestCase): 19 | @classmethod 20 | def setUpClass(cls): 21 | super().setUpClass() # important to call super() methods when extending TestCase. 22 | 23 | def test_get_woocommerce_order_shipment_trackings(self, mock_get_woocommerce_order): 24 | """ 25 | Test that the get_woocommerce_order_shipment_trackings method works as expected 26 | """ 27 | woocommerce_order = frappe._dict(shipment_trackings=json.dumps([{"foo": "bar"}])) 28 | mock_get_woocommerce_order.return_value = woocommerce_order 29 | 30 | sales_order = frappe._dict( 31 | doctype="Sales Order", woocommerce_server="site1.example.com", woocommerce_id="1" 32 | ) 33 | doc = json.dumps(sales_order) 34 | result = get_woocommerce_order_shipment_trackings(doc) 35 | 36 | self.assertEqual(result, [{"foo": "bar"}]) 37 | 38 | def test_update_woocommerce_order_shipment_trackings(self, mock_get_woocommerce_order): 39 | """ 40 | Test that the update_woocommerce_order_shipment_trackings method works as expected 41 | """ 42 | 43 | class DummyWooCommerceOrder: 44 | def __init__(self, shipment_trackings): 45 | self.shipment_trackings = shipment_trackings 46 | 47 | def save(self): 48 | pass 49 | 50 | woocommerce_order = DummyWooCommerceOrder(shipment_trackings=json.dumps([{"foo": "bar"}])) 51 | mock_get_woocommerce_order.return_value = woocommerce_order 52 | 53 | new_shipment_trackings = [{"foo": "baz"}] 54 | 55 | sales_order = frappe._dict( 56 | doctype="Sales Order", woocommerce_server="site1.example.com", woocommerce_id="1" 57 | ) 58 | doc = json.dumps(sales_order) 59 | update_woocommerce_order_shipment_trackings(doc, new_shipment_trackings) 60 | 61 | updated_woocommerce_order = mock_get_woocommerce_order.return_value 62 | 63 | self.assertEqual(updated_woocommerce_order.shipment_trackings, [{"foo": "baz"}]) 64 | 65 | def test_sales_order_uses_custom_class(self, mock_get_woocommerce_order): 66 | """ 67 | Test that SalesOrder doctype class is overrided by CustomSalesOrder doctype class 68 | """ 69 | so = create_so() 70 | self.assertEqual(so.__class__.__name__, "CustomSalesOrder") 71 | 72 | def test_sales_order_is_named_by_default_if_not_linked_to_woocommerce_order( 73 | self, mock_get_woocommerce_order 74 | ): 75 | """ 76 | Test that the Sales Order gets named with the default naming series if it is not linked to a WooCommerce Order 77 | """ 78 | sales_order = create_so() 79 | naming_series = get_default_naming_series("Sales Order") 80 | self.assertEqual(sales_order.name[:2], naming_series[:2]) 81 | 82 | @patch("woocommerce_fusion.overrides.selling.sales_order.frappe") 83 | def test_sales_order_is_named_to_web_if_linked_to_woocommerce_order( 84 | self, mock_frappe, mock_get_woocommerce_order 85 | ): 86 | """ 87 | Test that the Sales Order gets named with "WEBx-xxxxx if it is linked to a WooCommerce Order 88 | """ 89 | mock_frappe.get_all.return_value = [ 90 | frappe._dict( 91 | { 92 | "creation": "2024-01-01", 93 | "woocommerce_server_url": "https://somesite.co", 94 | "name": "somesite.co", 95 | } 96 | ) 97 | ] 98 | mock_frappe.get_cached_doc.return_value = frappe._dict({"sales_order_series": ""}) 99 | 100 | sales_order = create_so(woocommerce_id="123", woocommerce_server_url="https://somesite.co") 101 | 102 | # Expect WEB[x]-[yyyyyy] where x = 1 because it's the first item servers list, and yyy = 000123 because the woocommerce id = 123 103 | self.assertEqual(sales_order.name, "WEB1-000123") 104 | 105 | 106 | def create_so(woocommerce_id: str = None, woocommerce_server_url: str = None): 107 | so = frappe.new_doc("Sales Order") 108 | 109 | if woocommerce_server_url: 110 | wc_server = frappe.get_doc( 111 | { 112 | "doctype": "WooCommerce Server", 113 | "woocommerce_server_url": woocommerce_server_url, 114 | } 115 | ) 116 | if not wc_server: 117 | wc_server = frappe.new_doc("WooCommerce Server") 118 | wc_server.woocommerce_server_url = woocommerce_server_url 119 | wc_server.flags.ignore_mandatory = True 120 | wc_server.save() 121 | so.woocommerce_server = wc_server.name 122 | 123 | so.customer = "_Test Customer" 124 | so.company = "_Test Company" 125 | so.transaction_date = date.today() 126 | so.woocommerce_id = woocommerce_id 127 | 128 | so.set_warehouse = "Finished Goods - _TC" 129 | so.append( 130 | "items", 131 | {"item_code": "_Test Item", "delivery_date": date.today(), "qty": 10, "rate": 80}, 132 | ) 133 | so.insert() 134 | so.save() 135 | return so 136 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches.txt: -------------------------------------------------------------------------------- 1 | woocommerce_fusion.patches.v0.update_woocommerce_email_ids 2 | woocommerce_fusion.patches.v0.update_sales_order_woocommerce_payment_method_field #2023-11-08 3 | woocommerce_fusion.patches.v0.change_woocommerce_site_to_link_field 4 | woocommerce_fusion.patches.v0.update_log_settings 5 | woocommerce_fusion.patches.v1.migrate_woocommerce_settings 6 | woocommerce_fusion.patches.v1.migrate_woocommerce_settings_v1_4 7 | woocommerce_fusion.patches.v1.update_woocommerce_identifiers 8 | woocommerce_fusion.patches.v1.update_woocommerce_server_item_map -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v0/change_woocommerce_site_to_link_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import frappe 4 | from frappe.utils.fixtures import sync_fixtures 5 | 6 | 7 | @frappe.whitelist() 8 | def execute(): 9 | """ 10 | Updates the woocommerce_server field on all relevant doctypes 11 | """ 12 | # Sync fixtures to ensure that the custom fields `woocommerce_server` exist 13 | frappe.reload_doc("woocommerce", "doctype", "WooCommerce Server") 14 | frappe.reload_doc("woocommerce", "doctype", "WooCommerce Additional Settings") 15 | frappe.reload_doc("woocommerce", "doctype", "Item WooCommerce Server") 16 | sync_fixtures("woocommerce_fusion") 17 | 18 | # Update WooCommerce Additional Settings 19 | woocommerce_additional_settings = frappe.get_single("WooCommerce Additional Settings") 20 | print("Updating WooCommerce Additional Settings...") 21 | for wc_server in woocommerce_additional_settings.servers: 22 | print(f"Updating {wc_server.woocommerce_server_url}") 23 | woocommerce_server = frappe.new_doc("WooCommerce Server") 24 | woocommerce_server.woocommerce_server_url = wc_server.woocommerce_server_url 25 | woocommerce_server.save() 26 | wc_server.woocommerce_server = woocommerce_server.name 27 | woocommerce_additional_settings.save() 28 | 29 | # Update Customers 30 | # simple sql query, run once in a patch, no need for using frappe.qb 31 | # nosemgrep 32 | frappe.db.sql("""UPDATE `tabCustomer` SET woocommerce_server=woocommerce_site""") 33 | 34 | # Update Sales Orders 35 | # simple sql query, run once in a patch, no need for using frappe.qb 36 | # nosemgrep 37 | frappe.db.sql("""UPDATE `tabSales Order` SET woocommerce_server=woocommerce_site""") 38 | 39 | # Update Addresses 40 | # simple sql query, run once in a patch, no need for using frappe.qb 41 | # nosemgrep 42 | frappe.db.sql("""UPDATE `tabAddress` SET woocommerce_server=woocommerce_site""") 43 | 44 | # Update Items 45 | # simple sql query, run once in a patch, no need for using frappe.qb 46 | # nosemgrep 47 | frappe.db.sql("""UPDATE `tabItem WooCommerce Server` SET woocommerce_server=woocommerce_site""") 48 | 49 | frappe.db.commit() 50 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v0/update_log_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import frappe 4 | from frappe import _ 5 | from frappe.core.doctype.log_settings.log_settings import _supports_log_clearing 6 | from frappe.utils.data import cint 7 | 8 | 9 | def execute(): 10 | """ 11 | Updates Log Settings to add our custom Log doctype 12 | """ 13 | # Sync new doctype 14 | frappe.reload_doc("woocommerce", "doctype", "WooCommerce Request Log") 15 | 16 | WOOCOMMERCE_LOGTYPES_RETENTION = { 17 | "WooCommerce Request Log": 30, 18 | } 19 | 20 | log_settings = frappe.get_single("Log Settings") 21 | existing_logtypes = {d.ref_doctype for d in log_settings.logs_to_clear} 22 | added_logtypes = set() 23 | for logtype, retention in WOOCOMMERCE_LOGTYPES_RETENTION.items(): 24 | if logtype not in existing_logtypes and _supports_log_clearing(logtype): 25 | if not frappe.db.exists("DocType", logtype): 26 | continue 27 | 28 | log_settings.append("logs_to_clear", {"ref_doctype": logtype, "days": cint(retention)}) 29 | added_logtypes.add(logtype) 30 | log_settings.save() 31 | frappe.db.commit() 32 | 33 | if added_logtypes: 34 | print(_("Added default log doctypes: {}").format(",".join(added_logtypes))) 35 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v0/update_sales_order_woocommerce_payment_method_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe.utils.fixtures import sync_fixtures 7 | 8 | from woocommerce_fusion.woocommerce.woocommerce_api import ( 9 | generate_woocommerce_record_name_from_domain_and_id, 10 | ) 11 | 12 | 13 | @frappe.whitelist() 14 | def execute(): 15 | """ 16 | Updates the woocommerce_payment_method field on all sales orders where the field is blank 17 | """ 18 | # Sync fixtures to ensure that the custom field `woocommerce_payment_method` exists 19 | sync_fixtures("woocommerce_fusion") 20 | 21 | # Get the Sales Orders 22 | sales_orders = frappe.db.get_all( 23 | "Sales Order", 24 | fields=["name", "woocommerce_server", "woocommerce_id", "woocommerce_payment_method"], 25 | order_by="name", 26 | ) 27 | 28 | s = 0 29 | for so in sales_orders: 30 | if so.woocommerce_server and so.woocommerce_id and not so.woocommerce_payment_method: 31 | try: 32 | # Get the Sales Order doc 33 | sales_order = frappe.get_doc("Sales Order", so.name) 34 | 35 | # Get the WooCommerce Order doc 36 | wc_order = frappe.get_doc( 37 | { 38 | "doctype": "WooCommerce Order", 39 | "name": generate_woocommerce_record_name_from_domain_and_id( 40 | so.woocommerce_server, so.woocommerce_id 41 | ), 42 | } 43 | ) 44 | wc_order.load_from_db() 45 | 46 | # Set the payment_method_title field 47 | sales_order.meta.get_field("woocommerce_payment_method").allow_on_submit = 1 48 | sales_order.woocommerce_payment_method = wc_order.payment_method_title 49 | print(f"Updating {so.name}") 50 | sales_order.save() 51 | sales_order.meta.get_field("woocommerce_payment_method").allow_on_submit = 0 52 | 53 | except Exception as err: 54 | frappe.log_error( 55 | f"v0 WooCommerce Sales Orders Patch: Sales Order {so.name}", 56 | "".join(traceback.format_exception(err)), 57 | ) 58 | 59 | # Commit every 10 changes to avoid "Too many writes in one request. Please send smaller requests" error 60 | if s > 10: 61 | frappe.db.commit() 62 | s = 0 63 | 64 | frappe.db.commit() 65 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v0/update_woocommerce_email_ids.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe.contacts.doctype.contact.contact import get_contact_details, get_contacts_linking_to 7 | 8 | 9 | @frappe.whitelist() 10 | def execute(): 11 | """ 12 | Updates the woocommerce_email field on all customers with the ID from the linked contact 13 | """ 14 | 15 | customers = frappe.db.get_all( 16 | "Customer", 17 | fields=["name"], 18 | order_by="name", 19 | ) 20 | 21 | s = 0 22 | for customer in customers: 23 | try: 24 | contacts = get_contacts_linking_to("Customer", customer.name) 25 | 26 | woocommerce_email = None 27 | for contact in contacts: 28 | details = get_contact_details(contact) 29 | if details: 30 | woocommerce_email = details["contact_email"] 31 | if woocommerce_email: 32 | break 33 | 34 | if woocommerce_email: 35 | frappe.db.set_single_value("Customer", customer.name, "woocommerce_email", woocommerce_email) 36 | print(f"Setting {customer.name}'s woocommerce_email to {woocommerce_email}") 37 | s += 1 38 | 39 | except Exception as err: 40 | frappe.log_error("v0 WooCommerce Contacts Patch", traceback.format_exception(err)) 41 | 42 | # Commit every 10 changes to avoid "Too many writes in one request. Please send smaller requests" error 43 | if s > 10: 44 | frappe.db.commit() 45 | s = 0 46 | 47 | frappe.db.commit() 48 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v1/migrate_woocommerce_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe import _ 7 | from frappe.utils.fixtures import sync_fixtures 8 | 9 | 10 | def execute(): 11 | """ 12 | Try to get settings from deprecated "Woocommerce Settings" (erpnext) and "WooCommerce Additional Settings" (woocommerce_fusion) doctypes 13 | """ 14 | try: 15 | # Sync fixtures to ensure that the custom fields `woocommerce_server` exist 16 | frappe.reload_doc("woocommerce", "doctype", "WooCommerce Integration Settings") 17 | sync_fixtures("woocommerce_fusion") 18 | 19 | # Old settings doctypes 20 | woocommerce_settings = frappe.get_single("Woocommerce Settings") 21 | woocommerce_additional_settings = frappe.get_single("WooCommerce Additional Settings") 22 | 23 | # New settings doctypes 24 | woocommerce_integration_settings = frappe.get_single("WooCommerce Integration Settings") 25 | 26 | new_field_names = [f.fieldname for f in woocommerce_integration_settings.meta.fields] 27 | 28 | # Copy fields from "Woocommerce Settings" with the same fieldname 29 | for field in woocommerce_settings.meta.fields: 30 | if field.fieldname in new_field_names and field.fieldtype not in ( 31 | "Column Break", 32 | "Section Break", 33 | "HTML", 34 | "Table", 35 | ): 36 | setattr( 37 | woocommerce_integration_settings, 38 | field.fieldname, 39 | getattr(woocommerce_settings, field.fieldname), 40 | ) 41 | print(_("Copying WooCommerce Settings: {}").format(field.fieldname)) 42 | 43 | # Copy fields from "WooCommerce Additional Settings" with the same fieldname 44 | for field in woocommerce_additional_settings.meta.fields: 45 | if field.fieldname in new_field_names and field.fieldtype not in ( 46 | "Column Break", 47 | "Section Break", 48 | "HTML", 49 | "Table", 50 | ): 51 | setattr( 52 | woocommerce_integration_settings, 53 | field.fieldname, 54 | getattr(woocommerce_additional_settings, field.fieldname), 55 | ) 56 | print(_("Copying WooCommerce Settings: {}").format(field.fieldname)) 57 | 58 | # Copy Child Table records 59 | for row in woocommerce_additional_settings.servers: 60 | woocommerce_integration_settings.append( 61 | "servers", 62 | { 63 | "enable_sync": row.enable_sync, 64 | "wc_plugin_advanced_shipment_tracking": row.wc_plugin_advanced_shipment_tracking, 65 | "woocommerce_server": row.woocommerce_server, 66 | "woocommerce_server_url": row.woocommerce_server_url, 67 | "secret": row.secret, 68 | "api_consumer_key": row.api_consumer_key, 69 | "api_consumer_secret": row.api_consumer_secret, 70 | "wc_ast_shipment_providers": row.wc_ast_shipment_providers, 71 | "enable_payments_sync": row.enable_payments_sync, 72 | "payment_method_bank_account_mapping": row.payment_method_bank_account_mapping, 73 | "payment_method_gl_account_mapping": row.payment_method_gl_account_mapping, 74 | }, 75 | ) 76 | 77 | woocommerce_integration_settings.save() 78 | except Exception as err: 79 | print(_("Failed to get settings from deprecated 'Woocommerce Settings' doctypes")) 80 | print(traceback.format_exception(err)) 81 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v1/migrate_woocommerce_settings_v1_4.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe import _ 7 | 8 | 9 | def execute(): 10 | """ 11 | Try to get settings from deprecated "WooCommerce Integration Settings" to "WooCommerce Server" doctypes 12 | """ 13 | try: 14 | # Sync fixtures to ensure that the custom fields `woocommerce_server` exist 15 | frappe.reload_doc("woocommerce", "doctype", "WooCommerce Server") 16 | 17 | # Old settings doctypes 18 | woocommerce_integration_settings = frappe.get_single("WooCommerce Integration Settings") 19 | 20 | # New settings doctypes 21 | for wc_server in woocommerce_integration_settings.servers: 22 | woocommerce_server_doc = frappe.get_doc("WooCommerce Server", wc_server.woocommerce_server) 23 | new_field_names = [f.fieldname for f in woocommerce_server_doc.meta.fields] 24 | 25 | # Copy fields from "WooCommerce Integration Settings" with the same fieldname 26 | for field in woocommerce_integration_settings.meta.fields: 27 | if field.fieldname in new_field_names and field.fieldtype not in ( 28 | "Column Break", 29 | "Section Break", 30 | "HTML", 31 | "Table", 32 | "Button", 33 | ): 34 | setattr( 35 | woocommerce_server_doc, 36 | field.fieldname, 37 | getattr(woocommerce_integration_settings, field.fieldname), 38 | ) 39 | print(_("Copying WooCommerce Settings: {}").format(field.fieldname)) 40 | 41 | # Copy fields from "WooCommerce Integration Settings Servers" with the same fieldname 42 | for field in wc_server.meta.fields: 43 | if field.fieldname in new_field_names and field.fieldtype not in ( 44 | "Column Break", 45 | "Section Break", 46 | "HTML", 47 | "Table", 48 | "Button", 49 | ): 50 | setattr( 51 | woocommerce_server_doc, 52 | field.fieldname, 53 | getattr(wc_server, field.fieldname), 54 | ) 55 | print(_("Copying WooCommerce Settings: {}").format(field.fieldname)) 56 | 57 | woocommerce_server_doc.save() 58 | 59 | except Exception as err: 60 | print(_("Failed to get settings from deprecated 'Woocommerce Settings' doctypes")) 61 | print(traceback.format_exception(err)) 62 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v1/remove_old_settings_doctypes.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import frappe 4 | 5 | 6 | def execute(): 7 | """ 8 | Try to get settings from deprecated "WooCommerce Integration Settings" to "WooCommerce Server" doctypes 9 | """ 10 | frappe.delete_doc("DocType", "WooCommerce Additional Settings Servers", ignore_missing=True) 11 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v1/update_woocommerce_identifiers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe.utils.fixtures import sync_fixtures 7 | 8 | 9 | @frappe.whitelist() 10 | def execute(): 11 | """ 12 | Updates the woocommerce_identifier field on all customers 13 | """ 14 | 15 | sync_fixtures("woocommerce_fusion") 16 | 17 | customers = frappe.db.get_all( 18 | "Customer", 19 | filters=[["Customer", "woocommerce_email", "is", "set"]], 20 | fields=["name", "woocommerce_email"], 21 | order_by="name", 22 | ) 23 | 24 | s = 0 25 | for customer in customers: 26 | print(f"Setting {customer.name}'s woocommerce_identifier to {customer.woocommerce_email}") 27 | try: 28 | frappe.db.set_value( 29 | "Customer", customer.name, "woocommerce_identifier", customer.woocommerce_email 30 | ) 31 | s += 1 32 | 33 | except Exception as err: 34 | frappe.log_error("v1 WooCommerce Unique Identifier patch", traceback.format_exception(err)) 35 | 36 | # Commit every 10 changes to avoid "Too many writes in one request. Please send smaller requests" error 37 | if s > 10: 38 | frappe.db.commit() 39 | s = 0 40 | 41 | frappe.db.commit() 42 | 43 | # Delete unused custom fields 44 | custom_field_names = [ 45 | "Customer-woocommerce_email", 46 | "Address-woocommerce_server", 47 | "Address-woocommerce_email", 48 | ] 49 | for field_name in custom_field_names: 50 | if frappe.db.exists("Custom Field", field_name): 51 | frappe.db.delete("Custom Field", field_name) 52 | frappe.db.commit() 53 | sync_fixtures("woocommerce_fusion") 54 | 55 | 56 | if __name__ == "__main__": 57 | execute() 58 | -------------------------------------------------------------------------------- /woocommerce_fusion/patches/v1/update_woocommerce_server_item_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import traceback 4 | 5 | import frappe 6 | from frappe import _ 7 | 8 | 9 | def execute(): 10 | """ 11 | Update the Item Map on WooCommerce Server. The woocommerce_field_name now represents a JSONPath expression 12 | in stead of a fieldname. This patch will update the Item Map to reflect the new JSONPath expression. 13 | """ 14 | try: 15 | wc_server_items = frappe.get_all( 16 | "WooCommerce Server Item Field", fields=["name", "woocommerce_field_name"] 17 | ) 18 | for wc_server_item in wc_server_items: 19 | frappe.db.set_value( 20 | "WooCommerce Server Item Field", 21 | wc_server_item.name, 22 | "woocommerce_field_name", 23 | "$." + wc_server_item.woocommerce_field_name, 24 | ) 25 | 26 | except Exception as err: 27 | print(_("Failed to migrate WooCommerce Server Item Map fields to JSONPath")) 28 | print(traceback.format_exception(err)) 29 | -------------------------------------------------------------------------------- /woocommerce_fusion/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/public/.gitkeep -------------------------------------------------------------------------------- /woocommerce_fusion/public/js/selling/sales_order.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Sales Order', { 2 | refresh: function(frm) { 3 | // Add a custom button to navigate to WooCommerce and open this order 4 | if (frm.doc.woocommerce_id){ 5 | frm.add_custom_button(__("Open in WooCommerce"), function () { 6 | frappe.db.get_value('WooCommerce Server', frm.doc.woocommerce_server, 'woocommerce_server_url', (values) => { 7 | window.open(values.woocommerce_server_url + `/wp-admin/post.php?post=${frm.doc.woocommerce_id}&action=edit`, "_blank"); 8 | }); 9 | }, __('Actions')); 10 | } 11 | 12 | // Add a custom button to sync Sales Orders with WooCommerce 13 | if (frm.doc.woocommerce_id){ 14 | frm.add_custom_button(__("Sync this Order with WooCommerce"), function () { 15 | frm.trigger("sync_sales_order"); 16 | }, __('Actions')); 17 | } 18 | 19 | // Add a custom button to allow adding or editing Shipment Trackings 20 | if (frm.doc.woocommerce_id){ 21 | frm.add_custom_button(__("Edit WooCommerce Shipment Trackings"), function () { 22 | frm.trigger("prompt_user_for_shipment_trackings"); 23 | }, __('Actions')); 24 | } 25 | 26 | if (frm.doc.woocommerce_id && frm.doc.woocommerce_server && ["Shipped", "Delivered"].includes(frm.doc.woocommerce_status)){ 27 | frm.trigger("load_shipment_trackings_table"); 28 | } 29 | else { 30 | // Clean up Shipment Tracking HTML 31 | frm.doc.woocommerce_shipment_trackings = []; 32 | frm.set_df_property('woocommerce_shipment_tracking_html', 'options', " "); 33 | } 34 | }, 35 | 36 | sync_sales_order: function(frm) { 37 | // Sync this Sales Order 38 | frappe.dom.freeze(__("Sync Order with WooCommerce...")); 39 | frappe.call({ 40 | method: "woocommerce_fusion.tasks.sync_sales_orders.run_sales_order_sync", 41 | args: { 42 | sales_order_name: frm.doc.name 43 | }, 44 | callback: function(r) { 45 | frappe.dom.unfreeze(); 46 | frappe.show_alert({ 47 | message:__('Sync completed successfully'), 48 | indicator:'green' 49 | }, 5); 50 | frm.reload_doc(); 51 | }, 52 | error: (r) => { 53 | frappe.dom.unfreeze(); 54 | frappe.show_alert({ 55 | message: __('There was an error processing the request. See Error Log.'), 56 | indicator: 'red' 57 | }, 5); 58 | } 59 | }); 60 | }, 61 | 62 | woocommerce_status: function(frm) { 63 | // Triggered when woocommerce_status is changed 64 | frappe.confirm( 65 | 'Changing the status will update the order status on WooCommerce. Do you want to continue?', 66 | // If Yes is clicked 67 | function(){ 68 | frm.save( 69 | 'Update', 70 | function(){ 71 | console.log("frm.doc", frm.doc); 72 | // The callback on frm.save() is always called, even if there are errors in saving 73 | // so first check if the form is unsaved 74 | if (!frm.doc.__unsaved){ 75 | frappe.dom.freeze(__("Updating Order status on WooCommerce...")); 76 | frappe.call({ 77 | method: 'woocommerce_fusion.tasks.sync_sales_orders.run_sales_order_sync', 78 | args: { 79 | sales_order_name: frm.doc.name 80 | }, 81 | // disable the button until the request is completed 82 | btn: $('.primary-action'), 83 | callback: (r) => { 84 | frappe.dom.unfreeze(); 85 | frappe.show_alert({ 86 | message:__('Updated WooCommerce Order successfully'), 87 | indicator:'green' 88 | }, 5); 89 | }, 90 | error: (r) => { 91 | frappe.dom.unfreeze(); 92 | frappe.show_alert({ 93 | message: __('There was an error processing the request. See Error Log.'), 94 | indicator: 'red' 95 | }, 5); 96 | console.error(r); // Log the error for debugging 97 | } 98 | }) 99 | } 100 | }, 101 | on_error=function(error){ 102 | // If the .save() fails 103 | console.error(error.exception); // Log the error for debugging 104 | frm.reload_doc(); 105 | } 106 | ) 107 | }, 108 | // If No is clicked 109 | function(){ 110 | frm.reload_doc(); 111 | } 112 | ); 113 | }, 114 | 115 | load_shipment_trackings_table: function(frm) { 116 | // Add a table with Shipment Trackings 117 | frm.set_df_property('woocommerce_shipment_tracking_html', 'options', '🚚 <i>Loading Shipments...</i><br><br><br><br>'); 118 | frm.refresh_field('woocommerce_shipment_tracking_html'); 119 | frappe.call({ 120 | method: "woocommerce_fusion.overrides.selling.sales_order.get_woocommerce_order_shipment_trackings", 121 | args: { 122 | doc: frm.doc 123 | }, 124 | callback: function(r) { 125 | if (r.message) { 126 | frappe.show_alert({ 127 | indicator: "green", 128 | message: __("Retrieved WooCommerce Shipment Trackings"), 129 | }); 130 | frm.doc.woocommerce_shipment_trackings = r.message; 131 | 132 | let trackingsHTML = `<b>WooCommerce Shipments:</b><br><table class="table table-striped">`+ 133 | `<tr><th>Date Shipped</th><th>Provider</th><th>Tracking Number</th>`; 134 | frm.doc.woocommerce_shipment_trackings.forEach(tracking => { 135 | trackingsHTML += `<tr><td>${tracking.date_shipped}</td>`+ 136 | `<td>${tracking.tracking_provider}</td>`+ 137 | `<td><a href="${tracking.tracking_link}">${tracking.tracking_number}</a></td></tr>` 138 | }); 139 | trackingsHTML += `</table>` 140 | frm.set_df_property('woocommerce_shipment_tracking_html', 'options', trackingsHTML); 141 | frm.refresh_field('woocommerce_shipment_tracking_html'); 142 | } 143 | else { 144 | frm.set_df_property('woocommerce_shipment_tracking_html', 'options', ''); 145 | frm.refresh_field('woocommerce_shipment_tracking_html'); 146 | } 147 | } 148 | }); 149 | }, 150 | 151 | prompt_user_for_shipment_trackings: function(frm){ 152 | //Get the shipment providers from 'WooCommerce Server' 153 | frappe.call({ 154 | method: 155 | "woocommerce_fusion.woocommerce.doctype.woocommerce_server"+ 156 | ".woocommerce_server.get_woocommerce_shipment_providers", 157 | args: { 158 | woocommerce_server: frm.doc.woocommerce_server 159 | }, 160 | callback: function(r) { 161 | const trackingProviders = r.message; 162 | let shipment_trackings = frm.doc.woocommerce_shipment_trackings 163 | 164 | 165 | //Prompt the use to update the Tracking Details 166 | let d = new frappe.ui.Dialog({ 167 | title: __('Enter Shipment Tracking details'), 168 | fields: [ 169 | { 170 | 'fieldname': 'tracking_id', 171 | 'fieldtype': 'Data', 172 | 'label': 'Tracking ID', 173 | 'read_only': 1, 174 | 'default': shipment_trackings.length > 0 ? shipment_trackings[0].tracking_id : null 175 | }, 176 | { 177 | 'fieldname': 'tracking_provider', 178 | 'fieldtype': 'Select', 179 | 'label': 'Tracking Provider', 180 | 'reqd': 1, 181 | 'options': trackingProviders, 182 | 'default': shipment_trackings.length > 0 ? shipment_trackings[0].tracking_provider : null 183 | }, 184 | { 185 | 'fieldname': 'tracking_number', 186 | 'fieldtype': 'Data', 187 | 'label': 'Tracking Number', 188 | 'reqd': 1, 189 | 'default': shipment_trackings.length > 0 ? shipment_trackings[0].tracking_number : null 190 | }, 191 | { 192 | 'fieldname': 'tracking_link', 193 | 'fieldtype': 'Data', 194 | 'label': 'Tracking Link', 195 | 'read_only': 1, 196 | 'default': shipment_trackings.length > 0 ? shipment_trackings[0].tracking_link : null 197 | }, 198 | { 199 | 'fieldname': 'date_shipped', 200 | 'fieldtype': 'Date', 201 | 'label': 'Date Shipped', 202 | 'reqd': 1, 203 | 'default': shipment_trackings.length > 0 ? shipment_trackings[0].date_shipped : null 204 | }, 205 | ], 206 | primary_action: function(){ 207 | let values = d.get_values(); 208 | let shipment_tracking = { 209 | "tracking_id": null, 210 | "tracking_provider": values.tracking_provider, 211 | "tracking_link": null, 212 | "tracking_number": values.tracking_number, 213 | "date_shipped": values.date_shipped 214 | }; 215 | d.hide(); 216 | 217 | // Call a method to update the shipment tracking 218 | frm.doc.woocommerce_shipment_trackings = [shipment_tracking] 219 | frm.trigger("update_shipment_trackings"); 220 | }, 221 | primary_action_label: __('Submit and Sync to WooCommerce') 222 | }); 223 | d.show(); 224 | } 225 | }) 226 | }, 227 | 228 | update_shipment_trackings: function(frm){ 229 | //Call method to update the Shipment Trackings 230 | frappe.call({ 231 | method: 232 | "woocommerce_fusion.overrides.selling.sales_order.update_woocommerce_order_shipment_trackings", 233 | args: { 234 | doc: frm.doc, 235 | shipment_trackings: frm.doc.woocommerce_shipment_trackings 236 | }, 237 | callback: function(r) { 238 | frm.reload_doc(); 239 | } 240 | }) 241 | 242 | } 243 | 244 | }); -------------------------------------------------------------------------------- /woocommerce_fusion/public/js/selling/sales_order_list.js: -------------------------------------------------------------------------------- 1 | // Override ERPNext List View Settings for Sales Order 2 | // See erpnext/selling/doctype/sales_order/sales_order_list.js 3 | frappe.listview_settings['Sales Order'] = { 4 | add_fields: ["woocommerce_status", "base_grand_total", "customer_name", "currency", "delivery_date", 5 | "per_delivered", "per_billed", "status", "order_type", "name", "skip_delivery_note"], 6 | get_indicator: function (doc) { 7 | if (doc.status === "Closed") { 8 | // Closed 9 | return [__("Closed"), "green", "status,=,Closed"]; 10 | } else if (doc.status === "On Hold") { 11 | // on hold 12 | return [__("On Hold"), "orange", "status,=,On Hold"]; 13 | } else if (doc.status === "Completed") { 14 | return [__("Completed"), "green", "status,=,Completed"]; 15 | } else if (!doc.skip_delivery_note && flt(doc.per_delivered, 6) < 100) { 16 | /////////////////////////////////////////////////////////////////////////////////////// 17 | /////////////////////////////// Custom code starts here /////////////////////////////// 18 | /////////////////////////////////////////////////////////////////////////////////////// 19 | if (doc.advance_paid >= doc.grand_total) { 20 | // not delivered & not billed 21 | return [__("Paid in Advance"), "grey", 22 | "advance_paid,>=,grand_total"]; 23 | } else 24 | /////////////////////////////////////////////////////////////////////////////////////// 25 | //////////////////////////////// Custom code ends here //////////////////////////////// 26 | /////////////////////////////////////////////////////////////////////////////////////// 27 | if (frappe.datetime.get_diff(doc.delivery_date) < 0) { 28 | // not delivered & overdue 29 | return [__("Overdue"), "pink", 30 | "per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"]; 31 | } else if (flt(doc.grand_total) === 0) { 32 | // not delivered (zeroount order) 33 | return [__("To Deliver"), "orange", 34 | "per_delivered,<,100|grand_total,=,0|status,!=,Closed"]; 35 | } else if (flt(doc.per_billed, 6) < 100) { 36 | // not delivered & not billed 37 | return [__("To Deliver and Bill"), "orange", 38 | "per_delivered,<,100|per_billed,<,100|status,!=,Closed"]; 39 | } else { 40 | // not billed 41 | return [__("To Deliver"), "orange", 42 | "per_delivered,<,100|per_billed,=,100|status,!=,Closed"]; 43 | } 44 | } else if ((flt(doc.per_delivered, 6) === 100) && flt(doc.grand_total) !== 0 45 | && flt(doc.per_billed, 6) < 100) { 46 | // to bill 47 | return [__("To Bill"), "orange", 48 | "per_delivered,=,100|per_billed,<,100|status,!=,Closed"]; 49 | } else if (doc.skip_delivery_note && flt(doc.per_billed, 6) < 100){ 50 | return [__("To Bill"), "orange", "per_billed,<,100|status,!=,Closed"]; 51 | } 52 | } 53 | /////////////////////////////////////////////////////////////////////////////////////// 54 | /////////////////////////////// Custom code starts here /////////////////////////////// 55 | /////////////////////////////////////////////////////////////////////////////////////// 56 | ,formatters: { 57 | woocommerce_status(val) { 58 | // Format the WooCommerce status field 59 | const statusToColorMap = { 60 | 'Pending Payment': 'orange', 61 | 'On hold': 'grey', 62 | 'Failed': 'yellow', 63 | 'Cancelled': 'red', 64 | 'Processing': 'pink', 65 | 'Refunded': 'grey', 66 | 'Shipped': 'light-blue', 67 | 'Ready for Pickup': 'yellow', 68 | 'Picked up': 'light-green', 69 | 'Delivered': 'green', 70 | 'Processing LP': 'purple', 71 | 'Draft': 'grey', 72 | 'Quote Sent': 'grey', 73 | 'Trash': 'red', 74 | 'Partially Shipped': 'light-blue', 75 | } 76 | const color = statusToColorMap[val] || "" 77 | return ` 78 | <span class="indicator-pill ${color} filterable ellipsis" title="${val} on WooCommerce"> 79 | <span class="ellipsis"><small> ${val}</small></span> 80 | </span>` 81 | } 82 | /////////////////////////////////////////////////////////////////////////////////////// 83 | //////////////////////////////// Custom code ends here //////////////////////////////// 84 | /////////////////////////////////////////////////////////////////////////////////////// 85 | }, 86 | onload: function(listview) { 87 | var method = "erpnext.selling.doctype.sales_order.sales_order.close_or_unclose_sales_orders"; 88 | 89 | listview.page.add_menu_item(__("Close"), function() { 90 | listview.call_for_selected_items(method, {"status": "Closed"}); 91 | }); 92 | 93 | listview.page.add_menu_item(__("Re-open"), function() { 94 | listview.call_for_selected_items(method, {"status": "Submitted"}); 95 | }); 96 | 97 | listview.page.add_action_item(__("Sales Invoice"), ()=>{ 98 | erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice"); 99 | }); 100 | 101 | listview.page.add_action_item(__("Delivery Note"), ()=>{ 102 | erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note"); 103 | }); 104 | 105 | listview.page.add_action_item(__("Advance Payment"), ()=>{ 106 | erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Payment Entry"); 107 | }); 108 | 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /woocommerce_fusion/public/js/stock/item.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Item', { 2 | refresh: function(frm) { 3 | // Add a custom button to sync Item Stock with WooCommerce 4 | frm.add_custom_button(__("Sync this Item's Stock Levels to WooCommerce"), function () { 5 | frm.trigger("sync_item_stock"); 6 | }, __('Actions')); 7 | 8 | // Add a custom button to sync Item Price with WooCommerce 9 | frm.add_custom_button(__("Sync this Item's Price to WooCommerce"), function () { 10 | frm.trigger("sync_item_price"); 11 | }, __('Actions')); 12 | 13 | // Add a custom button to sync Item with WooCommerce 14 | frm.add_custom_button(__("Sync this Item with WooCommerce"), function () { 15 | frm.trigger("sync_item"); 16 | }, __('Actions')); 17 | }, 18 | 19 | sync_item_stock: function(frm) { 20 | // Sync this Item 21 | frappe.dom.freeze(__("Sync Item Stock with WooCommerce...")); 22 | frappe.call({ 23 | method: "woocommerce_fusion.tasks.stock_update.update_stock_levels_on_woocommerce_site", 24 | args: { 25 | item_code: frm.doc.name 26 | }, 27 | callback: function(r) { 28 | frappe.dom.unfreeze(); 29 | frappe.show_alert({ 30 | message:__('Synchronised stock level to WooCommerce for enabled servers'), 31 | indicator:'green' 32 | }, 5); 33 | frm.reload_doc(); 34 | }, 35 | error: (r) => { 36 | frappe.dom.unfreeze(); 37 | frappe.show_alert({ 38 | message: __('There was an error processing the request. See Error Log.'), 39 | indicator: 'red' 40 | }, 5); 41 | } 42 | }); 43 | }, 44 | 45 | sync_item_price: function(frm) { 46 | // Sync this Item's Price 47 | frappe.dom.freeze(__("Sync Item Price with WooCommerce...")); 48 | frappe.call({ 49 | method: "woocommerce_fusion.tasks.sync_item_prices.run_item_price_sync", 50 | args: { 51 | item_code: frm.doc.name 52 | }, 53 | callback: function(r) { 54 | frappe.dom.unfreeze(); 55 | frappe.show_alert({ 56 | message:__('Synchronised item price to WooCommerce'), 57 | indicator:'green' 58 | }, 5); 59 | frm.reload_doc(); 60 | }, 61 | error: (r) => { 62 | frappe.dom.unfreeze(); 63 | frappe.show_alert({ 64 | message: __('There was an error processing the request. See Error Log.'), 65 | indicator: 'red' 66 | }, 5); 67 | } 68 | }); 69 | }, 70 | 71 | sync_item: function(frm) { 72 | // Sync this Item 73 | frappe.dom.freeze(__("Sync Item with WooCommerce...")); 74 | frappe.call({ 75 | method: "woocommerce_fusion.tasks.sync_items.run_item_sync", 76 | args: { 77 | item_code: frm.doc.name 78 | }, 79 | callback: function(r) { 80 | frappe.dom.unfreeze(); 81 | frappe.show_alert({ 82 | message:__('Sync completed successfully'), 83 | indicator:'green' 84 | }, 5); 85 | frm.reload_doc(); 86 | }, 87 | error: (r) => { 88 | frappe.dom.unfreeze(); 89 | frappe.show_alert({ 90 | message: __('There was an error processing the request. See Error Log.'), 91 | indicator: 'red' 92 | }, 5); 93 | } 94 | }); 95 | }, 96 | }) 97 | 98 | frappe.ui.form.on('Item WooCommerce Server', { 99 | view_product: function(frm, cdt, cdn) { 100 | let current_row_doc = locals[cdt][cdn]; 101 | console.log(current_row_doc); 102 | frappe.set_route("Form", "WooCommerce Product", `${current_row_doc.woocommerce_server}~${current_row_doc.woocommerce_id}` ); 103 | } 104 | }) -------------------------------------------------------------------------------- /woocommerce_fusion/setup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/setup/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/setup/utils.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.setup.utils import _enable_all_roles_for_admin, set_defaults_for_tests 3 | from frappe.utils.data 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 | if not frappe.db.a_row_exists("Company"): 12 | current_year = now_datetime().year 13 | setup_complete( 14 | { 15 | "currency": "INR", 16 | "full_name": "Test User", 17 | "company_name": "Some Company (Pty) Ltd", 18 | "timezone": "Africa/Johannesburg", 19 | "company_abbr": "SC", 20 | "industry": "Manufacturing", 21 | "country": "South Africa", 22 | "fy_start_date": f"{current_year}-01-01", 23 | "fy_end_date": f"{current_year}-12-31", 24 | "language": "english", 25 | "company_tagline": "Testing", 26 | "email": "test@erpnext.com", 27 | "password": "test", 28 | "chart_of_accounts": "Standard", 29 | } 30 | ) 31 | 32 | _enable_all_roles_for_admin() 33 | 34 | set_defaults_for_tests() 35 | create_curr_exchange_record() 36 | 37 | # following same practice as in erpnext app to commit manually inside before_tests 38 | # nosemgrep 39 | frappe.db.commit() 40 | 41 | 42 | def create_curr_exchange_record(): 43 | """ 44 | Create Currency Exchange records for the currencies used in tests 45 | """ 46 | currencies = ["USD", "ZAR"] 47 | 48 | for currency in currencies: 49 | cur_exchange = frappe.new_doc("Currency Exchange") 50 | cur_exchange.date = "2016-01-01" 51 | cur_exchange.from_currency = currency 52 | cur_exchange.to_currency = "INR" 53 | cur_exchange.for_buying = 1 54 | cur_exchange.for_selling = 1 55 | cur_exchange.exchange_rate = 2.0 56 | 57 | cur_exchange.insert(ignore_if_duplicate=True) 58 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/tasks/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/stock_update.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import frappe 4 | 5 | from woocommerce_fusion.tasks.utils import APIWithRequestLogging 6 | 7 | 8 | def update_stock_levels_for_woocommerce_item(doc, method): 9 | if not frappe.flags.in_test: 10 | if doc.doctype in ("Stock Entry", "Stock Reconciliation", "Sales Invoice", "Delivery Note"): 11 | # Check if there are any enabled WooCommerce Servers with stock sync enabled 12 | if ( 13 | len( 14 | frappe.get_list( 15 | "WooCommerce Server", filters={"enable_sync": 1, "enable_stock_level_synchronisation": 1} 16 | ) 17 | ) 18 | > 0 19 | ): 20 | if doc.doctype == "Sales Invoice": 21 | if doc.update_stock == 0: 22 | return 23 | item_codes = [row.item_code for row in doc.items] 24 | for item_code in item_codes: 25 | frappe.enqueue( 26 | "woocommerce_fusion.tasks.stock_update.update_stock_levels_on_woocommerce_site", 27 | enqueue_after_commit=True, 28 | item_code=item_code, 29 | ) 30 | 31 | 32 | def update_stock_levels_for_all_enabled_items_in_background(): 33 | """ 34 | Get all enabled ERPNext Items and post stock updates to WooCommerce 35 | """ 36 | erpnext_items = [] 37 | current_page_length = 500 38 | start = 0 39 | 40 | # Get all items, 500 records at a time 41 | while current_page_length == 500: 42 | items = frappe.db.get_all( 43 | doctype="Item", 44 | filters={"disabled": 0}, 45 | fields=["name"], 46 | start=start, 47 | page_length=500, 48 | ) 49 | erpnext_items.extend(items) 50 | current_page_length = len(items) 51 | start += current_page_length 52 | 53 | for item in erpnext_items: 54 | frappe.enqueue( 55 | "woocommerce_fusion.tasks.stock_update.update_stock_levels_on_woocommerce_site", 56 | item_code=item.name, 57 | ) 58 | 59 | 60 | @frappe.whitelist() 61 | def update_stock_levels_on_woocommerce_site(item_code): 62 | """ 63 | Updates stock levels of an item on all its associated WooCommerce sites. 64 | 65 | This function fetches the item from the database, then for each associated 66 | WooCommerce site, it retrieves the current inventory, calculates the new stock quantity, 67 | and posts the updated stock levels back to the WooCommerce site. 68 | """ 69 | item = frappe.get_doc("Item", item_code) 70 | 71 | if len(item.woocommerce_servers) == 0 or not item.is_stock_item or item.disabled: 72 | return False 73 | else: 74 | bins = frappe.get_list( 75 | "Bin", {"item_code": item_code}, ["name", "warehouse", "reserved_qty", "actual_qty"] 76 | ) 77 | 78 | for wc_site in item.woocommerce_servers: 79 | if wc_site.woocommerce_id: 80 | woocommerce_id = wc_site.woocommerce_id 81 | woocommerce_server = wc_site.woocommerce_server 82 | wc_server = frappe.get_cached_doc("WooCommerce Server", woocommerce_server) 83 | 84 | if ( 85 | not wc_server 86 | or not wc_server.enable_sync 87 | or not wc_site.enabled 88 | or not wc_server.enable_stock_level_synchronisation 89 | ): 90 | continue 91 | 92 | wc_api = APIWithRequestLogging( 93 | url=wc_server.woocommerce_server_url, 94 | consumer_key=wc_server.api_consumer_key, 95 | consumer_secret=wc_server.api_consumer_secret, 96 | version="wc/v3", 97 | timeout=40, 98 | ) 99 | 100 | # Sum all quantities from select warehouses and round the total down (WooCommerce API doesn't accept float values) 101 | data_to_post = { 102 | "stock_quantity": math.floor( 103 | sum( 104 | bin.actual_qty 105 | if not wc_server.subtract_reserved_stock 106 | else bin.actual_qty - bin.reserved_qty 107 | for bin in bins 108 | if bin.warehouse in [row.warehouse for row in wc_server.warehouses] 109 | ) 110 | ) 111 | } 112 | 113 | try: 114 | parent_item_id = item.variant_of 115 | if parent_item_id: 116 | parent_item = frappe.get_doc("Item", parent_item_id) 117 | # Get the parent item's woocommerce_id 118 | for parent_wc_site in parent_item.woocommerce_servers: 119 | if parent_wc_site.woocommerce_server == woocommerce_server: 120 | parent_woocommerce_id = parent_wc_site.woocommerce_id 121 | break 122 | if not parent_woocommerce_id: 123 | continue 124 | endpoint = f"products/{parent_woocommerce_id}/variations/{woocommerce_id}" 125 | else: 126 | endpoint = f"products/{woocommerce_id}" 127 | response = wc_api.put(endpoint=endpoint, data=data_to_post) 128 | except Exception as err: 129 | error_message = f"{frappe.get_traceback()}\n\nData in PUT request: \n{str(data_to_post)}" 130 | frappe.log_error("WooCommerce Error", error_message) 131 | raise err 132 | if response.status_code != 200: 133 | error_message = f"Status Code not 200\n\nData in PUT request: \n{str(data_to_post)}" 134 | error_message += ( 135 | f"\n\nResponse: \n{response.status_code}\nResponse Text: {response.text}\nRequest URL: {response.request.url}\nRequest Body: {response.request.body}" 136 | if response is not None 137 | else "" 138 | ) 139 | frappe.log_error("WooCommerce Error", error_message) 140 | raise ValueError(error_message) 141 | 142 | return True 143 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/sync.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | from typing import List 5 | 6 | import frappe 7 | from frappe import _, _dict 8 | 9 | from woocommerce_fusion.woocommerce.doctype.woocommerce_server.woocommerce_server import ( 10 | WooCommerceServer, 11 | ) 12 | 13 | 14 | class SynchroniseWooCommerce: 15 | """ 16 | Class for managing synchronisation of WooCommerce data with ERPNext data 17 | """ 18 | 19 | servers: List[WooCommerceServer | _dict] 20 | 21 | def __init__(self, servers: List[WooCommerceServer | _dict] = None) -> None: 22 | self.servers = servers if servers else self.get_wc_servers() 23 | 24 | @staticmethod 25 | def get_wc_servers(): 26 | wc_servers = frappe.get_all("WooCommerce Server") 27 | return [frappe.get_doc("WooCommerce Server", server.name) for server in wc_servers] 28 | 29 | 30 | def log_and_raise_error(err): 31 | """ 32 | Create an "Error Log" and raise error 33 | """ 34 | log = frappe.log_error("WooCommerce Error", err) 35 | log_link = frappe.utils.get_link_to_form("Error Log", log.name) 36 | frappe.throw( 37 | msg=_("Something went wrong while connecting to WooCommerce. See Error Log {0}").format( 38 | log_link 39 | ), 40 | title=_("WooCommerce Error"), 41 | ) 42 | raise err 43 | 44 | 45 | def verify_request(): 46 | woocommerce_integration_settings = frappe.get_doc("WooCommerce Server") 47 | sig = base64.b64encode( 48 | hmac.new( 49 | woocommerce_integration_settings.secret.encode("utf8"), frappe.request.data, hashlib.sha256 50 | ).digest() 51 | ) 52 | 53 | if ( 54 | frappe.request.data 55 | and not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode() 56 | ): 57 | frappe.throw(_("Unverified Webhook Data")) 58 | frappe.set_user(woocommerce_integration_settings.creation_user) 59 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/sync_item_prices.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import List, Optional 3 | 4 | import frappe 5 | from erpnext.stock.doctype.item_price.item_price import ItemPrice 6 | from frappe import qb 7 | from frappe.query_builder import Criterion 8 | 9 | from woocommerce_fusion.tasks.sync import SynchroniseWooCommerce 10 | from woocommerce_fusion.woocommerce.doctype.woocommerce_server.woocommerce_server import ( 11 | WooCommerceServer, 12 | ) 13 | from woocommerce_fusion.woocommerce.woocommerce_api import ( 14 | generate_woocommerce_record_name_from_domain_and_id, 15 | ) 16 | 17 | 18 | def update_item_price_for_woocommerce_item_from_hook(doc, method): 19 | if not frappe.flags.in_test: 20 | if doc.doctype == "Item Price": 21 | frappe.enqueue( 22 | "woocommerce_fusion.tasks.sync_item_prices.run_item_price_sync", 23 | enqueue_after_commit=True, 24 | item_code=doc.item_code, 25 | item_price_doc=doc, 26 | ) 27 | 28 | 29 | @frappe.whitelist() 30 | def run_item_price_sync_in_background(): 31 | frappe.enqueue(run_item_price_sync, queue="long", timeout=3600) 32 | 33 | 34 | @frappe.whitelist() 35 | def run_item_price_sync( 36 | item_code: Optional[str] = None, item_price_doc: Optional[ItemPrice] = None 37 | ): 38 | sync = SynchroniseItemPrice(item_code=item_code, item_price_doc=item_price_doc) 39 | sync.run() 40 | return True 41 | 42 | 43 | class SynchroniseItemPrice(SynchroniseWooCommerce): 44 | """ 45 | Class for managing synchronisation of ERPNext Items with WooCommerce Products 46 | """ 47 | 48 | item_code: Optional[str] 49 | item_price_list: List 50 | 51 | def __init__( 52 | self, 53 | servers: List[WooCommerceServer | frappe._dict] = None, 54 | item_code: Optional[str] = None, 55 | item_price_doc: Optional[ItemPrice] = None, 56 | ) -> None: 57 | super().__init__(servers) 58 | self.item_code = item_code 59 | self.item_price_doc = item_price_doc 60 | self.wc_server = None 61 | self.item_price_list = [] 62 | 63 | def run(self) -> None: 64 | """ 65 | Run synchornisation 66 | """ 67 | for server in self.servers: 68 | self.wc_server = server 69 | self.get_erpnext_item_prices() 70 | self.sync_items_with_woocommerce_products() 71 | 72 | def get_erpnext_item_prices(self) -> None: 73 | """ 74 | Get list of ERPNext Item Prices to synchronise, 75 | """ 76 | self.item_price_list = [] 77 | if ( 78 | self.wc_server.enable_sync 79 | and self.wc_server.enable_price_list_sync 80 | and self.wc_server.price_list 81 | ): 82 | ip = qb.DocType("Item Price") 83 | iwc = qb.DocType("Item WooCommerce Server") 84 | item = qb.DocType("Item") 85 | and_conditions = [] 86 | and_conditions.append(ip.price_list == self.wc_server.price_list) 87 | and_conditions.append(iwc.woocommerce_server == self.wc_server.name) 88 | and_conditions.append(item.disabled == 0) 89 | and_conditions.append(iwc.woocommerce_id.isnotnull()) 90 | if self.item_code: 91 | and_conditions.append(ip.item_code == self.item_code) 92 | 93 | self.item_price_list = ( 94 | qb.from_(ip) 95 | .inner_join(iwc) 96 | .on(iwc.parent == ip.item_code) 97 | .inner_join(item) 98 | .on(item.name == ip.item_code) 99 | .select(ip.name, ip.item_code, ip.price_list_rate, iwc.woocommerce_server, iwc.woocommerce_id) 100 | .where(Criterion.all(and_conditions)) 101 | .run(as_dict=True) 102 | ) 103 | 104 | def sync_items_with_woocommerce_products(self) -> None: 105 | """ 106 | Synchronise Item Prices with WooCommerce Products 107 | """ 108 | for item_price in self.item_price_list: 109 | # Get the WooCommerce Product doc 110 | wc_product_name = generate_woocommerce_record_name_from_domain_and_id( 111 | domain=item_price.woocommerce_server, resource_id=item_price.woocommerce_id 112 | ) 113 | wc_product = frappe.get_doc({"doctype": "WooCommerce Product", "name": wc_product_name}) 114 | 115 | try: 116 | wc_product.load_from_db() 117 | 118 | # If self.item_price_doc is set, set the price_list_rate accordingly, else use the price_list_rate from the price list 119 | price_list_rate = ( 120 | self.item_price_doc.price_list_rate 121 | if self.item_price_doc and self.item_price_doc.price_list == self.wc_server.price_list 122 | else item_price.price_list_rate 123 | ) 124 | # Handle blank string for regular_price 125 | if not wc_product.regular_price: 126 | wc_product.regular_price = 0 127 | # When the price is set, the WooCommerce API returns a string value, when the price is not set, it returns a float value of 0.0 128 | wc_product_regular_price = ( 129 | float(wc_product.regular_price) 130 | if isinstance(wc_product.regular_price, str) 131 | else wc_product.regular_price 132 | ) 133 | if wc_product_regular_price != price_list_rate: 134 | wc_product.regular_price = price_list_rate 135 | wc_product.save() 136 | except Exception: 137 | error_message = f"{frappe.get_traceback()}\n\n Product Data: \n{str(wc_product.as_dict())}" 138 | frappe.log_error("WooCommerce Error: Price List Sync", error_message) 139 | 140 | sleep(self.wc_server.price_list_delay_per_item) 141 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/test_integration_item_price_sync.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | import frappe 4 | from erpnext import get_default_company 5 | from erpnext.stock.doctype.item.test_item import create_item 6 | 7 | from woocommerce_fusion.tasks.sync_item_prices import run_item_price_sync 8 | from woocommerce_fusion.tasks.test_integration_helpers import ( 9 | TestIntegrationWooCommerce, 10 | get_woocommerce_server, 11 | ) 12 | 13 | 14 | class TestIntegrationWooCommerceItemPriceSync(TestIntegrationWooCommerce): 15 | @classmethod 16 | def setUpClass(cls): 17 | super().setUpClass() # important to call super() methods when extending TestCase. 18 | 19 | def test_item_price_sync_when_synchronising_with_woocommerce(self): 20 | """ 21 | Test that the Item Price Synchronisation method posts the correct price to a WooCommerce website. 22 | """ 23 | # Create a new product in WooCommerce, set regular price to 10 24 | wc_product_id = self.post_woocommerce_product(product_name="ITEM002", regular_price=10) 25 | 26 | # Create the same product in ERPNext (with opening stock of 5, not 1) and link it 27 | item = create_item("ITEM002", valuation_rate=10, warehouse=None, company=get_default_company()) 28 | item.woocommerce_servers = [] 29 | row = item.append("woocommerce_servers") 30 | row.woocommerce_id = wc_product_id 31 | row.woocommerce_server = get_woocommerce_server(self.wc_url).name 32 | item.save() 33 | 34 | # Add an Item Price 35 | item_price = frappe.get_doc( 36 | { 37 | "doctype": "Item Price", 38 | "item_code": "ITEM002", 39 | "price_list": "_Test Price List", 40 | "price_list_rate": 5000, 41 | } 42 | ) 43 | item_price.insert() 44 | 45 | # Run synchronisation 46 | stock_update_result = run_item_price_sync(item_code=item.name) 47 | 48 | # Expect successful update 49 | self.assertEqual(stock_update_result, True) 50 | 51 | # Expect correct price of 5000 in WooCommerce 52 | wc_price = self.get_woocommerce_product_price(product_id=wc_product_id) 53 | self.assertEqual(float(wc_price), 5000) 54 | 55 | def test_item_price_sync_ignored_if_item_disabled_when_synchronising_with_woocommerce(self): 56 | """ 57 | Test that the Item Price Synchronisation method does not post a price to a WooCommerce website when the item is disabled. 58 | """ 59 | # Create a new product in WooCommerce, set regular price to 10 60 | wc_product_id = self.post_woocommerce_product(product_name="ITEM003", regular_price=10) 61 | 62 | # Create the same product in ERPNext (with opening stock of 5, not 1) and link it 63 | item = create_item("ITEM003", valuation_rate=10, warehouse=None, company=get_default_company()) 64 | item.woocommerce_servers = [] 65 | row = item.append("woocommerce_servers") 66 | row.woocommerce_id = wc_product_id 67 | row.woocommerce_server = get_woocommerce_server(self.wc_url).name 68 | 69 | # Disable the item 70 | item.disabled = 1 71 | item.save() 72 | 73 | # Add an Item Price 74 | item_price = frappe.get_doc( 75 | { 76 | "doctype": "Item Price", 77 | "item_code": "ITEM003", 78 | "price_list": "_Test Price List", 79 | "price_list_rate": 6000, 80 | } 81 | ) 82 | item_price.insert() 83 | 84 | # Run synchronisation 85 | stock_update_result = run_item_price_sync(item_code=item.name) 86 | 87 | # Expect successful update 88 | self.assertEqual(stock_update_result, True) 89 | 90 | # Expect correct unchanged price of 10 in WooCommerce 91 | wc_price = self.get_woocommerce_product_price(product_id=wc_product_id) 92 | self.assertEqual(float(wc_price), 10) 93 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/test_integration_stock_update.py: -------------------------------------------------------------------------------- 1 | import math 2 | from urllib.parse import urlparse 3 | 4 | import frappe 5 | from erpnext import get_default_company 6 | from erpnext.stock.doctype.item.test_item import create_item 7 | 8 | from woocommerce_fusion.tasks.stock_update import update_stock_levels_on_woocommerce_site 9 | from woocommerce_fusion.tasks.test_integration_helpers import ( 10 | TestIntegrationWooCommerce, 11 | get_woocommerce_server, 12 | ) 13 | 14 | 15 | class TestIntegrationWooCommerceStockSync(TestIntegrationWooCommerce): 16 | @classmethod 17 | def setUpClass(cls): 18 | super().setUpClass() # important to call super() methods when extending TestCase. 19 | 20 | def test_stock_sync_when_synchronising_with_woocommerce(self): 21 | """ 22 | Test that the Stock Synchronisation method posts the correct stock level to a WooCommerce website. 23 | """ 24 | # Create a new product in WooCommerce, set opening stock to 1 25 | wc_product_id = self.post_woocommerce_product(product_name="ITEM009", opening_stock=1) 26 | 27 | # Create the same product in ERPNext (with opening stock of 5, not 1) and link it 28 | item = create_item( 29 | "ITEM009", 30 | valuation_rate=10, 31 | warehouse="Stores - SC", 32 | company=get_default_company(), 33 | opening_stock=5, 34 | ) 35 | item.woocommerce_servers = [] 36 | row = item.append("woocommerce_servers") 37 | row.woocommerce_id = wc_product_id 38 | row.woocommerce_server = get_woocommerce_server(self.wc_url).name 39 | item.save() 40 | 41 | # Run synchronisation 42 | stock_update_result = update_stock_levels_on_woocommerce_site(item_code=item.name) 43 | 44 | # Expect successful update 45 | self.assertEqual(stock_update_result, True) 46 | 47 | # Expect correct stock level of 5 in WooCommerce 48 | wc_stock_level = self.get_woocommerce_product_stock_level(product_id=wc_product_id) 49 | self.assertEqual(wc_stock_level, 5) 50 | 51 | def test_stock_sync_with_decimal_when_synchronising_with_woocommerce(self): 52 | """ 53 | Test that the Stock Synchronisation method posts the correct stock level to a WooCommerce website 54 | while handling decimals. 55 | """ 56 | # Create a new product in WooCommerce, set opening stock to 1 57 | wc_product_id = self.post_woocommerce_product(product_name="ITEM002", opening_stock=1) 58 | 59 | # Create the same product in ERPNext (with opening stock of 6.9, not 1) and link it 60 | item = create_item( 61 | "ITEM002", 62 | valuation_rate=10, 63 | warehouse="Stores - SC", 64 | company=get_default_company(), 65 | stock_uom="Kg", 66 | opening_stock=6.9, 67 | ) 68 | row = item.append("woocommerce_servers") 69 | row.woocommerce_id = wc_product_id 70 | row.woocommerce_server = get_woocommerce_server(self.wc_url).name 71 | item.save() 72 | 73 | # Run synchronisation 74 | stock_update_result = update_stock_levels_on_woocommerce_site(item_code=item.name) 75 | 76 | # Expect successful update 77 | self.assertEqual(stock_update_result, True) 78 | 79 | # Expect correct stock level of 6.9 rounded down in WooCommerce (WooCommerce API doesn't accept float values) 80 | wc_stock_level = self.get_woocommerce_product_stock_level(product_id=wc_product_id) 81 | self.assertEqual(wc_stock_level, math.floor(6.9)) 82 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/test_stock_update.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock, call, patch 2 | 3 | import frappe 4 | from frappe import _dict 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | from woocommerce_fusion.tasks.stock_update import ( 8 | update_stock_levels_for_all_enabled_items_in_background, 9 | update_stock_levels_on_woocommerce_site, 10 | ) 11 | 12 | 13 | class TestWooCommerceStockSync(FrappeTestCase): 14 | @classmethod 15 | def setUpClass(cls): 16 | super().setUpClass() # important to call super() methods when extending TestCase. 17 | 18 | @patch("woocommerce_fusion.tasks.stock_update.frappe") 19 | @patch("woocommerce_fusion.tasks.stock_update.APIWithRequestLogging", autospec=True) 20 | def test_update_stock_levels_on_woocommerce_site(self, mock_wc_api, mock_frappe): 21 | # Set up a dummy item set to sync to two different WC sites 22 | some_item = frappe._dict( 23 | woocommerce_servers=[ 24 | frappe._dict(woocommerce_id=1, woocommerce_server="woo1.example.com", enabled=1), 25 | frappe._dict(woocommerce_id=2, woocommerce_server="woo2.example.com", enabled=1), 26 | ], 27 | is_stock_item=1, 28 | disabled=0, 29 | ) 30 | mock_frappe.get_doc.return_value = some_item 31 | 32 | # Set up a dummy bin list with stock in two Warehouses 33 | bin_list = [ 34 | frappe._dict(warehouse="Warehouse A", actual_qty=5), 35 | frappe._dict(warehouse="Warehouse B", actual_qty=10), 36 | frappe._dict(warehouse="Warehouse C", actual_qty=20), 37 | ] 38 | mock_frappe.get_list.return_value = bin_list 39 | 40 | # Set up mock return values 41 | mock_frappe.get_cached_doc.side_effect = [ 42 | frappe._dict( 43 | woocommerce_server="woo1.example.com", 44 | enable_sync=1, 45 | enable_stock_level_synchronisation=1, 46 | warehouses=[frappe._dict(warehouse="Warehouse A"), frappe._dict(warehouse="Warehouse B")], 47 | ), 48 | frappe._dict( 49 | woocommerce_server="woo2.example.com", 50 | enable_sync=1, 51 | enable_stock_level_synchronisation=1, 52 | warehouses=[frappe._dict(warehouse="Warehouse A"), frappe._dict(warehouse="Warehouse B")], 53 | ), 54 | ] 55 | 56 | # Mock out calls to WooCommerce API's 57 | mock_put_response = Mock() 58 | mock_put_response.status_code = 200 59 | 60 | mock_api_instance = MagicMock() 61 | mock_api_instance.put.return_value = mock_put_response 62 | mock_wc_api.return_value = mock_api_instance 63 | 64 | # Call function under test 65 | update_stock_levels_on_woocommerce_site("some_item_code") 66 | 67 | # Assert that the inventories put calls were made with the correct arguments 68 | self.assertEqual(mock_api_instance.put.call_count, 2) 69 | actual_put_endpoints = [call.kwargs["endpoint"] for call in mock_api_instance.put.call_args_list] 70 | actual_put_data = [call.kwargs["data"] for call in mock_api_instance.put.call_args_list] 71 | 72 | expected_put_endpoints = ["products/1", "products/2"] 73 | expected_data = {"stock_quantity": 15} 74 | expected_put_data = [expected_data for x in range(2)] 75 | self.assertEqual(actual_put_endpoints, expected_put_endpoints) 76 | self.assertEqual(actual_put_data, expected_put_data) 77 | 78 | @patch("woocommerce_fusion.tasks.stock_update.frappe") 79 | @patch("woocommerce_fusion.tasks.stock_update.APIWithRequestLogging", autospec=True) 80 | def test_update_stock_levels_on_woocommerce_site_variant(self, mock_wc_api, mock_frappe): 81 | # Set up a dummy variant item set to sync to a WC site 82 | variant_item = frappe._dict( 83 | woocommerce_servers=[ 84 | frappe._dict(woocommerce_id=101, woocommerce_server="woo1.example.com", enabled=1), 85 | ], 86 | is_stock_item=1, 87 | disabled=0, 88 | variant_of="parent_item_code", 89 | ) 90 | mock_frappe.get_doc.side_effect = [ 91 | variant_item, 92 | frappe._dict( 93 | woocommerce_servers=[ 94 | frappe._dict(woocommerce_id=100, woocommerce_server="woo1.example.com", enabled=1), 95 | ] 96 | ), 97 | ] 98 | 99 | # Set up a dummy bin list with stock in two Warehouses 100 | bin_list = [ 101 | frappe._dict(warehouse="Warehouse A", actual_qty=5), 102 | frappe._dict(warehouse="Warehouse B", actual_qty=10), 103 | ] 104 | mock_frappe.get_list.return_value = bin_list 105 | 106 | # Set up mock return values 107 | mock_frappe.get_cached_doc.return_value = frappe._dict( 108 | woocommerce_server="woo1.example.com", 109 | enable_sync=1, 110 | enable_stock_level_synchronisation=1, 111 | warehouses=[frappe._dict(warehouse="Warehouse A"), frappe._dict(warehouse="Warehouse B")], 112 | ) 113 | 114 | # Mock out calls to WooCommerce API's 115 | mock_put_response = Mock() 116 | mock_put_response.status_code = 200 117 | 118 | mock_api_instance = MagicMock() 119 | mock_api_instance.put.return_value = mock_put_response 120 | mock_wc_api.return_value = mock_api_instance 121 | 122 | # Call function under test 123 | update_stock_levels_on_woocommerce_site("variant_item_code") 124 | 125 | # Assert that the inventories put calls were made with the correct arguments 126 | self.assertEqual(mock_api_instance.put.call_count, 1) 127 | actual_put_endpoint = mock_api_instance.put.call_args.kwargs["endpoint"] 128 | actual_put_data = mock_api_instance.put.call_args.kwargs["data"] 129 | 130 | expected_put_endpoint = "products/100/variations/101" 131 | expected_data = {"stock_quantity": 15} 132 | self.assertEqual(actual_put_endpoint, expected_put_endpoint) 133 | self.assertEqual(actual_put_data, expected_data) 134 | 135 | @patch("woocommerce_fusion.tasks.stock_update.frappe.db.get_all") 136 | @patch("woocommerce_fusion.tasks.stock_update.frappe.enqueue") 137 | def test_update_stock_levels_for_all_enabled_items_in_background( 138 | self, mock_enqueue, mock_get_all 139 | ): 140 | # Set up mock return values 141 | mock_get_all.side_effect = [ 142 | [_dict({"name": f"Item-1-{x}"}) for x in range(500)], # First page of results 143 | [_dict({"name": f"Item-2-{x}"}) for x in range(500)], # Second page of results 144 | [], # No more results, loop should exit 145 | ] 146 | 147 | # Call the function 148 | update_stock_levels_for_all_enabled_items_in_background() 149 | 150 | # Assertions to check if get_all was called correctly 151 | self.assertEqual(mock_get_all.call_count, 3) 152 | expected_calls = [ 153 | call(doctype="Item", filters={"disabled": 0}, fields=["name"], start=0, page_length=500), 154 | call(doctype="Item", filters={"disabled": 0}, fields=["name"], start=500, page_length=500), 155 | call(doctype="Item", filters={"disabled": 0}, fields=["name"], start=1000, page_length=500), 156 | ] 157 | mock_get_all.assert_has_calls(expected_calls, any_order=True) 158 | 159 | # Assertions to check if enqueue was called correctly 160 | # This assumes we have 1000 items, based on the pagination logic above. 161 | self.assertEqual(mock_enqueue.call_count, 1000) 162 | mock_enqueue.assert_called_with( 163 | "woocommerce_fusion.tasks.stock_update.update_stock_levels_on_woocommerce_site", 164 | item_code="Item-2-499", # Here we'd check for the last `item_code` being passed. 165 | ) 166 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from frappe.tests.utils import FrappeTestCase 5 | 6 | from woocommerce_fusion.tasks.utils import ( # Adjust the import according to your project structure 7 | log_woocommerce_request, 8 | ) 9 | 10 | 11 | class TestLogWooCommerceRequest(FrappeTestCase): 12 | @classmethod 13 | def setUpClass(cls): 14 | super().setUpClass() # important to call super() methods when extending TestCase. 15 | 16 | @patch("woocommerce_fusion.tasks.utils.frappe") # Mock frappe 17 | def test_successful_request(self, mock_frappe): 18 | # Setup 19 | mock_response = Mock() 20 | mock_response.status_code = 200 21 | mock_response.text = "Success response text" 22 | 23 | # Execute 24 | log_woocommerce_request( 25 | "http://example.com", "endpoint", "GET", {"param": "value"}, {"data": "value"}, mock_response 26 | ) 27 | 28 | # Assert 29 | self.assertEqual(mock_frappe.get_doc.call_count, 1) 30 | logged_request = mock_frappe.get_doc.call_args[0][0] 31 | self.assertEqual(logged_request["status"], "Success") 32 | 33 | # @patch('woocommerce_fusion.tasks.utils.frappe') 34 | # def test_error_request(self, mock_frappe): 35 | # # Similar structure as above, but simulate an error response (e.g., status_code != 200) 36 | 37 | # @patch('woocommerce_fusion.tasks.utils.frappe') 38 | # def test_no_response(self, mock_frappe): 39 | # # Test the function when res is None 40 | -------------------------------------------------------------------------------- /woocommerce_fusion/tasks/utils.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import frappe 4 | import requests 5 | from frappe.utils.caching import redis_cache 6 | from woocommerce import API 7 | 8 | 9 | class APIWithRequestLogging(API): 10 | """WooCommerce API with Request Logging.""" 11 | 12 | def _API__request(self, method, endpoint, data, params=None, **kwargs): 13 | """Override _request method to also create a 'WooCommerce Request Log'""" 14 | result = None 15 | try: 16 | result = super()._API__request(method, endpoint, data, params, **kwargs) 17 | if not frappe.flags.in_test and is_woocommerce_request_logging_enabled(self.url): 18 | frappe.enqueue( 19 | "woocommerce_fusion.tasks.utils.log_woocommerce_request", 20 | url=self.url, 21 | endpoint=endpoint, 22 | request_method=method, 23 | params=params, 24 | data=data, 25 | res=result, 26 | traceback="".join(traceback.format_stack(limit=8)), 27 | ) 28 | return result 29 | except Exception as e: 30 | if not frappe.flags.in_test and is_woocommerce_request_logging_enabled(self.url): 31 | frappe.enqueue( 32 | "woocommerce_fusion.tasks.utils.log_woocommerce_request", 33 | url=self.url, 34 | endpoint=endpoint, 35 | request_method=method, 36 | params=params, 37 | data=data, 38 | res=result, 39 | traceback="".join(traceback.format_stack(limit=8)), 40 | ) 41 | raise e 42 | 43 | 44 | @redis_cache(ttl=86400) 45 | def is_woocommerce_request_logging_enabled(woocommerce_server_url: str) -> bool: 46 | """ 47 | Checks if WooCommerce request logging is enabled for the given WooCommerce server URL. 48 | Args: 49 | woocommerce_server_url (str): The URL of the WooCommerce server. 50 | Returns: 51 | bool: True if request logging is enabled, False otherwise. 52 | """ 53 | enabled = frappe.get_all( 54 | "WooCommerce Server", 55 | filters={"woocommerce_server_url": woocommerce_server_url}, 56 | fields=["enable_woocommerce_request_logs"], 57 | ) 58 | if not enabled: 59 | return False 60 | return enabled[0].enable_woocommerce_request_logs 61 | 62 | 63 | def log_woocommerce_request( 64 | url: str, 65 | endpoint: str, 66 | request_method: str, 67 | params: dict, 68 | data: dict, 69 | res: requests.Response | None = None, 70 | traceback: str = None, 71 | ): 72 | request_log = frappe.get_doc( 73 | { 74 | "doctype": "WooCommerce Request Log", 75 | "user": frappe.session.user if frappe.session.user else None, 76 | "url": url, 77 | "endpoint": endpoint, 78 | "method": request_method, 79 | "params": frappe.as_json(params) if params else None, 80 | "data": frappe.as_json(data) if data else None, 81 | "response": f"{str(res)}\n{res.text}" if res is not None else None, 82 | "error": frappe.get_traceback(), 83 | "status": "Success" if res and res.status_code in [200, 201] else "Error", 84 | "time_elapsed": res.elapsed.total_seconds() if res is not None else None, 85 | } 86 | ) 87 | 88 | request_log.save(ignore_permissions=True) 89 | -------------------------------------------------------------------------------- /woocommerce_fusion/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/templates/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/templates/pages/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/templates/pages/__pycache__/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/templates/pages/__pycache__/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/item_woocommerce_server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/item_woocommerce_server/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/item_woocommerce_server/item_woocommerce_server.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2023-05-27 11:45:54.137862", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "enabled", 10 | "woocommerce_id", 11 | "woocommerce_server", 12 | "view_product", 13 | "woocommerce_last_sync_hash" 14 | ], 15 | "fields": [ 16 | { 17 | "fieldname": "woocommerce_id", 18 | "fieldtype": "Data", 19 | "in_list_view": 1, 20 | "label": "WooCommerce ID" 21 | }, 22 | { 23 | "fieldname": "woocommerce_server", 24 | "fieldtype": "Link", 25 | "in_list_view": 1, 26 | "label": "WooCommerce Server", 27 | "mandatory_depends_on": "eval: doc.enabled", 28 | "options": "WooCommerce Server", 29 | "reqd": 1 30 | }, 31 | { 32 | "depends_on": "eval: doc.woocommerce_id && doc.woocommerce_server", 33 | "fieldname": "view_product", 34 | "fieldtype": "Button", 35 | "label": "View Product" 36 | }, 37 | { 38 | "default": "1", 39 | "fieldname": "enabled", 40 | "fieldtype": "Check", 41 | "label": "Enable Sync" 42 | }, 43 | { 44 | "fieldname": "woocommerce_last_sync_hash", 45 | "fieldtype": "Data", 46 | "label": "Last Sync Hash", 47 | "read_only": 1 48 | } 49 | ], 50 | "index_web_pages_for_search": 1, 51 | "istable": 1, 52 | "links": [], 53 | "modified": "2024-06-09 09:05:48.230782", 54 | "modified_by": "Administrator", 55 | "module": "WooCommerce", 56 | "name": "Item WooCommerce Server", 57 | "owner": "Administrator", 58 | "permissions": [], 59 | "sort_field": "modified", 60 | "sort_order": "DESC", 61 | "states": [] 62 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/item_woocommerce_server/item_woocommerce_server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse 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 ItemWooCommerceServer(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/test_woocommerce_integration_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestWooCommerceIntegrationSettings(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/woocommerce_integration_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, Dirk van der Laarse and contributors 2 | // For license information, please see license.txt 3 | 4 | // frappe.ui.form.on("WooCommerce Integration Settings", { 5 | // refresh(frm) { 6 | 7 | // }, 8 | // }); 9 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/woocommerce_integration_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-06-05 09:57:33.712292", 5 | "doctype": "DocType", 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "wc_last_sync_date", 9 | "wc_last_sync_date_items", 10 | "minimum_creation_date" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "wc_last_sync_date", 15 | "fieldtype": "Datetime", 16 | "in_list_view": 1, 17 | "label": "Last Sales Orders Syncronisation Date", 18 | "reqd": 1 19 | }, 20 | { 21 | "description": "WooCommerce Orders with a creation date earlier than this date will be ignored", 22 | "fieldname": "minimum_creation_date", 23 | "fieldtype": "Datetime", 24 | "label": "Minimum Creation Date" 25 | }, 26 | { 27 | "fieldname": "wc_last_sync_date_items", 28 | "fieldtype": "Datetime", 29 | "in_list_view": 1, 30 | "label": "Last Items Syncronisation Date", 31 | "reqd": 1 32 | } 33 | ], 34 | "index_web_pages_for_search": 1, 35 | "issingle": 1, 36 | "links": [], 37 | "modified": "2024-06-07 06:26:26.940482", 38 | "modified_by": "Administrator", 39 | "module": "WooCommerce", 40 | "name": "WooCommerce Integration Settings", 41 | "owner": "Administrator", 42 | "permissions": [ 43 | { 44 | "create": 1, 45 | "delete": 1, 46 | "email": 1, 47 | "print": 1, 48 | "read": 1, 49 | "role": "System Manager", 50 | "share": 1, 51 | "write": 1 52 | } 53 | ], 54 | "sort_field": "modified", 55 | "sort_order": "DESC", 56 | "states": [] 57 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_integration_settings/woocommerce_integration_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse 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 WooCommerceIntegrationSettings(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_order/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_order/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Dirk van der Laarse and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WooCommerce Order', { 5 | refresh: function(frm) { 6 | // Add a custom button to sync this WooCommerce order to a Sales Order 7 | frm.add_custom_button(__("Sync this Order to ERPNext"), function () { 8 | frm.trigger("sync_sales_order"); 9 | }, __('Actions')); 10 | 11 | // Set intro text 12 | const intro_txt = __( 13 | "Note: This is a Virtual Document. Saving changes on this document will update this resource on WooCommerce." 14 | ); 15 | frm.set_intro(intro_txt, "orange"); 16 | }, 17 | sync_sales_order: function(frm) { 18 | // Sync this WooCommerce Order 19 | frappe.dom.freeze(__("Sync Order with ERPNext...")); 20 | frappe.call({ 21 | method: "woocommerce_fusion.tasks.sync_sales_orders.run_sales_order_sync", 22 | args: { 23 | woocommerce_order_name: frm.doc.name 24 | }, 25 | callback: function(r) { 26 | console.log(r); 27 | frappe.dom.unfreeze(); 28 | frappe.show_alert({ 29 | message:__('Sync completed successfully'), 30 | indicator:'green' 31 | }, 5); 32 | frm.reload_doc(); 33 | }, 34 | error: (r) => { 35 | frappe.dom.unfreeze(); 36 | frappe.show_alert({ 37 | message: __('There was an error processing the request. See Error Log.'), 38 | indicator: 'red' 39 | }, 5); 40 | } 41 | }); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2023-05-20 13:20:27.875085", 4 | "default_view": "List", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "woocommerce_server", 10 | "id", 11 | "parent_id", 12 | "number", 13 | "order_key", 14 | "created_via", 15 | "version", 16 | "status", 17 | "currency", 18 | "date_created", 19 | "date_created_gmt", 20 | "date_modified", 21 | "date_modified_gmt", 22 | "discount_total", 23 | "discount_tax", 24 | "shipping_total", 25 | "shipping_tax", 26 | "cart_tax", 27 | "total", 28 | "total_tax", 29 | "prices_include_tax", 30 | "customer_id", 31 | "customer_ip_address", 32 | "customer_user_agent", 33 | "customer_note", 34 | "billing", 35 | "shipping", 36 | "payment_method", 37 | "payment_method_title", 38 | "transaction_id", 39 | "date_paid", 40 | "date_paid_gmt", 41 | "date_completed", 42 | "date_completed_gmt", 43 | "cart_hash", 44 | "meta_data", 45 | "line_items", 46 | "tax_lines", 47 | "shipping_lines", 48 | "fee_lines", 49 | "coupon_lines", 50 | "refunds", 51 | "set_paid", 52 | "shipment_trackings" 53 | ], 54 | "fields": [ 55 | { 56 | "description": "Unique identifier for the resource.", 57 | "fieldname": "id", 58 | "fieldtype": "Data", 59 | "in_list_view": 1, 60 | "label": "Id", 61 | "read_only": 1 62 | }, 63 | { 64 | "description": "Parent order ID.", 65 | "fieldname": "parent_id", 66 | "fieldtype": "Data", 67 | "label": "Parent Id", 68 | "read_only": 1 69 | }, 70 | { 71 | "description": "Order number.", 72 | "fieldname": "number", 73 | "fieldtype": "Data", 74 | "label": "Number", 75 | "read_only": 1 76 | }, 77 | { 78 | "description": "Order key.", 79 | "fieldname": "order_key", 80 | "fieldtype": "Data", 81 | "in_list_view": 1, 82 | "label": "Order Key", 83 | "read_only": 1 84 | }, 85 | { 86 | "description": "Shows where the order was created.", 87 | "fieldname": "created_via", 88 | "fieldtype": "Data", 89 | "label": "Created Via", 90 | "read_only": 1 91 | }, 92 | { 93 | "description": "Version of WooCommerce which last updated the order.", 94 | "fieldname": "version", 95 | "fieldtype": "Data", 96 | "label": "Version", 97 | "read_only": 1 98 | }, 99 | { 100 | "description": "Order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed and trash. Default is pending.", 101 | "fieldname": "status", 102 | "fieldtype": "Select", 103 | "in_list_view": 1, 104 | "label": "Status", 105 | "options": "pending\non-hold\nfailed\ncancelled\nprocessing\nrefunded\ncompleted\nready-pickup\npickup\ndelivered\nprocessing-lp\ncheckout-draft\ngplsquote-req\ntrash" 106 | }, 107 | { 108 | "description": "Currency the order was created with, in ISO format.", 109 | "fieldname": "currency", 110 | "fieldtype": "Data", 111 | "label": "Currency", 112 | "read_only": 1 113 | }, 114 | { 115 | "description": "The date the order was created, in the site's timezone.", 116 | "fieldname": "date_created", 117 | "fieldtype": "Datetime", 118 | "label": "Date Created", 119 | "read_only": 1 120 | }, 121 | { 122 | "description": "The date the order was created, as GMT.", 123 | "fieldname": "date_created_gmt", 124 | "fieldtype": "Datetime", 125 | "label": "Date Created GMT", 126 | "read_only": 1 127 | }, 128 | { 129 | "description": "The date the order was last modified, in the site's timezone.", 130 | "fieldname": "date_modified", 131 | "fieldtype": "Datetime", 132 | "label": "Date Modified", 133 | "read_only": 1 134 | }, 135 | { 136 | "description": "The date the order was last modified, as GMT.", 137 | "fieldname": "date_modified_gmt", 138 | "fieldtype": "Datetime", 139 | "label": "Date Modified GMT", 140 | "read_only": 1 141 | }, 142 | { 143 | "description": "Total discount amount for the order.", 144 | "fieldname": "discount_total", 145 | "fieldtype": "Data", 146 | "label": "Discount Total", 147 | "read_only": 1 148 | }, 149 | { 150 | "description": "Total discount tax amount for the order.", 151 | "fieldname": "discount_tax", 152 | "fieldtype": "Data", 153 | "label": "Discount Tax", 154 | "read_only": 1 155 | }, 156 | { 157 | "description": "Total shipping amount for the order.", 158 | "fieldname": "shipping_total", 159 | "fieldtype": "Data", 160 | "label": "Shipping Total", 161 | "read_only": 1 162 | }, 163 | { 164 | "description": "Total shipping tax amount for the order.", 165 | "fieldname": "shipping_tax", 166 | "fieldtype": "Data", 167 | "label": "Shipping Tax", 168 | "read_only": 1 169 | }, 170 | { 171 | "description": "Sum of line item taxes only.", 172 | "fieldname": "cart_tax", 173 | "fieldtype": "Data", 174 | "label": "Cart Tax", 175 | "read_only": 1 176 | }, 177 | { 178 | "description": "Grand total.", 179 | "fieldname": "total", 180 | "fieldtype": "Data", 181 | "label": "Total", 182 | "read_only": 1 183 | }, 184 | { 185 | "description": "Sum of all taxes.", 186 | "fieldname": "total_tax", 187 | "fieldtype": "Data", 188 | "label": "Total Tax", 189 | "read_only": 1 190 | }, 191 | { 192 | "default": "0", 193 | "description": "True the prices included tax during checkout.", 194 | "fieldname": "prices_include_tax", 195 | "fieldtype": "Check", 196 | "label": "Prices Include Tax", 197 | "read_only": 1 198 | }, 199 | { 200 | "description": "User ID who owns the order. 0 for guests. Default is 0.", 201 | "fieldname": "customer_id", 202 | "fieldtype": "Data", 203 | "label": "Customer Id", 204 | "read_only": 1 205 | }, 206 | { 207 | "description": "Customer's IP address.", 208 | "fieldname": "customer_ip_address", 209 | "fieldtype": "Data", 210 | "label": "Customer Ip Address", 211 | "read_only": 1 212 | }, 213 | { 214 | "description": "User agent of the customer.", 215 | "fieldname": "customer_user_agent", 216 | "fieldtype": "Long Text", 217 | "label": "Customer User Agent", 218 | "read_only": 1 219 | }, 220 | { 221 | "description": "Note left by customer during checkout.", 222 | "fieldname": "customer_note", 223 | "fieldtype": "Text", 224 | "label": "Customer Note", 225 | "read_only": 1 226 | }, 227 | { 228 | "description": "Billing address. See Order - Billing properties", 229 | "fieldname": "billing", 230 | "fieldtype": "JSON", 231 | "label": "Billing", 232 | "read_only": 1 233 | }, 234 | { 235 | "description": "Shipping address. See Order - Shipping properties", 236 | "fieldname": "shipping", 237 | "fieldtype": "JSON", 238 | "label": "Shipping", 239 | "read_only": 1 240 | }, 241 | { 242 | "description": "Payment method ID.", 243 | "fieldname": "payment_method", 244 | "fieldtype": "Data", 245 | "label": "Payment Method", 246 | "read_only": 1 247 | }, 248 | { 249 | "description": "Payment method title.", 250 | "fieldname": "payment_method_title", 251 | "fieldtype": "Long Text", 252 | "label": "Payment Method Title", 253 | "read_only": 1 254 | }, 255 | { 256 | "description": "Unique transaction ID.", 257 | "fieldname": "transaction_id", 258 | "fieldtype": "Data", 259 | "label": "Transaction Id", 260 | "read_only": 1 261 | }, 262 | { 263 | "description": "The date the order was paid, in the site's timezone.", 264 | "fieldname": "date_paid", 265 | "fieldtype": "Datetime", 266 | "label": "Date Paid", 267 | "read_only": 1 268 | }, 269 | { 270 | "description": "The date the order was paid, as GMT.", 271 | "fieldname": "date_paid_gmt", 272 | "fieldtype": "Datetime", 273 | "label": "Date Paid GMT", 274 | "read_only": 1 275 | }, 276 | { 277 | "description": "The date the order was completed, in the site's timezone.", 278 | "fieldname": "date_completed", 279 | "fieldtype": "Datetime", 280 | "label": "Date Completed", 281 | "read_only": 1 282 | }, 283 | { 284 | "description": "The date the order was completed, as GMT.", 285 | "fieldname": "date_completed_gmt", 286 | "fieldtype": "Datetime", 287 | "label": "Date Completed GMT", 288 | "read_only": 1 289 | }, 290 | { 291 | "description": "MD5 hash of cart items to ensure orders are not modified.", 292 | "fieldname": "cart_hash", 293 | "fieldtype": "Data", 294 | "label": "Cart Hash", 295 | "read_only": 1 296 | }, 297 | { 298 | "description": "Meta data. See Order - Meta data properties", 299 | "fieldname": "meta_data", 300 | "fieldtype": "JSON", 301 | "label": "Meta Data", 302 | "read_only": 1 303 | }, 304 | { 305 | "description": "Line items data. See Order - Line items properties", 306 | "fieldname": "line_items", 307 | "fieldtype": "JSON", 308 | "label": "Line Items", 309 | "read_only": 1 310 | }, 311 | { 312 | "description": "Tax lines data. See Order - Tax lines properties", 313 | "fieldname": "tax_lines", 314 | "fieldtype": "JSON", 315 | "label": "Tax Lines", 316 | "read_only": 1 317 | }, 318 | { 319 | "description": "Shipping lines data. See Order - Shipping lines properties", 320 | "fieldname": "shipping_lines", 321 | "fieldtype": "JSON", 322 | "label": "Shipping Lines", 323 | "read_only": 1 324 | }, 325 | { 326 | "description": "Fee lines data. See Order - Fee lines properties", 327 | "fieldname": "fee_lines", 328 | "fieldtype": "JSON", 329 | "label": "Fee Lines", 330 | "read_only": 1 331 | }, 332 | { 333 | "description": "Coupons line data. See Order - Coupon lines properties", 334 | "fieldname": "coupon_lines", 335 | "fieldtype": "JSON", 336 | "label": "Coupon Lines", 337 | "read_only": 1 338 | }, 339 | { 340 | "description": "List of refunds. See Order - Refunds properties", 341 | "fieldname": "refunds", 342 | "fieldtype": "JSON", 343 | "label": "Refunds", 344 | "read_only": 1 345 | }, 346 | { 347 | "default": "0", 348 | "description": "Define if the order is paid. It will set the status to processing and reduce stock items. Default is false.", 349 | "fieldname": "set_paid", 350 | "fieldtype": "Check", 351 | "label": "Set Paid", 352 | "read_only": 1 353 | }, 354 | { 355 | "description": "Only relevant if you are using the 'Advanced Shipment Tracking for WooCommerce' plugin", 356 | "fieldname": "shipment_trackings", 357 | "fieldtype": "JSON", 358 | "label": "Shipment Trackings", 359 | "read_only": 1 360 | }, 361 | { 362 | "fieldname": "woocommerce_server", 363 | "fieldtype": "Link", 364 | "label": "Woocommerce Server", 365 | "options": "WooCommerce Server", 366 | "read_only": 1, 367 | "reqd": 1 368 | } 369 | ], 370 | "index_web_pages_for_search": 1, 371 | "is_virtual": 1, 372 | "links": [], 373 | "modified": "2024-12-12 08:17:46.566425", 374 | "modified_by": "Administrator", 375 | "module": "WooCommerce", 376 | "name": "WooCommerce Order", 377 | "owner": "Administrator", 378 | "permissions": [ 379 | { 380 | "create": 1, 381 | "delete": 1, 382 | "email": 1, 383 | "export": 1, 384 | "print": 1, 385 | "read": 1, 386 | "report": 1, 387 | "role": "System Manager", 388 | "share": 1, 389 | "write": 1 390 | }, 391 | { 392 | "email": 1, 393 | "export": 1, 394 | "print": 1, 395 | "read": 1, 396 | "report": 1, 397 | "role": "Sales User", 398 | "share": 1, 399 | "write": 1 400 | }, 401 | { 402 | "create": 1, 403 | "delete": 1, 404 | "email": 1, 405 | "export": 1, 406 | "print": 1, 407 | "read": 1, 408 | "report": 1, 409 | "role": "Sales Manager", 410 | "share": 1, 411 | "write": 1 412 | } 413 | ], 414 | "sort_field": "modified", 415 | "sort_order": "DESC", 416 | "states": [] 417 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_order/woocommerce_order.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse and contributors 2 | # For license information, please see license.txt 3 | 4 | import json 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from typing import Dict, List 8 | 9 | import frappe 10 | 11 | from woocommerce_fusion.tasks.utils import APIWithRequestLogging 12 | from woocommerce_fusion.woocommerce.woocommerce_api import ( 13 | WooCommerceAPI, 14 | WooCommerceResource, 15 | get_domain_and_id_from_woocommerce_record_name, 16 | log_and_raise_error, 17 | ) 18 | 19 | WC_ORDER_DELIMITER = "~" 20 | 21 | WC_ORDER_STATUS_MAPPING = { 22 | "Pending Payment": "pending", 23 | "On hold": "on-hold", 24 | "Failed": "failed", 25 | "Cancelled": "cancelled", 26 | "Processing": "processing", 27 | "Refunded": "refunded", 28 | "Shipped": "completed", 29 | "Ready for Pickup": "ready-pickup", 30 | "Picked up": "pickup", 31 | "Delivered": "delivered", 32 | "Processing LP": "processing-lp", 33 | "Draft": "checkout-draft", 34 | "Quote Sent": "gplsquote-req", 35 | "Trash": "trash", 36 | "Partially Shipped": "partial-shipped", 37 | } 38 | WC_ORDER_STATUS_MAPPING_REVERSE = {v: k for k, v in WC_ORDER_STATUS_MAPPING.items()} 39 | 40 | 41 | @dataclass 42 | class WooCommerceOrderAPI(WooCommerceAPI): 43 | """Class for keeping track of a WooCommerce site.""" 44 | 45 | wc_plugin_advanced_shipment_tracking: bool = False 46 | 47 | 48 | class WooCommerceOrder(WooCommerceResource): 49 | """ 50 | Virtual doctype for WooCommerce Orders 51 | """ 52 | 53 | doctype = "WooCommerce Order" 54 | resource: str = "orders" 55 | 56 | @staticmethod 57 | def _init_api() -> List[WooCommerceAPI]: 58 | """ 59 | Initialise the WooCommerce API 60 | """ 61 | wc_servers = frappe.get_all("WooCommerce Server") 62 | wc_servers = [frappe.get_doc("WooCommerce Server", server.name) for server in wc_servers] 63 | 64 | wc_api_list = [ 65 | WooCommerceOrderAPI( 66 | api=APIWithRequestLogging( 67 | url=server.woocommerce_server_url, 68 | consumer_key=server.api_consumer_key, 69 | consumer_secret=server.api_consumer_secret, 70 | version="wc/v3", 71 | timeout=40, 72 | ), 73 | woocommerce_server_url=server.woocommerce_server_url, 74 | woocommerce_server=server.name, 75 | wc_plugin_advanced_shipment_tracking=server.wc_plugin_advanced_shipment_tracking, 76 | ) 77 | for server in wc_servers 78 | if server.enable_sync == 1 79 | ] 80 | 81 | return wc_api_list 82 | 83 | # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext 84 | # nosemgrep 85 | @staticmethod 86 | def get_list(args): 87 | return WooCommerceOrder.get_list_of_records(args) 88 | 89 | def after_load_from_db(self, order: Dict): 90 | return self.get_additional_order_attributes(order) 91 | 92 | # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext 93 | # nosemgrep 94 | @staticmethod 95 | def get_count(args) -> int: 96 | return WooCommerceOrder.get_count_of_records(args) 97 | 98 | def before_db_update(self, order: Dict): 99 | # Drop all fields except for 'status', 'shipment_trackings' and 'line_items' 100 | keys_to_pop = [ 101 | key for key in order.keys() if key not in ("status", "shipment_trackings", "line_items") 102 | ] 103 | for key in keys_to_pop: 104 | order.pop(key) 105 | 106 | return order 107 | 108 | def after_db_update(self): 109 | self.update_shipment_tracking() 110 | 111 | def get_additional_order_attributes(self, order: Dict): 112 | """ 113 | Make API calls to WC to get additional order attributes, such as Tracking Data 114 | managed by an additional WooCommerce plugin 115 | """ 116 | # Verify that the WC API has been initialised 117 | if self.current_wc_api: 118 | # If the "Advanced Shipment Tracking" WooCommerce Plugin is enabled, make an additional 119 | # API call to get the tracking information 120 | if self.current_wc_api.wc_plugin_advanced_shipment_tracking: 121 | wc_server_domain, order_id = get_domain_and_id_from_woocommerce_record_name(self.name) 122 | try: 123 | order["shipment_trackings"] = self.current_wc_api.api.get( 124 | f"orders/{order_id}/shipment-trackings" 125 | ).json() 126 | 127 | # Attempt to fix broken date in date_shipped field from /shipment-trackings endpoint 128 | if "meta_data" in order: 129 | shipment_trackings_meta_data = next( 130 | ( 131 | entry 132 | for entry in json.loads(order["meta_data"]) 133 | if entry["key"] == "_wc_shipment_tracking_items" 134 | ), 135 | None, 136 | ) 137 | if shipment_trackings_meta_data: 138 | for shipment_tracking in order["shipment_trackings"]: 139 | shipment_tracking_meta_data = next( 140 | ( 141 | entry 142 | for entry in shipment_trackings_meta_data["value"] 143 | if entry["tracking_id"] == shipment_tracking["tracking_id"] 144 | ), 145 | None, 146 | ) 147 | if shipment_tracking_meta_data: 148 | date_shipped = datetime.fromtimestamp(int(shipment_tracking_meta_data["date_shipped"])) 149 | shipment_tracking["date_shipped"] = date_shipped.strftime("%Y-%m-%d") 150 | 151 | order["shipment_trackings"] = json.dumps(order["shipment_trackings"]) 152 | 153 | except Exception as err: 154 | log_and_raise_error(err) 155 | 156 | return order 157 | 158 | def update_shipment_tracking(self): 159 | """ 160 | Handle fields from "Advanced Shipment Tracking" WooCommerce Plugin 161 | Replace the current shipment_trackings with shipment_tracking. 162 | 163 | See https://docs.zorem.com/docs/ast-free/add-tracking-to-orders/shipment-tracking-api/#shipment-tracking-properties 164 | """ 165 | # Verify that the WC API has been initialised 166 | if not self.wc_api_list: 167 | self.init_api() 168 | 169 | # Parse the server domain and order_id from the Document name 170 | wc_server_domain, order_id = get_domain_and_id_from_woocommerce_record_name(self.name) 171 | 172 | # Select the relevant WooCommerce server 173 | self.current_wc_api = next( 174 | (api for api in self.wc_api_list if wc_server_domain in api.woocommerce_server_url), None 175 | ) 176 | 177 | if self.current_wc_api.wc_plugin_advanced_shipment_tracking and self.shipment_trackings: 178 | 179 | # Verify if the 'shipment_trackings' field changed 180 | if self.shipment_trackings != self._doc_before_save.shipment_trackings: 181 | # Parse JSON 182 | new_shipment_tracking = json.loads(self.shipment_trackings) 183 | 184 | # Remove the tracking_id key-value pair 185 | for item in new_shipment_tracking: 186 | if "tracking_id" in item: 187 | item.pop("tracking_id") 188 | 189 | # Only the first shipment_tracking will be used 190 | tracking_info = new_shipment_tracking[0] 191 | tracking_info["replace_tracking"] = 1 192 | 193 | # Make the API Call 194 | try: 195 | response = self.current_wc_api.api.post( 196 | f"orders/{order_id}/shipment-trackings/", data=tracking_info 197 | ) 198 | except Exception as err: 199 | log_and_raise_error(err, error_text="update_shipment_tracking failed") 200 | if response.status_code != 201: 201 | log_and_raise_error(error_text="update_shipment_tracking failed", response=response) 202 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_product/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_product/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_product/test_woocommerce_product.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestWooCommerceProduct(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_product/woocommerce_product.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, Dirk van der Laarse and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("WooCommerce Product", { 5 | refresh(frm) { 6 | // Add a custom button to sync this WooCommerce order to a Sales Order 7 | frm.add_custom_button(__("Sync this Item to ERPNext"), function () { 8 | frm.trigger("sync_product"); 9 | }, __('Actions')); 10 | 11 | // Set intro text 12 | const intro_txt = __( 13 | "Note: This is a Virtual Document. Saving changes on this document will update this resource on WooCommerce." 14 | ); 15 | frm.set_intro(intro_txt, "orange"); 16 | }, 17 | sync_product: function(frm) { 18 | // Sync this WooCommerce Product 19 | frappe.dom.freeze(__("Sync Product with ERPNext...")); 20 | frappe.call({ 21 | method: "woocommerce_fusion.tasks.sync_items.run_item_sync", 22 | args: { 23 | woocommerce_product_name: frm.doc.name 24 | }, 25 | callback: function(r) { 26 | console.log(r); 27 | frappe.dom.unfreeze(); 28 | frappe.show_alert({ 29 | message:__('Sync completed successfully'), 30 | indicator:'green' 31 | }, 5); 32 | frm.reload_doc(); 33 | }, 34 | error: (r) => { 35 | frappe.dom.unfreeze(); 36 | frappe.show_alert({ 37 | message: __('There was an error processing the request. See Error Log.'), 38 | indicator: 'red' 39 | }, 5); 40 | } 41 | }); 42 | }, 43 | }); 44 | 45 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_product/woocommerce_product.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse and contributors 2 | # For license information, please see license.txt 3 | 4 | import json 5 | from dataclasses import dataclass 6 | from typing import Dict 7 | 8 | from woocommerce_fusion.woocommerce.woocommerce_api import WooCommerceAPI, WooCommerceResource 9 | 10 | 11 | @dataclass 12 | class WooCommerceProductAPI(WooCommerceAPI): 13 | """Class for keeping track of a WooCommerce site.""" 14 | 15 | pass 16 | 17 | 18 | class WooCommerceProduct(WooCommerceResource): 19 | """ 20 | Virtual doctype for WooCommerce Products 21 | """ 22 | 23 | doctype = "WooCommerce Product" 24 | resource: str = "products" 25 | child_resource: str = "variations" 26 | field_setter_map = {"woocommerce_name": "name", "woocommerce_id": "id"} 27 | 28 | # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext 29 | # nosemgrep 30 | @staticmethod 31 | def get_list(args): 32 | products = WooCommerceProduct.get_list_of_records(args) 33 | 34 | # Extend the list with product variants 35 | products_with_variants = [ 36 | (product.get("id"), product.get("woocommerce_name")) 37 | for product in products 38 | if product.get("type") == "variable" 39 | ] 40 | for id, woocommerce_name in products_with_variants: 41 | args["endpoint"] = f"products/{id}/variations" 42 | args["metadata"] = {"parent_woocommerce_name": woocommerce_name} 43 | variants = WooCommerceProduct.get_list_of_records(args) 44 | products.extend(variants) 45 | 46 | return products 47 | 48 | def after_load_from_db(self, product: Dict): 49 | product.pop("name") 50 | product = self.set_title(product) 51 | return product 52 | 53 | @classmethod 54 | def during_get_list_of_records(cls, product: Dict, args): 55 | # In the case of variations 56 | if product["parent_id"]: 57 | # Woocommerce product variantions endpoint results doesn't return the type, so set it manually 58 | product["type"] = "variation" 59 | 60 | if variation_name := cls.get_variation_name(product, args): 61 | # Set the name in args, for use by set_title() 62 | args["metadata"]["woocommerce_name"] = variation_name 63 | 64 | # Override the woocommerce_name field 65 | product = cls.override_woocommerce_name(product, variation_name) 66 | 67 | product = cls.set_title(product, args) 68 | return product 69 | 70 | @staticmethod 71 | def set_title(product: dict, args=None): 72 | if ( 73 | args and (metadata := args.get("metadata")) and (set_name := metadata.get("woocommerce_name")) 74 | ): 75 | product["title"] = set_name 76 | elif wc_name := product.get("woocommerce_name"): 77 | if sku := product.get("sku"): 78 | product["title"] = f"{sku} - {wc_name}" 79 | else: 80 | product["title"] = wc_name 81 | else: 82 | product["title"] = product["woocommerce_id"] 83 | 84 | return product 85 | 86 | @staticmethod 87 | def override_woocommerce_name(product: Dict, name: str): 88 | product["woocommerce_name"] = name 89 | return product 90 | 91 | @staticmethod 92 | def get_variation_name(product: Dict, args): 93 | # If this is a variation, we expect the variation's parent name in the metadata, then we can 94 | # build an item name in the format of {parent_name}, {attribute 1}, {attribute n} 95 | if ( 96 | (product["type"] == "variation") 97 | and (metadata := args.get("metadata")) 98 | and (attributes := product.get("attributes")) 99 | and (parent_wc_name := metadata.get("parent_woocommerce_name")) 100 | ): 101 | attr_values = [attr["option"] for attr in json.loads(attributes)] 102 | return parent_wc_name + " - " + ", ".join(attr_values) 103 | return None 104 | 105 | # use "args" despite frappe-semgrep-rules.rules.overusing-args, following convention in ERPNext 106 | # nosemgrep 107 | @staticmethod 108 | def get_count(args) -> int: 109 | return WooCommerceProduct.get_count_of_records(args) 110 | 111 | def before_db_insert(self, product: Dict): 112 | return self.clean_up_product_before_write(product) 113 | 114 | def before_db_update(self, product: Dict): 115 | return self.clean_up_product_before_write(product) 116 | 117 | def after_db_update(self): 118 | pass 119 | 120 | @staticmethod 121 | def clean_up_product_before_write(product): 122 | """ 123 | Perform some tasks to make sure that an product is in the correct format for the WC API 124 | """ 125 | 126 | # Convert back to string 127 | product["weight"] = str(product["weight"]) 128 | product["regular_price"] = str(product["regular_price"]) 129 | 130 | # Do not post Sale Price if it is 0 131 | if product["sale_price"] and float(product["sale_price"]) > 0: 132 | product["sale_price"] = str(product["sale_price"]) 133 | else: 134 | product.pop("sale_price") 135 | 136 | # Set corrected properties 137 | product["name"] = str(product["woocommerce_name"]) 138 | 139 | # Drop 'related_ids' field 140 | product.pop("related_ids") 141 | 142 | return product 143 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/test_woocommerce_request_log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestWooCommerceRequestLog(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/woocommerce_request_log.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Dirk van der Laarse and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WooCommerce Request Log', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/woocommerce_request_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "hash", 4 | "creation": "2023-12-06 10:24:01.125453", 5 | "default_view": "List", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "user", 11 | "params", 12 | "status", 13 | "time_elapsed", 14 | "data", 15 | "column_break_tkth5", 16 | "url", 17 | "method", 18 | "endpoint", 19 | "response", 20 | "error" 21 | ], 22 | "fields": [ 23 | { 24 | "fieldname": "user", 25 | "fieldtype": "Link", 26 | "in_list_view": 1, 27 | "label": "User", 28 | "options": "User", 29 | "read_only": 1 30 | }, 31 | { 32 | "fieldname": "data", 33 | "fieldtype": "Code", 34 | "label": "Data", 35 | "options": "JSON", 36 | "read_only": 1 37 | }, 38 | { 39 | "fieldname": "column_break_tkth5", 40 | "fieldtype": "Column Break" 41 | }, 42 | { 43 | "fieldname": "url", 44 | "fieldtype": "Long Text", 45 | "label": "URL", 46 | "read_only": 1 47 | }, 48 | { 49 | "fieldname": "response", 50 | "fieldtype": "Code", 51 | "label": "Response", 52 | "options": "JSON", 53 | "read_only": 1 54 | }, 55 | { 56 | "fieldname": "error", 57 | "fieldtype": "Text", 58 | "label": "Error", 59 | "read_only": 1 60 | }, 61 | { 62 | "fieldname": "params", 63 | "fieldtype": "Code", 64 | "label": "Parameters", 65 | "options": "JSON", 66 | "read_only": 1 67 | }, 68 | { 69 | "fieldname": "status", 70 | "fieldtype": "Select", 71 | "in_list_view": 1, 72 | "in_standard_filter": 1, 73 | "label": "Status", 74 | "options": "Success\nError", 75 | "read_only": 1 76 | }, 77 | { 78 | "fieldname": "endpoint", 79 | "fieldtype": "Data", 80 | "in_list_view": 1, 81 | "in_standard_filter": 1, 82 | "label": "Endpoint", 83 | "read_only": 1 84 | }, 85 | { 86 | "fieldname": "method", 87 | "fieldtype": "Data", 88 | "in_list_view": 1, 89 | "in_standard_filter": 1, 90 | "label": "Method", 91 | "read_only": 1 92 | }, 93 | { 94 | "fieldname": "time_elapsed", 95 | "fieldtype": "Duration", 96 | "label": "Time Elapsed", 97 | "read_only": 1 98 | } 99 | ], 100 | "grid_page_length": 50, 101 | "in_create": 1, 102 | "index_web_pages_for_search": 1, 103 | "links": [], 104 | "modified": "2025-05-04 16:39:42.630595", 105 | "modified_by": "Administrator", 106 | "module": "WooCommerce", 107 | "name": "WooCommerce Request Log", 108 | "naming_rule": "Random", 109 | "owner": "Administrator", 110 | "permissions": [ 111 | { 112 | "create": 1, 113 | "delete": 1, 114 | "email": 1, 115 | "export": 1, 116 | "print": 1, 117 | "read": 1, 118 | "report": 1, 119 | "role": "System Manager", 120 | "share": 1, 121 | "write": 1 122 | } 123 | ], 124 | "row_format": "Dynamic", 125 | "sort_field": "modified", 126 | "sort_order": "DESC", 127 | "states": [], 128 | "track_changes": 1 129 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_request_log/woocommerce_request_log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse 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 WooCommerceRequestLog(Document): 9 | @staticmethod 10 | def clear_old_logs(days=7): 11 | from frappe.query_builder import Interval 12 | from frappe.query_builder.functions import Now 13 | 14 | table = frappe.qb.DocType("WooCommerce Request Log") 15 | frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) 16 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server/test_woocommerce_server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestWooCommerceServer(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server/woocommerce_server.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Dirk van der Laarse and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WooCommerce Server', { 5 | refresh: function(frm) { 6 | // Only list enabled warehouses 7 | frm.fields_dict.warehouses.get_query = function (doc) { 8 | return { 9 | filters: { 10 | disabled: 0, 11 | is_group: 0 12 | } 13 | }; 14 | } 15 | 16 | // Set the Options for erpnext_field_name field on 'Items' > 'Fields Mapping' child table 17 | frappe.call({ 18 | method: "get_item_docfields", 19 | doc: frm.doc, 20 | args: { 21 | "doctype": "Item" 22 | }, 23 | callback: function(r) { 24 | // Sort the array of objects alphabetically by the label property 25 | r.message.sort((a, b) => { 26 | const labelA = a.label || ""; 27 | const labelB = b.label || ""; 28 | return labelA.localeCompare(labelB); 29 | }); 30 | 31 | // Use map to create an array of strings in the desired format 32 | const formattedStrings = r.message.map(fields => `${fields.fieldname} | ${fields.label}`); 33 | 34 | // Join the strings with newline characters to create the final string 35 | const options = formattedStrings.join('\n'); 36 | 37 | // Set the Options property 38 | frm.fields_dict.item_field_map.grid.update_docfield_property( 39 | "erpnext_field_name", 40 | "options", 41 | options 42 | ); 43 | } 44 | }); 45 | 46 | // Set the Options for order_line_item_field_map field on 'Sales Orders' > 'Fields Mapping' child table 47 | frappe.call({ 48 | method: "get_item_docfields", 49 | doc: frm.doc, 50 | args: { 51 | "doctype": "Sales Order Item" 52 | }, 53 | callback: function(r) { 54 | // Sort the array of objects alphabetically by the label property 55 | r.message.sort((a, b) => { 56 | const labelA = a.label || ""; 57 | const labelB = b.label || ""; 58 | return labelA.localeCompare(labelB); 59 | }); 60 | 61 | // Use map to create an array of strings in the desired format 62 | const formattedStrings = r.message.map(fields => `${fields.fieldname} | ${fields.label}`); 63 | 64 | // Join the strings with newline characters to create the final string 65 | const options = formattedStrings.join('\n'); 66 | 67 | // Set the Options property 68 | frm.fields_dict.order_line_item_field_map.grid.update_docfield_property( 69 | "erpnext_field_name", 70 | "options", 71 | options 72 | ); 73 | } 74 | }); 75 | 76 | if (frm.doc.enable_so_status_sync && !frm.fields_dict.sales_order_status_map.grid.get_docfield("woocommerce_sales_order_status").options) { 77 | frm.trigger('get_woocommerce_order_status_list'); 78 | } 79 | 80 | // Set Options field for 'Sales Order Status Sync' section 81 | warningHTML = ` 82 | <div class="form-message red"> 83 | <div> 84 | ${__("This setting is Experimental. Monitor your Error Log after enabling this setting")} 85 | </div> 86 | </div> 87 | ` 88 | frm.set_df_property('enable_so_status_sync_warning_html', 'options', warningHTML); 89 | frm.refresh_field('enable_so_status_sync_warning_html'); 90 | }, 91 | // Handle click of 'Keep the Status of ERPNext Sales Orders and WooCommerce Orders in sync' 92 | enable_so_status_sync: function(frm){ 93 | if (frm.doc.enable_so_status_sync && !frm.fields_dict.sales_order_status_map.grid.get_docfield("woocommerce_sales_order_status").options){ 94 | frm.trigger('get_woocommerce_order_status_list'); 95 | } 96 | }, 97 | // Retrieve WooCommerce order statuses 98 | get_woocommerce_order_status_list: function(frm){ 99 | frappe.call({ 100 | method: "get_woocommerce_order_status_list", 101 | doc: frm.doc, 102 | callback: function(r) { 103 | // Join the strings with newline characters to create the final string 104 | const options = r.message.join('\n'); 105 | 106 | // Set the Options property 107 | frm.fields_dict.sales_order_status_map.grid.update_docfield_property( 108 | "woocommerce_sales_order_status", 109 | "options", 110 | options 111 | ); 112 | } 113 | }); 114 | }, 115 | // View WooCommerce Webhook Configuration 116 | view_webhook_config: function(frm) { 117 | let d = new frappe.ui.Dialog({ 118 | title: __('WooCommerce Webhook Settings'), 119 | fields: [ 120 | { 121 | label: __('Status'), 122 | fieldname: 'status', 123 | fieldtype: 'Data', 124 | default: 'Active', 125 | read_only: 1 126 | }, 127 | { 128 | label: __('Topic'), 129 | fieldname: 'topic', 130 | fieldtype: 'Data', 131 | default: 'Order created', 132 | read_only: 1 133 | }, 134 | { 135 | label: __('Delivery URL'), 136 | fieldname: 'url', 137 | fieldtype: 'Data', 138 | default: '<site url here>/api/method/woocommerce_fusion.woocommerce_endpoint.order_created', 139 | read_only: 1 140 | }, 141 | { 142 | label: __('Secret'), 143 | fieldname: 'secret', 144 | fieldtype: 'Code', 145 | default: frm.doc.secret, 146 | read_only: 1 147 | }, 148 | { 149 | label: __('API Version'), 150 | fieldname: 'api_version', 151 | fieldtype: 'Data', 152 | default: 'WP REST API Integration v3', 153 | read_only: 1 154 | } 155 | ], 156 | size: 'large', // small, large, extra-large 157 | primary_action_label: __('OK'), 158 | primary_action(values) { 159 | d.hide(); 160 | } 161 | }); 162 | 163 | d.show(); 164 | 165 | } 166 | }); 167 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server/woocommerce_server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Dirk van der Laarse and contributors 2 | # For license information, please see license.txt 3 | 4 | from typing import List 5 | from urllib.parse import urlparse 6 | 7 | import frappe 8 | from frappe import _ 9 | from frappe.model.document import Document 10 | from frappe.utils.caching import redis_cache 11 | from jsonpath_ng.ext import parse 12 | from woocommerce import API 13 | 14 | from woocommerce_fusion.woocommerce.doctype.woocommerce_order.woocommerce_order import ( 15 | WC_ORDER_STATUS_MAPPING, 16 | ) 17 | from woocommerce_fusion.woocommerce.woocommerce_api import parse_domain_from_url 18 | 19 | 20 | class WooCommerceServer(Document): 21 | def autoname(self): 22 | """ 23 | Derive name from woocommerce_server_url field 24 | """ 25 | self.name = parse_domain_from_url(self.woocommerce_server_url) 26 | 27 | def validate(self): 28 | # Validate URL 29 | result = urlparse(self.woocommerce_server_url) 30 | if not all([result.scheme, result.netloc]): 31 | frappe.throw(_("Please enter a valid WooCommerce Server URL")) 32 | 33 | # Get Shipment Providers if the "Advanced Shipment Tracking" woocommerce plugin is used 34 | if self.enable_sync and self.wc_plugin_advanced_shipment_tracking: 35 | self.get_shipment_providers() 36 | 37 | if not self.secret: 38 | self.secret = frappe.generate_hash() 39 | 40 | self.validate_so_status_map() 41 | self.validate_item_map() 42 | self.validate_reserved_stock_setting() 43 | 44 | def validate_so_status_map(self): 45 | """ 46 | Validate Sales Order Status Map to have unique mappings 47 | """ 48 | erpnext_so_statuses = [map.erpnext_sales_order_status for map in self.sales_order_status_map] 49 | if len(erpnext_so_statuses) != len(set(erpnext_so_statuses)): 50 | frappe.throw(_("Duplicate ERPNext Sales Order Statuses found in Sales Order Status Map")) 51 | wc_so_statuses = [map.woocommerce_sales_order_status for map in self.sales_order_status_map] 52 | if len(wc_so_statuses) != len(set(wc_so_statuses)): 53 | frappe.throw(_("Duplicate WooCommerce Sales Order Statuses found in Sales Order Status Map")) 54 | 55 | def validate_item_map(self): 56 | """ 57 | Validate Item Map to have valid JSONPath expressions 58 | """ 59 | disallowed_fields = ["attributes"] 60 | 61 | # If the built-in image sync is enabled, disallow the image field in the item field map to avoid unexpected behavior 62 | if self.enable_image_sync: 63 | disallowed_fields.append("images") 64 | 65 | if self.item_field_map: 66 | for map in self.item_field_map: 67 | jsonpath_expr = map.woocommerce_field_name 68 | try: 69 | parse(jsonpath_expr) 70 | except Exception as e: 71 | frappe.throw( 72 | _("Invalid JSONPath syntax in Item Field Map Row {0}:<br><br><pre>{1}</pre>").format( 73 | map.idx, e 74 | ) 75 | ) 76 | 77 | for field in disallowed_fields: 78 | if field in jsonpath_expr: 79 | frappe.throw(_("Field '{0}' is not allowed in JSONPath expression").format(field)) 80 | 81 | def validate_reserved_stock_setting(self): 82 | """ 83 | If 'Reserved Stock Adjustment' is enabled, make sure that 'Reserve Stock' in ERPNext is enabled 84 | """ 85 | if self.subtract_reserved_stock: 86 | if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): 87 | frappe.throw( 88 | _( 89 | "In order to enable 'Reserved Stock Adjustment', please enable 'Enable Stock Reservation' in 'ERPNext > Stock Settings > Stock Reservation'" 90 | ) 91 | ) 92 | 93 | def get_shipment_providers(self): 94 | """ 95 | Fetches the names of all shipment providers from a given WooCommerce server. 96 | 97 | This function uses the WooCommerce API to get a list of shipment tracking 98 | providers. If the request is successful and providers are found, the function 99 | returns a newline-separated string of all provider names. 100 | """ 101 | 102 | wc_api = API( 103 | url=self.woocommerce_server_url, 104 | consumer_key=self.api_consumer_key, 105 | consumer_secret=self.api_consumer_secret, 106 | version="wc/v3", 107 | timeout=40, 108 | ) 109 | all_providers = wc_api.get("orders/1/shipment-trackings/providers").json() 110 | if all_providers: 111 | provider_names = [provider for country in all_providers for provider in all_providers[country]] 112 | self.wc_ast_shipment_providers = "\n".join(provider_names) 113 | 114 | @frappe.whitelist() 115 | @redis_cache(ttl=600) 116 | def get_item_docfields(self, doctype: str) -> List[dict]: 117 | """ 118 | Get a list of DocFields for the Item Doctype 119 | """ 120 | invalid_field_types = [ 121 | "Column Break", 122 | "Fold", 123 | "Heading", 124 | "Read Only", 125 | "Section Break", 126 | "Tab Break", 127 | "Table", 128 | "Table MultiSelect", 129 | ] 130 | docfields = frappe.get_all( 131 | "DocField", 132 | fields=["label", "name", "fieldname"], 133 | filters=[["fieldtype", "not in", invalid_field_types], ["parent", "=", doctype]], 134 | ) 135 | custom_fields = frappe.get_all( 136 | "Custom Field", 137 | fields=["label", "name", "fieldname"], 138 | filters=[["fieldtype", "not in", invalid_field_types], ["dt", "=", doctype]], 139 | ) 140 | return docfields + custom_fields 141 | 142 | @frappe.whitelist() 143 | @redis_cache(ttl=86400) 144 | def get_woocommerce_order_status_list(self) -> List[str]: 145 | """ 146 | Retrieve list of WooCommerce Order Statuses 147 | """ 148 | return [key for key in WC_ORDER_STATUS_MAPPING.keys()] 149 | 150 | 151 | @frappe.whitelist() 152 | def get_woocommerce_shipment_providers(woocommerce_server): 153 | """ 154 | Return the Shipment Providers for a given WooCommerce Server domain 155 | """ 156 | wc_server = frappe.get_cached_doc("WooCommerce Server", woocommerce_server) 157 | return wc_server.wc_ast_shipment_providers 158 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_item_field/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server_item_field/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_item_field/woocommerce_server_item_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-07-08 14:28:39.369279", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "erpnext_field_name", 10 | "column_break_kddy", 11 | "woocommerce_field_name" 12 | ], 13 | "fields": [ 14 | { 15 | "fieldname": "erpnext_field_name", 16 | "fieldtype": "Select", 17 | "in_list_view": 1, 18 | "label": "ERPNext Field Name", 19 | "reqd": 1 20 | }, 21 | { 22 | "fieldname": "column_break_kddy", 23 | "fieldtype": "Column Break" 24 | }, 25 | { 26 | "description": "e.g. <code>$.short_description</code> or <code>$.meta_data[0].value</code> where <code>$</code> refers to WooCommerce Product", 27 | "documentation_url": "https://woocommerce-fusion-docs.finfoot.tech/features/items.html#custom-fields-mapping", 28 | "fieldname": "woocommerce_field_name", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "JSONPath on WooCommerce Product", 32 | "reqd": 1 33 | } 34 | ], 35 | "index_web_pages_for_search": 1, 36 | "istable": 1, 37 | "links": [], 38 | "modified": "2025-02-07 11:44:33.375997", 39 | "modified_by": "Administrator", 40 | "module": "WooCommerce", 41 | "name": "WooCommerce Server Item Field", 42 | "owner": "Administrator", 43 | "permissions": [], 44 | "sort_field": "modified", 45 | "sort_order": "DESC", 46 | "states": [] 47 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_item_field/woocommerce_server_item_field.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse 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 WooCommerceServerItemField(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_item_field/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_item_field/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_item_field/woocommerce_server_order_item_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2025-05-05 17:30:51.984611", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "erpnext_field_name", 10 | "column_break_kddy", 11 | "woocommerce_field_name" 12 | ], 13 | "fields": [ 14 | { 15 | "fieldname": "erpnext_field_name", 16 | "fieldtype": "Select", 17 | "in_list_view": 1, 18 | "label": "ERPNext Field Name", 19 | "reqd": 1 20 | }, 21 | { 22 | "fieldname": "column_break_kddy", 23 | "fieldtype": "Column Break" 24 | }, 25 | { 26 | "description": "e.g. <code>$.short_description</code> or <code>$.meta_data[0].value</code> where <code>$</code> refers to WooCommerce Product", 27 | "documentation_url": "https://woocommerce-fusion-docs.finfoot.tech/features/sales-order.html#fields-mapping", 28 | "fieldname": "woocommerce_field_name", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "JSONPath on WooCommerce Order Line Item", 32 | "reqd": 1 33 | } 34 | ], 35 | "grid_page_length": 50, 36 | "index_web_pages_for_search": 1, 37 | "istable": 1, 38 | "links": [], 39 | "modified": "2025-05-05 17:34:42.059377", 40 | "modified_by": "Administrator", 41 | "module": "WooCommerce", 42 | "name": "WooCommerce Server Order Item Field", 43 | "owner": "Administrator", 44 | "permissions": [], 45 | "row_format": "Dynamic", 46 | "sort_field": "modified", 47 | "sort_order": "DESC", 48 | "states": [] 49 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_item_field/woocommerce_server_order_item_field.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Dirk van der Laarse 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 WooCommerceServerOrderItemField(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_status/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_status/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_status/woocommerce_server_order_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-12-17 09:24:59.507621", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "erpnext_sales_order_status", 10 | "woocommerce_sales_order_status" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "erpnext_sales_order_status", 15 | "fieldtype": "Select", 16 | "in_list_view": 1, 17 | "label": "ERPNext Sales Order Status", 18 | "options": "\nDraft\nOn Hold\nTo Deliver and Bill\nTo Bill\nTo Deliver\nCompleted\nCancelled\nClosed", 19 | "reqd": 1 20 | }, 21 | { 22 | "fieldname": "woocommerce_sales_order_status", 23 | "fieldtype": "Select", 24 | "in_list_view": 1, 25 | "label": "WooCommerce Sales Order Status", 26 | "reqd": 1 27 | } 28 | ], 29 | "index_web_pages_for_search": 1, 30 | "istable": 1, 31 | "links": [], 32 | "modified": "2024-12-17 09:50:49.483180", 33 | "modified_by": "Administrator", 34 | "module": "WooCommerce", 35 | "name": "WooCommerce Server Order Status", 36 | "owner": "Administrator", 37 | "permissions": [], 38 | "sort_field": "modified", 39 | "sort_order": "DESC", 40 | "states": [] 41 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_order_status/woocommerce_server_order_status.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse 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 WooCommerceServerOrderStatus(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_shipping_rule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server_shipping_rule/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_shipping_rule/woocommerce_server_shipping_rule.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-12-16 07:06:30.116533", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "wc_shipping_method_id", 10 | "shipping_rule" 11 | ], 12 | "fields": [ 13 | { 14 | "description": "<code>method_title</code> in <i>WooCommerce Order<i> > <i>Shipping Lines</i></i></i>", 15 | "fieldname": "wc_shipping_method_id", 16 | "fieldtype": "Data", 17 | "in_list_view": 1, 18 | "label": "WooCommerce Shipping Method Title", 19 | "reqd": 1 20 | }, 21 | { 22 | "fieldname": "shipping_rule", 23 | "fieldtype": "Link", 24 | "in_list_view": 1, 25 | "label": "Shipping Rule", 26 | "options": "Shipping Rule", 27 | "reqd": 1 28 | } 29 | ], 30 | "index_web_pages_for_search": 1, 31 | "istable": 1, 32 | "links": [], 33 | "modified": "2025-02-08 14:46:52.813670", 34 | "modified_by": "Administrator", 35 | "module": "WooCommerce", 36 | "name": "WooCommerce Server Shipping Rule", 37 | "owner": "Administrator", 38 | "permissions": [], 39 | "sort_field": "modified", 40 | "sort_order": "DESC", 41 | "states": [] 42 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_shipping_rule/woocommerce_server_shipping_rule.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse 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 WooCommerceServerShippingRule(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_warehouse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdl16/woocommerce_fusion/af471d8069f8103945d5486b0464b7ba98ece6f5/woocommerce_fusion/woocommerce/doctype/woocommerce_server_warehouse/__init__.py -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_warehouse/woocommerce_server_warehouse.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-06-20 14:39:33.217667", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "warehouse" 10 | ], 11 | "fields": [ 12 | { 13 | "fieldname": "warehouse", 14 | "fieldtype": "Link", 15 | "in_list_view": 1, 16 | "label": "Warehouse", 17 | "options": "Warehouse", 18 | "reqd": 1 19 | } 20 | ], 21 | "index_web_pages_for_search": 1, 22 | "istable": 1, 23 | "links": [], 24 | "modified": "2024-06-20 14:40:16.336919", 25 | "modified_by": "Administrator", 26 | "module": "WooCommerce", 27 | "name": "WooCommerce Server Warehouse", 28 | "owner": "Administrator", 29 | "permissions": [], 30 | "sort_field": "modified", 31 | "sort_order": "DESC", 32 | "states": [] 33 | } -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce/doctype/woocommerce_server_warehouse/woocommerce_server_warehouse.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Dirk van der Laarse 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 WooCommerceServerWarehouse(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /woocommerce_fusion/woocommerce_endpoint.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import json 5 | from http import HTTPStatus 6 | from typing import Optional, Tuple 7 | 8 | import frappe 9 | from frappe import _ 10 | from werkzeug.wrappers import Response 11 | 12 | from woocommerce_fusion.tasks.sync_sales_orders import run_sales_order_sync 13 | from woocommerce_fusion.woocommerce.woocommerce_api import ( 14 | WC_RESOURCE_DELIMITER, 15 | parse_domain_from_url, 16 | ) 17 | 18 | 19 | def validate_request() -> Tuple[bool, Optional[HTTPStatus], Optional[str]]: 20 | # Get relevant WooCommerce Server 21 | try: 22 | webhook_source_url = frappe.get_request_header("x-wc-webhook-source", "") 23 | wc_server = frappe.get_doc("WooCommerce Server", parse_domain_from_url(webhook_source_url)) 24 | except Exception: 25 | return False, HTTPStatus.BAD_REQUEST, _("Missing Header") 26 | 27 | # Validate secret 28 | sig = base64.b64encode( 29 | hmac.new(wc_server.secret.encode("utf8"), frappe.request.data, hashlib.sha256).digest() 30 | ) 31 | # if ( 32 | # frappe.request.data 33 | # and not sig == frappe.get_request_header("x-wc-webhook-signature", "").encode() 34 | # ): 35 | # return False, HTTPStatus.UNAUTHORIZED, _("Unauthorized") 36 | 37 | frappe.set_user(wc_server.creation_user) 38 | return True, None, None 39 | 40 | 41 | @frappe.whitelist(allow_guest=True, methods=["POST"]) 42 | def order_created(*args, **kwargs): 43 | """ 44 | Accepts payload data from WooCommerce "Order Created" webhook 45 | """ 46 | valid, status, msg = validate_request() 47 | if not valid: 48 | return Response(response=msg, status=status) 49 | 50 | if frappe.request and frappe.request.data: 51 | try: 52 | order = json.loads(frappe.request.data) 53 | except ValueError: 54 | # woocommerce returns 'webhook_id=value' for the first request which is not JSON 55 | order = frappe.request.data 56 | event = frappe.get_request_header("x-wc-webhook-event") 57 | else: 58 | return Response(response=_("Missing Header"), status=HTTPStatus.BAD_REQUEST) 59 | 60 | if event == "created": 61 | webhook_source_url = frappe.get_request_header("x-wc-webhook-source", "") 62 | woocommerce_order_name = ( 63 | f"{parse_domain_from_url(webhook_source_url)}{WC_RESOURCE_DELIMITER}{order['id']}" 64 | ) 65 | frappe.enqueue(run_sales_order_sync, queue="long", woocommerce_order_name=woocommerce_order_name) 66 | return Response(status=HTTPStatus.OK) 67 | else: 68 | return Response(response=_("Event not supported"), status=HTTPStatus.BAD_REQUEST) 69 | --------------------------------------------------------------------------------