diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/__init__.py b/erpnext/accounts/doctype/bisect_accounting_statements/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.js b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.js
new file mode 100644
index 00000000000..f3532e5be61
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.js
@@ -0,0 +1,100 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Bisect Accounting Statements", {
+ onload(frm) {
+ frm.trigger("render_heatmap");
+ },
+ refresh(frm) {
+ frm.add_custom_button(__("Bisect Left"), () => {
+ frm.trigger("bisect_left");
+ });
+
+ frm.add_custom_button(__("Bisect Right"), () => {
+ frm.trigger("bisect_right");
+ });
+
+ frm.add_custom_button(__("Up"), () => {
+ frm.trigger("move_up");
+ });
+ frm.add_custom_button(__("Build Tree"), () => {
+ frm.trigger("build_tree");
+ });
+ },
+ render_heatmap(frm) {
+ let bisect_heatmap = frm.get_field("bisect_heatmap").$wrapper;
+ bisect_heatmap.addClass("bisect_heatmap_location");
+
+ // milliseconds in a day
+ let msiad = 24 * 60 * 60 * 1000;
+ let datapoints = {};
+ let fr_dt = new Date(frm.doc.from_date).getTime();
+ let to_dt = new Date(frm.doc.to_date).getTime();
+ let bisect_start = new Date(frm.doc.current_from_date).getTime();
+ let bisect_end = new Date(frm.doc.current_to_date).getTime();
+
+ for (let x = fr_dt; x <= to_dt; x += msiad) {
+ let epoch_in_seconds = x / 1000;
+ if (bisect_start <= x && x <= bisect_end) {
+ datapoints[epoch_in_seconds] = 1.0;
+ } else {
+ datapoints[epoch_in_seconds] = 0.0;
+ }
+ }
+
+ new frappe.Chart(".bisect_heatmap_location", {
+ type: "heatmap",
+ data: {
+ dataPoints: datapoints,
+ start: new Date(frm.doc.from_date),
+ end: new Date(frm.doc.to_date),
+ },
+ countLabel: "Bisecting",
+ discreteDomains: 1,
+ });
+ },
+ bisect_left(frm) {
+ frm.call({
+ doc: frm.doc,
+ method: "bisect_left",
+ freeze: true,
+ freeze_message: __("Bisecting Left ..."),
+ callback: (r) => {
+ frm.trigger("render_heatmap");
+ },
+ });
+ },
+ bisect_right(frm) {
+ frm.call({
+ doc: frm.doc,
+ freeze: true,
+ freeze_message: __("Bisecting Right ..."),
+ method: "bisect_right",
+ callback: (r) => {
+ frm.trigger("render_heatmap");
+ },
+ });
+ },
+ move_up(frm) {
+ frm.call({
+ doc: frm.doc,
+ freeze: true,
+ freeze_message: __("Moving up in tree ..."),
+ method: "move_up",
+ callback: (r) => {
+ frm.trigger("render_heatmap");
+ },
+ });
+ },
+ build_tree(frm) {
+ frm.call({
+ doc: frm.doc,
+ freeze: true,
+ freeze_message: __("Rebuilding BTree for period ..."),
+ method: "build_tree",
+ callback: (r) => {
+ frm.trigger("render_heatmap");
+ },
+ });
+ },
+});
diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.json b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.json
new file mode 100644
index 00000000000..e129fa60c2c
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.json
@@ -0,0 +1,194 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2023-09-15 21:28:28.054773",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "section_break_cvfg",
+ "company",
+ "column_break_hcam",
+ "from_date",
+ "column_break_qxbi",
+ "to_date",
+ "column_break_iwny",
+ "algorithm",
+ "section_break_8ph9",
+ "current_node",
+ "section_break_ngid",
+ "bisect_heatmap",
+ "section_break_hmsy",
+ "bisecting_from",
+ "current_from_date",
+ "column_break_uqyd",
+ "bisecting_to",
+ "current_to_date",
+ "section_break_hbyo",
+ "heading_cppb",
+ "p_l_summary",
+ "column_break_aivo",
+ "balance_sheet_summary",
+ "b_s_summary",
+ "column_break_gvwx",
+ "difference_heading",
+ "difference"
+ ],
+ "fields": [
+ {
+ "fieldname": "column_break_qxbi",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "from_date",
+ "fieldtype": "Datetime",
+ "label": "From Date"
+ },
+ {
+ "fieldname": "to_date",
+ "fieldtype": "Datetime",
+ "label": "To Date"
+ },
+ {
+ "default": "BFS",
+ "fieldname": "algorithm",
+ "fieldtype": "Select",
+ "label": "Algorithm",
+ "options": "BFS\nDFS"
+ },
+ {
+ "fieldname": "column_break_iwny",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "current_node",
+ "fieldtype": "Link",
+ "label": "Current Node",
+ "options": "Bisect Nodes"
+ },
+ {
+ "fieldname": "section_break_hmsy",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "current_from_date",
+ "fieldtype": "Datetime",
+ "read_only": 1
+ },
+ {
+ "fieldname": "current_to_date",
+ "fieldtype": "Datetime",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_uqyd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_hbyo",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "p_l_summary",
+ "fieldtype": "Float",
+ "read_only": 1
+ },
+ {
+ "fieldname": "b_s_summary",
+ "fieldtype": "Float",
+ "read_only": 1
+ },
+ {
+ "fieldname": "difference",
+ "fieldtype": "Float",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_aivo",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_gvwx",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company"
+ },
+ {
+ "fieldname": "column_break_hcam",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_ngid",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "section_break_8ph9",
+ "fieldtype": "Section Break",
+ "hidden": 1
+ },
+ {
+ "fieldname": "bisect_heatmap",
+ "fieldtype": "HTML",
+ "label": "Heatmap"
+ },
+ {
+ "fieldname": "heading_cppb",
+ "fieldtype": "Heading",
+ "label": "Profit and Loss Summary"
+ },
+ {
+ "fieldname": "balance_sheet_summary",
+ "fieldtype": "Heading",
+ "label": "Balance Sheet Summary"
+ },
+ {
+ "fieldname": "difference_heading",
+ "fieldtype": "Heading",
+ "label": "Difference"
+ },
+ {
+ "fieldname": "bisecting_from",
+ "fieldtype": "Heading",
+ "label": "Bisecting From"
+ },
+ {
+ "fieldname": "bisecting_to",
+ "fieldtype": "Heading",
+ "label": "Bisecting To"
+ },
+ {
+ "fieldname": "section_break_cvfg",
+ "fieldtype": "Section Break"
+ }
+ ],
+ "hide_toolbar": 1,
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2023-12-01 16:49:54.073890",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Bisect Accounting Statements",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "Administrator",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "read_only": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py
new file mode 100644
index 00000000000..da273b9f891
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py
@@ -0,0 +1,226 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import datetime
+from collections import deque
+from math import floor
+
+import frappe
+from dateutil.relativedelta import relativedelta
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import getdate
+from frappe.utils.data import guess_date_format
+
+
+class BisectAccountingStatements(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ algorithm: DF.Literal["BFS", "DFS"]
+ b_s_summary: DF.Float
+ company: DF.Link | None
+ current_from_date: DF.Datetime | None
+ current_node: DF.Link | None
+ current_to_date: DF.Datetime | None
+ difference: DF.Float
+ from_date: DF.Datetime | None
+ p_l_summary: DF.Float
+ to_date: DF.Datetime | None
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_dates()
+
+ def validate_dates(self):
+ if getdate(self.from_date) > getdate(self.to_date):
+ frappe.throw(
+ _("From Date: {0} cannot be greater than To date: {1}").format(
+ frappe.bold(self.from_date), frappe.bold(self.to_date)
+ )
+ )
+
+ def bfs(self, from_date: datetime, to_date: datetime):
+ # Make Root node
+ node = frappe.new_doc("Bisect Nodes")
+ node.root = None
+ node.period_from_date = from_date
+ node.period_to_date = to_date
+ node.insert()
+
+ period_queue = deque([node])
+ while period_queue:
+ cur_node = period_queue.popleft()
+ delta = cur_node.period_to_date - cur_node.period_from_date
+ if delta.days == 0:
+ continue
+ else:
+ cur_floor = floor(delta.days / 2)
+ next_to_date = cur_node.period_from_date + relativedelta(days=+cur_floor)
+ left_node = frappe.new_doc("Bisect Nodes")
+ left_node.period_from_date = cur_node.period_from_date
+ left_node.period_to_date = next_to_date
+ left_node.root = cur_node.name
+ left_node.generated = False
+ left_node.insert()
+ cur_node.left_child = left_node.name
+ period_queue.append(left_node)
+
+ next_from_date = cur_node.period_from_date + relativedelta(days=+(cur_floor + 1))
+ right_node = frappe.new_doc("Bisect Nodes")
+ right_node.period_from_date = next_from_date
+ right_node.period_to_date = cur_node.period_to_date
+ right_node.root = cur_node.name
+ right_node.generated = False
+ right_node.insert()
+ cur_node.right_child = right_node.name
+ period_queue.append(right_node)
+
+ cur_node.save()
+
+ def dfs(self, from_date: datetime, to_date: datetime):
+ # Make Root node
+ node = frappe.new_doc("Bisect Nodes")
+ node.root = None
+ node.period_from_date = from_date
+ node.period_to_date = to_date
+ node.insert()
+
+ period_stack = [node]
+ while period_stack:
+ cur_node = period_stack.pop()
+ delta = cur_node.period_to_date - cur_node.period_from_date
+ if delta.days == 0:
+ continue
+ else:
+ cur_floor = floor(delta.days / 2)
+ next_to_date = cur_node.period_from_date + relativedelta(days=+cur_floor)
+ left_node = frappe.new_doc("Bisect Nodes")
+ left_node.period_from_date = cur_node.period_from_date
+ left_node.period_to_date = next_to_date
+ left_node.root = cur_node.name
+ left_node.generated = False
+ left_node.insert()
+ cur_node.left_child = left_node.name
+ period_stack.append(left_node)
+
+ next_from_date = cur_node.period_from_date + relativedelta(days=+(cur_floor + 1))
+ right_node = frappe.new_doc("Bisect Nodes")
+ right_node.period_from_date = next_from_date
+ right_node.period_to_date = cur_node.period_to_date
+ right_node.root = cur_node.name
+ right_node.generated = False
+ right_node.insert()
+ cur_node.right_child = right_node.name
+ period_stack.append(right_node)
+
+ cur_node.save()
+
+ @frappe.whitelist()
+ def build_tree(self):
+ frappe.db.delete("Bisect Nodes")
+
+ # Convert str to datetime format
+ dt_format = guess_date_format(self.from_date)
+ from_date = datetime.datetime.strptime(self.from_date, dt_format)
+ to_date = datetime.datetime.strptime(self.to_date, dt_format)
+
+ if self.algorithm == "BFS":
+ self.bfs(from_date, to_date)
+
+ if self.algorithm == "DFS":
+ self.dfs(from_date, to_date)
+
+ # set root as current node
+ root = frappe.db.get_all("Bisect Nodes", filters={"root": ["is", "not set"]})[0]
+ self.get_report_summary()
+ self.current_node = root.name
+ self.current_from_date = self.from_date
+ self.current_to_date = self.to_date
+ self.save()
+
+ def get_report_summary(self):
+ filters = {
+ "company": self.company,
+ "filter_based_on": "Date Range",
+ "period_start_date": self.current_from_date,
+ "period_end_date": self.current_to_date,
+ "periodicity": "Yearly",
+ }
+ pl_summary = frappe.get_doc("Report", "Profit and Loss Statement")
+ self.p_l_summary = pl_summary.execute_script_report(filters=filters)[5]
+ bs_summary = frappe.get_doc("Report", "Balance Sheet")
+ self.b_s_summary = bs_summary.execute_script_report(filters=filters)[5]
+ self.difference = abs(self.p_l_summary - self.b_s_summary)
+
+ def update_node(self):
+ current_node = frappe.get_doc("Bisect Nodes", self.current_node)
+ current_node.balance_sheet_summary = self.b_s_summary
+ current_node.profit_loss_summary = self.p_l_summary
+ current_node.difference = self.difference
+ current_node.generated = True
+ current_node.save()
+
+ def current_node_has_summary_info(self):
+ "Assertion method"
+ return frappe.db.get_value("Bisect Nodes", self.current_node, "generated")
+
+ def fetch_summary_info_from_current_node(self):
+ current_node = frappe.get_doc("Bisect Nodes", self.current_node)
+ self.p_l_summary = current_node.balance_sheet_summary
+ self.b_s_summary = current_node.profit_loss_summary
+ self.difference = abs(self.p_l_summary - self.b_s_summary)
+
+ def fetch_or_calculate(self):
+ if self.current_node_has_summary_info():
+ self.fetch_summary_info_from_current_node()
+ else:
+ self.get_report_summary()
+ self.update_node()
+
+ @frappe.whitelist()
+ def bisect_left(self):
+ if self.current_node is not None:
+ cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
+ if cur_node.left_child is not None:
+ lft_node = frappe.get_doc("Bisect Nodes", cur_node.left_child)
+ self.current_node = cur_node.left_child
+ self.current_from_date = lft_node.period_from_date
+ self.current_to_date = lft_node.period_to_date
+ self.fetch_or_calculate()
+ self.save()
+ else:
+ frappe.msgprint(_("No more children on Left"))
+
+ @frappe.whitelist()
+ def bisect_right(self):
+ if self.current_node is not None:
+ cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
+ if cur_node.right_child is not None:
+ rgt_node = frappe.get_doc("Bisect Nodes", cur_node.right_child)
+ self.current_node = cur_node.right_child
+ self.current_from_date = rgt_node.period_from_date
+ self.current_to_date = rgt_node.period_to_date
+ self.fetch_or_calculate()
+ self.save()
+ else:
+ frappe.msgprint(_("No more children on Right"))
+
+ @frappe.whitelist()
+ def move_up(self):
+ if self.current_node is not None:
+ cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
+ if cur_node.root is not None:
+ root = frappe.get_doc("Bisect Nodes", cur_node.root)
+ self.current_node = cur_node.root
+ self.current_from_date = root.period_from_date
+ self.current_to_date = root.period_to_date
+ self.fetch_or_calculate()
+ self.save()
+ else:
+ frappe.msgprint(_("Reached Root"))
diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/test_bisect_accounting_statements.py b/erpnext/accounts/doctype/bisect_accounting_statements/test_bisect_accounting_statements.py
new file mode 100644
index 00000000000..56ecc94a18e
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_accounting_statements/test_bisect_accounting_statements.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestBisectAccountingStatements(FrappeTestCase):
+ pass
diff --git a/erpnext/accounts/doctype/bisect_nodes/__init__.py b/erpnext/accounts/doctype/bisect_nodes/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.js b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.js
new file mode 100644
index 00000000000..6dea25fc924
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Bisect Nodes", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.json b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.json
new file mode 100644
index 00000000000..03fad261c3c
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.json
@@ -0,0 +1,97 @@
+{
+ "actions": [],
+ "autoname": "autoincrement",
+ "creation": "2023-09-27 14:56:38.112462",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "root",
+ "left_child",
+ "right_child",
+ "period_from_date",
+ "period_to_date",
+ "difference",
+ "balance_sheet_summary",
+ "profit_loss_summary",
+ "generated"
+ ],
+ "fields": [
+ {
+ "fieldname": "root",
+ "fieldtype": "Link",
+ "label": "Root",
+ "options": "Bisect Nodes"
+ },
+ {
+ "fieldname": "left_child",
+ "fieldtype": "Link",
+ "label": "Left Child",
+ "options": "Bisect Nodes"
+ },
+ {
+ "fieldname": "right_child",
+ "fieldtype": "Link",
+ "label": "Right Child",
+ "options": "Bisect Nodes"
+ },
+ {
+ "fieldname": "period_from_date",
+ "fieldtype": "Datetime",
+ "label": "Period_from_date"
+ },
+ {
+ "fieldname": "period_to_date",
+ "fieldtype": "Datetime",
+ "label": "Period To Date"
+ },
+ {
+ "fieldname": "difference",
+ "fieldtype": "Float",
+ "label": "Difference"
+ },
+ {
+ "fieldname": "balance_sheet_summary",
+ "fieldtype": "Float",
+ "label": "Balance Sheet Summary"
+ },
+ {
+ "fieldname": "profit_loss_summary",
+ "fieldtype": "Float",
+ "label": "Profit and Loss Summary"
+ },
+ {
+ "default": "0",
+ "fieldname": "generated",
+ "fieldtype": "Check",
+ "label": "Generated"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2023-12-01 17:46:12.437996",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Bisect Nodes",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Administrator",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "read_only": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.py b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.py
new file mode 100644
index 00000000000..f50776641d0
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_nodes/bisect_nodes.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BisectNodes(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ balance_sheet_summary: DF.Float
+ difference: DF.Float
+ generated: DF.Check
+ left_child: DF.Link | None
+ name: DF.Int | None
+ period_from_date: DF.Datetime | None
+ period_to_date: DF.Datetime | None
+ profit_loss_summary: DF.Float
+ right_child: DF.Link | None
+ root: DF.Link | None
+ # end: auto-generated types
+
+ pass
diff --git a/erpnext/accounts/doctype/bisect_nodes/test_bisect_nodes.py b/erpnext/accounts/doctype/bisect_nodes/test_bisect_nodes.py
new file mode 100644
index 00000000000..5399df139f1
--- /dev/null
+++ b/erpnext/accounts/doctype/bisect_nodes/test_bisect_nodes.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestBisectNodes(FrappeTestCase):
+ pass
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
index d931f627dbd..ad68352c2a4 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
@@ -3,22 +3,36 @@
frappe.ui.form.on("Currency Exchange Settings", {
service_provider: function (frm) {
- if (frm.doc.service_provider == "exchangerate.host") {
- let result = ["result"];
- let params = {
- date: "{transaction_date}",
- from: "{from_currency}",
- to: "{to_currency}",
- };
- add_param(frm, "https://api.exchangerate.host/convert", params, result);
- } else if (frm.doc.service_provider == "frankfurter.app") {
- let result = ["rates", "{to_currency}"];
- let params = {
- base: "{from_currency}",
- symbols: "{to_currency}",
- };
- add_param(frm, "https://frankfurter.app/{transaction_date}", params, result);
- }
+ frm.call({
+ method: "erpnext.accounts.doctype.currency_exchange_settings.currency_exchange_settings.get_api_endpoint",
+ args: {
+ service_provider: frm.doc.service_provider,
+ use_http: frm.doc.use_http,
+ },
+ callback: function (r) {
+ if (r && r.message) {
+ if (frm.doc.service_provider == "exchangerate.host") {
+ let result = ["result"];
+ let params = {
+ date: "{transaction_date}",
+ from: "{from_currency}",
+ to: "{to_currency}",
+ };
+ add_param(frm, r.message, params, result);
+ } else if (frm.doc.service_provider == "frankfurter.app") {
+ let result = ["rates", "{to_currency}"];
+ let params = {
+ base: "{from_currency}",
+ symbols: "{to_currency}",
+ };
+ add_param(frm, r.message, params, result);
+ }
+ }
+ },
+ });
+ },
+ use_http: function (frm) {
+ frm.trigger("service_provider");
},
});
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
index df232a5848c..bd90b8add80 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
@@ -9,6 +9,7 @@
"disabled",
"service_provider",
"api_endpoint",
+ "use_http",
"access_key",
"url",
"column_break_3",
@@ -91,12 +92,19 @@
"fieldname": "access_key",
"fieldtype": "Data",
"label": "Access Key"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.service_provider != \"Custom\"",
+ "fieldname": "use_http",
+ "fieldtype": "Check",
+ "label": "Use HTTP Protocol"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2023-10-04 15:30:25.333860",
+ "modified": "2024-03-18 08:32:26.895076",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
index 117d5ff21e8..7a420f984ad 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
self.set("result_key", [])
self.set("req_params", [])
- self.api_endpoint = "https://api.exchangerate.host/convert"
+ self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "result"})
self.append("req_params", {"key": "access_key", "value": self.access_key})
self.append("req_params", {"key": "amount", "value": "1"})
@@ -40,7 +40,7 @@ class CurrencyExchangeSettings(Document):
self.set("result_key", [])
self.set("req_params", [])
- self.api_endpoint = "https://frankfurter.app/{transaction_date}"
+ self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "rates"})
self.append("result_key", {"key": "{to_currency}"})
self.append("req_params", {"key": "base", "value": "{from_currency}"})
@@ -79,3 +79,19 @@ class CurrencyExchangeSettings(Document):
frappe.throw(_("Returned exchange rate is neither integer not float."))
self.url = response.url
+
+
+@frappe.whitelist()
+def get_api_endpoint(service_provider: str = None, use_http: bool = False):
+ if service_provider and service_provider in ["exchangerate.host", "frankfurter.app"]:
+ if service_provider == "exchangerate.host":
+ api = "api.exchangerate.host/convert"
+ elif service_provider == "frankfurter.app":
+ api = "frankfurter.app/{transaction_date}"
+
+ protocol = "https://"
+ if use_http:
+ protocol = "http://"
+
+ return protocol + api
+ return None
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.json b/erpnext/accounts/doctype/gl_entry/gl_entry.json
index 592eaecc1c5..eb99768b05e 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.json
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.json
@@ -174,7 +174,8 @@
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
"label": "Voucher Detail No",
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "project",
@@ -256,7 +257,8 @@
"icon": "fa fa-list",
"idx": 1,
"in_create": 1,
- "modified": "2020-04-07 16:22:33.766994",
+ "links": [],
+ "modified": "2024-03-19 18:30:49.613401",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",
@@ -288,5 +290,6 @@
"quick_entry": 1,
"search_fields": "voucher_no,account,posting_date,against_voucher",
"sort_field": "modified",
- "sort_order": "DESC"
-}
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index d04f0ac7f3c..e0e8e2154a6 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -350,7 +350,9 @@ class PaymentEntry(AccountsController):
ref_details = get_reference_details(
d.reference_doctype, d.reference_name, self.party_account_currency
)
- if ref_exchange_rate:
+
+ # Only update exchange rate when the reference is Journal Entry
+ if ref_exchange_rate and d.reference_doctype == "Journal Entry":
ref_details.update({"exchange_rate": ref_exchange_rate})
for field, value in ref_details.items():
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index eb4396a5c6f..662077d027e 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -1130,6 +1130,17 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(pr.allocation[0].allocated_amount, 85)
self.assertEqual(pr.allocation[0].difference_amount, 0)
+ pr.reconcile()
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ # No Exchange Gain/Loss journal should be generated
+ exc_gain_loss_journals = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"reference_type": si.doctype, "reference_name": si.name, "docstatus": 1},
+ fields=["parent"],
+ )
+ self.assertEqual(exc_gain_loss_journals, [])
+
def test_reconciliation_purchase_invoice_against_return(self):
self.supplier = "_Test Supplier USD"
pi = self.create_purchase_invoice(qty=5, rate=50, do_not_submit=True)
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 674db6c2e43..88a2ca575ac 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -121,7 +121,8 @@ class PeriodClosingVoucher(AccountsController):
previous_fiscal_year = get_fiscal_year(last_year_closing, company=self.company, boolean=True)
if previous_fiscal_year and not frappe.db.exists(
- "GL Entry", {"posting_date": ("<=", last_year_closing), "company": self.company}
+ "GL Entry",
+ {"posting_date": ("<=", last_year_closing), "company": self.company, "is_cancelled": 0},
):
return
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
index de333cb9e8d..854523f1009 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
@@ -774,7 +774,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1562,7 +1562,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
- "modified": "2023-11-20 12:27:12.848149",
+ "modified": "2024-03-20 16:00:34.268756",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
index 5307ccb1931..81ebf9744c4 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
@@ -89,10 +89,11 @@
- | 30 Days |
- 60 Days |
- 90 Days |
- 120 Days |
+ 0 - 30 Days |
+ 30 - 60 Days |
+ 60 - 90 Days |
+ 90 - 120 Days |
+ Above 120 Days |
@@ -101,6 +102,7 @@
{{ frappe.utils.fmt_money(ageing.range2, currency=filters.presentation_currency) }} |
{{ frappe.utils.fmt_money(ageing.range3, currency=filters.presentation_currency) }} |
{{ frappe.utils.fmt_money(ageing.range4, currency=filters.presentation_currency) }} |
+ {{ frappe.utils.fmt_money(ageing.range5, currency=filters.presentation_currency) }} |
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
index 88dd0113192..6b0ec8e8c85 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
@@ -756,7 +756,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1619,7 +1619,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
- "modified": "2024-03-11 14:46:30.298184",
+ "modified": "2024-03-20 15:57:00.736868",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 4719876f4ec..f54787de717 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -737,7 +737,6 @@ class PurchaseInvoice(BuyingController):
"Company", self.company, "enable_provisional_accounting_for_non_stock_items"
)
)
- self.provisional_enpenses_booked_in_pr = False
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
@@ -982,37 +981,36 @@ class PurchaseInvoice(BuyingController):
fields=["name", "provisional_expense_account", "qty", "base_rate"],
)
default_provisional_account = self.get_company_default("default_provisional_account")
+ provisional_accounts = set(
+ [
+ d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
+ for d in pr_items
+ ]
+ )
+
+ provisional_gl_entries = frappe.get_all(
+ "GL Entry",
+ filters={
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": ("in", linked_purchase_receipts),
+ "account": ("in", provisional_accounts),
+ "is_cancelled": 0,
+ },
+ fields=["voucher_detail_no"],
+ )
+ rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
for item in pr_items:
self.provisional_accounts[item.name] = {
"provisional_account": item.provisional_expense_account or default_provisional_account,
"qty": item.qty,
"base_rate": item.base_rate,
+ "has_provisional_entry": item.name in rows_with_provisional_entries,
}
def make_provisional_gl_entry(self, gl_entries, item):
if item.purchase_receipt:
- if not self.provisional_enpenses_booked_in_pr:
- pr_item = self.provisional_accounts.get(item.pr_detail, {})
- provisional_account = pr_item.get("provisional_account")
- pr_qty = pr_item.get("qty")
- pr_base_rate = pr_item.get("base_rate")
-
- # Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt
- provision_gle_against_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": provisional_account,
- },
- ["name"],
- )
- if provision_gle_against_pr:
- self.provisional_enpenses_booked_in_pr = True
-
- if self.provisional_enpenses_booked_in_pr:
+ pr_item = self.provisional_accounts.get(item.pr_detail, {})
+ if pr_item.get("has_provisional_entry"):
purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
# Intentionally passing purchase invoice item to handle partial billing
@@ -1020,9 +1018,9 @@ class PurchaseInvoice(BuyingController):
item,
gl_entries,
self.posting_date,
- provisional_account,
+ pr_item.get("provisional_account"),
reverse=1,
- item_amount=(min(item.qty, pr_qty) * pr_base_rate),
+ item_amount=(min(item.qty, pr_item.get("qty")) * pr_item.get("base_rate")),
)
def update_gross_purchase_amount_for_linked_assets(self, item):
diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
index 3d59d288e4d..cafdc0e12c6 100644
--- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
+++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
@@ -740,6 +740,7 @@
"fieldtype": "Currency",
"label": "Landed Cost Voucher Amount",
"no_copy": 1,
+ "options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
@@ -893,7 +894,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2023-12-25 22:00:28.043555",
+ "modified": "2024-03-19 19:09:47.210965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",
@@ -903,4 +904,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index 865362fbb48..ddb82d95f9d 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -943,7 +943,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"hide_days": 1,
"hide_seconds": 1,
"label": "Taxes and Charges Calculation",
@@ -2183,7 +2183,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2024-03-15 16:44:17.778370",
+ "modified": "2024-03-20 16:02:52.237732",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
@@ -2238,4 +2238,4 @@
"title_field": "title",
"track_changes": 1,
"track_seen": 1
-}
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/__init__.py b/erpnext/accounts/doctype/transaction_deletion_record_details/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json
new file mode 100644
index 00000000000..fe4b0852ac1
--- /dev/null
+++ b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json
@@ -0,0 +1,58 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2024-02-04 10:53:32.307930",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "doctype_name",
+ "docfield_name",
+ "no_of_docs",
+ "done"
+ ],
+ "fields": [
+ {
+ "fieldname": "doctype_name",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "DocType",
+ "options": "DocType",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "docfield_name",
+ "fieldtype": "Data",
+ "label": "DocField",
+ "read_only": 1
+ },
+ {
+ "fieldname": "no_of_docs",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "No of Docs",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "done",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Done",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2024-02-05 17:35:09.556054",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Transaction Deletion Record Details",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py
new file mode 100644
index 00000000000..bc5b5c41fdd
--- /dev/null
+++ b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class TransactionDeletionRecordDetails(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ docfield_name: DF.Data | None
+ doctype_name: DF.Link
+ done: DF.Check
+ no_of_docs: DF.Int
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py
index b225aac7b56..5d6ca23a6b2 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.py
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py
@@ -97,11 +97,11 @@ def execute(filters=None):
chart = get_chart_data(filters, columns, asset, liability, equity)
- report_summary = get_report_summary(
+ report_summary, primitive_summary = get_report_summary(
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
)
- return columns, data, message, chart, report_summary
+ return columns, data, message, chart, report_summary, primitive_summary
def get_provisional_profit_loss(
@@ -217,7 +217,7 @@ def get_report_summary(
"datatype": "Currency",
"currency": currency,
},
- ]
+ ], (net_asset - net_liability + net_equity)
def get_chart_data(filters, columns, asset, liability, equity):
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index de3d57d095a..187a154a651 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -669,20 +669,20 @@ class GrossProfitGenerator(object):
elif row.sales_order and row.so_detail:
incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code)
if incoming_amount:
- return incoming_amount
+ return flt(row.qty) * incoming_amount
else:
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
- from frappe.query_builder.functions import Sum
+ from frappe.query_builder.functions import Avg
delivery_note_item = frappe.qb.DocType("Delivery Note Item")
query = (
frappe.qb.from_(delivery_note_item)
- .select(Sum(delivery_note_item.incoming_rate * delivery_note_item.stock_qty))
+ .select(Avg(delivery_note_item.incoming_rate))
.where(delivery_note_item.docstatus == 1)
.where(delivery_note_item.item_code == item_code)
.where(delivery_note_item.against_sales_order == sales_order)
@@ -965,7 +965,7 @@ class GrossProfitGenerator(object):
& (sle.is_cancelled == 0)
)
.orderby(sle.item_code)
- .orderby(sle.warehouse, sle.posting_date, sle.posting_time, sle.creation, order=Order.desc)
+ .orderby(sle.warehouse, sle.posting_datetime, sle.creation, order=Order.desc)
.run(as_dict=True)
)
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index 82fe1a0ba12..aa820aa4c73 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -460,3 +460,95 @@ class TestGrossProfit(FrappeTestCase):
}
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
self.assertDictContainsSubset(expected_entry, gp_entry[0])
+
+ def test_different_rates_in_si_and_dn(self):
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+
+ """
+ Test gp calculation when invoice and delivery note differ in qty and aren't connected
+ SO -- INV
+ |
+ DN
+ """
+ se = make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=3,
+ basic_rate=700,
+ do_not_submit=True,
+ )
+ item = se.items[0]
+ se.append(
+ "items",
+ {
+ "item_code": item.item_code,
+ "s_warehouse": item.s_warehouse,
+ "t_warehouse": item.t_warehouse,
+ "qty": 10,
+ "basic_rate": 700,
+ "conversion_factor": item.conversion_factor or 1.0,
+ "transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
+ "serial_no": item.serial_no,
+ "batch_no": item.batch_no,
+ "cost_center": item.cost_center,
+ "expense_account": item.expense_account,
+ },
+ )
+ se = se.save().submit()
+
+ so = make_sales_order(
+ customer=self.customer,
+ company=self.company,
+ warehouse=self.warehouse,
+ item=self.item,
+ rate=800,
+ qty=10,
+ do_not_save=False,
+ do_not_submit=False,
+ )
+
+ from erpnext.selling.doctype.sales_order.sales_order import (
+ make_delivery_note,
+ make_sales_invoice,
+ )
+
+ dn1 = make_delivery_note(so.name)
+ dn1.items[0].qty = 4
+ dn1.items[0].rate = 800
+ dn1.save().submit()
+
+ dn2 = make_delivery_note(so.name)
+ dn2.items[0].qty = 6
+ dn2.items[0].rate = 800
+ dn2.save().submit()
+
+ sinv = make_sales_invoice(so.name)
+ sinv.items[0].qty = 4
+ sinv.items[0].rate = 800
+ sinv.save().submit()
+
+ filters = frappe._dict(
+ company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
+ )
+
+ columns, data = execute(filters=filters)
+ expected_entry = {
+ "parent_invoice": sinv.name,
+ "currency": "INR",
+ "sales_invoice": self.item,
+ "customer": self.customer,
+ "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
+ "item_code": self.item,
+ "item_name": self.item,
+ "warehouse": "Stores - _GP",
+ "qty": 4.0,
+ "avg._selling_rate": 800.0,
+ "valuation_rate": 700.0,
+ "selling_amount": 3200.0,
+ "buying_amount": 2800.0,
+ "gross_profit": 400.0,
+ "gross_profit_%": 12.5,
+ }
+ gp_entry = [x for x in data if x.parent_invoice == sinv.name]
+ self.assertDictContainsSubset(expected_entry, gp_entry[0])
diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
index 66353358a06..002c05c9e3f 100644
--- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
+++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
@@ -66,11 +66,11 @@ def execute(filters=None):
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
- report_summary = get_report_summary(
+ report_summary, primitive_summary = get_report_summary(
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
)
- return columns, data, None, chart, report_summary
+ return columns, data, None, chart, report_summary, primitive_summary
def get_report_summary(
@@ -112,7 +112,7 @@ def get_report_summary(
"datatype": "Currency",
"currency": currency,
},
- ]
+ ], net_profit
def get_net_profit_loss(income, expense, period_list, company, currency=None, consolidated=False):
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 4f1f967f202..6ca4aa2ada6 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -1372,8 +1372,7 @@ def sort_stock_vouchers_by_posting_date(
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
- .orderby(sle.posting_date)
- .orderby(sle.posting_time)
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
).run(as_dict=True)
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json
index 01ce8c33ff9..0230e499f4f 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.json
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.json
@@ -639,7 +639,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1273,7 +1273,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-10-01 20:58:07.851037",
+ "modified": "2024-03-20 16:03:31.611808",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index aa89b81b5ed..f13ceb04a50 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -461,7 +461,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Markdown Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -927,7 +927,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-11-20 11:15:30.083077",
+ "modified": "2024-03-20 16:03:59.069145",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js
index c109abd8146..f7d0d947b61 100644
--- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js
+++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js
@@ -77,7 +77,10 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
fieldname: "group_by",
label: __("Group by"),
fieldtype: "Select",
- options: [__("Group by Supplier"), __("Group by Item")],
+ options: [
+ { label: __("Group by Supplier"), value: "Group by Supplier" },
+ { label: __("Group by Item"), value: "Group by Item" },
+ ],
default: __("Group by Supplier"),
},
{
diff --git a/erpnext/e_commerce/doctype/item_review/test_item_review.py b/erpnext/e_commerce/doctype/item_review/test_item_review.py
index 8a4befc800a..6147a9153e6 100644
--- a/erpnext/e_commerce/doctype/item_review/test_item_review.py
+++ b/erpnext/e_commerce/doctype/item_review/test_item_review.py
@@ -5,6 +5,7 @@ import unittest
import frappe
from frappe.core.doctype.user_permission.test_user_permission import create_user
+from frappe.tests.utils import FrappeTestCase
from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
setup_e_commerce_settings,
@@ -19,7 +20,7 @@ from erpnext.e_commerce.shopping_cart.cart import get_party
from erpnext.stock.doctype.item.test_item import make_item
-class TestItemReview(unittest.TestCase):
+class TestItemReview(FrappeTestCase):
def setUp(self):
item = make_item("Test Mobile Phone")
if not frappe.db.exists("Website Item", {"item_code": "Test Mobile Phone"}):
@@ -29,8 +30,7 @@ class TestItemReview(unittest.TestCase):
frappe.local.shopping_cart_settings = None
def tearDown(self):
- frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete()
- setup_e_commerce_settings({"enable_reviews": 0})
+ frappe.db.rollback()
def test_add_and_get_item_reviews_from_customer(self):
"Add / Get Reviews from a User that is a valid customer (has added to cart or purchased in the past)"
@@ -44,7 +44,7 @@ class TestItemReview(unittest.TestCase):
# post review on "Test Mobile Phone"
try:
- add_item_review(web_item, "Great Product", 3, "Would recommend this product")
+ add_item_review(web_item, "Great Product", 1, "Would recommend this product")
review_name = frappe.db.get_value("Item Review", {"website_item": web_item})
except Exception:
self.fail(f"Error while publishing review for {web_item}")
@@ -52,8 +52,7 @@ class TestItemReview(unittest.TestCase):
review_data = get_item_reviews(web_item, 0, 10)
self.assertEqual(len(review_data.reviews), 1)
- self.assertEqual(review_data.average_rating, 3)
- self.assertEqual(review_data.reviews_per_rating[2], 100)
+ self.assertEqual(review_data.average_rating, 1)
# tear down
frappe.set_user("Administrator")
diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py
index 8eebfdb83af..8dffb4364e1 100644
--- a/erpnext/e_commerce/doctype/website_item/test_website_item.py
+++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py
@@ -24,10 +24,11 @@ WEBITEM_PRICE_TESTS = (
"test_website_item_price_for_guest_user",
)
+from frappe.tests.utils import FrappeTestCase
-class TestWebsiteItem(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
+
+class TestWebsiteItem(FrappeTestCase):
+ def setUp(self):
setup_e_commerce_settings(
{
"company": "_Test Company",
@@ -37,11 +38,6 @@ class TestWebsiteItem(unittest.TestCase):
}
)
- @classmethod
- def tearDownClass(cls):
- frappe.db.rollback()
-
- def setUp(self):
if self._testMethodName in WEBITEM_DESK_TESTS:
make_item(
"Test Web Item",
@@ -75,6 +71,9 @@ class TestWebsiteItem(unittest.TestCase):
customer="_Test Customer",
)
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_index_creation(self):
"Check if index is getting created in db."
from erpnext.e_commerce.doctype.website_item.website_item import on_doctype_update
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 5a4e8539bf6..e328c686f5d 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -299,7 +299,10 @@ period_closing_doctypes = [
doc_events = {
"*": {
- "validate": "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
+ "validate": [
+ "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
+ "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job",
+ ],
},
tuple(period_closing_doctypes): {
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index e281fbc1ec9..03190e7b965 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -978,8 +978,7 @@ def get_valuation_rate(data):
frappe.qb.from_(sle)
.select(sle.valuation_rate)
.where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
- .orderby(sle.posting_date, order=frappe.qb.desc)
- .orderby(sle.posting_time, order=frappe.qb.desc)
+ .orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
index 97f30ef62e9..8d3770805e6 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
@@ -58,7 +58,7 @@ def get_data(filters):
query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")])
data = frappe.get_all(
- "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc", debug=1
+ "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc"
)
res = []
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 9b3cf5d7bc7..e67dad0e8ba 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -274,6 +274,7 @@ erpnext.patches.v14_0.clear_reconciliation_values_from_singles
[post_model_sync]
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
+erpnext.patches.v14_0.update_posting_datetime_and_dropped_indexes
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_healthcare_doctypes
@@ -361,4 +362,4 @@ erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2
erpnext.patches.v14_0.set_maintain_stock_for_bom_item
execute:frappe.db.set_single_value('E Commerce Settings', 'show_actual_qty', 1)
erpnext.patches.v14_0.delete_orphaned_asset_movement_item_records
-erpnext.patches.v14_0.remove_cancelled_asset_capitalization_from_asset
+erpnext.patches.v14_0.remove_cancelled_asset_capitalization_from_asset
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py b/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py
new file mode 100644
index 00000000000..6ec3f842007
--- /dev/null
+++ b/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ frappe.db.sql(
+ """
+ UPDATE `tabStock Ledger Entry`
+ SET posting_datetime = timestamp(posting_date, posting_time)
+ """
+ )
+
+ drop_indexes()
+
+
+def drop_indexes():
+ if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"):
+ return
+
+ frappe.db.sql_ddl("ALTER TABLE `tabStock Ledger Entry` DROP INDEX `posting_sort_index`")
diff --git a/erpnext/public/scss/erpnext.scss b/erpnext/public/scss/erpnext.scss
index 6da8f24cf9b..be3aed1ed0b 100644
--- a/erpnext/public/scss/erpnext.scss
+++ b/erpnext/public/scss/erpnext.scss
@@ -496,3 +496,7 @@ body[data-route="pos"] {
.exercise-col {
padding: 10px;
}
+
+.frappe-control[data-fieldname="other_charges_calculation"] .ql-editor {
+ white-space: normal;
+}
diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json
index 2ffa6a5c120..40fa986951e 100644
--- a/erpnext/selling/doctype/quotation/quotation.json
+++ b/erpnext/selling/doctype/quotation/quotation.json
@@ -556,7 +556,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1072,7 +1072,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
- "modified": "2023-04-14 16:50:44.550098",
+ "modified": "2024-03-20 16:04:21.567847",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 490bd7a9830..09b73878aa2 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -774,7 +774,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"hide_days": 1,
"hide_seconds": 1,
"label": "Taxes and Charges Calculation",
@@ -1631,7 +1631,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-10-18 12:41:54.813462",
+ "modified": "2024-03-20 16:04:43.627183",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 69180d23d29..ee7cb323285 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -2160,6 +2160,40 @@ class TestSalesOrder(FrappeTestCase):
self.assertFalse(row.warehouse == rejected_warehouse)
self.assertTrue(row.warehouse == warehouse)
+ def test_auto_update_price_list(self):
+ item = make_item(
+ "_Test Auto Update Price List Item",
+ )
+
+ frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1)
+ so = make_sales_order(
+ item_code=item.name, currency="USD", qty=1, rate=100, price_list_rate=100, do_not_submit=True
+ )
+ so.save()
+
+ item_price = frappe.db.get_value("Item Price", {"item_code": item.name}, "price_list_rate")
+ self.assertEqual(item_price, 100)
+
+ so = make_sales_order(
+ item_code=item.name, currency="USD", qty=1, rate=200, price_list_rate=100, do_not_submit=True
+ )
+ so.save()
+
+ item_price = frappe.db.get_value("Item Price", {"item_code": item.name}, "price_list_rate")
+ self.assertEqual(item_price, 100)
+
+ frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 1)
+ so = make_sales_order(
+ item_code=item.name, currency="USD", qty=1, rate=200, price_list_rate=200, do_not_submit=True
+ )
+ so.save()
+
+ item_price = frappe.db.get_value("Item Price", {"item_code": item.name}, "price_list_rate")
+ self.assertEqual(item_price, 200)
+
+ frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0)
+ frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
+
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 9797b6ae11f..110d80253d3 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -832,7 +832,8 @@
"label": "Purchase Order",
"options": "Purchase Order",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "column_break_89",
@@ -875,7 +876,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2023-11-24 19:07:17.715231",
+ "modified": "2024-03-21 18:15:56.625005",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
index 53fedbf56cf..96b2c051e72 100644
--- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
+++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
@@ -259,6 +259,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
subject: __(frm.meta.name) + ": " + doc.name,
doctype: doc.doctype,
name: doc.name,
+ content: "",
send_email: 1,
print_format,
sender_full_name: frappe.user.full_name(),
diff --git a/erpnext/setup/demo.py b/erpnext/setup/demo.py
new file mode 100644
index 00000000000..f48402e175b
--- /dev/null
+++ b/erpnext/setup/demo.py
@@ -0,0 +1,228 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+import os
+from random import randint
+
+import frappe
+from frappe import _
+from frappe.utils import add_days, getdate
+
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.utils import get_fiscal_year
+from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
+from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account
+
+
+def setup_demo_data():
+ from frappe.utils.telemetry import capture
+
+ capture("demo_data_creation_started", "erpnext")
+ try:
+ company = create_demo_company()
+ process_masters()
+ make_transactions(company)
+ frappe.cache.delete_keys("bootinfo")
+ frappe.publish_realtime("demo_data_complete")
+ except Exception:
+ frappe.log_error("Failed to create demo data")
+ capture("demo_data_creation_failed", "erpnext", properties={"exception": frappe.get_traceback()})
+ raise
+ capture("demo_data_creation_completed", "erpnext")
+
+
+@frappe.whitelist()
+def clear_demo_data():
+ from frappe.utils.telemetry import capture
+
+ frappe.only_for("System Manager")
+
+ capture("demo_data_erased", "erpnext")
+ try:
+ company = frappe.db.get_single_value("Global Defaults", "demo_company")
+ create_transaction_deletion_record(company)
+ clear_masters()
+ delete_company(company)
+ default_company = frappe.db.get_single_value("Global Defaults", "default_company")
+ frappe.db.set_default("company", default_company)
+ except Exception:
+ frappe.db.rollback()
+ frappe.log_error("Failed to erase demo data")
+ frappe.throw(
+ _("Failed to erase demo data, please delete the demo company manually."),
+ title=_("Could Not Delete Demo Data"),
+ )
+
+
+def create_demo_company():
+ company = frappe.db.get_all("Company")[0].name
+ company_doc = frappe.get_doc("Company", company)
+
+ # Make a dummy company
+ new_company = frappe.new_doc("Company")
+ new_company.company_name = company_doc.company_name + " (Demo)"
+ new_company.abbr = company_doc.abbr + "D"
+ new_company.enable_perpetual_inventory = 1
+ new_company.default_currency = company_doc.default_currency
+ new_company.country = company_doc.country
+ new_company.chart_of_accounts_based_on = "Standard Template"
+ new_company.chart_of_accounts = company_doc.chart_of_accounts
+ new_company.insert()
+
+ # Set Demo Company as default to
+ frappe.db.set_single_value("Global Defaults", "demo_company", new_company.name)
+ frappe.db.set_default("company", new_company.name)
+
+ bank_account = create_bank_account({"company_name": new_company.name})
+ frappe.db.set_value("Company", new_company.name, "default_bank_account", bank_account.name)
+
+ return new_company.name
+
+
+def process_masters():
+ for doctype in frappe.get_hooks("demo_master_doctypes"):
+ data = read_data_file_using_hooks(doctype)
+ if data:
+ for item in json.loads(data):
+ create_demo_record(item)
+
+
+def create_demo_record(doctype):
+ frappe.get_doc(doctype).insert(ignore_permissions=True)
+
+
+def make_transactions(company):
+ frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
+ from erpnext.accounts.utils import FiscalYearError
+
+ try:
+ start_date = get_fiscal_year(date=getdate())[1]
+ except FiscalYearError:
+ # User might have setup fiscal year for previous or upcoming years
+ active_fiscal_years = frappe.db.get_all("Fiscal Year", filters={"disabled": 0}, as_list=1)
+ if active_fiscal_years:
+ start_date = frappe.db.get_value("Fiscal Year", active_fiscal_years[0][0], "year_start_date")
+ else:
+ frappe.throw(_("There are no active Fiscal Years for which Demo Data can be generated."))
+
+ for doctype in frappe.get_hooks("demo_transaction_doctypes"):
+ data = read_data_file_using_hooks(doctype)
+ if data:
+ for item in json.loads(data):
+ create_transaction(item, company, start_date)
+
+ convert_order_to_invoices()
+ frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0)
+
+
+def create_transaction(doctype, company, start_date):
+ document_type = doctype.get("doctype")
+ warehouse = get_warehouse(company)
+
+ if document_type == "Purchase Order":
+ posting_date = get_random_date(start_date, 1, 25)
+ else:
+ posting_date = get_random_date(start_date, 31, 350)
+
+ doctype.update(
+ {
+ "company": company,
+ "set_posting_time": 1,
+ "transaction_date": posting_date,
+ "schedule_date": posting_date,
+ "delivery_date": posting_date,
+ "set_warehouse": warehouse,
+ }
+ )
+
+ doc = frappe.get_doc(doctype)
+ doc.save(ignore_permissions=True)
+ doc.submit()
+
+
+def convert_order_to_invoices():
+ for document in ["Purchase Order", "Sales Order"]:
+ # Keep some orders intentionally unbilled/unpaid
+ for i, order in enumerate(
+ frappe.db.get_all(
+ document, filters={"docstatus": 1}, fields=["name", "transaction_date"], limit=6
+ )
+ ):
+
+ if document == "Purchase Order":
+ invoice = make_purchase_invoice(order.name)
+ elif document == "Sales Order":
+ invoice = make_sales_invoice(order.name)
+
+ invoice.set_posting_time = 1
+ invoice.posting_date = order.transaction_date
+ invoice.due_date = order.transaction_date
+ invoice.bill_date = order.transaction_date
+
+ if invoice.get("payment_schedule"):
+ invoice.payment_schedule[0].due_date = order.transaction_date
+
+ invoice.update_stock = 1
+ invoice.submit()
+
+ if i % 2 != 0:
+ payment = get_payment_entry(invoice.doctype, invoice.name)
+ payment.posting_date = order.transaction_date
+ payment.reference_no = invoice.name
+ payment.submit()
+
+
+def get_random_date(start_date, start_range, end_range):
+ return add_days(start_date, randint(start_range, end_range))
+
+
+def create_transaction_deletion_record(company):
+ transaction_deletion_record = frappe.new_doc("Transaction Deletion Record")
+ transaction_deletion_record.company = company
+ transaction_deletion_record.process_in_single_transaction = True
+ transaction_deletion_record.save(ignore_permissions=True)
+ transaction_deletion_record.submit()
+ transaction_deletion_record.start_deletion_tasks()
+
+
+def clear_masters():
+ for doctype in frappe.get_hooks("demo_master_doctypes")[::-1]:
+ data = read_data_file_using_hooks(doctype)
+ if data:
+ for item in json.loads(data):
+ clear_demo_record(item)
+
+
+def clear_demo_record(document):
+ document_type = document.get("doctype")
+ del document["doctype"]
+
+ valid_columns = frappe.get_meta(document_type).get_valid_columns()
+
+ filters = document
+ for key in list(filters):
+ if key not in valid_columns:
+ filters.pop(key, None)
+
+ doc = frappe.get_doc(document_type, filters)
+ doc.delete(ignore_permissions=True)
+
+
+def delete_company(company):
+ frappe.db.set_single_value("Global Defaults", "demo_company", "")
+ frappe.delete_doc("Company", company, ignore_permissions=True)
+
+
+def read_data_file_using_hooks(doctype):
+ path = os.path.join(os.path.dirname(__file__), "demo_data")
+ with open(os.path.join(path, doctype + ".json"), "r") as f:
+ data = f.read()
+
+ return data
+
+
+def get_warehouse(company):
+ warehouses = frappe.db.get_all("Warehouse", {"company": company, "is_group": 0})
+ return warehouses[randint(0, 3)].name
diff --git a/erpnext/setup/demo_data/item.json b/erpnext/setup/demo_data/item.json
new file mode 100644
index 00000000000..17024341225
--- /dev/null
+++ b/erpnext/setup/demo_data/item.json
@@ -0,0 +1,92 @@
+[
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU001",
+ "item_name": "T-shirt",
+ "valuation_rate": 400.0,
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/1484808/pexels-photo-1484808.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU002",
+ "valuation_rate": 300.0,
+ "item_name": "Laptop",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/3999538/pexels-photo-3999538.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU003",
+ "valuation_rate": 523.0,
+ "item_name": "Book",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/2422178/pexels-photo-2422178.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU004",
+ "valuation_rate": 725.0,
+ "item_name": "Smartphone",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/1647976/pexels-photo-1647976.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU005",
+ "valuation_rate": 222.0,
+ "item_name": "Sneakers",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/1598505/pexels-photo-1598505.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU006",
+ "valuation_rate": 420.0,
+ "item_name": "Coffee Mug",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/585753/pexels-photo-585753.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU007",
+ "valuation_rate": 375.0,
+ "item_name": "Television",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/8059376/pexels-photo-8059376.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU008",
+ "valuation_rate": 333.0,
+ "item_name": "Backpack",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/3731256/pexels-photo-3731256.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU009",
+ "valuation_rate": 700.0,
+ "item_name": "Headphones",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/3587478/pexels-photo-3587478.jpeg"
+ },
+ {
+ "doctype": "Item",
+ "item_group": "Demo Item Group",
+ "item_code": "SKU010",
+ "valuation_rate": 500.0,
+ "item_name": "Camera",
+ "gst_hsn_code": "999512",
+ "image": "https://images.pexels.com/photos/51383/photo-camera-subject-photographer-51383.jpeg"
+ }
+]
diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js
index 23a55487adc..6c237d787bb 100644
--- a/erpnext/setup/doctype/company/company.js
+++ b/erpnext/setup/doctype/company/company.js
@@ -169,43 +169,49 @@ frappe.ui.form.on("Company", {
},
delete_company_transactions: function (frm) {
- frappe.verify_password(function () {
- var d = frappe.prompt(
- {
- fieldtype: "Data",
- fieldname: "company_name",
- label: __("Please enter the company name to confirm"),
- reqd: 1,
- description: __(
- "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone."
- ),
- },
- function (data) {
- if (data.company_name !== frm.doc.name) {
- frappe.msgprint(__("Company name not same"));
- return;
- }
- frappe.call({
- method: "erpnext.setup.doctype.company.company.create_transaction_deletion_request",
- args: {
- company: data.company_name,
- },
- freeze: true,
- callback: function (r, rt) {
- if (!r.exc)
- frappe.msgprint(
- __("Successfully deleted all transactions related to this company!")
- );
- },
- onerror: function () {
- frappe.msgprint(__("Wrong Password"));
- },
+ frappe.call({
+ method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.is_deletion_doc_running",
+ args: {
+ company: frm.doc.name,
+ },
+ freeze: true,
+ callback: function (r) {
+ if (!r.exc) {
+ frappe.verify_password(function () {
+ var d = frappe.prompt(
+ {
+ fieldtype: "Data",
+ fieldname: "company_name",
+ label: __("Please enter the company name to confirm"),
+ reqd: 1,
+ description: __(
+ "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone."
+ ),
+ },
+ function (data) {
+ if (data.company_name !== frm.doc.name) {
+ frappe.msgprint(__("Company name not same"));
+ return;
+ }
+ frappe.call({
+ method: "erpnext.setup.doctype.company.company.create_transaction_deletion_request",
+ args: {
+ company: data.company_name,
+ },
+ freeze: true,
+ callback: function (r, rt) {},
+ onerror: function () {
+ frappe.msgprint(__("Wrong Password"));
+ },
+ });
+ },
+ __("Delete all the Transactions for this Company"),
+ __("Delete")
+ );
+ d.get_primary_btn().addClass("btn-danger");
});
- },
- __("Delete all the Transactions for this Company"),
- __("Delete")
- );
- d.get_primary_btn().addClass("btn-danger");
+ }
+ },
});
},
});
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index bea76f19412..5ee7dbb4b47 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -11,7 +11,7 @@ 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.desk.page.setup_wizard.setup_wizard import make_records
-from frappe.utils import cint, formatdate, get_timestamp, today
+from frappe.utils import cint, formatdate, get_link_to_form, get_timestamp, today
from frappe.utils.nestedset import NestedSet, rebuild_tree
from erpnext.accounts.doctype.account.account import get_account_currency
@@ -812,6 +812,19 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad
@frappe.whitelist()
def create_transaction_deletion_request(company):
+ from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
+ is_deletion_doc_running,
+ )
+
+ is_deletion_doc_running(company)
+
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
- tdr.insert()
tdr.submit()
+ tdr.start_deletion_tasks()
+
+ frappe.msgprint(
+ _("A Transaction Deletion Document: {0} is triggered for {0}").format(
+ get_link_to_form("Transaction Deletion Record", tdr.name)
+ ),
+ frappe.bold(company),
+ )
diff --git a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py
index 319d435ca69..24a12bac9fe 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py
@@ -28,6 +28,7 @@ class TestTransactionDeletionRecord(unittest.TestCase):
for i in range(5):
create_task("Dunder Mifflin Paper Co")
tdr = create_transaction_deletion_request("Dunder Mifflin Paper Co")
+ tdr.reload()
for doctype in tdr.doctypes:
if doctype.doctype_name == "Task":
self.assertEqual(doctype.no_of_docs, 5)
@@ -49,7 +50,9 @@ def create_company(company_name):
def create_transaction_deletion_request(company):
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
tdr.insert()
+ tdr.process_in_single_transaction = True
tdr.submit()
+ tdr.start_deletion_tasks()
return tdr
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js
index 527c753d6a9..9aa02784165 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js
@@ -10,20 +10,24 @@ frappe.ui.form.on("Transaction Deletion Record", {
callback: function (r) {
doctypes_to_be_ignored_array = r.message;
populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm);
- frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false);
frm.refresh_field("doctypes_to_be_ignored");
},
});
}
-
- frm.get_field("doctypes_to_be_ignored").grid.cannot_add_rows = true;
- frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false);
- frm.refresh_field("doctypes_to_be_ignored");
},
refresh: function (frm) {
- frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false);
- frm.refresh_field("doctypes_to_be_ignored");
+ if (frm.doc.docstatus == 1 && ["Queued", "Failed"].find((x) => x == frm.doc.status)) {
+ let execute_btn = frm.doc.status == "Queued" ? __("Start Deletion") : __("Retry");
+
+ frm.add_custom_button(execute_btn, () => {
+ // Entry point for chain of events
+ frm.call({
+ method: "start_deletion_tasks",
+ doc: frm.doc,
+ });
+ });
+ }
},
});
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
index 23e59472a6d..b9f911dbe8c 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
@@ -7,10 +7,21 @@
"engine": "InnoDB",
"field_order": [
"company",
+ "section_break_qpwb",
+ "status",
+ "error_log",
+ "tasks_section",
+ "delete_bin_data",
+ "delete_leads_and_addresses",
+ "reset_company_default_values",
+ "clear_notifications",
+ "initialize_doctypes_table",
+ "delete_transactions",
+ "section_break_tbej",
"doctypes",
"doctypes_to_be_ignored",
"amended_from",
- "status"
+ "process_in_single_transaction"
],
"fields": [
{
@@ -25,14 +36,16 @@
"fieldname": "doctypes",
"fieldtype": "Table",
"label": "Summary",
- "options": "Transaction Deletion Record Item",
+ "no_copy": 1,
+ "options": "Transaction Deletion Record Details",
"read_only": 1
},
{
"fieldname": "doctypes_to_be_ignored",
"fieldtype": "Table",
"label": "Excluded DocTypes",
- "options": "Transaction Deletion Record Item"
+ "options": "Transaction Deletion Record Item",
+ "read_only": 1
},
{
"fieldname": "amended_from",
@@ -46,18 +59,96 @@
{
"fieldname": "status",
"fieldtype": "Select",
- "hidden": 1,
"label": "Status",
- "options": "Draft\nCompleted"
+ "no_copy": 1,
+ "options": "Queued\nRunning\nFailed\nCompleted\nCancelled",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_tbej",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "tasks_section",
+ "fieldtype": "Section Break",
+ "label": "Tasks"
+ },
+ {
+ "default": "0",
+ "fieldname": "delete_bin_data",
+ "fieldtype": "Check",
+ "label": "Delete Bins",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "delete_leads_and_addresses",
+ "fieldtype": "Check",
+ "label": "Delete Leads and Addresses",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "clear_notifications",
+ "fieldtype": "Check",
+ "label": "Clear Notifications",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "reset_company_default_values",
+ "fieldtype": "Check",
+ "label": "Reset Company Default Values",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "delete_transactions",
+ "fieldtype": "Check",
+ "label": "Delete Transactions",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "initialize_doctypes_table",
+ "fieldtype": "Check",
+ "label": "Initialize Summary Table",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: doc.error_log",
+ "fieldname": "error_log",
+ "fieldtype": "Long Text",
+ "label": "Error Log"
+ },
+ {
+ "fieldname": "section_break_qpwb",
+ "fieldtype": "Section Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "process_in_single_transaction",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Process in Single Transaction",
+ "no_copy": 1,
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-08-04 20:15:59.071493",
+ "modified": "2024-03-21 10:29:19.456413",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -76,5 +167,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index 649b43b5e91..db5024bbc19 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -1,18 +1,31 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+from collections import OrderedDict
import frappe
from frappe import _, qb
from frappe.desk.notifications import clear_notifications
from frappe.model.document import Document
-from frappe.utils import cint, create_batch
+from frappe.utils import cint, comma_and, create_batch, get_link_to_form
+from frappe.utils.background_jobs import create_job_id, is_job_enqueued
class TransactionDeletionRecord(Document):
def __init__(self, *args, **kwargs):
super(TransactionDeletionRecord, self).__init__(*args, **kwargs)
self.batch_size = 5000
+ # Tasks are listed by their execution order
+ self.task_to_internal_method_map = OrderedDict(
+ {
+ "Delete Bins": "delete_bins",
+ "Delete Leads and Addresses": "delete_lead_addresses",
+ "Reset Company Values": "reset_company_values",
+ "Clear Notifications": "delete_notifications",
+ "Initialize Summary Table": "initialize_doctypes_to_be_deleted_table",
+ "Delete Transactions": "delete_company_transactions",
+ }
+ )
def validate(self):
frappe.only_for("System Manager")
@@ -29,104 +42,266 @@ class TransactionDeletionRecord(Document):
title=_("Not Allowed"),
)
+ def generate_job_name_for_task(self, task=None):
+ method = self.task_to_internal_method_map[task]
+ return f"{self.name}_{method}"
+
+ def generate_job_name_for_next_tasks(self, task=None):
+ job_names = []
+ current_task_idx = list(self.task_to_internal_method_map).index(task)
+ for idx, task in enumerate(self.task_to_internal_method_map.keys(), 0):
+ # generate job_name for next tasks
+ if idx > current_task_idx:
+ job_names.append(self.generate_job_name_for_task(task))
+ return job_names
+
+ def generate_job_name_for_all_tasks(self):
+ job_names = []
+ for task in self.task_to_internal_method_map.keys():
+ job_names.append(self.generate_job_name_for_task(task))
+ return job_names
+
def before_submit(self):
+ if queued_docs := frappe.db.get_all(
+ "Transaction Deletion Record",
+ filters={"company": self.company, "status": ("in", ["Running", "Queued"]), "docstatus": 1},
+ pluck="name",
+ ):
+ frappe.throw(
+ _(
+ "Cannot enqueue multi docs for one company. {0} is already queued/running for company: {1}"
+ ).format(
+ comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs]),
+ frappe.bold(self.company),
+ )
+ )
+
if not self.doctypes_to_be_ignored:
self.populate_doctypes_to_be_ignored_table()
- self.delete_bins()
- self.delete_lead_addresses()
- self.reset_company_values()
- clear_notifications()
- self.delete_company_transactions()
+ def reset_task_flags(self):
+ self.clear_notifications = 0
+ self.delete_bin_data = 0
+ self.delete_leads_and_addresses = 0
+ self.delete_transactions = 0
+ self.initialize_doctypes_table = 0
+ self.reset_company_default_values = 0
+
+ def before_save(self):
+ self.status = ""
+ self.doctypes.clear()
+ self.reset_task_flags()
+
+ def on_submit(self):
+ self.db_set("status", "Queued")
+
+ def on_cancel(self):
+ self.db_set("status", "Cancelled")
+
+ def enqueue_task(self, task: str | None = None):
+ if task and task in self.task_to_internal_method_map:
+ # make sure that none of next tasks are already running
+ job_names = self.generate_job_name_for_next_tasks(task=task)
+ self.validate_running_task_for_doc(job_names=job_names)
+
+ # Generate Job Id to uniquely identify each task for this document
+ job_id = self.generate_job_name_for_task(task)
+
+ if self.process_in_single_transaction:
+ self.execute_task(task_to_execute=task)
+ else:
+ frappe.enqueue(
+ "frappe.utils.background_jobs.run_doc_method",
+ doctype=self.doctype,
+ name=self.name,
+ doc_method="execute_task",
+ job_id=job_id,
+ queue="long",
+ enqueue_after_commit=True,
+ task_to_execute=task,
+ )
+
+ def execute_task(self, task_to_execute: str | None = None):
+ if task_to_execute:
+ method = self.task_to_internal_method_map[task_to_execute]
+ if task := getattr(self, method, None):
+ try:
+ task()
+ except Exception as err:
+ frappe.db.rollback()
+ traceback = frappe.get_traceback(with_context=True)
+ if traceback:
+ message = "Traceback:
" + traceback
+ frappe.db.set_value(self.doctype, self.name, "error_log", message)
+ frappe.db.set_value(self.doctype, self.name, "status", "Failed")
+
+ def delete_notifications(self):
+ self.validate_doc_status()
+ if not self.clear_notifications:
+ clear_notifications()
+ self.db_set("clear_notifications", 1)
+ self.enqueue_task(task="Initialize Summary Table")
def populate_doctypes_to_be_ignored_table(self):
doctypes_to_be_ignored_list = get_doctypes_to_be_ignored()
for doctype in doctypes_to_be_ignored_list:
self.append("doctypes_to_be_ignored", {"doctype_name": doctype})
- def delete_bins(self):
- frappe.db.sql(
- """delete from `tabBin` where warehouse in
- (select name from tabWarehouse where company=%s)""",
- self.company,
- )
+ def validate_running_task_for_doc(self, job_names: list = None):
+ # at most only one task should be runnning
+ running_tasks = []
+ for x in job_names:
+ if is_job_enqueued(x):
+ running_tasks.append(create_job_id(x))
- def delete_lead_addresses(self):
- """Delete addresses to which leads are linked"""
- leads = frappe.get_all("Lead", filters={"company": self.company})
- leads = ["'%s'" % row.get("name") for row in leads]
- addresses = []
- if leads:
- addresses = frappe.db.sql_list(
- """select parent from `tabDynamic Link` where link_name
- in ({leads})""".format(
- leads=",".join(leads)
+ if running_tasks:
+ frappe.throw(
+ _("{0} is already running for {1}").format(
+ comma_and([get_link_to_form("RQ Job", x) for x in running_tasks]), self.name
)
)
- if addresses:
- addresses = ["%s" % frappe.db.escape(addr) for addr in addresses]
-
- frappe.db.sql(
- """delete from `tabAddress` where name in ({addresses}) and
- name not in (select distinct dl1.parent from `tabDynamic Link` dl1
- inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent
- and dl1.link_doctype<>dl2.link_doctype)""".format(
- addresses=",".join(addresses)
- )
+ def validate_doc_status(self):
+ if self.status != "Running":
+ frappe.throw(
+ _("{0} is not running. Cannot trigger events for this Document").format(
+ get_link_to_form("Transaction Deletion Record", self.name)
)
+ )
- frappe.db.sql(
- """delete from `tabDynamic Link` where link_doctype='Lead'
- and parenttype='Address' and link_name in ({leads})""".format(
+ @frappe.whitelist()
+ def start_deletion_tasks(self):
+ # This method is the entry point for the chain of events that follow
+ self.db_set("status", "Running")
+ self.enqueue_task(task="Delete Bins")
+
+ def delete_bins(self):
+ self.validate_doc_status()
+ if not self.delete_bin_data:
+ frappe.db.sql(
+ """delete from `tabBin` where warehouse in
+ (select name from tabWarehouse where company=%s)""",
+ self.company,
+ )
+ self.db_set("delete_bin_data", 1)
+ self.enqueue_task(task="Delete Leads and Addresses")
+
+ def delete_lead_addresses(self):
+ """Delete addresses to which leads are linked"""
+ self.validate_doc_status()
+ if not self.delete_leads_and_addresses:
+ leads = frappe.get_all("Lead", filters={"company": self.company})
+ leads = ["'%s'" % row.get("name") for row in leads]
+ addresses = []
+ if leads:
+ addresses = frappe.db.sql_list(
+ """select parent from `tabDynamic Link` where link_name
+ in ({leads})""".format(
leads=",".join(leads)
)
)
- frappe.db.sql(
- """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format(
- leads=",".join(leads)
+ if addresses:
+ addresses = ["%s" % frappe.db.escape(addr) for addr in addresses]
+
+ frappe.db.sql(
+ """delete from `tabAddress` where name in ({addresses}) and
+ name not in (select distinct dl1.parent from `tabDynamic Link` dl1
+ inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent
+ and dl1.link_doctype<>dl2.link_doctype)""".format(
+ addresses=",".join(addresses)
+ )
+ )
+
+ frappe.db.sql(
+ """delete from `tabDynamic Link` where link_doctype='Lead'
+ and parenttype='Address' and link_name in ({leads})""".format(
+ leads=",".join(leads)
+ )
+ )
+
+ frappe.db.sql(
+ """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format(
+ leads=",".join(leads)
+ )
)
- )
+ self.db_set("delete_leads_and_addresses", 1)
+ self.enqueue_task(task="Reset Company Values")
def reset_company_values(self):
- company_obj = frappe.get_doc("Company", self.company)
- company_obj.total_monthly_sales = 0
- company_obj.sales_monthly_history = None
- company_obj.save()
+ self.validate_doc_status()
+ if not self.reset_company_default_values:
+ company_obj = frappe.get_doc("Company", self.company)
+ company_obj.total_monthly_sales = 0
+ company_obj.sales_monthly_history = None
+ company_obj.save()
+ self.db_set("reset_company_default_values", 1)
+ self.enqueue_task(task="Clear Notifications")
+
+ def initialize_doctypes_to_be_deleted_table(self):
+ self.validate_doc_status()
+ if not self.initialize_doctypes_table:
+ doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
+ docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
+ tables = self.get_all_child_doctypes()
+ for docfield in docfields:
+ if docfield["parent"] != self.doctype:
+ no_of_docs = self.get_number_of_docs_linked_with_specified_company(
+ docfield["parent"], docfield["fieldname"]
+ )
+ if no_of_docs > 0:
+ # Initialize
+ self.populate_doctypes_table(tables, docfield["parent"], docfield["fieldname"], 0)
+ self.db_set("initialize_doctypes_table", 1)
+ self.enqueue_task(task="Delete Transactions")
def delete_company_transactions(self):
- doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
- docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
+ self.validate_doc_status()
+ if not self.delete_transactions:
+ doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
+ docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
- tables = self.get_all_child_doctypes()
- for docfield in docfields:
- if docfield["parent"] != self.doctype:
- no_of_docs = self.get_number_of_docs_linked_with_specified_company(
- docfield["parent"], docfield["fieldname"]
- )
-
- if no_of_docs > 0:
- self.delete_version_log(docfield["parent"], docfield["fieldname"])
-
- reference_docs = frappe.get_all(
- docfield["parent"], filters={docfield["fieldname"]: self.company}
+ tables = self.get_all_child_doctypes()
+ for docfield in self.doctypes:
+ if docfield.doctype_name != self.doctype and not docfield.done:
+ no_of_docs = self.get_number_of_docs_linked_with_specified_company(
+ docfield.doctype_name, docfield.docfield_name
)
- reference_doc_names = [r.name for r in reference_docs]
+ if no_of_docs > 0:
+ reference_docs = frappe.get_all(
+ docfield.doctype_name, filters={docfield.docfield_name: self.company}, limit=self.batch_size
+ )
+ reference_doc_names = [r.name for r in reference_docs]
- self.delete_communications(docfield["parent"], reference_doc_names)
- self.delete_comments(docfield["parent"], reference_doc_names)
- self.unlink_attachments(docfield["parent"], reference_doc_names)
+ self.delete_version_log(docfield.doctype_name, reference_doc_names)
+ self.delete_communications(docfield.doctype_name, reference_doc_names)
+ self.delete_comments(docfield.doctype_name, reference_doc_names)
+ self.unlink_attachments(docfield.doctype_name, reference_doc_names)
+ self.delete_child_tables(docfield.doctype_name, reference_doc_names)
+ self.delete_docs_linked_with_specified_company(docfield.doctype_name, reference_doc_names)
+ processed = int(docfield.no_of_docs) + len(reference_doc_names)
+ frappe.db.set_value(docfield.doctype, docfield.name, "no_of_docs", processed)
+ else:
+ # reset naming series
+ naming_series = frappe.db.get_value("DocType", docfield.doctype_name, "autoname")
+ if naming_series:
+ if "#" in naming_series:
+ self.update_naming_series(naming_series, docfield.doctype_name)
+ frappe.db.set_value(docfield.doctype, docfield.name, "done", 1)
- self.populate_doctypes_table(tables, docfield["parent"], no_of_docs)
-
- self.delete_child_tables(docfield["parent"], docfield["fieldname"])
- self.delete_docs_linked_with_specified_company(docfield["parent"], docfield["fieldname"])
-
- naming_series = frappe.db.get_value("DocType", docfield["parent"], "autoname")
- if naming_series:
- if "#" in naming_series:
- self.update_naming_series(naming_series, docfield["parent"])
+ pending_doctypes = frappe.db.get_all(
+ "Transaction Deletion Record Details",
+ filters={"parent": self.name, "done": 0},
+ pluck="doctype_name",
+ )
+ if pending_doctypes:
+ # as method is enqueued after commit, calling itself will not make validate_doc_status to throw
+ # recursively call this task to delete all transactions
+ self.enqueue_task(task="Delete Transactions")
+ else:
+ self.db_set("status", "Completed")
+ self.db_set("delete_transactions", 1)
+ self.db_set("error_log", None)
def get_doctypes_to_be_ignored_list(self):
singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name")
@@ -155,25 +330,24 @@ class TransactionDeletionRecord(Document):
def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname):
return frappe.db.count(doctype, {company_fieldname: self.company})
- def populate_doctypes_table(self, tables, doctype, no_of_docs):
+ def populate_doctypes_table(self, tables, doctype, fieldname, no_of_docs):
+ self.flags.ignore_validate_update_after_submit = True
if doctype not in tables:
- self.append("doctypes", {"doctype_name": doctype, "no_of_docs": no_of_docs})
-
- def delete_child_tables(self, doctype, company_fieldname):
- parent_docs_to_be_deleted = frappe.get_all(
- doctype, {company_fieldname: self.company}, pluck="name"
- )
+ self.append(
+ "doctypes", {"doctype_name": doctype, "docfield_name": fieldname, "no_of_docs": no_of_docs}
+ )
+ self.save(ignore_permissions=True)
+ def delete_child_tables(self, doctype, reference_doc_names):
child_tables = frappe.get_all(
"DocField", filters={"fieldtype": "Table", "parent": doctype}, pluck="options"
)
- for batch in create_batch(parent_docs_to_be_deleted, self.batch_size):
- for table in child_tables:
- frappe.db.delete(table, {"parent": ["in", batch]})
+ for table in child_tables:
+ frappe.db.delete(table, {"parent": ["in", reference_doc_names]})
- def delete_docs_linked_with_specified_company(self, doctype, company_fieldname):
- frappe.db.delete(doctype, {company_fieldname: self.company})
+ def delete_docs_linked_with_specified_company(self, doctype, reference_doc_names):
+ frappe.db.delete(doctype, {"name": ("in", reference_doc_names)})
def update_naming_series(self, naming_series, doctype_name):
if "." in naming_series:
@@ -194,17 +368,11 @@ class TransactionDeletionRecord(Document):
frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix))
- def delete_version_log(self, doctype, company_fieldname):
- dt = qb.DocType(doctype)
- names = qb.from_(dt).select(dt.name).where(dt[company_fieldname] == self.company).run(as_list=1)
- names = [x[0] for x in names]
-
- if names:
- versions = qb.DocType("Version")
- for batch in create_batch(names, self.batch_size):
- qb.from_(versions).delete().where(
- (versions.ref_doctype == doctype) & (versions.docname.isin(batch))
- ).run()
+ def delete_version_log(self, doctype, docnames):
+ versions = qb.DocType("Version")
+ qb.from_(versions).delete().where(
+ (versions.ref_doctype == doctype) & (versions.docname.isin(docnames))
+ ).run()
def delete_communications(self, doctype, reference_doc_names):
communications = frappe.get_all(
@@ -276,3 +444,34 @@ def get_doctypes_to_be_ignored():
doctypes_to_be_ignored.extend(frappe.get_hooks("company_data_to_be_ignored") or [])
return doctypes_to_be_ignored
+
+
+@frappe.whitelist()
+def is_deletion_doc_running(company: str | None = None, err_msg: str | None = None):
+ if company:
+ if running_deletion_jobs := frappe.db.get_all(
+ "Transaction Deletion Record",
+ filters={"docstatus": 1, "company": company, "status": "Running"},
+ ):
+ if not err_msg:
+ err_msg = ""
+ frappe.throw(
+ title=_("Deletion in Progress!"),
+ msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format(
+ get_link_to_form("Transaction Deletion Record", running_deletion_jobs[0].name), err_msg
+ ),
+ )
+
+
+def check_for_running_deletion_job(doc, method=None):
+ # Check if DocType has 'company' field
+ df = qb.DocType("DocField")
+ if (
+ not_allowed := qb.from_(df)
+ .select(df.parent)
+ .where((df.fieldname == "company") & (df.parent == doc.doctype))
+ .run()
+ ):
+ is_deletion_doc_running(
+ doc.company, _("Cannot make any transactions until the deletion job is completed")
+ )
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js
index 08a35df2c17..285cb6dd221 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js
@@ -2,11 +2,15 @@
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Transaction Deletion Record"] = {
+ add_fields: ["status"],
get_indicator: function (doc) {
- if (doc.docstatus == 0) {
- return [__("Draft"), "red"];
- } else {
- return [__("Completed"), "green"];
- }
+ let colors = {
+ Queued: "orange",
+ Completed: "green",
+ Running: "blue",
+ Failed: "red",
+ };
+ let status = doc.status;
+ return [__(status), colors[status], "status,=," + status];
},
};
diff --git a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json
index be0be945c4e..89db63694c2 100644
--- a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json
+++ b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json
@@ -5,8 +5,7 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "doctype_name",
- "no_of_docs"
+ "doctype_name"
],
"fields": [
{
@@ -16,18 +15,12 @@
"label": "DocType",
"options": "DocType",
"reqd": 1
- },
- {
- "fieldname": "no_of_docs",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Number of Docs"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-05-08 23:10:46.166744",
+ "modified": "2024-02-04 10:56:27.413691",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record Item",
@@ -35,5 +28,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json
index 5731bda495e..60e69599dae 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.json
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.json
@@ -680,7 +680,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1401,7 +1401,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
- "modified": "2023-12-18 17:19:39.368239",
+ "modified": "2024-03-20 16:05:02.854990",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index b85bfe5036d..25788966780 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -809,7 +809,8 @@
"label": "Purchase Order",
"options": "Purchase Order",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "column_break_82",
@@ -870,7 +871,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2023-11-14 18:37:38.638144",
+ "modified": "2024-03-21 18:15:07.603672",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
index de1263d8f66..28c54dab9b2 100755
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
@@ -649,7 +649,7 @@
},
{
"fieldname": "other_charges_calculation",
- "fieldtype": "Long Text",
+ "fieldtype": "Text Editor",
"label": "Taxes and Charges Calculation",
"no_copy": 1,
"oldfieldtype": "HTML",
@@ -1242,7 +1242,7 @@
"idx": 261,
"is_submittable": 1,
"links": [],
- "modified": "2023-12-18 17:26:41.279663",
+ "modified": "2024-03-20 16:05:31.713453",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 7defbc5bcdf..d4f85b1aa7e 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -3,7 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
-from frappe.utils import add_days, cint, cstr, flt, today
+from frappe.utils import add_days, cint, cstr, flt, nowtime, today
from pypika import functions as fn
import erpnext
@@ -2224,6 +2224,95 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.reload()
self.assertEqual(pr.per_billed, 100)
+ def test_sle_qty_after_transaction(self):
+ item = make_item(
+ "_Test Item Qty After Transaction",
+ properties={"is_stock_item": 1, "valuation_method": "FIFO"},
+ ).name
+
+ posting_date = today()
+ posting_time = nowtime()
+
+ # Step 1: Create Purchase Receipt
+ pr = make_purchase_receipt(
+ item_code=item,
+ qty=1,
+ rate=100,
+ posting_date=posting_date,
+ posting_time=posting_time,
+ do_not_save=1,
+ )
+
+ for i in range(9):
+ pr.append(
+ "items",
+ {
+ "item_code": item,
+ "qty": 1,
+ "rate": 100,
+ "warehouse": pr.items[0].warehouse,
+ "cost_center": pr.items[0].cost_center,
+ "expense_account": pr.items[0].expense_account,
+ "uom": pr.items[0].uom,
+ "stock_uom": pr.items[0].stock_uom,
+ "conversion_factor": pr.items[0].conversion_factor,
+ },
+ )
+
+ self.assertEqual(len(pr.items), 10)
+ pr.save()
+ pr.submit()
+
+ data = frappe.get_all(
+ "Stock Ledger Entry",
+ fields=["qty_after_transaction", "creation", "posting_datetime"],
+ filters={"voucher_no": pr.name, "is_cancelled": 0},
+ order_by="creation",
+ )
+
+ for index, d in enumerate(data):
+ self.assertEqual(d.qty_after_transaction, 1 + index)
+
+ # Step 2: Create Purchase Receipt
+ pr = make_purchase_receipt(
+ item_code=item,
+ qty=1,
+ rate=100,
+ posting_date=posting_date,
+ posting_time=posting_time,
+ do_not_save=1,
+ )
+
+ for i in range(9):
+ pr.append(
+ "items",
+ {
+ "item_code": item,
+ "qty": 1,
+ "rate": 100,
+ "warehouse": pr.items[0].warehouse,
+ "cost_center": pr.items[0].cost_center,
+ "expense_account": pr.items[0].expense_account,
+ "uom": pr.items[0].uom,
+ "stock_uom": pr.items[0].stock_uom,
+ "conversion_factor": pr.items[0].conversion_factor,
+ },
+ )
+
+ self.assertEqual(len(pr.items), 10)
+ pr.save()
+ pr.submit()
+
+ data = frappe.get_all(
+ "Stock Ledger Entry",
+ fields=["qty_after_transaction", "creation", "posting_datetime"],
+ filters={"voucher_no": pr.name, "is_cancelled": 0},
+ order_by="creation",
+ )
+
+ for index, d in enumerate(data):
+ self.assertEqual(d.qty_after_transaction, 11 + index)
+
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 1d7e4da26d5..771dae53864 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -1671,24 +1671,22 @@ class TestStockEntry(FrappeTestCase):
item_code = "Test Negative Item - 001"
item_doc = create_item(item_code=item_code, is_stock_item=1, valuation_rate=10)
- make_stock_entry(
+ se1 = make_stock_entry(
item_code=item_code,
posting_date=add_days(today(), -3),
posting_time="00:00:00",
- purpose="Material Receipt",
+ target="_Test Warehouse - _TC",
qty=10,
to_warehouse="_Test Warehouse - _TC",
- do_not_save=True,
)
- make_stock_entry(
+ se2 = make_stock_entry(
item_code=item_code,
posting_date=today(),
posting_time="00:00:00",
- purpose="Material Receipt",
+ source="_Test Warehouse - _TC",
qty=8,
from_warehouse="_Test Warehouse - _TC",
- do_not_save=True,
)
sr_doc = create_stock_reconciliation(
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
index 0a666b44fbd..835002f0e16 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
@@ -11,6 +11,7 @@
"warehouse",
"posting_date",
"posting_time",
+ "posting_datetime",
"is_adjustment_entry",
"column_break_6",
"voucher_type",
@@ -96,7 +97,6 @@
"oldfieldtype": "Date",
"print_width": "100px",
"read_only": 1,
- "search_index": 1,
"width": "100px"
},
{
@@ -249,7 +249,6 @@
"options": "Company",
"print_width": "150px",
"read_only": 1,
- "search_index": 1,
"width": "150px"
},
{
@@ -316,6 +315,11 @@
"fieldname": "is_adjustment_entry",
"fieldtype": "Check",
"label": "Is Adjustment Entry"
+ },
+ {
+ "fieldname": "posting_datetime",
+ "fieldtype": "Datetime",
+ "label": "Posting Datetime"
}
],
"hide_toolbar": 1,
@@ -324,7 +328,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2024-03-13 09:56:13.021696",
+ "modified": "2024-02-07 09:18:13.999231",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Ledger Entry",
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 9580e83ed95..da4f2c9db80 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -52,6 +52,12 @@ class StockLedgerEntry(Document):
self.validate_with_last_transaction_posting_time()
self.validate_inventory_dimension_negative_stock()
+ def set_posting_datetime(self):
+ from erpnext.stock.utils import get_combine_datetime
+
+ self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
+ self.db_set("posting_datetime", self.posting_datetime)
+
def validate_inventory_dimension_negative_stock(self):
if self.is_cancelled:
return
@@ -122,6 +128,7 @@ class StockLedgerEntry(Document):
return inv_dimension_dict
def on_submit(self):
+ self.set_posting_datetime()
self.check_stock_frozen_date()
self.calculate_batch_qty()
@@ -293,9 +300,7 @@ class StockLedgerEntry(Document):
def on_doctype_update():
- frappe.db.add_index(
- "Stock Ledger Entry", fields=["posting_date", "posting_time"], index_name="posting_sort_index"
- )
frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"])
frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"])
frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse")
+ frappe.db.add_index("Stock Ledger Entry", ["posting_datetime", "creation"])
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index 6c341d9e9ec..6154910c2f1 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -2,6 +2,7 @@
# See license.txt
import json
+import time
from uuid import uuid4
import frappe
@@ -1066,7 +1067,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
frappe.qb.from_(sle)
.select("qty_after_transaction")
.where((sle.item_code == item) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0))
- .orderby(CombineDatetime(sle.posting_date, sle.posting_time))
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
).run(pluck=True)
@@ -1143,6 +1144,89 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
except Exception as e:
self.fail("Double processing of qty for clashing timestamp.")
+ def test_previous_sle_with_clashed_timestamp(self):
+
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ reciept1 = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=100,
+ rate=10,
+ posting_date="2021-01-01",
+ posting_time="02:00:00",
+ )
+
+ time.sleep(3)
+
+ reciept2 = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=5,
+ posting_date="2021-01-01",
+ rate=10,
+ posting_time="02:00:00.1234",
+ )
+
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_no": reciept1.name},
+ fields=["qty_after_transaction", "actual_qty"],
+ )
+ self.assertEqual(sle[0].qty_after_transaction, 100)
+ self.assertEqual(sle[0].actual_qty, 100)
+
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_no": reciept2.name},
+ fields=["qty_after_transaction", "actual_qty"],
+ )
+ self.assertEqual(sle[0].qty_after_transaction, 105)
+ self.assertEqual(sle[0].actual_qty, 5)
+
+ def test_backdated_sle_with_same_timestamp(self):
+
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ reciept1 = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=5,
+ posting_date="2021-01-01",
+ rate=10,
+ posting_time="02:00:00.1234",
+ )
+
+ time.sleep(3)
+
+ # backdated entry with same timestamp but different ms part
+ reciept2 = make_stock_entry(
+ item_code=item,
+ to_warehouse=warehouse,
+ qty=100,
+ rate=10,
+ posting_date="2021-01-01",
+ posting_time="02:00:00",
+ )
+
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_no": reciept1.name},
+ fields=["qty_after_transaction", "actual_qty"],
+ )
+ self.assertEqual(sle[0].qty_after_transaction, 5)
+ self.assertEqual(sle[0].actual_qty, 5)
+
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_no": reciept2.name},
+ fields=["qty_after_transaction", "actual_qty"],
+ )
+ self.assertEqual(sle[0].qty_after_transaction, 105)
+ self.assertEqual(sle[0].actual_qty, 100)
+
@change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
def test_transfer_invariants(self):
"""Extact stock value should be transferred."""
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 831fcac93ce..33abdcb5321 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -854,10 +854,14 @@ def get_price_list_rate(args, item_doc, out=None):
price_list_rate = get_price_list_rate_for(args, item_doc.variant_of)
# insert in database
- if price_list_rate is None:
+ if price_list_rate is None or frappe.db.get_single_value(
+ "Stock Settings", "update_existing_price_list_rate"
+ ):
if args.price_list and args.rate:
insert_item_price(args)
- return out
+
+ if not price_list_rate:
+ return out
out.price_list_rate = (
flt(price_list_rate) * flt(args.plc_conversion_rate) / flt(args.conversion_rate)
diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py
index e4f657ca707..da958a8b0f1 100644
--- a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py
+++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py
@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.query_builder import Field
-from frappe.query_builder.functions import CombineDatetime, Min
+from frappe.query_builder.functions import Min
from frappe.utils import add_days, getdate, today
import erpnext
@@ -75,7 +75,7 @@ def get_data(report_filters):
& (sle.company == report_filters.company)
& (sle.is_cancelled == 0)
)
- .orderby(CombineDatetime(sle.posting_date, sle.posting_time), sle.creation)
+ .orderby(sle.posting_datetime, sle.creation)
).run(as_dict=True)
for d in data:
diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
index 9e75201bd14..dd79e7fcaf5 100644
--- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
+++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
@@ -213,13 +213,11 @@ def get_stock_ledger_entries(filters, items):
query = (
frappe.qb.from_(sle)
- .force_index("posting_sort_index")
.left_join(sle2)
.on(
(sle.item_code == sle2.item_code)
& (sle.warehouse == sle2.warehouse)
- & (sle.posting_date < sle2.posting_date)
- & (sle.posting_time < sle2.posting_time)
+ & (sle.posting_datetime < sle2.posting_datetime)
& (sle.name < sle2.name)
)
.select(sle.item_code, sle.warehouse, sle.qty_after_transaction, sle.company)
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 32a2b302d7b..6b0bbf3f44d 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TypedDict
import frappe
from frappe import _
from frappe.query_builder import Order
-from frappe.query_builder.functions import Coalesce, CombineDatetime
+from frappe.query_builder.functions import Coalesce
from frappe.utils import add_days, cint, date_diff, flt, getdate
from frappe.utils.nestedset import get_descendants_of
@@ -283,7 +283,7 @@ class StockBalanceReport(object):
item_table.item_name,
)
.where((sle.docstatus < 2) & (sle.is_cancelled == 0))
- .orderby(CombineDatetime(sle.posting_date, sle.posting_time))
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
.orderby(sle.actual_qty)
)
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index eeef39641b0..21b90c4b026 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -276,7 +276,7 @@ def get_stock_ledger_entries(filters, items):
frappe.qb.from_(sle)
.select(
sle.item_code,
- CombineDatetime(sle.posting_date, sle.posting_time).as_("date"),
+ sle.posting_datetime.as_("date"),
sle.warehouse,
sle.posting_date,
sle.posting_time,
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index ef1b0cda4ff..96a554de72b 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -7,13 +7,14 @@ from typing import Optional, Set, Tuple
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
-from frappe.query_builder.functions import CombineDatetime, Sum
+from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.utils import (
+ get_combine_datetime,
get_incoming_outgoing_rate_for_cancel,
get_incoming_rate,
get_or_make_bin,
@@ -69,6 +70,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
args = sle_doc.as_dict()
args["allow_zero_valuation_rate"] = sle.get("allow_zero_valuation_rate") or False
+ args["posting_datetime"] = get_combine_datetime(args.posting_date, args.posting_time)
if sle.get("voucher_type") == "Stock Reconciliation":
# preserve previous_qty_after_transaction for qty reposting
@@ -431,12 +433,14 @@ class update_entries_after(object):
self.process_sle(sle)
def get_sle_against_current_voucher(self):
- self.args["time_format"] = "%H:%i:%s"
+ self.args["posting_datetime"] = get_combine_datetime(
+ self.args.posting_date, self.args.posting_time
+ )
return frappe.db.sql(
"""
select
- *, timestamp(posting_date, posting_time) as "timestamp"
+ *, posting_datetime as "timestamp"
from
`tabStock Ledger Entry`
where
@@ -444,8 +448,7 @@ class update_entries_after(object):
and warehouse = %(warehouse)s
and is_cancelled = 0
and (
- posting_date = %(posting_date)s and
- time_format(posting_time, %(time_format)s) = time_format(%(posting_time)s, %(time_format)s)
+ posting_datetime = %(posting_datetime)s
)
order by
creation ASC
@@ -1186,11 +1189,11 @@ class update_entries_after(object):
def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False):
"""get stock ledger entries filtered by specific posting datetime conditions"""
- args["time_format"] = "%H:%i:%s"
if not args.get("posting_date"):
- args["posting_date"] = "1900-01-01"
- if not args.get("posting_time"):
- args["posting_time"] = "00:00"
+ args["posting_datetime"] = "1900-01-01 00:00:00"
+
+ if not args.get("posting_datetime"):
+ args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
voucher_condition = ""
if exclude_current_voucher:
@@ -1199,23 +1202,20 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
sle = frappe.db.sql(
"""
- select *, timestamp(posting_date, posting_time) as "timestamp"
+ select *, posting_datetime as "timestamp"
from `tabStock Ledger Entry`
where item_code = %(item_code)s
and warehouse = %(warehouse)s
and is_cancelled = 0
{voucher_condition}
and (
- posting_date < %(posting_date)s or
- (
- posting_date = %(posting_date)s and
- time_format(posting_time, %(time_format)s) {operator} time_format(%(posting_time)s, %(time_format)s)
- )
+ posting_datetime {operator} %(posting_datetime)s
)
- order by timestamp(posting_date, posting_time) desc, creation desc
+ order by posting_datetime desc, creation desc
limit 1
for update""".format(
- operator=operator, voucher_condition=voucher_condition
+ operator=operator,
+ voucher_condition=voucher_condition,
),
args,
as_dict=1,
@@ -1256,9 +1256,7 @@ def get_stock_ledger_entries(
extra_cond=None,
):
"""get stock ledger entries filtered by specific posting datetime conditions"""
- conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format(
- operator
- )
+ conditions = " and posting_datetime {0} %(posting_datetime)s".format(operator)
if previous_sle.get("warehouse"):
conditions += " and warehouse = %(warehouse)s"
elif previous_sle.get("warehouse_condition"):
@@ -1284,9 +1282,11 @@ def get_stock_ledger_entries(
)
if not previous_sle.get("posting_date"):
- previous_sle["posting_date"] = "1900-01-01"
- if not previous_sle.get("posting_time"):
- previous_sle["posting_time"] = "00:00"
+ previous_sle["posting_datetime"] = "1900-01-01 00:00:00"
+ else:
+ previous_sle["posting_datetime"] = get_combine_datetime(
+ previous_sle["posting_date"], previous_sle["posting_time"]
+ )
if operator in (">", "<=") and previous_sle.get("name"):
conditions += " and name!=%(name)s"
@@ -1299,12 +1299,12 @@ def get_stock_ledger_entries(
return frappe.db.sql(
"""
- select *, timestamp(posting_date, posting_time) as "timestamp"
+ select *, posting_datetime as "timestamp"
from `tabStock Ledger Entry`
where item_code = %%(item_code)s
and is_cancelled = 0
%(conditions)s
- order by timestamp(posting_date, posting_time) %(order)s, creation %(order)s
+ order by posting_datetime %(order)s, creation %(order)s
%(limit)s %(for_update)s"""
% {
"conditions": conditions,
@@ -1330,7 +1330,7 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
"posting_date",
"posting_time",
"voucher_detail_no",
- "timestamp(posting_date, posting_time) as timestamp",
+ "posting_datetime as timestamp",
],
as_dict=1,
)
@@ -1340,15 +1340,18 @@ def get_batch_incoming_rate(
item_code, warehouse, batch_no, posting_date, posting_time, creation=None
):
+ import datetime
+
sle = frappe.qb.DocType("Stock Ledger Entry")
- timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime(
- posting_date, posting_time
- )
+ posting_datetime = get_combine_datetime(posting_date, posting_time)
+ if not creation:
+ posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1)
+
+ timestamp_condition = sle.posting_datetime < posting_datetime
if creation:
timestamp_condition |= (
- CombineDatetime(sle.posting_date, sle.posting_time)
- == CombineDatetime(posting_date, posting_time)
+ sle.posting_datetime == get_combine_datetime(posting_date, posting_time)
) & (sle.creation < creation)
batch_details = (
@@ -1411,7 +1414,7 @@ def get_valuation_rate(
AND valuation_rate >= 0
AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s)
- order by posting_date desc, posting_time desc, name desc limit 1""",
+ order by posting_datetime desc, name desc limit 1""",
(item_code, warehouse, voucher_no, voucher_type),
)
@@ -1472,7 +1475,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
datetime_limit_condition = ""
qty_shift = args.actual_qty
- args["time_format"] = "%H:%i:%s"
+ args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
# find difference/shift in qty caused by stock reconciliation
if args.voucher_type == "Stock Reconciliation":
@@ -1482,8 +1485,6 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail:
detail = next_stock_reco_detail[0]
-
- # add condition to update SLEs before this date & time
datetime_limit_condition = get_datetime_limit_condition(detail)
frappe.db.sql(
@@ -1496,13 +1497,9 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
and voucher_no != %(voucher_no)s
and is_cancelled = 0
and (
- posting_date > %(posting_date)s or
- (
- posting_date = %(posting_date)s and
- time_format(posting_time, %(time_format)s) > time_format(%(posting_time)s, %(time_format)s)
- )
+ posting_datetime > %(posting_datetime)s
)
- {datetime_limit_condition}
+ {datetime_limit_condition}
""",
args,
)
@@ -1557,20 +1554,11 @@ def get_next_stock_reco(kwargs):
& (sle.voucher_no != kwargs.get("voucher_no"))
& (sle.is_cancelled == 0)
& (
- (
- CombineDatetime(sle.posting_date, sle.posting_time)
- > CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
- )
- | (
- (
- CombineDatetime(sle.posting_date, sle.posting_time)
- == CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
- )
- & (sle.creation > kwargs.get("creation"))
- )
+ sle.posting_datetime
+ >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
)
)
- .orderby(CombineDatetime(sle.posting_date, sle.posting_time))
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
.limit(1)
)
@@ -1582,11 +1570,13 @@ def get_next_stock_reco(kwargs):
def get_datetime_limit_condition(detail):
+ posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time)
+
return f"""
and
- (timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}')
+ (posting_datetime < '{posting_datetime}'
or (
- timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}')
+ posting_datetime = '{posting_datetime}'
and creation < '{detail.creation}'
)
)"""
@@ -1659,14 +1649,11 @@ def get_future_sle_with_negative_qty(sle):
(SLE.item_code == sle.item_code)
& (SLE.warehouse == sle.warehouse)
& (SLE.voucher_no != sle.voucher_no)
- & (
- CombineDatetime(SLE.posting_date, SLE.posting_time)
- >= CombineDatetime(sle.posting_date, sle.posting_time)
- )
+ & (SLE.posting_datetime >= get_combine_datetime(sle.posting_date, sle.posting_time))
& (SLE.is_cancelled == 0)
& (SLE.qty_after_transaction < 0)
)
- .orderby(CombineDatetime(SLE.posting_date, SLE.posting_time))
+ .orderby(SLE.posting_datetime)
.limit(1)
)
@@ -1681,20 +1668,20 @@ def get_future_sle_with_negative_batch_qty(args):
"""
with batch_ledger as (
select
- posting_date, posting_time, voucher_type, voucher_no,
- sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
+ posting_date, posting_time, posting_datetime, voucher_type, voucher_no,
+ sum(actual_qty) over (order by posting_datetime, creation) as cumulative_total
from `tabStock Ledger Entry`
where
item_code = %(item_code)s
and warehouse = %(warehouse)s
and batch_no=%(batch_no)s
and is_cancelled = 0
- order by posting_date, posting_time, creation
+ order by posting_datetime, creation
)
select * from batch_ledger
where
cumulative_total < 0.0
- and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
+ and posting_datetime >= %(posting_datetime)s
limit 1
""",
args,
@@ -1746,6 +1733,7 @@ def is_internal_transfer(sle):
def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, voucher_no=None):
table = frappe.qb.DocType("Stock Ledger Entry")
+ posting_datetime = get_combine_datetime(posting_date, posting_time)
query = (
frappe.qb.from_(table)
@@ -1754,10 +1742,7 @@ def get_stock_value_difference(item_code, warehouse, posting_date, posting_time,
(table.is_cancelled == 0)
& (table.item_code == item_code)
& (table.warehouse == warehouse)
- & (
- (table.posting_date < posting_date)
- | ((table.posting_date == posting_date) & (table.posting_time <= posting_time))
- )
+ & (table.posting_datetime <= posting_datetime)
)
)
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 2b57a1be8fa..0c3e15ac487 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -8,7 +8,7 @@ from typing import Dict, Optional
import frappe
from frappe import _
from frappe.query_builder.functions import CombineDatetime, IfNull, Sum
-from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime
+from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime
import erpnext
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
@@ -619,3 +619,18 @@ def _update_item_info(scan_result: Dict[str, Optional[str]]) -> Dict[str, Option
):
scan_result.update(item_info)
return scan_result
+
+
+def get_combine_datetime(posting_date, posting_time):
+ import datetime
+
+ if isinstance(posting_date, str):
+ posting_date = getdate(posting_date)
+
+ if isinstance(posting_time, str):
+ posting_time = get_time(posting_time)
+
+ if isinstance(posting_time, datetime.timedelta):
+ posting_time = (datetime.datetime.min + posting_time).time()
+
+ return datetime.datetime.combine(posting_date, posting_time).replace(microsecond=0)
diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py
index b519617435f..17146e50d67 100644
--- a/erpnext/utilities/bulk_transaction.py
+++ b/erpnext/utilities/bulk_transaction.py
@@ -162,7 +162,7 @@ def create_log(doc_name, e, from_doctype, to_doctype, status, log_date=None, res
transaction_log.from_doctype = from_doctype
transaction_log.to_doctype = to_doctype
transaction_log.retried = restarted
- transaction_log.save()
+ transaction_log.save(ignore_permissions=True)
def show_job_status(fail_count, deserialized_data_count, to_doctype):