From 68338abe07b52479a4235fd4bf3a81c92bba5db6 Mon Sep 17 00:00:00 2001 From: NaviN <118178330+Navin-S-R@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:03:14 +0530 Subject: [PATCH 01/40] fix: merge taxes in purchase receipt when get items from multiple purchase invoices (#51422) * fix: merge taxes in purchase receipt when get items from multiple purchase invoices * fix: make merge tax configurable * chore: follow standard merge taxes method * chore: follow standard merge taxes method (cherry picked from commit 6fde0a6261475927cc75d3daf917b27c88348518) # Conflicts: # erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py --- .../purchase_invoice/purchase_invoice.py | 21 +++++++++++++++++-- erpnext/public/js/utils.js | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8be04ab67a1..c53d72f9eba 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -38,7 +38,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.accounts_controller import validate_account_head +from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( @@ -2083,6 +2083,19 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + def post_parent_process(source_parent, target_parent): + remove_items_with_zero_qty(target_parent) + set_missing_values(source_parent, target_parent) + + def remove_items_with_zero_qty(target_parent): + target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0] + + def set_missing_values(source_parent, target_parent): + target_parent.run_method("set_missing_values") + if args and args.get("merge_taxes"): + merge_taxes(source_parent, target_parent) + target_parent.run_method("calculate_taxes_and_totals") + def update_item(obj, target, source_parent): target.qty = flt(obj.qty) - flt(obj.received_qty) target.received_qty = flt(obj.qty) - flt(obj.received_qty) @@ -2122,7 +2135,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): "postprocess": update_item, "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), }, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, }, target_doc, ) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 5942b34158d..59d2c90dc14 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -979,7 +979,7 @@ erpnext.utils.map_current_doc = function (opts) { if (opts.source_doctype) { let data_fields = []; - if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) { + if (["Purchase Receipt", "Delivery Note", "Purchase Invoice"].includes(opts.source_doctype)) { let target_meta = frappe.get_meta(cur_frm.doc.doctype); if (target_meta.fields.find((f) => f.fieldname === "taxes")) { data_fields.push({ From cacca812ed96076d6b55ccfb54f84534c69169ed Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 3 Feb 2026 20:21:51 +0530 Subject: [PATCH 02/40] fix: rate comparison in stock reco (cherry picked from commit f1b4fe12a2d0e6959ef6ac1b614ffe0e87a51518) --- .../stock/doctype/stock_reconciliation/stock_reconciliation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 8ab9cf210ce..1df367905a0 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -524,6 +524,9 @@ class StockReconciliation(StockController): if abs(difference_amount) > 0: return True + float_precision = frappe.db.get_default("float_precision") or 3 + item_dict["rate"] = flt(item_dict.get("rate"), float_precision) + item.valuation_rate = flt(item.valuation_rate, float_precision) if ( (item.qty is None or item.qty == item_dict.get("qty")) and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) From 195f02063630b8163fe3eba8b5cf72b8ebd4d2e8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 4 Feb 2026 12:02:14 +0530 Subject: [PATCH 03/40] fix: return None instead of 0 if valuation rate is falsy (cherry picked from commit e8d1e9d946ff19f608cd47a77734bbc72248fe56) --- .../stock/doctype/stock_reconciliation/stock_reconciliation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 1df367905a0..90a30ef62e6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -526,7 +526,7 @@ class StockReconciliation(StockController): float_precision = frappe.db.get_default("float_precision") or 3 item_dict["rate"] = flt(item_dict.get("rate"), float_precision) - item.valuation_rate = flt(item.valuation_rate, float_precision) + item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None if ( (item.qty is None or item.qty == item_dict.get("qty")) and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) From 4e910d8a69427e991d564cf6805befbe9310b11c Mon Sep 17 00:00:00 2001 From: elshafei-developer Date: Mon, 12 Jan 2026 06:56:13 +0000 Subject: [PATCH 04/40] fix(gross profit report): translate column Sales Invoice (cherry picked from commit 3e39d131723d36a4a0d310b246495cf8bef57e63) --- erpnext/accounts/report/gross_profit/gross_profit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index d2fe570fa3b..a6dc46cabd9 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -176,7 +176,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ column_names = get_column_names() # to display item as Item Code: Item Name - columns[0] = "Sales Invoice:Link/Item:300" + columns[0]["fieldname"] = "sales_invoice" + columns[0]["options"] = "Item" + columns[0]["width"] = 300 # removing Item Code and Item Name columns supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name") customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name") From f965b352c8817ce685dfb64672127020181e5dcd Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Fri, 16 Jan 2026 00:01:34 +0530 Subject: [PATCH 05/40] fix(manufacturing): handle None value for actual_end_date (cherry picked from commit 16f09141da954f16b9ea232c75df1530f0f8644a) --- .../production_analytics.py | 160 ++++++++---------- 1 file changed, 70 insertions(+), 90 deletions(-) diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.py b/erpnext/manufacturing/report/production_analytics/production_analytics.py index 5c84a2dc8d1..f0fe0e87a78 100644 --- a/erpnext/manufacturing/report/production_analytics/production_analytics.py +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.py @@ -8,6 +8,8 @@ from frappe.utils import getdate, today from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges +WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"] + def execute(filters=None): columns = get_columns(filters) @@ -16,119 +18,97 @@ def execute(filters=None): def get_columns(filters): - columns = [{"label": _("Status"), "fieldname": "Status", "fieldtype": "Data", "width": 140}] - + columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}] ranges = get_period_date_ranges(filters) for _dummy, end_date in ranges: period = get_period(end_date, filters) - columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120}) return columns -def get_periodic_data(filters, entry): - periodic_data = { - "Not Started": {}, - "Overdue": {}, - "Pending": {}, - "Completed": {}, - "Closed": {}, - "Stopped": {}, - } +def get_work_orders(filters): + from_date = filters.get("from_date") + to_date = filters.get("to_date") - ranges = get_period_date_ranges(filters) + WorkOrder = frappe.qb.DocType("Work Order") - for from_date, end_date in ranges: - period = get_period(end_date, filters) - for d in entry: - if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [ - "Draft", - "Submitted", - "Completed", - "Cancelled", - ]: - if d.status in ["Not Started", "Closed", "Stopped"]: - periodic_data = update_periodic_data(periodic_data, d.status, period) - elif getdate(today()) > getdate(d.planned_end_date): - periodic_data = update_periodic_data(periodic_data, "Overdue", period) - elif getdate(today()) < getdate(d.planned_end_date): - periodic_data = update_periodic_data(periodic_data, "Pending", period) - - if ( - getdate(from_date) <= getdate(d.actual_end_date) <= getdate(end_date) - and d.status == "Completed" - ): - periodic_data = update_periodic_data(periodic_data, "Completed", period) - - return periodic_data - - -def update_periodic_data(periodic_data, status, period): - if periodic_data.get(status).get(period): - periodic_data[status][period] += 1 - else: - periodic_data[status][period] = 1 - - return periodic_data + return ( + frappe.qb.from_(WorkOrder) + .select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status) + .where( + (WorkOrder.docstatus == 1) + & (WorkOrder.company == filters.get("company")) + & ( + (WorkOrder.creation.between(from_date, to_date)) + | (WorkOrder.actual_end_date.between(from_date, to_date)) + ) + ) + .run(as_dict=True) + ) def get_data(filters, columns): - data = [] - entry = frappe.get_all( - "Work Order", - fields=[ - "creation", - "actual_end_date", - "planned_end_date", - "status", - ], - filters={"docstatus": 1, "company": filters["company"]}, - ) + ranges = build_ranges(filters) + period_labels = [pd for _fd, _td, pd in ranges] + periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST} + entries = get_work_orders(filters) - periodic_data = get_periodic_data(filters, entry) + for d in entries: + if d.status == "Completed": + if not d.actual_end_date: + continue - labels = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"] - chart_data = get_chart_data(periodic_data, columns) - ranges = get_period_date_ranges(filters) + if period := get_period_for_date(getdate(d.actual_end_date), ranges): + periodic_data["Completed"][period] += 1 + continue - for label in labels: - work = {} - work["Status"] = _(label) - for _dummy, end_date in ranges: - period = get_period(end_date, filters) - if periodic_data.get(label).get(period): - work[scrub(period)] = periodic_data.get(label).get(period) + creation_date = getdate(d.creation) + period = get_period_for_date(creation_date, ranges) + if not period: + continue + + if d.status in ("Not Started", "Closed", "Stopped"): + periodic_data[d.status][period] += 1 + else: + if d.planned_end_date and getdate(today()) > getdate(d.planned_end_date): + periodic_data["Overdue"][period] += 1 else: - work[scrub(period)] = 0.0 - data.append(work) + periodic_data["Pending"][period] += 1 - return data, chart_data + data = [] + for status in WORK_ORDER_STATUS_LIST: + row = {"status": _(status)} + for _fd, _td, pd in ranges: + row[scrub(pd)] = periodic_data[status].get(pd, 0) + data.append(row) + + chart = get_chart_data(periodic_data, columns) + return data, chart + + +def get_period_for_date(date, ranges): + for from_date, to_date, period in ranges: + if from_date <= date <= to_date: + return period + return None + + +def build_ranges(filters): + ranges = [] + for from_date, end_date in get_period_date_ranges(filters): + period = get_period(end_date, filters) + ranges.append((getdate(from_date), getdate(end_date), period)) + return ranges def get_chart_data(periodic_data, columns): labels = [d.get("label") for d in columns[1:]] - not_start, overdue, pending, completed, closed, stopped = [], [], [], [], [], [] datasets = [] + for status in WORK_ORDER_STATUS_LIST: + values = [periodic_data.get(status, {}).get(label, 0) for label in labels] + datasets.append({"name": _(status), "values": values}) - for d in labels: - not_start.append(periodic_data.get("Not Started").get(d)) - overdue.append(periodic_data.get("Overdue").get(d)) - pending.append(periodic_data.get("Pending").get(d)) - completed.append(periodic_data.get("Completed").get(d)) - closed.append(periodic_data.get("Closed").get(d)) - stopped.append(periodic_data.get("Stopped").get(d)) - - datasets.append({"name": _("Not Started"), "values": not_start}) - datasets.append({"name": _("Overdue"), "values": overdue}) - datasets.append({"name": _("Pending"), "values": pending}) - datasets.append({"name": _("Completed"), "values": completed}) - datasets.append({"name": _("Closed"), "values": closed}) - datasets.append({"name": _("Stopped"), "values": stopped}) - - chart = {"data": {"labels": labels, "datasets": datasets}} - chart["type"] = "line" - - return chart + return {"data": {"labels": labels, "datasets": datasets}, "type": "line"} From 99f3a7e4cf1c9996a0afccccd9f0d7daf64ce245 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 4 Feb 2026 12:18:03 +0530 Subject: [PATCH 06/40] fix(manufacturing): fix chart period keys (cherry picked from commit 27091e516881c1749cbaf3101ff53664fcd9d086) --- .../production_analytics.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.py b/erpnext/manufacturing/report/production_analytics/production_analytics.py index f0fe0e87a78..41fd4dd0e82 100644 --- a/erpnext/manufacturing/report/production_analytics/production_analytics.py +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.py @@ -51,7 +51,7 @@ def get_work_orders(filters): def get_data(filters, columns): ranges = build_ranges(filters) - period_labels = [pd for _fd, _td, pd in ranges] + period_labels = [scrub(pd) for _fd, _td, pd in ranges] periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST} entries = get_work_orders(filters) @@ -60,12 +60,12 @@ def get_data(filters, columns): if not d.actual_end_date: continue - if period := get_period_for_date(getdate(d.actual_end_date), ranges): + if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)): periodic_data["Completed"][period] += 1 continue creation_date = getdate(d.creation) - period = get_period_for_date(creation_date, ranges) + period = scrub(get_period_for_date(creation_date, ranges)) if not period: continue @@ -80,8 +80,8 @@ def get_data(filters, columns): data = [] for status in WORK_ORDER_STATUS_LIST: row = {"status": _(status)} - for _fd, _td, pd in ranges: - row[scrub(pd)] = periodic_data[status].get(pd, 0) + for _fd, _td, period in ranges: + row[scrub(period)] = periodic_data[status].get(scrub(period), 0) data.append(row) chart = get_chart_data(periodic_data, columns) @@ -104,11 +104,12 @@ def build_ranges(filters): def get_chart_data(periodic_data, columns): - labels = [d.get("label") for d in columns[1:]] + period_labels = [d.get("label") for d in columns[1:]] + period_fieldnames = [d.get("fieldname") for d in columns[1:]] datasets = [] for status in WORK_ORDER_STATUS_LIST: - values = [periodic_data.get(status, {}).get(label, 0) for label in labels] + values = [periodic_data.get(status, {}).get(fieldname, 0) for fieldname in period_fieldnames] datasets.append({"name": _(status), "values": values}) - return {"data": {"labels": labels, "datasets": datasets}, "type": "line"} + return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"} From fb525fec803240ad979ad6348ad3132075ee4ef7 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 Jan 2026 11:01:55 +0530 Subject: [PATCH 07/40] refactor: scrub http and use https in sales partner (cherry picked from commit 8cf31548f2d1b81cc49c8d7f65adb7b440aefa1e) --- .../setup/doctype/sales_partner/sales_partner.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/sales_partner/sales_partner.py b/erpnext/setup/doctype/sales_partner/sales_partner.py index c6b0b944de7..7b754a6458e 100644 --- a/erpnext/setup/doctype/sales_partner/sales_partner.py +++ b/erpnext/setup/doctype/sales_partner/sales_partner.py @@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator): if not self.route: self.route = "partners/" + self.scrub(self.partner_name) super().validate() - if self.partner_website and not self.partner_website.startswith("http"): - self.partner_website = "http://" + self.partner_website + if self.partner_website: + from urllib.parse import urlsplit, urlunsplit + + # scrub http + parts = urlsplit(self.partner_website) + if not parts.netloc and parts.path: + parts = parts._replace(netloc=parts.path, path="") + if not parts.scheme or parts.scheme == "http": + parts = parts._replace(scheme="https") + + self.partner_website = urlunsplit(parts) def get_context(self, context): address = frappe.db.get_value( From 9efdcf208a7c4d366519182e1ae6d9a1a77dfa24 Mon Sep 17 00:00:00 2001 From: archielister Date: Wed, 4 Feb 2026 12:20:43 +0000 Subject: [PATCH 08/40] fix for obtaining bom_no (cherry picked from commit e4df0a393abc884833bd9844fa94b6168efb11d5) --- .../manufacturing/doctype/production_plan/production_plan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 6173f40fb10..d38da9257ff 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -478,7 +478,7 @@ class ProductionPlan(Document): item_details = get_item_details(data.item_code, throw=False) if self.combine_items: - bom_no = item_details.bom_no + bom_no = item_details.get("bom_no") if data.get("bom_no"): bom_no = data.get("bom_no") From d91cf01970bfb0ee849b821cecec2ed15bd8281d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 Jan 2026 12:04:37 +0530 Subject: [PATCH 09/40] refactor: patch partner_website for old data (cherry picked from commit 8db29b0a81325c2c0fc234b8d218427f1b61618b) # Conflicts: # erpnext/patches.txt --- erpnext/patches.txt | 1 + .../v15_0/replace_http_with_https_in_sales_partner.py | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7ded266c62a..5c7102b1ad7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -429,3 +429,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11 erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges erpnext.patches.v16_0.set_ordered_qty_in_quotation_item +erpnext.patches.v15_0.replace_http_with_https_in_sales_partner diff --git a/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py b/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py new file mode 100644 index 00000000000..80bc418920a --- /dev/null +++ b/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py @@ -0,0 +1,10 @@ +import frappe +from frappe import qb +from pypika.functions import Replace + + +def execute(): + sp = frappe.qb.DocType("Sales Partner") + qb.update(sp).set(sp.partner_website, Replace(sp.partner_website, "http://", "https://")).where( + sp.partner_website.rlike("^http://.*") + ).run() From bda7220b701fe35abc8704a69f250b0fac19da63 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 5 Feb 2026 14:53:17 +0530 Subject: [PATCH 10/40] fix: stock balance report issue --- erpnext/stock/report/stock_balance/stock_balance.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 913a31df1a7..5ce641eb210 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -198,10 +198,8 @@ class StockBalanceReport: for field in self.inventory_dimensions: qty_dict[field] = entry.get(field) - if ( - entry.voucher_type == "Stock Reconciliation" - and frappe.get_cached_value(entry.voucher_type, entry.voucher_no, "purpose") != "Opening Stock" - and (not entry.batch_no or entry.serial_no) + if entry.voucher_type == "Stock Reconciliation" and ( + not entry.batch_no and not entry.serial_no and not entry.serial_and_batch_bundle ): qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) else: From a8f05cadea5b744d1f687345ac2623e301c9921d Mon Sep 17 00:00:00 2001 From: Nishka Gosalia Date: Fri, 30 Jan 2026 12:23:38 +0530 Subject: [PATCH 11/40] fix: enabling skip delivery option for order type maintenance (cherry picked from commit 1a22e3cb6168c6e1850b7799ee6200c129a863d0) # Conflicts: # erpnext/selling/doctype/sales_order/sales_order.json # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../doctype/sales_order/sales_order.json | 6 ++++- .../doctype/sales_order/test_sales_order.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 1542721d117..c2667d2eb21 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1484,9 +1484,9 @@ }, { "default": "0", + "depends_on": "eval:doc.order_type == 'Maintenance';", "fieldname": "skip_delivery_note", "fieldtype": "Check", - "hidden": 1, "hide_days": 1, "hide_seconds": 1, "label": "Skip Delivery Note", @@ -1671,7 +1671,11 @@ "idx": 105, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2025-07-28 12:14:29.760988", +======= + "modified": "2026-02-06 11:06:16.092658", +>>>>>>> 1a22e3cb61 (fix: enabling skip delivery option for order type maintenance) "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 13759d0f7f7..8a82e79faae 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -57,7 +57,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): def tearDown(self): frappe.set_user("Administrator") +<<<<<<< HEAD @change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) +======= + def test_sales_order_skip_delivery_note(self): + so = make_sales_order(do_not_submit=True) + so.order_type = "Maintenance" + so.skip_delivery_note = 1 + so.append( + "items", + { + "item_code": "_Test Item 2", + "qty": 2, + "rate": 100, + }, + ) + so.save() + so.submit() + + so.reload() + si = make_sales_invoice(so.name) + si.insert() + si.submit() + so.reload() + self.assertEqual(so.status, "Completed") + + @IntegrationTestCase.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) +>>>>>>> 1a22e3cb61 (fix: enabling skip delivery option for order type maintenance) def test_sales_order_with_negative_rate(self): """ Test if negative rate is allowed in Sales Order via doc submission and update items From 740dd878e9626e537aca3a21fa160fa27af819df Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 6 Feb 2026 12:12:24 +0530 Subject: [PATCH 12/40] chore: resolve conflicts --- erpnext/selling/doctype/sales_order/test_sales_order.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 8a82e79faae..d38473e4b69 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -57,9 +57,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): def tearDown(self): frappe.set_user("Administrator") -<<<<<<< HEAD - @change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) -======= def test_sales_order_skip_delivery_note(self): so = make_sales_order(do_not_submit=True) so.order_type = "Maintenance" @@ -82,8 +79,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so.reload() self.assertEqual(so.status, "Completed") - @IntegrationTestCase.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) ->>>>>>> 1a22e3cb61 (fix: enabling skip delivery option for order type maintenance) + @change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) def test_sales_order_with_negative_rate(self): """ Test if negative rate is allowed in Sales Order via doc submission and update items From 292f17b1b0d2c65e93f7410df5fd96072f1ef1c6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 6 Feb 2026 12:12:59 +0530 Subject: [PATCH 13/40] chore: resolve conflicts --- erpnext/selling/doctype/sales_order/sales_order.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index c2667d2eb21..4bbdb20d311 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1671,11 +1671,7 @@ "idx": 105, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2025-07-28 12:14:29.760988", -======= "modified": "2026-02-06 11:06:16.092658", ->>>>>>> 1a22e3cb61 (fix: enabling skip delivery option for order type maintenance) "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", From 29b35494da07a96ca8d897d4b8f48704b3e3aed6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:46:54 +0000 Subject: [PATCH 14/40] Merge pull request #52483 from frappe/mergify/bp/version-15-hotfix/pr-52475 fix: do not show update stock flag unneccessarily (backport #52475) --- .../accounts/doctype/purchase_invoice/purchase_invoice.json | 3 ++- erpnext/accounts/doctype/sales_invoice/sales_invoice.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index ca31f1de4ec..efaeff12279 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -603,6 +603,7 @@ }, { "default": "0", + "depends_on": "eval:doc.items.every((item) => !item.pr_detail)", "fieldname": "update_stock", "fieldtype": "Check", "label": "Update Stock", @@ -1659,7 +1660,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2025-08-04 19:19:11.380664", + "modified": "2026-02-05 20:45:16.964500", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 8cc9598d915..8f04f173c1f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -701,6 +701,7 @@ }, { "default": "0", + "depends_on": "eval:doc.items.every((item) => !item.dn_detail)", "fieldname": "update_stock", "fieldtype": "Check", "hide_days": 1, @@ -2199,7 +2200,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-09-09 14:48:59.472826", + "modified": "2026-02-05 20:43:44.732805", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", From 9cfd704eef992bcdea2ad902aae1e541b78cdc86 Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Wed, 4 Feb 2026 15:19:56 +0530 Subject: [PATCH 15/40] fix(stock): update target field attribute (cherry picked from commit 7e081542177d47db795b7497b93548761ad2e4fb) --- .../stock/doctype/inventory_dimension/inventory_dimension.py | 2 +- .../doctype/inventory_dimension/test_inventory_dimension.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 83854ad53bb..7349838e816 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -138,7 +138,7 @@ class InventoryDimension(Document): self.source_fieldname = scrub(self.dimension_name) if not self.target_fieldname: - self.target_fieldname = scrub(self.reference_document) + self.target_fieldname = scrub(self.dimension_name) def on_update(self): self.add_custom_fields() diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index bfa1d8821ad..96088db1923 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -118,12 +118,12 @@ class TestInventoryDimension(FrappeTestCase): inward.load_from_db() sle_data = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1 + "Stock Ledger Entry", {"voucher_no": inward.name}, ["to_shelf", "warehouse"], as_dict=1 ) self.assertEqual(inward.items[0].to_shelf, "Shelf 1") self.assertEqual(sle_data.warehouse, warehouse) - self.assertEqual(sle_data.shelf, "Shelf 1") + self.assertEqual(sle_data.to_shelf, "Shelf 1") outward = make_stock_entry( item_code=item_code, From d0a8639a2d697a8222673ad0cb0cd9341347808d Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Wed, 4 Feb 2026 22:10:22 +0530 Subject: [PATCH 16/40] test(stock): testcase for different inventory dimension (cherry picked from commit 21d0ee8db15625799ed8c8dc95d80cff3eef7012) --- .../test_stock_reconciliation.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 63228e5a764..4e81c65a58d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1698,6 +1698,101 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(docstatus, 2) + def test_stock_reco_with_opening_stock_with_diff_inventory(self): + from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import ( + create_inventory_dimension, + ) + + if frappe.db.exists("DocType", "Plant"): + return + + doctype = frappe.get_doc( + { + "doctype": "DocType", + "name": "Plant", + "module": "Stock", + "custom": 1, + "fields": [ + { + "fieldname": "plant_name", + "fieldtype": "Data", + "label": "Plant Name", + "reqd": 1, + } + ], + "autoname": "field:plant_name", + } + ) + doctype.insert(ignore_permissions=True) + create_inventory_dimension(dimension_name="ID-Plant", reference_document="Plant") + + plant_a = frappe.get_doc( + { + "doctype": "Plant", + "plant_name": "Plant A", + } + ).insert(ignore_permissions=True) + + plant_b = frappe.get_doc( + { + "doctype": "Plant", + "plant_name": "Plant B", + } + ).insert(ignore_permissions=True) + + warehouse = "_Test Warehouse - _TC" + + item_code = "Item-Test" + item = self.make_item(item_code, {"is_stock_item": 1}) + + sr = frappe.new_doc("Stock Reconciliation") + sr.purpose = "Opening Stock" + sr.posting_date = nowdate() + sr.posting_time = nowtime() + sr.company = "_Test Company" + + sr.append( + "items", + { + "item_code": item.name, + "warehouse": warehouse, + "qty": 5, + "valuation_rate": 100, + "id_plant": plant_a.name, + }, + ) + + sr.append( + "items", + { + "item_code": item.name, + "warehouse": warehouse, + "qty": 3, + "valuation_rate": 110, + "id_plant": plant_b.name, + }, + ) + + sr.insert() + sr.submit() + + self.assertEqual(len(sr.items), 2) + sle_count = frappe.db.count( + "Stock Ledger Entry", + {"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0}, + ) + self.assertEqual(sle_count, 2) + sle = frappe.get_all( + "Stock Ledger Entry", + {"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0}, + ["item_code", "id_plant", "actual_qty", "valuation_rate"], + ) + for s in sle: + if s.id_plant == plant_a.name: + self.assertEqual(s.actual_qty, 5) + elif s.id_plant == plant_b.name: + self.assertEqual(s.actual_qty, 3) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) From 436cb8dbfc2dca2be6abe7021b1a7bff21b447a7 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Fri, 6 Feb 2026 13:12:04 +0530 Subject: [PATCH 17/40] fix(buying): add supplier group link filters in field level (cherry picked from commit cfdc554a19158ede2d1b7b4fff80d2cebdc166f6) # Conflicts: # erpnext/buying/doctype/supplier/supplier.json --- erpnext/buying/doctype/supplier/supplier.js | 8 -------- erpnext/buying/doctype/supplier/supplier.json | 5 +++++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index e04ac2ce302..a27244d1528 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -139,14 +139,6 @@ frappe.ui.form.on("Supplier", { // indicators erpnext.utils.set_party_dashboard_indicators(frm); } - - frm.set_query("supplier_group", () => { - return { - filters: { - is_group: 0, - }, - }; - }); }, get_supplier_group_details: function (frm) { frappe.call({ diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index c70b3e4081a..5516f7740f5 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -165,6 +165,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Supplier Group", + "link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]", "oldfieldname": "supplier_type", "oldfieldtype": "Link", "options": "Supplier Group" @@ -485,7 +486,11 @@ "link_fieldname": "party" } ], +<<<<<<< HEAD "modified": "2024-05-08 18:02:57.342931", +======= + "modified": "2026-02-06 12:58:01.398824", +>>>>>>> cfdc554a19 (fix(buying): add supplier group link filters in field level) "modified_by": "Administrator", "module": "Buying", "name": "Supplier", From 5b7ee0af661131cd4a3959b498fbe59924e568bd Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Fri, 6 Feb 2026 13:13:44 +0530 Subject: [PATCH 18/40] fix(stock): add is group filter for warehouse fields (cherry picked from commit a9829f5f7b3f3efef57f4c96f67a09b5a3af7f4a) --- .../material_request/material_request.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 978a7b41d69..89dba460809 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", { frm.set_query("from_warehouse", "items", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); @@ -62,19 +65,28 @@ frappe.ui.form.on("Material Request", { frm.set_query("warehouse", "items", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); frm.set_query("set_warehouse", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); frm.set_query("set_from_warehouse", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); From 66a48236402bb87d2b4c3a7fe60bd1776c0c30cd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 7 Feb 2026 21:45:35 +0530 Subject: [PATCH 19/40] chore: resolve conflicts --- erpnext/buying/doctype/supplier/supplier.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index 5516f7740f5..ee3c9c840f0 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -486,11 +486,7 @@ "link_fieldname": "party" } ], -<<<<<<< HEAD - "modified": "2024-05-08 18:02:57.342931", -======= "modified": "2026-02-06 12:58:01.398824", ->>>>>>> cfdc554a19 (fix(buying): add supplier group link filters in field level) "modified_by": "Administrator", "module": "Buying", "name": "Supplier", @@ -556,4 +552,4 @@ "states": [], "title_field": "supplier_name", "track_changes": 1 -} \ No newline at end of file +} From 635a4218078d9353a251bee7917d606fca857aba Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 7 Feb 2026 11:16:35 +0530 Subject: [PATCH 20/40] fix(stock): ignore pos reserved batches for stock levels (cherry picked from commit 277ba9cb794b1a979a9b3a59208f56679c20faf3) --- .../serial_and_batch_bundle/serial_and_batch_bundle.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index f51254784d9..b7164b05464 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2546,7 +2546,10 @@ def get_auto_batch_nos(kwargs): qty = flt(kwargs.qty) stock_ledgers_batches = get_stock_ledgers_batches(kwargs) - pos_invoice_batches = get_reserved_batches_for_pos(kwargs) + + pos_invoice_batches = frappe._dict() + if not kwargs.for_stock_levels: + pos_invoice_batches = get_reserved_batches_for_pos(kwargs) sre_reserved_batches = frappe._dict() if not kwargs.ignore_reserved_stock: From aedab5c2101056874bd370906e7183a34372e95d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 7 Feb 2026 11:18:06 +0530 Subject: [PATCH 21/40] test(stock): add test to ignore pos reserved batches for stock levels (cherry picked from commit 47ac67f7a23fdc0e2bc29409e4d49080cf2c3c32) --- erpnext/stock/doctype/batch/test_batch.py | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 83efc12a7d8..65a2420693f 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -129,6 +129,80 @@ class TestBatch(FrappeTestCase): for d in batches: self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)]) + def test_batch_qty_on_pos_creation(self): + from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import ( + init_user_and_profile, + ) + from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice + from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_batch_nos, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_batch_item_with_batch, + ) + + invoice_type = frappe.db.get_single_value("POS Settings", "invoice_type") + session_user = frappe.session.user + + try: + # Set invoice type to POS Invoice + frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice") + + # Create batch item + create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02") + + # Create stock entry + se = make_stock_entry( + target="_Test Warehouse - _TC", + item_code="_Test BATCH ITEM", + qty=30, + basic_rate=100, + ) + + se.reload() + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + # Create opening entry + session_user = frappe.session.user + test_user, pos_profile = init_user_and_profile() + create_opening_entry(pos_profile, test_user.name) + + # POS Invoice 1, for the batch without bundle + pos_inv1 = create_pos_invoice(item="_Test BATCH ITEM", rate=300, qty=15, do_not_save=1) + pos_inv1.append( + "payments", + {"mode_of_payment": "Cash", "amount": 4500}, + ) + pos_inv1.items[0].batch_no = batch_no + pos_inv1.save() + pos_inv1.submit() + pos_inv1.reload() + + # Get auto batch nos after pos invoice + batches = get_auto_batch_nos( + frappe._dict( + { + "item_code": "_Test BATCH ITEM", + "warehouse": "_Test Warehouse - _TC", + "for_stock_levels": True, + "ignore_reserved_stock": True, + } + ) + ) + + # Check batch qty after pos invoice + row = _find_batch_row(batches, batch_no, "_Test Warehouse - _TC") + self.assertIsNotNone(row) + self.assertEqual(row.qty, 30) + + finally: + # Set invoice type to Sales Invoice + frappe.db.set_single_value("POS Settings", "invoice_type", invoice_type) + # Set user to session user + frappe.set_user(session_user) + def test_stock_entry_incoming(self): """Test batch creation via Stock Entry (Work Order)""" @@ -624,6 +698,10 @@ def create_price_list_for_batch(item_code, batch, rate): ).insert() +def _find_batch_row(batches, batch_no, warehouse): + return next((b for b in batches if b.batch_no == batch_no and b.warehouse == warehouse), None) + + def make_new_batch(**args): args = frappe._dict(args) From ad92c021f73449702f9d8ea2c27c0ab88aa51f10 Mon Sep 17 00:00:00 2001 From: ravibharathi656 Date: Fri, 6 Feb 2026 15:48:46 +0530 Subject: [PATCH 22/40] fix(quotation): ignore zero ordered_qty (cherry picked from commit 32ea37035e36e0862fda165ebceb3b9331b9e861) --- erpnext/selling/doctype/quotation/quotation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 3f30664e39b..f41116203ba 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -617,6 +617,9 @@ def handle_mandatory_error(e, customer, lead_name): def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( - "Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True + "Quotation Item", + {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)}, + ["name", "ordered_qty"], + as_list=True, ) ) From 4c094c3d8614d7d49cbdbfad3d9b97317fda39df Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 9 Feb 2026 15:04:19 +0530 Subject: [PATCH 23/40] feat: allow negative stock for the batch item (cherry picked from commit 376ab0e34649d8d93fc9001dfb4f1f702b42476d) # Conflicts: # erpnext/stock/doctype/stock_settings/stock_settings.json --- .../serial_and_batch_bundle.py | 3 +++ .../stock/doctype/stock_settings/stock_settings.json | 12 ++++++++++++ .../stock/doctype/stock_settings/stock_settings.py | 1 + 3 files changed, 16 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index f51254784d9..a0091bb7c6f 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1429,6 +1429,9 @@ class SerialandBatchBundle(Document): def throw_negative_batch(self, batch_no, available_qty, precision): from erpnext.stock.stock_ledger import NegativeStockError + if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"): + return + frappe.throw( _( """ diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index d61c8f575cb..ed1417ad6cb 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -47,6 +47,7 @@ "disable_serial_no_and_batch_selector", "use_serial_batch_fields", "do_not_update_serial_batch_on_creation_of_auto_bundle", + "allow_negative_stock_for_batch", "serial_and_batch_bundle_section", "set_serial_and_batch_bundle_naming_based_on_naming_series", "section_break_gnhq", @@ -538,6 +539,13 @@ "fieldname": "validate_material_transfer_warehouses", "fieldtype": "Check", "label": "Validate Material Transfer Warehouses" + }, + { + "default": "0", + "description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.", + "fieldname": "allow_negative_stock_for_batch", + "fieldtype": "Check", + "label": "Allow Negative Stock for Batch" } ], "icon": "icon-cog", @@ -545,7 +553,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2025-11-11 11:35:39.864923", +======= + "modified": "2026-02-09 15:01:12.466175", +>>>>>>> 376ab0e346 (feat: allow negative stock for the batch item) "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 268d1570b08..0ea1738b60e 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -30,6 +30,7 @@ class StockSettings(Document): allow_from_pr: DF.Check allow_internal_transfer_at_arms_length_price: DF.Check allow_negative_stock: DF.Check + allow_negative_stock_for_batch: DF.Check allow_partial_reservation: DF.Check allow_to_edit_stock_uom_qty_for_purchase: DF.Check allow_to_edit_stock_uom_qty_for_sales: DF.Check From 7f69556c45963f51a8f9494436edff67d437e495 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 9 Feb 2026 16:31:33 +0530 Subject: [PATCH 24/40] chore: fix conflicts --- erpnext/stock/doctype/stock_settings/stock_settings.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ed1417ad6cb..9e6eb8d6f2d 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -553,11 +553,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2025-11-11 11:35:39.864923", -======= "modified": "2026-02-09 15:01:12.466175", ->>>>>>> 376ab0e346 (feat: allow negative stock for the batch item) "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From b034f3d3db8947bf5e9f89a3bb625b5759a44581 Mon Sep 17 00:00:00 2001 From: Trusted Computer <75872475+trustedcomputer@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:15:56 -0800 Subject: [PATCH 25/40] fix: remove incorrect validation from email digest throwing spurious error (backport #51827) (#52582) --- erpnext/setup/doctype/email_digest/email_digest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index c00b947b295..ea25bd033b9 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -162,8 +162,6 @@ class EmailDigest(Document): context.purchase_order_list, context.purchase_orders_items_overdue_list, ) = self.get_purchase_orders_items_overdue_list() - if not context.purchase_order_list: - frappe.throw(_("No items to be received are overdue")) if not context: return None From eea8cb58853c6baae6cbddf75319496d30305352 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:02:23 +0530 Subject: [PATCH 26/40] fix: validate asset movement transaction date (backport #52340) (#52560) * fix: validate asset movement transaction date (#52340) * fix: validate asset transaction date * fix: validate asset transaction date * fix: add translation in validate_transaction_date * test: test_movement_transaction_date * fix: to ensure test reliability (cherry picked from commit e98b68c38f303dda754b3139f247e3865a423a40) # Conflicts: # erpnext/assets/doctype/asset_movement/test_asset_movement.py * chore: fix conflicts Removed unused imports and cleaned up code. --------- Co-authored-by: Poojashree T R <159940572+22-poojashree@users.noreply.github.com> Co-authored-by: rohitwaghchaure --- .../doctype/asset_movement/asset_movement.py | 15 +++++++- .../asset_movement/test_asset_movement.py | 38 ++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 44aa271846c..6c93fd50583 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, get_link_to_form +from frappe.utils import cstr, get_datetime, get_link_to_form from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity @@ -34,6 +34,7 @@ class AssetMovement(Document): for d in self.assets: self.validate_asset(d) self.validate_movement(d) + self.validate_transaction_date(d) def validate_asset(self, d): status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"]) @@ -51,6 +52,18 @@ class AssetMovement(Document): else: self.validate_employee(d) + def validate_transaction_date(self, d): + previous_movement_date = frappe.db.get_value( + "Asset Movement", + [["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]], + "transaction_date", + order_by="transaction_date desc", + ) + if previous_movement_date and get_datetime(previous_movement_date) > get_datetime( + self.transaction_date + ): + frappe.throw(_("Transaction date can't be earlier than previous movement date")) + def validate_location_and_employee(self, d): self.validate_location(d) self.validate_employee(d) diff --git a/erpnext/assets/doctype/asset_movement/test_asset_movement.py b/erpnext/assets/doctype/asset_movement/test_asset_movement.py index 07879acd1f0..e99e8c194b3 100644 --- a/erpnext/assets/doctype/asset_movement/test_asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/test_asset_movement.py @@ -4,9 +4,9 @@ import unittest import frappe -from frappe.utils import now +from frappe.utils import add_days, now -from erpnext.assets.doctype.asset.test_asset import create_asset_data +from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data from erpnext.setup.doctype.employee.test_employee import make_employee from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -147,6 +147,33 @@ class TestAssetMovement(unittest.TestCase): movement1.cancel() self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location") + def test_movement_transaction_date(self): + asset = create_asset(item_code="Macbook Pro", do_not_save=1) + asset.save().submit() + + if not frappe.db.exists("Location", "Test Location 2"): + frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert() + + asset_creation_date = frappe.db.get_value( + "Asset Movement", + [["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]], + "transaction_date", + ) + asset_movement = create_asset_movement( + purpose="Transfer", + company=asset.company, + assets=[ + { + "asset": asset.name, + "source_location": "Test Location", + "target_location": "Test Location 2", + } + ], + transaction_date=add_days(asset_creation_date, -1), + do_not_save=True, + ) + self.assertRaises(frappe.ValidationError, asset_movement.save) + def create_asset_movement(**args): args = frappe._dict(args) @@ -165,9 +192,10 @@ def create_asset_movement(**args): "reference_name": args.reference_name, } ) - - movement.insert() - movement.submit() + if not args.do_not_save: + movement.insert(ignore_if_duplicate=True) + if not args.do_not_submit: + movement.submit() return movement From 9519773c5c64014ceca4662fdb667ccbda110dee Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:26:16 +0530 Subject: [PATCH 27/40] fix(map_current_doc): prevent mutation of query args in get_query (backport #52202) (#52583) fix(map_current_doc): prevent mutation of query args in get_query (#52202) (cherry picked from commit 23a73c9cdba6e070278e4d73be047024aa806a4c) Co-authored-by: V Shankar --- erpnext/public/js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 59d2c90dc14..989c361dd8e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -974,7 +974,7 @@ erpnext.utils.map_current_doc = function (opts) { } if (query_args.filters || query_args.query) { - opts.get_query = () => query_args; + opts.get_query = () => JSON.parse(JSON.stringify(query_args)); } if (opts.source_doctype) { From 6c9681ba4cdbb50edfe66ce0451a19c221adb532 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:10:05 +0530 Subject: [PATCH 28/40] fix: email campaign timeout issue (backport #51994) (#52555) fix: email campaign timeout issue (#51994) * fix: email campaign timeout issue * refactor: email campaign backend logic * refactor: use sendmail instead of manually batching (cherry picked from commit 22123dd95521915ccb3cd95537498ea4115677ff) Co-authored-by: Pratik Badhe Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../doctype/email_campaign/email_campaign.py | 167 +++++++++++++----- 1 file changed, 121 insertions(+), 46 deletions(-) diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py index a5a2132dc0c..9e24a26caa8 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -38,18 +38,18 @@ class EmailCampaign(Document): def set_date(self): if getdate(self.start_date) < getdate(today()): frappe.throw(_("Start Date cannot be before the current date")) + # set the end date as start date + max(send after days) in campaign schedule - send_after_days = [] - campaign = frappe.get_doc("Campaign", self.campaign_name) - for entry in campaign.get("campaign_schedules"): - send_after_days.append(entry.send_after_days) - try: - self.end_date = add_days(getdate(self.start_date), max(send_after_days)) - except ValueError: + campaign = frappe.get_cached_doc("Campaign", self.campaign_name) + send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")] + + if not send_after_days: frappe.throw( _("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name) ) + self.end_date = add_days(getdate(self.start_date), max(send_after_days)) + def validate_lead(self): lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id") if not lead_email_id: @@ -77,58 +77,128 @@ class EmailCampaign(Document): start_date = getdate(self.start_date) end_date = getdate(self.end_date) today_date = getdate(today()) + if start_date > today_date: - self.db_set("status", "Scheduled", update_modified=False) + new_status = "Scheduled" elif end_date >= today_date: - self.db_set("status", "In Progress", update_modified=False) - elif end_date < today_date: - self.db_set("status", "Completed", update_modified=False) + new_status = "In Progress" + else: + new_status = "Completed" + + if self.status != new_status: + self.db_set("status", new_status, update_modified=False) # called through hooks to send campaign mails to leads def send_email_to_leads_or_contacts(): + today_date = getdate(today()) + + # Get all active email campaigns in a single query email_campaigns = frappe.get_all( - "Email Campaign", filters={"status": ("not in", ["Unsubscribed", "Completed", "Scheduled"])} + "Email Campaign", + filters={"status": "In Progress"}, + fields=["name", "campaign_name", "email_campaign_for", "recipient", "start_date", "sender"], ) - for camp in email_campaigns: - email_campaign = frappe.get_doc("Email Campaign", camp.name) - campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name) + + if not email_campaigns: + return + + # Process each email campaign + for email_campaign in email_campaigns: + try: + campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name) + except frappe.DoesNotExistError: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("Campaign {0} not found").format(email_campaign.campaign_name), + ) + continue + + # Find schedules that match today for entry in campaign.get("campaign_schedules"): - scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days")) - if scheduled_date == getdate(today()): - send_mail(entry, email_campaign) + try: + scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days")) + if scheduled_date == today_date: + send_mail(entry, email_campaign) + except Exception: + frappe.log_error( + title=_("Email Campaign Send Error"), + message=_("Failed to send email for campaign {0} to {1}").format( + email_campaign.name, email_campaign.recipient + ), + ) def send_mail(entry, email_campaign): - recipient_list = [] - if email_campaign.email_campaign_for == "Email Group": - for member in frappe.db.get_list( - "Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"] - ): - recipient_list.append(member["email"]) + campaign_for = email_campaign.get("email_campaign_for") + recipient = email_campaign.get("recipient") + sender_user = email_campaign.get("sender") + campaign_name = email_campaign.get("name") + + # Get recipient emails + if campaign_for == "Email Group": + recipient_list = frappe.get_all( + "Email Group Member", + filters={"email_group": recipient, "unsubscribed": 0}, + pluck="email", + ) else: - recipient_list.append( - frappe.db.get_value( - email_campaign.email_campaign_for, email_campaign.get("recipient"), "email_id" + email_id = frappe.db.get_value(campaign_for, recipient, "email_id") + if not email_id: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("No email found for {0} {1}").format(campaign_for, recipient), ) + return + recipient_list = [email_id] + + if not recipient_list: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("No recipients found for campaign {0}").format(campaign_name), + ) + return + + # Get email template and sender + email_template = frappe.get_cached_doc("Email Template", entry.get("email_template")) + sender = frappe.db.get_value("User", sender_user, "email") if sender_user else None + + # Build context for template rendering + if campaign_for != "Email Group": + context = {"doc": frappe.get_doc(campaign_for, recipient)} + else: + # For email groups, use the email group document as context + context = {"doc": frappe.get_doc("Email Group", recipient)} + + # Render template + subject = frappe.render_template(email_template.get("subject"), context) + content = frappe.render_template(email_template.response_, context) + + try: + comm = make( + doctype="Email Campaign", + name=campaign_name, + subject=subject, + content=content, + sender=sender, + recipients=recipient_list, + communication_medium="Email", + sent_or_received="Sent", + send_email=False, + email_template=email_template.name, ) - email_template = frappe.get_doc("Email Template", entry.get("email_template")) - sender = frappe.db.get_value("User", email_campaign.get("sender"), "email") - context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)} - # send mail and link communication to document - comm = make( - doctype="Email Campaign", - name=email_campaign.name, - subject=frappe.render_template(email_template.get("subject"), context), - content=frappe.render_template(email_template.response_, context), - sender=sender, - bcc=recipient_list, - communication_medium="Email", - sent_or_received="Sent", - send_email=True, - email_template=email_template.name, - ) + frappe.sendmail( + recipients=recipient_list, + subject=subject, + content=content, + sender=sender, + communication=comm["name"], + queue_separately=True, + ) + except Exception: + frappe.log_error(title="Email Campaign Failed.") + return comm @@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method): # called through hooks to update email campaign status daily def set_email_campaign_status(): - email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")}) - for entry in email_campaigns: - email_campaign = frappe.get_doc("Email Campaign", entry.name) + email_campaigns = frappe.get_all( + "Email Campaign", + filters={"status": ("!=", "Unsubscribed")}, + pluck="name", + ) + + for name in email_campaigns: + email_campaign = frappe.get_doc("Email Campaign", name) email_campaign.update_status() From a6f5b88f9b275c2122dc1c23cee3b86a7f3118b4 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:59:58 +0000 Subject: [PATCH 29/40] fix: Added a missing option to the currency field (backport #52528) (#52586) fix: Added a missing option to the currency field (#52528) (cherry picked from commit da07f84e4474a9873e2bcb4f8ce8bc97eb2d3b5d) Co-authored-by: El-Shafei H. --- .../advance_payment_ledger_entry.json | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json index 35a3196c140..76e64ff4fa1 100644 --- a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json @@ -48,6 +48,7 @@ "fieldname": "amount", "fieldtype": "Currency", "label": "Amount", + "options": "currency", "read_only": 1 }, { From 19dca36dece3baa4fd916e846d68f74779138884 Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Tue, 10 Feb 2026 12:10:34 +0530 Subject: [PATCH 30/40] fix(stock): set source warehouse for issue type --- erpnext/stock/doctype/material_request/material_request.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 7d160b24e2d..cc993325fb1 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -706,6 +706,9 @@ def make_stock_entry(source_name, target_doc=None): target.purpose = source.material_request_type target.from_warehouse = source.set_from_warehouse target.to_warehouse = source.set_warehouse + if source.material_request_type == "Material Issue": + target.from_warehouse = source.set_warehouse + target.to_warehouse = None if source.job_card: target.purpose = "Material Transfer for Manufacture" From 37ca45ea49ba0deff749d8ff2a5a8e75bbaddef0 Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Tue, 10 Feb 2026 12:11:59 +0530 Subject: [PATCH 31/40] test(stock): add test to check from warehouse for issue type --- .../material_request/test_material_request.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index fff0db2f93d..c089adf5a66 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -970,6 +970,19 @@ class TestMaterialRequest(FrappeTestCase): self.assertRaises(frappe.ValidationError, end_transit_2.submit) + def test_make_stock_entry_material_issue_warehouse_mapping(self): + """Test to ensure while making stock entry from material request of type Material Issue, warehouse is mapped correctly""" + mr = make_material_request(material_request_type="Material Issue", do_not_submit=True) + mr.set_warehouse = "_Test Warehouse - _TC" + mr.save() + mr.submit() + + se = make_stock_entry(mr.name) + self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC") + self.assertIsNone(se.to_warehouse) + se.save() + se.submit() + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): From 5a42ff0c3cd901069278d82682f378ce6142b3fb Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Tue, 10 Feb 2026 12:14:54 +0530 Subject: [PATCH 32/40] fix(stock): inward stock for pick list test record --- .../material_request/test_material_request.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index c089adf5a66..2da1861ba21 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -902,15 +902,27 @@ class TestMaterialRequest(FrappeTestCase): import json from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - mr = make_material_request(material_request_type="Material Transfer") + new_item = create_item("_Test Pick List Item", is_stock_item=1) + item_code = new_item.name + + make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=10, + do_not_save=False, + do_not_submit=False, + ) + + mr = make_material_request(item_code=item_code, material_request_type="Material Transfer") pl = create_pick_list(mr.name) pl.save() pl.locations[0].qty = 5 pl.locations[0].stock_qty = 5 pl.submit() - to_warehouse = create_warehouse("Test To Warehouse") + to_warehouse = create_warehouse("_Test Warehouse - _TC") se_data = create_stock_entry(json.dumps(pl.as_dict())) se = frappe.get_doc(se_data) From 36e2cf49f359cb59bb52e5ebdaf0327cf446c7b5 Mon Sep 17 00:00:00 2001 From: Kavin <78342682+kavin-114@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:35:52 +0530 Subject: [PATCH 33/40] fix: resolve conflicts - Remove POS Settings configuration for version 15 backport. --- erpnext/stock/doctype/batch/test_batch.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 65a2420693f..2100f2f663f 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -141,14 +141,10 @@ class TestBatch(FrappeTestCase): from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_batch_item_with_batch, ) - - invoice_type = frappe.db.get_single_value("POS Settings", "invoice_type") + session_user = frappe.session.user try: - # Set invoice type to POS Invoice - frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice") - # Create batch item create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02") @@ -198,8 +194,6 @@ class TestBatch(FrappeTestCase): self.assertEqual(row.qty, 30) finally: - # Set invoice type to Sales Invoice - frappe.db.set_single_value("POS Settings", "invoice_type", invoice_type) # Set user to session user frappe.set_user(session_user) From ae490804f9ef510fee5107e233199c3d296e39f1 Mon Sep 17 00:00:00 2001 From: Kavin <78342682+kavin-114@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:02:23 +0530 Subject: [PATCH 34/40] chore: fix failing pre-commit checks - Remove empty line with spaces --- erpnext/stock/doctype/batch/test_batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 2100f2f663f..c3764138a08 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -141,7 +141,7 @@ class TestBatch(FrappeTestCase): from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_batch_item_with_batch, ) - + session_user = frappe.session.user try: From 303dac262cd023e70fbed0d4aaf8ae780789de16 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Fri, 23 Jan 2026 12:40:38 +0530 Subject: [PATCH 35/40] fix(gross-profit): handle returns outside sale period (cherry picked from commit 67d8223f73b7d744f5005c2c80adb51d8a86c5b2) --- .../report/gross_profit/gross_profit.py | 253 ++++++++++-------- 1 file changed, 148 insertions(+), 105 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index d2fe570fa3b..1df871f5d0e 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -5,15 +5,16 @@ from collections import OrderedDict import frappe from frappe import _, qb, scrub -from frappe.query_builder import Order +from frappe.query_builder import Case, Order +from frappe.query_builder.functions import Coalesce from frappe.utils import cint, flt, formatdate +from pypika.terms import ExistsCriterion from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, get_dimension_with_children, ) from erpnext.accounts.report.financial_statements import get_cost_centers_with_children -from erpnext.controllers.queries import get_match_cond from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition from erpnext.stock.utils import get_incoming_rate @@ -851,129 +852,171 @@ class GrossProfitGenerator: return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0 def load_invoice_items(self): - conditions = "" - if self.filters.company: - conditions += " and `tabSales Invoice`.company = %(company)s" - if self.filters.from_date: - conditions += " and posting_date >= %(from_date)s" - if self.filters.to_date: - conditions += " and posting_date <= %(to_date)s" + self.si_list = [] + + SalesInvoice = frappe.qb.DocType("Sales Invoice") + base_query = self.prepare_invoice_query() if self.filters.include_returned_invoices: - conditions += " and (is_return = 0 or (is_return=1 and return_against is null))" + invoice_query = base_query.where( + (SalesInvoice.is_return == 0) + | ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull()) + ) else: - conditions += " and is_return = 0" + invoice_query = base_query.where(SalesInvoice.is_return == 0) - if self.filters.item_group: - conditions += f" and {get_item_group_condition(self.filters.item_group)}" + self.si_list += invoice_query.run(as_dict=True) + self.prepare_vouchers_to_ignore() - if self.filters.sales_person: - conditions += """ - and exists(select 1 - from `tabSales Team` st - where st.parent = `tabSales Invoice`.name - and st.sales_person = %(sales_person)s) - """ + ret_invoice_query = base_query.where( + (SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull() + ) + if self.vouchers_to_ignore: + ret_invoice_query = base_query.where(SalesInvoice.return_against.notin(self.vouchers_to_ignore)) + + self.si_list += ret_invoice_query.run(as_dict=True) + + def prepare_invoice_query(self): + SalesInvoice = frappe.qb.DocType("Sales Invoice") + SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item") + Item = frappe.qb.DocType("Item") + SalesTeam = frappe.qb.DocType("Sales Team") + PaymentSchedule = frappe.qb.DocType("Payment Schedule") + + query = ( + frappe.qb.from_(SalesInvoice) + .join(SalesInvoiceItem) + .on(SalesInvoiceItem.parent == SalesInvoice.name) + .join(Item) + .on(Item.name == SalesInvoiceItem.item_code) + .where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes")) + ) + + query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam) + + query = query.select( + SalesInvoiceItem.parenttype, + SalesInvoiceItem.parent, + SalesInvoice.posting_date, + SalesInvoice.posting_time, + SalesInvoice.project, + SalesInvoice.update_stock, + SalesInvoice.customer, + SalesInvoice.customer_group, + SalesInvoice.customer_name, + SalesInvoice.territory, + SalesInvoiceItem.item_code, + SalesInvoice.base_net_total.as_("invoice_base_net_total"), + SalesInvoiceItem.item_name, + SalesInvoiceItem.description, + SalesInvoiceItem.warehouse, + SalesInvoiceItem.item_group, + SalesInvoiceItem.brand, + SalesInvoiceItem.so_detail, + SalesInvoiceItem.sales_order, + SalesInvoiceItem.dn_detail, + SalesInvoiceItem.delivery_note, + SalesInvoiceItem.stock_qty.as_("qty"), + SalesInvoiceItem.base_net_rate, + SalesInvoiceItem.base_net_amount, + SalesInvoiceItem.name.as_("item_row"), + SalesInvoice.is_return, + SalesInvoiceItem.cost_center, + SalesInvoiceItem.serial_and_batch_bundle, + ) if self.filters.group_by == "Sales Person": - sales_person_cols = """, sales.sales_person, - sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount, - sales.incentives - """ - sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name" - else: - sales_person_cols = "" - sales_team_table = "" + query = query.select( + SalesTeam.sales_person, + (SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_( + "allocated_amount" + ), + SalesTeam.incentives, + ) + + query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name) if self.filters.group_by == "Payment Term": - payment_term_cols = """,if(`tabSales Invoice`.is_return = 1, - '{}', - coalesce(schedule.payment_term, '{}')) as payment_term, - schedule.invoice_portion, - schedule.payment_amount """.format(_("Sales Return"), _("No Terms")) - payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and - `tabSales Invoice`.is_return = 0 """ - else: - payment_term_cols = "" - payment_term_table = "" + query = query.select( + Case() + .when(SalesInvoice.is_return == 1, _("Sales Return")) + .else_(Coalesce(PaymentSchedule.payment_term, _("No Terms"))) + .as_("payment_term"), + PaymentSchedule.invoice_portion, + PaymentSchedule.payment_amount, + ) - if self.filters.get("sales_invoice"): - conditions += " and `tabSales Invoice`.name = %(sales_invoice)s" + query = query.left_join(PaymentSchedule).on( + (PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0) + ) - if self.filters.get("item_code"): - conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s" + query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby( + SalesInvoice.posting_time, order=Order.desc + ) - if self.filters.get("cost_center"): + return query + + def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam): + if self.filters.company: + query = query.where(SalesInvoice.company == self.filters.company) + + if self.filters.from_date: + query = query.where(SalesInvoice.posting_date >= self.filters.from_date) + + if self.filters.to_date: + query = query.where(SalesInvoice.posting_date <= self.filters.to_date) + + if self.filters.item_group: + query = query.where(get_item_group_condition(self.filters.item_group)) + + if self.filters.sales_person: + query = query.where( + ExistsCriterion( + frappe.qb.from_(SalesTeam) + .select(1) + .where( + (SalesTeam.parent == SalesInvoice.name) + & (SalesTeam.sales_person == self.filters.sales_person) + ) + ) + ) + + if self.filters.sales_invoice: + query = query.where(SalesInvoice.name == self.filters.sales_invoice) + + if self.filters.item_code: + query = query.where(SalesInvoiceItem.item_code == self.filters.item_code) + + if self.filters.cost_center: self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center")) self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center) - conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s" + query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center)) - if self.filters.get("project"): + if self.filters.project: self.filters.project = frappe.parse_json(self.filters.get("project")) - conditions += " and `tabSales Invoice Item`.project in %(project)s" + query = query.where(SalesInvoiceItem.project.isin(self.filters.project)) - accounting_dimensions = get_accounting_dimensions(as_list=False) - if accounting_dimensions: - for dimension in accounting_dimensions: - if self.filters.get(dimension.fieldname): - if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): - self.filters[dimension.fieldname] = get_dimension_with_children( - dimension.document_type, self.filters.get(dimension.fieldname) - ) - conditions += ( - f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s" - ) - else: - conditions += ( - f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s" - ) + for dim in get_accounting_dimensions(as_list=False) or []: + if self.filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + self.filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, self.filters.get(dim.fieldname) + ) + query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname])) - if self.filters.get("warehouse"): - warehouse_details = frappe.db.get_value( - "Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1 + if self.filters.warehouse: + lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"]) + WH = frappe.qb.DocType("Warehouse") + query = query.where( + SalesInvoiceItem.warehouse.isin( + frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt)) + ) ) - if warehouse_details: - conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)" - self.si_list = frappe.db.sql( - """ - select - `tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent, - `tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time, - `tabSales Invoice`.project, `tabSales Invoice`.update_stock, - `tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name, - `tabSales Invoice`.territory, `tabSales Invoice Item`.item_code, - `tabSales Invoice`.base_net_total as "invoice_base_net_total", - `tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description, - `tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group, - `tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail, - `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail, - `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty, - `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, - `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, - `tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle - {sales_person_cols} - {payment_term_cols} - from - `tabSales Invoice` inner join `tabSales Invoice Item` - on `tabSales Invoice Item`.parent = `tabSales Invoice`.name - join `tabItem` item on item.name = `tabSales Invoice Item`.item_code - {sales_team_table} - {payment_term_table} - where - `tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond} - order by - `tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format( - conditions=conditions, - sales_person_cols=sales_person_cols, - sales_team_table=sales_team_table, - payment_term_cols=payment_term_cols, - payment_term_table=payment_term_table, - match_cond=get_match_cond("Sales Invoice"), - ), - self.filters, - as_dict=1, - ) + return query + + def prepare_vouchers_to_ignore(self): + self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list) def get_delivery_notes(self): self.delivery_notes = frappe._dict({}) From bde19ab0101aaa9d81f8cc271e4b9ded995b1674 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sat, 24 Jan 2026 01:25:18 +0530 Subject: [PATCH 36/40] fix: handle gross profit and percentage for return invoices (cherry picked from commit 51709f032fc5ec84e2fd74f8ca981351b36a4ed8) --- .../report/gross_profit/gross_profit.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 1df871f5d0e..0638ea3b4c1 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -204,7 +204,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ data.append(row) - total_gross_profit = total_base_amount - total_buying_amount + total_gross_profit = flt( + total_base_amount + abs(total_buying_amount) + if total_buying_amount < 0 + else total_base_amount - total_buying_amount, + ) data.append( frappe._dict( { @@ -216,7 +220,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_%": flt( - (total_gross_profit / total_base_amount) * 100.0, + (total_gross_profit / abs(total_base_amount)) * 100.0, cint(frappe.db.get_default("currency_precision")) or 3, ) if total_base_amount @@ -249,9 +253,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ data.append(row) - total_gross_profit = total_base_amount - total_buying_amount + total_gross_profit = flt( + total_base_amount + abs(total_buying_amount) + if total_buying_amount < 0 + else total_base_amount - total_buying_amount, + ) currency_precision = cint(frappe.db.get_default("currency_precision")) or 3 - gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0 + gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0 total_row = { group_columns[0]: "Total", @@ -582,10 +590,15 @@ class GrossProfitGenerator: base_amount += row.base_amount # calculate gross profit - row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision) + row.gross_profit = flt( + row.base_amount + abs(row.buying_amount) + if row.buying_amount < 0 + else row.base_amount - row.buying_amount, + self.currency_precision, + ) if row.base_amount: row.gross_profit_percent = flt( - (row.gross_profit / row.base_amount) * 100.0, + (row.gross_profit / abs(row.base_amount)) * 100.0, self.currency_precision, ) else: @@ -674,9 +687,14 @@ class GrossProfitGenerator: return new_row def set_average_gross_profit(self, new_row): - new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision) + new_row.gross_profit = flt( + new_row.base_amount + abs(new_row.buying_amount) + if new_row.buying_amount < 0 + else new_row.base_amount - new_row.buying_amount, + self.currency_precision, + ) new_row.gross_profit_percent = ( - flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) + flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision) if new_row.base_amount else 0 ) From 8ba5ef683f57996d4058a8b963a3e600bd2314d4 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sat, 24 Jan 2026 01:43:05 +0530 Subject: [PATCH 37/40] test: validate return invoice profit and profit percentage (cherry picked from commit 4da3d430132fbd64ab6f2696f6006859e2afa496) --- .../report/gross_profit/test_gross_profit.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index d92c16ab440..94ea006f6a0 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -465,7 +465,7 @@ class TestGrossProfit(FrappeTestCase): "selling_amount": -100.0, "buying_amount": 0.0, "gross_profit": -100.0, - "gross_profit_%": 100.0, + "gross_profit_%": -100.0, } gp_entry = [x for x in data if x.parent_invoice == sinv.name] self.assertDictContainsSubset(expected_entry, gp_entry[0]) @@ -642,21 +642,24 @@ class TestGrossProfit(FrappeTestCase): def test_profit_for_later_period_return(self): month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) + sales_inv_date = month_start_date + return_inv_date = add_days(month_end_date, 1) + # create sales invoice on month start date sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True) sinv.set_posting_time = 1 - sinv.posting_date = month_start_date + sinv.posting_date = sales_inv_date sinv.save().submit() # create credit note on next month start date cr_note = make_sales_return(sinv.name) cr_note.set_posting_time = 1 - cr_note.posting_date = add_days(month_end_date, 1) + cr_note.posting_date = return_inv_date cr_note.save().submit() # apply filters for invoiced period filters = frappe._dict( - company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice" + company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice" ) _, data = execute(filters=filters) @@ -668,7 +671,7 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total.get("gross_profit_%"), 100.0) # extend filters upto returned period - filters.update(to_date=add_days(month_end_date, 1)) + filters.update({"to_date": return_inv_date}) _, data = execute(filters=filters) total = data[-1] @@ -677,3 +680,13 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total.buying_amount, 0.0) self.assertEqual(total.gross_profit, 0.0) self.assertEqual(total.get("gross_profit_%"), 0.0) + + # apply filters only on returned period + filters.update({"from_date": return_inv_date, "to_date": return_inv_date}) + _, data = execute(filters=filters) + total = data[-1] + + self.assertEqual(total.selling_amount, -100.0) + self.assertEqual(total.buying_amount, 0.0) + self.assertEqual(total.gross_profit, -100.0) + self.assertEqual(total.get("gross_profit_%"), -100.0) From a912b78bb85bf280a09c73e7f391274bd15d1219 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Thu, 29 Jan 2026 00:50:32 +0530 Subject: [PATCH 38/40] test: validate sales person wise gross profit (cherry picked from commit 3ab978ab469f01934c888aa197db24075fd3ed96) --- .../report/gross_profit/test_gross_profit.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 94ea006f6a0..871c181c251 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -690,3 +690,53 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total.buying_amount, 0.0) self.assertEqual(total.gross_profit, -100.0) self.assertEqual(total.get("gross_profit_%"), -100.0) + + def test_sales_person_wise_gross_profit(self): + sales_person = make_sales_person("_Test Sales Person") + + posting_date = get_first_day(nowdate()) + qty = 10 + rate = 100 + + sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True) + sinv.set_posting_time = 1 + sinv.posting_date = posting_date + sinv.append( + "sales_team", + { + "sales_person": sales_person.name, + "allocated_percentage": 100, + "allocated_amount": 1000.0, + "commission_rate": 5, + "incentives": 5, + }, + ) + sinv.save().submit() + + filters = frappe._dict( + company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person" + ) + + _, data = execute(filters=filters) + total = data[-1] + + self.assertEqual(total.selling_amount, 1000.0) + self.assertEqual(total.buying_amount, 0.0) + self.assertEqual(total.gross_profit, 1000.0) + self.assertEqual(total.get("gross_profit_%"), 100.0) + + +def make_sales_person(sales_person_name="_Test Sales Person"): + if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): + sales_person_doc = frappe.get_doc( + { + "doctype": "Sales Person", + "is_group": 0, + "parent_sales_person": "Sales Team", + "sales_person_name": sales_person_name, + } + ).insert(ignore_permissions=True) + else: + sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name}) + + return sales_person_doc From da37fea583f9bf04fb93b4e3cbfb503d2eb313ff Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Thu, 29 Jan 2026 17:55:13 +0530 Subject: [PATCH 39/40] test: fix test assertions to use index-based totals (cherry picked from commit fdfa7bc9635f5ecbef95c83d2499185a9816399d) --- erpnext/accounts/report/gross_profit/test_gross_profit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 871c181c251..49ca61e950d 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -720,10 +720,10 @@ class TestGrossProfit(FrappeTestCase): _, data = execute(filters=filters) total = data[-1] - self.assertEqual(total.selling_amount, 1000.0) - self.assertEqual(total.buying_amount, 0.0) - self.assertEqual(total.gross_profit, 1000.0) - self.assertEqual(total.get("gross_profit_%"), 100.0) + self.assertEqual(total[5], 1000.0) + self.assertEqual(total[6], 0.0) + self.assertEqual(total[7], 1000.0) + self.assertEqual(total[8], 100.0) def make_sales_person(sales_person_name="_Test Sales Person"): From 7cd9de211f10fcca4f13dfbda0bfc7d668ae5d4c Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Tue, 10 Feb 2026 11:47:35 +0530 Subject: [PATCH 40/40] fix(gross-profit): handle item group filters (cherry picked from commit 047b2787915e42d7ccdbb863c2f5909f5c01f157) --- erpnext/accounts/report/gross_profit/gross_profit.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 0638ea3b4c1..9adf75945e0 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -890,7 +890,9 @@ class GrossProfitGenerator: (SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull() ) if self.vouchers_to_ignore: - ret_invoice_query = base_query.where(SalesInvoice.return_against.notin(self.vouchers_to_ignore)) + ret_invoice_query = ret_invoice_query.where( + SalesInvoice.return_against.notin(self.vouchers_to_ignore) + ) self.si_list += ret_invoice_query.run(as_dict=True) @@ -910,7 +912,7 @@ class GrossProfitGenerator: .where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes")) ) - query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam) + query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item) query = query.select( SalesInvoiceItem.parenttype, @@ -974,7 +976,7 @@ class GrossProfitGenerator: return query - def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam): + def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item): if self.filters.company: query = query.where(SalesInvoice.company == self.filters.company) @@ -985,7 +987,7 @@ class GrossProfitGenerator: query = query.where(SalesInvoice.posting_date <= self.filters.to_date) if self.filters.item_group: - query = query.where(get_item_group_condition(self.filters.item_group)) + query = query.where(get_item_group_condition(self.filters.item_group, Item)) if self.filters.sales_person: query = query.where(