diff --git a/erpnext/accounts/doctype/cost_center/cost_center.js b/erpnext/accounts/doctype/cost_center/cost_center.js
index ee23b1be5c..632fab0197 100644
--- a/erpnext/accounts/doctype/cost_center/cost_center.js
+++ b/erpnext/accounts/doctype/cost_center/cost_center.js
@@ -15,17 +15,6 @@ frappe.ui.form.on('Cost Center', {
}
}
});
-
- frm.set_query("cost_center", "distributed_cost_center", function() {
- return {
- filters: {
- company: frm.doc.company,
- is_group: 0,
- enable_distributed_cost_center: 0,
- name: ['!=', frm.doc.name]
- }
- };
- });
},
refresh: function(frm) {
if (!frm.is_new()) {
diff --git a/erpnext/accounts/doctype/cost_center/cost_center.json b/erpnext/accounts/doctype/cost_center/cost_center.json
index e7fa954e01..7cbb290947 100644
--- a/erpnext/accounts/doctype/cost_center/cost_center.json
+++ b/erpnext/accounts/doctype/cost_center/cost_center.json
@@ -16,9 +16,6 @@
"cb0",
"is_group",
"disabled",
- "section_break_9",
- "enable_distributed_cost_center",
- "distributed_cost_center",
"lft",
"rgt",
"old_parent"
@@ -122,31 +119,13 @@
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
- },
- {
- "default": "0",
- "fieldname": "enable_distributed_cost_center",
- "fieldtype": "Check",
- "label": "Enable Distributed Cost Center"
- },
- {
- "depends_on": "eval:doc.is_group==0",
- "fieldname": "section_break_9",
- "fieldtype": "Section Break"
- },
- {
- "depends_on": "enable_distributed_cost_center",
- "fieldname": "distributed_cost_center",
- "fieldtype": "Table",
- "label": "Distributed Cost Center",
- "options": "Distributed Cost Center"
}
],
"icon": "fa fa-money",
"idx": 1,
"is_tree": 1,
"links": [],
- "modified": "2020-06-17 16:09:30.025214",
+ "modified": "2022-01-31 13:22:58.916273",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@@ -189,5 +168,6 @@
"search_fields": "parent_cost_center, is_group",
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center/cost_center.py b/erpnext/accounts/doctype/cost_center/cost_center.py
index 7ae0a72e3d..07cc0764e3 100644
--- a/erpnext/accounts/doctype/cost_center/cost_center.py
+++ b/erpnext/accounts/doctype/cost_center/cost_center.py
@@ -4,7 +4,6 @@
import frappe
from frappe import _
-from frappe.utils import cint
from frappe.utils.nestedset import NestedSet
from erpnext.accounts.utils import validate_field_number
@@ -20,24 +19,6 @@ class CostCenter(NestedSet):
def validate(self):
self.validate_mandatory()
self.validate_parent_cost_center()
- self.validate_distributed_cost_center()
-
- def validate_distributed_cost_center(self):
- if cint(self.enable_distributed_cost_center):
- if not self.distributed_cost_center:
- frappe.throw(_("Please enter distributed cost center"))
- if sum(x.percentage_allocation for x in self.distributed_cost_center) != 100:
- frappe.throw(_("Total percentage allocation for distributed cost center should be equal to 100"))
- if not self.get('__islocal'):
- if not cint(frappe.get_cached_value("Cost Center", {"name": self.name}, "enable_distributed_cost_center")) \
- and self.check_if_part_of_distributed_cost_center():
- frappe.throw(_("Cannot enable Distributed Cost Center for a Cost Center already allocated in another Distributed Cost Center"))
- if next((True for x in self.distributed_cost_center if x.cost_center == x.parent), False):
- frappe.throw(_("Parent Cost Center cannot be added in Distributed Cost Center"))
- if check_if_distributed_cost_center_enabled(list(x.cost_center for x in self.distributed_cost_center)):
- frappe.throw(_("A Distributed Cost Center cannot be added in the Distributed Cost Center allocation table."))
- else:
- self.distributed_cost_center = []
def validate_mandatory(self):
if self.cost_center_name != self.company and not self.parent_cost_center:
@@ -64,10 +45,10 @@ class CostCenter(NestedSet):
@frappe.whitelist()
def convert_ledger_to_group(self):
- if cint(self.enable_distributed_cost_center):
- frappe.throw(_("Cost Center with enabled distributed cost center can not be converted to group"))
- if self.check_if_part_of_distributed_cost_center():
- frappe.throw(_("Cost Center Already Allocated in a Distributed Cost Center cannot be converted to group"))
+ if self.if_allocation_exists_against_cost_center():
+ frappe.throw(_("Cost Center with Allocation records can not be converted to a group"))
+ if self.check_if_part_of_cost_center_allocation():
+ frappe.throw(_("Cost Center is a part of Cost Center Allocation, hence cannot be converted to a group"))
if self.check_gle_exists():
frappe.throw(_("Cost Center with existing transactions can not be converted to group"))
self.is_group = 1
@@ -81,8 +62,17 @@ class CostCenter(NestedSet):
return frappe.db.sql("select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2", self.name)
- def check_if_part_of_distributed_cost_center(self):
- return frappe.db.get_value("Distributed Cost Center", {"cost_center": self.name})
+ def if_allocation_exists_against_cost_center(self):
+ return frappe.db.get_value("Cost Center Allocation", filters = {
+ "main_cost_center": self.name,
+ "docstatus": 1
+ })
+
+ def check_if_part_of_cost_center_allocation(self):
+ return frappe.db.get_value("Cost Center Allocation Percentage", filters = {
+ "cost_center": self.name,
+ "docstatus": 1
+ })
def before_rename(self, olddn, newdn, merge=False):
# Add company abbr if not provided
@@ -126,8 +116,4 @@ def on_doctype_update():
def get_name_with_number(new_account, account_number):
if account_number and not new_account[0].isdigit():
new_account = account_number + " - " + new_account
- return new_account
-
-def check_if_distributed_cost_center_enabled(cost_center_list):
- value_list = frappe.get_list("Cost Center", {"name": ["in", cost_center_list]}, "enable_distributed_cost_center", as_list=1)
- return next((True for x in value_list if x[0]), False)
+ return new_account
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center/test_cost_center.py b/erpnext/accounts/doctype/cost_center/test_cost_center.py
index f8615ec03a..ff50a21124 100644
--- a/erpnext/accounts/doctype/cost_center/test_cost_center.py
+++ b/erpnext/accounts/doctype/cost_center/test_cost_center.py
@@ -23,33 +23,6 @@ class TestCostCenter(unittest.TestCase):
self.assertRaises(frappe.ValidationError, cost_center.save)
- def test_validate_distributed_cost_center(self):
-
- if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center - _TC'}):
- frappe.get_doc(test_records[0]).insert()
-
- if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center 2 - _TC'}):
- frappe.get_doc(test_records[1]).insert()
-
- invalid_distributed_cost_center = frappe.get_doc({
- "company": "_Test Company",
- "cost_center_name": "_Test Distributed Cost Center",
- "doctype": "Cost Center",
- "is_group": 0,
- "parent_cost_center": "_Test Company - _TC",
- "enable_distributed_cost_center": 1,
- "distributed_cost_center": [{
- "cost_center": "_Test Cost Center - _TC",
- "percentage_allocation": 40
- }, {
- "cost_center": "_Test Cost Center 2 - _TC",
- "percentage_allocation": 50
- }
- ]
- })
-
- self.assertRaises(frappe.ValidationError, invalid_distributed_cost_center.save)
-
def create_cost_center(**args):
args = frappe._dict(args)
if args.cost_center_name:
diff --git a/erpnext/accounts/doctype/distributed_cost_center/__init__.py b/erpnext/accounts/doctype/cost_center_allocation/__init__.py
similarity index 100%
rename from erpnext/accounts/doctype/distributed_cost_center/__init__.py
rename to erpnext/accounts/doctype/cost_center_allocation/__init__.py
diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js
new file mode 100644
index 0000000000..ab0baab24a
--- /dev/null
+++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js
@@ -0,0 +1,19 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Cost Center Allocation', {
+ setup: function(frm) {
+ let filters = {"is_group": 0};
+ if (frm.doc.company) {
+ $.extend(filters, {
+ "company": frm.doc.company
+ });
+ }
+
+ frm.set_query('main_cost_center', function() {
+ return {
+ filters: filters
+ };
+ });
+ }
+});
diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json
new file mode 100644
index 0000000000..45ab886d33
--- /dev/null
+++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json
@@ -0,0 +1,128 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "CC-ALLOC-.#####",
+ "creation": "2022-01-13 20:07:29.871109",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "main_cost_center",
+ "company",
+ "column_break_2",
+ "valid_from",
+ "section_break_5",
+ "allocation_percentages",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "main_cost_center",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Main Cost Center",
+ "options": "Cost Center",
+ "reqd": 1
+ },
+ {
+ "default": "Today",
+ "fieldname": "valid_from",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Valid From",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_5",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fetch_from": "main_cost_center.company",
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ },
+ {
+ "fieldname": "allocation_percentages",
+ "fieldtype": "Table",
+ "label": "Cost Center Allocation Percentages",
+ "options": "Cost Center Allocation Percentage",
+ "reqd": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Cost Center Allocation",
+ "print_hide": 1,
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-01-31 11:47:12.086253",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Cost Center Allocation",
+ "name_case": "UPPER CASE",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py
new file mode 100644
index 0000000000..bad3fb4f96
--- /dev/null
+++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py
@@ -0,0 +1,90 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import add_days, format_date, getdate
+
+
+class MainCostCenterCantBeChild(frappe.ValidationError):
+ pass
+class InvalidMainCostCenter(frappe.ValidationError):
+ pass
+class InvalidChildCostCenter(frappe.ValidationError):
+ pass
+class WrongPercentageAllocation(frappe.ValidationError):
+ pass
+class InvalidDateError(frappe.ValidationError):
+ pass
+
+class CostCenterAllocation(Document):
+ def validate(self):
+ self.validate_total_allocation_percentage()
+ self.validate_from_date_based_on_existing_gle()
+ self.validate_backdated_allocation()
+ self.validate_main_cost_center()
+ self.validate_child_cost_centers()
+
+ def validate_total_allocation_percentage(self):
+ total_percentage = sum([d.percentage for d in self.get("allocation_percentages", [])])
+
+ if total_percentage != 100:
+ frappe.throw(_("Total percentage against cost centers should be 100"), WrongPercentageAllocation)
+
+ def validate_from_date_based_on_existing_gle(self):
+ # Check if GLE exists against the main cost center
+ # If exists ensure from date is set after posting date of last GLE
+
+ last_gle_date = frappe.db.get_value("GL Entry",
+ {"cost_center": self.main_cost_center, "is_cancelled": 0},
+ "posting_date", order_by="posting_date desc")
+
+ if last_gle_date:
+ if getdate(self.valid_from) <= getdate(last_gle_date):
+ frappe.throw(_("Valid From must be after {0} as last GL Entry against the cost center {1} posted on this date")
+ .format(last_gle_date, self.main_cost_center), InvalidDateError)
+
+ def validate_backdated_allocation(self):
+ # Check if there are any future existing allocation records against the main cost center
+ # If exists, warn the user about it
+
+ future_allocation = frappe.db.get_value("Cost Center Allocation", filters = {
+ "main_cost_center": self.main_cost_center,
+ "valid_from": (">=", self.valid_from),
+ "name": ("!=", self.name),
+ "docstatus": 1
+ }, fieldname=['valid_from', 'name'], order_by='valid_from', as_dict=1)
+
+ if future_allocation:
+ frappe.msgprint(_("Another Cost Center Allocation record {0} applicable from {1}, hence this allocation will be applicable upto {2}")
+ .format(frappe.bold(future_allocation.name), frappe.bold(format_date(future_allocation.valid_from)),
+ frappe.bold(format_date(add_days(future_allocation.valid_from, -1)))),
+ title=_("Warning!"), indicator="orange", alert=1
+ )
+
+ def validate_main_cost_center(self):
+ # Main cost center itself cannot be entered in child table
+ if self.main_cost_center in [d.cost_center for d in self.allocation_percentages]:
+ frappe.throw(_("Main Cost Center {0} cannot be entered in the child table")
+ .format(self.main_cost_center), MainCostCenterCantBeChild)
+
+ # If main cost center is used for allocation under any other cost center,
+ # allocation cannot be done against it
+ parent = frappe.db.get_value("Cost Center Allocation Percentage", filters = {
+ "cost_center": self.main_cost_center,
+ "docstatus": 1
+ }, fieldname='parent')
+ if parent:
+ frappe.throw(_("{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}")
+ .format(self.main_cost_center, parent), InvalidMainCostCenter)
+
+ def validate_child_cost_centers(self):
+ # Check if child cost center is used as main cost center in any existing allocation
+ main_cost_centers = [d.main_cost_center for d in
+ frappe.get_all("Cost Center Allocation", {'docstatus': 1}, 'main_cost_center')]
+
+ for d in self.allocation_percentages:
+ if d.cost_center in main_cost_centers:
+ frappe.throw(_("Cost Center {0} cannot be used for allocation as it is used as main cost center in other allocation record.")
+ .format(d.cost_center), InvalidChildCostCenter)
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py
new file mode 100644
index 0000000000..9cf4c00217
--- /dev/null
+++ b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py
@@ -0,0 +1,156 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import unittest
+
+import frappe
+from frappe.utils import add_days, today
+
+from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
+from erpnext.accounts.doctype.cost_center_allocation.cost_center_allocation import (
+ InvalidChildCostCenter,
+ InvalidDateError,
+ InvalidMainCostCenter,
+ MainCostCenterCantBeChild,
+ WrongPercentageAllocation,
+)
+from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
+
+
+class TestCostCenterAllocation(unittest.TestCase):
+ def setUp(self):
+ cost_centers = ["Main Cost Center 1", "Main Cost Center 2", "Sub Cost Center 1", "Sub Cost Center 2"]
+ for cc in cost_centers:
+ create_cost_center(cost_center_name=cc, company="_Test Company")
+
+ def test_gle_based_on_cost_center_allocation(self):
+ cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 60,
+ "Sub Cost Center 2 - _TC": 40
+ }
+ )
+
+ jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100,
+ cost_center = "Main Cost Center 1 - _TC", submit=True)
+
+ expected_values = [
+ ["Sub Cost Center 1 - _TC", 0.0, 60],
+ ["Sub Cost Center 2 - _TC", 0.0, 40]
+ ]
+
+ gle = frappe.qb.DocType("GL Entry")
+ gl_entries = (
+ frappe.qb.from_(gle)
+ .select(gle.cost_center, gle.debit, gle.credit)
+ .where(gle.voucher_type == 'Journal Entry')
+ .where(gle.voucher_no == jv.name)
+ .where(gle.account == 'Sales - _TC')
+ .orderby(gle.cost_center)
+ ).run(as_dict=1)
+
+ self.assertTrue(gl_entries)
+
+ for i, gle in enumerate(gl_entries):
+ self.assertEqual(expected_values[i][0], gle.cost_center)
+ self.assertEqual(expected_values[i][1], gle.debit)
+ self.assertEqual(expected_values[i][2], gle.credit)
+
+ cca.cancel()
+ jv.cancel()
+
+ def test_main_cost_center_cant_be_child(self):
+ # Main cost center itself cannot be entered in child table
+ cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 60,
+ "Main Cost Center 1 - _TC": 40
+ }, save=False
+ )
+
+ self.assertRaises(MainCostCenterCantBeChild, cca.save)
+
+ def test_invalid_main_cost_center(self):
+ # If main cost center is used for allocation under any other cost center,
+ # allocation cannot be done against it
+ cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 60,
+ "Sub Cost Center 2 - _TC": 40
+ }
+ )
+
+ cca2 = create_cost_center_allocation("_Test Company", "Sub Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 2 - _TC": 100
+ }, save=False
+ )
+
+ self.assertRaises(InvalidMainCostCenter, cca2.save)
+
+ cca1.cancel()
+
+ def test_if_child_cost_center_has_any_allocation_record(self):
+ # Check if any child cost center is used as main cost center in any other existing allocation
+ cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 60,
+ "Sub Cost Center 2 - _TC": 40
+ }
+ )
+
+ cca2 = create_cost_center_allocation("_Test Company", "Main Cost Center 2 - _TC",
+ {
+ "Main Cost Center 1 - _TC": 60,
+ "Sub Cost Center 1 - _TC": 40
+ }, save=False
+ )
+
+ self.assertRaises(InvalidChildCostCenter, cca2.save)
+
+ cca1.cancel()
+
+ def test_total_percentage(self):
+ cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 40,
+ "Sub Cost Center 2 - _TC": 40
+ }, save=False
+ )
+ self.assertRaises(WrongPercentageAllocation, cca.save)
+
+ def test_valid_from_based_on_existing_gle(self):
+ # GLE posted against Sub Cost Center 1 on today
+ jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100,
+ cost_center = "Main Cost Center 1 - _TC", posting_date=today(), submit=True)
+
+ # try to set valid from as yesterday
+ cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
+ {
+ "Sub Cost Center 1 - _TC": 60,
+ "Sub Cost Center 2 - _TC": 40
+ }, valid_from=add_days(today(), -1), save=False
+ )
+
+ self.assertRaises(InvalidDateError, cca.save)
+
+ jv.cancel()
+
+def create_cost_center_allocation(company, main_cost_center, allocation_percentages,
+ valid_from=None, valid_upto=None, save=True, submit=True):
+ doc = frappe.new_doc("Cost Center Allocation")
+ doc.main_cost_center = main_cost_center
+ doc.company = company
+ doc.valid_from = valid_from or today()
+ doc.valid_upto = valid_upto
+ for cc, percentage in allocation_percentages.items():
+ doc.append("allocation_percentages", {
+ "cost_center": cc,
+ "percentage": percentage
+ })
+ if save:
+ doc.save()
+ if submit:
+ doc.submit()
+
+ return doc
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/accounts/doctype/cost_center_allocation_percentage/__init__.py
similarity index 100%
rename from erpnext/patches/v14_0/__init__.py
rename to erpnext/accounts/doctype/cost_center_allocation_percentage/__init__.py
diff --git a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
similarity index 63%
rename from erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json
rename to erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
index 45b0e2df9b..7e50962a87 100644
--- a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json
+++ b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
@@ -1,12 +1,13 @@
{
"actions": [],
- "creation": "2020-03-19 12:34:01.500390",
+ "allow_rename": 1,
+ "creation": "2022-01-13 20:07:30.096306",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cost_center",
- "percentage_allocation"
+ "percentage"
],
"fields": [
{
@@ -18,23 +19,23 @@
"reqd": 1
},
{
- "fieldname": "percentage_allocation",
- "fieldtype": "Float",
+ "fieldname": "percentage",
+ "fieldtype": "Percent",
"in_list_view": 1,
- "label": "Percentage Allocation",
+ "label": "Percentage (%)",
"reqd": 1
}
],
+ "index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-03-19 12:54:43.674655",
+ "modified": "2022-02-01 22:22:31.589523",
"modified_by": "Administrator",
"module": "Accounts",
- "name": "Distributed Cost Center",
+ "name": "Cost Center Allocation Percentage",
"owner": "Administrator",
"permissions": [],
- "quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
- "track_changes": 1
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py
new file mode 100644
index 0000000000..7d20efb6d5
--- /dev/null
+++ b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class CostCenterAllocationPercentage(Document):
+ pass
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 134bccf3d1..97d34e0a71 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -42,7 +42,6 @@ class POSInvoice(SalesInvoice):
self.validate_serialised_or_batched_item()
self.validate_stock_availablility()
self.validate_return_items_qty()
- self.validate_non_stock_items()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
@@ -158,22 +157,39 @@ class POSInvoice(SalesInvoice):
frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.")
.format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable"))
+ def validate_invalid_serial_nos(self, item):
+ serial_nos = get_serial_nos(item.serial_no)
+ error_msg = []
+ invalid_serials, msg = "", ""
+ for serial_no in serial_nos:
+ if not frappe.db.exists('Serial No', serial_no):
+ invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no
+ msg = (_("Row #{}: Following Serial numbers for item {} are Invalid: {}").format(item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials)))
+ if invalid_serials:
+ error_msg.append(msg)
+
+ if error_msg:
+ frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
+
def validate_stock_availablility(self):
if self.is_return or self.docstatus != 1:
return
-
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
+ is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
+ if is_service_item:
+ return
if d.serial_no:
self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d)
+ self.validate_invalid_serial_nos(d)
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
if allow_negative_stock:
return
- available_stock = get_stock_availability(d.item_code, d.warehouse)
+ available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0:
@@ -244,14 +260,6 @@ class POSInvoice(SalesInvoice):
.format(d.idx, bold_serial_no, bold_return_against)
)
- def validate_non_stock_items(self):
- for d in self.get("items"):
- is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
- if not is_stock_item:
- if not frappe.db.exists('Product Bundle', d.item_code):
- frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
- .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
-
def validate_mode_of_payment(self):
if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
@@ -491,12 +499,18 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
+ is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
- return bin_qty - pos_sales_qty
+ return bin_qty - pos_sales_qty, is_stock_item
else:
+ is_stock_item = False
if frappe.db.exists('Product Bundle', item_code):
- return get_bundle_availability(item_code, warehouse)
+ return get_bundle_availability(item_code, warehouse), is_stock_item
+ else:
+ # Is a service item
+ return 0, is_stock_item
+
def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index 56479a0b77..ba751c081b 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit)
+ def test_invalid_serial_no_validation(self):
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
+
+ se = make_serialized_item(company='_Test Company',
+ target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC')
+ serial_nos = se.get("items")[0].serial_no + 'wrong'
+
+ pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC',
+ account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC',
+ expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC',
+ item=se.get("items")[0].item_code, rate=1000, qty=2, do_not_save=1)
+
+ pos.get('items')[0].has_serial_no = 1
+ pos.get('items')[0].serial_no = serial_nos
+ pos.insert()
+
+ self.assertRaises(frappe.ValidationError, pos.submit)
+
def test_loyalty_points(self):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index b3642181ac..279557adc7 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -548,6 +548,10 @@ class PurchaseInvoice(BuyingController):
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
enable_discount_accounting = cint(frappe.db.get_single_value('Accounts Settings', 'enable_discount_accounting'))
+ provisional_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, \
+ 'enable_provisional_accounting_for_non_stock_items'))
+
+ purchase_receipt_doc_map = {}
for item in self.get("items"):
if flt(item.base_net_amount):
@@ -643,19 +647,23 @@ class PurchaseInvoice(BuyingController):
else:
amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount"))
- auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
-
- if auto_accounting_for_non_stock_items:
- service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
-
+ if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
+ provisional_account = self.get_company_default("default_provisional_account")
+ purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
+
+ if not purchase_receipt_doc:
+ purchase_receipt_doc = frappe.get_doc("Purchase Receipt", item.purchase_receipt)
+ purchase_receipt_doc_map[item.purchase_receipt] = purchase_receipt_doc
+
# Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt
expense_booked_in_pr = frappe.db.get_value('GL Entry', {'is_cancelled': 0,
'voucher_type': 'Purchase Receipt', 'voucher_no': item.purchase_receipt, 'voucher_detail_no': item.pr_detail,
- 'account':service_received_but_not_billed_account}, ['name'])
+ 'account':provisional_account}, ['name'])
if expense_booked_in_pr:
- expense_account = service_received_but_not_billed_account
+ # Intentionally passing purchase invoice item to handle partial billing
+ purchase_receipt_doc.add_provisional_gl_entry(item, gl_entries, self.posting_date, reverse=1)
if not self.is_internal_transfer():
gl_entries.append(self.get_gl_dict({
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 21846bb76c..d51a008d94 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -11,12 +11,17 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
+from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.controllers.accounts_controller import get_payment_terms
from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ make_purchase_invoice as create_purchase_invoice_from_receipt,
+)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_taxes,
make_purchase_receipt,
@@ -1147,8 +1152,6 @@ class TestPurchaseInvoice(unittest.TestCase):
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
- from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
- from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
# create a new supplier to test
supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier',
@@ -1221,6 +1224,45 @@ class TestPurchaseInvoice(unittest.TestCase):
payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
+ def test_provisional_accounting_entry(self):
+ item = create_item("_Test Non Stock Item", is_stock_item=0)
+ provisional_account = create_account(account_name="Provision Account",
+ parent_account="Current Liabilities - _TC", company="_Test Company")
+
+ company = frappe.get_doc('Company', '_Test Company')
+ company.enable_provisional_accounting_for_non_stock_items = 1
+ company.default_provisional_account = provisional_account
+ company.save()
+
+ pr = make_purchase_receipt(item_code="_Test Non Stock Item", posting_date=add_days(nowdate(), -2))
+
+ pi = create_purchase_invoice_from_receipt(pr.name)
+ pi.set_posting_time = 1
+ pi.posting_date = add_days(pr.posting_date, -1)
+ pi.items[0].expense_account = 'Cost of Goods Sold - _TC'
+ pi.save()
+ pi.submit()
+
+ # Check GLE for Purchase Invoice
+ expected_gle = [
+ ['Cost of Goods Sold - _TC', 250, 0, add_days(pr.posting_date, -1)],
+ ['Creditors - _TC', 0, 250, add_days(pr.posting_date, -1)]
+ ]
+
+ check_gl_entries(self, pi.name, expected_gle, pi.posting_date)
+
+ expected_gle_for_purchase_receipt = [
+ ["Provision Account - _TC", 250, 0, pr.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 0, 250, pr.posting_date],
+ ["Provision Account - _TC", 0, 250, pi.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date]
+ ]
+
+ check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
+
+ company.enable_provisional_accounting_for_non_stock_items = 0
+ company.save()
+
def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql("""select account, debit, credit, posting_date
from `tabGL Entry`
diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india.js b/erpnext/accounts/doctype/sales_invoice/regional/india.js
index 6336db16eb..f54bce8aac 100644
--- a/erpnext/accounts/doctype/sales_invoice/regional/india.js
+++ b/erpnext/accounts/doctype/sales_invoice/regional/india.js
@@ -1,6 +1,8 @@
{% include "erpnext/regional/india/taxes.js" %}
+{% include "erpnext/regional/india/e_invoice/einvoice.js" %}
erpnext.setup_auto_gst_taxation('Sales Invoice');
+erpnext.setup_einvoice_actions('Sales Invoice')
frappe.ui.form.on("Sales Invoice", {
setup: function(frm) {
diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
index d9d6634c39..f01325d80b 100644
--- a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
+++ b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js
@@ -36,4 +36,139 @@ frappe.listview_settings['Sales Invoice'].onload = function (list_view) {
};
list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false);
+
+ const generate_irns = () => {
+ const docnames = list_view.get_checked_items(true);
+ if (docnames && docnames.length) {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_einvoices',
+ args: { docnames },
+ freeze: true,
+ freeze_message: __('Generating E-Invoices...')
+ });
+ } else {
+ frappe.msgprint({
+ message: __('Please select at least one sales invoice to generate IRN'),
+ title: __('No Invoice Selected'),
+ indicator: 'red'
+ });
+ }
+ };
+
+ const cancel_irns = () => {
+ const docnames = list_view.get_checked_items(true);
+
+ const fields = [
+ {
+ "label": "Reason",
+ "fieldname": "reason",
+ "fieldtype": "Select",
+ "reqd": 1,
+ "default": "1-Duplicate",
+ "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
+ },
+ {
+ "label": "Remark",
+ "fieldname": "remark",
+ "fieldtype": "Data",
+ "reqd": 1
+ }
+ ];
+
+ const d = new frappe.ui.Dialog({
+ title: __("Cancel IRN"),
+ fields: fields,
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_irns',
+ args: {
+ doctype: list_view.doctype,
+ docnames,
+ reason: data.reason.split('-')[0],
+ remark: data.remark
+ },
+ freeze: true,
+ freeze_message: __('Cancelling E-Invoices...'),
+ });
+ d.hide();
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+
+ let einvoicing_enabled = false;
+ frappe.db.get_single_value("E Invoice Settings", "enable").then(enabled => {
+ einvoicing_enabled = enabled;
+ });
+
+ list_view.$result.on("change", "input[type=checkbox]", () => {
+ if (einvoicing_enabled) {
+ const docnames = list_view.get_checked_items(true);
+ // show/hide e-invoicing actions when no sales invoices are checked
+ if (docnames && docnames.length) {
+ // prevent adding actions twice if e-invoicing action group already exists
+ if (list_view.page.get_inner_group_button(__('E-Invoicing')).length == 0) {
+ list_view.page.add_inner_button(__('Generate IRNs'), generate_irns, __('E-Invoicing'));
+ list_view.page.add_inner_button(__('Cancel IRNs'), cancel_irns, __('E-Invoicing'));
+ }
+ } else {
+ list_view.page.remove_inner_button(__('Generate IRNs'), __('E-Invoicing'));
+ list_view.page.remove_inner_button(__('Cancel IRNs'), __('E-Invoicing'));
+ }
+ }
+ });
+
+ frappe.realtime.on("bulk_einvoice_generation_complete", (data) => {
+ const { failures, user, invoices } = data;
+
+ if (invoices.length != failures.length) {
+ frappe.msgprint({
+ message: __('{0} e-invoices generated successfully', [invoices.length]),
+ title: __('Bulk E-Invoice Generation Complete'),
+ indicator: 'orange'
+ });
+ }
+
+ if (failures && failures.length && user == frappe.session.user) {
+ let message = `
+ Failed to generate IRNs for following ${failures.length} sales invoices:
+
+ ${failures.map(d => `- ${d.docname}
`).join('')}
+
+ `;
+ frappe.msgprint({
+ message: message,
+ title: __('Bulk E-Invoice Generation Complete'),
+ indicator: 'orange'
+ });
+ }
+ });
+
+ frappe.realtime.on("bulk_einvoice_cancellation_complete", (data) => {
+ const { failures, user, invoices } = data;
+
+ if (invoices.length != failures.length) {
+ frappe.msgprint({
+ message: __('{0} e-invoices cancelled successfully', [invoices.length]),
+ title: __('Bulk E-Invoice Cancellation Complete'),
+ indicator: 'orange'
+ });
+ }
+
+ if (failures && failures.length && user == frappe.session.user) {
+ let message = `
+ Failed to cancel IRNs for following ${failures.length} sales invoices:
+
+ ${failures.map(d => `- ${d.docname}
`).join('')}
+
+ `;
+ frappe.msgprint({
+ message: message,
+ title: __('Bulk E-Invoice Cancellation Complete'),
+ indicator: 'orange'
+ });
+ }
+ });
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index 39dfd8d76d..af6a52a642 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -469,7 +469,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail)
});
- frm.trigger("calculate_timesheet_totals");
+ this.frm.trigger("calculate_timesheet_totals");
}
}
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index f04e7eacb9..bc443581e4 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -294,6 +294,8 @@ class SalesInvoice(SellingController):
def before_cancel(self):
self.check_if_consolidated_invoice()
+
+ super(SalesInvoice, self).before_cancel()
self.update_time_sheet(None)
def on_cancel(self):
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 55e3853060..941061f2a2 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2100,6 +2100,54 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(data['billLists'][0]['actualFromStateCode'],7)
self.assertEqual(data['billLists'][0]['fromStateCode'],27)
+ def test_einvoice_submission_without_irn(self):
+ # init
+ einvoice_settings = frappe.get_doc('E Invoice Settings')
+ einvoice_settings.enable = 1
+ einvoice_settings.applicable_from = nowdate()
+ einvoice_settings.append('credentials', {
+ 'company': '_Test Company',
+ 'gstin': '27AAECE4835E1ZR',
+ 'username': 'test',
+ 'password': 'test'
+ })
+ einvoice_settings.save()
+
+ country = frappe.flags.country
+ frappe.flags.country = 'India'
+
+ si = make_sales_invoice_for_ewaybill()
+ self.assertRaises(frappe.ValidationError, si.submit)
+
+ si.irn = 'test_irn'
+ si.submit()
+
+ # reset
+ einvoice_settings = frappe.get_doc('E Invoice Settings')
+ einvoice_settings.enable = 0
+ frappe.flags.country = country
+
+ def test_einvoice_json(self):
+ from erpnext.regional.india.e_invoice.utils import make_einvoice, validate_totals
+
+ si = get_sales_invoice_for_e_invoice()
+ si.discount_amount = 100
+ si.save()
+
+ einvoice = make_einvoice(si)
+ self.assertTrue(einvoice['EwbDtls'])
+ validate_totals(einvoice)
+
+ si.apply_discount_on = 'Net Total'
+ si.save()
+ einvoice = make_einvoice(si)
+ validate_totals(einvoice)
+
+ [d.set('included_in_print_rate', 1) for d in si.taxes]
+ si.save()
+ einvoice = make_einvoice(si)
+ validate_totals(einvoice)
+
def test_item_tax_net_range(self):
item = create_item("T Shirt")
diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
index 7e5129911e..792e7d21a7 100644
--- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
+++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
@@ -71,7 +71,8 @@ class ShippingRule(Document):
if doc.currency != doc.company_currency:
shipping_amount = flt(shipping_amount / doc.conversion_rate, 2)
- self.add_shipping_rule_to_tax_table(doc, shipping_amount)
+ if shipping_amount:
+ self.add_shipping_rule_to_tax_table(doc, shipping_amount)
def get_shipping_amount_from_rules(self, value):
for condition in self.get("conditions"):
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 1836db6477..55bc9673c1 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -2,6 +2,8 @@
# License: GNU General Public License v3. See license.txt
+import copy
+
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
@@ -51,49 +53,57 @@ def validate_accounting_period(gl_map):
.format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod)
def process_gl_map(gl_map, merge_entries=True, precision=None):
+ if not gl_map:
+ return []
+
+ gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision)
+
if merge_entries:
gl_map = merge_similar_entries(gl_map, precision)
- for entry in gl_map:
- # toggle debit, credit if negative entry
- if flt(entry.debit) < 0:
- entry.credit = flt(entry.credit) - flt(entry.debit)
- entry.debit = 0.0
- if flt(entry.debit_in_account_currency) < 0:
- entry.credit_in_account_currency = \
- flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency)
- entry.debit_in_account_currency = 0.0
-
- if flt(entry.credit) < 0:
- entry.debit = flt(entry.debit) - flt(entry.credit)
- entry.credit = 0.0
-
- if flt(entry.credit_in_account_currency) < 0:
- entry.debit_in_account_currency = \
- flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency)
- entry.credit_in_account_currency = 0.0
-
- update_net_values(entry)
+ gl_map = toggle_debit_credit_if_negative(gl_map)
return gl_map
-def update_net_values(entry):
- # In some scenarios net value needs to be shown in the ledger
- # This method updates net values as debit or credit
- if entry.post_net_value and entry.debit and entry.credit:
- if entry.debit > entry.credit:
- entry.debit = entry.debit - entry.credit
- entry.debit_in_account_currency = entry.debit_in_account_currency \
- - entry.credit_in_account_currency
- entry.credit = 0
- entry.credit_in_account_currency = 0
- else:
- entry.credit = entry.credit - entry.debit
- entry.credit_in_account_currency = entry.credit_in_account_currency \
- - entry.debit_in_account_currency
+def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
+ cost_center_allocation = get_cost_center_allocation_data(gl_map[0]["company"], gl_map[0]["posting_date"])
+ if not cost_center_allocation:
+ return gl_map
- entry.debit = 0
- entry.debit_in_account_currency = 0
+ new_gl_map = []
+ for d in gl_map:
+ cost_center = d.get("cost_center")
+ if cost_center and cost_center_allocation.get(cost_center):
+ for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
+ gle = copy.deepcopy(d)
+ gle.cost_center = sub_cost_center
+ for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"):
+ gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
+ new_gl_map.append(gle)
+ else:
+ new_gl_map.append(d)
+
+ return new_gl_map
+
+def get_cost_center_allocation_data(company, posting_date):
+ par = frappe.qb.DocType("Cost Center Allocation")
+ child = frappe.qb.DocType("Cost Center Allocation Percentage")
+
+ records = (
+ frappe.qb.from_(par).inner_join(child).on(par.name == child.parent)
+ .select(par.main_cost_center, child.cost_center, child.percentage)
+ .where(par.docstatus == 1)
+ .where(par.company == company)
+ .where(par.valid_from <= posting_date)
+ .orderby(par.valid_from, order=frappe.qb.desc)
+ ).run(as_dict=True)
+
+ cc_allocation = frappe._dict()
+ for d in records:
+ cc_allocation.setdefault(d.main_cost_center, frappe._dict())\
+ .setdefault(d.cost_center, d.percentage)
+
+ return cc_allocation
def merge_similar_entries(gl_map, precision=None):
merged_gl_map = []
@@ -145,6 +155,49 @@ def check_if_in_list(gle, gl_map, dimensions=None):
if same_head:
return e
+def toggle_debit_credit_if_negative(gl_map):
+ for entry in gl_map:
+ # toggle debit, credit if negative entry
+ if flt(entry.debit) < 0:
+ entry.credit = flt(entry.credit) - flt(entry.debit)
+ entry.debit = 0.0
+
+ if flt(entry.debit_in_account_currency) < 0:
+ entry.credit_in_account_currency = \
+ flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency)
+ entry.debit_in_account_currency = 0.0
+
+ if flt(entry.credit) < 0:
+ entry.debit = flt(entry.debit) - flt(entry.credit)
+ entry.credit = 0.0
+
+ if flt(entry.credit_in_account_currency) < 0:
+ entry.debit_in_account_currency = \
+ flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency)
+ entry.credit_in_account_currency = 0.0
+
+ update_net_values(entry)
+
+ return gl_map
+
+def update_net_values(entry):
+ # In some scenarios net value needs to be shown in the ledger
+ # This method updates net values as debit or credit
+ if entry.post_net_value and entry.debit and entry.credit:
+ if entry.debit > entry.credit:
+ entry.debit = entry.debit - entry.credit
+ entry.debit_in_account_currency = entry.debit_in_account_currency \
+ - entry.credit_in_account_currency
+ entry.credit = 0
+ entry.credit_in_account_currency = 0
+ else:
+ entry.credit = entry.credit - entry.debit
+ entry.credit_in_account_currency = entry.credit_in_account_currency \
+ - entry.debit_in_account_currency
+
+ entry.debit = 0
+ entry.debit_in_account_currency = 0
+
def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False):
if not from_repost:
validate_cwip_accounts(gl_map)
diff --git a/erpnext/accounts/print_format/gst_e_invoice/__init__.py b/erpnext/accounts/print_format/gst_e_invoice/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html
new file mode 100644
index 0000000000..e658049309
--- /dev/null
+++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html
@@ -0,0 +1,173 @@
+{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%}
+{%- set einvoice = json.loads(doc.signed_einvoice) -%}
+
+
+
+ {% if print_settings.repeat_header_footer %}
+
+ {% endif %}
+
1. Transaction Details
+
+
+
+
+
{{ einvoice.Irn }}
+
+
+
+
{{ einvoice.AckNo }}
+
+
+
+
{{ frappe.utils.format_datetime(einvoice.AckDt, "dd/MM/yyyy hh:mm:ss") }}
+
+
+
+
{{ einvoice.TranDtls.SupTyp }}
+
+
+
+
{{ einvoice.DocDtls.Typ }}
+
+
+
+
{{ einvoice.DocDtls.No }}
+
+
+
+
+
+
+
2. Party Details
+
+ {%- set seller = einvoice.SellerDtls -%}
+
+
Seller
+
{{ seller.Gstin }}
+
{{ seller.LglNm }}
+
{{ seller.Addr1 }}
+ {%- if seller.Addr2 -%}
{{ seller.Addr2 }}
{% endif %}
+
{{ seller.Loc }}
+
{{ frappe.db.get_value("Address", doc.company_address, "gst_state") }} - {{ seller.Pin }}
+
+ {%- if einvoice.ShipDtls -%}
+ {%- set shipping = einvoice.ShipDtls -%}
+
Shipped From
+
{{ shipping.Gstin }}
+
{{ shipping.LglNm }}
+
{{ shipping.Addr1 }}
+ {%- if shipping.Addr2 -%}
{{ shipping.Addr2 }}
{% endif %}
+
{{ shipping.Loc }}
+
{{ frappe.db.get_value("Address", doc.shipping_address_name, "gst_state") }} - {{ shipping.Pin }}
+ {% endif %}
+
+ {%- set buyer = einvoice.BuyerDtls -%}
+
+
Buyer
+
{{ buyer.Gstin }}
+
{{ buyer.LglNm }}
+
{{ buyer.Addr1 }}
+ {%- if buyer.Addr2 -%}
{{ buyer.Addr2 }}
{% endif %}
+
{{ buyer.Loc }}
+
{{ frappe.db.get_value("Address", doc.customer_address, "gst_state") }} - {{ buyer.Pin }}
+
+ {%- if einvoice.DispDtls -%}
+ {%- set dispatch = einvoice.DispDtls -%}
+
Dispatched From
+ {%- if dispatch.Gstin -%}
{{ dispatch.Gstin }}
{% endif %}
+
{{ dispatch.LglNm }}
+
{{ dispatch.Addr1 }}
+ {%- if dispatch.Addr2 -%}
{{ dispatch.Addr2 }}
{% endif %}
+
{{ dispatch.Loc }}
+
{{ frappe.db.get_value("Address", doc.dispatch_address_name, "gst_state") }} - {{ dispatch.Pin }}
+ {% endif %}
+
+
+
+
3. Item Details
+
+
+
+ Sr. No. |
+ Item |
+ HSN Code |
+ Qty |
+ UOM |
+ Rate |
+ Discount |
+ Taxable Amount |
+ Tax Rate |
+ Other Charges |
+ Total |
+
+
+
+ {% for item in einvoice.ItemList %}
+
+ {{ item.SlNo }} |
+ {{ item.PrdDesc }} |
+ {{ item.HsnCd }} |
+ {{ item.Qty }} |
+ {{ item.Unit }} |
+ {{ frappe.utils.fmt_money(item.UnitPrice, None, "INR") }} |
+ {{ frappe.utils.fmt_money(item.Discount, None, "INR") }} |
+ {{ frappe.utils.fmt_money(item.AssAmt, None, "INR") }} |
+ {{ item.GstRt + item.CesRt }} % |
+ {{ frappe.utils.fmt_money(0, None, "INR") }} |
+ {{ frappe.utils.fmt_money(item.TotItemVal, None, "INR") }} |
+
+ {% endfor %}
+
+
+
+
+
4. Value Details
+
+
+
+ Taxable Amount |
+ CGST |
+ SGST |
+ IGST |
+ CESS |
+ State CESS |
+ Discount |
+ Other Charges |
+ Round Off |
+ Total Value |
+
+
+
+ {%- set value_details = einvoice.ValDtls -%}
+
+ {{ frappe.utils.fmt_money(value_details.AssVal, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.CgstVal, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.SgstVal, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.IgstVal, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.CesVal, None, "INR") }} |
+ {{ frappe.utils.fmt_money(0, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.Discount, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.OthChrg, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.RndOffAmt, None, "INR") }} |
+ {{ frappe.utils.fmt_money(value_details.TotInvVal, None, "INR") }} |
+
+
+
+
+
diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json
new file mode 100644
index 0000000000..1001199a09
--- /dev/null
+++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json
@@ -0,0 +1,24 @@
+{
+ "align_labels_right": 1,
+ "creation": "2020-10-10 18:01:21.032914",
+ "custom_format": 0,
+ "default_print_language": "en-US",
+ "disabled": 1,
+ "doc_type": "Sales Invoice",
+ "docstatus": 0,
+ "doctype": "Print Format",
+ "font": "Default",
+ "html": "",
+ "idx": 0,
+ "line_breaks": 1,
+ "modified": "2020-10-23 19:54:40.634936",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "GST E-Invoice",
+ "owner": "Administrator",
+ "print_format_builder": 0,
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 1,
+ "standard": "Yes"
+}
\ No newline at end of file
diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
index 3bb590a564..56ee5008cf 100644
--- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
+++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
@@ -29,18 +29,6 @@ def execute(filters=None):
dimension_items = cam_map.get(dimension)
if dimension_items:
data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, 0)
- else:
- DCC_allocation = frappe.db.sql('''SELECT parent, sum(percentage_allocation) as percentage_allocation
- FROM `tabDistributed Cost Center`
- WHERE cost_center IN %(dimension)s
- AND parent NOT IN %(dimension)s
- GROUP BY parent''',{'dimension':[dimension]})
- if DCC_allocation:
- filters['budget_against_filter'] = [DCC_allocation[0][0]]
- ddc_cam_map = get_dimension_account_month_map(filters)
- dimension_items = ddc_cam_map.get(DCC_allocation[0][0])
- if dimension_items:
- data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation[0][1])
chart = get_chart_data(filters, columns, data)
diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py
index 1e89b650c7..03ae0aea13 100644
--- a/erpnext/accounts/report/financial_statements.py
+++ b/erpnext/accounts/report/financial_statements.py
@@ -387,42 +387,15 @@ def set_gl_entries_by_account(
key: value
})
- distributed_cost_center_query = ""
- if filters and filters.get('cost_center'):
- distributed_cost_center_query = """
- UNION ALL
- SELECT posting_date,
- account,
- debit*(DCC_allocation.percentage_allocation/100) as debit,
- credit*(DCC_allocation.percentage_allocation/100) as credit,
- is_opening,
- fiscal_year,
- debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency,
- credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency,
- account_currency
- FROM `tabGL Entry`,
- (
- SELECT parent, sum(percentage_allocation) as percentage_allocation
- FROM `tabDistributed Cost Center`
- WHERE cost_center IN %(cost_center)s
- AND parent NOT IN %(cost_center)s
- GROUP BY parent
- ) as DCC_allocation
- WHERE company=%(company)s
- {additional_conditions}
- AND posting_date <= %(to_date)s
- AND is_cancelled = 0
- AND cost_center = DCC_allocation.parent
- """.format(additional_conditions=additional_conditions.replace("and cost_center in %(cost_center)s ", ''))
-
- gl_entries = frappe.db.sql("""select posting_date, account, debit, credit, is_opening, fiscal_year, debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry`
+ gl_entries = frappe.db.sql("""
+ select posting_date, account, debit, credit, is_opening, fiscal_year,
+ debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry`
where company=%(company)s
{additional_conditions}
and posting_date <= %(to_date)s
- and is_cancelled = 0
- {distributed_cost_center_query}""".format(
- additional_conditions=additional_conditions,
- distributed_cost_center_query=distributed_cost_center_query), gl_filters, as_dict=True) #nosec
+ and is_cancelled = 0""".format(
+ additional_conditions=additional_conditions), gl_filters, as_dict=True
+ )
if filters and filters.get('presentation_currency'):
convert_to_presentation_currency(gl_entries, get_currency(filters), filters.get('company'))
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index 7f27920547..4ff0297dba 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -176,44 +176,7 @@ def get_gl_entries(filters, accounting_dimensions):
if accounting_dimensions:
dimension_fields = ', '.join(accounting_dimensions) + ','
- distributed_cost_center_query = ""
- if filters and filters.get('cost_center'):
- select_fields_with_percentage = """, debit*(DCC_allocation.percentage_allocation/100) as debit,
- credit*(DCC_allocation.percentage_allocation/100) as credit,
- debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency,
- credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency """
-
- distributed_cost_center_query = """
- UNION ALL
- SELECT name as gl_entry,
- posting_date,
- account,
- party_type,
- party,
- voucher_type,
- voucher_no, {dimension_fields}
- cost_center, project,
- against_voucher_type,
- against_voucher,
- account_currency,
- remarks, against,
- is_opening, `tabGL Entry`.creation {select_fields_with_percentage}
- FROM `tabGL Entry`,
- (
- SELECT parent, sum(percentage_allocation) as percentage_allocation
- FROM `tabDistributed Cost Center`
- WHERE cost_center IN %(cost_center)s
- AND parent NOT IN %(cost_center)s
- GROUP BY parent
- ) as DCC_allocation
- WHERE company=%(company)s
- {conditions}
- AND posting_date <= %(to_date)s
- AND cost_center = DCC_allocation.parent
- """.format(dimension_fields=dimension_fields,select_fields_with_percentage=select_fields_with_percentage, conditions=get_conditions(filters).replace("and cost_center in %(cost_center)s ", ''))
-
- gl_entries = frappe.db.sql(
- """
+ gl_entries = frappe.db.sql("""
select
name as gl_entry, posting_date, account, party_type, party,
voucher_type, voucher_no, {dimension_fields}
@@ -222,13 +185,11 @@ def get_gl_entries(filters, accounting_dimensions):
remarks, against, is_opening, creation {select_fields}
from `tabGL Entry`
where company=%(company)s {conditions}
- {distributed_cost_center_query}
{order_by_statement}
- """.format(
- dimension_fields=dimension_fields, select_fields=select_fields, conditions=get_conditions(filters), distributed_cost_center_query=distributed_cost_center_query,
- order_by_statement=order_by_statement
- ),
- filters, as_dict=1)
+ """.format(
+ dimension_fields=dimension_fields, select_fields=select_fields,
+ conditions=get_conditions(filters), order_by_statement=order_by_statement
+ ), filters, as_dict=1)
if filters.get('presentation_currency'):
return convert_to_presentation_currency(gl_entries, currency_map, filters.get('company'))
diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
index 3dcb86267c..f4b8731ba8 100644
--- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
+++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
@@ -109,7 +109,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name):
def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
data = []
- new_accounts = accounts
company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency")
for d in accounts:
@@ -123,19 +122,6 @@ def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
"currency": company_currency,
"based_on": based_on
}
- if based_on == 'cost_center':
- cost_center_doc = frappe.get_doc("Cost Center",d.name)
- if not cost_center_doc.enable_distributed_cost_center:
- DCC_allocation = frappe.db.sql("""SELECT parent, sum(percentage_allocation) as percentage_allocation
- FROM `tabDistributed Cost Center`
- WHERE cost_center IN %(cost_center)s
- AND parent NOT IN %(cost_center)s
- GROUP BY parent""",{'cost_center': [d.name]})
- if DCC_allocation:
- for account in new_accounts:
- if account['name'] == DCC_allocation[0][0]:
- for value in value_fields:
- d[value] += account[value]*(DCC_allocation[0][1]/100)
for key in value_fields:
row[key] = flt(d.get(key, 0.0), 3)
diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json
index 203ea20882..a456c7fb57 100644
--- a/erpnext/accounts/workspace/accounting/accounting.json
+++ b/erpnext/accounts/workspace/accounting/accounting.json
@@ -1023,6 +1023,17 @@
"onboard": 0,
"type": "Link"
},
+ {
+ "dependencies": "Cost Center",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Cost Center Allocation",
+ "link_count": 0,
+ "link_to": "Cost Center Allocation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"dependencies": "Cost Center",
"hidden": 0,
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index 153f5c537a..f414930d72 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -108,6 +108,10 @@ frappe.ui.form.on('Asset', {
frm.trigger("create_asset_repair");
}, __("Manage"));
+ frm.add_custom_button(__("Split Asset"), function() {
+ frm.trigger("split_asset");
+ }, __("Manage"));
+
if (frm.doc.status != 'Fully Depreciated') {
frm.add_custom_button(__("Adjust Asset Value"), function() {
frm.trigger("create_asset_value_adjustment");
@@ -322,6 +326,43 @@ frappe.ui.form.on('Asset', {
});
},
+ split_asset: function(frm) {
+ const title = __('Split Asset');
+
+ const fields = [
+ {
+ fieldname: 'split_qty',
+ fieldtype: 'Int',
+ label: __('Split Qty'),
+ reqd: 1
+ }
+ ];
+
+ let dialog = new frappe.ui.Dialog({
+ title: title,
+ fields: fields
+ });
+
+ dialog.set_primary_action(__('Split'), function() {
+ const dialog_data = dialog.get_values();
+ frappe.call({
+ args: {
+ "asset_name": frm.doc.name,
+ "split_qty": cint(dialog_data.split_qty)
+ },
+ method: "erpnext.assets.doctype.asset.asset.split_asset",
+ callback: function(r) {
+ let doclist = frappe.model.sync(r.message);
+ frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
+ }
+ });
+
+ dialog.hide();
+ });
+
+ dialog.show();
+ },
+
create_asset_value_adjustment: function(frm) {
frappe.call({
args: {
diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json
index 0a7c041396..6e6bbf1cd2 100644
--- a/erpnext/assets/doctype/asset/asset.json
+++ b/erpnext/assets/doctype/asset/asset.json
@@ -3,7 +3,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
- "creation": "2016-03-01 17:01:27.920130",
+ "creation": "2022-01-18 02:26:55.975005",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
@@ -23,6 +23,7 @@
"asset_name",
"asset_category",
"location",
+ "split_from",
"custodian",
"department",
"disposal_date",
@@ -142,6 +143,7 @@
},
{
"allow_on_submit": 1,
+ "fetch_from": "item_code.image",
"fieldname": "image",
"fieldtype": "Attach Image",
"hidden": 1,
@@ -482,6 +484,13 @@
"fieldtype": "Section Break",
"label": "Finance Books"
},
+ {
+ "fieldname": "split_from",
+ "fieldtype": "Link",
+ "label": "Split From",
+ "options": "Asset",
+ "read_only": 1
+ },
{
"fieldname": "asset_quantity",
"fieldtype": "Int",
@@ -509,7 +518,7 @@
"link_fieldname": "asset"
}
],
- "modified": "2022-01-18 12:57:36.741192",
+ "modified": "2022-01-30 20:19:24.680027",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index ac64a95a53..6e87426ccb 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -38,7 +38,8 @@ class Asset(AccountsController):
self.validate_item()
self.validate_cost_center()
self.set_missing_values()
- self.prepare_depreciation_data()
+ if not self.split_from:
+ self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount()
if self.get("schedules"):
self.validate_expected_value_after_useful_life()
@@ -202,143 +203,143 @@ class Asset(AccountsController):
start = self.clear_depreciation_schedule()
for finance_book in self.get('finance_books'):
- self.validate_asset_finance_books(finance_book)
+ self._make_depreciation_schedule(finance_book, start, date_of_sale)
- # value_after_depreciation - current Asset value
- if self.docstatus == 1 and finance_book.value_after_depreciation:
- value_after_depreciation = flt(finance_book.value_after_depreciation)
- else:
- value_after_depreciation = (flt(self.gross_purchase_amount) -
- flt(self.opening_accumulated_depreciation))
+ def _make_depreciation_schedule(self, finance_book, start, date_of_sale):
+ self.validate_asset_finance_books(finance_book)
- finance_book.value_after_depreciation = value_after_depreciation
+ value_after_depreciation = self._get_value_after_depreciation(finance_book)
+ finance_book.value_after_depreciation = value_after_depreciation
- number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
- cint(self.number_of_depreciations_booked)
+ number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
+ cint(self.number_of_depreciations_booked)
- has_pro_rata = self.check_is_pro_rata(finance_book)
+ has_pro_rata = self.check_is_pro_rata(finance_book)
+ if has_pro_rata:
+ number_of_pending_depreciations += 1
- if has_pro_rata:
- number_of_pending_depreciations += 1
+ skip_row = False
- skip_row = False
+ for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
+ # If depreciation is already completed (for double declining balance)
+ if skip_row: continue
- for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
- # If depreciation is already completed (for double declining balance)
- if skip_row: continue
+ depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
- depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
+ if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
+ schedule_date = add_months(finance_book.depreciation_start_date,
+ n * cint(finance_book.frequency_of_depreciation))
- if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
- schedule_date = add_months(finance_book.depreciation_start_date,
- n * cint(finance_book.frequency_of_depreciation))
+ # schedule date will be a year later from start date
+ # so monthly schedule date is calculated by removing 11 months from it
+ monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
- # schedule date will be a year later from start date
- # so monthly schedule date is calculated by removing 11 months from it
- monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
-
- # if asset is being sold
- if date_of_sale:
- from_date = self.get_from_date(finance_book.finance_book)
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
- from_date, date_of_sale)
-
- if depreciation_amount > 0:
- self.append("schedules", {
- "schedule_date": date_of_sale,
- "depreciation_amount": depreciation_amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
-
- break
-
- # For first row
- if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
- from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
- from_date, finance_book.depreciation_start_date)
-
- # For first depr schedule date will be the start date
- # so monthly schedule date is calculated by removing month difference between use date and start date
- monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
-
- # For last row
- elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
- if not self.flags.increase_in_asset_life:
- # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
- self.to_date = add_months(self.available_for_use_date,
- (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
-
- depreciation_amount_without_pro_rata = depreciation_amount
-
- depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
- depreciation_amount, schedule_date, self.to_date)
-
- depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
- depreciation_amount, finance_book.finance_book)
-
- monthly_schedule_date = add_months(schedule_date, 1)
- schedule_date = add_days(schedule_date, days)
- last_schedule_date = schedule_date
-
- if not depreciation_amount: continue
- value_after_depreciation -= flt(depreciation_amount,
- self.precision("gross_purchase_amount"))
-
- # Adjust depreciation amount in the last period based on the expected value after useful life
- if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
- and value_after_depreciation != finance_book.expected_value_after_useful_life)
- or value_after_depreciation < finance_book.expected_value_after_useful_life):
- depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
- skip_row = True
+ # if asset is being sold
+ if date_of_sale:
+ from_date = self.get_from_date(finance_book.finance_book)
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
+ from_date, date_of_sale)
if depreciation_amount > 0:
- # With monthly depreciation, each depreciation is divided by months remaining until next date
- if self.allow_monthly_depreciation:
- # month range is 1 to 12
- # In pro rata case, for first and last depreciation, month range would be different
- month_range = months \
- if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
- else finance_book.frequency_of_depreciation
+ self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
- for r in range(month_range):
- if (has_pro_rata and n == 0):
- # For first entry of monthly depr
- if r == 0:
- days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date)
- per_day_amt = depreciation_amount / days
- depreciation_amount_for_current_month = per_day_amt * days_until_first_depr
- depreciation_amount -= depreciation_amount_for_current_month
- date = monthly_schedule_date
- amount = depreciation_amount_for_current_month
- else:
- date = add_months(monthly_schedule_date, r)
- amount = depreciation_amount / (month_range - 1)
- elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1:
- # For last entry of monthly depr
- date = last_schedule_date
- amount = depreciation_amount / month_range
+ break
+
+ # For first row
+ if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
+ from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
+ from_date, finance_book.depreciation_start_date)
+
+ # For first depr schedule date will be the start date
+ # so monthly schedule date is calculated by removing month difference between use date and start date
+ monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
+
+ # For last row
+ elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
+ if not self.flags.increase_in_asset_life:
+ # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
+ self.to_date = add_months(self.available_for_use_date,
+ (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
+
+ depreciation_amount_without_pro_rata = depreciation_amount
+
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
+ depreciation_amount, schedule_date, self.to_date)
+
+ depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
+ depreciation_amount, finance_book.finance_book)
+
+ monthly_schedule_date = add_months(schedule_date, 1)
+ schedule_date = add_days(schedule_date, days)
+ last_schedule_date = schedule_date
+
+ if not depreciation_amount: continue
+ value_after_depreciation -= flt(depreciation_amount,
+ self.precision("gross_purchase_amount"))
+
+ # Adjust depreciation amount in the last period based on the expected value after useful life
+ if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
+ and value_after_depreciation != finance_book.expected_value_after_useful_life)
+ or value_after_depreciation < finance_book.expected_value_after_useful_life):
+ depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
+ skip_row = True
+
+ if depreciation_amount > 0:
+ # With monthly depreciation, each depreciation is divided by months remaining until next date
+ if self.allow_monthly_depreciation:
+ # month range is 1 to 12
+ # In pro rata case, for first and last depreciation, month range would be different
+ month_range = months \
+ if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
+ else finance_book.frequency_of_depreciation
+
+ for r in range(month_range):
+ if (has_pro_rata and n == 0):
+ # For first entry of monthly depr
+ if r == 0:
+ days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date)
+ per_day_amt = depreciation_amount / days
+ depreciation_amount_for_current_month = per_day_amt * days_until_first_depr
+ depreciation_amount -= depreciation_amount_for_current_month
+ date = monthly_schedule_date
+ amount = depreciation_amount_for_current_month
else:
date = add_months(monthly_schedule_date, r)
- amount = depreciation_amount / month_range
+ amount = depreciation_amount / (month_range - 1)
+ elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1:
+ # For last entry of monthly depr
+ date = last_schedule_date
+ amount = depreciation_amount / month_range
+ else:
+ date = add_months(monthly_schedule_date, r)
+ amount = depreciation_amount / month_range
- self.append("schedules", {
- "schedule_date": date,
- "depreciation_amount": amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
- else:
- self.append("schedules", {
- "schedule_date": schedule_date,
- "depreciation_amount": depreciation_amount,
- "depreciation_method": finance_book.depreciation_method,
- "finance_book": finance_book.finance_book,
- "finance_book_id": finance_book.idx
- })
+ self._add_depreciation_row(date, amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
+ else:
+ self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method,
+ finance_book.finance_book, finance_book.idx)
+
+ def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id):
+ self.append("schedules", {
+ "schedule_date": schedule_date,
+ "depreciation_amount": depreciation_amount,
+ "depreciation_method": depreciation_method,
+ "finance_book": finance_book,
+ "finance_book_id": finance_book_id
+ })
+
+ def _get_value_after_depreciation(self, finance_book):
+ # value_after_depreciation - current Asset value
+ if self.docstatus == 1 and finance_book.value_after_depreciation:
+ value_after_depreciation = flt(finance_book.value_after_depreciation)
+ else:
+ value_after_depreciation = (flt(self.gross_purchase_amount) -
+ flt(self.opening_accumulated_depreciation))
+
+ return value_after_depreciation
# depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
# JE: Journal Entry, FB: Finance Book
@@ -348,7 +349,6 @@ class Asset(AccountsController):
depr_schedule = []
for schedule in self.get('schedules'):
-
# to update start when there are JEs linked with all the schedule rows corresponding to an FB
if len(start) == (int(schedule.finance_book_id) - 2):
start.append(num_of_depreciations_completed)
@@ -924,3 +924,113 @@ def get_depreciation_amount(asset, depreciable_value, row):
depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100))
return depreciation_amount
+
+@frappe.whitelist()
+def split_asset(asset_name, split_qty):
+ asset = frappe.get_doc("Asset", asset_name)
+ split_qty = cint(split_qty)
+
+ if split_qty >= asset.asset_quantity:
+ frappe.throw(_("Split qty cannot be grater than or equal to asset qty"))
+
+ remaining_qty = asset.asset_quantity - split_qty
+
+ new_asset = create_new_asset_after_split(asset, split_qty)
+ update_existing_asset(asset, remaining_qty)
+
+ return new_asset
+
+def update_existing_asset(asset, remaining_qty):
+ remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity)
+ opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity)
+
+ frappe.db.set_value("Asset", asset.name, {
+ 'opening_accumulated_depreciation': opening_accumulated_depreciation,
+ 'gross_purchase_amount': remaining_gross_purchase_amount,
+ 'asset_quantity': remaining_qty
+ })
+
+ for finance_book in asset.get('finance_books'):
+ value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.asset_quantity)
+ expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * remaining_qty)/asset.asset_quantity)
+ frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation)
+ frappe.db.set_value('Asset Finance Book', finance_book.name, 'expected_value_after_useful_life', expected_value_after_useful_life)
+
+ accumulated_depreciation = 0
+
+ for term in asset.get('schedules'):
+ depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.asset_quantity)
+ frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount)
+ accumulated_depreciation += depreciation_amount
+ frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', accumulated_depreciation)
+
+def create_new_asset_after_split(asset, split_qty):
+ new_asset = frappe.copy_doc(asset)
+ new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.asset_quantity)
+ opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * split_qty) / asset.asset_quantity)
+
+ new_asset.gross_purchase_amount = new_gross_purchase_amount
+ new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation
+ new_asset.asset_quantity = split_qty
+ new_asset.split_from = asset.name
+ accumulated_depreciation = 0
+
+ for finance_book in new_asset.get('finance_books'):
+ finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.asset_quantity)
+ finance_book.expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * split_qty)/asset.asset_quantity)
+
+ for term in new_asset.get('schedules'):
+ depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.asset_quantity)
+ term.depreciation_amount = depreciation_amount
+ accumulated_depreciation += depreciation_amount
+ term.accumulated_depreciation_amount = accumulated_depreciation
+
+ new_asset.submit()
+ new_asset.set_status()
+
+ for term in new_asset.get('schedules'):
+ # Update references in JV
+ if term.journal_entry:
+ add_reference_in_jv_on_split(term.journal_entry, new_asset.name, asset.name, term.depreciation_amount)
+
+ return new_asset
+
+def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount):
+ journal_entry = frappe.get_doc('Journal Entry', entry_name)
+ entries_to_add = []
+ idx = len(journal_entry.get('accounts')) + 1
+
+ for account in journal_entry.get('accounts'):
+ if account.reference_name == old_asset_name:
+ entries_to_add.append(frappe.copy_doc(account).as_dict())
+ if account.credit:
+ account.credit = account.credit - depreciation_amount
+ account.credit_in_account_currency = account.credit_in_account_currency - \
+ account.exchange_rate * depreciation_amount
+ elif account.debit:
+ account.debit = account.debit - depreciation_amount
+ account.debit_in_account_currency = account.debit_in_account_currency - \
+ account.exchange_rate * depreciation_amount
+
+ for entry in entries_to_add:
+ entry.reference_name = new_asset_name
+ if entry.credit:
+ entry.credit = depreciation_amount
+ entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount
+ elif entry.debit:
+ entry.debit = depreciation_amount
+ entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount
+
+ entry.idx = idx
+ idx += 1
+
+ journal_entry.append('accounts', entry)
+
+ journal_entry.flags.ignore_validate_update_after_submit = True
+ journal_entry.save()
+
+ # Repost GL Entries
+ journal_entry.docstatus = 2
+ journal_entry.make_gl_entries(1)
+ journal_entry.docstatus = 1
+ journal_entry.make_gl_entries()
\ No newline at end of file
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index b9545f46a9..c08dc21a8f 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -7,7 +7,7 @@ import frappe
from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
-from erpnext.assets.doctype.asset.asset import make_sales_invoice
+from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset
from erpnext.assets.doctype.asset.depreciation import (
post_depreciation_entries,
restore_asset,
@@ -245,6 +245,57 @@ class TestAsset(AssetSetup):
si.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
+ def test_asset_splitting(self):
+ asset = create_asset(
+ calculate_depreciation = 1,
+ asset_quantity=10,
+ available_for_use_date = '2020-01-01',
+ purchase_date = '2020-01-01',
+ expected_value_after_useful_life = 0,
+ total_number_of_depreciations = 6,
+ number_of_depreciations_booked = 1,
+ frequency_of_depreciation = 10,
+ depreciation_start_date = '2021-01-01',
+ opening_accumulated_depreciation=20000,
+ gross_purchase_amount=120000,
+ submit = 1
+ )
+
+ post_depreciation_entries(date="2021-01-01")
+
+ self.assertEqual(asset.asset_quantity, 10)
+ self.assertEqual(asset.gross_purchase_amount, 120000)
+ self.assertEqual(asset.opening_accumulated_depreciation, 20000)
+
+ new_asset = split_asset(asset.name, 2)
+ asset.load_from_db()
+
+ self.assertEqual(new_asset.asset_quantity, 2)
+ self.assertEqual(new_asset.gross_purchase_amount, 24000)
+ self.assertEqual(new_asset.opening_accumulated_depreciation, 4000)
+ self.assertEqual(new_asset.split_from, asset.name)
+ self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000)
+ self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000)
+
+ self.assertEqual(asset.asset_quantity, 8)
+ self.assertEqual(asset.gross_purchase_amount, 96000)
+ self.assertEqual(asset.opening_accumulated_depreciation, 16000)
+ self.assertEqual(asset.schedules[0].depreciation_amount, 16000)
+ self.assertEqual(asset.schedules[1].depreciation_amount, 16000)
+
+ journal_entry = asset.schedules[0].journal_entry
+
+ jv = frappe.get_doc('Journal Entry', journal_entry)
+ self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000)
+ self.assertEqual(jv.accounts[1].debit_in_account_currency, 16000)
+ self.assertEqual(jv.accounts[2].credit_in_account_currency, 4000)
+ self.assertEqual(jv.accounts[3].debit_in_account_currency, 4000)
+
+ self.assertEqual(jv.accounts[0].reference_name, asset.name)
+ self.assertEqual(jv.accounts[1].reference_name, asset.name)
+ self.assertEqual(jv.accounts[2].reference_name, new_asset.name)
+ self.assertEqual(jv.accounts[3].reference_name, new_asset.name)
+
def test_expense_head(self):
pr = make_purchase_receipt(item_code="Macbook Pro",
qty=2, rate=200000.0, location="Test Location")
@@ -1197,7 +1248,8 @@ def create_asset(**args):
"available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location",
"asset_owner": args.asset_owner or "Company",
- "is_existing_asset": args.is_existing_asset or 1
+ "is_existing_asset": args.is_existing_asset or 1,
+ "asset_quantity": args.get("asset_quantity") or 1
})
if asset.calculate_depreciation:
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 27e882e8d5..f8b849eab7 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -167,9 +167,14 @@ class AccountsController(TransactionBase):
validate_regional(self)
+ validate_einvoice_fields(self)
+
if self.doctype != 'Material Request':
apply_pricing_rule_on_transaction(self)
+ def before_cancel(self):
+ validate_einvoice_fields(self)
+
def on_trash(self):
# delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'):
@@ -2159,3 +2164,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
@erpnext.allow_regional
def validate_regional(doc):
pass
+
+@erpnext.allow_regional
+def validate_einvoice_fields(doc):
+ pass
diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py
index b8dc92efde..ae2c73758c 100644
--- a/erpnext/controllers/employee_boarding_controller.py
+++ b/erpnext/controllers/employee_boarding_controller.py
@@ -132,13 +132,17 @@ class EmployeeBoardingController(Document):
def on_cancel(self):
# delete task project
- for task in frappe.get_all('Task', filters={'project': self.project}):
+ project = self.project
+ for task in frappe.get_all('Task', filters={'project': project}):
frappe.delete_doc('Task', task.name, force=1)
- frappe.delete_doc('Project', self.project, force=1)
+ frappe.delete_doc('Project', project, force=1)
self.db_set('project', '')
for activity in self.activities:
activity.db_set('task', '')
+ frappe.msgprint(_('Linked Project {} and Tasks deleted.').format(
+ project), alert=True, indicator='blue')
+
@frappe.whitelist()
def get_onboarding_details(parent, parenttype):
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 4ff851d7f9..75fcaee383 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -204,7 +204,7 @@ class SellingController(StockController):
valuation_rate_map = {}
for item in self.items:
- if not item.item_code:
+ if not item.item_code or item.is_free_item:
continue
last_purchase_rate, is_stock_item = frappe.get_cached_value(
@@ -251,7 +251,7 @@ class SellingController(StockController):
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
for item in self.items:
- if not item.item_code:
+ if not item.item_code or item.is_free_item:
continue
last_valuation_rate = valuation_rate_map.get(
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index b97432e748..8d17683953 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -40,7 +40,10 @@ class StockController(AccountsController):
if self.docstatus == 2:
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
- if cint(erpnext.is_perpetual_inventory_enabled(self.company)):
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
+
+ if cint(erpnext.is_perpetual_inventory_enabled(self.company)) or provisional_accounting_for_non_stock_items:
warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus==1:
@@ -77,17 +80,17 @@ class StockController(AccountsController):
.format(d.idx, get_link_to_form("Batch", d.get("batch_no"))))
def clean_serial_nos(self):
+ from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string
+
for row in self.get("items"):
if hasattr(row, "serial_no") and row.serial_no:
- # replace commas by linefeed
- row.serial_no = row.serial_no.replace(",", "\n")
+ # remove extra whitespace and store one serial no on each line
+ row.serial_no = clean_serial_no_string(row.serial_no)
- # strip preceeding and succeeding spaces for each SN
- # (SN could have valid spaces in between e.g. SN - 123 - 2021)
- serial_no_list = row.serial_no.split("\n")
- serial_no_list = [sn.strip() for sn in serial_no_list]
-
- row.serial_no = "\n".join(serial_no_list)
+ for row in self.get('packed_items') or []:
+ if hasattr(row, "serial_no") and row.serial_no:
+ # remove extra whitespace and store one serial no on each line
+ row.serial_no = clean_serial_no_string(row.serial_no)
def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None):
diff --git a/erpnext/crm/doctype/campaign/campaign.js b/erpnext/crm/doctype/campaign/campaign.js
index 11bfa74b29..cac45c682c 100644
--- a/erpnext/crm/doctype/campaign/campaign.js
+++ b/erpnext/crm/doctype/campaign/campaign.js
@@ -5,7 +5,7 @@ frappe.ui.form.on('Campaign', {
refresh: function(frm) {
erpnext.toggle_naming_series();
- if (frm.doc.__islocal) {
+ if (frm.is_new()) {
frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series");
} else {
cur_frm.add_custom_button(__("View Leads"), function() {
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py
index bde52547c9..98cf7d845e 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.py
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.py
@@ -1,9 +1,10 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
from frappe.model.document import Document
class CRMSettings(Document):
- pass
+ def validate(self):
+ frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js
index f8376e6ca9..8e7d67e057 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.js
+++ b/erpnext/crm/doctype/opportunity/opportunity.js
@@ -24,6 +24,14 @@ frappe.ui.form.on("Opportunity", {
frm.trigger('set_contact_link');
}
},
+
+ validate: function(frm) {
+ if (frm.doc.status == "Lost" && !frm.doc.lost_reasons.length) {
+ frm.trigger('set_as_lost_dialog');
+ frappe.throw(__("Lost Reasons are required in case opportunity is Lost."));
+ }
+ },
+
contact_date: function(frm) {
if(frm.doc.contact_date < frappe.datetime.now_datetime()){
frm.set_value("contact_date", "");
@@ -82,7 +90,7 @@ frappe.ui.form.on("Opportunity", {
frm.trigger('setup_opportunity_from');
erpnext.toggle_naming_series();
- if(!doc.__islocal && doc.status!=="Lost") {
+ if(!frm.is_new() && doc.status!=="Lost") {
if(doc.with_items){
frm.add_custom_button(__('Supplier Quotation'),
function() {
@@ -187,11 +195,11 @@ frappe.ui.form.on("Opportunity", {
change_form_labels: function(frm) {
let company_currency = erpnext.get_currency(frm.doc.company);
- frm.set_currency_labels(["base_opportunity_amount", "base_total", "base_grand_total"], company_currency);
- frm.set_currency_labels(["opportunity_amount", "total", "grand_total"], frm.doc.currency);
+ frm.set_currency_labels(["base_opportunity_amount", "base_total"], company_currency);
+ frm.set_currency_labels(["opportunity_amount", "total"], frm.doc.currency);
// toggle fields
- frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total", "base_grand_total"],
+ frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total"],
frm.doc.currency != company_currency);
},
@@ -209,20 +217,15 @@ frappe.ui.form.on("Opportunity", {
},
calculate_total: function(frm) {
- let total = 0, base_total = 0, grand_total = 0, base_grand_total = 0;
+ let total = 0, base_total = 0;
frm.doc.items.forEach(item => {
total += item.amount;
base_total += item.base_amount;
})
- base_grand_total = base_total + frm.doc.base_opportunity_amount;
- grand_total = total + frm.doc.opportunity_amount;
-
frm.set_value({
'total': flt(total),
- 'base_total': flt(base_total),
- 'grand_total': flt(grand_total),
- 'base_grand_total': flt(base_grand_total)
+ 'base_total': flt(base_total)
});
}
diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json
index dc32d9a412..089f2d2faa 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.json
+++ b/erpnext/crm/doctype/opportunity/opportunity.json
@@ -42,10 +42,8 @@
"items",
"section_break_32",
"base_total",
- "base_grand_total",
"column_break_33",
"total",
- "grand_total",
"contact_info",
"customer_address",
"address_display",
@@ -475,21 +473,6 @@
"fieldname": "column_break_33",
"fieldtype": "Column Break"
},
- {
- "fieldname": "base_grand_total",
- "fieldtype": "Currency",
- "label": "Grand Total (Company Currency)",
- "options": "Company:company:default_currency",
- "print_hide": 1,
- "read_only": 1
- },
- {
- "fieldname": "grand_total",
- "fieldtype": "Currency",
- "label": "Grand Total",
- "options": "currency",
- "read_only": 1
- },
{
"fieldname": "lost_detail_section",
"fieldtype": "Section Break",
@@ -510,7 +493,7 @@
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
- "modified": "2021-10-21 12:04:30.151379",
+ "modified": "2022-01-29 19:32:26.382896",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
@@ -547,6 +530,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"subject_field": "title",
"timeline_field": "party_name",
"title_field": "title",
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index a4fd7658ee..2d538748ec 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -69,8 +69,6 @@ class Opportunity(TransactionBase):
self.total = flt(total)
self.base_total = flt(base_total)
- self.grand_total = flt(self.total) + flt(self.opportunity_amount)
- self.base_grand_total = flt(self.base_total) + flt(self.base_opportunity_amount)
def make_new_lead_if_required(self):
"""Set lead against new opportunity"""
diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js
index 67018e1ef9..8721a5b42d 100644
--- a/erpnext/crm/doctype/prospect/prospect.js
+++ b/erpnext/crm/doctype/prospect/prospect.js
@@ -3,6 +3,8 @@
frappe.ui.form.on('Prospect', {
refresh (frm) {
+ frappe.dynamic_link = { doc: frm.doc, fieldname: "name", doctype: frm.doctype };
+
if (!frm.is_new() && frappe.boot.user.can_create.includes("Customer")) {
frm.add_custom_button(__("Customer"), function() {
frappe.model.open_mapped_doc({
diff --git a/erpnext/crm/module_onboarding/crm/crm.json b/erpnext/crm/module_onboarding/crm/crm.json
index 8315218c84..0faad1d315 100644
--- a/erpnext/crm/module_onboarding/crm/crm.json
+++ b/erpnext/crm/module_onboarding/crm/crm.json
@@ -16,7 +16,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/CRM",
"idx": 0,
"is_complete": 0,
- "modified": "2020-07-08 14:05:42.644448",
+ "modified": "2022-01-29 20:14:29.502145",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
@@ -33,6 +33,9 @@
},
{
"step": "Create and Send Quotation"
+ },
+ {
+ "step": "CRM Settings"
}
],
"subtitle": "Lead, Opportunity, Customer, and more.",
diff --git a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
index 78f7e4de9c..f0f50de5d1 100644
--- a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
+++ b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-28 21:07:11.461172",
@@ -13,6 +12,7 @@
"name": "Create and Send Quotation",
"owner": "Administrator",
"reference_document": "Quotation",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create and Send Quotation",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/create_lead/create_lead.json b/erpnext/crm/onboarding_step/create_lead/create_lead.json
index c45e8b036c..cb5cce66a5 100644
--- a/erpnext/crm/onboarding_step/create_lead/create_lead.json
+++ b/erpnext/crm/onboarding_step/create_lead/create_lead.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-28 21:07:01.373403",
@@ -13,6 +12,7 @@
"name": "Create Lead",
"owner": "Administrator",
"reference_document": "Lead",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create Lead",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
index 0ee9317c85..96e0256d70 100644
--- a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
+++ b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json
@@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-01-21 15:28:52.483839",
@@ -13,6 +12,7 @@
"name": "Create Opportunity",
"owner": "Administrator",
"reference_document": "Opportunity",
+ "show_form_tour": 0,
"show_full_form": 1,
"title": "Create Opportunity",
"validate_action": 1
diff --git a/erpnext/crm/onboarding_step/crm_settings/crm_settings.json b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json
new file mode 100644
index 0000000000..555d795987
--- /dev/null
+++ b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json
@@ -0,0 +1,21 @@
+{
+ "action": "Go to Page",
+ "creation": "2022-01-29 20:14:24.803844",
+ "description": "# CRM Settings\n\nCRM module\u2019s features are configurable as per your business needs. CRM Settings is the place where you can set your preferences for:\n- Campaign\n- Lead\n- Opportunity\n- Quotation",
+ "docstatus": 0,
+ "doctype": "Onboarding Step",
+ "idx": 0,
+ "is_complete": 0,
+ "is_single": 1,
+ "is_skipped": 0,
+ "modified": "2022-01-29 20:14:24.803844",
+ "modified_by": "Administrator",
+ "name": "CRM Settings",
+ "owner": "Administrator",
+ "path": "#crm-settings/CRM%20Settings",
+ "reference_document": "CRM Settings",
+ "show_form_tour": 0,
+ "show_full_form": 0,
+ "title": "CRM Settings",
+ "validate_action": 1
+}
\ No newline at end of file
diff --git a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
index fa26921ae2..8871753094 100644
--- a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
+++ b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json
@@ -5,13 +5,13 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
- "is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-14 17:28:16.448676",
"modified_by": "Administrator",
"name": "Introduction to CRM",
"owner": "Administrator",
+ "show_form_tour": 0,
"show_full_form": 0,
"title": "Introduction to CRM",
"validate_action": 1,
diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py
index a23d49267e..4d0f3a9801 100644
--- a/erpnext/education/doctype/program_enrollment/program_enrollment.py
+++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py
@@ -6,6 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
+from frappe.query_builder.functions import Min
from frappe.utils import comma_and, get_link_to_form, getdate
@@ -60,8 +61,15 @@ class ProgramEnrollment(Document):
frappe.throw(_("Student is already enrolled."))
def update_student_joining_date(self):
- date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student)
- frappe.db.set_value("Student", self.student, "joining_date", date)
+ table = frappe.qb.DocType('Program Enrollment')
+ date = (
+ frappe.qb.from_(table)
+ .select(Min(table.enrollment_date).as_('enrollment_date'))
+ .where(table.student == self.student)
+ ).run(as_dict=True)
+
+ if date:
+ frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date)
def make_fee_records(self):
from erpnext.education.api import get_fee_components
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index d172da3f1f..04f5793836 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -443,6 +443,7 @@ regional_overrides = {
'erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts': 'erpnext.regional.india.utils.get_regional_round_off_accounts',
'erpnext.hr.utils.calculate_annual_eligible_hra_exemption': 'erpnext.regional.india.utils.calculate_annual_eligible_hra_exemption',
'erpnext.hr.utils.calculate_hra_exemption_for_period': 'erpnext.regional.india.utils.calculate_hra_exemption_for_period',
+ 'erpnext.controllers.accounts_controller.validate_einvoice_fields': 'erpnext.regional.india.e_invoice.utils.validate_einvoice_fields',
'erpnext.assets.doctype.asset.asset.get_depreciation_amount': 'erpnext.regional.india.utils.get_depreciation_amount',
'erpnext.stock.doctype.item.item.set_item_tax_from_hsn_code': 'erpnext.regional.india.utils.set_item_tax_from_hsn_code'
},
diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py
index a1247d9eb1..fb62b0414d 100644
--- a/erpnext/hr/doctype/employee/employee_dashboard.py
+++ b/erpnext/hr/doctype/employee/employee_dashboard.py
@@ -21,7 +21,7 @@ def get_data():
},
{
'label': _('Lifecycle'),
- 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance']
+ 'items': ['Employee Onboarding', 'Employee Transfer', 'Employee Promotion', 'Employee Grievance']
},
{
'label': _('Exit'),
diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 559bd393e6..0bb66374d1 100644
--- a/erpnext/hr/doctype/employee/employee_reminders.py
+++ b/erpnext/hr/doctype/employee/employee_reminders.py
@@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly():
send_advance_holiday_reminders("Weekly")
+
def send_reminders_in_advance_monthly():
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders"))
frequency = frappe.db.get_single_value("HR Settings", "frequency")
@@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly():
send_advance_holiday_reminders("Monthly")
+
def send_advance_holiday_reminders(frequency):
"""Send Holiday Reminders in Advance to Employees
`frequency` (str): 'Weekly' or 'Monthly'
@@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency):
else:
return
- employees = frappe.db.get_all('Employee', pluck='name')
+ employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name')
for employee in employees:
holidays = get_holidays_for_employee(
employee,
@@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency):
raise_exception=False
)
- if not (holidays is None):
- send_holidays_reminder_in_advance(employee, holidays)
+ send_holidays_reminder_in_advance(employee, holidays)
+
def send_holidays_reminder_in_advance(employee, holidays):
+ if not holidays:
+ return
+
employee_doc = frappe.get_doc('Employee', employee)
employee_email = get_employee_email(employee_doc)
frequency = frappe.db.get_single_value("HR Settings", "frequency")
@@ -101,6 +106,7 @@ def send_birthday_reminders():
reminder_text, message = get_birthday_reminder_text_and_message(others)
send_birthday_reminder(person_email, reminder_text, others, message)
+
def get_birthday_reminder_text_and_message(birthday_persons):
if len(birthday_persons) == 1:
birthday_person_text = birthday_persons[0]['name']
@@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons):
return reminder_text, message
+
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
header=_("Birthday Reminder 🎂")
)
+
def get_employees_who_are_born_today():
"""Get all employee born today & group them based on their company"""
return get_employees_having_an_event_today("birthday")
+
def get_employees_having_an_event_today(event_type):
"""Get all employee who have `event_type` today
& group them based on their company. `event_type`
@@ -210,13 +219,14 @@ def send_work_anniversary_reminders():
reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
send_work_anniversary_reminder(person_email, reminder_text, others, message)
+
def get_work_anniversary_reminder_text_and_message(anniversary_persons):
if len(anniversary_persons) == 1:
anniversary_person = anniversary_persons[0]['name']
persons_name = anniversary_person
# Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year
- anniversary_person += f" completed {completed_years} years"
+ anniversary_person += f" completed {completed_years} year(s)"
else:
person_names_with_years = []
names = []
@@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text)
# Number of years completed at the company
completed_years = getdate().year - person['date_of_joining'].year
- person_text += f" completed {completed_years} years"
+ person_text += f" completed {completed_years} year(s)"
person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
@@ -239,6 +249,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
return reminder_text, message
+
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person
anniversary_persons=anniversary_persons,
message=message,
),
- header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️")
+ header=_("Work Anniversary Reminder")
)
diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py
index 8a2da0866e..67cbea67e1 100644
--- a/erpnext/hr/doctype/employee/test_employee.py
+++ b/erpnext/hr/doctype/employee/test_employee.py
@@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
employee_doc.reload()
make_holiday_list()
- frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
+ frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List")
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",
diff --git a/erpnext/hr/doctype/employee/test_employee_reminders.py b/erpnext/hr/doctype/employee/test_employee_reminders.py
index 52c0098244..a4097ab9d1 100644
--- a/erpnext/hr/doctype/employee/test_employee_reminders.py
+++ b/erpnext/hr/doctype/employee/test_employee_reminders.py
@@ -5,10 +5,12 @@ import unittest
from datetime import timedelta
import frappe
-from frappe.utils import getdate
+from frappe.utils import add_months, getdate
+from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
+from erpnext.hr.utils import get_holidays_for_employee
class TestEmployeeReminders(unittest.TestCase):
@@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
cls.test_employee = test_employee
cls.test_holiday_dates = test_holiday_dates
+ # Employee without holidays in this month/week
+ test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company")
+ test_employee_2 = frappe.get_doc('Employee', test_employee_2)
+
+ test_holiday_list = make_holiday_list(
+ 'TestHolidayRemindersList2',
+ holiday_dates=[
+ {'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'},
+ ],
+ from_date=add_months(getdate(), -2),
+ to_date=add_months(getdate(), 2)
+ )
+ test_employee_2.holiday_list = test_holiday_list.name
+ test_employee_2.save()
+
+ cls.test_employee_2 = test_employee_2
+ cls.holiday_list_2 = test_holiday_list
+
@classmethod
def get_test_holiday_dates(cls):
today_date = getdate()
@@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
def setUp(self):
# Clear Email Queue
frappe.db.sql("delete from `tabEmail Queue`")
+ frappe.db.sql("delete from `tabEmail Queue Recipient`")
def test_is_holiday(self):
from erpnext.hr.doctype.employee.employee import is_holiday
@@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase):
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
def test_work_anniversary_reminders(self):
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
- employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
- employee.company_email = "test@example.com"
- employee.company = "_Test Company"
- employee.save()
+ make_employee("test_work_anniversary@gmail.com",
+ date_of_joining="1998" + frappe.utils.nowdate()[4:],
+ company="_Test Company",
+ )
from erpnext.hr.doctype.employee.employee_reminders import (
get_employees_having_an_event_today,
@@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase):
)
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
- self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
+ employees = employees_having_work_anniversary.get("_Test Company") or []
+ user_ids = []
+ for entry in employees:
+ user_ids.append(entry.user_id)
+
+ self.assertTrue("test_work_anniversary@gmail.com" in user_ids)
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_work_anniversary_reminders = 1
@@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
- def test_send_holidays_reminder_in_advance(self):
- from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
- from erpnext.hr.utils import get_holidays_for_employee
+ def test_work_anniversary_reminder_not_sent_for_0_years(self):
+ make_employee("test_work_anniversary_2@gmail.com",
+ date_of_joining=getdate(),
+ company="_Test Company",
+ )
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- set_proceed_with_frequency_change()
- hr_settings.frequency = 'Weekly'
- hr_settings.save()
+ from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
+
+ employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
+ employees = employees_having_work_anniversary.get("_Test Company") or []
+ user_ids = []
+ for entry in employees:
+ user_ids.append(entry.user_id)
+
+ self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids)
+
+ def test_send_holidays_reminder_in_advance(self):
+ setup_hr_settings('Weekly')
holidays = get_holidays_for_employee(
self.test_employee.get('name'),
@@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertEqual(len(email_queue), 1)
+ self.assertTrue("Holidays this Week." in email_queue[0].message)
def test_advance_holiday_reminders_monthly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- set_proceed_with_frequency_change()
- hr_settings.frequency = 'Monthly'
- hr_settings.save()
+ setup_hr_settings('Monthly')
+
+ # disable emp 2, set same holiday list
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Left',
+ 'holiday_list': self.test_employee.holiday_list
+ })
send_reminders_in_advance_monthly()
-
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
+ # even though emp 2 has holiday, non-active employees should not be recipients
+ recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
+ self.assertTrue(self.test_employee_2.user_id not in recipients)
+
+ # teardown: enable emp 2
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Active',
+ 'holiday_list': self.holiday_list_2.name
+ })
+
def test_advance_holiday_reminders_weekly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- hr_settings.frequency = 'Weekly'
- hr_settings.save()
+ setup_hr_settings('Weekly')
+
+ # disable emp 2, set same holiday list
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Left',
+ 'holiday_list': self.test_employee.holiday_list
+ })
send_reminders_in_advance_weekly()
-
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
+
+ # even though emp 2 has holiday, non-active employees should not be recipients
+ recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
+ self.assertTrue(self.test_employee_2.user_id not in recipients)
+
+ # teardown: enable emp 2
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Active',
+ 'holiday_list': self.holiday_list_2.name
+ })
+
+ def test_reminder_not_sent_if_no_holdays(self):
+ setup_hr_settings('Monthly')
+
+ # reminder not sent if there are no holidays
+ holidays = get_holidays_for_employee(
+ self.test_employee_2.get('name'),
+ getdate(), getdate() + timedelta(days=3),
+ only_non_weekly=True,
+ raise_exception=False
+ )
+ send_holidays_reminder_in_advance(
+ self.test_employee_2.get('name'),
+ holidays
+ )
+ email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
+ self.assertEqual(len(email_queue), 0)
+
+
+def setup_hr_settings(frequency=None):
+ # Get HR settings and enable advance holiday reminders
+ hr_settings = frappe.get_doc("HR Settings", "HR Settings")
+ hr_settings.send_holiday_reminders = 1
+ set_proceed_with_frequency_change()
+ hr_settings.frequency = frequency or 'Weekly'
+ hr_settings.save()
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
index 044a5a9886..8474bd09d5 100644
--- a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
+++ b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json
@@ -62,6 +62,7 @@
},
{
"default": "0",
+ "depends_on": "eval:['Employee Onboarding', 'Employee Onboarding Template'].includes(doc.parenttype)",
"description": "Applicable in the case of Employee Onboarding",
"fieldname": "required_for_employee_creation",
"fieldtype": "Check",
@@ -93,7 +94,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-07-30 15:55:22.470102",
+ "modified": "2022-01-29 14:05:00.543122",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Boarding Activity",
@@ -102,5 +103,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
index 5d1a024ebb..6fbb54d002 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js
@@ -3,12 +3,6 @@
frappe.ui.form.on('Employee Onboarding', {
setup: function(frm) {
- frm.add_fetch("employee_onboarding_template", "company", "company");
- frm.add_fetch("employee_onboarding_template", "department", "department");
- frm.add_fetch("employee_onboarding_template", "designation", "designation");
- frm.add_fetch("employee_onboarding_template", "employee_grade", "employee_grade");
-
-
frm.set_query("job_applicant", function () {
return {
filters:{
@@ -71,5 +65,19 @@ frappe.ui.form.on('Employee Onboarding', {
}
});
}
+ },
+
+ job_applicant: function(frm) {
+ if (frm.doc.job_applicant) {
+ frappe.db.get_value('Employee', {'job_applicant': frm.doc.job_applicant}, 'name', (r) => {
+ if (r.name) {
+ frm.set_value('employee', r.name);
+ } else {
+ frm.set_value('employee', '');
+ }
+ });
+ } else {
+ frm.set_value('employee', '');
+ }
}
});
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
index fd877a68d8..1d2ea0c669 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json
@@ -92,6 +92,7 @@
"options": "Employee Onboarding Template"
},
{
+ "fetch_from": "employee_onboarding_template.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
@@ -99,6 +100,7 @@
"reqd": 1
},
{
+ "fetch_from": "employee_onboarding_template.department",
"fieldname": "department",
"fieldtype": "Link",
"in_list_view": 1,
@@ -106,6 +108,7 @@
"options": "Department"
},
{
+ "fetch_from": "employee_onboarding_template.designation",
"fieldname": "designation",
"fieldtype": "Link",
"in_list_view": 1,
@@ -113,6 +116,7 @@
"options": "Designation"
},
{
+ "fetch_from": "employee_onboarding_template.employee_grade",
"fieldname": "employee_grade",
"fieldtype": "Link",
"label": "Employee Grade",
@@ -170,10 +174,11 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2021-07-30 14:55:04.560683",
+ "modified": "2022-01-29 12:33:57.120384",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Onboarding",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -194,6 +199,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "employee_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
index eba2a03193..a0939a847b 100644
--- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
+++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py
@@ -14,10 +14,15 @@ class IncompleteTaskError(frappe.ValidationError): pass
class EmployeeOnboarding(EmployeeBoardingController):
def validate(self):
super(EmployeeOnboarding, self).validate()
+ self.set_employee()
self.validate_duplicate_employee_onboarding()
+ def set_employee(self):
+ if not self.employee:
+ self.employee = frappe.db.get_value('Employee', {'job_applicant': self.job_applicant}, 'name')
+
def validate_duplicate_employee_onboarding(self):
- emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant})
+ emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant, "docstatus": ("!=", 2)})
if emp_onboarding and emp_onboarding != self.name:
frappe.throw(_("Employee Onboarding: {0} already exists for Job Applicant: {1}").format(frappe.bold(emp_onboarding), frappe.bold(self.job_applicant)))
diff --git a/erpnext/hr/doctype/interview/interview.py b/erpnext/hr/doctype/interview/interview.py
index 4bb003ded1..a3b111ccb1 100644
--- a/erpnext/hr/doctype/interview/interview.py
+++ b/erpnext/hr/doctype/interview/interview.py
@@ -7,7 +7,7 @@ import datetime
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, get_datetime, get_link_to_form
+from frappe.utils import cstr, flt, get_datetime, get_link_to_form
class DuplicateInterviewRoundError(frappe.ValidationError):
@@ -18,6 +18,7 @@ class Interview(Document):
self.validate_duplicate_interview()
self.validate_designation()
self.validate_overlap()
+ self.set_average_rating()
def on_submit(self):
if self.status not in ['Cleared', 'Rejected']:
@@ -67,6 +68,13 @@ class Interview(Document):
overlapping_details = _('Interview overlaps with {0}').format(get_link_to_form('Interview', overlaps[0][0]))
frappe.throw(overlapping_details, title=_('Overlap'))
+ def set_average_rating(self):
+ total_rating = 0
+ for entry in self.interview_details:
+ if entry.average_rating:
+ total_rating += entry.average_rating
+
+ self.average_rating = flt(total_rating / len(self.interview_details) if len(self.interview_details) else 0)
@frappe.whitelist()
def reschedule_interview(self, scheduled_on, from_time, to_time):
diff --git a/erpnext/hr/doctype/interview/test_interview.py b/erpnext/hr/doctype/interview/test_interview.py
index 1a2257a6d9..fdb11afe82 100644
--- a/erpnext/hr/doctype/interview/test_interview.py
+++ b/erpnext/hr/doctype/interview/test_interview.py
@@ -12,6 +12,7 @@ from frappe.utils import add_days, getdate, nowtime
from erpnext.hr.doctype.designation.test_designation import create_designation
from erpnext.hr.doctype.interview.interview import DuplicateInterviewRoundError
+from erpnext.hr.doctype.job_applicant.job_applicant import get_interview_details
from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant
@@ -70,6 +71,20 @@ class TestInterview(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Interview Feedback Reminder" in email_queue[0].message)
+ def test_get_interview_details_for_applicant_dashboard(self):
+ job_applicant = create_job_applicant()
+ interview = create_interview_and_dependencies(job_applicant.name)
+
+ details = get_interview_details(job_applicant.name)
+ self.assertEqual(details.get('stars'), 5)
+ self.assertEqual(details.get('interviews').get(interview.name), {
+ 'name': interview.name,
+ 'interview_round': interview.interview_round,
+ 'expected_average_rating': interview.expected_average_rating * 5,
+ 'average_rating': interview.average_rating * 5,
+ 'status': 'Pending'
+ })
+
def tearDown(self):
frappe.db.rollback()
@@ -106,7 +121,8 @@ def create_interview_round(name, skill_set, interviewers=[], designation=None, s
interview_round = frappe.new_doc("Interview Round")
interview_round.round_name = name
interview_round.interview_type = create_interview_type()
- interview_round.expected_average_rating = 4
+ # average rating = 4
+ interview_round.expected_average_rating = 0.8
if designation:
interview_round.designation = designation
diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.py b/erpnext/hr/doctype/interview_feedback/interview_feedback.py
index d046458f19..2ff00c1cac 100644
--- a/erpnext/hr/doctype/interview_feedback/interview_feedback.py
+++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.py
@@ -57,7 +57,6 @@ class InterviewFeedback(Document):
def update_interview_details(self):
doc = frappe.get_doc('Interview', self.interview)
- total_rating = 0
if self.docstatus == 2:
for entry in doc.interview_details:
@@ -72,10 +71,6 @@ class InterviewFeedback(Document):
entry.comments = self.feedback
entry.result = self.result
- if entry.average_rating:
- total_rating += entry.average_rating
-
- doc.average_rating = flt(total_rating / len(doc.interview_details) if len(doc.interview_details) else 0)
doc.save()
doc.notify_update()
diff --git a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
index d2ec5b9438..19c464296a 100644
--- a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
+++ b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py
@@ -24,7 +24,7 @@ class TestInterviewFeedback(unittest.TestCase):
create_skill_set(['Leadership'])
interview_feedback = create_interview_feedback(interview.name, interviewer, skill_ratings)
- interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 4})
+ interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 0.8})
frappe.set_user(interviewer)
self.assertRaises(frappe.ValidationError, interview_feedback.save)
@@ -50,7 +50,7 @@ class TestInterviewFeedback(unittest.TestCase):
avg_rating = flt(total_rating / len(feedback_1.skill_assessment) if len(feedback_1.skill_assessment) else 0)
- self.assertEqual(flt(avg_rating, 3), feedback_1.average_rating)
+ self.assertEqual(flt(avg_rating, 2), flt(feedback_1.average_rating, 2))
avg_on_interview_detail = frappe.db.get_value('Interview Detail', {
'parent': feedback_1.interview,
@@ -59,7 +59,7 @@ class TestInterviewFeedback(unittest.TestCase):
}, 'average_rating')
# 1. average should be reflected in Interview Detail.
- self.assertEqual(avg_on_interview_detail, feedback_1.average_rating)
+ self.assertEqual(flt(avg_on_interview_detail, 2), flt(feedback_1.average_rating, 2))
'''For Second Interviewer Feedback'''
interviewer = interview.interview_details[1].interviewer
@@ -97,5 +97,5 @@ def get_skills_rating(interview_round):
skills = frappe.get_all("Expected Skill Set", filters={"parent": interview_round}, fields = ["skill"])
for d in skills:
- d["rating"] = random.randint(1, 5)
+ d["rating"] = random.random()
return skills
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.js b/erpnext/hr/doctype/job_applicant/job_applicant.js
index d7b1c6c9df..c1e8257168 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant.js
+++ b/erpnext/hr/doctype/job_applicant/job_applicant.js
@@ -21,9 +21,9 @@ frappe.ui.form.on("Job Applicant", {
create_custom_buttons: function(frm) {
if (!frm.doc.__islocal && frm.doc.status !== "Rejected" && frm.doc.status !== "Accepted") {
- frm.add_custom_button(__("Create Interview"), function() {
+ frm.add_custom_button(__("Interview"), function() {
frm.events.create_dialog(frm);
- });
+ }, __("Create"));
}
if (!frm.doc.__islocal) {
@@ -40,10 +40,10 @@ frappe.ui.form.on("Job Applicant", {
frappe.route_options = {
"job_applicant": frm.doc.name,
"applicant_name": frm.doc.applicant_name,
- "designation": frm.doc.job_opening,
+ "designation": frm.doc.job_opening || frm.doc.designation,
};
frappe.new_doc("Job Offer");
- });
+ }, __("Create"));
}
}
},
@@ -55,13 +55,16 @@ frappe.ui.form.on("Job Applicant", {
job_applicant: frm.doc.name
},
callback: function(r) {
- $("div").remove(".form-dashboard-section.custom");
- frm.dashboard.add_section(
- frappe.render_template('job_applicant_dashboard', {
- data: r.message
- }),
- __("Interview Summary")
- );
+ if (r.message) {
+ $("div").remove(".form-dashboard-section.custom");
+ frm.dashboard.add_section(
+ frappe.render_template("job_applicant_dashboard", {
+ data: r.message.interviews,
+ number_of_stars: r.message.stars
+ }),
+ __("Interview Summary")
+ );
+ }
}
});
},
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.py b/erpnext/hr/doctype/job_applicant/job_applicant.py
index 5b3d9bfb4f..ccc21ced2c 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant.py
+++ b/erpnext/hr/doctype/job_applicant/job_applicant.py
@@ -81,8 +81,13 @@ def get_interview_details(job_applicant):
fields=["name", "interview_round", "expected_average_rating", "average_rating", "status"]
)
interview_detail_map = {}
+ meta = frappe.get_meta("Interview")
+ number_of_stars = meta.get_options("expected_average_rating") or 5
for detail in interview_details:
+ detail.expected_average_rating = detail.expected_average_rating * number_of_stars if detail.expected_average_rating else 0
+ detail.average_rating = detail.average_rating * number_of_stars if detail.average_rating else 0
+
interview_detail_map[detail.name] = detail
- return interview_detail_map
+ return {"interviews": interview_detail_map, "stars": number_of_stars}
diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
index c286787a55..734b2fe5e8 100644
--- a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
+++ b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html
@@ -17,24 +17,33 @@
{%= key %} |
{%= value["interview_round"] %} |
{%= value["status"] %} |
-
- {% for (i = 0; i < value["expected_average_rating"]; i++) { %}
-
- {% } %}
- {% for (i = 0; i < (5-value["expected_average_rating"]); i++) { %}
-
- {% } %}
- |
-
- {% if(value["average_rating"]){ %}
- {% for (i = 0; i < value["average_rating"]; i++) { %}
-
- {% } %}
- {% for (i = 0; i < (5-value["average_rating"]); i++) { %}
-
- {% } %}
- {% } %}
- |
+ {% let right_class = ''; %}
+ {% let left_class = ''; %}
+
+ {% $.each([value["expected_average_rating"], value["average_rating"]], (_, val) => { %}
+
+
+ {% for (let i = 1; i <= number_of_stars; i++) { %}
+ {% if (i <= val) { %}
+ {% right_class = 'star-click'; %}
+ {% } else { %}
+ {% right_class = ''; %}
+ {% } %}
+
+ {% if ((i <= val) || ((i - 0.5) == val)) { %}
+ {% left_class = 'star-click'; %}
+ {% } else { %}
+ {% left_class = ''; %}
+ {% } %}
+
+
+ {% } %}
+
+ |
+ {% }); %}
{% } %}
diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py
index 39f471929b..072fc73271 100644
--- a/erpnext/hr/doctype/job_offer/job_offer.py
+++ b/erpnext/hr/doctype/job_offer/job_offer.py
@@ -78,6 +78,7 @@ def make_employee(source_name, target_doc=None):
"doctype": "Employee",
"field_map": {
"applicant_name": "employee_name",
+ "offer_date": "scheduled_confirmation_date"
}}
}, target_doc, set_missing_values)
return doc
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 75e99f8991..6b85927d3e 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase):
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
frappe.set_user("Administrator")
-
- @classmethod
- def setUpClass(cls):
set_leave_approver()
+
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
def tearDown(self):
@@ -134,10 +132,11 @@ class TestLeaveApplication(unittest.TestCase):
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ employee = get_employee()
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
- leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
self.assertEqual(leave_application.total_leave_days, 4)
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
@@ -157,25 +156,28 @@ class TestLeaveApplication(unittest.TestCase):
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ employee = get_employee()
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
# already marked attendance on a holiday should be deleted in this case
config = {
"doctype": "Attendance",
- "employee": "_T-Employee-00001",
+ "employee": employee.name,
"status": "Present"
}
attendance_on_holiday = frappe.get_doc(config)
attendance_on_holiday.attendance_date = first_sunday
+ attendance_on_holiday.flags.ignore_validate = True
attendance_on_holiday.save()
# already marked attendance on a non-holiday should be updated
attendance = frappe.get_doc(config)
attendance.attendance_date = add_days(first_sunday, 3)
+ attendance.flags.ignore_validate = True
attendance.save()
- leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
# holiday should be excluded while marking attendance
self.assertEqual(leave_application.total_leave_days, 3)
@@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase):
employee = get_employee()
default_holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", default_holiday_list)
first_sunday = get_first_sunday(default_holiday_list)
optional_leave_date = add_days(first_sunday, 1)
diff --git a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
index 3d07081215..b7b20d945d 100644
--- a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
+++ b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
@@ -70,7 +70,6 @@
{
"fieldname": "loan_repayment_entry",
"fieldtype": "Link",
- "hidden": 1,
"label": "Loan Repayment Entry",
"no_copy": 1,
"options": "Loan Repayment",
@@ -88,7 +87,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-03-14 20:47:11.725818",
+ "modified": "2022-01-31 14:50:14.823213",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Salary Slip Loan",
@@ -97,5 +96,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index fc3b971bcb..8a7634e24e 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -93,7 +93,7 @@ frappe.ui.form.on("BOM", {
});
}
- if(frm.doc.docstatus!=0) {
+ if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order");
}, __("Create"));
diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json
index 218ac64d8d..0b44196940 100644
--- a/erpnext/manufacturing/doctype/bom/bom.json
+++ b/erpnext/manufacturing/doctype/bom/bom.json
@@ -37,7 +37,6 @@
"inspection_required",
"quality_inspection_template",
"column_break_31",
- "bom_level",
"section_break_33",
"items",
"scrap_section",
@@ -522,13 +521,6 @@
"fieldname": "column_break_31",
"fieldtype": "Column Break"
},
- {
- "default": "0",
- "fieldname": "bom_level",
- "fieldtype": "Int",
- "label": "BOM Level",
- "read_only": 1
- },
{
"fieldname": "section_break_33",
"fieldtype": "Section Break",
@@ -540,7 +532,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
- "modified": "2021-11-18 13:04:16.271975",
+ "modified": "2022-01-30 21:27:54.727298",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",
@@ -577,5 +569,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 045e5bc95a..d640f3fda7 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -155,7 +155,6 @@ class BOM(WebsiteGenerator):
self.calculate_cost()
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate = False, save=False)
- self.set_bom_level()
self.validate_scrap_items()
def get_context(self, context):
@@ -716,20 +715,6 @@ class BOM(WebsiteGenerator):
"""Get a complete tree representation preserving order of child items."""
return BOMTree(self.name)
- def set_bom_level(self, update=False):
- levels = []
-
- self.bom_level = 0
- for row in self.items:
- if row.bom_no:
- levels.append(frappe.get_cached_value("BOM", row.bom_no, "bom_level") or 0)
-
- if levels:
- self.bom_level = max(levels) + 1
-
- if update:
- self.db_set("bom_level", self.bom_level)
-
def validate_scrap_items(self):
for item in self.scrap_items:
msg = ""
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 8b1dbd0b90..4290ca3e4c 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -559,9 +559,11 @@ class ProductionPlan(Document):
get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty)
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
- def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
- bom_data = sorted(bom_data, key = lambda i: i.bom_level)
+ self.sub_assembly_items.sort(key= lambda d: d.bom_level, reverse=True)
+ for idx, row in enumerate(self.sub_assembly_items, start=1):
+ row.idx = idx
+ def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
for data in bom_data:
data.qty = data.stock_qty
data.production_plan_item = row.name
@@ -1004,9 +1006,6 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
for d in data:
if d.expandable:
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
- bom_level = (frappe.get_cached_value("BOM", d.value, "bom_level")
- if d.value else 0)
-
stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
bom_data.append(frappe._dict({
'parent_item_code': parent_item_code,
@@ -1017,7 +1016,7 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
'uom': d.stock_uom,
'bom_no': d.value,
'is_sub_contracted_item': d.is_sub_contracted_item,
- 'bom_level': bom_level,
+ 'bom_level': indent,
'indent': indent,
'stock_qty': stock_qty
}))
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 2febc1e23c..21a126b2a7 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -347,6 +347,45 @@ class TestProductionPlan(ERPNextTestCase):
frappe.db.rollback()
+ def test_subassmebly_sorting(self):
+ """ Test subassembly sorting in case of multiple items with nested BOMs"""
+ from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+
+ prefix = "_TestLevel_"
+ boms = {
+ "Assembly": {
+ "SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},},
+ "SubAssembly2": {"ChildPart3": {}},
+ "SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}},
+ "ChildPart5": {},
+ "ChildPart6": {},
+ "SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}},
+ },
+ "MegaDeepAssy": {
+ "SecretSubassy": {"SecretPart": {"VerySecret" : { "SuperSecret": {"Classified": {}}}},},
+ # ^ assert that this is
+ # first item in subassy table
+ }
+ }
+ create_nested_bom(boms, prefix=prefix)
+
+ items = [prefix + item_code for item_code in boms.keys()]
+ plan = create_production_plan(item_code=items[0], do_not_save=True)
+ plan.append("po_items", {
+ 'use_multi_level_bom': 1,
+ 'item_code': items[1],
+ 'bom_no': frappe.db.get_value('Item', items[1], 'default_bom'),
+ 'planned_qty': 1,
+ 'planned_start_date': now_datetime()
+ })
+ plan.get_sub_assembly_items()
+
+ bom_level_order = [d.bom_level for d in plan.sub_assembly_items]
+ self.assertEqual(bom_level_order, sorted(bom_level_order, reverse=True))
+ # lowest most level of subassembly should be first
+ self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
+
+
def create_production_plan(**args):
args = frappe._dict(args)
diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
index 657ee35a85..45ea26c3a8 100644
--- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
+++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json
@@ -102,7 +102,6 @@
},
{
"columns": 1,
- "fetch_from": "bom_no.bom_level",
"fieldname": "bom_level",
"fieldtype": "Int",
"in_list_view": 1,
@@ -189,7 +188,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-06-28 20:10:56.296410",
+ "modified": "2022-01-30 21:31:10.527559",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",
@@ -198,5 +197,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 93ca805e34..03e0910345 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -31,6 +31,7 @@ from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import (
auto_make_serial_nos,
+ clean_serial_no_string,
get_auto_serial_nos,
get_serial_nos,
)
@@ -358,6 +359,7 @@ class WorkOrder(Document):
frappe.delete_doc("Batch", row.name)
def make_serial_nos(self, args):
+ self.serial_no = clean_serial_no_string(self.serial_no)
serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series")
if serial_no_series:
self.serial_no = get_auto_serial_nos(serial_no_series, self.qty)
diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
index 25de2e0379..19a80ab407 100644
--- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
+++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
@@ -26,8 +26,7 @@ def get_exploded_items(bom, data, indent=0, qty=1):
'item_code': item.item_code,
'item_name': item.item_name,
'indent': indent,
- 'bom_level': (frappe.get_cached_value("BOM", item.bom_no, "bom_level")
- if item.bom_no else ""),
+ 'bom_level': indent,
'bom': item.bom_no,
'qty': item.qty * qty,
'uom': item.uom,
@@ -73,7 +72,7 @@ def get_columns():
},
{
"label": "BOM Level",
- "fieldtype": "Data",
+ "fieldtype": "Int",
"fieldname": "bom_level",
"width": 100
},
diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
index 97e7e0a7d2..72eed5e0d7 100644
--- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
+++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js
@@ -17,14 +17,12 @@ frappe.query_reports["Cost of Poor Quality Report"] = {
fieldname:"from_date",
fieldtype: "Datetime",
default: frappe.datetime.convert_to_system_tz(frappe.datetime.add_months(frappe.datetime.now_datetime(), -1)),
- reqd: 1
},
{
label: __("To Date"),
fieldname:"to_date",
fieldtype: "Datetime",
default: frappe.datetime.now_datetime(),
- reqd: 1,
},
{
label: __("Job Card"),
diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
index 77418235b0..88b21170e8 100644
--- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
+++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py
@@ -3,46 +3,65 @@
import frappe
from frappe import _
-from frappe.utils import flt
def execute(filters=None):
- columns, data = [], []
+ return get_columns(filters), get_data(filters)
- columns = get_columns(filters)
- data = get_data(filters)
-
- return columns, data
def get_data(report_filters):
data = []
operations = frappe.get_all("Operation", filters = {"is_corrective_operation": 1})
if operations:
- operations = [d.name for d in operations]
- fields = ["production_item as item_code", "item_name", "work_order", "operation",
- "workstation", "total_time_in_mins", "name", "hour_rate", "serial_no", "batch_no"]
+ if report_filters.get('operation'):
+ operations = [report_filters.get('operation')]
+ else:
+ operations = [d.name for d in operations]
- filters = get_filters(report_filters, operations)
+ job_card = frappe.qb.DocType("Job Card")
- job_cards = frappe.get_all("Job Card", fields = fields,
- filters = filters)
+ operating_cost = ((job_card.hour_rate) * (job_card.total_time_in_mins) / 60.0).as_('operating_cost')
+ item_code = (job_card.production_item).as_('item_code')
- for row in job_cards:
- row.operating_cost = flt(row.hour_rate) * (flt(row.total_time_in_mins) / 60.0)
- data.append(row)
+ query = (frappe.qb
+ .from_(job_card)
+ .select(job_card.name, job_card.work_order, item_code, job_card.item_name,
+ job_card.operation, job_card.serial_no, job_card.batch_no,
+ job_card.workstation, job_card.total_time_in_mins, job_card.hour_rate,
+ operating_cost)
+ .where(
+ (job_card.docstatus == 1)
+ & (job_card.is_corrective_job_card == 1))
+ .groupby(job_card.name)
+ )
+ query = append_filters(query, report_filters, operations, job_card)
+ data = query.run(as_dict=True)
return data
-def get_filters(report_filters, operations):
- filters = {"docstatus": 1, "operation": ("in", operations), "is_corrective_job_card": 1}
- for field in ["name", "work_order", "operation", "workstation", "company", "serial_no", "batch_no", "production_item"]:
- if report_filters.get(field):
- if field != 'serial_no':
- filters[field] = report_filters.get(field)
- else:
- filters[field] = ('like', '% {} %'.format(report_filters.get(field)))
+def append_filters(query, report_filters, operations, job_card):
+ """Append optional filters to query builder. """
- return filters
+ for field in ("name", "work_order", "operation", "workstation",
+ "company", "serial_no", "batch_no", "production_item"):
+ if report_filters.get(field):
+ if field == 'serial_no':
+ query = query.where(job_card[field].like('%{}%'.format(report_filters.get(field))))
+ elif field == 'operation':
+ query = query.where(job_card[field].isin(operations))
+ else:
+ query = query.where(job_card[field] == report_filters.get(field))
+
+ if report_filters.get('from_date') or report_filters.get('to_date'):
+ job_card_time_log = frappe.qb.DocType("Job Card Time Log")
+
+ query = query.join(job_card_time_log).on(job_card.name == job_card_time_log.parent)
+ if report_filters.get('from_date'):
+ query = query.where(job_card_time_log.from_time >= report_filters.get('from_date'))
+ if report_filters.get('to_date'):
+ query = query.where(job_card_time_log.to_time <= report_filters.get('to_date'))
+
+ return query
def get_columns(filters):
return [
diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
index 55b1a3f2f9..aaa231466f 100644
--- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
+++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
@@ -48,7 +48,7 @@ def get_production_plan_item_details(filters, data, order_details):
"qty": row.planned_qty,
"document_type": "Work Order",
"document_name": work_order or "",
- "bom_level": frappe.get_cached_value("BOM", row.bom_no, "bom_level"),
+ "bom_level": 0,
"produced_qty": order_details.get((work_order, row.item_code), {}).get("produced_qty", 0),
"pending_qty": flt(row.planned_qty) - flt(order_details.get((work_order, row.item_code), {}).get("produced_qty", 0))
})
diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py
index 1de472659e..9f51ded6c7 100644
--- a/erpnext/manufacturing/report/test_reports.py
+++ b/erpnext/manufacturing/report/test_reports.py
@@ -18,7 +18,7 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [
("BOM Operations Time", {}),
("BOM Stock Calculated", {"bom": frappe.get_last_doc("BOM").name, "qty_to_make": 2}),
("BOM Stock Report", {"bom": frappe.get_last_doc("BOM").name, "qty_to_produce": 2}),
- ("Cost of Poor Quality Report", {}),
+ ("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}),
("Downtime Analysis", {}),
(
"Exponential Smoothing Forecasting",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ad5062fc0e..8bd2214f38 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -1,5 +1,6 @@
[pre_model_sync]
erpnext.patches.v12_0.update_is_cancelled_field
+erpnext.patches.v13_0.add_bin_unique_constraint
erpnext.patches.v11_0.rename_production_order_to_work_order
erpnext.patches.v11_0.refactor_naming_series
erpnext.patches.v11_0.refactor_autoname_naming
@@ -203,6 +204,7 @@ execute:frappe.delete_doc_if_exists("DocType", "Bank Reconciliation")
erpnext.patches.v13_0.move_doctype_reports_and_notification_from_hr_to_payroll #22-06-2020
erpnext.patches.v13_0.move_payroll_setting_separately_from_hr_settings #22-06-2020
erpnext.patches.v12_0.create_itc_reversal_custom_fields
+execute:frappe.reload_doc("regional", "doctype", "e_invoice_settings")
erpnext.patches.v13_0.check_is_income_tax_component #22-06-2020
erpnext.patches.v13_0.loyalty_points_entry_for_pos_invoice #22-07-2020
erpnext.patches.v12_0.add_taxjar_integration_field
@@ -223,6 +225,7 @@ erpnext.patches.v13_0.set_youtube_video_id
erpnext.patches.v13_0.set_app_name
erpnext.patches.v13_0.print_uom_after_quantity_patch
erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account
+erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02
erpnext.patches.v13_0.updates_for_multi_currency_payroll
erpnext.patches.v13_0.update_reason_for_resignation_in_employee
execute:frappe.delete_doc("Report", "Quoted Item Comparison")
@@ -242,6 +245,8 @@ erpnext.patches.v12_0.add_state_code_for_ladakh
erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl
erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes
erpnext.patches.v13_0.update_vehicle_no_reqd_condition
+erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17
+erpnext.patches.v12_0.add_einvoice_summary_report_permissions
erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation
erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings
erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae
@@ -255,6 +260,7 @@ erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing
erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022
erpnext.patches.v13_0.update_shipment_status
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
+erpnext.patches.v12_0.add_ewaybill_validity_field
erpnext.patches.v13_0.germany_make_custom_fields
erpnext.patches.v13_0.germany_fill_debtor_creditor_number
erpnext.patches.v13_0.set_pos_closing_as_failed
@@ -267,10 +273,10 @@ erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice
erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold
erpnext.patches.v13_0.update_response_by_variance
erpnext.patches.v13_0.update_job_card_details
-erpnext.patches.v13_0.update_level_in_bom #1234sswef
erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry
erpnext.patches.v13_0.update_subscription_status_in_memberships
erpnext.patches.v13_0.update_amt_in_work_order_required_items
+erpnext.patches.v12_0.show_einvoice_irn_cancelled_field
erpnext.patches.v13_0.delete_orphaned_tables
erpnext.patches.v13_0.update_export_type_for_gst #2021-08-16
erpnext.patches.v13_0.update_tds_check_field #3
@@ -281,7 +287,6 @@ erpnext.patches.v13_0.remove_bad_selling_defaults
erpnext.patches.v13_0.trim_whitespace_from_serial_nos # 16-01-2022
erpnext.patches.v13_0.migrate_stripe_api
erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries
-erpnext.patches.v13_0.einvoicing_deprecation_warning
execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings")
execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category")
erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
@@ -323,15 +328,18 @@ erpnext.patches.v13_0.hospitality_deprecation_warning
erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v13_0.update_asset_quantity_field
erpnext.patches.v13_0.delete_bank_reconciliation_detail
+erpnext.patches.v13_0.enable_provisional_accounting
[post_model_sync]
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template
-erpnext.patches.v14_0.delete_einvoicing_doctypes
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
erpnext.patches.v14_0.delete_agriculture_doctypes
erpnext.patches.v14_0.rearrange_company_fields
erpnext.patches.v14_0.update_leave_notification_template
+erpnext.patches.v14_0.restore_einvoice_fields
erpnext.patches.v13_0.update_sane_transfer_against
+erpnext.patches.v12_0.add_company_link_to_einvoice_settings
+erpnext.patches.v14_0.migrate_cost_center_allocations
diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py
new file mode 100644
index 0000000000..e498b673fb
--- /dev/null
+++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py
@@ -0,0 +1,18 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company or not frappe.db.count('E Invoice User'):
+ return
+
+ frappe.reload_doc("regional", "doctype", "e_invoice_user")
+ for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']):
+ company_name = frappe.db.sql("""
+ select dl.link_name from `tabAddress` a, `tabDynamic Link` dl
+ where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company'
+ """, (creds.get('gstin')))
+ if company_name and len(company_name) > 0:
+ frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0])
diff --git a/erpnext/patches/v12_0/add_einvoice_status_field.py b/erpnext/patches/v12_0/add_einvoice_status_field.py
new file mode 100644
index 0000000000..aeff9ca841
--- /dev/null
+++ b/erpnext/patches/v12_0/add_einvoice_status_field.py
@@ -0,0 +1,72 @@
+from __future__ import unicode_literals
+
+import json
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ # move hidden einvoice fields to a different section
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type',
+ print_hide=1, hidden=1),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image',
+ options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON',
+ hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1)
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+
+ if frappe.db.exists('E Invoice Settings') and frappe.db.get_single_value('E Invoice Settings', 'enable'):
+ frappe.db.sql('''
+ UPDATE `tabSales Invoice` SET einvoice_status = 'Pending'
+ WHERE
+ posting_date >= '2021-04-01'
+ AND ifnull(irn, '') = ''
+ AND ifnull(`billing_address_gstin`, '') != ifnull(`company_gstin`, '')
+ AND ifnull(gst_category, '') in ('Registered Regular', 'SEZ', 'Overseas', 'Deemed Export')
+ ''')
+
+ # set appropriate statuses
+ frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Generated'
+ WHERE ifnull(irn, '') != '' AND ifnull(irn_cancelled, 0) = 0''')
+
+ frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Cancelled'
+ WHERE ifnull(irn_cancelled, 0) = 1''')
+
+ # set correct acknowledgement in e-invoices
+ einvoices = frappe.get_all('Sales Invoice', {'irn': ['is', 'set']}, ['name', 'signed_einvoice'])
+
+ if einvoices:
+ for inv in einvoices:
+ signed_einvoice = inv.get('signed_einvoice')
+ if signed_einvoice:
+ signed_einvoice = json.loads(signed_einvoice)
+ frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_no', signed_einvoice.get('AckNo'), update_modified=False)
+ frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_date', signed_einvoice.get('AckDt'), update_modified=False)
diff --git a/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py
new file mode 100644
index 0000000000..e837786138
--- /dev/null
+++ b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py
@@ -0,0 +1,20 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ if frappe.db.exists('Report', 'E-Invoice Summary') and \
+ not frappe.db.get_value('Custom Role', dict(report='E-Invoice Summary')):
+ frappe.get_doc(dict(
+ doctype='Custom Role',
+ report='E-Invoice Summary',
+ roles= [
+ dict(role='Accounts User'),
+ dict(role='Accounts Manager')
+ ]
+ )).insert()
diff --git a/erpnext/patches/v12_0/add_ewaybill_validity_field.py b/erpnext/patches/v12_0/add_ewaybill_validity_field.py
new file mode 100644
index 0000000000..247140d21d
--- /dev/null
+++ b/erpnext/patches/v12_0/add_ewaybill_validity_field.py
@@ -0,0 +1,18 @@
+from __future__ import unicode_literals
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
+ depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill')
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
diff --git a/erpnext/patches/v12_0/setup_einvoice_fields.py b/erpnext/patches/v12_0/setup_einvoice_fields.py
new file mode 100644
index 0000000000..c17666add1
--- /dev/null
+++ b/erpnext/patches/v12_0/setup_einvoice_fields.py
@@ -0,0 +1,59 @@
+from __future__ import unicode_literals
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+from erpnext.regional.india.setup import add_permissions, add_print_formats
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ frappe.reload_doc("custom", "doctype", "custom_field")
+ frappe.reload_doc("regional", "doctype", "e_invoice_settings")
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1,
+ depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1)
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+ add_permissions()
+ add_print_formats()
+
+ einvoice_cond = 'in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
+ t = {
+ 'mode_of_transport': [{'default': None}],
+ 'distance': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.transporter'}],
+ 'gst_vehicle_type': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
+ 'lr_date': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
+ 'lr_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
+ 'vehicle_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
+ 'ewaybill': [
+ {'read_only_depends_on': 'eval:doc.irn && doc.ewaybill'},
+ {'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)'}
+ ]
+ }
+
+ for field, conditions in t.items():
+ for c in conditions:
+ [(prop, value)] = c.items()
+ frappe.db.set_value('Custom Field', { 'fieldname': field }, prop, value)
diff --git a/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py
new file mode 100644
index 0000000000..3f90a03020
--- /dev/null
+++ b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py
@@ -0,0 +1,14 @@
+from __future__ import unicode_literals
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ irn_cancelled_field = frappe.db.exists('Custom Field', {'dt': 'Sales Invoice', 'fieldname': 'irn_cancelled'})
+ if irn_cancelled_field:
+ frappe.db.set_value('Custom Field', irn_cancelled_field, 'depends_on', 'eval: doc.irn')
+ frappe.db.set_value('Custom Field', irn_cancelled_field, 'read_only', 0)
diff --git a/erpnext/patches/v13_0/add_bin_unique_constraint.py b/erpnext/patches/v13_0/add_bin_unique_constraint.py
new file mode 100644
index 0000000000..57fbaae9d8
--- /dev/null
+++ b/erpnext/patches/v13_0/add_bin_unique_constraint.py
@@ -0,0 +1,63 @@
+import frappe
+
+from erpnext.stock.stock_balance import (
+ get_balance_qty_from_sle,
+ get_indented_qty,
+ get_ordered_qty,
+ get_planned_qty,
+ get_reserved_qty,
+)
+from erpnext.stock.utils import get_bin
+
+
+def execute():
+ delete_broken_bins()
+ delete_and_patch_duplicate_bins()
+
+def delete_broken_bins():
+ # delete useless bins
+ frappe.db.sql("delete from `tabBin` where item_code is null or warehouse is null")
+
+def delete_and_patch_duplicate_bins():
+
+ duplicate_bins = frappe.db.sql("""
+ SELECT
+ item_code, warehouse, count(*) as bin_count
+ FROM
+ tabBin
+ GROUP BY
+ item_code, warehouse
+ HAVING
+ bin_count > 1
+ """, as_dict=1)
+
+ for duplicate_bin in duplicate_bins:
+ item_code = duplicate_bin.item_code
+ warehouse = duplicate_bin.warehouse
+ existing_bins = frappe.get_list("Bin",
+ filters={
+ "item_code": item_code,
+ "warehouse": warehouse
+ },
+ fields=["name"],
+ order_by="creation",)
+
+ # keep last one
+ existing_bins.pop()
+
+ for broken_bin in existing_bins:
+ frappe.delete_doc("Bin", broken_bin.name)
+
+ qty_dict = {
+ "reserved_qty": get_reserved_qty(item_code, warehouse),
+ "indented_qty": get_indented_qty(item_code, warehouse),
+ "ordered_qty": get_ordered_qty(item_code, warehouse),
+ "planned_qty": get_planned_qty(item_code, warehouse),
+ "actual_qty": get_balance_qty_from_sle(item_code, warehouse)
+ }
+
+ bin = get_bin(item_code, warehouse)
+ bin.update(qty_dict)
+ bin.update_reserved_qty_for_production()
+ bin.update_reserved_qty_for_sub_contracting()
+ bin.db_update()
diff --git a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py b/erpnext/patches/v13_0/einvoicing_deprecation_warning.py
deleted file mode 100644
index e123a55f5a..0000000000
--- a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import click
-
-
-def execute():
- click.secho(
- "Indian E-Invoicing integration is moved to a separate app and will be removed from ERPNext in version-14.\n"
- "Please install the app to continue using the integration: https://github.com/frappe/erpnext_gst_compliance",
- fg="yellow",
- )
diff --git a/erpnext/patches/v13_0/enable_provisional_accounting.py b/erpnext/patches/v13_0/enable_provisional_accounting.py
new file mode 100644
index 0000000000..8e222700f8
--- /dev/null
+++ b/erpnext/patches/v13_0/enable_provisional_accounting.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc("setup", "doctype", "company")
+
+ company = frappe.qb.DocType("Company")
+
+ frappe.qb.update(
+ company
+ ).set(
+ company.enable_provisional_accounting_for_non_stock_items, company.enable_perpetual_inventory_for_non_stock_items
+ ).set(
+ company.default_provisional_account, company.service_received_but_not_billed
+ ).where(
+ company.enable_perpetual_inventory_for_non_stock_items == 1
+ ).where(
+ company.service_received_but_not_billed.isnotnull()
+ ).run()
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/remove_bad_selling_defaults.py b/erpnext/patches/v13_0/remove_bad_selling_defaults.py
index 5487a6c60c..02625396dd 100644
--- a/erpnext/patches/v13_0/remove_bad_selling_defaults.py
+++ b/erpnext/patches/v13_0/remove_bad_selling_defaults.py
@@ -3,6 +3,7 @@ from frappe import _
def execute():
+ frappe.reload_doctype('Selling Settings')
selling_settings = frappe.get_single("Selling Settings")
if selling_settings.customer_group in (_("All Customer Groups"), "All Customer Groups"):
diff --git a/erpnext/patches/v13_0/update_level_in_bom.py b/erpnext/patches/v13_0/update_level_in_bom.py
deleted file mode 100644
index 499412ee27..0000000000
--- a/erpnext/patches/v13_0/update_level_in_bom.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Copyright (c) 2020, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-def execute():
- for document in ["bom", "bom_item", "bom_explosion_item"]:
- frappe.reload_doc('manufacturing', 'doctype', document)
-
- frappe.db.sql(" update `tabBOM` set bom_level = 0 where docstatus = 1")
-
- bom_list = frappe.db.sql_list("""select name from `tabBOM` bom
- where docstatus=1 and is_active=1 and not exists(select bom_no from `tabBOM Item`
- where parent=bom.name and ifnull(bom_no, '')!='')""")
-
- count = 0
- while(count < len(bom_list)):
- for parent_bom in get_parent_boms(bom_list[count]):
- bom_doc = frappe.get_cached_doc("BOM", parent_bom)
- bom_doc.set_bom_level(update=True)
- bom_list.append(parent_bom)
- count += 1
-
-def get_parent_boms(bom_no):
- return frappe.db.sql_list("""
- select distinct bom_item.parent from `tabBOM Item` bom_item
- where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM'
- and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1)
- """, bom_no)
diff --git a/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py
index 450c00e421..7a8c1c6135 100644
--- a/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py
+++ b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py
@@ -3,6 +3,9 @@ import frappe
def execute():
+ frappe.reload_doctype('Maintenance Visit')
+ frappe.reload_doctype('Maintenance Visit Purpose')
+
# Updates the Maintenance Schedule link to fetch serial nos
from frappe.query_builder.functions import Coalesce
mvp = frappe.qb.DocType('Maintenance Visit Purpose')
diff --git a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py b/erpnext/patches/v14_0/delete_einvoicing_doctypes.py
deleted file mode 100644
index a3a8149be3..0000000000
--- a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import frappe
-
-
-def execute():
- frappe.delete_doc('DocType', 'E Invoice Settings', ignore_missing=True)
- frappe.delete_doc('DocType', 'E Invoice User', ignore_missing=True)
- frappe.delete_doc('Report', 'E-Invoice Summary', ignore_missing=True)
- frappe.delete_doc('Print Format', 'GST E-Invoice', ignore_missing=True)
- frappe.delete_doc('Custom Field', 'Sales Invoice-eway_bill_cancelled', ignore_missing=True)
- frappe.delete_doc('Custom Field', 'Sales Invoice-irn_cancelled', ignore_missing=True)
diff --git a/erpnext/patches/v14_0/migrate_cost_center_allocations.py b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
new file mode 100644
index 0000000000..c4f097fdd9
--- /dev/null
+++ b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
@@ -0,0 +1,48 @@
+import frappe
+from frappe.utils import today
+
+
+def execute():
+ for dt in ("cost_center_allocation", "cost_center_allocation_percentage"):
+ frappe.reload_doc('accounts', 'doctype', dt)
+
+ cc_allocations = get_existing_cost_center_allocations()
+ if cc_allocations:
+ create_new_cost_center_allocation_records(cc_allocations)
+
+ frappe.delete_doc('DocType', 'Distributed Cost Center', ignore_missing=True)
+
+def create_new_cost_center_allocation_records(cc_allocations):
+ for main_cc, allocations in cc_allocations.items():
+ cca = frappe.new_doc("Cost Center Allocation")
+ cca.main_cost_center = main_cc
+ cca.valid_from = today()
+
+ for child_cc, percentage in allocations.items():
+ cca.append("allocation_percentages", ({
+ "cost_center": child_cc,
+ "percentage": percentage
+ }))
+ cca.save()
+ cca.submit()
+
+def get_existing_cost_center_allocations():
+ if not frappe.db.exists("DocType", "Distributed Cost Center"):
+ return
+
+ par = frappe.qb.DocType("Cost Center")
+ child = frappe.qb.DocType("Distributed Cost Center")
+
+ records = (
+ frappe.qb.from_(par)
+ .inner_join(child).on(par.name == child.parent)
+ .select(par.name, child.cost_center, child.percentage_allocation)
+ .where(par.enable_distributed_cost_center == 1)
+ ).run(as_dict=True)
+
+ cc_allocations = frappe._dict()
+ for d in records:
+ cc_allocations.setdefault(d.name, frappe._dict())\
+ .setdefault(d.cost_center, d.percentage_allocation)
+
+ return cc_allocations
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/migrate_crm_settings.py b/erpnext/patches/v14_0/migrate_crm_settings.py
index 30d3ea0cb1..0c7785367c 100644
--- a/erpnext/patches/v14_0/migrate_crm_settings.py
+++ b/erpnext/patches/v14_0/migrate_crm_settings.py
@@ -9,8 +9,9 @@ def execute():
], as_dict=True)
frappe.reload_doc('crm', 'doctype', 'crm_settings')
- frappe.db.set_value('CRM Settings', 'CRM Settings', {
- 'campaign_naming_by': settings.campaign_naming_by,
- 'close_opportunity_after_days': settings.close_opportunity_after_days,
- 'default_valid_till': settings.default_valid_till
- })
+ if settings:
+ frappe.db.set_value('CRM Settings', 'CRM Settings', {
+ 'campaign_naming_by': settings.campaign_naming_by,
+ 'close_opportunity_after_days': settings.close_opportunity_after_days,
+ 'default_valid_till': settings.default_valid_till
+ })
diff --git a/erpnext/patches/v14_0/restore_einvoice_fields.py b/erpnext/patches/v14_0/restore_einvoice_fields.py
new file mode 100644
index 0000000000..c4431fb9db
--- /dev/null
+++ b/erpnext/patches/v14_0/restore_einvoice_fields.py
@@ -0,0 +1,24 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+from erpnext.regional.india.setup import add_permissions, add_print_formats
+
+
+def execute():
+ # restores back the 2 custom fields that was deleted while removing e-invoicing from v14
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ custom_fields = {
+ 'Sales Invoice': [
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+ ]
+ }
+ create_custom_fields(custom_fields, update=True)
+ add_permissions()
+ add_print_formats()
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index f33443d0d7..f727ff4378 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -746,11 +746,12 @@ class SalarySlip(TransactionBase):
previous_total_paid_taxes = self.get_tax_paid_in_period(payroll_period.start_date, self.start_date, tax_component)
# get taxable_earnings for current period (all days)
- current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption)
+ current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption, payroll_period=payroll_period)
future_structured_taxable_earnings = current_taxable_earnings.taxable_earnings * (math.ceil(remaining_sub_periods) - 1)
# get taxable_earnings, addition_earnings for current actual payment days
- current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, based_on_payment_days=1)
+ current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption,
+ based_on_payment_days=1, payroll_period=payroll_period)
current_structured_taxable_earnings = current_taxable_earnings_for_payment_days.taxable_earnings
current_additional_earnings = current_taxable_earnings_for_payment_days.additional_income
current_additional_earnings_with_full_tax = current_taxable_earnings_for_payment_days.additional_income_with_full_tax
@@ -876,7 +877,7 @@ class SalarySlip(TransactionBase):
return total_tax_paid
- def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0):
+ def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0, payroll_period=None):
joining_date, relieving_date = self.get_joining_and_relieving_dates()
taxable_earnings = 0
@@ -903,7 +904,7 @@ class SalarySlip(TransactionBase):
# Get additional amount based on future recurring additional salary
if additional_amount and earning.is_recurring_additional_salary:
additional_income += self.get_future_recurring_additional_amount(earning.additional_salary,
- earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month
+ earning.additional_amount, payroll_period) # Used earning.additional_amount to consider the amount for the full month
if earning.deduct_full_tax_on_selected_payroll_date:
additional_income_with_full_tax += additional_amount
@@ -920,7 +921,7 @@ class SalarySlip(TransactionBase):
if additional_amount and ded.is_recurring_additional_salary:
additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary,
- ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month
+ ded.additional_amount, payroll_period) # Used ded.additional_amount to consider the amount for the full month
return frappe._dict({
"taxable_earnings": taxable_earnings,
@@ -929,12 +930,18 @@ class SalarySlip(TransactionBase):
"flexi_benefits": flexi_benefits
})
- def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount):
+ def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount, payroll_period):
future_recurring_additional_amount = 0
to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date')
# future month count excluding current
from_date, to_date = getdate(self.start_date), getdate(to_date)
+
+ # If recurring period end date is beyond the payroll period,
+ # last day of payroll period should be considered for recurring period calculation
+ if getdate(to_date) > getdate(payroll_period.end_date):
+ to_date = getdate(payroll_period.end_date)
+
future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_date.month)
if future_recurring_period > 0:
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index bcf981b74d..597fd5a250 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -147,7 +147,7 @@ class TestSalarySlip(unittest.TestCase):
# Payroll based on attendance
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
- emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
+ emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List")
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
# mark attendance
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 2570df7026..1cda0a08c4 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -235,13 +235,13 @@
{
"fieldname": "actual_start_date",
"fieldtype": "Data",
- "label": "Actual Start Date",
+ "label": "Actual Start Date (via Time Sheet)",
"read_only": 1
},
{
"fieldname": "actual_time",
"fieldtype": "Float",
- "label": "Actual Time (in Hours)",
+ "label": "Actual Time (in Hours via Time Sheet)",
"read_only": 1
},
{
@@ -251,7 +251,7 @@
{
"fieldname": "actual_end_date",
"fieldtype": "Date",
- "label": "Actual End Date",
+ "label": "Actual End Date (via Time Sheet)",
"oldfieldname": "act_completion_date",
"oldfieldtype": "Date",
"read_only": 1
@@ -458,10 +458,11 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2021-04-28 16:36:11.654632",
+ "modified": "2022-01-29 13:58:27.712714",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -499,6 +500,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json
index ef4740d9ee..81822085a5 100644
--- a/erpnext/projects/doctype/task/task.json
+++ b/erpnext/projects/doctype/task/task.json
@@ -249,7 +249,7 @@
{
"fieldname": "actual_time",
"fieldtype": "Float",
- "label": "Actual Time (in hours)",
+ "label": "Actual Time (in Hours via Time Sheet)",
"read_only": 1
},
{
@@ -397,10 +397,11 @@
"is_tree": 1,
"links": [],
"max_attachments": 5,
- "modified": "2021-04-16 12:46:51.556741",
+ "modified": "2022-01-29 13:58:47.005241",
"modified_by": "Administrator",
"module": "Projects",
"name": "Task",
+ "naming_rule": "Expression (old style)",
"nsm_parent_field": "parent_task",
"owner": "Administrator",
"permissions": [
@@ -421,6 +422,7 @@
"show_preview_popup": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "project",
"title_field": "subject",
"track_seen": 1
diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js
index 597d77c6e9..08270bdea1 100644
--- a/erpnext/public/js/utils/serial_no_batch_selector.js
+++ b/erpnext/public/js/utils/serial_no_batch_selector.js
@@ -592,6 +592,6 @@ function check_can_calculate_pending_qty(me) {
&& doc.fg_completed_qty
&& erpnext.stock.bom
&& erpnext.stock.bom.name === doc.bom_no;
- const itemChecks = !!item;
+ const itemChecks = !!item && !item.allow_alternative_item;
return docChecks && itemChecks;
}
diff --git a/erpnext/regional/doctype/e_invoice_request_log/__init__.py b/erpnext/regional/doctype/e_invoice_request_log/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js
new file mode 100644
index 0000000000..7b7ba964e5
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('E Invoice Request Log', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json
new file mode 100644
index 0000000000..3034370fea
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json
@@ -0,0 +1,102 @@
+{
+ "actions": [],
+ "autoname": "EINV-REQ-.#####",
+ "creation": "2020-12-08 12:54:08.175992",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "url",
+ "headers",
+ "response",
+ "column_break_7",
+ "timestamp",
+ "reference_invoice",
+ "data"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "label": "User",
+ "options": "User"
+ },
+ {
+ "fieldname": "reference_invoice",
+ "fieldtype": "Data",
+ "label": "Reference Invoice"
+ },
+ {
+ "fieldname": "headers",
+ "fieldtype": "Code",
+ "label": "Headers",
+ "options": "JSON"
+ },
+ {
+ "fieldname": "data",
+ "fieldtype": "Code",
+ "label": "Data",
+ "options": "JSON"
+ },
+ {
+ "default": "Now",
+ "fieldname": "timestamp",
+ "fieldtype": "Datetime",
+ "label": "Timestamp"
+ },
+ {
+ "fieldname": "response",
+ "fieldtype": "Code",
+ "label": "Response",
+ "options": "JSON"
+ },
+ {
+ "fieldname": "url",
+ "fieldtype": "Data",
+ "label": "URL"
+ },
+ {
+ "fieldname": "column_break_7",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-01-13 12:06:57.253111",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice Request Log",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC"
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py
new file mode 100644
index 0000000000..c89552d782
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EInvoiceRequestLog(Document):
+ pass
diff --git a/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py
new file mode 100644
index 0000000000..091cc88e45
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+
+class TestEInvoiceRequestLog(unittest.TestCase):
+ pass
diff --git a/erpnext/regional/doctype/e_invoice_settings/__init__.py b/erpnext/regional/doctype/e_invoice_settings/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js
new file mode 100644
index 0000000000..54e488610d
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('E Invoice Settings', {
+ refresh(frm) {
+ const docs_link = 'https://docs.erpnext.com/docs/v13/user/manual/en/regional/india/setup-e-invoicing';
+ frm.dashboard.set_headline(
+ __("Read {0} for more information on E Invoicing features.", [`documentation`])
+ );
+ }
+});
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json
new file mode 100644
index 0000000000..16b2963301
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json
@@ -0,0 +1,97 @@
+{
+ "actions": [],
+ "creation": "2020-09-24 16:23:16.235722",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "enable",
+ "section_break_2",
+ "sandbox_mode",
+ "applicable_from",
+ "credentials",
+ "advanced_settings_section",
+ "client_id",
+ "column_break_8",
+ "client_secret",
+ "auth_token",
+ "token_expiry"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "enable",
+ "fieldtype": "Check",
+ "label": "Enable"
+ },
+ {
+ "depends_on": "enable",
+ "fieldname": "section_break_2",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "auth_token",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "token_expiry",
+ "fieldtype": "Datetime",
+ "hidden": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "credentials",
+ "fieldtype": "Table",
+ "label": "Credentials",
+ "mandatory_depends_on": "enable",
+ "options": "E Invoice User"
+ },
+ {
+ "default": "0",
+ "fieldname": "sandbox_mode",
+ "fieldtype": "Check",
+ "label": "Sandbox Mode"
+ },
+ {
+ "fieldname": "applicable_from",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Applicable From",
+ "reqd": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "advanced_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Advanced Settings"
+ },
+ {
+ "fieldname": "client_id",
+ "fieldtype": "Data",
+ "label": "Client ID"
+ },
+ {
+ "fieldname": "client_secret",
+ "fieldtype": "Password",
+ "label": "Client Secret"
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2021-11-16 19:50:28.029517",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice Settings",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py
new file mode 100644
index 0000000000..342d583cc0
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class EInvoiceSettings(Document):
+ def validate(self):
+ if self.enable and not self.credentials:
+ frappe.throw(_('You must add atleast one credentials to be able to use E Invoicing.'))
diff --git a/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py
new file mode 100644
index 0000000000..10770deb0e
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+
+class TestEInvoiceSettings(unittest.TestCase):
+ pass
diff --git a/erpnext/regional/doctype/e_invoice_user/__init__.py b/erpnext/regional/doctype/e_invoice_user/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json
new file mode 100644
index 0000000000..a65b1ca7ca
--- /dev/null
+++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json
@@ -0,0 +1,57 @@
+{
+ "actions": [],
+ "creation": "2020-12-22 15:02:46.229474",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "company",
+ "gstin",
+ "username",
+ "password"
+ ],
+ "fields": [
+ {
+ "fieldname": "gstin",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "GSTIN",
+ "reqd": 1
+ },
+ {
+ "fieldname": "username",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Username",
+ "reqd": 1
+ },
+ {
+ "fieldname": "password",
+ "fieldtype": "Password",
+ "in_list_view": 1,
+ "label": "Password",
+ "reqd": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-03-22 12:16:56.365616",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E Invoice User",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py
similarity index 77%
rename from erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py
rename to erpnext/regional/doctype/e_invoice_user/e_invoice_user.py
index dcf0e3b99d..4e0e89c09a 100644
--- a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py
+++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py
@@ -1,10 +1,10 @@
+# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
# import frappe
from frappe.model.document import Document
-class DistributedCostCenter(Document):
+class EInvoiceUser(Document):
pass
diff --git a/erpnext/regional/india/e_invoice/__init__.py b/erpnext/regional/india/e_invoice/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/regional/india/e_invoice/einv_item_template.json b/erpnext/regional/india/e_invoice/einv_item_template.json
new file mode 100644
index 0000000000..78e56518df
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_item_template.json
@@ -0,0 +1,31 @@
+{{
+ "SlNo": "{item.sr_no}",
+ "PrdDesc": "{item.description}",
+ "IsServc": "{item.is_service_item}",
+ "HsnCd": "{item.gst_hsn_code}",
+ "Barcde": "{item.barcode}",
+ "Unit": "{item.uom}",
+ "Qty": "{item.qty}",
+ "FreeQty": "{item.free_qty}",
+ "UnitPrice": "{item.unit_rate}",
+ "TotAmt": "{item.gross_amount}",
+ "Discount": "{item.discount_amount}",
+ "AssAmt": "{item.taxable_value}",
+ "PrdSlNo": "{item.serial_no}",
+ "GstRt": "{item.tax_rate}",
+ "IgstAmt": "{item.igst_amount}",
+ "CgstAmt": "{item.cgst_amount}",
+ "SgstAmt": "{item.sgst_amount}",
+ "CesRt": "{item.cess_rate}",
+ "CesAmt": "{item.cess_amount}",
+ "CesNonAdvlAmt": "{item.cess_nadv_amount}",
+ "StateCesRt": "{item.state_cess_rate}",
+ "StateCesAmt": "{item.state_cess_amount}",
+ "StateCesNonAdvlAmt": "{item.state_cess_nadv_amount}",
+ "OthChrg": "{item.other_charges}",
+ "TotItemVal": "{item.total_value}",
+ "BchDtls": {{
+ "Nm": "{item.batch_no}",
+ "ExpDt": "{item.batch_expiry_date}"
+ }}
+}}
\ No newline at end of file
diff --git a/erpnext/regional/india/e_invoice/einv_template.json b/erpnext/regional/india/e_invoice/einv_template.json
new file mode 100644
index 0000000000..c2a28f2049
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_template.json
@@ -0,0 +1,110 @@
+{{
+ "Version": "1.1",
+ "TranDtls": {{
+ "TaxSch": "{transaction_details.tax_scheme}",
+ "SupTyp": "{transaction_details.supply_type}",
+ "RegRev": "{transaction_details.reverse_charge}",
+ "EcmGstin": "{transaction_details.ecom_gstin}",
+ "IgstOnIntra": "{transaction_details.igst_on_intra}"
+ }},
+ "DocDtls": {{
+ "Typ": "{doc_details.invoice_type}",
+ "No": "{doc_details.invoice_name}",
+ "Dt": "{doc_details.invoice_date}"
+ }},
+ "SellerDtls": {{
+ "Gstin": "{seller_details.gstin}",
+ "LglNm": "{seller_details.legal_name}",
+ "TrdNm": "{seller_details.trade_name}",
+ "Loc": "{seller_details.location}",
+ "Pin": "{seller_details.pincode}",
+ "Stcd": "{seller_details.state_code}",
+ "Addr1": "{seller_details.address_line1}",
+ "Addr2": "{seller_details.address_line2}",
+ "Ph": "{seller_details.phone}",
+ "Em": "{seller_details.email}"
+ }},
+ "BuyerDtls": {{
+ "Gstin": "{buyer_details.gstin}",
+ "LglNm": "{buyer_details.legal_name}",
+ "TrdNm": "{buyer_details.trade_name}",
+ "Addr1": "{buyer_details.address_line1}",
+ "Addr2": "{buyer_details.address_line2}",
+ "Loc": "{buyer_details.location}",
+ "Pin": "{buyer_details.pincode}",
+ "Stcd": "{buyer_details.state_code}",
+ "Ph": "{buyer_details.phone}",
+ "Em": "{buyer_details.email}",
+ "Pos": "{buyer_details.place_of_supply}"
+ }},
+ "DispDtls": {{
+ "Nm": "{dispatch_details.legal_name}",
+ "Addr1": "{dispatch_details.address_line1}",
+ "Addr2": "{dispatch_details.address_line2}",
+ "Loc": "{dispatch_details.location}",
+ "Pin": "{dispatch_details.pincode}",
+ "Stcd": "{dispatch_details.state_code}"
+ }},
+ "ShipDtls": {{
+ "Gstin": "{shipping_details.gstin}",
+ "LglNm": "{shipping_details.legal_name}",
+ "TrdNm": "{shipping_details.trader_name}",
+ "Addr1": "{shipping_details.address_line1}",
+ "Addr2": "{shipping_details.address_line2}",
+ "Loc": "{shipping_details.location}",
+ "Pin": "{shipping_details.pincode}",
+ "Stcd": "{shipping_details.state_code}"
+ }},
+ "ItemList": [
+ {item_list}
+ ],
+ "ValDtls": {{
+ "AssVal": "{invoice_value_details.base_total}",
+ "CgstVal": "{invoice_value_details.total_cgst_amt}",
+ "SgstVal": "{invoice_value_details.total_sgst_amt}",
+ "IgstVal": "{invoice_value_details.total_igst_amt}",
+ "CesVal": "{invoice_value_details.total_cess_amt}",
+ "Discount": "{invoice_value_details.invoice_discount_amt}",
+ "RndOffAmt": "{invoice_value_details.round_off}",
+ "OthChrg": "{invoice_value_details.total_other_charges}",
+ "TotInvVal": "{invoice_value_details.base_grand_total}",
+ "TotInvValFc": "{invoice_value_details.grand_total}"
+ }},
+ "PayDtls": {{
+ "Nm": "{payment_details.payee_name}",
+ "AccDet": "{payment_details.account_no}",
+ "Mode": "{payment_details.mode_of_payment}",
+ "FinInsBr": "{payment_details.ifsc_code}",
+ "PayTerm": "{payment_details.terms}",
+ "PaidAmt": "{payment_details.paid_amount}",
+ "PaymtDue": "{payment_details.outstanding_amount}"
+ }},
+ "RefDtls": {{
+ "DocPerdDtls": {{
+ "InvStDt": "{period_details.start_date}",
+ "InvEndDt": "{period_details.end_date}"
+ }},
+ "PrecDocDtls": [{{
+ "InvNo": "{prev_doc_details.invoice_name}",
+ "InvDt": "{prev_doc_details.invoice_date}"
+ }}]
+ }},
+ "ExpDtls": {{
+ "ShipBNo": "{export_details.bill_no}",
+ "ShipBDt": "{export_details.bill_date}",
+ "Port": "{export_details.port}",
+ "ForCur": "{export_details.foreign_curr_code}",
+ "CntCode": "{export_details.country_code}",
+ "ExpDuty": "{export_details.export_duty}"
+ }},
+ "EwbDtls": {{
+ "TransId": "{eway_bill_details.gstin}",
+ "TransName": "{eway_bill_details.name}",
+ "TransMode": "{eway_bill_details.mode_of_transport}",
+ "Distance": "{eway_bill_details.distance}",
+ "TransDocNo": "{eway_bill_details.document_name}",
+ "TransDocDt": "{eway_bill_details.document_date}",
+ "VehNo": "{eway_bill_details.vehicle_no}",
+ "VehType": "{eway_bill_details.vehicle_type}"
+ }}
+}}
\ No newline at end of file
diff --git a/erpnext/regional/india/e_invoice/einv_validation.json b/erpnext/regional/india/e_invoice/einv_validation.json
new file mode 100644
index 0000000000..f4a3542a60
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einv_validation.json
@@ -0,0 +1,957 @@
+{
+ "Version": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 6,
+ "description": "Version of the schema"
+ },
+ "Irn": {
+ "type": "string",
+ "minLength": 64,
+ "maxLength": 64,
+ "description": "Invoice Reference Number"
+ },
+ "TranDtls": {
+ "type": "object",
+ "properties": {
+ "TaxSch": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 10,
+ "enum": ["GST"],
+ "description": "GST- Goods and Services Tax Scheme"
+ },
+ "SupTyp": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 10,
+ "enum": ["B2B", "SEZWP", "SEZWOP", "EXPWP", "EXPWOP", "DEXP"],
+ "description": "Type of Supply: B2B-Business to Business, SEZWP - SEZ with payment, SEZWOP - SEZ without payment, EXPWP - Export with Payment, EXPWOP - Export without payment,DEXP - Deemed Export"
+ },
+ "RegRev": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Y- whether the tax liability is payable under reverse charge"
+ },
+ "EcmGstin": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "pattern": "([0-9]{2}[0-9A-Z]{13})",
+ "description": "E-Commerce GSTIN",
+ "validationMsg": "E-Commerce GSTIN is invalid"
+ },
+ "IgstOnIntra": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Y- indicates the supply is intra state but chargeable to IGST"
+ }
+ },
+ "required": ["TaxSch", "SupTyp"]
+ },
+ "DocDtls": {
+ "type": "object",
+ "properties": {
+ "Typ": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 3,
+ "enum": ["INV", "CRN", "DBN"],
+ "description": "Document Type"
+ },
+ "No": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^([A-Z1-9]{1}[A-Z0-9/-]{0,15})$",
+ "description": "Document Number",
+ "validationMsg": "Document Number should not be starting with 0, / and -"
+ },
+ "Dt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Document Date"
+ }
+ },
+ "required": ["Typ", "No", "Dt"]
+ },
+ "SellerDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "pattern": "([0-9]{2}[0-9A-Z]{13})",
+ "description": "Supplier GSTIN",
+ "validationMsg": "Company GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Tradename"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 50,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Supplier State Code"
+ },
+ "Ph": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 12,
+ "description": "Phone"
+ },
+ "Em": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 100,
+ "description": "Email-Id"
+ }
+ },
+ "required": ["Gstin", "LglNm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "BuyerDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 15,
+ "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$",
+ "description": "Buyer GSTIN",
+ "validationMsg": "Customer GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Trade Name"
+ },
+ "Pos": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Place of Supply State code"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "Buyer State Code"
+ },
+ "Ph": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 12,
+ "description": "Phone"
+ },
+ "Em": {
+ "type": "string",
+ "minLength": 6,
+ "maxLength": 100,
+ "description": "Email-Id"
+ }
+ },
+ "required": ["Gstin", "LglNm", "Pos", "Addr1", "Loc", "Stcd"]
+ },
+ "DispDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Dispatch Address Name"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "State Code"
+ }
+ },
+ "required": ["Nm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "ShipDtls": {
+ "type": "object",
+ "properties": {
+ "Gstin": {
+ "type": "string",
+ "maxLength": 15,
+ "minLength": 3,
+ "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$",
+ "description": "Shipping Address GSTIN",
+ "validationMsg": "Shipping Address GSTIN is invalid"
+ },
+ "LglNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Legal Name"
+ },
+ "TrdNm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Trade Name"
+ },
+ "Addr1": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Address Line 1"
+ },
+ "Addr2": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Address Line 2"
+ },
+ "Loc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Location"
+ },
+ "Pin": {
+ "type": "number",
+ "minimum": 100000,
+ "maximum": 999999,
+ "description": "Pincode"
+ },
+ "Stcd": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2,
+ "description": "State Code"
+ }
+ },
+ "required": ["LglNm", "Addr1", "Loc", "Pin", "Stcd"]
+ },
+ "ItemList": {
+ "type": "Array",
+ "properties": {
+ "SlNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 6,
+ "description": "Serial No. of Item"
+ },
+ "PrdDesc": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "description": "Item Name"
+ },
+ "IsServc": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["Y", "N"],
+ "description": "Is Service Item"
+ },
+ "HsnCd": {
+ "type": "string",
+ "minLength": 4,
+ "maxLength": 8,
+ "description": "HSN Code"
+ },
+ "Barcde": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 30,
+ "description": "Barcode"
+ },
+ "Qty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999999999.999,
+ "description": "Quantity"
+ },
+ "FreeQty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999999999.999,
+ "description": "Free Quantity"
+ },
+ "Unit": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 8,
+ "description": "UOM"
+ },
+ "UnitPrice": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.999,
+ "description": "Rate"
+ },
+ "TotAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Gross Amount"
+ },
+ "Discount": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Discount"
+ },
+ "PreTaxVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Pre tax value"
+ },
+ "AssAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Taxable Value"
+ },
+ "GstRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "GST Rate"
+ },
+ "IgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "IGST Amount"
+ },
+ "CgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "CGST Amount"
+ },
+ "SgstAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "SGST Amount"
+ },
+ "CesRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "Cess Rate"
+ },
+ "CesAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Cess Amount (Advalorem)"
+ },
+ "CesNonAdvlAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Cess Amount (Non-Advalorem)"
+ },
+ "StateCesRt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999.999,
+ "description": "State CESS Rate"
+ },
+ "StateCesAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "State CESS Amount"
+ },
+ "StateCesNonAdvlAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "State CESS Amount (Non Advalorem)"
+ },
+ "OthChrg": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Other Charges"
+ },
+ "TotItemVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Total Item Value"
+ },
+ "OrdLineRef": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 50,
+ "description": "Order line reference"
+ },
+ "OrgCntry": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 2,
+ "description": "Origin Country"
+ },
+ "PrdSlNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Serial number"
+ },
+ "BchDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 20,
+ "description": "Batch number"
+ },
+ "ExpDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Batch Expiry Date"
+ },
+ "WrDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Warranty Date"
+ }
+ },
+ "required": ["Nm"]
+ },
+ "AttribDtls": {
+ "type": "Array",
+ "Attribute": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Attribute name of the item"
+ },
+ "Val": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Attribute value of the item"
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "SlNo",
+ "IsServc",
+ "HsnCd",
+ "UnitPrice",
+ "TotAmt",
+ "AssAmt",
+ "GstRt",
+ "TotItemVal"
+ ]
+ },
+ "ValDtls": {
+ "type": "object",
+ "properties": {
+ "AssVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total Assessable value of all items"
+ },
+ "CgstVal": {
+ "type": "number",
+ "maximum": 99999999999999.99,
+ "minimum": 0,
+ "description": "Total CGST value of all items"
+ },
+ "SgstVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total SGST value of all items"
+ },
+ "IgstVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total IGST value of all items"
+ },
+ "CesVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total CESS value of all items"
+ },
+ "StCesVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Total State CESS value of all items"
+ },
+ "Discount": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Invoice Discount"
+ },
+ "OthChrg": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Other Charges"
+ },
+ "RndOffAmt": {
+ "type": "number",
+ "minimum": -99.99,
+ "maximum": 99.99,
+ "description": "Rounded off Amount"
+ },
+ "TotInvVal": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Final Invoice Value "
+ },
+ "TotInvValFc": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Final Invoice value in Foreign Currency"
+ }
+ },
+ "required": ["AssVal", "TotInvVal"]
+ },
+ "PayDtls": {
+ "type": "object",
+ "properties": {
+ "Nm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Payee Name"
+ },
+ "AccDet": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 18,
+ "description": "Bank Account Number of Payee"
+ },
+ "Mode": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 18,
+ "description": "Mode of Payment"
+ },
+ "FinInsBr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 11,
+ "description": "Branch or IFSC code"
+ },
+ "PayTerm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Terms of Payment"
+ },
+ "PayInstr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Payment Instruction"
+ },
+ "CrTrn": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Credit Transfer"
+ },
+ "DirDr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "description": "Direct Debit"
+ },
+ "CrDay": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 9999,
+ "description": "Credit Days"
+ },
+ "PaidAmt": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Advance Amount"
+ },
+ "PaymtDue": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 99999999999999.99,
+ "description": "Outstanding Amount"
+ }
+ }
+ },
+ "RefDtls": {
+ "type": "object",
+ "properties": {
+ "InvRm": {
+ "type": "string",
+ "maxLength": 100,
+ "minLength": 3,
+ "pattern": "^[0-9A-Za-z/-]{3,100}$",
+ "description": "Remarks/Note"
+ },
+ "DocPerdDtls": {
+ "type": "object",
+ "properties": {
+ "InvStDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Invoice Period Start Date"
+ },
+ "InvEndDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Invoice Period End Date"
+ }
+ },
+ "required": ["InvStDt ", "InvEndDt "]
+ },
+ "PrecDocDtls": {
+ "type": "object",
+ "properties": {
+ "InvNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^[1-9A-Z]{1}[0-9A-Z/-]{1,15}$",
+ "description": "Reference of Original Invoice"
+ },
+ "InvDt": {
+ "type": "string",
+ "maxLength": 10,
+ "minLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Date of Orginal Invoice"
+ },
+ "OthRefNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Other Reference"
+ }
+ }
+ },
+ "required": ["InvNo", "InvDt"],
+ "ContrDtls": {
+ "type": "object",
+ "properties": {
+ "RecAdvRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Receipt Advice No."
+ },
+ "RecAdvDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Date of receipt advice"
+ },
+ "TendRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Lot/Batch Reference No."
+ },
+ "ContrRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Contract Reference Number"
+ },
+ "ExtRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Any other reference"
+ },
+ "ProjRefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "pattern": "^([0-9A-Za-z/-]){1,20}$",
+ "description": "Project Reference Number"
+ },
+ "PORefr": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 16,
+ "pattern": "^([0-9A-Za-z/-]){1,16}$",
+ "description": "PO Reference Number"
+ },
+ "PORefDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "PO Reference date"
+ }
+ }
+ }
+ }
+ },
+ "AddlDocDtls": {
+ "type": "Array",
+ "properties": {
+ "Url": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Supporting document URL"
+ },
+ "Docs": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 1000,
+ "description": "Supporting document in Base64 Format"
+ },
+ "Info": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 1000,
+ "description": "Any additional information"
+ }
+ }
+ },
+
+ "ExpDtls": {
+ "type": "object",
+ "properties": {
+ "ShipBNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20,
+ "description": "Shipping Bill No."
+ },
+ "ShipBDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Shipping Bill Date"
+ },
+ "Port": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 10,
+ "pattern": "^[0-9A-Za-z]{2,10}$",
+ "description": "Port Code. Refer the master"
+ },
+ "RefClm": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "description": "Claiming Refund. Y/N"
+ },
+ "ForCur": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 16,
+ "description": "Additional Currency Code. Refer the master"
+ },
+ "CntCode": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 2,
+ "description": "Country Code. Refer the master"
+ },
+ "ExpDuty": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 999999999999.99,
+ "description": "Export Duty"
+ }
+ }
+ },
+ "EwbDtls": {
+ "type": "object",
+ "properties": {
+ "TransId": {
+ "type": "string",
+ "minLength": 15,
+ "maxLength": 15,
+ "description": "Transporter GSTIN"
+ },
+ "TransName": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 100,
+ "description": "Transporter Name"
+ },
+ "TransMode": {
+ "type": "string",
+ "maxLength": 1,
+ "minLength": 1,
+ "enum": ["1", "2", "3", "4"],
+ "description": "Mode of Transport"
+ },
+ "Distance": {
+ "type": "number",
+ "minimum": 1,
+ "maximum": 9999,
+ "description": "Distance"
+ },
+ "TransDocNo": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 15,
+ "pattern": "^([0-9A-Z/-]){1,15}$",
+ "description": "Tranport Document Number",
+ "validationMsg": "Transport Receipt No is invalid"
+ },
+ "TransDocDt": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10,
+ "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]",
+ "description": "Transport Document Date"
+ },
+ "VehNo": {
+ "type": "string",
+ "minLength": 4,
+ "maxLength": 20,
+ "description": "Vehicle Number"
+ },
+ "VehType": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1,
+ "enum": ["O", "R"],
+ "description": "Vehicle Type"
+ }
+ },
+ "required": ["Distance"]
+ },
+ "required": [
+ "Version",
+ "TranDtls",
+ "DocDtls",
+ "SellerDtls",
+ "BuyerDtls",
+ "ItemList",
+ "ValDtls"
+ ]
+}
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
new file mode 100644
index 0000000000..348f0c6fee
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -0,0 +1,292 @@
+erpnext.setup_einvoice_actions = (doctype) => {
+ frappe.ui.form.on(doctype, {
+ async refresh(frm) {
+ if (frm.doc.docstatus == 2) return;
+
+ const res = await frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility',
+ args: { doc: frm.doc }
+ });
+ const invoice_eligible = res.message;
+
+ if (!invoice_eligible) return;
+
+ const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc;
+
+ const add_custom_button = (label, action) => {
+ if (!frm.custom_buttons[label]) {
+ frm.add_custom_button(label, action, __('E Invoicing'));
+ }
+ };
+
+ if (!irn && !__unsaved) {
+ const action = () => {
+ if (frm.doc.__unsaved) {
+ frappe.throw(__('Please save the document to generate IRN.'));
+ }
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.get_einvoice',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: (res) => {
+ const einvoice = res.message;
+ show_einvoice_preview(frm, einvoice);
+ }
+ });
+ };
+
+ add_custom_button(__("Generate IRN"), action);
+ }
+
+ if (irn && !irn_cancelled && !ewaybill) {
+ const fields = [
+ {
+ "label": "Reason",
+ "fieldname": "reason",
+ "fieldtype": "Select",
+ "reqd": 1,
+ "default": "1-Duplicate",
+ "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
+ },
+ {
+ "label": "Remark",
+ "fieldname": "remark",
+ "fieldtype": "Data",
+ "reqd": 1
+ }
+ ];
+ const action = () => {
+ const d = new frappe.ui.Dialog({
+ title: __("Cancel IRN"),
+ fields: fields,
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_irn',
+ args: {
+ doctype,
+ docname: name,
+ irn: irn,
+ reason: data.reason.split('-')[0],
+ remark: data.remark
+ },
+ freeze: true,
+ callback: () => frm.reload_doc() || d.hide(),
+ error: () => d.hide()
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+ add_custom_button(__("Cancel IRN"), action);
+ }
+
+ if (irn && !irn_cancelled && !ewaybill) {
+ const action = () => {
+ const d = new frappe.ui.Dialog({
+ title: __('Generate E-Way Bill'),
+ size: "large",
+ fields: get_ewaybill_fields(frm),
+ primary_action: function() {
+ const data = d.get_values();
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_eway_bill',
+ args: {
+ doctype,
+ docname: name,
+ irn,
+ ...data
+ },
+ freeze: true,
+ callback: () => frm.reload_doc() || d.hide(),
+ error: () => d.hide()
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ };
+
+ add_custom_button(__("Generate E-Way Bill"), action);
+ }
+
+ if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
+ const action = () => {
+ let message = __('Cancellation of e-way bill is currently not supported.') + ' ';
+ message += '
';
+ message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.');
+
+ const dialog = frappe.msgprint({
+ title: __('Update E-Way Bill Cancelled Status?'),
+ message: message,
+ indicator: 'orange',
+ primary_action: {
+ action: function() {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc() || dialog.hide()
+ });
+ }
+ },
+ primary_action_label: __('Yes')
+ });
+ };
+ add_custom_button(__("Cancel E-Way Bill"), action);
+ }
+ }
+ });
+};
+
+const get_ewaybill_fields = (frm) => {
+ return [
+ {
+ 'fieldname': 'transporter',
+ 'label': 'Transporter',
+ 'fieldtype': 'Link',
+ 'options': 'Supplier',
+ 'default': frm.doc.transporter
+ },
+ {
+ 'fieldname': 'gst_transporter_id',
+ 'label': 'GST Transporter ID',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'transporter.gst_transporter_id',
+ 'default': frm.doc.gst_transporter_id
+ },
+ {
+ 'fieldname': 'driver',
+ 'label': 'Driver',
+ 'fieldtype': 'Link',
+ 'options': 'Driver',
+ 'default': frm.doc.driver
+ },
+ {
+ 'fieldname': 'lr_no',
+ 'label': 'Transport Receipt No',
+ 'fieldtype': 'Data',
+ 'default': frm.doc.lr_no
+ },
+ {
+ 'fieldname': 'vehicle_no',
+ 'label': 'Vehicle No',
+ 'fieldtype': 'Data',
+ 'default': frm.doc.vehicle_no
+ },
+ {
+ 'fieldname': 'distance',
+ 'label': 'Distance (in km)',
+ 'fieldtype': 'Float',
+ 'default': frm.doc.distance
+ },
+ {
+ 'fieldname': 'transporter_col_break',
+ 'fieldtype': 'Column Break',
+ },
+ {
+ 'fieldname': 'transporter_name',
+ 'label': 'Transporter Name',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'transporter.name',
+ 'read_only': 1,
+ 'default': frm.doc.transporter_name
+ },
+ {
+ 'fieldname': 'mode_of_transport',
+ 'label': 'Mode of Transport',
+ 'fieldtype': 'Select',
+ 'options': `\nRoad\nAir\nRail\nShip`,
+ 'default': frm.doc.mode_of_transport
+ },
+ {
+ 'fieldname': 'driver_name',
+ 'label': 'Driver Name',
+ 'fieldtype': 'Data',
+ 'fetch_from': 'driver.full_name',
+ 'read_only': 1,
+ 'default': frm.doc.driver_name
+ },
+ {
+ 'fieldname': 'lr_date',
+ 'label': 'Transport Receipt Date',
+ 'fieldtype': 'Date',
+ 'default': frm.doc.lr_date
+ },
+ {
+ 'fieldname': 'gst_vehicle_type',
+ 'label': 'GST Vehicle Type',
+ 'fieldtype': 'Select',
+ 'options': `Regular\nOver Dimensional Cargo (ODC)`,
+ 'depends_on': 'eval:(doc.mode_of_transport === "Road")',
+ 'default': frm.doc.gst_vehicle_type
+ }
+ ];
+};
+
+const request_irn_generation = (frm) => {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_irn',
+ args: { doctype: frm.doc.doctype, docname: frm.doc.name },
+ freeze: true,
+ callback: () => frm.reload_doc()
+ });
+};
+
+const get_preview_dialog = (frm, action) => {
+ const dialog = new frappe.ui.Dialog({
+ title: __("Preview"),
+ size: "large",
+ fields: [
+ {
+ "label": "Preview",
+ "fieldname": "preview_html",
+ "fieldtype": "HTML"
+ }
+ ],
+ primary_action: () => action(frm) || dialog.hide(),
+ primary_action_label: __('Generate IRN')
+ });
+ return dialog;
+};
+
+const show_einvoice_preview = (frm, einvoice) => {
+ const preview_dialog = get_preview_dialog(frm, request_irn_generation);
+
+ // initialize e-invoice fields
+ einvoice["Irn"] = einvoice["AckNo"] = ''; einvoice["AckDt"] = frappe.datetime.nowdate();
+ frm.doc.signed_einvoice = JSON.stringify(einvoice);
+
+ // initialize preview wrapper
+ const $preview_wrapper = preview_dialog.get_field("preview_html").$wrapper;
+ $preview_wrapper.html(
+ ``
+ );
+
+ frappe.call({
+ method: "frappe.www.printview.get_html_and_style",
+ args: {
+ doc: frm.doc,
+ print_format: "GST E-Invoice",
+ no_letterhead: 1
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ $preview_wrapper.find(".print-format").html(r.message.html);
+ const style = `
+ .print-format { box-shadow: 0px 0px 5px rgba(0,0,0,0.2); padding: 0.30in; min-height: 80vh; }
+ .print-preview { min-height: 0px; }
+ .modal-dialog { width: 720px; }`;
+
+ frappe.dom.set_style(style, "custom-print-style");
+ preview_dialog.show();
+ }
+ }
+ });
+};
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
new file mode 100644
index 0000000000..e3f7e90ff3
--- /dev/null
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -0,0 +1,1171 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import base64
+import io
+import json
+import os
+import re
+import sys
+import traceback
+
+import frappe
+import jwt
+from frappe import _, bold
+from frappe.core.page.background_jobs.background_jobs import get_info
+from frappe.integrations.utils import make_get_request, make_post_request
+from frappe.utils.background_jobs import enqueue
+from frappe.utils.data import (
+ add_to_date,
+ cint,
+ cstr,
+ flt,
+ format_date,
+ get_link_to_form,
+ getdate,
+ now_datetime,
+ time_diff_in_hours,
+ time_diff_in_seconds,
+)
+from frappe.utils.scheduler import is_scheduler_inactive
+from pyqrcode import create as qrcreate
+
+from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply
+
+
+@frappe.whitelist()
+def validate_eligibility(doc):
+ if isinstance(doc, str):
+ 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')
+
+ # if export invoice, then taxes can be empty
+ # invoice can only be ineligible if no taxes applied and is not an export invoice
+ no_taxes_applied = not doc.get('taxes') and not doc.get('gst_category') == 'Overseas'
+ has_non_gst_item = any(d for d in doc.get('items', []) if d.get('is_non_gst'))
+
+ if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied or has_non_gst_item:
+ 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':
+ if doc.irn:
+ frappe.throw(_('You cannot edit the invoice after generating IRN'), title=_('Edit Not Allowed'))
+ 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'))
+
+ elif doc.irn and doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled:
+ frappe.throw(_('You must cancel IRN before cancelling the document.'), title=_('Cancel Not Allowed'))
+
+def raise_document_name_too_long_error():
+ title = _('Document ID Too Long')
+ msg = _('As you have E-Invoicing enabled, to be able to generate IRN for this invoice')
+ msg += ', '
+ msg += _('document id {} exceed 16 letters.').format(bold(_('should not')))
+ msg += '
'
+ msg += _('You must {} your {} in order to have document id of {} length 16.').format(
+ bold(_('modify')), bold(_('naming series')), bold(_('maximum'))
+ )
+ msg += _('Please account for ammended documents too.')
+ frappe.throw(msg, title=title)
+
+def read_json(name):
+ file_path = os.path.join(os.path.dirname(__file__), '{name}.json'.format(name=name))
+ with open(file_path, 'r') as f:
+ return cstr(f.read())
+
+def get_transaction_details(invoice):
+ supply_type = ''
+ if invoice.gst_category == 'Registered Regular': supply_type = 'B2B'
+ elif invoice.gst_category == 'SEZ': supply_type = 'SEZWOP'
+ elif invoice.gst_category == 'Overseas': supply_type = 'EXPWOP'
+ elif invoice.gst_category == 'Deemed Export': supply_type = 'DEXP'
+
+ if not supply_type:
+ rr, sez, overseas, export = bold('Registered Regular'), bold('SEZ'), bold('Overseas'), bold('Deemed Export')
+ frappe.throw(_('GST category should be one of {}, {}, {}, {}').format(rr, sez, overseas, export),
+ title=_('Invalid Supply Type'))
+
+ return frappe._dict(dict(
+ tax_scheme='GST',
+ supply_type=supply_type,
+ reverse_charge=invoice.reverse_charge
+ ))
+
+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
+ invoice_date = format_date(invoice.posting_date, 'dd/mm/yyyy')
+
+ return frappe._dict(dict(
+ invoice_type=invoice_type,
+ invoice_name=invoice_name,
+ invoice_date=invoice_date
+ ))
+
+def validate_address_fields(address, skip_gstin_validation):
+ if ((not address.gstin and not skip_gstin_validation)
+ 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 are mandatory for address {}. Please set them and try again.').format(address.name),
+ title=_('Missing Address Fields')
+ )
+
+ if address.address_line2 and len(address.address_line2) < 2:
+ # to prevent "The field Address 2 must be a string with a minimum length of 3 and a maximum length of 100"
+ address.address_line2 = ""
+
+def get_party_details(address_name, skip_gstin_validation=False):
+ addr = frappe.get_doc('Address', address_name)
+
+ validate_address_fields(addr, skip_gstin_validation)
+
+ 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)
+ ))
+
+ return party_address_details
+
+def get_overseas_address_details(address_name):
+ address_title, address_line1, address_line2, city = frappe.db.get_value(
+ 'Address', address_name, ['address_title', 'address_line1', 'address_line2', 'city']
+ )
+
+ if not address_title or not address_line1 or not city:
+ frappe.throw(
+ msg=_('Address lines and city is mandatory for address {}. Please set them and try again.').format(
+ get_link_to_form('Address', address_name)
+ ),
+ title=_('Missing Address Fields')
+ )
+
+ return frappe._dict(dict(
+ gstin='URP',
+ legal_name=sanitize_for_json(address_title),
+ location=city,
+ address_line1=sanitize_for_json(address_line1),
+ address_line2=sanitize_for_json(address_line2),
+ pincode=999999, state_code=96, place_of_supply=96
+ ))
+
+def get_item_list(invoice):
+ item_list = []
+
+ for d in invoice.items:
+ einvoice_item_schema = read_json('einv_item_template')
+ item = frappe._dict({})
+ item.update(d.as_dict())
+
+ item.sr_no = d.idx
+ item.description = sanitize_for_json(d.item_name)
+
+ item.qty = abs(item.qty)
+ if flt(item.qty) != 0.0:
+ item.unit_rate = abs(item.taxable_value / item.qty)
+ else:
+ item.unit_rate = abs(item.taxable_value)
+ item.gross_amount = abs(item.taxable_value)
+ item.taxable_value = abs(item.taxable_value)
+ item.discount_amount = 0
+
+ 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
+ item.is_service_item = 'Y' if item.gst_hsn_code and item.gst_hsn_code[:2] == "99" else 'N'
+ item.serial_no = ""
+
+ item = update_item_taxes(invoice, item)
+
+ item.total_value = abs(
+ item.taxable_value + item.igst_amount + item.sgst_amount +
+ item.cgst_amount + item.cess_amount + item.cess_nadv_amount + item.other_charges
+ )
+ einv_item = einvoice_item_schema.format(item=item)
+ item_list.append(einv_item)
+
+ return ', '.join(item_list)
+
+def update_item_taxes(invoice, item):
+ gst_accounts = get_gst_accounts(invoice.company)
+ gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d]
+
+ for attr in [
+ 'tax_rate', 'cess_rate', 'cess_nadv_amount',
+ 'cgst_amount', 'sgst_amount', 'igst_amount',
+ 'cess_amount', 'cess_nadv_amount', 'other_charges'
+ ]:
+ item[attr] = 0
+
+ for t in invoice.taxes:
+ 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 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.taxable_value
+
+ if t.account_head in gst_accounts.cess_account:
+ item_tax_amount_after_discount = item_tax_detail[1]
+ if t.charge_type == 'On Item Quantity':
+ item.cess_nadv_amount += abs(item_tax_amount_after_discount)
+ else:
+ item.cess_rate += item_tax_rate
+ item.cess_amount += abs(item_tax_amount_after_discount)
+
+ for tax_type in ['igst', 'cgst', 'sgst']:
+ 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
+
+def get_invoice_value_details(invoice):
+ invoice_value_details = frappe._dict(dict())
+ invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')]))
+ invoice_value_details.invoice_discount_amt = 0
+
+ invoice_value_details.round_off = invoice.base_rounding_adjustment
+ invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total)
+ invoice_value_details.grand_total = abs(invoice.rounded_total) or abs(invoice.grand_total)
+
+ invoice_value_details = update_invoice_taxes(invoice, invoice_value_details)
+
+ return invoice_value_details
+
+def update_invoice_taxes(invoice, invoice_value_details):
+ gst_accounts = get_gst_accounts(invoice.company)
+ gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d]
+
+ invoice_value_details.total_cgst_amt = 0
+ invoice_value_details.total_sgst_amt = 0
+ 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_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
+ invoice_value_details.total_cess_amt += abs(t.base_tax_amount_after_discount_amount)
+
+ 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(tax_amount)
+ update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows)
+ else:
+ 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])
+ paid_amount = invoice.base_paid_amount
+ outstanding_amount = invoice.outstanding_amount
+
+ return frappe._dict(dict(
+ payee_name=payee_name, mode_of_payment=mode_of_payment,
+ paid_amount=paid_amount, outstanding_amount=outstanding_amount
+ ))
+
+def get_return_doc_reference(invoice):
+ 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')
+ ))
+
+def get_eway_bill_details(invoice):
+ if invoice.is_return:
+ 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'))
+
+
+ mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' }
+ vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' }
+
+ return frappe._dict(dict(
+ gstin=invoice.gst_transporter_id,
+ name=invoice.transporter_name,
+ mode_of_transport=mode_of_transport[invoice.mode_of_transport],
+ distance=invoice.distance or 0,
+ document_name=invoice.lr_no,
+ document_date=format_date(invoice.lr_date, 'dd/mm/yyyy'),
+ vehicle_no=invoice.vehicle_no,
+ vehicle_type=vehicle_type[invoice.gst_vehicle_type]
+ ))
+
+def validate_mandatory_fields(invoice):
+ if not invoice.company_address:
+ 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. 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.'),
+ title=_('Missing Fields')
+ )
+ if invoice.gst_category != 'Overseas' and not frappe.db.get_value('Address', invoice.customer_address, 'gstin'):
+ frappe.throw(
+ _('GSTIN is mandatory to fetch customer GSTIN details. Please enter GSTIN in selected customer address.'),
+ 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['CgstVal']) + flt(value_details['SgstVal']) - total_item_cgst_value - total_item_sgst_value) > 1:
+ frappe.throw(_('CGST + SGST value of the items is not equal to total CGST + SGST value. Please review taxes for any correction.'))
+
+ if abs(flt(value_details['IgstVal']) - total_item_igst_value) > 1:
+ frappe.throw(_('IGST value of all items is not equal to total IGST value. Please review taxes for any correction.'))
+
+ if abs(
+ flt(value_details['TotInvVal']) + flt(value_details['Discount']) -
+ flt(value_details['OthChrg']) - flt(value_details['RndOffAmt']) -
+ 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['RndOffAmt']) - 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)
+
+ schema = read_json('einv_template')
+
+ transaction_details = get_transaction_details(invoice)
+ item_list = get_item_list(invoice)
+ doc_details = get_doc_details(invoice)
+ invoice_value_details = get_invoice_value_details(invoice)
+ seller_details = get_party_details(invoice.company_address)
+
+ if invoice.gst_category == 'Overseas':
+ 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)
+ 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, skip_gstin_validation=True)
+
+ dispatch_details = frappe._dict({})
+ if invoice.dispatch_address_name:
+ dispatch_details = get_party_details(invoice.dispatch_address_name, skip_gstin_validation=True)
+
+ if invoice.is_pos and invoice.base_paid_amount:
+ payment_details = get_payment_details(invoice)
+
+ if invoice.is_return and invoice.return_against:
+ prev_doc_details = get_return_doc_reference(invoice)
+
+ if invoice.transporter and not invoice.is_return:
+ eway_bill_details = get_eway_bill_details(invoice)
+
+ # not yet implemented
+ period_details = export_details = frappe._dict({})
+
+ einvoice = schema.format(
+ transaction_details=transaction_details, doc_details=doc_details, dispatch_details=dispatch_details,
+ seller_details=seller_details, buyer_details=buyer_details, shipping_details=shipping_details,
+ item_list=item_list, invoice_value_details=invoice_value_details, payment_details=payment_details,
+ period_details=period_details, prev_doc_details=prev_doc_details,
+ export_details=export_details, eway_bill_details=eway_bill_details
+ )
+
+ try:
+ einvoice = safe_json_load(einvoice)
+ einvoice = santize_einvoice_fields(einvoice)
+ except Exception:
+ show_link_to_error_log(invoice, einvoice)
+
+ try:
+ validate_totals(einvoice)
+ except Exception:
+ log_error(einvoice)
+ raise
+
+ 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, str):
+ 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
+ ])
+ return 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
+
+def safe_json_load(json_string):
+ try:
+ return json.loads(json_string)
+ except json.JSONDecodeError as e:
+ # print a snippet of 40 characters around the location where error occured
+ pos = e.pos
+ start, end = max(0, pos-20), min(len(json_string)-1, pos+20)
+ snippet = json_string[start:end]
+ frappe.throw(_("Error in input data. Please check for any special characters near following input:
{}").format(snippet))
+
+class RequestFailed(Exception):
+ pass
+class CancellationNotAllowed(Exception):
+ pass
+
+class GSPConnector():
+ def __init__(self, doctype=None, docname=None):
+ self.doctype = doctype
+ self.docname = docname
+
+ 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 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'
+ self.generate_irn_url = self.base_url + '/enriched/ei/api/invoice'
+ self.gstin_details_url = self.base_url + '/enriched/ei/api/master/gstin'
+ self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB'
+ self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill'
+
+ 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()
+ 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:
+ self.credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None
+
+ def get_seller_gstin(self):
+ 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
+
+ def get_auth_token(self):
+ if time_diff_in_seconds(self.e_invoice_settings.token_expiry, now_datetime()) < 150.0:
+ self.fetch_auth_token()
+
+ return self.e_invoice_settings.auth_token
+
+ def make_request(self, request_type, url, headers=None, data=None):
+ if request_type == 'post':
+ res = make_post_request(url, headers=headers, data=data)
+ else:
+ res = make_get_request(url, headers=headers, data=data)
+
+ self.log_request(url, headers, data, res)
+ return res
+
+ def log_request(self, url, headers, data, res):
+ headers.update({ 'password': self.credentials.password })
+ request_log = frappe.get_doc({
+ "doctype": "E Invoice Request Log",
+ "user": frappe.session.user,
+ "reference_invoice": self.invoice.name if self.invoice else None,
+ "url": url,
+ "headers": json.dumps(headers, indent=4) if headers else None,
+ "data": json.dumps(data, indent=4) if isinstance(data, dict) else data,
+ "response": json.dumps(res, indent=4) if res else None
+ })
+ request_log.save(ignore_permissions=True)
+ frappe.db.commit()
+
+ def get_client_credentials(self):
+ if self.e_invoice_settings.client_id and self.e_invoice_settings.client_secret:
+ return self.e_invoice_settings.client_id, self.e_invoice_settings.get_password('client_secret')
+
+ return frappe.conf.einvoice_client_id, frappe.conf.einvoice_client_secret
+
+ def fetch_auth_token(self):
+ client_id, client_secret = self.get_client_credentials()
+ headers = {
+ 'gspappid': client_id,
+ 'gspappsecret': client_secret
+ }
+ res = {}
+ try:
+ res = self.make_request('post', self.authenticate_url, headers)
+ self.e_invoice_settings.auth_token = "{} {}".format(res.get('token_type'), res.get('access_token'))
+ self.e_invoice_settings.token_expiry = add_to_date(None, seconds=res.get('expires_in'))
+ self.e_invoice_settings.save(ignore_permissions=True)
+ self.e_invoice_settings.reload()
+
+ except Exception:
+ log_error(res)
+ self.raise_error(True)
+
+ def get_headers(self):
+ return {
+ 'content-type': 'application/json',
+ 'user_name': self.credentials.username,
+ 'password': self.credentials.get_password(),
+ 'gstin': self.credentials.gstin,
+ 'authorization': self.get_auth_token(),
+ 'requestid': str(base64.b64encode(os.urandom(18))),
+ }
+
+ def fetch_gstin_details(self, gstin):
+ headers = self.get_headers()
+
+ try:
+ params = '?gstin={gstin}'.format(gstin=gstin)
+ res = self.make_request('get', self.gstin_details_url + params, headers)
+ if res.get('success'):
+ return res.get('result')
+ else:
+ log_error(res)
+ raise RequestFailed
+
+ except RequestFailed:
+ self.raise_error()
+
+ except Exception:
+ log_error()
+ self.raise_error(True)
+ @staticmethod
+ def get_gstin_details(gstin):
+ '''fetch and cache GSTIN details'''
+ if not hasattr(frappe.local, 'gstin_cache'):
+ frappe.local.gstin_cache = {}
+
+ key = gstin
+ gsp_connector = GSPConnector()
+ details = gsp_connector.fetch_gstin_details(gstin)
+
+ frappe.local.gstin_cache[key] = details
+ frappe.cache().hset('gstin_cache', key, details)
+ return details
+
+ def generate_irn(self):
+ 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'))
+
+ elif '2150' in res.get('message'):
+ # IRN already generated but not updated in invoice
+ # Extract the IRN from the response description and fetch irn details
+ irn = res.get('result')[0].get('Desc').get('Irn')
+ irn_details = self.get_irn_details(irn)
+ if irn_details:
+ self.set_einvoice_data(irn_details)
+ else:
+ raise RequestFailed('IRN has already been generated for the invoice but cannot fetch details for the it. \
+ Contact ERPNext support to resolve the issue.')
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.set_failed_status(errors=errors)
+ self.raise_error(errors=errors)
+
+ 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()
+
+ try:
+ params = '?irn={irn}'.format(irn=irn)
+ res = self.make_request('get', self.irn_details_url + params, headers)
+ if res.get('success'):
+ return res.get('result')
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error()
+ self.raise_error(True)
+
+ def cancel_irn(self, irn, reason, remark):
+ 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') 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,
+ 'label': _('IRN Cancelled - {}').format(remark)
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.set_failed_status(errors=errors)
+ self.raise_error(errors=errors)
+
+ 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)
+
+ headers = self.get_headers()
+ eway_bill_details = get_eway_bill_details(args)
+ data = json.dumps({
+ 'Irn': args.irn,
+ 'Distance': cint(eway_bill_details.distance),
+ 'TransMode': eway_bill_details.mode_of_transport,
+ 'TransId': eway_bill_details.gstin,
+ 'TransName': eway_bill_details.transporter,
+ 'TrnDocDt': eway_bill_details.document_date,
+ 'TrnDocNo': eway_bill_details.document_name,
+ 'VehNo': eway_bill_details.vehicle_no,
+ 'VehType': eway_bill_details.vehicle_type
+ }, indent=4)
+
+ try:
+ res = self.make_request('post', self.generate_ewaybill_url, headers, data)
+ if res.get('success'):
+ self.invoice.ewaybill = res.get('result').get('EwbNo')
+ self.invoice.eway_bill_validity = res.get('result').get('EwbValidTill')
+ self.invoice.eway_bill_cancelled = 0
+ self.invoice.update(args)
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('E-Way Bill Generated')
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error(data)
+ self.raise_error(True)
+
+ def cancel_eway_bill(self, eway_bill, reason, remark):
+ headers = self.get_headers()
+ data = json.dumps({
+ 'ewbNo': eway_bill,
+ 'cancelRsnCode': reason,
+ 'cancelRmrk': remark
+ }, indent=4)
+ headers["username"] = headers["user_name"]
+ del headers["user_name"]
+ try:
+ res = self.make_request('post', self.cancel_ewaybill_url, headers, data)
+ if res.get('success'):
+ self.invoice.ewaybill = ''
+ self.invoice.eway_bill_cancelled = 1
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('E-Way Bill Cancelled - {}').format(remark)
+ }
+ self.update_invoice()
+
+ else:
+ raise RequestFailed
+
+ except RequestFailed:
+ errors = self.sanitize_error_message(res.get('message'))
+ self.raise_error(errors=errors)
+
+ except Exception:
+ log_error(data)
+ self.raise_error(True)
+
+ def sanitize_error_message(self, message):
+ '''
+ On validation errors, response message looks something like this:
+ message = '2174 : For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable,
+ 3095 : Supplier GSTIN is inactive'
+ we search for string between ':' to extract the error messages
+ errors = [
+ ': For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, 3095 ',
+ ': Test'
+ ]
+ 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
+ errors[idx] = errors[idx].replace(':', '').strip()
+ # if not last
+ if idx != len(errors) - 1:
+ # remove last 7 chars eg: ', 3095 '
+ errors[idx] = errors[idx][:-6]
+
+ return errors
+
+ def raise_error(self, raise_exception=False, errors=None):
+ if errors is None:
+ errors = []
+ title = _('E Invoice Request Failed')
+ if errors:
+ frappe.throw(errors, title=title, as_list=1)
+ else:
+ link_to_error_list = 'Error Log'
+ frappe.msgprint(
+ _('An error occurred while making e-invoicing request. Please check {} for more information.').format(link_to_error_list),
+ title=title,
+ raise_exception=raise_exception,
+ indicator='red'
+ )
+
+ def set_einvoice_data(self, res):
+ enc_signed_invoice = res.get('SignedInvoice')
+ dec_signed_invoice = jwt.decode(enc_signed_invoice, options={"verify_signature": False})['data']
+
+ self.invoice.irn = res.get('Irn')
+ self.invoice.ewaybill = res.get('EwbNo')
+ self.invoice.eway_bill_validity = res.get('EwbValidTill')
+ 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()
+
+ self.invoice.flags.updater_reference = {
+ 'doctype': self.invoice.doctype,
+ 'docname': self.invoice.name,
+ 'label': _('IRN Generated')
+ }
+ self.update_invoice()
+
+ def attach_qrcode_image(self):
+ qrcode = self.invoice.signed_qr_code
+ doctype = self.invoice.doctype
+ docname = self.invoice.name
+ filename = 'QRCode_{}.png'.format(docname).replace(os.path.sep, "__")
+
+ qr_image = io.BytesIO()
+ url = qrcreate(qrcode, error='L')
+ url.png(qr_image, scale=2, quiet_zone=1)
+ _file = frappe.get_doc({
+ "doctype": "File",
+ "file_name": filename,
+ "attached_to_doctype": doctype,
+ "attached_to_name": docname,
+ "attached_to_field": "qrcode_image",
+ "is_private": 0,
+ "content": qr_image.getvalue()})
+ _file.save()
+ frappe.db.commit()
+ self.invoice.qrcode_image = _file.file_url
+
+ def update_invoice(self):
+ self.invoice.flags.ignore_validate_update_after_submit = True
+ 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."""
+
+ # json.dumps adds double-quotes to the string. Indexing to remove them.
+ return json.dumps(string)[1:-1]
+
+@frappe.whitelist()
+def get_einvoice(doctype, docname):
+ invoice = frappe.get_doc(doctype, docname)
+ return make_einvoice(invoice)
+
+@frappe.whitelist()
+def generate_irn(doctype, docname):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.generate_irn()
+
+@frappe.whitelist()
+def cancel_irn(doctype, docname, irn, reason, remark):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.cancel_irn(irn, reason, remark)
+
+@frappe.whitelist()
+def generate_eway_bill(doctype, docname, **kwargs):
+ gsp_connector = GSPConnector(doctype, docname)
+ gsp_connector.generate_eway_bill(**kwargs)
+
+@frappe.whitelist()
+def cancel_eway_bill(doctype, docname):
+ # TODO: uncomment when eway_bill api from Adequare is enabled
+ # gsp_connector = GSPConnector(doctype, docname)
+ # gsp_connector.cancel_eway_bill(eway_bill, reason, remark)
+
+ frappe.db.set_value(doctype, docname, 'ewaybill', '')
+ 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(['{}'.format(err) for err in errors])
+ message = '''{} has following errors:
+ '''.format(docname, error_list)
+ else:
+ message = '{} - {}'.format(docname, message)
+
+ frappe.msgprint(
+ message,
+ title=_('Bulk E-Invoice Generation Complete'),
+ indicator='red'
+ )
+
+@frappe.whitelist()
+def cancel_irns(docnames, reason, remark):
+ docnames = json.loads(docnames) or []
+
+ if len(docnames) < 10:
+ failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark)
+ frappe.local.message_log = []
+
+ if failures:
+ show_bulk_action_failure_message(failures)
+
+ success = len(docnames) - len(failures)
+ frappe.msgprint(
+ _('{} e-invoices cancelled successfully').format(success),
+ title=_('Bulk E-Invoice Cancellation Complete')
+ )
+ else:
+ enqueue_bulk_action(schedule_bulk_cancel_irn, docnames=docnames, reason=reason, remark=remark)
+
+def schedule_bulk_cancel_irn(docnames, reason, remark):
+ failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark)
+ frappe.local.message_log = []
+
+ frappe.publish_realtime("bulk_einvoice_cancellation_complete", {
+ "user": frappe.session.user,
+ "failures": failures,
+ "invoices": docnames
+ })
+
+def enqueue_bulk_action(job, **kwargs):
+ check_scheduler_status()
+
+ enqueue(
+ job,
+ **kwargs,
+ queue="long",
+ timeout=10000,
+ event="processing_bulk_einvoice_action",
+ now=frappe.conf.developer_mode or frappe.flags.in_test,
+ )
+
+ if job == schedule_bulk_generate_irn:
+ msg = _('E-Invoices will be generated in a background process.')
+ else:
+ msg = _('E-Invoices will be cancelled in a background process.')
+
+ frappe.msgprint(msg, alert=1)
+
+def check_scheduler_status():
+ if is_scheduler_inactive() and not frappe.flags.in_test:
+ frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive"))
+
+def job_already_enqueued(job_name):
+ enqueued_jobs = [d.get("job_name") for d in get_info()]
+ if job_name in enqueued_jobs:
+ return True
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index 4b9942121a..074bd527e2 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -60,7 +60,7 @@ def create_hsn_codes(data, code_field):
def add_custom_roles_for_reports():
for report_name in ('GST Sales Register', 'GST Purchase Register',
- 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill'):
+ 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill', 'E-Invoice Summary'):
if not frappe.db.get_value('Custom Role', dict(report=report_name)):
frappe.get_doc(dict(
@@ -99,7 +99,7 @@ def add_custom_roles_for_reports():
)).insert()
def add_permissions():
- for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate'):
+ for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate', 'E Invoice Settings'):
add_permission(doctype, 'All', 0)
for role in ('Accounts Manager', 'Accounts User', 'System Manager'):
add_permission(doctype, role, 0)
@@ -115,9 +115,11 @@ def add_permissions():
def add_print_formats():
frappe.reload_doc("regional", "print_format", "gst_tax_invoice")
frappe.reload_doc("accounts", "print_format", "gst_pos_invoice")
+ frappe.reload_doc("accounts", "print_format", "GST E-Invoice")
frappe.db.set_value("Print Format", "GST POS Invoice", "disabled", 0)
frappe.db.set_value("Print Format", "GST Tax Invoice", "disabled", 0)
+ frappe.db.set_value("Print Format", "GST E-Invoice", "disabled", 0)
def make_property_setters(patch=False):
# GST rules do not allow for an invoice no. bigger than 16 characters
@@ -453,7 +455,7 @@ def get_custom_fields():
'fieldname': 'ewaybill',
'label': 'E-Way Bill No.',
'fieldtype': 'Data',
- 'depends_on': 'eval:(doc.docstatus === 1)',
+ 'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)',
'allow_on_submit': 1,
'insert_after': 'tax_id',
'translatable': 0,
@@ -481,6 +483,46 @@ def get_custom_fields():
fetch_from='customer_address.gstin', print_hide=1, read_only=1)
]
+ si_einvoice_fields = [
+ dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1,
+ depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
+
+ dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval: doc.irn', allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
+ depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill'),
+
+ dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
+ depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
+
+ dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type',
+ print_hide=1, hidden=1),
+
+ dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
+
+ dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date',
+ no_copy=1, print_hide=1),
+
+ dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code',
+ no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image',
+ options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1),
+
+ dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON',
+ hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1)
+ ]
+
custom_fields = {
'Address': [
dict(fieldname='gstin', label='Party GSTIN', fieldtype='Data',
@@ -493,7 +535,7 @@ def get_custom_fields():
'Purchase Invoice': purchase_invoice_gst_category + invoice_gst_fields + purchase_invoice_itc_fields + purchase_invoice_gst_fields,
'Purchase Order': purchase_invoice_gst_fields,
'Purchase Receipt': purchase_invoice_gst_fields,
- 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields,
+ 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields + si_einvoice_fields,
'POS Invoice': sales_invoice_gst_fields,
'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields + delivery_note_gst_category,
'Payment Entry': payment_entry_fields,
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index d443f9c15c..8715ef57ba 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -219,6 +219,7 @@ def get_regional_address_details(party_details, doctype, company):
if not party_details.place_of_supply: return party_details
if not party_details.company_gstin: return party_details
+ if not party_details.supplier_gstin: return party_details
if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",
diff --git a/erpnext/regional/report/e_invoice_summary/__init__.py b/erpnext/regional/report/e_invoice_summary/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js
new file mode 100644
index 0000000000..4713217d83
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["E-Invoice Summary"] = {
+ "filters": [
+ {
+ "fieldtype": "Link",
+ "options": "Company",
+ "reqd": 1,
+ "fieldname": "company",
+ "label": __("Company"),
+ "default": frappe.defaults.get_user_default("Company"),
+ },
+ {
+ "fieldtype": "Link",
+ "options": "Customer",
+ "fieldname": "customer",
+ "label": __("Customer")
+ },
+ {
+ "fieldtype": "Date",
+ "reqd": 1,
+ "fieldname": "from_date",
+ "label": __("From Date"),
+ "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1),
+ },
+ {
+ "fieldtype": "Date",
+ "reqd": 1,
+ "fieldname": "to_date",
+ "label": __("To Date"),
+ "default": frappe.datetime.get_today(),
+ },
+ {
+ "fieldtype": "Select",
+ "fieldname": "status",
+ "label": __("Status"),
+ "options": "\nPending\nGenerated\nCancelled\nFailed"
+ }
+ ],
+
+ "formatter": function (value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+
+ if (column.fieldname == "einvoice_status" && value) {
+ if (value == 'Pending') value = `${value}`;
+ else if (value == 'Generated') value = `${value}`;
+ else if (value == 'Cancelled') value = `${value}`;
+ else if (value == 'Failed') value = `${value}`;
+ }
+
+ return value;
+ }
+};
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json
new file mode 100644
index 0000000000..d0000ad50d
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json
@@ -0,0 +1,28 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-03-12 11:23:37.312294",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "json": "{}",
+ "letter_head": "Logo",
+ "modified": "2021-03-13 12:36:48.689413",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "E-Invoice Summary",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Sales Invoice",
+ "report_name": "E-Invoice Summary",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Administrator"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py
new file mode 100644
index 0000000000..2110c44447
--- /dev/null
+++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py
@@ -0,0 +1,110 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+
+def execute(filters=None):
+ validate_filters(filters)
+
+ columns = get_columns()
+ data = get_data(filters)
+
+ return columns, data
+
+def validate_filters(filters=None):
+ if filters is None:
+ filters = {}
+ filters = frappe._dict(filters)
+
+ if not filters.company:
+ frappe.throw(_('{} is mandatory for generating E-Invoice Summary Report').format(_('Company')), title=_('Invalid Filter'))
+ if filters.company:
+ # validate if company has e-invoicing enabled
+ pass
+ if not filters.from_date or not filters.to_date:
+ frappe.throw(_('From Date & To Date is mandatory for generating E-Invoice Summary Report'), title=_('Invalid Filter'))
+ if filters.from_date > filters.to_date:
+ frappe.throw(_('From Date must be before To Date'), title=_('Invalid Filter'))
+
+def get_data(filters=None):
+ if filters is None:
+ filters = {}
+ query_filters = {
+ 'posting_date': ['between', [filters.from_date, filters.to_date]],
+ 'einvoice_status': ['is', 'set'],
+ 'company': filters.company
+ }
+ if filters.customer:
+ query_filters['customer'] = filters.customer
+ if filters.status:
+ query_filters['einvoice_status'] = filters.status
+
+ data = frappe.get_all(
+ 'Sales Invoice',
+ filters=query_filters,
+ fields=[d.get('fieldname') for d in get_columns()]
+ )
+
+ return data
+
+def get_columns():
+ return [
+ {
+ "fieldtype": "Date",
+ "fieldname": "posting_date",
+ "label": _("Posting Date"),
+ "width": 0
+ },
+ {
+ "fieldtype": "Link",
+ "fieldname": "name",
+ "label": _("Sales Invoice"),
+ "options": "Sales Invoice",
+ "width": 140
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "einvoice_status",
+ "label": _("Status"),
+ "width": 100
+ },
+ {
+ "fieldtype": "Link",
+ "fieldname": "customer",
+ "options": "Customer",
+ "label": _("Customer")
+ },
+ {
+ "fieldtype": "Check",
+ "fieldname": "is_return",
+ "label": _("Is Return"),
+ "width": 85
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "ack_no",
+ "label": "Ack. No.",
+ "width": 145
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "ack_date",
+ "label": "Ack. Date",
+ "width": 165
+ },
+ {
+ "fieldtype": "Data",
+ "fieldname": "irn",
+ "label": _("IRN No."),
+ "width": 250
+ },
+ {
+ "fieldtype": "Currency",
+ "options": "Company:company:default_currency",
+ "fieldname": "base_grand_total",
+ "label": _("Grand Total"),
+ "width": 120
+ }
+ ]
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 79e9e17e41..eb98e6c0bf 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -457,12 +457,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_delivery_note_based_on_delivery_date() {
var me = this;
- var delivery_dates = [];
- $.each(this.frm.doc.items || [], function(i, d) {
- if(!delivery_dates.includes(d.delivery_date)) {
- delivery_dates.push(d.delivery_date);
- }
- });
+ var delivery_dates = this.frm.doc.items.map(i => i.delivery_date);
+ delivery_dates = [ ...new Set(delivery_dates) ];
var item_grid = this.frm.fields_dict["items"].grid;
if(!item_grid.get_selected().length && delivery_dates.length > 1) {
@@ -500,14 +496,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
if(!dates) return;
- $.each(dates, function(i, d) {
- $.each(item_grid.grid_rows || [], function(j, row) {
- if(row.doc.delivery_date == d) {
- row.doc.__checked = 1;
- }
- });
- })
- me.make_delivery_note();
+ me.make_delivery_note(dates);
dialog.hide();
});
dialog.show();
@@ -516,10 +505,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
}
- make_delivery_note() {
+ make_delivery_note(delivery_dates) {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
- frm: this.frm
+ frm: this.frm,
+ args: {
+ delivery_dates
+ }
})
}
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index cc951850a4..0f5b1e3b89 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -565,6 +565,13 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
}
if not skip_item_mapping:
+ def condition(doc):
+ # make_mapped_doc sets js `args` into `frappe.flags.args`
+ if frappe.flags.args and frappe.flags.args.delivery_dates:
+ if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
+ return False
+ return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
+
mapper["Sales Order Item"] = {
"doctype": "Delivery Note Item",
"field_map": {
@@ -573,7 +580,7 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
"parent": "against_sales_order",
},
"postprocess": update_item,
- "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
+ "condition": condition
}
target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py
index fb86e614b6..e1ef63578e 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.py
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.py
@@ -16,7 +16,7 @@ class SellingSettings(Document):
self.toggle_editable_rate_for_bundle_items()
def validate(self):
- for key in ["cust_master_name", "campaign_naming_by", "customer_group", "territory",
+ for key in ["cust_master_name", "customer_group", "territory",
"maintain_same_sales_rate", "editable_price_list_rate", "selling_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index db5b20e3e1..993c61d563 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -24,7 +24,7 @@ def search_by_term(search_term, warehouse, price_list):
["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"],
as_dict=1)
- item_stock_qty = get_stock_availability(item_code, warehouse)
+ item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
price_list_rate, currency = frappe.db.get_value('Item Price', {
'price_list': price_list,
'item_code': item_code
@@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
), {'warehouse': warehouse}, as_dict=1)
if items_data:
- items_data = filter_service_items(items_data)
items = [d.item_code for d in items_data]
item_prices_data = frappe.get_all("Item Price",
fields = ["item_code", "price_list_rate", "currency"],
@@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
for item in items_data:
item_code = item.item_code
item_price = item_prices.get(item_code) or {}
- item_stock_qty = get_stock_availability(item_code, warehouse)
+ item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
row = {}
row.update(item)
@@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value):
return {}
-def filter_service_items(items):
- for item in items:
- if not item['is_stock_item']:
- if not frappe.db.exists('Product Bundle', item['item_code']):
- items.remove(item)
-
- return items
-
def get_conditions(search_term):
condition = "("
condition += """item.name like {search_term}
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index ce74f6d0a5..ea8459f970 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class {
numpad_event: (value, action) => this.update_item_field(value, action),
- checkout: () => this.payment.checkout(),
+ checkout: () => this.save_and_checkout(),
edit_cart: () => this.payment.edit_cart(),
@@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class {
}
async check_stock_availability(item_row, qty_needed, warehouse) {
- const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message;
+ const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message;
+ const available_qty = resp[0];
+ const is_stock_item = resp[1];
frappe.dom.unfreeze();
const bold_item_code = item_row.item_code.bold();
const bold_warehouse = warehouse.bold();
const bold_available_qty = available_qty.toString().bold()
if (!(available_qty > 0)) {
- frappe.model.clear_doc(item_row.doctype, item_row.name);
- frappe.throw({
- title: __("Not Available"),
- message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
- })
+ if (is_stock_item) {
+ frappe.model.clear_doc(item_row.doctype, item_row.name);
+ frappe.throw({
+ title: __("Not Available"),
+ message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
+ });
+ } else {
+ return;
+ }
} else if (available_qty < qty_needed) {
frappe.throw({
message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]),
@@ -675,8 +681,8 @@ erpnext.PointOfSale.Controller = class {
},
callback(res) {
if (!me.item_stock_map[item_code])
- me.item_stock_map[item_code] = {}
- me.item_stock_map[item_code][warehouse] = res.message;
+ me.item_stock_map[item_code] = {};
+ me.item_stock_map[item_code][warehouse] = res.message[0];
}
});
}
@@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class {
})
.catch(e => console.log(e));
}
+
+ async save_and_checkout() {
+ this.frm.is_dirty() && await this.frm.save();
+ this.payment.checkout();
+ }
};
diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js
index 4920584d95..4a99f068cd 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_cart.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js
@@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class {
this.numpad_value = '';
});
- this.$component.on('click', '.checkout-btn', function() {
+ this.$component.on('click', '.checkout-btn', async function() {
if ($(this).attr('style').indexOf('--blue-500') == -1) return;
- me.events.checkout();
+ await me.events.checkout();
me.toggle_checkout_btn(false);
me.allow_discount_change && me.$add_discount_elem.removeClass("d-none");
@@ -985,6 +985,7 @@ erpnext.PointOfSale.ItemCart = class {
$(frm.wrapper).off('refresh-fields');
$(frm.wrapper).on('refresh-fields', () => {
if (frm.doc.items.length) {
+ this.$cart_items_wrapper.html('');
frm.doc.items.forEach(item => {
this.update_item_html(item);
});
diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js
index a30bcd7cf6..1177615aee 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_selector.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js
@@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class {
const me = this;
// eslint-disable-next-line no-unused-vars
const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item;
- const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange";
const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0;
-
+ let indicator_color;
let qty_to_display = actual_qty;
- if (Math.round(qty_to_display) > 999) {
- qty_to_display = Math.round(qty_to_display)/1000;
- qty_to_display = qty_to_display.toFixed(1) + 'K';
+ if (item.is_stock_item) {
+ indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange");
+
+ if (Math.round(qty_to_display) > 999) {
+ qty_to_display = Math.round(qty_to_display)/1000;
+ qty_to_display = qty_to_display.toFixed(1) + 'K';
+ }
+ } else {
+ indicator_color = '';
+ qty_to_display = '';
}
function get_item_image_html() {
diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js
index 540aca234b..16e3847168 100644
--- a/erpnext/selling/sales_common.js
+++ b/erpnext/selling/sales_common.js
@@ -486,7 +486,7 @@ frappe.ui.form.on(cur_frm.doctype, {
"options": "Competitor Detail"
},
{
- "fieldtype": "Text",
+ "fieldtype": "Small Text",
"label": __("Detailed Reason"),
"fieldname": "detailed_reason"
},
@@ -499,7 +499,7 @@ frappe.ui.form.on(cur_frm.doctype, {
method: 'declare_enquiry_lost',
args: {
'lost_reasons_list': values.lost_reason,
- 'competitors': values.competitors,
+ 'competitors': values.competitors ? values.competitors : [],
'detailed_reason': values.detailed_reason
},
callback: function(r) {
diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json
index 63d96bf85e..370a3278a0 100644
--- a/erpnext/setup/doctype/company/company.json
+++ b/erpnext/setup/doctype/company/company.json
@@ -3,7 +3,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:company_name",
- "creation": "2013-04-10 08:35:39",
+ "creation": "2022-01-25 10:29:55.938239",
"description": "Legal Entity / Subsidiary with a separate Chart of Accounts belonging to the Organization.",
"doctype": "DocType",
"document_type": "Setup",
@@ -77,13 +77,13 @@
"default_finance_book",
"auto_accounting_for_stock_settings",
"enable_perpetual_inventory",
- "enable_perpetual_inventory_for_non_stock_items",
+ "enable_provisional_accounting_for_non_stock_items",
"default_inventory_account",
"stock_adjustment_account",
"default_in_transit_warehouse",
"column_break_32",
"stock_received_but_not_billed",
- "service_received_but_not_billed",
+ "default_provisional_account",
"expenses_included_in_valuation",
"fixed_asset_defaults",
"accumulated_depreciation_account",
@@ -684,20 +684,6 @@
"label": "Default Buying Terms",
"options": "Terms and Conditions"
},
- {
- "fieldname": "service_received_but_not_billed",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "label": "Service Received But Not Billed",
- "no_copy": 1,
- "options": "Account"
- },
- {
- "default": "0",
- "fieldname": "enable_perpetual_inventory_for_non_stock_items",
- "fieldtype": "Check",
- "label": "Enable Perpetual Inventory For Non Stock Items"
- },
{
"fieldname": "default_in_transit_warehouse",
"fieldtype": "Link",
@@ -741,6 +727,20 @@
"fieldname": "section_break_28",
"fieldtype": "Section Break",
"label": "Chart of Accounts"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_provisional_accounting_for_non_stock_items",
+ "fieldtype": "Check",
+ "label": "Enable Provisional Accounting For Non Stock Items"
+ },
+ {
+ "fieldname": "default_provisional_account",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Default Provisional Account",
+ "no_copy": 1,
+ "options": "Account"
}
],
"icon": "fa fa-building",
@@ -748,7 +748,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
- "modified": "2021-10-04 12:09:25.833133",
+ "modified": "2022-01-25 10:33:16.826067",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",
@@ -809,5 +809,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 0a02bcd6cd..95b1e8b9c6 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -10,6 +10,7 @@ import frappe.defaults
from frappe import _
from frappe.cache_manager import clear_defaults_cache
from frappe.contacts.address_and_contact import load_address_and_contact
+from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.utils import cint, formatdate, get_timestamp, today
from frappe.utils.nestedset import NestedSet
@@ -45,7 +46,7 @@ class Company(NestedSet):
self.validate_currency()
self.validate_coa_input()
self.validate_perpetual_inventory()
- self.validate_perpetual_inventory_for_non_stock_items()
+ self.validate_provisional_account_for_non_stock_items()
self.check_country_change()
self.check_parent_changed()
self.set_chart_of_accounts()
@@ -187,11 +188,14 @@ class Company(NestedSet):
frappe.msgprint(_("Set default inventory account for perpetual inventory"),
alert=True, indicator='orange')
- def validate_perpetual_inventory_for_non_stock_items(self):
+ def validate_provisional_account_for_non_stock_items(self):
if not self.get("__islocal"):
- if cint(self.enable_perpetual_inventory_for_non_stock_items) == 1 and not self.service_received_but_not_billed:
- frappe.throw(_("Set default {0} account for perpetual inventory for non stock items").format(
- frappe.bold('Service Received But Not Billed')))
+ if cint(self.enable_provisional_accounting_for_non_stock_items) == 1 and not self.default_provisional_account:
+ frappe.throw(_("Set default {0} account for non stock items").format(
+ frappe.bold('Provisional Account')))
+
+ make_property_setter("Purchase Receipt", "provisional_expense_account", "hidden",
+ not self.enable_provisional_accounting_for_non_stock_items, "Check", validate_fields_for_doctype=False)
def check_country_change(self):
frappe.flags.country_change = False
diff --git a/erpnext/stock/doctype/bin/bin.json b/erpnext/stock/doctype/bin/bin.json
index 8e79f0e555..56dc71c57e 100644
--- a/erpnext/stock/doctype/bin/bin.json
+++ b/erpnext/stock/doctype/bin/bin.json
@@ -33,6 +33,7 @@
"oldfieldtype": "Link",
"options": "Warehouse",
"read_only": 1,
+ "reqd": 1,
"search_index": 1
},
{
@@ -46,6 +47,7 @@
"oldfieldtype": "Link",
"options": "Item",
"read_only": 1,
+ "reqd": 1,
"search_index": 1
},
{
@@ -169,10 +171,11 @@
"idx": 1,
"in_create": 1,
"links": [],
- "modified": "2021-03-30 23:09:39.572776",
+ "modified": "2022-01-30 17:04:54.715288",
"modified_by": "Administrator",
"module": "Stock",
"name": "Bin",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -200,5 +203,6 @@
"quick_entry": 1,
"search_fields": "item_code,warehouse",
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index 0ef7ce2923..c34e9d05ce 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -123,7 +123,7 @@ class Bin(Document):
self.db_set('projected_qty', self.projected_qty)
def on_doctype_update():
- frappe.db.add_index("Bin", ["item_code", "warehouse"])
+ frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):
diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py
index 9c390d94b4..250126c6b9 100644
--- a/erpnext/stock/doctype/bin/test_bin.py
+++ b/erpnext/stock/doctype/bin/test_bin.py
@@ -1,9 +1,36 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-import unittest
+import frappe
-# test_records = frappe.get_test_records('Bin')
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.utils import _create_bin
+from erpnext.tests.utils import ERPNextTestCase
-class TestBin(unittest.TestCase):
- pass
+
+class TestBin(ERPNextTestCase):
+
+
+ def test_concurrent_inserts(self):
+ """ Ensure no duplicates are possible in case of concurrent inserts"""
+ item_code = "_TestConcurrentBin"
+ make_item(item_code)
+ warehouse = "_Test Warehouse - _TC"
+
+ bin1 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
+ bin1.insert()
+
+ bin2 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
+ with self.assertRaises(frappe.UniqueValidationError):
+ bin2.insert()
+
+ # util method should handle it
+ bin = _create_bin(item_code, warehouse)
+ self.assertEqual(bin.item_code, item_code)
+
+ frappe.db.rollback()
+
+ def test_index_exists(self):
+ indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
+ if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):
+ self.fail(f"Expected unique index on item-warehouse")
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 752a1fe732..86c702c539 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -376,8 +376,7 @@ $.extend(erpnext.item, {
// Show Stock Levels only if is_stock_item
if (frm.doc.is_stock_item) {
frappe.require('item-dashboard.bundle.js', function() {
- frm.dashboard.parent.find('.stock-levels').remove();
- const section = frm.dashboard.add_section('', __("Stock Levels"), 'stock-levels');
+ const section = frm.dashboard.add_section('', __("Stock Levels"));
erpnext.item.item_dashboard = new erpnext.stock.ItemDashboard({
parent: section,
item_code: frm.doc.name,
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
index 112ddedac2..b54a90eed3 100755
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
@@ -106,6 +106,8 @@
"terms",
"bill_no",
"bill_date",
+ "accounting_details_section",
+ "provisional_expense_account",
"more_info",
"project",
"status",
@@ -1144,16 +1146,30 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_details_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Details"
+ },
+ {
+ "fieldname": "provisional_expense_account",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Provisional Expense Account",
+ "options": "Account"
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
- "modified": "2021-09-28 13:11:10.181328",
+ "modified": "2022-02-01 11:40:52.690984",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -1214,6 +1230,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "supplier",
"title_field": "title",
"track_changes": 1
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index c97b306c4e..1257057ea3 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -8,6 +8,7 @@ from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, getdate, nowdate
+import erpnext
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
@@ -112,6 +113,7 @@ class PurchaseReceipt(BuyingController):
self.validate_uom_is_integer("uom", ["qty", "received_qty"])
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_cwip_accounts()
+ self.validate_provisional_expense_account()
self.check_on_hold_or_closed_status()
@@ -133,6 +135,15 @@ class PurchaseReceipt(BuyingController):
company = self.company)
break
+ def validate_provisional_expense_account(self):
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
+
+ if provisional_accounting_for_non_stock_items:
+ default_provisional_account = self.get_company_default("default_provisional_account")
+ if not self.provisional_expense_account:
+ self.provisional_expense_account = default_provisional_account
+
def validate_with_previous_doc(self):
super(PurchaseReceipt, self).validate_with_previous_doc({
"Purchase Order": {
@@ -258,13 +269,15 @@ class PurchaseReceipt(BuyingController):
get_purchase_document_details,
)
- stock_rbnb = self.get_company_default("stock_received_but_not_billed")
- landed_cost_entries = get_item_account_wise_additional_cost(self.name)
- expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
- auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
+ if erpnext.is_perpetual_inventory_enabled(self.company):
+ stock_rbnb = self.get_company_default("stock_received_but_not_billed")
+ landed_cost_entries = get_item_account_wise_additional_cost(self.name)
+ expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
warehouse_with_no_account = []
stock_items = self.get_stock_items()
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
@@ -422,43 +435,58 @@ class PurchaseReceipt(BuyingController):
elif d.warehouse not in warehouse_with_no_account or \
d.rejected_warehouse not in warehouse_with_no_account:
warehouse_with_no_account.append(d.warehouse)
- elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and auto_accounting_for_non_stock_items:
- service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
- credit_currency = get_account_currency(service_received_but_not_billed_account)
- debit_currency = get_account_currency(d.expense_account)
- remarks = self.get("remarks") or _("Accounting Entry for Service")
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=service_received_but_not_billed_account,
- cost_center=d.cost_center,
- debit=0.0,
- credit=d.amount,
- remarks=remarks,
- against_account=d.expense_account,
- account_currency=credit_currency,
- project=d.project,
- voucher_detail_no=d.name, item=d)
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=d.expense_account,
- cost_center=d.cost_center,
- debit=d.amount,
- credit=0.0,
- remarks=remarks,
- against_account=service_received_but_not_billed_account,
- account_currency = debit_currency,
- project=d.project,
- voucher_detail_no=d.name,
- item=d)
+ elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and provisional_accounting_for_non_stock_items:
+ self.add_provisional_gl_entry(d, gl_entries, self.posting_date)
if warehouse_with_no_account:
frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" +
"\n".join(warehouse_with_no_account))
+ def add_provisional_gl_entry(self, item, gl_entries, posting_date, reverse=0):
+ provisional_expense_account = self.get('provisional_expense_account')
+ credit_currency = get_account_currency(provisional_expense_account)
+ debit_currency = get_account_currency(item.expense_account)
+ expense_account = item.expense_account
+ remarks = self.get("remarks") or _("Accounting Entry for Service")
+ multiplication_factor = 1
+
+ if reverse:
+ multiplication_factor = -1
+ expense_account = frappe.db.get_value('Purchase Receipt Item', {'name': item.get('pr_detail')}, ['expense_account'])
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=provisional_expense_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=multiplication_factor * item.amount,
+ remarks=remarks,
+ against_account=expense_account,
+ account_currency=credit_currency,
+ project=item.project,
+ voucher_detail_no=item.name,
+ item=item,
+ posting_date=posting_date)
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=expense_account,
+ cost_center=item.cost_center,
+ debit=multiplication_factor * item.amount,
+ credit=0.0,
+ remarks=remarks,
+ against_account=provisional_expense_account,
+ account_currency = debit_currency,
+ project=item.project,
+ voucher_detail_no=item.name,
+ item=item,
+ posting_date=posting_date)
+
def make_tax_gl_entries(self, gl_entries):
- expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
+
+ if erpnext.is_perpetual_inventory_enabled(self.company):
+ expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
+
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get('items')])
# Cost center-wise amount breakup for other charges included for valuation
valuation_tax = {}
@@ -515,7 +543,8 @@ class PurchaseReceipt(BuyingController):
def add_gl_entry(self, gl_entries, account, cost_center, debit, credit, remarks, against_account,
debit_in_account_currency=None, credit_in_account_currency=None, account_currency=None,
- project=None, voucher_detail_no=None, item=None):
+ project=None, voucher_detail_no=None, item=None, posting_date=None):
+
gl_entry = {
"account": account,
"cost_center": cost_center,
@@ -534,6 +563,9 @@ class PurchaseReceipt(BuyingController):
if credit_in_account_currency:
gl_entry.update({"credit_in_account_currency": credit_in_account_currency})
+ if posting_date:
+ gl_entry.update({"posting_date": posting_date})
+
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
def get_asset_gl_entry(self, gl_entries):
@@ -562,6 +594,7 @@ class PurchaseReceipt(BuyingController):
# debit cwip account
debit_in_account_currency = (base_asset_amount
if cwip_account_currency == self.company_currency else asset_amount)
+
self.add_gl_entry(
gl_entries=gl_entries,
account=cwip_account,
@@ -577,6 +610,7 @@ class PurchaseReceipt(BuyingController):
# credit arbnb account
credit_in_account_currency = (base_asset_amount
if asset_rbnb_currency == self.company_currency else asset_amount)
+
self.add_gl_entry(
gl_entries=gl_entries,
account=arbnb_account,
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 2909a2d2e7..b87d9205e0 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -1312,58 +1312,6 @@ class TestPurchaseReceipt(ERPNextTestCase):
self.assertEqual(pr.status, "To Bill")
self.assertAlmostEqual(pr.per_billed, 50.0, places=2)
- def test_service_item_purchase_with_perpetual_inventory(self):
- company = '_Test Company with perpetual inventory'
- service_item = '_Test Non Stock Item'
-
- before_test_value = frappe.db.get_value(
- 'Company', company, 'enable_perpetual_inventory_for_non_stock_items'
- )
- frappe.db.set_value(
- 'Company', company,
- 'enable_perpetual_inventory_for_non_stock_items', 1
- )
- srbnb_account = 'Stock Received But Not Billed - TCP1'
- frappe.db.set_value(
- 'Company', company,
- 'service_received_but_not_billed', srbnb_account
- )
-
- pr = make_purchase_receipt(
- company=company, item=service_item,
- warehouse='Finished Goods - TCP1', do_not_save=1
- )
- item_row_with_diff_rate = frappe.copy_doc(pr.items[0])
- item_row_with_diff_rate.rate = 100
- pr.append('items', item_row_with_diff_rate)
-
- pr.save()
- pr.submit()
-
- item_one_gl_entry = frappe.db.get_all("GL Entry", {
- 'voucher_type': pr.doctype,
- 'voucher_no': pr.name,
- 'account': srbnb_account,
- 'voucher_detail_no': pr.items[0].name
- }, pluck="name")
-
- item_two_gl_entry = frappe.db.get_all("GL Entry", {
- 'voucher_type': pr.doctype,
- 'voucher_no': pr.name,
- 'account': srbnb_account,
- 'voucher_detail_no': pr.items[1].name
- }, pluck="name")
-
- # check if the entries are not merged into one
- # seperate entries should be made since voucher_detail_no is different
- self.assertEqual(len(item_one_gl_entry), 1)
- self.assertEqual(len(item_two_gl_entry), 1)
-
- frappe.db.set_value(
- 'Company', company,
- 'enable_perpetual_inventory_for_non_stock_items', before_test_value
- )
-
def test_purchase_receipt_with_exchange_rate_difference(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
make_purchase_receipt as create_purchase_receipt,
diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index 30ea1c3cad..e5994b2dd4 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -976,7 +976,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-11-15 15:46:10.591600",
+ "modified": "2022-02-01 11:32:27.980524",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
@@ -985,5 +985,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index ee55af3475..ee08e38f33 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -465,6 +465,13 @@ def get_serial_nos(serial_no):
return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n')
if s.strip()]
+def clean_serial_no_string(serial_no: str) -> str:
+ if not serial_no:
+ return ""
+
+ serial_no_list = get_serial_nos(serial_no)
+ return "\n".join(serial_no_list)
+
def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False):
for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]:
if args.get(field):
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 60154afa15..c51c9bc5f4 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1673,6 +1673,8 @@ class StockEntry(StockController):
for d in self.get("items"):
item_code = d.get('original_item') or d.get('item_code')
reserve_warehouse = item_wh.get(item_code)
+ if not (reserve_warehouse and item_code):
+ continue
stock_bin = get_bin(item_code, reserve_warehouse)
stock_bin.update_reserved_qty_for_sub_contracting()
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index f620c183eb..7c63c17ad0 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -176,13 +176,7 @@ def get_latest_stock_balance():
def get_bin(item_code, warehouse):
bin = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin:
- bin_obj = frappe.get_doc({
- "doctype": "Bin",
- "item_code": item_code,
- "warehouse": warehouse,
- })
- bin_obj.flags.ignore_permissions = 1
- bin_obj.insert()
+ bin_obj = _create_bin(item_code, warehouse)
else:
bin_obj = frappe.get_doc('Bin', bin, for_update=True)
bin_obj.flags.ignore_permissions = True
@@ -192,16 +186,24 @@ def get_or_make_bin(item_code: str , warehouse: str) -> str:
bin_record = frappe.db.get_value('Bin', {'item_code': item_code, 'warehouse': warehouse})
if not bin_record:
- bin_obj = frappe.get_doc({
- "doctype": "Bin",
- "item_code": item_code,
- "warehouse": warehouse,
- })
+ bin_obj = _create_bin(item_code, warehouse)
+ bin_record = bin_obj.name
+ return bin_record
+
+def _create_bin(item_code, warehouse):
+ """Create a bin and take care of concurrent inserts."""
+
+ bin_creation_savepoint = "create_bin"
+ try:
+ frappe.db.savepoint(bin_creation_savepoint)
+ bin_obj = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse)
bin_obj.flags.ignore_permissions = 1
bin_obj.insert()
- bin_record = bin_obj.name
+ except frappe.UniqueValidationError:
+ frappe.db.rollback(save_point=bin_creation_savepoint) # preserve transaction in postgres
+ bin_obj = frappe.get_last_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
- return bin_record
+ return bin_obj
def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
"""WARNING: This function is deprecated. Inline this function instead of using it."""
diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py
new file mode 100644
index 0000000000..df2dc8b99a
--- /dev/null
+++ b/erpnext/tests/test_point_of_sale.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+
+from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
+from erpnext.selling.page.point_of_sale.point_of_sale import get_items
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.tests.utils import ERPNextTestCase
+
+
+class TestPointOfSale(ERPNextTestCase):
+ def test_item_search(self):
+ """
+ Test Stock and Service Item Search.
+ """
+
+ pos_profile = make_pos_profile()
+ item1 = make_item("Test Search Stock Item", {"is_stock_item": 1})
+ make_stock_entry(
+ item_code="Test Search Stock Item",
+ qty=10,
+ to_warehouse="_Test Warehouse - _TC",
+ rate=500,
+ )
+
+ result = get_items(
+ start=0,
+ page_length=20,
+ price_list=None,
+ item_group=item1.item_group,
+ pos_profile=pos_profile.name,
+ search_term="Test Search Stock Item",
+ )
+ filtered_items = result.get("items")
+
+ self.assertEqual(len(filtered_items), 1)
+ self.assertEqual(filtered_items[0]["item_code"], item1.item_code)
+ self.assertEqual(filtered_items[0]["actual_qty"], 10)
+
+ item2 = make_item("Test Search Service Item", {"is_stock_item": 0})
+ result = get_items(
+ start=0,
+ page_length=20,
+ price_list=None,
+ item_group=item2.item_group,
+ pos_profile=pos_profile.name,
+ search_term="Test Search Service Item",
+ )
+ filtered_items = result.get("items")
+
+ self.assertEqual(len(filtered_items), 1)
+ self.assertEqual(filtered_items[0]["item_code"], item2.item_code)