diff --git a/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md
new file mode 100644
index 00000000000..670d8d280f2
--- /dev/null
+++ b/.github/helper/semgrep_rules/README.md
@@ -0,0 +1,38 @@
+# Semgrep linting
+
+## What is semgrep?
+Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc.
+
+Example:
+
+To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc.
+
+You can read more such examples in `.github/helper/semgrep_rules` directory.
+
+# Why/when to use this?
+We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us.
+
+## Running locally
+
+Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`.
+
+To run locally use following command:
+
+`semgrep --config=.github/helper/semgrep_rules [file/folder names]`
+
+## Testing
+semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/
+
+When writing new rules you should write few positive and few negative cases as shown in the guide and current tests.
+
+To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules`
+
+
+## Reference
+
+If you are new to Semgrep read following pages to get started on writing/modifying rules:
+
+- https://semgrep.dev/docs/getting-started/
+- https://semgrep.dev/docs/writing-rules/rule-syntax
+- https://semgrep.dev/docs/writing-rules/pattern-examples/
+- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases
diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py
new file mode 100644
index 00000000000..4798b927f83
--- /dev/null
+++ b/.github/helper/semgrep_rules/frappe_correctness.py
@@ -0,0 +1,28 @@
+import frappe
+from frappe import _, flt
+
+from frappe.model.document import Document
+
+
+def on_submit(self):
+ if self.value_of_goods == 0:
+ frappe.throw(_('Value of goods cannot be 0'))
+ # ruleid: frappe-modifying-after-submit
+ self.status = 'Submitted'
+
+def on_submit(self):
+ if flt(self.per_billed) < 100:
+ self.update_billing_status()
+ else:
+ # todook: frappe-modifying-after-submit
+ self.status = "Completed"
+ self.db_set("status", "Completed")
+
+class TestDoc(Document):
+ pass
+
+ def validate(self):
+ #ruleid: frappe-modifying-child-tables-while-iterating
+ for item in self.child_table:
+ if item.value < 0:
+ self.remove(item)
diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml
new file mode 100644
index 00000000000..54df0624806
--- /dev/null
+++ b/.github/helper/semgrep_rules/frappe_correctness.yml
@@ -0,0 +1,74 @@
+# This file specifies rules for correctness according to how frappe doctype data model works.
+
+rules:
+- id: frappe-modifying-after-submit
+ patterns:
+ - pattern: self.$ATTR = ...
+ - pattern-inside: |
+ def on_submit(self, ...):
+ ...
+ - metavariable-regex:
+ metavariable: '$ATTR'
+ # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me)
+ regex: '^(?!status_updater)(.*)$'
+ message: |
+ Doctype modified after submission. Please check if modification of self.$ATTR is commited to database.
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-modifying-after-cancel
+ patterns:
+ - pattern: self.$ATTR = ...
+ - pattern-inside: |
+ def on_cancel(self, ...):
+ ...
+ - metavariable-regex:
+ metavariable: '$ATTR'
+ regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$'
+ message: |
+ Doctype modified after cancellation. Please check if modification of self.$ATTR is commited to database.
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-print-function-in-doctypes
+ pattern: print(...)
+ message: |
+ Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement.
+ languages: [python]
+ severity: WARNING
+ paths:
+ exclude:
+ - test_*.py
+ include:
+ - "*/**/doctype/*"
+
+- id: frappe-modifying-child-tables-while-iterating
+ pattern-either:
+ - pattern: |
+ for $ROW in self.$TABLE:
+ ...
+ self.remove(...)
+ - pattern: |
+ for $ROW in self.$TABLE:
+ ...
+ self.append(...)
+ message: |
+ Child table being modified while iterating on it.
+ languages: [python]
+ severity: ERROR
+ paths:
+ include:
+ - "*/**/doctype/*"
+
+- id: frappe-same-key-assigned-twice
+ pattern-either:
+ - pattern: |
+ {..., $X: $A, ..., $X: $B, ...}
+ - pattern: |
+ dict(..., ($X, $A), ..., ($X, $B), ...)
+ - pattern: |
+ _dict(..., ($X, $A), ..., ($X, $B), ...)
+ message: |
+ key `$X` is uselessly assigned twice. This could be a potential bug.
+ languages: [python]
+ severity: ERROR
diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py
new file mode 100644
index 00000000000..f477d7c1768
--- /dev/null
+++ b/.github/helper/semgrep_rules/security.py
@@ -0,0 +1,6 @@
+def function_name(input):
+ # ruleid: frappe-codeinjection-eval
+ eval(input)
+
+# ok: frappe-codeinjection-eval
+eval("1 + 1")
diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml
new file mode 100644
index 00000000000..5a5098bf506
--- /dev/null
+++ b/.github/helper/semgrep_rules/security.yml
@@ -0,0 +1,25 @@
+rules:
+- id: frappe-codeinjection-eval
+ patterns:
+ - pattern-not: eval("...")
+ - pattern: eval(...)
+ message: |
+ Detected the use of eval(). eval() can be dangerous if used to evaluate
+ dynamic content. Avoid it or use safe_eval().
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-sqli-format-strings
+ patterns:
+ - pattern-inside: |
+ @frappe.whitelist()
+ def $FUNC(...):
+ ...
+ - pattern-either:
+ - pattern: frappe.db.sql("..." % ...)
+ - pattern: frappe.db.sql(f"...", ...)
+ - pattern: frappe.db.sql("...".format(...), ...)
+ message: |
+ Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines
+ languages: [python]
+ severity: WARNING
diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js
new file mode 100644
index 00000000000..7b92fe2dffb
--- /dev/null
+++ b/.github/helper/semgrep_rules/translate.js
@@ -0,0 +1,37 @@
+// ruleid: frappe-translation-empty-string
+__("")
+// ruleid: frappe-translation-empty-string
+__('')
+
+// ok: frappe-translation-js-formatting
+__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]);
+
+// ruleid: frappe-translation-js-formatting
+__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`);
+
+// ok: frappe-translation-js-formatting
+__('This is fine');
+
+
+// ok: frappe-translation-trailing-spaces
+__('This is fine');
+
+// ruleid: frappe-translation-trailing-spaces
+__(' this is not ok ');
+// ruleid: frappe-translation-trailing-spaces
+__('this is not ok ');
+// ruleid: frappe-translation-trailing-spaces
+__(' this is not ok');
+
+// ok: frappe-translation-js-splitting
+__('You have {0} subscribers in your mailing list.', [subscribers.length])
+
+// todoruleid: frappe-translation-js-splitting
+__('You have') + subscribers.length + __('subscribers in your mailing list.')
+
+// ruleid: frappe-translation-js-splitting
+__('You have' + 'subscribers in your mailing list.')
+
+// ruleid: frappe-translation-js-splitting
+__('You have {0} subscribers' +
+ 'in your mailing list', [subscribers.length])
diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py
new file mode 100644
index 00000000000..bd6cd9126c9
--- /dev/null
+++ b/.github/helper/semgrep_rules/translate.py
@@ -0,0 +1,53 @@
+# Examples taken from https://frappeframework.com/docs/user/en/translations
+# This file is used for testing the tests.
+
+from frappe import _
+
+full_name = "Jon Doe"
+# ok: frappe-translation-python-formatting
+_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name)
+
+# ruleid: frappe-translation-python-formatting
+_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name)
+# ruleid: frappe-translation-python-formatting
+_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name})
+
+# ruleid: frappe-translation-python-formatting
+_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name))
+
+
+subscribers = ["Jon", "Doe"]
+# ok: frappe-translation-python-formatting
+_('You have {0} subscribers in your mailing list.').format(len(subscribers))
+
+# ruleid: frappe-translation-python-splitting
+_('You have') + len(subscribers) + _('subscribers in your mailing list.')
+
+# ruleid: frappe-translation-python-splitting
+_('You have {0} subscribers \
+ in your mailing list').format(len(subscribers))
+
+# ok: frappe-translation-python-splitting
+_('You have {0} subscribers') \
+ + 'in your mailing list'
+
+# ruleid: frappe-translation-trailing-spaces
+msg = _(" You have {0} pending invoice ")
+# ruleid: frappe-translation-trailing-spaces
+msg = _("You have {0} pending invoice ")
+# ruleid: frappe-translation-trailing-spaces
+msg = _(" You have {0} pending invoice")
+
+# ok: frappe-translation-trailing-spaces
+msg = ' ' + _("You have {0} pending invoices") + ' '
+
+# ruleid: frappe-translation-python-formatting
+_(f"can not format like this - {subscribers}")
+# ruleid: frappe-translation-python-splitting
+_(f"what" + f"this is also not cool")
+
+
+# ruleid: frappe-translation-empty-string
+_("")
+# ruleid: frappe-translation-empty-string
+_('')
diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml
new file mode 100644
index 00000000000..3737da5a7e2
--- /dev/null
+++ b/.github/helper/semgrep_rules/translate.yml
@@ -0,0 +1,63 @@
+rules:
+- id: frappe-translation-empty-string
+ pattern-either:
+ - pattern: _("")
+ - pattern: __("")
+ message: |
+ Empty string is useless for translation.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [python, javascript, json]
+ severity: ERROR
+
+- id: frappe-translation-trailing-spaces
+ pattern-either:
+ - pattern: _("=~/(^[ \t]+|[ \t]+$)/")
+ - pattern: __("=~/(^[ \t]+|[ \t]+$)/")
+ message: |
+ Trailing or leading whitespace not allowed in translate strings.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [python, javascript, json]
+ severity: ERROR
+
+- id: frappe-translation-python-formatting
+ pattern-either:
+ - pattern: _("..." % ...)
+ - pattern: _("...".format(...))
+ - pattern: _(f"...")
+ message: |
+ Only positional formatters are allowed and formatting should not be done before translating.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-translation-js-formatting
+ patterns:
+ - pattern: __(`...`)
+ - pattern-not: __("...")
+ message: |
+ Template strings are not allowed for text formatting.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [javascript, json]
+ severity: ERROR
+
+- id: frappe-translation-python-splitting
+ pattern-either:
+ - pattern: _(...) + ... + _(...)
+ - pattern: _("..." + "...")
+ - pattern-regex: '_\([^\)]*\\\s*'
+ message: |
+ Do not split strings inside translate function. Do not concatenate using translate functions.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-translation-js-splitting
+ pattern-either:
+ - pattern-regex: '__\([^\)]*[\+\\]\s*'
+ - pattern: __('...' + '...')
+ - pattern: __('...') + __('...')
+ message: |
+ Do not split strings inside translate function. Do not concatenate using translate functions.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages: [javascript, json]
+ severity: ERROR
diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py
new file mode 100644
index 00000000000..4a744574350
--- /dev/null
+++ b/.github/helper/semgrep_rules/ux.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe import msgprint, throw, _
+
+
+# ruleid: frappe-missing-translate-function
+throw("Error Occured")
+
+# ruleid: frappe-missing-translate-function
+frappe.throw("Error Occured")
+
+# ruleid: frappe-missing-translate-function
+frappe.msgprint("Useful message")
+
+# ruleid: frappe-missing-translate-function
+msgprint("Useful message")
+
+
+# ok: frappe-missing-translate-function
+translatedmessage = _("Hello")
+
+# ok: frappe-missing-translate-function
+throw(translatedmessage)
+
+# ok: frappe-missing-translate-function
+msgprint(translatedmessage)
+
+# ok: frappe-missing-translate-function
+msgprint(_("Helpful message"))
+
+# ok: frappe-missing-translate-function
+frappe.throw(_("Error occured"))
diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml
new file mode 100644
index 00000000000..ed06a6a80c9
--- /dev/null
+++ b/.github/helper/semgrep_rules/ux.yml
@@ -0,0 +1,15 @@
+rules:
+- id: frappe-missing-translate-function
+ pattern-either:
+ - patterns:
+ - pattern: frappe.msgprint("...", ...)
+ - pattern-not: frappe.msgprint(_("..."), ...)
+ - pattern-not: frappe.msgprint(__("..."), ...)
+ - patterns:
+ - pattern: frappe.throw("...", ...)
+ - pattern-not: frappe.throw(_("..."), ...)
+ - pattern-not: frappe.throw(__("..."), ...)
+ message: |
+ All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
+ languages: [python, javascript, json]
+ severity: ERROR
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
new file mode 100644
index 00000000000..df082632365
--- /dev/null
+++ b/.github/workflows/semgrep.yml
@@ -0,0 +1,24 @@
+name: Semgrep
+
+on:
+ pull_request:
+ branches:
+ - develop
+jobs:
+ semgrep:
+ name: Frappe Linter
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup python3
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.8
+ - name: Run semgrep
+ run: |
+ python -m pip install -q semgrep
+ git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
+ files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
+ [[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files
+ semgrep --config="r/python.lang.correctness" --quiet --error $files
+ [[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index a3c29b6d640..e1276e7da3d 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -12,6 +12,7 @@
"frozen_accounts_modifier",
"determine_address_tax_category_from",
"over_billing_allowance",
+ "role_allowed_to_over_bill",
"column_break_4",
"credit_controller",
"check_supplier_invoice_uniqueness",
@@ -226,6 +227,13 @@
"fieldname": "delete_linked_ledger_entries",
"fieldtype": "Check",
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
+ },
+ {
+ "description": "Users with this role are allowed to over bill above the allowance percentage",
+ "fieldname": "role_allowed_to_over_bill",
+ "fieldtype": "Link",
+ "label": "Role Allowed to Over Bill ",
+ "options": "Role"
}
],
"icon": "icon-cog",
@@ -233,7 +241,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-01-05 13:04:00.118892",
+ "modified": "2021-03-11 18:52:05.601996",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js
index 49b2b186c4b..059e1d31588 100644
--- a/erpnext/accounts/doctype/bank/bank.js
+++ b/erpnext/accounts/doctype/bank/bank.js
@@ -42,10 +42,9 @@ let add_fields_to_mapping_table = function (frm) {
});
});
- frappe.meta.get_docfield("Bank Transaction Mapping", "bank_transaction_field",
- frm.doc.name).options = options;
-
- frm.fields_dict.bank_transaction_mapping.grid.refresh();
+ frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property(
+ 'bank_transaction_field', 'options', options
+ );
};
erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
index 69ee4971cd5..88aa7ef8b59 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json
@@ -175,22 +175,24 @@
},
{
"fieldname": "deposit",
- "oldfieldname": "debit",
"fieldtype": "Currency",
"in_list_view": 1,
- "label": "Deposit"
+ "label": "Deposit",
+ "oldfieldname": "debit",
+ "options": "currency"
},
{
"fieldname": "withdrawal",
- "oldfieldname": "credit",
"fieldtype": "Currency",
"in_list_view": 1,
- "label": "Withdrawal"
+ "label": "Withdrawal",
+ "oldfieldname": "credit",
+ "options": "currency"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2020-12-30 19:40:54.221070",
+ "modified": "2021-04-14 17:31:58.963529",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Transaction",
diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index 17e39d562a2..ce149f96e6f 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -61,7 +61,6 @@ class TestBankTransaction(unittest.TestCase):
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc("Bank Transaction", dict(description="Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07"))
linked_payments = get_linked_payments(bank_transaction.name, ['payment_entry', 'exact_match'])
- print(linked_payments)
self.assertTrue(linked_payments[0][3])
# Check error if already reconciled
diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
index 03c3eb0ac0b..f96f59169e8 100644
--- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
+++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
@@ -293,6 +293,11 @@ def validate_accounts(file_name):
accounts_dict = {}
for account in accounts:
accounts_dict.setdefault(account["account_name"], account)
+ if not hasattr(account, "parent_account"):
+ msg = _("Please make sure the file you are using has 'Parent Account' column present in the header.")
+ msg += "
"
+ msg += _("Alternatively, you can download the template and fill your data in.")
+ frappe.throw(msg, title=_("Parent Account Missing"))
if account["parent_account"] and accounts_dict.get(account["parent_account"]):
accounts_dict[account["parent_account"]]["is_group"] = 1
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index 37b03f3f0e0..d76641dc9bd 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -327,18 +327,16 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({
},
setup_balance_formatter: function() {
- var me = this;
- $.each(["balance", "party_balance"], function(i, field) {
- var df = frappe.meta.get_docfield("Journal Entry Account", field, me.frm.doc.name);
- df.formatter = function(value, df, options, doc) {
- var currency = frappe.meta.get_field_currency(df, doc);
- var dr_or_cr = value ? ('') : "";
- return "
${value}
`; + } else { + value = `${value}
`; + } + } + return value + } +}; diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json new file mode 100644 index 00000000000..100c422433e --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-03-25 15:03:19.857418", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-04-15 15:49:35.432486", + "modified_by": "Administrator", + "module": "Projects", + "name": "Delayed Tasks Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Task", + "report_name": "Delayed Tasks Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Projects User" + }, + { + "role": "Projects Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py new file mode 100644 index 00000000000..cdabe6487ea --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py @@ -0,0 +1,133 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.utils import date_diff, nowdate + +def execute(filters=None): + columns, data = [], [] + data = get_data(filters) + columns = get_columns() + charts = get_chart_data(data) + return columns, data, None, charts + +def get_data(filters): + conditions = get_conditions(filters) + tasks = frappe.get_all("Task", + filters = conditions, + fields = ["name", "subject", "exp_start_date", "exp_end_date", + "status", "priority", "completed_on", "progress"], + order_by="creation" + ) + for task in tasks: + if task.exp_end_date: + if task.completed_on: + task.delay = date_diff(task.completed_on, task.exp_end_date) + elif task.status == "Completed": + # task is completed but completed on is not set (for older tasks) + task.delay = 0 + else: + # task not completed + task.delay = date_diff(nowdate(), task.exp_end_date) + else: + # task has no end date, hence no delay + task.delay = 0 + + # Sort by descending order of delay + tasks.sort(key=lambda x: x["delay"], reverse=True) + return tasks + +def get_conditions(filters): + conditions = frappe._dict() + keys = ["priority", "status"] + for key in keys: + if filters.get(key): + conditions[key] = filters.get(key) + if filters.get("from_date"): + conditions.exp_end_date = [">=", filters.get("from_date")] + if filters.get("to_date"): + conditions.exp_start_date = ["<=", filters.get("to_date")] + return conditions + +def get_chart_data(data): + delay, on_track = 0, 0 + for entry in data: + if entry.get("delay") > 0: + delay = delay + 1 + else: + on_track = on_track + 1 + charts = { + "data": { + "labels": ["On Track", "Delayed"], + "datasets": [ + { + "name": "Delayed", + "values": [on_track, delay] + } + ] + }, + "type": "percentage", + "colors": ["#84D5BA", "#CB4B5F"] + } + return charts + +def get_columns(): + columns = [ + { + "fieldname": "name", + "fieldtype": "Link", + "label": "Task", + "options": "Task", + "width": 150 + }, + { + "fieldname": "subject", + "fieldtype": "Data", + "label": "Subject", + "width": 200 + }, + { + "fieldname": "status", + "fieldtype": "Data", + "label": "Status", + "width": 100 + }, + { + "fieldname": "priority", + "fieldtype": "Data", + "label": "Priority", + "width": 80 + }, + { + "fieldname": "progress", + "fieldtype": "Data", + "label": "Progress (%)", + "width": 120 + }, + { + "fieldname": "exp_start_date", + "fieldtype": "Date", + "label": "Expected Start Date", + "width": 150 + }, + { + "fieldname": "exp_end_date", + "fieldtype": "Date", + "label": "Expected End Date", + "width": 150 + }, + { + "fieldname": "completed_on", + "fieldtype": "Date", + "label": "Actual End Date", + "width": 130 + }, + { + "fieldname": "delay", + "fieldtype": "Data", + "label": "Delay (In Days)", + "width": 120 + } + ] + return columns diff --git a/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py new file mode 100644 index 00000000000..dbeedb4be92 --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +import unittest +import frappe +from frappe.utils import nowdate, add_days, add_months +from erpnext.projects.doctype.task.test_task import create_task +from erpnext.projects.report.delayed_tasks_summary.delayed_tasks_summary import execute + +class TestDelayedTasksSummary(unittest.TestCase): + @classmethod + def setUp(self): + task1 = create_task("_Test Task 98", add_days(nowdate(), -10), nowdate()) + create_task("_Test Task 99", add_days(nowdate(), -10), add_days(nowdate(), -1)) + + task1.status = "Completed" + task1.completed_on = add_days(nowdate(), -1) + task1.save() + + def test_delayed_tasks_summary(self): + filters = frappe._dict({ + "from_date": add_months(nowdate(), -1), + "to_date": nowdate(), + "priority": "Low", + "status": "Open" + }) + expected_data = [ + { + "subject": "_Test Task 99", + "status": "Open", + "priority": "Low", + "delay": 1 + }, + { + "subject": "_Test Task 98", + "status": "Completed", + "priority": "Low", + "delay": -1 + } + ] + report = execute(filters) + data = list(filter(lambda x: x.subject == "_Test Task 99", report[1]))[0] + + for key in ["subject", "status", "priority", "delay"]: + self.assertEqual(expected_data[0].get(key), data.get(key)) + + filters.status = "Completed" + report = execute(filters) + data = list(filter(lambda x: x.subject == "_Test Task 98", report[1]))[0] + + for key in ["subject", "status", "priority", "delay"]: + self.assertEqual(expected_data[1].get(key), data.get(key)) + + def tearDown(self): + for task in ["_Test Task 98", "_Test Task 99"]: + frappe.get_doc("Task", {"subject": task}).delete() \ No newline at end of file diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json index 621c4bb52fb..b65e9aa7808 100644 --- a/erpnext/projects/workspace/projects/projects.json +++ b/erpnext/projects/workspace/projects/projects.json @@ -159,6 +159,16 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "dependencies": "Task", + "hidden": 0, + "is_query_report": 1, + "label": "Delayed Tasks Summary", + "link_to": "Delayed Tasks Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" } ], "modified": "2021-04-16 16:27:16.548780", diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index 649eb454acc..ceeecb28a25 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -276,74 +276,3 @@ erpnext.taxes.set_conditional_mandatory_rate_or_amount = function(grid_row) { } } } - - -// For customizing print -cur_frm.pformat.total = function(doc) { return ''; } -cur_frm.pformat.discount_amount = function(doc) { return ''; } -cur_frm.pformat.grand_total = function(doc) { return ''; } -cur_frm.pformat.rounded_total = function(doc) { return ''; } -cur_frm.pformat.in_words = function(doc) { return ''; } - -cur_frm.pformat.taxes= function(doc){ - //function to make row of table - var make_row = function(title, val, bold, is_negative) { - var bstart = ''; var bend = ''; - return '';
-
- // main table
-
- out +='
'; + message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.'); + + frappe.msgprint({ + title: __('Update E-Way Bill Cancelled Status?'), + message: message, + indicator: 'orange', primary_action: function() { - const data = d.get_values(); frappe.call({ method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', - args: { - doctype, - docname: name, - eway_bill: ewaybill, - reason: data.reason.split('-')[0], - remark: data.remark - }, + args: { doctype, docname: name }, freeze: true, - callback: () => frm.reload_doc() || d.hide(), - error: () => d.hide() + callback: () => frm.reload_doc() }); }, - primary_action_label: __('Submit') + primary_action_label: __('Yes') }); - d.show(); }; add_custom_button(__("Cancel E-Way Bill"), action); } @@ -254,7 +235,7 @@ const get_preview_dialog = (frm, action) => { title: __("Preview"), size: "large", fields: [ - { + { "label": "Preview", "fieldname": "preview_html", "fieldtype": "HTML" diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 96f7f1b224f..1d3cb661dd5 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -15,18 +15,44 @@ import traceback import io from frappe import _, bold from pyqrcode import create as qrcreate +from frappe.utils.background_jobs import enqueue +from frappe.utils.scheduler import is_scheduler_inactive +from frappe.core.page.background_jobs.background_jobs import get_info from frappe.integrations.utils import make_post_request, make_get_request from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply -from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form +from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form, getdate, time_diff_in_hours -def validate_einvoice_fields(doc): - einvoicing_enabled = cint(frappe.db.get_value('E Invoice Settings', 'E Invoice Settings', 'enable')) - invalid_doctype = doc.doctype != 'Sales Invoice' +@frappe.whitelist() +def validate_eligibility(doc): + if isinstance(doc, six.string_types): + doc = json.loads(doc) + + invalid_doctype = doc.get('doctype') != 'Sales Invoice' + if invalid_doctype: + return False + + einvoicing_enabled = cint(frappe.db.get_single_value('E Invoice Settings', 'enable')) + if not einvoicing_enabled: + return False + + einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01' + if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from): + return False + + invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') }) invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') no_taxes_applied = not doc.get('taxes') - if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied: + return False + + return True + +def validate_einvoice_fields(doc): + invoice_eligible = validate_eligibility(doc) + + if not invoice_eligible: return if doc.docstatus == 0 and doc._action == 'save': @@ -35,6 +61,8 @@ def validate_einvoice_fields(doc): if len(doc.name) > 16: raise_document_name_too_long_error() + doc.einvoice_status = 'Pending' + elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn: frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN')) @@ -76,6 +104,9 @@ def get_transaction_details(invoice): )) def get_doc_details(invoice): + if getdate(invoice.posting_date) < getdate('2021-01-01'): + frappe.throw(_('IRN generation is not allowed for invoices dated before 1st Jan 2021'), title=_('Not Allowed')) + invoice_type = 'CRN' if invoice.is_return else 'INV' invoice_name = invoice.name @@ -87,53 +118,38 @@ def get_doc_details(invoice): invoice_date=invoice_date )) -def get_party_details(address_name): - d = frappe.get_all('Address', filters={'name': address_name}, fields=['*'])[0] - - if (not d.gstin - or not d.city - or not d.pincode - or not d.address_title - or not d.address_line1 - or not d.gst_state_number): +def validate_address_fields(address, is_shipping_address): + if ((not address.gstin and not is_shipping_address) + or not address.city + or not address.pincode + or not address.address_title + or not address.address_line1 + or not address.gst_state_number): frappe.throw( - msg=_('Address lines, city, pincode, gstin is mandatory for address {}. Please set them and try again.').format( - get_link_to_form('Address', address_name) - ), + msg=_('Address Lines, City, Pincode, GSTIN are mandatory for address {}. Please set them and try again.').format(address.name), title=_('Missing Address Fields') ) - if d.gst_state_number == 97: - # according to einvoice standard - pincode = 999999 +def get_party_details(address_name, is_shipping_address=False): + addr = frappe.get_doc('Address', address_name) + + validate_address_fields(addr, is_shipping_address) - return frappe._dict(dict( - gstin=d.gstin, - legal_name=sanitize_for_json(d.address_title), - location=sanitize_for_json(d.city), - pincode=d.pincode, - state_code=d.gst_state_number, - address_line1=sanitize_for_json(d.address_line1), - address_line2=sanitize_for_json(d.address_line2) + if addr.gst_state_number == 97: + # according to einvoice standard + addr.pincode = 999999 + + party_address_details = frappe._dict(dict( + legal_name=sanitize_for_json(addr.address_title), + location=sanitize_for_json(addr.city), + pincode=addr.pincode, gstin=addr.gstin, + state_code=addr.gst_state_number, + address_line1=sanitize_for_json(addr.address_line1), + address_line2=sanitize_for_json(addr.address_line2) )) -def get_gstin_details(gstin): - if not hasattr(frappe.local, 'gstin_cache'): - frappe.local.gstin_cache = {} - - key = gstin - details = frappe.local.gstin_cache.get(key) - if details: - return details - - details = frappe.cache().hget('gstin_cache', key) - if details: - frappe.local.gstin_cache[key] = details - return details - - if not details: - return GSPConnector.get_gstin_details(gstin) + return party_address_details def get_overseas_address_details(address_name): address_title, address_line1, address_line2, city = frappe.db.get_value( @@ -169,10 +185,15 @@ def get_item_list(invoice): item.description = sanitize_for_json(d.item_name) item.qty = abs(item.qty) - item.discount_amount = 0 - item.unit_rate = abs(item.base_net_amount / item.qty) - item.gross_amount = abs(item.base_net_amount) - item.taxable_value = abs(item.base_net_amount) + + if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: + item.discount_amount = abs(item.base_amount - item.base_net_amount) + else: + item.discount_amount = 0 + + item.unit_rate = abs((abs(item.taxable_value) - item.discount_amount)/ item.qty) + item.gross_amount = abs(item.taxable_value) + item.discount_amount + item.taxable_value = abs(item.taxable_value) item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None @@ -205,11 +226,11 @@ def update_item_taxes(invoice, item): is_applicable = t.tax_amount and t.account_head in gst_accounts_list if is_applicable: # this contains item wise tax rate & tax amount (incl. discount) - item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code) + item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code or item.item_name) item_tax_rate = item_tax_detail[0] # item tax amount excluding discount amount - item_tax_amount = (item_tax_rate / 100) * item.base_net_amount + item_tax_amount = (item_tax_rate / 100) * item.taxable_value if t.account_head in gst_accounts.cess_account: item_tax_amount_after_discount = item_tax_detail[1] @@ -223,6 +244,9 @@ def update_item_taxes(invoice, item): if t.account_head in gst_accounts[f'{tax_type}_account']: item.tax_rate += item_tax_rate item[f'{tax_type}_amount'] += abs(item_tax_amount) + else: + # TODO: other charges per item + pass return item @@ -230,10 +254,14 @@ def get_invoice_value_details(invoice): invoice_value_details = frappe._dict(dict()) if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: - invoice_value_details.base_total = abs(invoice.base_total) - invoice_value_details.invoice_discount_amt = abs(invoice.base_discount_amount) + # Discount already applied on net total which means on items + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) + invoice_value_details.invoice_discount_amt = 0 + elif invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount: + invoice_value_details.invoice_discount_amt = invoice.base_discount_amount + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) else: - invoice_value_details.base_total = abs(invoice.base_net_total) + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) # since tax already considers discount amount invoice_value_details.invoice_discount_amt = 0 @@ -254,7 +282,11 @@ def update_invoice_taxes(invoice, invoice_value_details): invoice_value_details.total_igst_amt = 0 invoice_value_details.total_cess_amt = 0 invoice_value_details.total_other_charges = 0 + considered_rows = [] + for t in invoice.taxes: + tax_amount = t.base_tax_amount if (invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount) \ + else t.base_tax_amount_after_discount_amount if t.account_head in gst_accounts_list: if t.account_head in gst_accounts.cess_account: # using after discount amt since item also uses after discount amt for cess calc @@ -262,12 +294,26 @@ def update_invoice_taxes(invoice, invoice_value_details): for tax_type in ['igst', 'cgst', 'sgst']: if t.account_head in gst_accounts[f'{tax_type}_account']: - invoice_value_details[f'total_{tax_type}_amt'] += abs(t.base_tax_amount_after_discount_amount) + + invoice_value_details[f'total_{tax_type}_amt'] += abs(tax_amount) + update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows) else: - invoice_value_details.total_other_charges += abs(t.base_tax_amount_after_discount_amount) + invoice_value_details.total_other_charges += abs(tax_amount) return invoice_value_details +def update_other_charges(tax_row, invoice_value_details, gst_accounts_list, invoice, considered_rows): + prev_row_id = cint(tax_row.row_id) - 1 + if tax_row.account_head in gst_accounts_list and prev_row_id not in considered_rows: + if tax_row.charge_type == 'On Previous Row Amount': + amount = invoice.get('taxes')[prev_row_id].tax_amount_after_discount_amount + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + if tax_row.charge_type == 'On Previous Row Total': + amount = invoice.get('taxes')[prev_row_id].base_total - invoice.base_net_total + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + def get_payment_details(invoice): payee_name = invoice.company mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments]) @@ -280,6 +326,10 @@ def get_payment_details(invoice): )) def get_return_doc_reference(invoice): + if not invoice.return_against: + frappe.throw(_('For generating IRN, reference to the original invoice is mandatory for a credit note. Please set {} field to generate e-invoice.') + .format(frappe.bold('Return Against')), title=_('Missing Field')) + invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date') return frappe._dict(dict( invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy') @@ -287,7 +337,11 @@ def get_return_doc_reference(invoice): def get_eway_bill_details(invoice): if invoice.is_return: - frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes'), title=_('E Invoice Validation Failed')) + frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes. Please clear fields in the Transporter Section of the invoice.'), + title=_('Invalid Fields')) + + if not invoice.distance: + frappe.throw(_('Distance is mandatory for generating e-way bill for an e-invoice.'), title=_('Missing Field')) mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' } vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' } @@ -305,9 +359,15 @@ def get_eway_bill_details(invoice): def validate_mandatory_fields(invoice): if not invoice.company_address: - frappe.throw(_('Company Address is mandatory to fetch company GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Company Address is mandatory to fetch company GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not invoice.customer_address: - frappe.throw(_('Customer Address is mandatory to fetch customer GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Customer Address is mandatory to fetch customer GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not frappe.db.get_value('Address', invoice.company_address, 'gstin'): frappe.throw( _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), @@ -319,6 +379,39 @@ def validate_mandatory_fields(invoice): title=_('Missing Fields') ) +def validate_totals(einvoice): + item_list = einvoice['ItemList'] + value_details = einvoice['ValDtls'] + + total_item_ass_value = 0 + total_item_cgst_value = 0 + total_item_sgst_value = 0 + total_item_igst_value = 0 + total_item_value = 0 + for item in item_list: + total_item_ass_value += flt(item['AssAmt']) + total_item_cgst_value += flt(item['CgstAmt']) + total_item_sgst_value += flt(item['SgstAmt']) + total_item_igst_value += flt(item['IgstAmt']) + total_item_value += flt(item['TotItemVal']) + + if abs(flt(item['AssAmt']) * flt(item['GstRt']) / 100) - (flt(item['CgstAmt']) + flt(item['SgstAmt']) + flt(item['IgstAmt'])) > 1: + frappe.throw(_('Row #{}: GST rate is invalid. Please remove tax rows with zero tax amount from taxes table.').format(item.idx)) + + if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: + frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) + + if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - flt(value_details['OthChrg']) - total_item_value) > 1: + frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) + + calculated_invoice_value = \ + flt(value_details['AssVal']) + flt(value_details['CgstVal']) \ + + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \ + + flt(value_details['OthChrg']) - flt(value_details['Discount']) + + if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1: + frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.')) + def make_einvoice(invoice): validate_mandatory_fields(invoice) @@ -334,24 +427,30 @@ def make_einvoice(invoice): buyer_details = get_overseas_address_details(invoice.customer_address) else: buyer_details = get_party_details(invoice.customer_address) - place_of_supply = get_place_of_supply(invoice, invoice.doctype) or sanitize_for_json(invoice.billing_address_gstin) - place_of_supply = place_of_supply[:2] + place_of_supply = get_place_of_supply(invoice, invoice.doctype) + if place_of_supply: + place_of_supply = place_of_supply.split('-')[0] + else: + place_of_supply = sanitize_for_json(invoice.billing_address_gstin)[:2] buyer_details.update(dict(place_of_supply=place_of_supply)) + seller_details.update(dict(legal_name=invoice.company)) + buyer_details.update(dict(legal_name=invoice.customer_name or invoice.customer)) + shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({}) if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name: if invoice.gst_category == 'Overseas': shipping_details = get_overseas_address_details(invoice.shipping_address_name) else: - shipping_details = get_party_details(invoice.shipping_address_name) + shipping_details = get_party_details(invoice.shipping_address_name, is_shipping_address=True) if invoice.is_pos and invoice.base_paid_amount: payment_details = get_payment_details(invoice) - if invoice.is_return and invoice.return_against: + if invoice.is_return: prev_doc_details = get_return_doc_reference(invoice) - if invoice.transporter: + if invoice.transporter and flt(invoice.distance) and not invoice.is_return: eway_bill_details = get_eway_bill_details(invoice) # not yet implemented @@ -364,18 +463,73 @@ def make_einvoice(invoice): period_details=period_details, prev_doc_details=prev_doc_details, export_details=export_details, eway_bill_details=eway_bill_details ) - einvoice = safe_json_load(einvoice) - validations = json.loads(read_json('einv_validation')) - errors = validate_einvoice(validations, einvoice) - if errors: - message = "\n".join([ - "E Invoice: ", json.dumps(einvoice, indent=4), - "-" * 50, - "Errors: ", json.dumps(errors, indent=4) - ]) - frappe.log_error(title="E Invoice Validation Failed", message=message) - frappe.throw(errors, title=_('E Invoice Validation Failed'), as_list=1) + try: + einvoice = safe_json_load(einvoice) + einvoice = santize_einvoice_fields(einvoice) + except Exception: + show_link_to_error_log(invoice, einvoice) + + validate_totals(einvoice) + + return einvoice + +def show_link_to_error_log(invoice, einvoice): + err_log = log_error(einvoice) + link_to_error_log = get_link_to_form('Error Log', err_log.name, 'Error Log') + frappe.throw( + _('An error occurred while creating e-invoice for {}. Please check {} for more information.').format( + invoice.name, link_to_error_log), + title=_('E Invoice Creation Failed') + ) + +def log_error(data=None): + if isinstance(data, six.string_types): + data = json.loads(data) + + seperator = "--" * 50 + err_tb = traceback.format_exc() + err_msg = str(sys.exc_info()[1]) + data = json.dumps(data, indent=4) + + message = "\n".join([ + "Error", err_msg, seperator, + "Data:", data, seperator, + "Exception:", err_tb + ]) + frappe.log_error(title=_('E Invoice Request Failed'), message=message) + +def santize_einvoice_fields(einvoice): + int_fields = ["Pin","Distance","CrDay"] + float_fields = ["Qty","FreeQty","UnitPrice","TotAmt","Discount","PreTaxVal","AssAmt","GstRt","IgstAmt","CgstAmt","SgstAmt","CesRt","CesAmt","CesNonAdvlAmt","StateCesRt","StateCesAmt","StateCesNonAdvlAmt","OthChrg","TotItemVal","AssVal","CgstVal","SgstVal","IgstVal","CesVal","StCesVal","Discount","OthChrg","RndOffAmt","TotInvVal","TotInvValFc","PaidAmt","PaymtDue","ExpDuty",] + copy = einvoice.copy() + for key, value in copy.items(): + if isinstance(value, list): + for idx, d in enumerate(value): + santized_dict = santize_einvoice_fields(d) + if santized_dict: + einvoice[key][idx] = santized_dict + else: + einvoice[key].pop(idx) + + if not einvoice[key]: + einvoice.pop(key, None) + + elif isinstance(value, dict): + santized_dict = santize_einvoice_fields(value) + if santized_dict: + einvoice[key] = santized_dict + else: + einvoice.pop(key, None) + + elif not value or value == "None": + einvoice.pop(key, None) + + elif key in float_fields: + einvoice[key] = flt(value, 2) + + elif key in int_fields: + einvoice[key] = cint(value) return einvoice @@ -391,70 +545,22 @@ def safe_json_load(json_string): snippet = json_string[start:end] frappe.throw(_("Error in input data. Please check for any special characters near following input: {}").format(snippet)) -def validate_einvoice(validations, einvoice, errors=[]): - for fieldname, field_validation in validations.items(): - value = einvoice.get(fieldname, None) - if not value or value == "None": - # remove keys with empty values - einvoice.pop(fieldname, None) - continue - - value_type = field_validation.get("type").lower() - if value_type in ['object', 'array']: - child_validations = field_validation.get('properties') - - if isinstance(value, list): - for d in value: - validate_einvoice(child_validations, d, errors) - if not d: - # remove empty dicts - einvoice.pop(fieldname, None) - else: - validate_einvoice(child_validations, value, errors) - if not value: - # remove empty dicts - einvoice.pop(fieldname, None) - continue - - # convert to int or str - if value_type == 'string': - einvoice[fieldname] = str(value) - elif value_type == 'number': - is_integer = '.' not in str(field_validation.get('maximum')) - precision = 3 if '.999' in str(field_validation.get('maximum')) else 2 - einvoice[fieldname] = flt(value, precision) if not is_integer else cint(value) - value = einvoice[fieldname] - - max_length = field_validation.get('maxLength') - minimum = flt(field_validation.get('minimum')) - maximum = flt(field_validation.get('maximum')) - pattern_str = field_validation.get('pattern') - pattern = re.compile(pattern_str or '') - - label = field_validation.get('description') or fieldname - - if value_type == 'string' and len(value) > max_length: - errors.append(_('{} should not exceed {} characters').format(label, max_length)) - if value_type == 'number' and (value > maximum or value < minimum): - errors.append(_('{} {} should be between {} and {}').format(label, value, minimum, maximum)) - if pattern_str and not pattern.match(value): - errors.append(field_validation.get('validationMsg')) - - return errors - -class RequestFailed(Exception): pass +class RequestFailed(Exception): + pass +class CancellationNotAllowed(Exception): + pass class GSPConnector(): def __init__(self, doctype=None, docname=None): - self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') - sandbox_mode = self.e_invoice_settings.sandbox_mode + self.doctype = doctype + self.docname = docname - self.invoice = frappe.get_cached_doc(doctype, docname) if doctype and docname else None - self.credentials = self.get_credentials() + self.set_invoice() + self.set_credentials() # authenticate url is same for sandbox & live self.authenticate_url = 'https://gsp.adaequare.com/gsp/authenticate?grant_type=token' - self.base_url = 'https://gsp.adaequare.com' if not sandbox_mode else 'https://gsp.adaequare.com/test' + self.base_url = 'https://gsp.adaequare.com' if not self.e_invoice_settings.sandbox_mode else 'https://gsp.adaequare.com/test' self.cancel_irn_url = self.base_url + '/enriched/ei/api/invoice/cancel' self.irn_details_url = self.base_url + '/enriched/ei/api/invoice/irn' @@ -463,18 +569,29 @@ class GSPConnector(): self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB' self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill' - def get_credentials(self): + def set_invoice(self): + self.invoice = None + if self.doctype and self.docname: + self.invoice = frappe.get_cached_doc(self.doctype, self.docname) + + def set_credentials(self): + self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') + + if not self.e_invoice_settings.enable: + frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) + if self.invoice: gstin = self.get_seller_gstin() - if not self.e_invoice_settings.enable: - frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) - credentials = next(d for d in self.e_invoice_settings.credentials if d.gstin == gstin) + credentials_for_gstin = [d for d in self.e_invoice_settings.credentials if d.gstin == gstin] + if credentials_for_gstin: + self.credentials = credentials_for_gstin[0] + else: + frappe.throw(_('Cannot find e-invoicing credentials for selected Company GSTIN. Please check E-Invoice Settings')) else: - credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None - return credentials + self.credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None def get_seller_gstin(self): - gstin = self.invoice.company_gstin or frappe.db.get_value('Address', self.invoice.company_address, 'gstin') + gstin = frappe.db.get_value('Address', self.invoice.company_address, 'gstin') if not gstin: frappe.throw(_('Cannot retrieve Company GSTIN. Please select company address with valid GSTIN.')) return gstin @@ -522,7 +639,7 @@ class GSPConnector(): self.e_invoice_settings.reload() except Exception: - self.log_error(res) + log_error(res) self.raise_error(True) def get_headers(self): @@ -544,16 +661,15 @@ class GSPConnector(): if res.get('success'): return res.get('result') else: - self.log_error(res) + log_error(res) raise RequestFailed except RequestFailed: self.raise_error() except Exception: - self.log_error() + log_error() self.raise_error(True) - @staticmethod def get_gstin_details(gstin): '''fetch and cache GSTIN details''' @@ -569,12 +685,13 @@ class GSPConnector(): return details def generate_irn(self): - headers = self.get_headers() - einvoice = make_einvoice(self.invoice) - data = json.dumps(einvoice, indent=4) - + data = {} try: + headers = self.get_headers() + einvoice = make_einvoice(self.invoice) + data = json.dumps(einvoice, indent=4) res = self.make_request('post', self.generate_irn_url, headers, data) + if res.get('success'): self.set_einvoice_data(res.get('result')) @@ -594,12 +711,36 @@ class GSPConnector(): except RequestFailed: errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) self.raise_error(errors=errors) - except Exception: - self.log_error(data) + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) self.raise_error(True) + @staticmethod + def bulk_generate_irn(invoices): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + gsp_connector.generate_irn() + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + def get_irn_details(self, irn): headers = self.get_headers() @@ -616,21 +757,30 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error() + log_error() self.raise_error(True) def cancel_irn(self, irn, reason, remark): - headers = self.get_headers() - data = json.dumps({ - 'Irn': irn, - 'Cnlrsn': reason, - 'Cnlrem': remark - }, indent=4) - + data, res = {}, {} try: + # validate cancellation + if time_diff_in_hours(now_datetime(), self.invoice.ack_date) > 24: + frappe.throw(_('E-Invoice cannot be cancelled after 24 hours of IRN generation.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + if not irn: + frappe.throw(_('IRN not found. You must generate IRN before cancelling.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + + headers = self.get_headers() + data = json.dumps({ + 'Irn': irn, + 'Cnlrsn': reason, + 'Cnlrem': remark + }, indent=4) + res = self.make_request('post', self.cancel_irn_url, headers, data) - if res.get('success'): + if res.get('success') or '9999' in res.get('message'): self.invoice.irn_cancelled = 1 + self.invoice.irn_cancel_date = res.get('result')['CancelDate'] if res.get('result') else "" + self.invoice.einvoice_status = 'Cancelled' self.invoice.flags.updater_reference = { 'doctype': self.invoice.doctype, 'docname': self.invoice.name, @@ -643,12 +793,41 @@ class GSPConnector(): except RequestFailed: errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) self.raise_error(errors=errors) - except Exception: - self.log_error(data) + except CancellationNotAllowed as e: + self.set_failed_status(errors=str(e)) + self.raise_error(errors=str(e)) + + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) self.raise_error(True) + @staticmethod + def bulk_cancel_irn(invoices, reason, remark): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + irn = gsp_connector.invoice.irn + gsp_connector.cancel_irn(irn, reason, remark) + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + def generate_eway_bill(self, **kwargs): args = frappe._dict(kwargs) @@ -687,7 +866,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def cancel_eway_bill(self, eway_bill, reason, remark): @@ -719,7 +898,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def sanitize_error_message(self, message): @@ -734,6 +913,9 @@ class GSPConnector(): ] then we trim down the message by looping over errors ''' + if not message: + return [] + errors = re.findall(': [^:]+', message) for idx, e in enumerate(errors): # remove colons @@ -745,22 +927,6 @@ class GSPConnector(): return errors - def log_error(self, data={}): - if not isinstance(data, dict): - data = json.loads(data) - - seperator = "--" * 50 - err_tb = traceback.format_exc() - err_msg = str(sys.exc_info()[1]) - data = json.dumps(data, indent=4) - - message = "\n".join([ - "Error", err_msg, seperator, - "Data:", data, seperator, - "Exception:", err_tb - ]) - frappe.log_error(title=_('E Invoice Request Failed'), message=message) - def raise_error(self, raise_exception=False, errors=[]): title = _('E Invoice Request Failed') if errors: @@ -780,8 +946,13 @@ class GSPConnector(): self.invoice.irn = res.get('Irn') self.invoice.ewaybill = res.get('EwbNo') + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') self.invoice.signed_einvoice = dec_signed_invoice + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') self.invoice.signed_qr_code = res.get('SignedQRCode') + self.invoice.einvoice_status = 'Generated' self.attach_qrcode_image() @@ -791,7 +962,6 @@ class GSPConnector(): 'label': _('IRN Generated') } self.update_invoice() - def attach_qrcode_image(self): qrcode = self.invoice.signed_qr_code doctype = self.invoice.doctype @@ -818,6 +988,17 @@ class GSPConnector(): self.invoice.flags.ignore_validate = True self.invoice.save() + def set_failed_status(self, errors=None): + frappe.db.rollback() + self.invoice.einvoice_status = 'Failed' + self.invoice.failure_description = self.get_failure_message(errors) if errors else "" + self.update_invoice() + frappe.db.commit() + + def get_failure_message(self, errors): + if isinstance(errors, list): + errors = ', '.join(errors) + return errors def sanitize_for_json(string): """Escape JSON specific characters from a string.""" @@ -847,5 +1028,114 @@ def generate_eway_bill(doctype, docname, **kwargs): @frappe.whitelist() def cancel_eway_bill(doctype, docname, eway_bill, reason, remark): - gsp_connector = GSPConnector(doctype, docname) - gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + # TODO: uncomment when eway_bill api from Adequare is enabled + # gsp_connector = GSPConnector(doctype, docname) + # gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + + # update cancelled status only, to be able to cancel irn next + frappe.db.set_value(doctype, docname, 'eway_bill_cancelled', 1) + +@frappe.whitelist() +def generate_einvoices(docnames): + docnames = json.loads(docnames) or [] + + if len(docnames) < 10: + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + if failures: + show_bulk_action_failure_message(failures) + + success = len(docnames) - len(failures) + frappe.msgprint( + _('{} e-invoices generated successfully').format(success), + title=_('Bulk E-Invoice Generation Complete') + ) + + else: + enqueue_bulk_action(schedule_bulk_generate_irn, docnames=docnames) + +def schedule_bulk_generate_irn(docnames): + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + frappe.publish_realtime("bulk_einvoice_generation_complete", { + "user": frappe.session.user, + "failures": failures, + "invoices": docnames + }) + +def show_bulk_action_failure_message(failures): + for doc in failures: + docname = '{0}'.format(doc.get('docname')) + message = doc.get('message').replace("'", '"') + if message[0] == '[': + errors = json.loads(message) + error_list = ''.join([' +
Sent via
ERPNext """
@@ -29,6 +31,7 @@ def after_install():
add_company_to_session_defaults()
add_standard_navbar_items()
add_app_name()
+ add_non_standard_user_types()
frappe.db.commit()
@@ -164,3 +167,81 @@ def add_standard_navbar_items():
def add_app_name():
frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext')
+
+def add_non_standard_user_types():
+ user_types = get_user_types_data()
+
+ user_type_limit = {}
+ for user_type, data in iteritems(user_types):
+ user_type_limit.setdefault(frappe.scrub(user_type), 10)
+
+ update_site_config('user_type_doctype_limit', user_type_limit)
+
+ for user_type, data in iteritems(user_types):
+ create_custom_role(data)
+ create_user_type(user_type, data)
+
+def get_user_types_data():
+ return {
+ 'Employee Self Service': {
+ 'role': 'Employee Self Service',
+ 'apply_user_permission_on': 'Employee',
+ 'user_id_field': 'user_id',
+ 'doctypes': {
+ 'Salary Slip': ['read'],
+ 'Employee': ['read', 'write'],
+ 'Expense Claim': ['read', 'write', 'create', 'delete'],
+ 'Leave Application': ['read', 'write', 'create', 'delete'],
+ 'Attendance Request': ['read', 'write', 'create', 'delete'],
+ 'Compensatory Leave Request': ['read', 'write', 'create', 'delete'],
+ 'Employee Tax Exemption Declaration': ['read', 'write', 'create', 'delete'],
+ 'Employee Tax Exemption Proof Submission': ['read', 'write', 'create', 'delete'],
+ 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend']
+ }
+ }
+ }
+
+def create_custom_role(data):
+ if data.get('role') and not frappe.db.exists('Role', data.get('role')):
+ frappe.get_doc({
+ 'doctype': 'Role',
+ 'role_name': data.get('role'),
+ 'desk_access': 1,
+ 'is_custom': 1
+ }).insert(ignore_permissions=True)
+
+def create_user_type(user_type, data):
+ if frappe.db.exists('User Type', user_type):
+ doc = frappe.get_cached_doc('User Type', user_type)
+ doc.user_doctypes = []
+ else:
+ doc = frappe.new_doc('User Type')
+ doc.update({
+ 'name': user_type,
+ 'role': data.get('role'),
+ 'user_id_field': data.get('user_id_field'),
+ 'apply_user_permission_on': data.get('apply_user_permission_on')
+ })
+
+ create_role_permissions_for_doctype(doc, data)
+ doc.save(ignore_permissions=True)
+
+def create_role_permissions_for_doctype(doc, data):
+ for doctype, perms in iteritems(data.get('doctypes')):
+ args = {'document_type': doctype}
+ for perm in perms:
+ args[perm] = 1
+
+ doc.append('user_doctypes', args)
+
+def update_select_perm_after_install():
+ if not frappe.flags.update_select_perm_after_migrate:
+ return
+
+ frappe.flags.ignore_select_perm = False
+ for row in frappe.get_all('User Type', filters= {'is_standard': 0}):
+ print('Updating user type :- ', row.name)
+ doc = frappe.get_doc('User Type', row.name)
+ doc.save()
+
+ frappe.flags.update_select_perm_after_migrate = False
diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json
index beddaeed793..58764880330 100644
--- a/erpnext/setup/setup_wizard/data/country_wise_tax.json
+++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json
@@ -481,14 +481,250 @@
},
"Germany": {
- "Germany VAT 19%": {
- "account_name": "VAT 19%",
- "tax_rate": 19.00,
- "default": 1
- },
- "Germany VAT 7%": {
- "account_name": "VAT 7%",
- "tax_rate": 7.00
+ "chart_of_accounts": {
+ "SKR04 mit Kontonummern": {
+ "sales_tax_templates": [
+ {
+ "title": "Umsatzsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 19%",
+ "account_number": "3806",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Umsatzsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 7%",
+ "account_number": "3801",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ],
+ "purchase_tax_templates": [
+ {
+ "title": "Abziehbare Vorsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 19%",
+ "account_number": "1406",
+ "root_type": "Asset",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Abziehbare Vorsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 7%",
+ "account_number": "1401",
+ "root_type": "Asset",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Innergemeinschaftlicher Erwerb 19% Umsatzsteuer und 19% Vorsteuer",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer nach § 13b UStG 19%",
+ "account_number": "1407",
+ "root_type": "Asset",
+ "tax_rate": 19.00
+ },
+ "add_deduct_tax": "Add"
+ },
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer nach § 13b UStG 19%",
+ "account_number": "3837",
+ "root_type": "Liability",
+ "tax_rate": 19.00
+ },
+ "add_deduct_tax": "Deduct"
+ }
+ ]
+ }
+ ]
+ },
+ "SKR03 mit Kontonummern": {
+ "sales_tax_templates": [
+ {
+ "title": "Umsatzsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 19%",
+ "account_number": "1776",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Umsatzsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 7%",
+ "account_number": "1771",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ],
+ "purchase_tax_templates": [
+ {
+ "title": "Abziehbare Vorsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 19%",
+ "account_number": "1576",
+ "root_type": "Asset",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Abziehbare Vorsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 7%",
+ "account_number": "1571",
+ "root_type": "Asset",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "Standard with Numbers": {
+ "sales_tax_templates": [
+ {
+ "title": "Umsatzsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 19%",
+ "account_number": "2301",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Umsatzsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 7%",
+ "account_number": "2302",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ],
+ "purchase_tax_templates": [
+ {
+ "title": "Abziehbare Vorsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 19%",
+ "account_number": "1501",
+ "root_type": "Asset",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Abziehbare Vorsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 7%",
+ "account_number": "1502",
+ "root_type": "Asset",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "*": {
+ "sales_tax_templates": [
+ {
+ "title": "Umsatzsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 19%",
+ "tax_rate": 19.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Umsatzsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Umsatzsteuer 7%",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ],
+ "purchase_tax_templates": [
+ {
+ "title": "Abziehbare Vorsteuer 19%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 19%",
+ "tax_rate": 19.00,
+ "root_type": "Asset"
+ }
+ }
+ ]
+ },
+ {
+ "title": "Abziehbare Vorsteuer 7%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "Abziehbare Vorsteuer 7%",
+ "root_type": "Asset",
+ "tax_rate": 7.00
+ }
+ }
+ ]
+ }
+ ]
+ }
}
},
@@ -580,26 +816,135 @@
},
"India": {
- "In State GST": {
- "account_name": ["SGST", "CGST"],
- "tax_rate": [9.00, 9.00],
- "default": 1
- },
- "Out of State GST": {
- "account_name": "IGST",
- "tax_rate": 18.00
- },
- "VAT 5%": {
- "account_name": "VAT 5%",
- "tax_rate": 5.00
- },
- "VAT 4%": {
- "account_name": "VAT 4%",
- "tax_rate": 4.00
- },
- "VAT 14%": {
- "account_name": "VAT 14%",
- "tax_rate": 14.00
+ "chart_of_accounts": {
+ "*": {
+ "item_tax_templates": [
+ {
+ "title": "In State GST",
+ "taxes": [
+ {
+ "tax_type": {
+ "account_name": "SGST",
+ "tax_rate": 9.00
+ }
+ },
+ {
+ "tax_type": {
+ "account_name": "CGST",
+ "tax_rate": 9.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Out of State GST",
+ "taxes": [
+ {
+ "tax_type": {
+ "account_name": "IGST",
+ "tax_rate": 18.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 5%",
+ "taxes": [
+ {
+ "tax_type": {
+ "account_name": "VAT 5%",
+ "tax_rate": 5.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 4%",
+ "taxes": [
+ {
+ "tax_type": {
+ "account_name": "VAT 4%",
+ "tax_rate": 4.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 14%",
+ "taxes": [
+ {
+ "tax_type": {
+ "account_name": "VAT 14%",
+ "tax_rate": 14.00
+ }
+ }
+ ]
+ }
+ ],
+ "*": [
+ {
+ "title": "In State GST",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "SGST",
+ "tax_rate": 9.00
+ }
+ },
+ {
+ "account_head": {
+ "account_name": "CGST",
+ "tax_rate": 9.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "Out of State GST",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "IGST",
+ "tax_rate": 18.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 5%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "VAT 5%",
+ "tax_rate": 5.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 4%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "VAT 4%",
+ "tax_rate": 4.00
+ }
+ }
+ ]
+ },
+ {
+ "title": "VAT 14%",
+ "taxes": [
+ {
+ "account_head": {
+ "account_name": "VAT 14%",
+ "tax_rate": 14.00
+ }
+ }
+ ]
+ }
+ ]
+ }
}
},
diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py
index c3c1593c046..429a558c589 100644
--- a/erpnext/setup/setup_wizard/operations/taxes_setup.py
+++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py
@@ -1,123 +1,232 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
-import frappe, copy, os, json
-from frappe.utils import flt
-from erpnext.accounts.doctype.account.account import RootNotEditable
-def create_sales_tax(args):
- country_wise_tax = get_country_wise_tax(args.get("country"))
- if country_wise_tax and len(country_wise_tax) > 0:
- for sales_tax, tax_data in country_wise_tax.items():
- make_tax_account_and_template(
- args.get("company_name"),
- tax_data.get('account_name'),
- tax_data.get('tax_rate'), sales_tax)
+import os
+import json
-def make_tax_account_and_template(company, account_name, tax_rate, template_name=None):
- if not isinstance(account_name, (list, tuple)):
- account_name = [account_name]
- tax_rate = [tax_rate]
+import frappe
+from frappe import _
- accounts = []
- for i, name in enumerate(account_name):
- tax_account = make_tax_account(company, account_name[i], tax_rate[i])
- if tax_account:
- accounts.append(tax_account)
- try:
- if accounts:
- make_sales_and_purchase_tax_templates(accounts, template_name)
- make_item_tax_templates(accounts, template_name)
- except frappe.NameError:
- if frappe.message_log: frappe.message_log.pop()
- except RootNotEditable:
- pass
+def setup_taxes_and_charges(company_name: str, country: str):
+ file_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'country_wise_tax.json')
+ with open(file_path, 'r') as json_file:
+ tax_data = json.load(json_file)
-def make_tax_account(company, account_name, tax_rate):
- tax_group = get_tax_account_group(company)
- if tax_group:
- try:
- return frappe.get_doc({
- "doctype":"Account",
- "company": company,
- "parent_account": tax_group,
- "account_name": account_name,
- "is_group": 0,
- "report_type": "Balance Sheet",
- "root_type": "Liability",
- "account_type": "Tax",
- "tax_rate": flt(tax_rate) if tax_rate else None
- }).insert(ignore_permissions=True, ignore_mandatory=True)
- except frappe.NameError:
- if frappe.message_log: frappe.message_log.pop()
- abbr = frappe.get_cached_value('Company', company, 'abbr')
- account = '{0} - {1}'.format(account_name, abbr)
- return frappe.get_doc('Account', account)
+ country_wise_tax = tax_data.get(country)
-def make_sales_and_purchase_tax_templates(accounts, template_name=None):
- if not template_name:
- template_name = accounts[0].name
+ if not country_wise_tax:
+ return
- sales_tax_template = {
- "doctype": "Sales Taxes and Charges Template",
- "title": template_name,
- "company": accounts[0].company,
- 'taxes': []
+ if 'chart_of_accounts' not in country_wise_tax:
+ country_wise_tax = simple_to_detailed(country_wise_tax)
+
+ from_detailed_data(company_name, country_wise_tax.get('chart_of_accounts'))
+
+
+def simple_to_detailed(templates):
+ """
+ Convert a simple taxes object into a more detailed data structure.
+
+ Example input:
+
+ {
+ "France VAT 20%": {
+ "account_name": "VAT 20%",
+ "tax_rate": 20,
+ "default": 1
+ },
+ "France VAT 10%": {
+ "account_name": "VAT 10%",
+ "tax_rate": 10
+ }
}
-
- for account in accounts:
- sales_tax_template['taxes'].append({
- "category": "Total",
- "charge_type": "On Net Total",
- "account_head": account.name,
- "description": "{0} @ {1}".format(account.account_name, account.tax_rate),
- "rate": account.tax_rate
- })
- # Sales
- frappe.get_doc(copy.deepcopy(sales_tax_template)).insert(ignore_permissions=True)
-
- # Purchase
- purchase_tax_template = copy.deepcopy(sales_tax_template)
- purchase_tax_template["doctype"] = "Purchase Taxes and Charges Template"
-
- doc = frappe.get_doc(purchase_tax_template)
- doc.insert(ignore_permissions=True)
-
-def make_item_tax_templates(accounts, template_name=None):
- if not template_name:
- template_name = accounts[0].name
-
- item_tax_template = {
- "doctype": "Item Tax Template",
- "title": template_name,
- "company": accounts[0].company,
- 'taxes': []
+ """
+ return {
+ 'chart_of_accounts': {
+ '*': {
+ 'item_tax_templates': [{
+ 'title': title,
+ 'taxes': [{
+ 'tax_type': {
+ 'account_name': data.get('account_name'),
+ 'tax_rate': data.get('tax_rate')
+ }
+ }]
+ } for title, data in templates.items()],
+ '*': [{
+ 'title': title,
+ 'is_default': data.get('default', 0),
+ 'taxes': [{
+ 'account_head': {
+ 'account_name': data.get('account_name'),
+ 'tax_rate': data.get('tax_rate')
+ }
+ }]
+ } for title, data in templates.items()]
+ }
+ }
}
- for account in accounts:
- item_tax_template['taxes'].append({
- "tax_type": account.name,
- "tax_rate": account.tax_rate
- })
+def from_detailed_data(company_name, data):
+ """Create Taxes and Charges Templates from detailed data."""
+ coa_name = frappe.db.get_value('Company', company_name, 'chart_of_accounts')
+ tax_templates = data.get(coa_name) or data.get('*')
+ sales_tax_templates = tax_templates.get('sales_tax_templates') or tax_templates.get('*')
+ purchase_tax_templates = tax_templates.get('purchase_tax_templates') or tax_templates.get('*')
+ item_tax_templates = tax_templates.get('item_tax_templates') or tax_templates.get('*')
- # Items
- frappe.get_doc(copy.deepcopy(item_tax_template)).insert(ignore_permissions=True)
+ if sales_tax_templates:
+ for template in sales_tax_templates:
+ make_taxes_and_charges_template(company_name, 'Sales Taxes and Charges Template', template)
-def get_tax_account_group(company):
- tax_group = frappe.db.get_value("Account",
- {"account_name": "Duties and Taxes", "is_group": 1, "company": company})
- if not tax_group:
- tax_group = frappe.db.get_value("Account", {"is_group": 1, "root_type": "Liability",
- "account_type": "Tax", "company": company})
+ if purchase_tax_templates:
+ for template in purchase_tax_templates:
+ make_taxes_and_charges_template(company_name, 'Purchase Taxes and Charges Template', template)
- return tax_group
+ if item_tax_templates:
+ for template in item_tax_templates:
+ make_item_tax_template(company_name, template)
-def get_country_wise_tax(country):
- data = {}
- with open (os.path.join(os.path.dirname(__file__), "..", "data", "country_wise_tax.json")) as countrywise_tax:
- data = json.load(countrywise_tax).get(country)
- return data
+def make_taxes_and_charges_template(company_name, doctype, template):
+ template['company'] = company_name
+ template['doctype'] = doctype
+
+ if frappe.db.exists(doctype, {'title': template.get('title'), 'company': company_name}):
+ return
+
+ for tax_row in template.get('taxes'):
+ account_data = tax_row.get('account_head')
+ tax_row_defaults = {
+ 'category': 'Total',
+ 'charge_type': 'On Net Total'
+ }
+
+ # if account_head is a dict, search or create the account and get it's name
+ if isinstance(account_data, dict):
+ tax_row_defaults['description'] = '{0} @ {1}'.format(account_data.get('account_name'), account_data.get('tax_rate'))
+ tax_row_defaults['rate'] = account_data.get('tax_rate')
+ account = get_or_create_account(company_name, account_data)
+ tax_row['account_head'] = account.name
+
+ # use the default value if nothing other is specified
+ for fieldname, default_value in tax_row_defaults.items():
+ if fieldname not in tax_row:
+ tax_row[fieldname] = default_value
+
+ return frappe.get_doc(template).insert(ignore_permissions=True)
+
+
+def make_item_tax_template(company_name, template):
+ """Create an Item Tax Template.
+
+ This requires a separate method because Item Tax Template is structured
+ differently from Sales and Purchase Tax Templates.
+ """
+ doctype = 'Item Tax Template'
+ template['company'] = company_name
+ template['doctype'] = doctype
+
+ if frappe.db.exists(doctype, {'title': template.get('title'), 'company': company_name}):
+ return
+
+ for tax_row in template.get('taxes'):
+ account_data = tax_row.get('tax_type')
+
+ # if tax_type is a dict, search or create the account and get it's name
+ if isinstance(account_data, dict):
+ account = get_or_create_account(company_name, account_data)
+ tax_row['tax_type'] = account.name
+ if 'tax_rate' not in tax_row:
+ tax_row['tax_rate'] = account_data.get('tax_rate')
+
+ return frappe.get_doc(template).insert(ignore_permissions=True)
+
+
+def get_or_create_account(company_name, account):
+ """
+ Check if account already exists. If not, create it.
+ Return a tax account or None.
+ """
+ default_root_type = 'Liability'
+ root_type = account.get('root_type', default_root_type)
+
+ existing_accounts = frappe.get_list('Account',
+ filters={
+ 'company': company_name,
+ 'root_type': root_type
+ },
+ or_filters={
+ 'account_name': account.get('account_name'),
+ 'account_number': account.get('account_number')
+ }
+ )
+
+ if existing_accounts:
+ return frappe.get_doc('Account', existing_accounts[0].name)
+
+ tax_group = get_or_create_tax_group(company_name, root_type)
+
+ account['doctype'] = 'Account'
+ account['company'] = company_name
+ account['parent_account'] = tax_group
+ account['report_type'] = 'Balance Sheet'
+ account['account_type'] = 'Tax'
+ account['root_type'] = root_type
+ account['is_group'] = 0
+
+ return frappe.get_doc(account).insert(ignore_permissions=True, ignore_mandatory=True)
+
+
+def get_or_create_tax_group(company_name, root_type):
+ # Look for a group account of type 'Tax'
+ tax_group_name = frappe.db.get_value('Account', {
+ 'is_group': 1,
+ 'root_type': root_type,
+ 'account_type': 'Tax',
+ 'company': company_name
+ })
+
+ if tax_group_name:
+ return tax_group_name
+
+ # Look for a group account named 'Duties and Taxes' or 'Tax Assets'
+ account_name = _('Duties and Taxes') if root_type == 'Liability' else _('Tax Assets')
+ tax_group_name = frappe.db.get_value('Account', {
+ 'is_group': 1,
+ 'root_type': root_type,
+ 'account_name': account_name,
+ 'company': company_name
+ })
+
+ if tax_group_name:
+ return tax_group_name
+
+ # Create a new group account named 'Duties and Taxes' or 'Tax Assets' just
+ # below the root account
+ root_account = frappe.get_list('Account', {
+ 'is_group': 1,
+ 'root_type': root_type,
+ 'company': company_name,
+ 'report_type': 'Balance Sheet',
+ 'parent_account': ('is', 'not set')
+ }, limit=1)[0]
+
+ tax_group_account = frappe.get_doc({
+ 'doctype': 'Account',
+ 'company': company_name,
+ 'is_group': 1,
+ 'report_type': 'Balance Sheet',
+ 'root_type': root_type,
+ 'account_type': 'Tax',
+ 'account_name': account_name,
+ 'parent_account': root_account.name
+ }).insert(ignore_permissions=True)
+
+ tax_group_name = tax_group_account.name
+
+ return tax_group_name
diff --git a/erpnext/setup/setup_wizard/utils.py b/erpnext/setup/setup_wizard/utils.py
index e82bc96d937..4223f000a6b 100644
--- a/erpnext/setup/setup_wizard/utils.py
+++ b/erpnext/setup/setup_wizard/utils.py
@@ -9,5 +9,4 @@ def complete():
'data', 'test_mfg.json'), 'r') as f:
data = json.loads(f.read())
- #setup_wizard.create_sales_tax(data)
setup_complete(data)
diff --git a/erpnext/shopping_cart/cart.py b/erpnext/shopping_cart/cart.py
index 681d161edcd..56afe95efd0 100644
--- a/erpnext/shopping_cart/cart.py
+++ b/erpnext/shopping_cart/cart.py
@@ -112,9 +112,7 @@ def place_order():
def request_for_quotation():
quotation = _get_cart_quotation()
quotation.flags.ignore_permissions = True
- quotation.save()
- if not get_shopping_cart_settings().save_quotations_as_draft:
- quotation.submit()
+ quotation.submit()
return quotation.name
@frappe.whitelist()
@@ -232,12 +230,12 @@ def update_cart_address(address_type, address_name):
if address_type.lower() == "billing":
quotation.customer_address = address_name
quotation.address_display = address_display
- quotation.shipping_address_name == quotation.shipping_address_name or address_name
+ quotation.shipping_address_name = quotation.shipping_address_name or address_name
address_doc = next((doc for doc in get_billing_addresses() if doc["name"] == address_name), None)
elif address_type.lower() == "shipping":
quotation.shipping_address_name = address_name
quotation.shipping_address = address_display
- quotation.customer_address == quotation.customer_address or address_name
+ quotation.customer_address = quotation.customer_address or address_name
address_doc = next((doc for doc in get_shipping_addresses() if doc["name"] == address_name), None)
apply_cart_settings(quotation=quotation)
diff --git a/erpnext/shopping_cart/test_shopping_cart.py b/erpnext/shopping_cart/test_shopping_cart.py
index cf59a52b5b2..d857bf5f5c1 100644
--- a/erpnext/shopping_cart/test_shopping_cart.py
+++ b/erpnext/shopping_cart/test_shopping_cart.py
@@ -16,6 +16,11 @@ class TestShoppingCart(unittest.TestCase):
Note:
Shopping Cart == Quotation
"""
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.sql("delete from `tabTax Rule`")
+
def setUp(self):
frappe.set_user("Administrator")
create_test_contact_and_address()
@@ -51,8 +56,8 @@ class TestShoppingCart(unittest.TestCase):
def test_add_to_cart(self):
self.login_as_customer()
- # remove from cart
- self.remove_all_items_from_cart()
+ # clear existing quotations
+ self.clear_existing_quotations()
# add first item
update_cart("_Test Item", 1)
@@ -100,6 +105,7 @@ class TestShoppingCart(unittest.TestCase):
self.assertEqual(len(quotation.get("items")), 1)
def test_tax_rule(self):
+ self.create_tax_rule()
self.login_as_customer()
quotation = self.create_quotation()
@@ -115,6 +121,13 @@ class TestShoppingCart(unittest.TestCase):
self.remove_test_quotation(quotation)
+ def create_tax_rule(self):
+ tax_rule = frappe.get_test_records("Tax Rule")[0]
+ try:
+ frappe.get_doc(tax_rule).insert()
+ except frappe.DuplicateEntryError:
+ pass
+
def create_quotation(self):
quotation = frappe.new_doc("Quotation")
@@ -195,10 +208,15 @@ class TestShoppingCart(unittest.TestCase):
"_Test Contact For _Test Customer")
frappe.set_user("test_contact_customer@example.com")
- def remove_all_items_from_cart(self):
- quotation = _get_cart_quotation()
- quotation.flags.ignore_permissions=True
- quotation.delete()
+ def clear_existing_quotations(self):
+ quotations = frappe.get_all("Quotation", filters={
+ "party_name": get_party().name,
+ "order_type": "Shopping Cart",
+ "docstatus": 0
+ }, order_by="modified desc", pluck="name")
+
+ for quotation in quotations:
+ frappe.delete_doc("Quotation", quotation, ignore_permissions=True, force=True)
def create_user_if_not_exists(self, email, first_name = None):
if frappe.db.exists("User", email):
diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js
index 95cb92b1b36..933ca8ab3d4 100644
--- a/erpnext/stock/dashboard/item_dashboard.js
+++ b/erpnext/stock/dashboard/item_dashboard.js
@@ -1,14 +1,14 @@
frappe.provide('erpnext.stock');
erpnext.stock.ItemDashboard = Class.extend({
- init: function(opts) {
+ init: function (opts) {
$.extend(this, opts);
this.make();
},
- make: function() {
+ make: function () {
var me = this;
this.start = 0;
- if(!this.sort_by) {
+ if (!this.sort_by) {
this.sort_by = 'projected_qty';
this.sort_order = 'asc';
}
@@ -16,22 +16,25 @@ erpnext.stock.ItemDashboard = Class.extend({
this.content = $(frappe.render_template('item_dashboard')).appendTo(this.parent);
this.result = this.content.find('.result');
- this.content.on('click', '.btn-move', function() {
- handle_move_add($(this), "Move")
+ this.content.on('click', '.btn-move', function () {
+ handle_move_add($(this), "Move");
});
- this.content.on('click', '.btn-add', function() {
- handle_move_add($(this), "Add")
+ this.content.on('click', '.btn-add', function () {
+ handle_move_add($(this), "Add");
});
- this.content.on('click', '.btn-edit', function() {
+ this.content.on('click', '.btn-edit', function () {
let item = unescape($(this).attr('data-item'));
let warehouse = unescape($(this).attr('data-warehouse'));
let company = unescape($(this).attr('data-company'));
- frappe.db.get_value('Putaway Rule',
- {'item_code': item, 'warehouse': warehouse, 'company': company}, 'name', (r) => {
- frappe.set_route("Form", "Putaway Rule", r.name);
- });
+ frappe.db.get_value('Putaway Rule', {
+ 'item_code': item,
+ 'warehouse': warehouse,
+ 'company': company
+ }, 'name', (r) => {
+ frappe.set_route("Form", "Putaway Rule", r.name);
+ });
});
function handle_move_add(element, action) {
@@ -39,23 +42,26 @@ erpnext.stock.ItemDashboard = Class.extend({
let warehouse = unescape(element.attr('data-warehouse'));
let actual_qty = unescape(element.attr('data-actual_qty'));
let disable_quick_entry = Number(unescape(element.attr('data-disable_quick_entry')));
- let entry_type = action === "Move" ? "Material Transfer": null;
+ let entry_type = action === "Move" ? "Material Transfer" : null;
if (disable_quick_entry) {
open_stock_entry(item, warehouse, entry_type);
} else {
if (action === "Add") {
let rate = unescape($(this).attr('data-rate'));
- erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, function() { me.refresh(); });
- }
- else {
- erpnext.stock.move_item(item, warehouse, null, actual_qty, null, function() { me.refresh(); });
+ erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, function () {
+ me.refresh();
+ });
+ } else {
+ erpnext.stock.move_item(item, warehouse, null, actual_qty, null, function () {
+ me.refresh();
+ });
}
}
}
function open_stock_entry(item, warehouse, entry_type) {
- frappe.model.with_doctype('Stock Entry', function() {
+ frappe.model.with_doctype('Stock Entry', function () {
var doc = frappe.model.get_new_doc('Stock Entry');
if (entry_type) doc.stock_entry_type = entry_type;
@@ -64,18 +70,18 @@ erpnext.stock.ItemDashboard = Class.extend({
row.s_warehouse = warehouse;
frappe.set_route('Form', doc.doctype, doc.name);
- })
+ });
}
// more
- this.content.find('.btn-more').on('click', function() {
+ this.content.find('.btn-more').on('click', function () {
me.start += me.page_length;
me.refresh();
});
},
- refresh: function() {
- if(this.before_refresh) {
+ refresh: function () {
+ if (this.before_refresh) {
this.before_refresh();
}
@@ -94,13 +100,13 @@ erpnext.stock.ItemDashboard = Class.extend({
frappe.call({
method: this.method,
args: args,
- callback: function(r) {
+ callback: function (r) {
me.render(r.message);
}
});
},
- render: function(data) {
- if (this.start===0) {
+ render: function (data) {
+ if (this.start === 0) {
this.max_count = 0;
this.result.empty();
}
@@ -115,7 +121,7 @@ erpnext.stock.ItemDashboard = Class.extend({
this.max_count = this.max_count;
// show more button
- if (data && data.length===(this.page_length + 1)) {
+ if (data && data.length === (this.page_length + 1)) {
this.content.find('.more').removeClass('hidden');
// remove the last element
@@ -137,15 +143,15 @@ erpnext.stock.ItemDashboard = Class.extend({
}
},
- get_item_dashboard_data: function(data, max_count, show_item) {
- if(!max_count) max_count = 0;
- if(!data) data = [];
+ get_item_dashboard_data: function (data, max_count, show_item) {
+ if (!max_count) max_count = 0;
+ if (!data) data = [];
- data.forEach(function(d) {
+ data.forEach(function (d) {
d.actual_or_pending = d.projected_qty + d.reserved_qty + d.reserved_qty_for_production + d.reserved_qty_for_sub_contract;
d.pending_qty = 0;
d.total_reserved = d.reserved_qty + d.reserved_qty_for_production + d.reserved_qty_for_sub_contract;
- if(d.actual_or_pending > d.actual_qty) {
+ if (d.actual_or_pending > d.actual_qty) {
d.pending_qty = d.actual_or_pending - d.actual_qty;
}
@@ -161,16 +167,16 @@ erpnext.stock.ItemDashboard = Class.extend({
return {
data: data,
max_count: max_count,
- can_write:can_write,
+ can_write: can_write,
show_item: show_item || false
};
},
- get_capacity_dashboard_data: function(data) {
+ get_capacity_dashboard_data: function (data) {
if (!data) data = [];
- data.forEach(function(d) {
- d.color = d.percent_occupied >=80 ? "#f8814f" : "#2490ef";
+ data.forEach(function (d) {
+ d.color = d.percent_occupied >= 80 ? "#f8814f" : "#2490ef";
});
let can_write = 0;
@@ -185,53 +191,77 @@ erpnext.stock.ItemDashboard = Class.extend({
}
});
-erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callback) {
+erpnext.stock.move_item = function (item, source, target, actual_qty, rate, callback) {
var dialog = new frappe.ui.Dialog({
title: target ? __('Add Item') : __('Move Item'),
- fields: [
- {fieldname: 'item_code', label: __('Item'),
- fieldtype: 'Link', options: 'Item', read_only: 1},
- {fieldname: 'source', label: __('Source Warehouse'),
- fieldtype: 'Link', options: 'Warehouse', read_only: 1},
- {fieldname: 'target', label: __('Target Warehouse'),
- fieldtype: 'Link', options: 'Warehouse', reqd: 1},
- {fieldname: 'qty', label: __('Quantity'), reqd: 1,
- fieldtype: 'Float', description: __('Available {0}', [actual_qty]) },
- {fieldname: 'rate', label: __('Rate'), fieldtype: 'Currency', hidden: 1 },
+ fields: [{
+ fieldname: 'item_code',
+ label: __('Item'),
+ fieldtype: 'Link',
+ options: 'Item',
+ read_only: 1
+ },
+ {
+ fieldname: 'source',
+ label: __('Source Warehouse'),
+ fieldtype: 'Link',
+ options: 'Warehouse',
+ read_only: 1
+ },
+ {
+ fieldname: 'target',
+ label: __('Target Warehouse'),
+ fieldtype: 'Link',
+ options: 'Warehouse',
+ reqd: 1
+ },
+ {
+ fieldname: 'qty',
+ label: __('Quantity'),
+ reqd: 1,
+ fieldtype: 'Float',
+ description: __('Available {0}', [actual_qty])
+ },
+ {
+ fieldname: 'rate',
+ label: __('Rate'),
+ fieldtype: 'Currency',
+ hidden: 1
+ },
],
- })
+ });
dialog.show();
dialog.get_field('item_code').set_input(item);
- if(source) {
+ if (source) {
dialog.get_field('source').set_input(source);
} else {
dialog.get_field('source').df.hidden = 1;
dialog.get_field('source').refresh();
}
- if(rate) {
+ if (rate) {
dialog.get_field('rate').set_value(rate);
dialog.get_field('rate').df.hidden = 0;
dialog.get_field('rate').refresh();
}
- if(target) {
+ if (target) {
dialog.get_field('target').df.read_only = 1;
dialog.get_field('target').value = target;
dialog.get_field('target').refresh();
}
- dialog.set_primary_action(__('Submit'), function() {
+ dialog.set_primary_action(__('Submit'), function () {
var values = dialog.get_values();
- if(!values) {
+ if (!values) {
return;
}
- if(source && values.qty > actual_qty) {
+ if (source && values.qty > actual_qty) {
frappe.msgprint(__('Quantity must be less than or equal to {0}', [actual_qty]));
return;
}
- if(values.source === values.target) {
+ if (values.source === values.target) {
frappe.msgprint(__('Source and target warehouse must be different'));
}
@@ -239,21 +269,21 @@ erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callb
method: 'erpnext.stock.doctype.stock_entry.stock_entry_utils.make_stock_entry',
args: values,
freeze: true,
- callback: function(r) {
+ callback: function (r) {
frappe.show_alert(__('Stock Entry {0} created',
- ['' + r.message.name+ '']));
+ ['' + r.message.name + '']));
dialog.hide();
callback(r);
},
});
});
- $('')
+ $('')
.appendTo(dialog.body)
.find('.link-open')
- .on('click', function() {
- frappe.model.with_doctype('Stock Entry', function() {
+ .on('click', function () {
+ frappe.model.with_doctype('Stock Entry', function () {
var doc = frappe.model.get_new_doc('Stock Entry');
doc.from_warehouse = dialog.get_value('source');
doc.to_warehouse = dialog.get_value('target');
@@ -266,6 +296,6 @@ erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callb
row.transfer_qty = dialog.get_value('qty');
row.basic_rate = dialog.get_value('rate');
frappe.set_route('Form', doc.doctype, doc.name);
- })
+ });
});
-}
+};
diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py
index cafb5c3a0a9..45e662807a0 100644
--- a/erpnext/stock/dashboard/item_dashboard.py
+++ b/erpnext/stock/dashboard/item_dashboard.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
import frappe
from frappe.model.db_query import DatabaseQuery
+from frappe.utils import flt, cint
@frappe.whitelist()
def get_data(item_code=None, warehouse=None, item_group=None,
@@ -42,11 +43,20 @@ def get_data(item_code=None, warehouse=None, item_group=None,
limit_start=start,
limit_page_length='21')
+ precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
+
for item in items:
item.update({
- 'item_name': frappe.get_cached_value("Item", item.item_code, 'item_name'),
- 'disable_quick_entry': frappe.get_cached_value("Item", item.item_code, 'has_batch_no')
- or frappe.get_cached_value("Item", item.item_code, 'has_serial_no'),
+ 'item_name': frappe.get_cached_value(
+ "Item", item.item_code, 'item_name'),
+ 'disable_quick_entry': frappe.get_cached_value(
+ "Item", item.item_code, 'has_batch_no')
+ or frappe.get_cached_value(
+ "Item", item.item_code, 'has_serial_no'),
+ 'projected_qty': flt(item.projected_qty, precision),
+ 'reserved_qty': flt(item.reserved_qty, precision),
+ 'reserved_qty_for_production': flt(item.reserved_qty_for_production, precision),
+ 'reserved_qty_for_sub_contract': flt(item.reserved_qty_for_sub_contract, precision),
+ 'actual_qty': flt(item.actual_qty, precision),
})
-
return items
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json
index f595aade917..280fde158f5 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.json
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.json
@@ -99,6 +99,7 @@
"rounding_adjustment",
"rounded_total",
"in_words",
+ "disable_rounded_total",
"terms_section_break",
"tc_name",
"terms",
@@ -768,6 +769,7 @@
"width": "150px"
},
{
+ "depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "base_rounding_adjustment",
"fieldtype": "Currency",
"label": "Rounding Adjustment (Company Currency)",
@@ -777,6 +779,7 @@
"read_only": 1
},
{
+ "depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "base_rounded_total",
"fieldtype": "Currency",
"label": "Rounded Total (Company Currency)",
@@ -819,6 +822,7 @@
"width": "150px"
},
{
+ "depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "rounding_adjustment",
"fieldtype": "Currency",
"label": "Rounding Adjustment",
@@ -829,6 +833,7 @@
},
{
"bold": 1,
+ "depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "rounded_total",
"fieldtype": "Currency",
"label": "Rounded Total",
@@ -1271,13 +1276,20 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "grand_total",
+ "fieldname": "disable_rounded_total",
+ "fieldtype": "Check",
+ "label": "Disable Rounded Total"
}
],
"icon": "fa fa-truck",
"idx": 146,
"is_submittable": 1,
"links": [],
- "modified": "2020-12-26 17:07:59.194403",
+ "modified": "2021-04-15 23:55:49.620641",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 36d0de1e5df..e0b89d8e451 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -494,7 +494,8 @@ def make_item_variant():
test_records = frappe.get_test_records('Item')
-def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, is_customer_provided_item=None, customer=None, is_purchase_item=None, opening_stock=None):
+def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, is_customer_provided_item=None,
+ customer=None, is_purchase_item=None, opening_stock=None, company=None):
if not frappe.db.exists("Item", item_code):
item = frappe.new_doc("Item")
item.item_code = item_code
@@ -509,7 +510,7 @@ def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None,
item.customer = customer or ''
item.append("item_defaults", {
"default_warehouse": warehouse or '_Test Warehouse - _TC',
- "company": "_Test Company"
+ "company": company or "_Test Company"
})
item.save()
else:
diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json
index c1f20a47b71..6cec85288fe 100644
--- a/erpnext/stock/doctype/item/test_records.json
+++ b/erpnext/stock/doctype/item/test_records.json
@@ -59,6 +59,8 @@
"show_in_website": 1,
"website_warehouse": "_Test Warehouse - _TC",
"gst_hsn_code": "999800",
+ "opening_stock": 10,
+ "valuation_rate": 100,
"item_defaults": [{
"company": "_Test Company",
"default_warehouse": "_Test Warehouse - _TC",
diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
index 24f7e31a0cc..e8fb34732fc 100644
--- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
+++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
@@ -15,8 +15,9 @@ frappe.ui.form.on('Item Variant Settings', {
}
});
- const child = frappe.meta.get_docfield("Variant Field", "field_name", frm.doc.name);
- child.options = allow_fields;
+ frm.fields_dict.fields.grid.update_docfield_property(
+ 'field_name', 'options', allow_fields
+ );
});
}
});
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js
index bd14e5f6161..40d46852d03 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.js
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.js
@@ -110,19 +110,4 @@ cur_frm.cscript.calc_net_total_pkg = function(doc, ps_detail) {
refresh_many(['net_weight_pkg', 'net_weight_uom', 'gross_weight_uom', 'gross_weight_pkg']);
}
-var make_row = function(title,val,bold){
- var bstart = ''; var bend = '';
- return ' | |||||
| '+(bold?bstart:'')+title+(bold?bend:'')+' | ' - +''+ val +' | ' - +'||||
{{ education_settings.description }}
+ {% if education_settings.description %} +{{ education_settings.description }}
+ {% endif %}{% if frappe.session.user == 'Guest' %} {{_('Sign Up')}} @@ -51,13 +53,15 @@
You have not enrolled in any program. Contact your Instructor.
{% endif %}