├── .editorconfig ├── .eslintrc ├── .flake8 ├── .git-blame-ignore-revs ├── .github └── workflows │ └── linters.yml ├── .gitignore ├── .pre-commit-config.yaml ├── MANIFEST.in ├── README.md ├── commitlint.config.js ├── erpnext_shipping ├── __init__.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── custom_fields.py ├── erpnext_shipping │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ ├── letmeship │ │ │ ├── __init__.py │ │ │ ├── letmeship.js │ │ │ ├── letmeship.json │ │ │ ├── letmeship.py │ │ │ └── test_letmeship.py │ │ ├── parcel_service │ │ │ ├── __init__.py │ │ │ ├── parcel_service.js │ │ │ ├── parcel_service.json │ │ │ ├── parcel_service.py │ │ │ └── test_parcel_service.py │ │ ├── parcel_service_type │ │ │ ├── __init__.py │ │ │ ├── parcel_service_type.js │ │ │ ├── parcel_service_type.json │ │ │ ├── parcel_service_type.py │ │ │ └── test_parcel_service_type.py │ │ ├── parcel_service_type_alias │ │ │ ├── __init__.py │ │ │ ├── parcel_service_type_alias.json │ │ │ └── parcel_service_type_alias.py │ │ └── sendcloud │ │ │ ├── __init__.py │ │ │ ├── sendcloud.js │ │ │ ├── sendcloud.json │ │ │ ├── sendcloud.py │ │ │ └── test_sendcloud.py │ ├── patches │ │ ├── change_tracking_url_column_type.py │ │ └── create_custom_delivery_note_fields.py │ ├── shipping.py │ └── utils.py ├── hooks.py ├── install.py ├── modules.txt ├── patches.txt ├── property_setters.py ├── public │ ├── js │ │ ├── shipment.js │ │ └── shipment_service_selector.html │ └── shipping.bundle.js ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── translations │ └── de.csv └── utils.py ├── license.txt ├── pyproject.toml └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js,*.vue,*.css,*.scss,*.html}] 13 | indent_style = tab 14 | indent_size = 4 15 | max_line_length = 99 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 11, 9 | "sourceType": "module" 10 | }, 11 | "extends": "eslint:recommended", 12 | "rules": { 13 | "indent": [ 14 | "error", 15 | "tab", 16 | { "SwitchCase": 1 } 17 | ], 18 | "brace-style": [ 19 | "error", 20 | "1tbs" 21 | ], 22 | "space-unary-ops": [ 23 | "error", 24 | { "words": true } 25 | ], 26 | "linebreak-style": [ 27 | "error", 28 | "unix" 29 | ], 30 | "quotes": [ 31 | "off" 32 | ], 33 | "semi": [ 34 | "warn", 35 | "always" 36 | ], 37 | "camelcase": [ 38 | "off" 39 | ], 40 | "no-unused-vars": [ 41 | "warn" 42 | ], 43 | "no-redeclare": [ 44 | "warn" 45 | ], 46 | "no-console": [ 47 | "warn" 48 | ], 49 | "no-extra-boolean-cast": [ 50 | "off" 51 | ], 52 | "no-control-regex": [ 53 | "off" 54 | ], 55 | "space-before-blocks": "warn", 56 | "keyword-spacing": "warn", 57 | "comma-spacing": "warn", 58 | "key-spacing": "warn", 59 | }, 60 | "root": true, 61 | "globals": { 62 | "frappe": true, 63 | "Vue": true, 64 | "__": true, 65 | "repl": true, 66 | "Class": true, 67 | "locals": true, 68 | "cint": true, 69 | "cstr": true, 70 | "cur_frm": true, 71 | "cur_dialog": true, 72 | "cur_page": true, 73 | "cur_list": true, 74 | "cur_tree": true, 75 | "msg_dialog": true, 76 | "is_null": true, 77 | "in_list": true, 78 | "has_common": true, 79 | "has_words": true, 80 | "validate_email": true, 81 | "validate_name": true, 82 | "validate_phone": true, 83 | "validate_url": true, 84 | "get_number_format": true, 85 | "format_number": true, 86 | "format_currency": true, 87 | "comment_when": true, 88 | "open_url_post": true, 89 | "toTitle": true, 90 | "lstrip": true, 91 | "rstrip": true, 92 | "strip": true, 93 | "strip_html": true, 94 | "replace_all": true, 95 | "flt": true, 96 | "precision": true, 97 | "CREATE": true, 98 | "AMEND": true, 99 | "CANCEL": true, 100 | "copy_dict": true, 101 | "get_number_format_info": true, 102 | "strip_number_groups": true, 103 | "print_table": true, 104 | "Layout": true, 105 | "web_form_settings": true, 106 | "$c": true, 107 | "$a": true, 108 | "$i": true, 109 | "$bg": true, 110 | "$y": true, 111 | "$c_obj": true, 112 | "refresh_many": true, 113 | "refresh_field": true, 114 | "toggle_field": true, 115 | "get_field_obj": true, 116 | "get_query_params": true, 117 | "unhide_field": true, 118 | "hide_field": true, 119 | "set_field_options": true, 120 | "getCookie": true, 121 | "getCookies": true, 122 | "get_url_arg": true, 123 | "md5": true, 124 | "$": true, 125 | "jQuery": true, 126 | "moment": true, 127 | "hljs": true, 128 | "Awesomplete": true, 129 | "Sortable": true, 130 | "Showdown": true, 131 | "Taggle": true, 132 | "Gantt": true, 133 | "Slick": true, 134 | "Webcam": true, 135 | "PhotoSwipe": true, 136 | "PhotoSwipeUI_Default": true, 137 | "io": true, 138 | "JsBarcode": true, 139 | "L": true, 140 | "Chart": true, 141 | "DataTable": true, 142 | "Cypress": true, 143 | "cy": true, 144 | "it": true, 145 | "describe": true, 146 | "expect": true, 147 | "context": true, 148 | "before": true, 149 | "beforeEach": true, 150 | "after": true, 151 | "qz": true, 152 | "localforage": true, 153 | "extend_cscript": true 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | B001, 4 | B007, 5 | B009, 6 | B010, 7 | B950, 8 | E101, 9 | E111, 10 | E114, 11 | E116, 12 | E117, 13 | E121, 14 | E122, 15 | E123, 16 | E124, 17 | E125, 18 | E126, 19 | E127, 20 | E128, 21 | E131, 22 | E201, 23 | E202, 24 | E203, 25 | E211, 26 | E221, 27 | E222, 28 | E223, 29 | E224, 30 | E225, 31 | E226, 32 | E228, 33 | E231, 34 | E241, 35 | E242, 36 | E251, 37 | E261, 38 | E262, 39 | E265, 40 | E266, 41 | E271, 42 | E272, 43 | E273, 44 | E274, 45 | E301, 46 | E302, 47 | E303, 48 | E305, 49 | E306, 50 | E402, 51 | E501, 52 | E502, 53 | E701, 54 | E702, 55 | E703, 56 | E741, 57 | F401, 58 | F403, 59 | F405, 60 | W191, 61 | W291, 62 | W292, 63 | W293, 64 | W391, 65 | W503, 66 | W504, 67 | E711, 68 | E129, 69 | F841, 70 | E713, 71 | E712, 72 | B028, 73 | W604, 74 | 75 | max-line-length = 200 76 | exclude=,test_*.py 77 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Since version 2.23 (released in August 2019), git-blame has a feature 2 | # to ignore or bypass certain commits. 3 | # 4 | # This file contains a list of commits that are not likely what you 5 | # are looking for in a blame, such as mass reformatting or renaming. 6 | # You can set this file as a default ignore file for blame by running 7 | # the following command. 8 | # 9 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 10 | 11 | # bulk format python files 12 | 067d3ee8561507a0755dde90a1aaf51c3e4db72a 13 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | push: 7 | branches: [ version-14 ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: commitcheck-erpnext_shipping-${{ github.event.number }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | commit-lint: 18 | name: 'Semantic Commits' 19 | runs-on: ubuntu-latest 20 | if: github.event_name == 'pull_request' 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 200 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 16 29 | check-latest: true 30 | 31 | - name: Check commit titles 32 | run: | 33 | npm install @commitlint/cli @commitlint/config-conventional 34 | npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} 35 | 36 | linter: 37 | name: 'Frappe Linter' 38 | runs-on: ubuntu-latest 39 | if: github.event_name == 'pull_request' 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - uses: actions/setup-python@v4 44 | with: 45 | python-version: '3.10' 46 | - uses: pre-commit/action@v3.0.0 47 | 48 | - name: Download Semgrep rules 49 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 50 | 51 | - name: Run Semgrep rules 52 | run: | 53 | pip install semgrep 54 | semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness 55 | 56 | deps-vulnerable-check: 57 | name: 'Vulnerable Dependency Check' 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - uses: actions/setup-python@v4 62 | with: 63 | python-version: '3.10' 64 | 65 | - uses: actions/checkout@v3 66 | 67 | - name: Cache pip 68 | uses: actions/cache@v3 69 | with: 70 | path: ~/.cache/pip 71 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} 72 | restore-keys: | 73 | ${{ runner.os }}-pip- 74 | ${{ runner.os }}- 75 | 76 | - run: | 77 | pip install pip-audit 78 | cd ${GITHUB_WORKSPACE} 79 | pip-audit --desc on --ignore-vuln GHSA-4xqq-73wg-5mjp . 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | __pycache__ 4 | *.egg-info 5 | *.swp 6 | tags 7 | erpnext_shipping/docs/current 8 | erpnext_shipping/public/dist 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'node_modules|.git' 2 | default_stages: [pre-commit] 3 | fail_fast: false 4 | 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.3.0 9 | hooks: 10 | - id: trailing-whitespace 11 | files: "frappe.*" 12 | exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" 13 | - id: check-yaml 14 | - id: no-commit-to-branch 15 | args: ['--branch', 'develop'] 16 | - id: check-merge-conflict 17 | - id: check-ast 18 | - id: check-json 19 | - id: check-toml 20 | - id: check-yaml 21 | - id: debug-statements 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: v2.34.0 25 | hooks: 26 | - id: pyupgrade 27 | args: ['--py310-plus'] 28 | 29 | - repo: https://github.com/astral-sh/ruff-pre-commit 30 | rev: v0.2.0 31 | hooks: 32 | - id: ruff 33 | name: "Sort Python imports" 34 | args: ["--select", "I", "--fix"] 35 | 36 | - id: ruff-format 37 | name: "Format Python code" 38 | 39 | - repo: https://github.com/pre-commit/mirrors-prettier 40 | rev: v2.7.1 41 | hooks: 42 | - id: prettier 43 | types_or: [javascript, vue, scss] 44 | # Ignore any files that might contain jinja / bundles 45 | exclude: | 46 | (?x)^( 47 | erpnext_shipping/public/dist/.* 48 | )$ 49 | 50 | - repo: https://github.com/PyCQA/flake8 51 | rev: 5.0.4 52 | hooks: 53 | - id: flake8 54 | additional_dependencies: ['flake8-bugbear',] 55 | 56 | ci: 57 | autoupdate_schedule: weekly 58 | skip: [] 59 | submodules: false 60 | -------------------------------------------------------------------------------- /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 erpnext_shipping *.css 8 | recursive-include erpnext_shipping *.csv 9 | recursive-include erpnext_shipping *.html 10 | recursive-include erpnext_shipping *.ico 11 | recursive-include erpnext_shipping *.js 12 | recursive-include erpnext_shipping *.json 13 | recursive-include erpnext_shipping *.md 14 | recursive-include erpnext_shipping *.png 15 | recursive-include erpnext_shipping *.py 16 | recursive-include erpnext_shipping *.svg 17 | recursive-include erpnext_shipping *.txt 18 | recursive-exclude erpnext_shipping *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ERPNext Shipping 2 | 3 | A Shipping Integration for ERPNext with various platforms. Platforms integrated in this app are: 4 | 5 | - [LetMeShip](https://www.letmeship.com/en/) 6 | - [SendCloud](https://www.sendcloud.com/home-new/) 7 | 8 | > [!TIP] 9 | > Please make sure to get your API access enabled first, by contacting the LetMeShip support. 10 | 11 | ## Features 12 | - Creation of shipment to a carrier service (e.g. FedEx, UPS) via LetMeShip and SendCloud. 13 | - Compare shipping rates. 14 | - Printing the shipping label is also made available within the Shipment doctype. 15 | - Templates for the parcel dimensions. 16 | - Shipment tracking. 17 | 18 | ## Installation 19 | 20 | Install [on Frappe Cloud](https://frappecloud.com/marketplace/apps/shipping) or your own server: 21 | 22 | ```bash 23 | cd ~/frappe-bench 24 | bench get-app https://github.com/frappe/erpnext-shipping.git --branch version-14 25 | bench --site $MY_SITE install-app erpnext_shipping 26 | ``` 27 | 28 | ## Setup 29 | 30 | Some shipping providers require the contact details of the pickup contact. Please make sure that the **User** selected as the _Pickup Contact Person_ has a first name, last name, email address, and phone number before submitting the **Shipment**. 31 | 32 | For the 'compare shipping rates' feature to work as expected, you need to generate an API key from your service provider. Service providers have their own specific doctypes similar to those from the `Integrations`. They can be enabled or disabled depending on your needs. 33 | 34 | ![LetMeShip 2020-08-05 09-54-28](https://user-images.githubusercontent.com/17470909/89377411-500c4f80-d724-11ea-8fe5-b11fec2a5c27.png) 35 | 36 | ### Fetch Shipping Rates 37 | ![core2](https://user-images.githubusercontent.com/17470909/89377460-70d4a500-d724-11ea-8550-a2813b936651.gif) 38 | 39 | You can see the list of shipping rates by clicking the `Fetch Shipping Rates` button. Once you picked a rate, it will create the shipment for you. 40 | 41 | ### Shipping Label 42 | ![71bcfc9d-9d66-4a58-8238-1eeab4e9a24f 2020-08-05 09-48-32](https://user-images.githubusercontent.com/17470909/89377478-78944980-d724-11ea-8120-a5374c6e4c5e.png) 43 | 44 | The service provider will also provide the shipping label and to generate the label, click on the `Print Shipping Label` on top of the doctype. 45 | 46 | ----------------------- 47 | #### License 48 | 49 | MIT 50 | -------------------------------------------------------------------------------- /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 | }; 26 | -------------------------------------------------------------------------------- /erpnext_shipping/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "15.0.3" 2 | -------------------------------------------------------------------------------- /erpnext_shipping/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/config/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | 4 | def get_data(): 5 | return [ 6 | { 7 | "module_name": "ERPNext Shipping", 8 | "color": "grey", 9 | "icon": "octicon octicon-file-directory", 10 | "type": "module", 11 | "label": _("ERPNext Shipping"), 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /erpnext_shipping/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/erpnext_shipping" 6 | # docs_base_url = "https://[org_name].github.io/erpnext_shipping" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | 11 | def get_context(context): 12 | context.brand_html = "ERPNext Shipping" 13 | -------------------------------------------------------------------------------- /erpnext_shipping/custom_fields.py: -------------------------------------------------------------------------------- 1 | from .utils import identity as _ 2 | 3 | 4 | def get_custom_fields(): 5 | return { 6 | "Delivery Note": [ 7 | { 8 | "fieldname": "shipping_sec_break", 9 | "label": _("Shipping Details"), 10 | "fieldtype": "Section Break", 11 | "collapsible": 1, 12 | "insert_after": "sales_team", 13 | }, 14 | { 15 | "fieldname": "delivery_type", 16 | "label": _("Delivery Type"), 17 | "fieldtype": "Data", 18 | "read_only": 1, 19 | "translatable": 0, 20 | "insert_after": "shipping_sec_break", 21 | }, 22 | { 23 | "fieldname": "parcel_service", 24 | "label": _("Parcel Service"), 25 | "fieldtype": "Data", # needs to be "Data" for backward compat 26 | "options": "Parcel Service", 27 | "read_only": 1, 28 | "insert_after": "delivery_type", 29 | }, 30 | { 31 | "fieldname": "parcel_service_type", 32 | "label": _("Parcel Service Type"), 33 | "fieldtype": "Data", # needs to be "Data" for backward compat 34 | "options": "Parcel Service Type", 35 | "read_only": 1, 36 | "insert_after": "parcel_service", 37 | }, 38 | { 39 | "fieldname": "shipping_col_break", 40 | "fieldtype": "Column Break", 41 | "insert_after": "parcel_service_type", 42 | }, 43 | { 44 | "fieldname": "tracking_number", 45 | "label": _("Tracking Number"), 46 | "fieldtype": "Data", 47 | "read_only": 1, 48 | "translatable": 0, 49 | "insert_after": "shipping_col_break", 50 | }, 51 | { 52 | "fieldname": "tracking_url", 53 | "label": _("Tracking URL"), 54 | "fieldtype": "Small Text", 55 | "read_only": 1, 56 | "translatable": 0, 57 | "insert_after": "tracking_number", 58 | }, 59 | { 60 | "fieldname": "tracking_status", 61 | "label": _("Tracking Status"), 62 | "fieldtype": "Data", 63 | "read_only": 1, 64 | "translatable": 0, 65 | "insert_after": "tracking_url", 66 | }, 67 | { 68 | "fieldname": "tracking_status_info", 69 | "label": _("Tracking Status Information"), 70 | "fieldtype": "Data", 71 | "read_only": 1, 72 | "translatable": 0, 73 | "insert_after": "tracking_status", 74 | }, 75 | ] 76 | } 77 | 78 | 79 | def get_fields_for_patch(doctype: str, fieldnames: list[str]) -> dict[str, list[dict]]: 80 | """Return specific fields that are needed for a patch.""" 81 | return {doctype: [field for field in get_custom_fields()[doctype] if field["fieldname"] in fieldnames]} 82 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/letmeship/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/letmeship/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/letmeship/letmeship.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("LetMeShip", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/letmeship/letmeship.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2020-07-23 10:55:19.669830", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "enabled", 9 | "use_test_environment", 10 | "api_id", 11 | "api_password" 12 | ], 13 | "fields": [ 14 | { 15 | "default": "0", 16 | "fieldname": "enabled", 17 | "fieldtype": "Check", 18 | "label": "Enabled" 19 | }, 20 | { 21 | "fieldname": "api_id", 22 | "fieldtype": "Data", 23 | "label": "API ID", 24 | "mandatory_depends_on": "enabled" 25 | }, 26 | { 27 | "fieldname": "api_password", 28 | "fieldtype": "Password", 29 | "label": "API Password", 30 | "mandatory_depends_on": "enabled" 31 | }, 32 | { 33 | "default": "0", 34 | "fieldname": "use_test_environment", 35 | "fieldtype": "Check", 36 | "label": "Use Test Environment" 37 | } 38 | ], 39 | "issingle": 1, 40 | "links": [], 41 | "modified": "2024-04-22 17:42:04.777538", 42 | "modified_by": "Administrator", 43 | "module": "ERPNext Shipping", 44 | "name": "LetMeShip", 45 | "owner": "Administrator", 46 | "permissions": [ 47 | { 48 | "create": 1, 49 | "delete": 1, 50 | "email": 1, 51 | "print": 1, 52 | "read": 1, 53 | "role": "System Manager", 54 | "share": 1, 55 | "write": 1 56 | } 57 | ], 58 | "quick_entry": 1, 59 | "sort_field": "modified", 60 | "sort_order": "DESC", 61 | "states": [], 62 | "track_changes": 1 63 | } -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/letmeship/letmeship.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe Technologies and contributors 2 | # For license information, please see license.txt 3 | 4 | import json 5 | import re 6 | from json import dumps as json_dumps 7 | 8 | import frappe 9 | import requests 10 | from frappe import _ 11 | from frappe.model.document import Document 12 | from frappe.utils.data import get_link_to_form 13 | from requests.exceptions import HTTPError 14 | 15 | from erpnext_shipping.erpnext_shipping.utils import show_error_alert 16 | 17 | LETMESHIP_PROVIDER = "LetMeShip" 18 | PROD_BASE_URL = "https://api.letmeship.com/v1" 19 | TEST_BASE_URL = "https://api.test.letmeship.com/v1" 20 | 21 | 22 | class LetMeShip(Document): 23 | def validate(self): 24 | if not self.enabled: 25 | return 26 | 27 | utils = LetMeShipUtils( 28 | base_url=TEST_BASE_URL if self.use_test_environment else PROD_BASE_URL, 29 | api_id=self.api_id, 30 | api_password=self.get_password("api_password"), 31 | ) 32 | 33 | try: 34 | utils.request("GET", "documents") # check if the API and credentials are working 35 | except HTTPError as e: 36 | if e.response.status_code == 401: 37 | frappe.throw(_("Invalid API ID or Password")) 38 | else: 39 | frappe.throw( 40 | _("There was an error with the LetMeShip API. HTTP Status Code: {0}").format( 41 | e.response.status_code 42 | ) 43 | ) 44 | 45 | 46 | class LetMeShipUtils: 47 | def __init__(self, base_url: str, api_id: str, api_password: str): 48 | self.base_url = base_url 49 | self.api_password = api_password 50 | self.api_id = api_id 51 | 52 | def request(self, method: str, endpoint: str, json: dict | None = None, params: dict | None = None): 53 | """Make a request to LetMeShip API.""" 54 | response = requests.request( 55 | method, 56 | f"{self.base_url}/{endpoint}", 57 | auth=(self.api_id, self.api_password), 58 | headers={ 59 | "Content-Type": "application/json", 60 | "Accept": "application/json", 61 | "Access-Control-Allow-Origin": "string", 62 | }, 63 | params=params, 64 | json=json, 65 | ) 66 | 67 | response.raise_for_status() 68 | 69 | data = response.json() 70 | if "status" in data and data["status"]["code"] != "0": 71 | frappe.throw( 72 | _("An Error occurred while fetching LetMeShip {0}:\n{1}").format( 73 | endpoint, json_dumps(data["status"], indent=4) 74 | ) 75 | ) 76 | 77 | return data 78 | 79 | def get_available_services( 80 | self, 81 | delivery_to_type, 82 | pickup_address, 83 | delivery_address, 84 | parcels, 85 | description_of_content, 86 | pickup_date, 87 | value_of_goods, 88 | pickup_contact=None, 89 | delivery_contact=None, 90 | ): 91 | self.set_letmeship_specific_fields(pickup_contact, delivery_contact) 92 | pickup_address.address_title = self.first_30_chars(pickup_address.address_title) 93 | delivery_address.address_title = self.first_30_chars(delivery_address.address_title) 94 | parcel_list = self.get_parcel_list(parcels, description_of_content) 95 | payload = self.generate_payload( 96 | pickup_address=pickup_address, 97 | pickup_contact=pickup_contact, 98 | delivery_address=delivery_address, 99 | delivery_contact=delivery_contact, 100 | description_of_content=description_of_content, 101 | value_of_goods=value_of_goods, 102 | parcel_list=parcel_list, 103 | pickup_date=pickup_date, 104 | ) 105 | 106 | try: 107 | response_data = self.request("POST", "available", json=payload) 108 | if "serviceList" in response_data and response_data["serviceList"]: 109 | available_services = [] 110 | for response in response_data["serviceList"]: 111 | available_service = self.get_service_dict(response) 112 | available_services.append(available_service) 113 | 114 | return available_services 115 | except Exception: 116 | show_error_alert("fetching LetMeShip prices") 117 | 118 | return [] 119 | 120 | def create_shipment( 121 | self, 122 | pickup_address, 123 | delivery_company_name, 124 | delivery_address, 125 | shipment_parcel, 126 | description_of_content, 127 | pickup_date, 128 | value_of_goods, 129 | service_info, 130 | pickup_contact=None, 131 | delivery_contact=None, 132 | ): 133 | self.set_letmeship_specific_fields(pickup_contact, delivery_contact) 134 | pickup_address.address_title = self.first_30_chars(pickup_address.address_title) 135 | delivery_address.address_title = self.first_30_chars( 136 | delivery_company_name or delivery_address.address_title 137 | ) 138 | parcel_list = self.get_parcel_list(json.loads(shipment_parcel), description_of_content) 139 | 140 | payload = self.generate_payload( 141 | pickup_address=pickup_address, 142 | pickup_contact=pickup_contact, 143 | delivery_address=delivery_address, 144 | delivery_contact=delivery_contact, 145 | description_of_content=description_of_content, 146 | value_of_goods=value_of_goods, 147 | parcel_list=parcel_list, 148 | pickup_date=pickup_date, 149 | service_info=service_info, 150 | ) 151 | try: 152 | response_data = self.request("POST", "shipments", json=payload) 153 | if "shipmentId" in response_data: 154 | shipment_amount = response_data["service"]["baseServiceDetails"]["priceInfo"]["totalPrice"] 155 | shipment_id = response_data["shipmentId"] 156 | 157 | return { 158 | "service_provider": LETMESHIP_PROVIDER, 159 | "shipment_id": shipment_id, 160 | "carrier": response_data["service"]["baseServiceDetails"]["carrier"], 161 | "carrier_service": response_data["service"]["baseServiceDetails"]["name"], 162 | "shipment_amount": shipment_amount, 163 | "awb_number": self.get_awb_number(shipment_id), 164 | } 165 | except Exception: 166 | show_error_alert("creating LetMeShip Shipment") 167 | 168 | def get_awb_number(self, shipment_id: str): 169 | shipment_data = self.request("GET", f"shipments/{shipment_id}") 170 | if "trackingData" in shipment_data: 171 | return shipment_data["trackingData"].get("awbNumber", "") 172 | 173 | return "" 174 | 175 | def get_label(self, shipment_id): 176 | try: 177 | shipment_label_response_data = self.request( 178 | "GET", f"shipments/{shipment_id}/documents", params={"types": "LABEL"} 179 | ) 180 | if "documents" in shipment_label_response_data: 181 | for label in shipment_label_response_data["documents"]: 182 | if "data" in label: 183 | return json.dumps(label["data"]) 184 | else: 185 | frappe.throw( 186 | _("Error occurred while printing Shipment: {0}").format( 187 | shipment_label_response_data["message"] 188 | ) 189 | ) 190 | except Exception: 191 | show_error_alert("printing LetMeShip Label") 192 | 193 | def get_tracking_data(self, shipment_id): 194 | from erpnext_shipping.erpnext_shipping.utils import get_tracking_url 195 | 196 | try: 197 | tracking_data = self.request("GET", "tracking", params={"shipmentid": shipment_id}) 198 | 199 | if "awbNumber" in tracking_data: 200 | tracking_status = "In Progress" 201 | if tracking_data["lmsTrackingStatus"].startswith("DELIVERED"): 202 | tracking_status = "Delivered" 203 | if tracking_data["lmsTrackingStatus"] == "RETURNED": 204 | tracking_status = "Returned" 205 | if tracking_data["lmsTrackingStatus"] == "LOST": 206 | tracking_status = "Lost" 207 | tracking_url = get_tracking_url( 208 | carrier=tracking_data["carrier"], tracking_number=tracking_data["awbNumber"] 209 | ) 210 | return { 211 | "awb_number": tracking_data["awbNumber"], 212 | "tracking_status": tracking_status, 213 | "tracking_status_info": tracking_data["lmsTrackingStatus"], 214 | "tracking_url": tracking_url, 215 | } 216 | elif "message" in tracking_data: 217 | frappe.throw( 218 | _("Error occurred while updating Shipment: {0}").format(tracking_data["message"]) 219 | ) 220 | except Exception: 221 | show_error_alert("updating LetMeShip Shipment") 222 | 223 | def generate_payload( 224 | self, 225 | pickup_address, 226 | pickup_contact, 227 | delivery_address, 228 | delivery_contact, 229 | description_of_content, 230 | value_of_goods, 231 | parcel_list, 232 | pickup_date, 233 | service_info=None, 234 | ): 235 | payload = { 236 | "pickupInfo": self.get_pickup_delivery_info(pickup_address, pickup_contact), 237 | "deliveryInfo": self.get_pickup_delivery_info(delivery_address, delivery_contact), 238 | "shipmentDetails": { 239 | "contentDescription": description_of_content, 240 | "shipmentType": "PARCEL", 241 | "shipmentSettings": { 242 | "saturdayDelivery": False, 243 | "ddp": False, 244 | "insurance": False, 245 | "pickupOrder": False, 246 | "pickupTailLift": False, 247 | "deliveryTailLift": False, 248 | "holidayDelivery": False, 249 | }, 250 | "goodsValue": float(value_of_goods), 251 | "parcelList": parcel_list, 252 | "pickupInterval": {"date": pickup_date}, 253 | }, 254 | } 255 | 256 | if service_info: 257 | payload["service"] = { 258 | "baseServiceDetails": { 259 | "id": service_info["id"], 260 | "name": service_info["service_name"], 261 | "carrier": service_info["carrier"], 262 | "priceInfo": service_info["price_info"], 263 | }, 264 | "supportedExWorkType": [], 265 | "messages": [""], 266 | "description": "", 267 | "serviceInfo": "", 268 | } 269 | payload["shipmentNotification"] = { 270 | "trackingNotification": { 271 | "deliveryNotification": True, 272 | "problemNotification": True, 273 | "emails": [], 274 | "notificationText": "", 275 | }, 276 | "recipientNotification": {"notificationText": "", "emails": []}, 277 | } 278 | payload["labelEmail"] = True 279 | return payload 280 | 281 | def first_30_chars(self, address_title: str): 282 | # LetMeShip has a limit of 30 characters for Company field 283 | return address_title[:30] if len(address_title) > 30 else address_title 284 | 285 | def get_service_dict(self, response): 286 | """Returns a dictionary with service info.""" 287 | available_service = frappe._dict() 288 | basic_info = response["baseServiceDetails"] 289 | price_info = basic_info["priceInfo"] 290 | available_service.service_provider = LETMESHIP_PROVIDER 291 | available_service.id = basic_info["id"] 292 | available_service.carrier = basic_info["carrier"] 293 | available_service.carrier_name = basic_info["carrier"] 294 | available_service.service_name = basic_info["name"] 295 | available_service.is_preferred = 0 296 | available_service.real_weight = price_info["realWeight"] 297 | available_service.total_price = price_info["netPrice"] 298 | available_service.price_info = price_info 299 | available_service.currency = "EUR" 300 | return available_service 301 | 302 | def set_letmeship_specific_fields(self, pickup_contact, delivery_contact): 303 | pickup_contact.phone_prefix = pickup_contact.phone[:3] 304 | pickup_contact.phone = re.sub("[^A-Za-z0-9]+", "", pickup_contact.phone[3:]) 305 | 306 | pickup_contact.title = "MS" 307 | if pickup_contact.gender == "Male": 308 | pickup_contact.title = "MR" 309 | 310 | delivery_contact.phone_prefix = delivery_contact.phone[:3] 311 | delivery_contact.phone = re.sub("[^A-Za-z0-9]+", "", delivery_contact.phone[3:]) 312 | 313 | delivery_contact.title = "MS" 314 | if delivery_contact.gender == "Male": 315 | delivery_contact.title = "MR" 316 | 317 | def get_parcel_list(self, parcels, description_of_content): 318 | parcel_list = [] 319 | for parcel in parcels: 320 | formatted_parcel = {} 321 | formatted_parcel["height"] = parcel.get("height") 322 | formatted_parcel["width"] = parcel.get("width") 323 | formatted_parcel["length"] = parcel.get("length") 324 | formatted_parcel["weight"] = parcel.get("weight") 325 | formatted_parcel["quantity"] = parcel.get("count") 326 | formatted_parcel["contentDescription"] = description_of_content 327 | parcel_list.append(formatted_parcel) 328 | return parcel_list 329 | 330 | def get_pickup_delivery_info(self, address, contact): 331 | return { 332 | "address": { 333 | "countryCode": address.country_code, 334 | "zip": address.pincode, 335 | "city": address.city, 336 | "street": address.address_line1, 337 | "addressInfo1": address.address_line2, 338 | "houseNo": "", 339 | }, 340 | "company": address.address_title, 341 | "person": { 342 | "title": contact.title, 343 | "firstname": contact.first_name, 344 | "lastname": contact.last_name, 345 | }, 346 | "phone": {"phoneNumber": contact.phone, "phoneNumberPrefix": contact.phone_prefix}, 347 | "email": contact.email_id, 348 | } 349 | 350 | 351 | def get_letmeship_utils() -> "LetMeShipUtils": 352 | settings = frappe.get_single("LetMeShip") 353 | if not settings.enabled: 354 | link = get_link_to_form("LetMeShip", "LetMeShip", frappe.bold("LetMeShip Settings")) 355 | frappe.throw(_(f"Please enable LetMeShip Integration in {link}"), title=_("Mandatory")) 356 | 357 | return LetMeShipUtils( 358 | base_url=TEST_BASE_URL if settings.use_test_environment else PROD_BASE_URL, 359 | api_id=settings.api_id, 360 | api_password=settings.get_password("api_password"), 361 | ) 362 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/letmeship/test_letmeship.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestLetMeShip(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/parcel_service/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service/parcel_service.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Parcel Service", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service/parcel_service.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "field:parcel_service_name", 5 | "creation": "2020-07-23 10:35:38.211715", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "parcel_service_name", 11 | "parcel_service_code", 12 | "url_reference" 13 | ], 14 | "fields": [ 15 | { 16 | "fieldname": "parcel_service_name", 17 | "fieldtype": "Data", 18 | "label": "Parcel Service Name", 19 | "unique": 1 20 | }, 21 | { 22 | "fieldname": "parcel_service_code", 23 | "fieldtype": "Data", 24 | "label": "Parcel Service Code" 25 | }, 26 | { 27 | "fieldname": "url_reference", 28 | "fieldtype": "Data", 29 | "label": "URL Reference" 30 | } 31 | ], 32 | "links": [], 33 | "modified": "2020-11-09 19:48:21.690285", 34 | "modified_by": "Administrator", 35 | "module": "ERPNext Shipping", 36 | "name": "Parcel Service", 37 | "owner": "Administrator", 38 | "permissions": [ 39 | { 40 | "create": 1, 41 | "delete": 1, 42 | "email": 1, 43 | "export": 1, 44 | "print": 1, 45 | "read": 1, 46 | "report": 1, 47 | "role": "System Manager", 48 | "share": 1, 49 | "write": 1 50 | } 51 | ], 52 | "quick_entry": 1, 53 | "sort_field": "modified", 54 | "sort_order": "DESC", 55 | "track_changes": 1 56 | } -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service/parcel_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ParcelService(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service/test_parcel_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestParcelService(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/parcel_service_type.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Parcel Service Type Alias", { 5 | parcel_type_alias: function (frm, cdt, cdn) { 6 | let row = locals[cdt][cdn]; 7 | if (row.parcel_type_alias) { 8 | frappe.model.set_value(cdt, cdn, "parcel_service", frm.doc.parcel_service); 9 | frm.refresh_field("parcel_service_type_alias"); 10 | } 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/parcel_service_type.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "format: {parcel_service} - {parcel_service_type}", 5 | "creation": "2020-07-23 10:47:43.794083", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "parcel_service", 11 | "parcel_service_type", 12 | "description", 13 | "section_break_4", 14 | "parcel_service_type_alias", 15 | "column_break_6", 16 | "section_break_7", 17 | "show_in_preferred_services_list" 18 | ], 19 | "fields": [ 20 | { 21 | "fieldname": "parcel_service", 22 | "fieldtype": "Link", 23 | "in_list_view": 1, 24 | "label": "Parcel Service", 25 | "options": "Parcel Service", 26 | "reqd": 1 27 | }, 28 | { 29 | "fieldname": "parcel_service_type", 30 | "fieldtype": "Data", 31 | "label": "Parcel Service Type", 32 | "reqd": 1, 33 | "set_only_once": 1 34 | }, 35 | { 36 | "fieldname": "description", 37 | "fieldtype": "Small Text", 38 | "label": "Description" 39 | }, 40 | { 41 | "fieldname": "section_break_4", 42 | "fieldtype": "Section Break" 43 | }, 44 | { 45 | "fieldname": "parcel_service_type_alias", 46 | "fieldtype": "Table", 47 | "label": "Parcel Service Type Alias", 48 | "options": "Parcel Service Type Alias" 49 | }, 50 | { 51 | "fieldname": "column_break_6", 52 | "fieldtype": "Column Break" 53 | }, 54 | { 55 | "fieldname": "section_break_7", 56 | "fieldtype": "Section Break" 57 | }, 58 | { 59 | "default": "0", 60 | "fieldname": "show_in_preferred_services_list", 61 | "fieldtype": "Check", 62 | "label": "Show in Preferred Services List" 63 | } 64 | ], 65 | "links": [], 66 | "modified": "2020-11-09 19:47:48.253666", 67 | "modified_by": "Administrator", 68 | "module": "ERPNext Shipping", 69 | "name": "Parcel Service Type", 70 | "owner": "Administrator", 71 | "permissions": [ 72 | { 73 | "create": 1, 74 | "delete": 1, 75 | "email": 1, 76 | "export": 1, 77 | "print": 1, 78 | "read": 1, 79 | "report": 1, 80 | "role": "System Manager", 81 | "share": 1, 82 | "write": 1 83 | } 84 | ], 85 | "quick_entry": 1, 86 | "sort_field": "modified", 87 | "sort_order": "DESC", 88 | "track_changes": 1 89 | } -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/parcel_service_type.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class ParcelServiceType(Document): 9 | pass 10 | 11 | 12 | def match_parcel_service_type_alias(parcel_service_type, parcel_service): 13 | # Match and return Parcel Service Type Alias to Parcel Service Type if exists. 14 | if frappe.db.exists("Parcel Service", parcel_service): 15 | matched_parcel_service_type = frappe.db.get_value( 16 | "Parcel Service Type Alias", 17 | {"parcel_type_alias": parcel_service_type, "parcel_service": parcel_service}, 18 | "parent", 19 | ) 20 | if matched_parcel_service_type: 21 | parcel_service_type = matched_parcel_service_type 22 | return parcel_service_type 23 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type/test_parcel_service_type.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestParcelServiceType(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type_alias/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/parcel_service_type_alias/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type_alias/parcel_service_type_alias.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2020-07-23 10:47:23.626510", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "parcel_service", 9 | "parcel_type_alias" 10 | ], 11 | "fields": [ 12 | { 13 | "fieldname": "parcel_service", 14 | "fieldtype": "Link", 15 | "hidden": 1, 16 | "in_list_view": 1, 17 | "label": "Parcel Service", 18 | "options": "Parcel Service", 19 | "read_only": 1 20 | }, 21 | { 22 | "fieldname": "parcel_type_alias", 23 | "fieldtype": "Data", 24 | "in_list_view": 1, 25 | "label": "Parcel Type Alias", 26 | "reqd": 1 27 | } 28 | ], 29 | "istable": 1, 30 | "links": [], 31 | "modified": "2020-11-09 19:48:05.381127", 32 | "modified_by": "Administrator", 33 | "module": "ERPNext Shipping", 34 | "name": "Parcel Service Type Alias", 35 | "owner": "Administrator", 36 | "permissions": [], 37 | "quick_entry": 1, 38 | "sort_field": "modified", 39 | "sort_order": "DESC", 40 | "track_changes": 1 41 | } -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/parcel_service_type_alias/parcel_service_type_alias.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | 5 | # import frappe 6 | from frappe.model.document import Document 7 | 8 | 9 | class ParcelServiceTypeAlias(Document): 10 | pass 11 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/sendcloud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/erpnext_shipping/doctype/sendcloud/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/sendcloud/sendcloud.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("SendCloud", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/sendcloud/sendcloud.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2020-08-18 09:48:50.836233", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "enabled", 9 | "api_key", 10 | "api_secret" 11 | ], 12 | "fields": [ 13 | { 14 | "default": "0", 15 | "fieldname": "enabled", 16 | "fieldtype": "Check", 17 | "label": "Enabled" 18 | }, 19 | { 20 | "fieldname": "api_key", 21 | "fieldtype": "Data", 22 | "label": "API Key", 23 | "mandatory_depends_on": "enabled" 24 | }, 25 | { 26 | "fieldname": "api_secret", 27 | "fieldtype": "Password", 28 | "label": "API Secret", 29 | "mandatory_depends_on": "enabled" 30 | } 31 | ], 32 | "index_web_pages_for_search": 1, 33 | "issingle": 1, 34 | "links": [], 35 | "modified": "2024-04-22 17:43:51.711897", 36 | "modified_by": "Administrator", 37 | "module": "ERPNext Shipping", 38 | "name": "SendCloud", 39 | "owner": "Administrator", 40 | "permissions": [ 41 | { 42 | "create": 1, 43 | "delete": 1, 44 | "email": 1, 45 | "print": 1, 46 | "read": 1, 47 | "role": "System Manager", 48 | "share": 1, 49 | "write": 1 50 | } 51 | ], 52 | "quick_entry": 1, 53 | "sort_field": "modified", 54 | "sort_order": "DESC", 55 | "states": [], 56 | "track_changes": 1 57 | } -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/sendcloud/sendcloud.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors 2 | # For license information, please see license.txt 3 | 4 | import json 5 | import re 6 | 7 | import frappe 8 | import requests 9 | from frappe import _ 10 | from frappe.model.document import Document 11 | from frappe.utils import flt 12 | from frappe.utils.data import get_link_to_form 13 | from requests.exceptions import HTTPError 14 | 15 | from erpnext_shipping.erpnext_shipping.utils import show_error_alert 16 | 17 | SENDCLOUD_PROVIDER = "SendCloud" 18 | WEIGHT_DECIMALS = 3 19 | CURRENCY_DECIMALS = 2 20 | 21 | BASE_URL = "https://panel.sendcloud.sc/api" 22 | FETCH_SHIPPING_OPTIONS_URL = f"{BASE_URL}/v3/fetch-shipping-options" 23 | SHIPMENTS_URL = f"{BASE_URL}/v3/shipments" 24 | SHIPMENTS_ANNOUNCE_URL = f"{BASE_URL}/v3/shipments/announce" 25 | LABELS_URL = f"{BASE_URL}/v2/labels" 26 | PARCELS_URL = f"{BASE_URL}/v2/parcels" 27 | 28 | 29 | class SendCloud(Document): 30 | pass 31 | 32 | 33 | class SendCloudUtils: 34 | def __init__(self): 35 | settings = frappe.get_single("SendCloud") 36 | self.api_key = settings.api_key 37 | self.api_secret = settings.get_password("api_secret") 38 | self.enabled = settings.enabled 39 | 40 | if not self.enabled: 41 | link = get_link_to_form("SendCloud", "SendCloud", _("SendCloud Settings")) 42 | frappe.throw(_("Please enable SendCloud Integration in {0}").format(link)) 43 | 44 | def get_available_services(self, delivery_address, pickup_address, parcels: list[dict]): 45 | # Retrieve rates at SendCloud from specification stated. 46 | if not self.enabled or not self.api_key or not self.api_secret: 47 | return [] 48 | 49 | max_weight = max(parcel.get("weight", 0) for parcel in parcels) 50 | max_length = max(parcel.get("length", 0) for parcel in parcels) 51 | max_width = max(parcel.get("width", 0) for parcel in parcels) 52 | max_height = max(parcel.get("height", 0) for parcel in parcels) 53 | 54 | to_country = delivery_address.country_code.upper() 55 | from_country = pickup_address.country_code.upper() 56 | 57 | payload = { 58 | "to_country_code": to_country, 59 | "from_country_code": from_country, 60 | "weight": {"value": max_weight, "unit": "kg"}, 61 | "dimensions": {"length": max_length, "width": max_width, "height": max_height, "unit": "cm"}, 62 | } 63 | 64 | try: 65 | response = requests.post( 66 | FETCH_SHIPPING_OPTIONS_URL, 67 | json=payload, 68 | auth=(self.api_key, self.api_secret), 69 | headers={"Accept": "application/json", "Content-Type": "application/json"}, 70 | ) 71 | 72 | response_data = response.json() 73 | 74 | if "error" in response_data: 75 | error_message = response_data["error"]["message"] 76 | frappe.throw(error_message, title=_("SendCloud")) 77 | 78 | if "data" not in response_data or not response_data["data"]: 79 | frappe.throw(_("No shipping options found for this destination."), title=_("Sendcloud")) 80 | 81 | available_services = [] 82 | for service in response_data["data"]: 83 | available_service = self.get_service_dict(service, parcels) 84 | available_services.append(available_service) 85 | 86 | return available_services 87 | except Exception: 88 | show_error_alert("fetching SendCloud prices") 89 | 90 | def create_shipment( 91 | self, 92 | shipment, 93 | pickup_address, 94 | pickup_contact, 95 | delivery_address, 96 | delivery_contact, 97 | service_info, 98 | shipment_parcel, 99 | ): 100 | if not self.enabled or not self.api_key or not self.api_secret: 101 | return [] 102 | 103 | parcels = [] 104 | for i, parcel in enumerate(json.loads(shipment_parcel), start=1): 105 | parcel_count = parcel.get("count", 1) 106 | for j in range(parcel_count): 107 | parcel_data = self.get_parcel( 108 | parcel, 109 | shipment, 110 | i, 111 | ) 112 | parcels.append(parcel_data) 113 | 114 | house_number, address = self.extract_house_number(pickup_address.address_line1) 115 | 116 | payload = { 117 | "parcels": parcels, 118 | "to_address": { 119 | "company_name": delivery_address.address_title, 120 | "name": f"{delivery_contact.first_name} {delivery_contact.last_name}", 121 | "address_line_1": delivery_address.address_line1, 122 | "postal_code": delivery_address.pincode, 123 | "city": delivery_address.city, 124 | "country_code": delivery_address.country_code.upper(), 125 | "phone_number": delivery_contact.phone, 126 | }, 127 | "from_address": { 128 | "name": f"{pickup_contact.first_name} {pickup_contact.last_name}", 129 | "company_name": pickup_address.address_title, 130 | "address_line_1": address 131 | or pickup_address.address_line1, # Using original address if parsing fails 132 | "house_number": house_number 133 | or " ", # API requires a house number. If None, we use a U+200A HAIR SPACE to bypass validation without displaying a number 134 | "postal_code": pickup_address.pincode, 135 | "city": pickup_address.city, 136 | "country_code": pickup_address.country_code.upper(), 137 | "phone_number": pickup_contact.phone, 138 | }, 139 | "ship_with": { 140 | "type": "shipping_option_code", 141 | "properties": { 142 | "shipping_option_code": service_info["service_id"], 143 | }, 144 | }, 145 | } 146 | 147 | if service_info.get("multicollo"): 148 | # Multicollo Logic: All packages are processed in a single API call 149 | try: 150 | response = requests.post( 151 | SHIPMENTS_URL, 152 | json=payload, 153 | auth=(self.api_key, self.api_secret), 154 | ) 155 | response_data = response.json() 156 | if "errors" in response_data and response_data["errors"]: 157 | error_details = [ 158 | f"Code: {err.get('code', 'N/A')}, Detail: {err.get('detail', 'N/A')}" 159 | for err in response_data["errors"] 160 | ] 161 | error_message = "\n".join(error_details) 162 | frappe.msgprint( 163 | _("Error occurred while creating shipment for parcel {0}:").format( 164 | parcel.get("order_number") 165 | ) 166 | + f"\n{error_message}", 167 | indicator="red", 168 | alert=True, 169 | ) 170 | return None 171 | 172 | parcels_data = response_data.get("data", {}).get("parcels", []) 173 | if parcels_data: 174 | shipment_ids = [str(parcel["id"]) for parcel in parcels_data] 175 | tracking_numbers = [parcel.get("tracking_number") or "" for parcel in parcels_data] 176 | tracking_urls = [parcel.get("tracking_url") or "" for parcel in parcels_data] 177 | return { 178 | "service_provider": "SendCloud", 179 | "shipment_id": ", ".join(shipment_ids), 180 | "carrier": self.get_carrier(service_info["carrier"], post_or_get="post"), 181 | "carrier_service": service_info["service_name"], 182 | "shipment_amount": service_info["total_price"], 183 | "awb_number": ", ".join(tracking_numbers), 184 | "tracking_url": ", ".join(tracking_urls), 185 | } 186 | except Exception: 187 | show_error_alert("creating SendCloud Shipment (multicollo)") 188 | else: 189 | # Non-Multicollo Logic: A separate API call is made for each package 190 | shipments_results = [] 191 | for parcel in parcels: 192 | payload_single = payload.copy() 193 | payload_single["parcels"] = [parcel] 194 | try: 195 | response = requests.post( 196 | SHIPMENTS_ANNOUNCE_URL, 197 | json=payload_single, 198 | auth=(self.api_key, self.api_secret), 199 | ) 200 | response_data = response.json() 201 | if "errors" in response_data and response_data["errors"]: 202 | error_details = [ 203 | f"Code: {err.get('code', 'N/A')}, Detail: {err.get('detail', 'N/A')}" 204 | for err in response_data["errors"] 205 | ] 206 | error_message = "\n".join(error_details) 207 | frappe.msgprint( 208 | _("Error occurred while creating shipment for parcel {0}:").format( 209 | parcel.get("order_number") 210 | ) 211 | + f"\n{error_message}", 212 | indicator="red", 213 | alert=True, 214 | ) 215 | continue 216 | 217 | parcels_data = response_data.get("data", {}).get("parcels", []) 218 | if parcels_data: 219 | parcel_data = parcels_data[0] 220 | shipments_results.append( 221 | { 222 | "shipment_id": str(parcel_data["id"]), 223 | "awb_number": parcel_data.get("tracking_number", ""), 224 | "tracking_url": parcel_data.get("tracking_url", ""), 225 | "carrier": self.get_carrier(service_info["carrier"], post_or_get="post"), 226 | "carrier_service": service_info["service_name"], 227 | "shipment_amount": service_info["total_price"], 228 | } 229 | ) 230 | except Exception: 231 | show_error_alert(f"creating SendCloud Shipment for parcel {parcel.get('order_number')}") 232 | if shipments_results: 233 | combined_result = { 234 | "service_provider": "SendCloud", 235 | "shipment_id": ", ".join(item["shipment_id"] for item in shipments_results), 236 | "carrier": shipments_results[0]["carrier"], 237 | "carrier_service": shipments_results[0]["carrier_service"], 238 | "shipment_amount": service_info["total_price"], 239 | "awb_number": ", ".join(item["awb_number"] for item in shipments_results), 240 | "tracking_url": ", ".join(item["tracking_url"] for item in shipments_results), 241 | } 242 | return combined_result 243 | 244 | return None 245 | 246 | def get_label(self, shipment_id): 247 | # Retrieve shipment label from SendCloud 248 | shipment_id_list = shipment_id.split(", ") 249 | label_urls = [] 250 | 251 | try: 252 | for ship_id in shipment_id_list: 253 | shipment_label_response = requests.get( 254 | f"{LABELS_URL}/{ship_id}", 255 | auth=(self.api_key, self.api_secret), 256 | ) 257 | shipment_label = json.loads(shipment_label_response.text) 258 | label_urls.append(shipment_label["label"]["label_printer"]) 259 | if len(label_urls): 260 | return label_urls 261 | else: 262 | message = _( 263 | "Please make sure Shipment (ID: {0}), exists and is a complete Shipment on SendCloud." 264 | ).format(shipment_id) 265 | frappe.msgprint(msg=_(message), title=_("Label Not Found")) 266 | except Exception: 267 | show_error_alert("printing SendCloud Label") 268 | 269 | def download_label(self, label_url: str): 270 | """Download label from SendCloud.""" 271 | try: 272 | resp = requests.get(label_url, auth=(self.api_key, self.api_secret)) 273 | resp.raise_for_status() 274 | return resp.content 275 | except HTTPError: 276 | frappe.msgprint( 277 | _("An error occurred while downloading label from SendCloud"), indicator="orange", alert=True 278 | ) 279 | 280 | def get_tracking_data(self, shipment_id): 281 | # return SendCloud tracking data 282 | try: 283 | shipment_id_list = shipment_id.split(", ") 284 | awb_number, tracking_status, tracking_status_info, tracking_urls = [], [], [], [] 285 | 286 | for ship_id in shipment_id_list: 287 | tracking_data_response = requests.get( 288 | f"{PARCELS_URL}/{ship_id}", 289 | auth=(self.api_key, self.api_secret), 290 | ) 291 | tracking_data = json.loads(tracking_data_response.text) 292 | tracking_data_parcel = tracking_data["parcel"] 293 | tracking_data_parcel_status = tracking_data_parcel["status"]["message"] 294 | tracking_url = tracking_data_parcel.get("tracking_url") 295 | if tracking_url: 296 | tracking_urls.append(tracking_url) 297 | tracking_number = tracking_data_parcel.get("tracking_number") 298 | if tracking_number: 299 | awb_number.append(tracking_number) 300 | tracking_status.append(tracking_data_parcel_status) 301 | tracking_status_info.append(tracking_data_parcel_status) 302 | return { 303 | "awb_number": ", ".join(awb_number), 304 | "tracking_status": ", ".join(tracking_status), 305 | "tracking_status_info": ", ".join(tracking_status_info), 306 | "tracking_url": ", ".join(tracking_urls), 307 | } 308 | except Exception: 309 | show_error_alert("updating SendCloud Shipment") 310 | 311 | def total_parcel_price(self, parcel_price, parcels: list[dict]): 312 | count = 0 313 | for parcel in parcels: 314 | count += parcel.get("count") 315 | return flt(parcel_price) * count 316 | 317 | def get_service_dict(self, service, parcels: list[dict]): 318 | """Returns a dictionary with service info.""" 319 | available_service = frappe._dict() 320 | available_service.service_provider = "SendCloud" 321 | available_service.carrier = service["carrier"]["name"] 322 | available_service.service_name = service["product"]["name"] 323 | available_service.service_id = service["code"] 324 | available_service.multicollo = service["functionalities"].get("multicollo", False) 325 | 326 | quotes = service.get("quotes", []) 327 | if quotes: 328 | price_data = quotes[0].get("price", {}).get("total", {}) 329 | available_service.total_price = self.total_parcel_price( 330 | float(price_data.get("value", 0)), parcels 331 | ) 332 | available_service.currency = price_data.get("currency") 333 | 334 | return available_service 335 | 336 | def get_carrier(self, carrier_name, post_or_get=None): 337 | # make 'sendcloud' => 'SendCloud' while displaying rates 338 | # reverse the same while creating shipment 339 | if carrier_name in ("sendcloud", "SendCloud"): 340 | return "SendCloud" if post_or_get == "get" else "sendcloud" 341 | else: 342 | return carrier_name.upper() if post_or_get == "get" else carrier_name.lower() 343 | 344 | def get_parcel(self, parcel, shipment, index): 345 | return { 346 | "dimensions": { 347 | "length": parcel.get("length", 0), 348 | "width": parcel.get("width", 0), 349 | "height": parcel.get("height", 0), 350 | "unit": "cm", 351 | }, 352 | "weight": {"value": flt(parcel.get("weight", 0), WEIGHT_DECIMALS), "unit": "kg"}, 353 | "order_number": f"{shipment}-{index}", 354 | } 355 | 356 | def extract_house_number(self, address): 357 | pattern = r"\b\d+[/-]?\w*(?:-\d+\w*)?\b" 358 | match = re.search(pattern, address) 359 | if match: 360 | house_number = match.group(0) 361 | cleaned_address = re.sub(pattern, "", address).strip() 362 | return house_number, cleaned_address 363 | else: 364 | return None, None 365 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/doctype/sendcloud/test_sendcloud.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestSendCloud(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/patches/change_tracking_url_column_type.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def execute(): 5 | """Chnage the column type of tracking_url in Delivery Note to 'text'.""" 6 | custom_field = "Delivery Note-tracking_url" 7 | if not frappe.db.exists("Custom Field", custom_field): 8 | return 9 | 10 | frappe.db.set_value("Custom Field", custom_field, "fieldtype", "Small Text") 11 | frappe.db.change_column_type("Delivery Note", "tracking_url", "text", nullable=True) 12 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/patches/create_custom_delivery_note_fields.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe Technologies and contributors 2 | # For license information, please see license.txt 3 | import frappe 4 | from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 5 | 6 | 7 | def execute(): 8 | custom_fields = frappe.get_hooks("shipping_custom_fields") 9 | create_custom_fields(custom_fields) 10 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/shipping.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe Technologies and contributors 2 | # For license information, please see license.txt 3 | import json 4 | 5 | import frappe 6 | from erpnext.stock.doctype.shipment.shipment import get_company_contact 7 | 8 | from erpnext_shipping.erpnext_shipping.doctype.letmeship.letmeship import ( 9 | LETMESHIP_PROVIDER, 10 | get_letmeship_utils, 11 | ) 12 | from erpnext_shipping.erpnext_shipping.doctype.sendcloud.sendcloud import SENDCLOUD_PROVIDER, SendCloudUtils 13 | from erpnext_shipping.erpnext_shipping.utils import ( 14 | get_address, 15 | get_contact, 16 | match_parcel_service_type_carrier, 17 | ) 18 | 19 | 20 | @frappe.whitelist() 21 | def fetch_shipping_rates( 22 | pickup_from_type, 23 | delivery_to_type, 24 | pickup_address_name, 25 | delivery_address_name, 26 | parcels, 27 | description_of_content, 28 | pickup_date, 29 | value_of_goods, 30 | pickup_contact_name=None, 31 | delivery_contact_name=None, 32 | ): 33 | # Return Shipping Rates for the various Shipping Providers 34 | shipment_prices = [] 35 | letmeship_enabled = frappe.db.get_single_value("LetMeShip", "enabled") 36 | sendcloud_enabled = frappe.db.get_single_value("SendCloud", "enabled") 37 | pickup_address = get_address(pickup_address_name) 38 | delivery_address = get_address(delivery_address_name) 39 | parcels = json.loads(parcels) 40 | 41 | if letmeship_enabled: 42 | pickup_contact = None 43 | delivery_contact = None 44 | if pickup_from_type != "Company": 45 | pickup_contact = get_contact(pickup_contact_name) 46 | else: 47 | pickup_contact = get_company_contact(user=pickup_contact_name) 48 | pickup_contact.email_id = pickup_contact.pop("email", None) 49 | 50 | delivery_contact = get_contact(delivery_contact_name) 51 | 52 | letmeship = get_letmeship_utils() 53 | letmeship_prices = ( 54 | letmeship.get_available_services( 55 | delivery_to_type=delivery_to_type, 56 | pickup_address=pickup_address, 57 | delivery_address=delivery_address, 58 | parcels=parcels, 59 | description_of_content=description_of_content, 60 | pickup_date=pickup_date, 61 | value_of_goods=value_of_goods, 62 | pickup_contact=pickup_contact, 63 | delivery_contact=delivery_contact, 64 | ) 65 | or [] 66 | ) 67 | letmeship_prices = match_parcel_service_type_carrier(letmeship_prices, "carrier", "service_name") 68 | shipment_prices += letmeship_prices 69 | 70 | if sendcloud_enabled: 71 | sendcloud = SendCloudUtils() 72 | sendcloud_prices = ( 73 | sendcloud.get_available_services( 74 | delivery_address=delivery_address, pickup_address=pickup_address, parcels=parcels 75 | ) 76 | or [] 77 | ) 78 | sendcloud_prices = match_parcel_service_type_carrier(sendcloud_prices, "carrier", "service_name") 79 | shipment_prices += sendcloud_prices 80 | 81 | shipment_prices = sorted(shipment_prices, key=lambda k: k["total_price"]) 82 | return shipment_prices 83 | 84 | 85 | @frappe.whitelist() 86 | def create_shipment( 87 | shipment, 88 | pickup_from_type, 89 | delivery_to_type, 90 | pickup_address_name, 91 | delivery_address_name, 92 | shipment_parcel, 93 | description_of_content, 94 | pickup_date, 95 | value_of_goods, 96 | service_data, 97 | shipment_notific_email=None, 98 | tracking_notific_email=None, 99 | pickup_contact_name=None, 100 | delivery_contact_name=None, 101 | delivery_notes=None, 102 | ): 103 | if isinstance(delivery_notes, str): 104 | delivery_notes = json.loads(delivery_notes) 105 | 106 | if delivery_notes is None: 107 | delivery_notes = [] 108 | 109 | service_info = json.loads(service_data) 110 | shipment_info, pickup_contact, delivery_contact = None, None, None 111 | pickup_address = get_address(pickup_address_name) 112 | delivery_address = get_address(delivery_address_name) 113 | delivery_company_name = get_delivery_company_name(shipment) 114 | 115 | if pickup_from_type != "Company": 116 | pickup_contact = get_contact(pickup_contact_name) 117 | 118 | else: 119 | pickup_contact = get_company_contact(user=pickup_contact_name) 120 | pickup_contact.email_id = pickup_contact.pop("email", None) 121 | 122 | delivery_contact = get_contact(delivery_contact_name) 123 | 124 | if service_info["service_provider"] == LETMESHIP_PROVIDER: 125 | letmeship = get_letmeship_utils() 126 | shipment_info = letmeship.create_shipment( 127 | pickup_address=pickup_address, 128 | delivery_company_name=delivery_company_name, 129 | delivery_address=delivery_address, 130 | shipment_parcel=shipment_parcel, 131 | description_of_content=description_of_content, 132 | pickup_date=pickup_date, 133 | value_of_goods=value_of_goods, 134 | pickup_contact=pickup_contact, 135 | delivery_contact=delivery_contact, 136 | service_info=service_info, 137 | ) 138 | 139 | if service_info["service_provider"] == SENDCLOUD_PROVIDER: 140 | sendcloud = SendCloudUtils() 141 | shipment_info = sendcloud.create_shipment( 142 | shipment=shipment, 143 | delivery_address=delivery_address, 144 | pickup_address=pickup_address, 145 | pickup_contact=pickup_contact, 146 | shipment_parcel=shipment_parcel, 147 | delivery_contact=delivery_contact, 148 | service_info=service_info, 149 | ) 150 | 151 | if shipment_info: 152 | shipment = frappe.get_doc("Shipment", shipment) 153 | shipment.db_set( 154 | { 155 | "service_provider": shipment_info.get("service_provider"), 156 | "carrier": shipment_info.get("carrier"), 157 | "carrier_service": shipment_info.get("carrier_service"), 158 | "shipment_id": shipment_info.get("shipment_id"), 159 | "shipment_amount": shipment_info.get("shipment_amount"), 160 | "awb_number": shipment_info.get("awb_number"), 161 | "status": "Booked", 162 | } 163 | ) 164 | 165 | if delivery_notes: 166 | update_delivery_note(delivery_notes=delivery_notes, shipment_info=shipment_info) 167 | 168 | return shipment_info 169 | 170 | 171 | def get_delivery_company_name(shipment: str) -> str | None: 172 | shipment_doc = frappe.get_doc("Shipment", shipment) 173 | if shipment_doc.delivery_customer: 174 | return frappe.db.get_value("Customer", shipment_doc.delivery_customer, "customer_name") 175 | if shipment_doc.delivery_supplier: 176 | return frappe.db.get_value("Supplier", shipment_doc.delivery_supplier, "supplier_name") 177 | if shipment_doc.delivery_company: 178 | return frappe.db.get_value("Company", shipment_doc.delivery_company, "company_name") 179 | 180 | return None 181 | 182 | 183 | @frappe.whitelist() 184 | def print_shipping_label(shipment: str): 185 | shipment_doc = frappe.get_doc("Shipment", shipment) 186 | service_provider = shipment_doc.service_provider 187 | shipment_id = shipment_doc.shipment_id 188 | 189 | if service_provider == LETMESHIP_PROVIDER: 190 | letmeship = get_letmeship_utils() 191 | shipping_label = letmeship.get_label(shipment_id) 192 | elif service_provider == SENDCLOUD_PROVIDER: 193 | sendcloud = SendCloudUtils() 194 | shipping_label = [] 195 | _labels = sendcloud.get_label(shipment_id) 196 | for i, label_url in enumerate(_labels, start=1): 197 | content = sendcloud.download_label(label_url) 198 | file_url = save_label_as_attachment(shipment, content, i) 199 | shipping_label.append(file_url) 200 | 201 | return shipping_label 202 | 203 | 204 | def save_label_as_attachment(shipment: str, content: bytes, index: int = None) -> str: 205 | """Store label as attachment to Shipment and return the URL.""" 206 | attachment = frappe.new_doc("File") 207 | if index is not None: 208 | attachment.file_name = f"label_{shipment}_{index}.pdf" 209 | else: 210 | attachment.file_name = f"label_{shipment}.pdf" 211 | attachment.content = content 212 | attachment.folder = "Home/Attachments" 213 | attachment.attached_to_doctype = "Shipment" 214 | attachment.attached_to_name = shipment 215 | attachment.is_private = 1 216 | attachment.save() 217 | return attachment.file_url 218 | 219 | 220 | @frappe.whitelist() 221 | def update_tracking(shipment, service_provider, shipment_id, delivery_notes=None): 222 | if isinstance(delivery_notes, str): 223 | delivery_notes = json.loads(delivery_notes) 224 | 225 | if delivery_notes is None: 226 | delivery_notes = [] 227 | 228 | # Update Tracking info in Shipment 229 | tracking_data = None 230 | if service_provider == LETMESHIP_PROVIDER: 231 | letmeship = get_letmeship_utils() 232 | tracking_data = letmeship.get_tracking_data(shipment_id) 233 | elif service_provider == SENDCLOUD_PROVIDER: 234 | sendcloud = SendCloudUtils() 235 | tracking_data = sendcloud.get_tracking_data(shipment_id) 236 | 237 | if not tracking_data: 238 | return 239 | 240 | shipment = frappe.get_doc("Shipment", shipment) 241 | shipment.db_set( 242 | { 243 | "awb_number": tracking_data.get("awb_number"), 244 | "tracking_status": tracking_data.get("tracking_status"), 245 | "tracking_status_info": tracking_data.get("tracking_status_info"), 246 | "tracking_url": tracking_data.get("tracking_url"), 247 | } 248 | ) 249 | 250 | if delivery_notes: 251 | update_delivery_note(delivery_notes=delivery_notes, tracking_info=tracking_data) 252 | 253 | 254 | def update_delivery_note(delivery_notes, shipment_info=None, tracking_info=None): 255 | # Update Shipment Info in Delivery Note 256 | # Using db_set since some services might not exist 257 | delivery_notes = list(set(delivery_notes)) 258 | 259 | for delivery_note in delivery_notes: 260 | dl_doc = frappe.get_doc("Delivery Note", delivery_note) 261 | if shipment_info: 262 | dl_doc.db_set("delivery_type", "Parcel Service") 263 | dl_doc.db_set("parcel_service", shipment_info.get("carrier")) 264 | dl_doc.db_set("parcel_service_type", shipment_info.get("carrier_service")) 265 | if tracking_info: 266 | dl_doc.db_set("tracking_number", tracking_info.get("awb_number")) 267 | dl_doc.db_set("tracking_url", tracking_info.get("tracking_url")) 268 | dl_doc.db_set("tracking_status", tracking_info.get("tracking_status")) 269 | dl_doc.db_set("tracking_status_info", tracking_info.get("tracking_status_info")) 270 | -------------------------------------------------------------------------------- /erpnext_shipping/erpnext_shipping/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020, Frappe Technologies and contributors 2 | # For license information, please see license.txt 3 | import re 4 | 5 | import frappe 6 | from frappe import _ 7 | from frappe.utils.data import get_link_to_form 8 | 9 | 10 | def get_tracking_url(carrier, tracking_number): 11 | # Return the formatted Tracking URL. 12 | tracking_url = "" 13 | url_reference = frappe.get_value("Parcel Service", carrier, "url_reference") 14 | if url_reference: 15 | tracking_url = frappe.render_template(url_reference, {"tracking_number": tracking_number}) 16 | return tracking_url 17 | 18 | 19 | def get_address(address_name): 20 | address = frappe.db.get_value( 21 | "Address", 22 | address_name, 23 | [ 24 | "address_title", 25 | "address_line1", 26 | "address_line2", 27 | "city", 28 | "pincode", 29 | "country", 30 | ], 31 | as_dict=1, 32 | ) 33 | validate_address(address) 34 | 35 | address.country = address.country.strip() 36 | address.country_code = get_country_code(address.country) 37 | address.pincode = address.pincode.replace(" ", "") 38 | address.city = address.city.strip() 39 | 40 | return address 41 | 42 | 43 | def validate_address(address): 44 | if not address.country: 45 | frappe.throw(f"Please add a valid country in Address {address.address_title}.") 46 | 47 | if not address.pincode or address.pincode.strip() == "": 48 | frappe.throw(_("Please add a valid pincode in Address {0}.").format(address.address_title)) 49 | 50 | 51 | def validate_parcels(doc, method=None): 52 | if doc.docstatus != 0: 53 | return 54 | 55 | for parcel in doc.shipment_parcel: 56 | for field in ("length", "width", "height"): 57 | if (parcel.get(field) or 0) < 1: 58 | frappe.throw( 59 | _("Parcel row {idx}: {field_label} must be at least 1 cm.").format( 60 | idx=parcel.idx, field_label=_(parcel.meta.get_label(field)) 61 | ) 62 | ) 63 | 64 | 65 | def validate_phone(doc, method=None): 66 | if doc.pickup_from_type == "Company": 67 | phone_number = frappe.db.get_value("User", doc.pickup_contact_person, "phone") 68 | else: 69 | phone_number = frappe.db.get_value("Contact", doc.pickup_contact_name, "phone") 70 | 71 | if not phone_number: 72 | frappe.throw(_("Pickup contact phone is required.")) 73 | 74 | if not re.match(r"^\+(?![\s0])[\d\s]+\d$", phone_number): 75 | frappe.throw(_("Pickup contact phone must consist of a '+' followed by one or more digits.")) 76 | 77 | 78 | def get_country_code(country_name): 79 | country_code = frappe.db.get_value("Country", country_name, "code") 80 | if not country_code: 81 | frappe.throw(_("Country Code not found for {0}").format(country_name)) 82 | return country_code 83 | 84 | 85 | def get_contact(contact_name): 86 | fields = ["first_name", "last_name", "email_id", "phone", "mobile_no", "gender"] 87 | contact = frappe.db.get_value("Contact", contact_name, fields, as_dict=1) 88 | 89 | if not contact.last_name: 90 | frappe.throw( 91 | msg=_("Please set Last Name for Contact {0}").format(get_link_to_form("Contact", contact_name)), 92 | title=_("Last Name is mandatory to continue."), 93 | ) 94 | 95 | if not contact.phone: 96 | contact.phone = contact.mobile_no 97 | 98 | return contact 99 | 100 | 101 | def match_parcel_service_type_carrier( 102 | shipment_prices: list[dict], carrier_fieldname: str, service_fieldname: str 103 | ): 104 | from erpnext_shipping.erpnext_shipping.doctype.parcel_service_type.parcel_service_type import ( 105 | match_parcel_service_type_alias, 106 | ) 107 | 108 | for idx, prices in enumerate(shipment_prices): 109 | service_name = match_parcel_service_type_alias( 110 | prices.get(carrier_fieldname), prices.get(service_fieldname) 111 | ) 112 | is_preferred = frappe.db.get_value( 113 | "Parcel Service Type", service_name, "show_in_preferred_services_list" 114 | ) 115 | if is_preferred: 116 | shipment_prices[idx].is_preferred = is_preferred 117 | 118 | return shipment_prices 119 | 120 | 121 | def show_error_alert(action): 122 | log = frappe.log_error(title="Shipping Error") 123 | link_to_log = get_link_to_form("Error Log", log.name, "See what happened.") 124 | frappe.msgprint( 125 | msg=_("An Error occurred while {0}. {1}").format(action, link_to_log), indicator="orange", alert=True 126 | ) 127 | 128 | 129 | def update_tracking_info_daily(): 130 | """Daily scheduled event to update Tracking info for not delivered Shipments 131 | 132 | Also Updates the related Delivery Notes. 133 | """ 134 | from erpnext_shipping.erpnext_shipping.shipping import update_tracking 135 | 136 | shipments = frappe.get_all( 137 | "Shipment", 138 | filters={ 139 | "docstatus": 1, 140 | "status": "Booked", 141 | "shipment_id": ["!=", ""], 142 | "tracking_status": ["!=", "Delivered"], 143 | }, 144 | ) 145 | for shipment in shipments: 146 | shipment_doc = frappe.get_doc("Shipment", shipment.name) 147 | tracking_info = update_tracking( 148 | shipment.name, 149 | shipment_doc.service_provider, 150 | shipment_doc.shipment_id, 151 | shipment_doc.shipment_delivery_note, 152 | ) 153 | 154 | if tracking_info: 155 | fields = ["awb_number", "tracking_status", "tracking_status_info", "tracking_url"] 156 | for field in fields: 157 | shipment_doc.db_set(field, tracking_info.get(field)) 158 | -------------------------------------------------------------------------------- /erpnext_shipping/hooks.py: -------------------------------------------------------------------------------- 1 | from . import __version__ as app_version 2 | 3 | app_name = "erpnext_shipping" 4 | app_title = "ERPNext Shipping" 5 | app_publisher = "Frappe" 6 | app_description = "A Shipping Integration fir ERPNext" 7 | app_icon = "octicon octicon-file-directory" 8 | app_color = "grey" 9 | app_email = "developers@frappe.io" 10 | app_license = "MIT" 11 | 12 | # Includes in 13 | # ------------------ 14 | 15 | # include js, css files in header of desk.html 16 | # app_include_css = "/assets/erpnext_shipping/css/erpnext_shipping.css" 17 | app_include_js = "shipping.bundle.js" 18 | 19 | # include js, css files in header of web template 20 | # web_include_css = "/assets/erpnext_shipping/css/erpnext_shipping.css" 21 | # web_include_js = "/assets/erpnext_shipping/js/erpnext_shipping.js" 22 | 23 | # include custom scss in every website theme (without file extension ".scss") 24 | # website_theme_scss = "erpnext_shipping/public/scss/website" 25 | 26 | # include js, css files in header of web form 27 | # webform_include_js = {"doctype": "public/js/doctype.js"} 28 | # webform_include_css = {"doctype": "public/css/doctype.css"} 29 | 30 | # include js in page 31 | # page_js = {"page" : "public/js/file.js"} 32 | 33 | # include js in doctype views 34 | doctype_js = {"Shipment": "public/js/shipment.js"} 35 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 36 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 37 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 38 | 39 | # Home Pages 40 | # ---------- 41 | 42 | # application home page (will override Website Settings) 43 | # home_page = "login" 44 | 45 | # website user home page (by Role) 46 | # role_home_page = { 47 | # "Role": "home_page" 48 | # } 49 | 50 | # Generators 51 | # ---------- 52 | 53 | # automatically create page for each record of this doctype 54 | # website_generators = ["Web Page"] 55 | 56 | # Installation 57 | # ------------ 58 | 59 | # before_install = "erpnext_shipping.install.before_install" 60 | after_install = "erpnext_shipping.install.after_install" 61 | 62 | # Desk Notifications 63 | # ------------------ 64 | # See frappe.core.notifications.get_notification_config 65 | 66 | # notification_config = "erpnext_shipping.notifications.get_notification_config" 67 | 68 | # Permissions 69 | # ----------- 70 | # Permissions evaluated in scripted ways 71 | 72 | # permission_query_conditions = { 73 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 74 | # } 75 | # 76 | # has_permission = { 77 | # "Event": "frappe.desk.doctype.event.event.has_permission", 78 | # } 79 | 80 | # DocType Class 81 | # --------------- 82 | # Override standard doctype classes 83 | 84 | # override_doctype_class = { 85 | # "ToDo": "custom_app.overrides.CustomToDo" 86 | # } 87 | 88 | # Document Events 89 | # --------------- 90 | # Hook on document methods and events 91 | 92 | # doc_events = { 93 | # "*": { 94 | # "on_update": "method", 95 | # "on_cancel": "method", 96 | # "on_trash": "method" 97 | # } 98 | # } 99 | 100 | # Scheduled Tasks 101 | # --------------- 102 | 103 | scheduler_events = {"daily": ["erpnext_shipping.erpnext_shipping.utils.update_tracking_info_daily"]} 104 | 105 | # Testing 106 | # ------- 107 | 108 | # before_tests = "erpnext_shipping.install.before_tests" 109 | 110 | # Overriding Methods 111 | # ------------------------------ 112 | # 113 | # override_whitelisted_methods = { 114 | # "frappe.desk.doctype.event.event.get_events": "erpnext_shipping.event.get_events" 115 | # } 116 | # 117 | # each overriding function accepts a `data` argument; 118 | # generated from the base implementation of the doctype dashboard, 119 | # along with any modifications made in other Frappe apps 120 | # override_doctype_dashboards = { 121 | # "Task": "erpnext_shipping.task.get_dashboard_data" 122 | # } 123 | 124 | # exempt linked doctypes from being automatically cancelled 125 | # 126 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 127 | 128 | doc_events = { 129 | "Shipment": { 130 | "validate": [ 131 | "erpnext_shipping.erpnext_shipping.utils.validate_parcels", 132 | "erpnext_shipping.erpnext_shipping.utils.validate_phone", 133 | ] 134 | }, 135 | } 136 | -------------------------------------------------------------------------------- /erpnext_shipping/install.py: -------------------------------------------------------------------------------- 1 | from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 2 | 3 | from .custom_fields import get_custom_fields 4 | from .property_setters import get_property_setters 5 | from .utils import make_property_setters 6 | 7 | 8 | def after_install(): 9 | create_custom_fields(get_custom_fields()) 10 | make_property_setters(get_property_setters()) 11 | -------------------------------------------------------------------------------- /erpnext_shipping/modules.txt: -------------------------------------------------------------------------------- 1 | ERPNext Shipping -------------------------------------------------------------------------------- /erpnext_shipping/patches.txt: -------------------------------------------------------------------------------- 1 | erpnext_shipping.erpnext_shipping.patches.create_custom_delivery_note_fields # 2024-01-29 2 | erpnext_shipping.erpnext_shipping.patches.change_tracking_url_column_type 3 | execute:from erpnext_shipping.install import after_install;after_install() -------------------------------------------------------------------------------- /erpnext_shipping/property_setters.py: -------------------------------------------------------------------------------- 1 | def get_property_setters(): 2 | return [ 3 | # ("Address", "state", "hidden", 1), 4 | # ("Address", None, "track_changes", 1), 5 | ] 6 | -------------------------------------------------------------------------------- /erpnext_shipping/public/js/shipment.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Shipment", { 5 | refresh: function (frm) { 6 | if (frm.doc.docstatus === 1 && !frm.doc.shipment_id) { 7 | frm.add_custom_button(__("Fetch Shipping Rates"), function () { 8 | if (frm.doc.shipment_parcel.length > 1) { 9 | frappe.confirm( 10 | __( 11 | "If your shipment contains packages with varying weights, the estimated shipping rates may differ from the final price charged by your carrier. Do you wish to proceed?" 12 | ), 13 | function () { 14 | frm.events.fetch_shipping_rates(frm); 15 | } 16 | ); 17 | } else { 18 | frm.events.fetch_shipping_rates(frm); 19 | } 20 | }); 21 | } 22 | if (frm.doc.shipment_id) { 23 | frm.add_custom_button( 24 | __("Print Shipping Label"), 25 | function () { 26 | return frm.events.print_shipping_label(frm); 27 | }, 28 | __("Tools") 29 | ); 30 | if (frm.doc.tracking_status != "Delivered") { 31 | frm.add_custom_button( 32 | __("Update Tracking"), 33 | function () { 34 | return frm.events.update_tracking( 35 | frm, 36 | frm.doc.service_provider, 37 | frm.doc.shipment_id 38 | ); 39 | }, 40 | __("Tools") 41 | ); 42 | 43 | frm.add_custom_button( 44 | __("Track Status"), 45 | function () { 46 | if (frm.doc.tracking_url) { 47 | const urls = frm.doc.tracking_url.split(", "); 48 | urls.forEach((url) => window.open(url)); 49 | } else { 50 | let msg = __( 51 | "Please complete Shipment (ID: {0}) on {1} and Update Tracking.", 52 | [frm.doc.shipment_id, frm.doc.service_provider] 53 | ); 54 | frappe.msgprint({ message: msg, title: __("Incomplete Shipment") }); 55 | } 56 | }, 57 | __("View") 58 | ); 59 | } 60 | } 61 | }, 62 | 63 | fetch_shipping_rates: function (frm) { 64 | if (!frm.doc.shipment_id) { 65 | frappe.call({ 66 | method: "erpnext_shipping.erpnext_shipping.shipping.fetch_shipping_rates", 67 | freeze: true, 68 | freeze_message: __("Fetching Shipping Rates"), 69 | args: { 70 | pickup_from_type: frm.doc.pickup_from_type, 71 | delivery_to_type: frm.doc.delivery_to_type, 72 | pickup_address_name: frm.doc.pickup_address_name, 73 | delivery_address_name: frm.doc.delivery_address_name, 74 | parcels: frm.doc.shipment_parcel, 75 | description_of_content: frm.doc.description_of_content, 76 | pickup_date: frm.doc.pickup_date, 77 | pickup_contact_name: 78 | frm.doc.pickup_from_type === "Company" 79 | ? frm.doc.pickup_contact_person 80 | : frm.doc.pickup_contact_name, 81 | delivery_contact_name: frm.doc.delivery_contact_name, 82 | value_of_goods: frm.doc.value_of_goods, 83 | }, 84 | callback: function (r) { 85 | if (r.message && r.message.length) { 86 | select_from_available_services(frm, r.message); 87 | } else { 88 | frappe.msgprint({ 89 | message: __("No Shipment Services available"), 90 | title: __("Note"), 91 | }); 92 | } 93 | }, 94 | }); 95 | } else { 96 | frappe.throw(__("Shipment already created")); 97 | } 98 | }, 99 | 100 | print_shipping_label: function (frm) { 101 | frappe.call({ 102 | method: "erpnext_shipping.erpnext_shipping.shipping.print_shipping_label", 103 | freeze: true, 104 | freeze_message: __("Printing Shipping Label"), 105 | args: { 106 | shipment: frm.doc.name, 107 | }, 108 | callback: function (r) { 109 | if (r.message) { 110 | if (frm.doc.service_provider == "LetMeShip") { 111 | var array = JSON.parse(r.message); 112 | // Uint8Array for unsigned bytes 113 | array = new Uint8Array(array); 114 | const file = new Blob([array], { type: "application/pdf" }); 115 | const file_url = URL.createObjectURL(file); 116 | window.open(file_url); 117 | } else { 118 | if (Array.isArray(r.message)) { 119 | r.message.forEach((url) => window.open(url)); 120 | } else { 121 | window.open(r.message); 122 | } 123 | } 124 | } 125 | }, 126 | }); 127 | }, 128 | 129 | update_tracking: function (frm, service_provider, shipment_id) { 130 | const delivery_notes = frm.doc.shipment_delivery_note.map((d) => d.delivery_note); 131 | 132 | frappe.call({ 133 | method: "erpnext_shipping.erpnext_shipping.shipping.update_tracking", 134 | freeze: true, 135 | freeze_message: __("Updating Tracking"), 136 | args: { 137 | shipment: frm.doc.name, 138 | shipment_id: shipment_id, 139 | service_provider: service_provider, 140 | delivery_notes: delivery_notes, 141 | }, 142 | callback: function (r) { 143 | if (!r.exc) { 144 | frm.reload_doc(); 145 | } 146 | }, 147 | }); 148 | }, 149 | }); 150 | 151 | function select_from_available_services(frm, available_services) { 152 | const arranged_services = available_services.reduce( 153 | (prev, curr) => { 154 | if (curr.is_preferred) { 155 | prev.preferred_services.push(curr); 156 | } else { 157 | prev.other_services.push(curr); 158 | } 159 | return prev; 160 | }, 161 | { preferred_services: [], other_services: [] } 162 | ); 163 | 164 | const dialog = new frappe.ui.Dialog({ 165 | title: __("Select Service to Create Shipment"), 166 | size: "extra-large", 167 | fields: [ 168 | { 169 | fieldtype: "HTML", 170 | fieldname: "available_services", 171 | label: __("Available Services"), 172 | }, 173 | ], 174 | }); 175 | 176 | const delivery_notes = frm.doc.shipment_delivery_note.map((d) => d.delivery_note); 177 | 178 | dialog.fields_dict.available_services.$wrapper.html( 179 | frappe.render_template("shipment_service_selector", { 180 | header_columns: [__("Platform"), __("Carrier"), __("Parcel Service"), __("Price"), ""], 181 | data: arranged_services, 182 | }) 183 | ); 184 | 185 | dialog.$body.on("click", ".btn", function () { 186 | let service_type = $(this).attr("data-type"); 187 | let service_index = cint($(this).attr("id").split("-")[2]); 188 | let service_data = arranged_services[service_type][service_index]; 189 | frm.select_row(service_data); 190 | }); 191 | 192 | frm.select_row = function (service_data) { 193 | frappe.call({ 194 | method: "erpnext_shipping.erpnext_shipping.shipping.create_shipment", 195 | freeze: true, 196 | freeze_message: __("Creating Shipment"), 197 | args: { 198 | shipment: frm.doc.name, 199 | pickup_from_type: frm.doc.pickup_from_type, 200 | delivery_to_type: frm.doc.delivery_to_type, 201 | pickup_address_name: frm.doc.pickup_address_name, 202 | delivery_address_name: frm.doc.delivery_address_name, 203 | shipment_parcel: frm.doc.shipment_parcel, 204 | description_of_content: frm.doc.description_of_content, 205 | pickup_date: frm.doc.pickup_date, 206 | pickup_contact_name: 207 | frm.doc.pickup_from_type === "Company" 208 | ? frm.doc.pickup_contact_person 209 | : frm.doc.pickup_contact_name, 210 | delivery_contact_name: frm.doc.delivery_contact_name, 211 | value_of_goods: frm.doc.value_of_goods, 212 | service_data: service_data, 213 | delivery_notes: delivery_notes, 214 | }, 215 | callback: function (r) { 216 | if (!r.exc) { 217 | frm.reload_doc(); 218 | frappe.msgprint({ 219 | message: __("Shipment {1} has been created with {0}.", [ 220 | r.message.service_provider, 221 | r.message.shipment_id.bold(), 222 | ]), 223 | title: __("Shipment Created"), 224 | indicator: "green", 225 | }); 226 | frm.events.update_tracking( 227 | frm, 228 | r.message.service_provider, 229 | r.message.shipment_id 230 | ); 231 | } 232 | }, 233 | }); 234 | dialog.hide(); 235 | }; 236 | dialog.show(); 237 | } 238 | -------------------------------------------------------------------------------- /erpnext_shipping/public/js/shipment_service_selector.html: -------------------------------------------------------------------------------- 1 | {% if (data.preferred_services.length || data.other_services.length) { %} 2 |
3 |
{{ __("Preferred Services") }}
4 | {% if (data.preferred_services.length) { %} 5 | 6 | 7 | 8 | {% for (var i = 0; i < header_columns.length; i++) { %} 9 | 10 | {% } %} 11 | 12 | 13 | 14 | {% for (var i = 0; i < data.preferred_services.length; i++) { %} 15 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | {% } %} 29 | 30 |
{{ header_columns[i] }}
{{ data.preferred_services[i].service_provider }}{{ data.preferred_services[i].carrier }}{{ data.preferred_services[i].service_name }}{{ format_currency(data.preferred_services[i].total_price, data.preferred_services[i].currency, 2) }} 21 | 26 |
31 | {% } else { %} 32 |
33 | 34 | {{ __("No Preferred Services Available") }} 35 | 36 |
37 | {% } %} 38 |
{{ __("Other Services") }}
39 | {% if (data.other_services.length) { %} 40 | 41 | 42 | 43 | {% for (var i = 0; i < header_columns.length; i++) { %} 44 | 45 | {% } %} 46 | 47 | 48 | 49 | {% for (var i = 0; i < data.other_services.length; i++) { %} 50 | 51 | 52 | 53 | 54 | 55 | 62 | 63 | {% } %} 64 | 65 |
{{ header_columns[i] }}
{{ data.other_services[i].service_provider }}{{ data.other_services[i].carrier }}{{ data.other_services[i].service_name }}{{ format_currency(data.other_services[i].total_price, data.other_services[i].currency, 2) }} 56 | 61 |
66 | {% } else { %} 67 |
68 | 69 | {{ __("No Services Available") }} 70 | 71 |
72 | {% } %} 73 |
74 | {% } else { %} 75 |
76 | 77 | {{ __("No Services Available") }} 78 | 79 |
80 | {% } %} 81 | 82 | -------------------------------------------------------------------------------- /erpnext_shipping/public/shipping.bundle.js: -------------------------------------------------------------------------------- 1 | import "./js/shipment_service_selector.html"; 2 | -------------------------------------------------------------------------------- /erpnext_shipping/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/templates/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/erpnext-shipping/abb425892633661a2f2322ec6d3136bbba925725/erpnext_shipping/templates/pages/__init__.py -------------------------------------------------------------------------------- /erpnext_shipping/translations/de.csv: -------------------------------------------------------------------------------- 1 | API ID,API-ID, 2 | API Key,API-Schlüssel, 3 | API Password,API-Passwort, 4 | API Secret,API-Geheimnis, 5 | Carrier,Dienstleister, 6 | Creating Shipment,Sendung wird erstellt, 7 | Delivery Type,Art der Lieferung, 8 | Enabled,Aktiviert, 9 | Fetch Shipping Rates,Versandtarife abrufen, 10 | Fetching Shipping Rates,Versandtarife werden abgerufen, 11 | Incomplete Shipment,Unvollständige Sendung, 12 | No Preferred Services Available,Keine bevorzugten Dienste verfügbar, 13 | No Services Available,Keine Dienste verfügbar, 14 | No Shipment Services available,Keine Versanddienste verfügbar, 15 | Other Services,Andere Dienste, 16 | Parcel Service Type,Paketdiensttyp, 17 | Parcel Service,Paketdienst, 18 | Parcel Service,Sendungstyp, 19 | Platform,Plattform, 20 | Please complete Shipment (ID: {0}) on {1} and Update Tracking.,Bitte vervollständigen Sie die Sendung (ID: {0}) bei {1} und aktualisieren Sie das Tracking., 21 | Please log in as a regular user to use the shipping integration.,"Bitte melden Sie sich als regulärer Benutzer an, um die Versandintegration zu verwenden.", 22 | Preferred Services,Bevorzugte Dienste, 23 | Price,Preis, 24 | Print Shipping Label,Versandetikett drucken, 25 | Printing Shipping Label,Versandetikett wird gedruckt, 26 | Select Service to Create Shipment,"Wählen Sie einen Dienst aus, um eine Sendung zu erstellen", 27 | Select,Auswählen, 28 | Shipment {1} has been created with {0}.,Sendung {1} via {0} wurde erstellt., 29 | Shipment already created,Sendung wurde bereits erstellt, 30 | Track Status,Status verfolgen, 31 | Tracking Number,Sendungsnummer, 32 | Tracking Status Information,Tracking-Statusinformation, 33 | Tracking Status,Tracking-Status, 34 | Tracking URL,Tracking-URL, 35 | Update Tracking,Tracking aktualisieren, 36 | Updating Tracking,Tracking wird aktualisiert, 37 | Use Test Environment,Testumgebung verwenden, 38 | Parcel row {idx}: {field_label} must be at least 1 cm.,Paketzeile {idx}: {field_label} muss mindestens 1 cm betragen., 39 | Pickup contact phone is required.,Telefonnummer des Abholkontakts ist erforderlich., 40 | Pickup contact phone must consist of a '+' followed by one or more digits.,Telefonnummer des Abholkontakts muss aus einem '+' gefolgt von einer oder mehreren Ziffern bestehen., 41 | -------------------------------------------------------------------------------- /erpnext_shipping/utils.py: -------------------------------------------------------------------------------- 1 | from frappe.custom.doctype.customize_form.customize_form import docfield_properties, doctype_properties 2 | from frappe.custom.doctype.property_setter.property_setter import make_property_setter 3 | 4 | 5 | def identity(value): 6 | """Used for dummy translation""" 7 | return value 8 | 9 | 10 | def make_property_setters(property_setters): 11 | for prop_setter in property_setters: 12 | if prop_setter[1]: 13 | for_doctype = False 14 | fieldtype = docfield_properties[prop_setter[2]] 15 | else: 16 | for_doctype = True 17 | # Use workaround for field_order property, maybe add to incustomize_form.py (?) 18 | fieldtype = doctype_properties[prop_setter[2]] if prop_setter[2] != "field_order" else "Data" 19 | 20 | make_property_setter(*prop_setter[:4], fieldtype, for_doctype=for_doctype) 21 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "erpnext_shipping" 3 | authors = [ 4 | { name = "Frappe Technologies Pvt Ltd and contributors", email = "developers@frappe.io"} 5 | ] 6 | description = "Shipping Integrations for ERPNext" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [] 11 | 12 | [tool.bench.frappe-dependencies] 13 | frappe = ">=15.0.0,<16.0.0" 14 | erpnext = ">=15.0.0,<16.0.0" 15 | 16 | [build-system] 17 | requires = ["flit_core >=3.4,<4"] 18 | build-backend = "flit_core.buildapi" 19 | 20 | [tool.ruff] 21 | line-length = 110 22 | 23 | [tool.ruff.format] 24 | indent-style = "tab" 25 | docstring-code-format = true 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # TODO: Remove this file when bench >=v5.11.0 is adopted / v15.0.0 is released 2 | from setuptools import setup 3 | 4 | name = "erpnext_shipping" 5 | 6 | setup() 7 | --------------------------------------------------------------------------------