├── .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: ""
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 | 
4 | [](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 | 
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 | 
12 |
13 | 
14 |
15 | ---
16 |
17 | Click on the "Sales Orders" tab and complete the mandatory fields
18 |
19 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
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', '🚚 Loading Shipments...
');
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 = `WooCommerce Shipments:
`+
133 | `Date Shipped | Provider | Tracking Number | `;
134 | frm.doc.woocommerce_shipment_trackings.forEach(tracking => {
135 | trackingsHTML += `
---|
${tracking.date_shipped} | `+
136 | `${tracking.tracking_provider} | `+
137 | `${tracking.tracking_number} |
`
138 | });
139 | trackingsHTML += `
`
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 |
79 | ${val}
80 | `
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 |
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: '/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}:
{1}
").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. $.short_description
or $.meta_data[0].value
where $
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. $.short_description
or $.meta_data[0].value
where $
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": "method_title
in WooCommerce Order > Shipping Lines",
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 |
--------------------------------------------------------------------------------