├── .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 | 
35 |
36 | ### Fetch Shipping Rates
37 | 
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 | 
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 | {{ header_columns[i] }} |
10 | {% } %}
11 |
12 |
13 |
14 | {% for (var i = 0; i < data.preferred_services.length; i++) { %}
15 |
16 | {{ data.preferred_services[i].service_provider }} |
17 | {{ data.preferred_services[i].carrier }} |
18 | {{ data.preferred_services[i].service_name }} |
19 | {{ format_currency(data.preferred_services[i].total_price, data.preferred_services[i].currency, 2) }} |
20 |
21 |
26 | |
27 |
28 | {% } %}
29 |
30 |
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 | {{ header_columns[i] }} |
45 | {% } %}
46 |
47 |
48 |
49 | {% for (var i = 0; i < data.other_services.length; i++) { %}
50 |
51 | {{ data.other_services[i].service_provider }} |
52 | {{ data.other_services[i].carrier }} |
53 | {{ data.other_services[i].service_name }} |
54 | {{ format_currency(data.other_services[i].total_price, data.other_services[i].currency, 2) }} |
55 |
56 |
61 | |
62 |
63 | {% } %}
64 |
65 |
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 |
--------------------------------------------------------------------------------