From 3217a69fc26633ec3a404ed89c36c88e86c09059 Mon Sep 17 00:00:00 2001 From: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:32:41 +0530 Subject: [PATCH 01/24] fix: skip zero quantity items in production plan material requests (#58642) --- .../doctype/production_plan/production_plan.py | 3 +++ .../doctype/production_plan/test_production_plan.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 0382d0e201a..c293b7d034a 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -887,6 +887,9 @@ class ProductionPlan(Document): material_request_map = {} for item in self.mr_items: + if not item.quantity: + continue + item_doc = frappe.get_cached_doc("Item", item.item_code) material_request_type = item.material_request_type or item_doc.default_material_request_type diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 72fb5debb97..053c7ba7673 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -107,6 +107,19 @@ class TestProductionPlan(FrappeTestCase): pln = frappe.get_doc("Production Plan", pln.name) pln.cancel() + def test_production_plan_mr_creation_skips_zero_qty(self): + pln = create_production_plan(item_code="Test Production Item 1", do_not_submit=1) + pln.mr_items[0].quantity = 0 + pln.save().submit() + + pln.make_material_request() + + quantities = frappe.get_all( + "Material Request Item", filters={"production_plan": pln.name}, pluck="qty" + ) + self.assertEqual(len(quantities), len(pln.mr_items) - 1) + self.assertNotIn(0, quantities) + def test_production_plan_start_date(self): "Test if Work Order has same Planned Start Date as Prod Plan." planned_date = add_to_date(date=None, days=3) From 4dd56ec4e6fae71ea112c53770dfd1f6f8c3546b Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Wed, 2 Sep 2026 13:50:38 +0530 Subject: [PATCH 02/24] fix(crm): check read permission on lead in add_lead_to_prospect (cherry picked from commit 02fcdc0337b3afab1eafd586c4a3d39444570b11) # Conflicts: # erpnext/crm/doctype/lead/lead.py --- erpnext/crm/doctype/lead/lead.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 42b82395719..79fc7c1c186 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -516,8 +516,11 @@ def get_lead_with_phone_number(number): return lead -@frappe.whitelist() -def add_lead_to_prospect(lead, prospect): +@frappe.whitelist(methods=["POST"]) +def add_lead_to_prospect(lead: str, prospect: str): + if lead: + frappe.has_permission("Lead", "read", lead, throw=True) + prospect = frappe.get_doc("Prospect", prospect) prospect.append("leads", {"lead": lead}) prospect.save() From 1ea597d6b5a9ef50a998249513354cae942ba1ed Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:12:57 +0530 Subject: [PATCH 03/24] fix(crm): validate contact email before saving an email campaign (backport #58667) (#58671) Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com> --- .../doctype/email_campaign/email_campaign.py | 20 ++++- .../email_campaign/test_email_campaign.py | 79 ++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py index dbc4382a041..3dadc830d0a 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -29,12 +29,19 @@ class EmailCampaign(Document): def validate(self): self.set_date() - # checking if email is set for lead. Not checking for contact as email is a mandatory field for contact. - if self.email_campaign_for == "Lead": - self.validate_lead() + self.validate_recipient_email() self.validate_email_campaign_already_exists() self.update_status() + def validate_recipient_email(self): + if not self.recipient: + return + + if self.email_campaign_for == "Lead": + self.validate_lead() + elif self.email_campaign_for == "Contact": + self.validate_contact() + def set_date(self): if getdate(self.start_date) < getdate(today()): frappe.throw(_("Start Date cannot be before the current date")) @@ -56,6 +63,13 @@ class EmailCampaign(Document): lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name") frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name)) + def validate_contact(self): + contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True) + if contact and not contact.email_id: + frappe.throw( + _("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name)) + ) + def validate_email_campaign_already_exists(self): email_campaign_exists = frappe.db.exists( "Email Campaign", diff --git a/erpnext/crm/doctype/email_campaign/test_email_campaign.py b/erpnext/crm/doctype/email_campaign/test_email_campaign.py index 997d903c578..184df7ae69f 100644 --- a/erpnext/crm/doctype/email_campaign/test_email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/test_email_campaign.py @@ -1,9 +1,84 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe import unittest +import frappe +from frappe.utils import add_days, getdate, today + class TestEmailCampaign(unittest.TestCase): - pass + """Email Campaign derives its window from the linked Campaign schedule and + guards the start date and the recipient's email.""" + + def setUp(self): + frappe.set_user("Administrator") + + def make_email_template(self): + name = "_Test EC Email Template" + if not frappe.db.exists("Email Template", name): + frappe.get_doc( + {"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"} + ).insert() + return name + + def make_campaign(self, schedules): + campaign = frappe.new_doc("Campaign") + campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}" + for days in schedules: + campaign.append( + "campaign_schedules", + {"send_after_days": days, "email_template": self.make_email_template()}, + ) + return campaign.insert() + + def make_email_campaign(self, campaign_name, start_date=None): + doc = frappe.new_doc("Email Campaign") + doc.campaign_name = campaign_name + doc.start_date = start_date or today() + return doc + + def test_start_date_cannot_be_in_the_past(self): + doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1)) + self.assertRaises(frappe.ValidationError, doc.set_date) + + def test_end_date_is_start_plus_max_send_after_days(self): + campaign = self.make_campaign(schedules=[0, 5]) + doc = self.make_email_campaign(campaign.name) + doc.set_date() + self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5)) + + def test_campaign_without_a_schedule_is_rejected(self): + campaign = self.make_campaign(schedules=[]) + doc = self.make_email_campaign(campaign.name) + self.assertRaises(frappe.ValidationError, doc.set_date) + + def test_lead_without_an_email_is_rejected(self): + lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert() + doc = frappe.new_doc("Email Campaign") + doc.email_campaign_for = "Lead" + doc.recipient = lead.name + self.assertRaises(frappe.ValidationError, doc.validate_lead) + + def test_contact_without_an_email_is_rejected(self): + contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert() + campaign = self.make_campaign(schedules=[0]) + doc = self.make_email_campaign(campaign.name) + doc.email_campaign_for = "Contact" + doc.recipient = contact.name + self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert) + + def test_contact_with_an_email_is_accepted(self): + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": "_Test Contact With Email", + "email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}], + } + ).insert() + campaign = self.make_campaign(schedules=[0]) + doc = self.make_email_campaign(campaign.name) + doc.email_campaign_for = "Contact" + doc.recipient = contact.name + doc.insert() + self.assertEqual(doc.status, "In Progress") From fff261680c14cdd7bf8791e07597afa216344a1a Mon Sep 17 00:00:00 2001 From: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:33:15 +0530 Subject: [PATCH 04/24] fix: filter cancelled BOMs in BOM Stock Analysis (#58647) (cherry picked from commit a2071a6fddcc3a18cd01e4ed6d1e7fc42352258c) # Conflicts: # erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js --- .../bom_stock_analysis/bom_stock_analysis.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js new file mode 100644 index 00000000000..3d11a7d7263 --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js @@ -0,0 +1,60 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["BOM Stock Analysis"] = { + filters: [ + { + fieldname: "bom", + label: __("BOM"), + fieldtype: "Link", + options: "BOM", + reqd: 1, + get_query: () => ({ filters: { docstatus: 1 } }), + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + }, + { + fieldname: "qty_to_make", + label: __("FG Items to Make"), + fieldtype: "Float", + }, + { + fieldname: "show_exploded_view", + label: __("Show availability of exploded items"), + fieldtype: "Check", + default: false, + }, + ], + formatter(value, row, column, data, default_formatter) { + if (data && data.bold && column.fieldname === "item") { + return value ? `${value}` : ""; + } + + value = default_formatter(value, row, column, data); + + if (column.fieldname === "difference_qty" && value !== "" && value !== undefined) { + const numeric = parseFloat(value.replace(/,/g, "")) || 0; + if (numeric < 0) { + value = `${value}`; + } else if (numeric > 0) { + value = `${value}`; + } + } + + if (data && data.bold) { + if (column.fieldname === "description") { + const qty_to_make = Number(frappe.query_report.get_filter_value("qty_to_make")) || 0; + const producible = Number(String(data.description ?? "").replace(/,/g, "")) || 0; + const colour = qty_to_make && producible < qty_to_make ? "red" : "green"; + return `${value}`; + } + return `${value}`; + } + + return value; + }, +}; From 24e7ff55dec724ca4015dbc4fe089d8908b87d19 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:01:20 +0530 Subject: [PATCH 05/24] =?UTF-8?q?fix:=20include=20payment=20deductions=20i?= =?UTF-8?q?n=20sales/purchase=20register=20ledger=20bal=E2=80=A6=20(backpo?= =?UTF-8?q?rt=20#58437)=20(#58679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> --- erpnext/accounts/report/utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 3661e787f41..189ac56c874 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -304,6 +304,9 @@ def get_payment_entries(filters, args): pe.mode_of_payment, pe.project, pe.cost_center, + pe.payment_type, + pe.source_exchange_rate, + pe.target_exchange_rate, ) .where( (pe.docstatus == 1) @@ -314,6 +317,22 @@ def get_payment_entries(filters, args): ) query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True) payment_entries = query.run(as_dict=True) + + if payment_entries: + ded = frappe.qb.DocType("Payment Entry Deduction") + deduction_totals = frappe._dict( + frappe.qb.from_(ded) + .select(ded.parent, Sum(ded.amount)) + .where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0)) + .groupby(ded.parent) + .run() + ) + for d in payment_entries: + exchange_rate = ( + d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate + ) or 1 + d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate + return payment_entries From cd93007780a6deb589c3cffc6d3dfc4f3c28fe04 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 4 Sep 2026 12:21:01 +0530 Subject: [PATCH 06/24] fix(batch): show Expired status only after expiry date has passed (#58736) Co-authored-by: Ajish18 (cherry picked from commit 00f04fc084bd48140013172f9f71c8ad2c07df8a) --- erpnext/stock/doctype/batch/batch_list.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch_list.js b/erpnext/stock/doctype/batch/batch_list.js index 644ef131399..a64aff903c3 100644 --- a/erpnext/stock/doctype/batch/batch_list.js +++ b/erpnext/stock/doctype/batch/batch_list.js @@ -5,12 +5,12 @@ frappe.listview_settings["Batch"] = { return [__("Disabled"), "gray", "disabled,=,1"]; } else if ( doc.expiry_date && - frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) <= 0 + frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) < 0 ) { return [ __("Expired"), "red", - "expiry_date,not in,|expiry_date,<=,Today|batch_qty,>,0|disabled,=,0", + "expiry_date,not in,|expiry_date,<,Today|batch_qty,>,0|disabled,=,0", ]; } else if (!doc.batch_qty) { return [__("Empty"), "gray", "batch_qty,=,0|disabled,=,0"]; From 934c7d833ce2c9ffea045fe8ddf13d1b2f68247f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:53:44 +0000 Subject: [PATCH 07/24] fix(setup): strict permissions for transaction deletion record (backport #58687) (#58722) Co-authored-by: Diptanil Saha --- erpnext/setup/doctype/company/company.py | 7 ++++++- .../transaction_deletion_record.json | 11 +++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 569d06349f4..fd4cbdbef34 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -986,8 +986,12 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No return {"primary_address": primary_address, "shipping_address": shipping_address} -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def create_transaction_deletion_request(company): + frappe.only_for("System Manager") + # User Permission check + frappe.has_permission("Company", ptype="delete", doc=company, throw=True) + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( is_deletion_doc_running, ) @@ -995,6 +999,7 @@ def create_transaction_deletion_request(company): is_deletion_doc_running(company) tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company}) + tdr.submit() tdr.start_deletion_tasks() diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json index b9f911dbe8c..8df377aab00 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "TDL.####", "creation": "2021-04-06 20:17:18.404716", "doctype": "DocType", @@ -141,19 +142,18 @@ "read_only": 1 } ], + "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-21 10:29:19.456413", + "modified": "2026-09-02 20:32:19.679290", "modified_by": "Administrator", "module": "Setup", "name": "Transaction Deletion Record", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { - "create": 1, - "delete": 1, "email": 1, "export": 1, "print": 1, @@ -161,7 +161,6 @@ "report": 1, "role": "System Manager", "share": 1, - "submit": 1, "write": 1 } ], @@ -169,4 +168,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From a348d413d0143e63570c3e0aeb5c412107c8f64d Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 4 Sep 2026 13:02:59 +0530 Subject: [PATCH 08/24] fix: check material request price list permission (#58740) (cherry picked from commit 0b1f1d6851092a62f714d08a653ed689700581aa) --- .../material_request/material_request.js | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 1a868dc546a..cdf6b8cba7a 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -93,8 +93,27 @@ frappe.ui.form.on("Material Request", { erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); if (!frm.doc.buying_price_list) { const buying_price_list = frappe.defaults.get_default("buying_price_list"); - if (frappe.has_permission("Price List", "read", buying_price_list)) { - frm.set_value("buying_price_list", buying_price_list); + if (buying_price_list) { + const docname = frm.doc.name; + frappe.call({ + type: "GET", + method: "frappe.client.has_permission", + no_spinner: true, + args: { + doctype: "Price List", + docname: buying_price_list, + perm_type: "read", + }, + callback: ({ message }) => { + if ( + message?.has_permission && + frm.doc.name === docname && + !frm.doc.buying_price_list + ) { + frm.set_value("buying_price_list", buying_price_list); + } + }, + }); } } }, From 7cbf8b8660d4b7091d2e7f7992740f7374fe240e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 4 Sep 2026 14:32:57 +0530 Subject: [PATCH 09/24] revert(manufacturing): remove material coverage changes on version 15 (#58715) --- .../doctype/job_card/test_job_card.py | 38 ----- .../doctype/work_order/services/__init__.py | 1 - .../work_order/services/material_coverage.py | 22 --- .../doctype/work_order/test_work_order.py | 138 +----------------- .../doctype/work_order/work_order.py | 29 ++-- erpnext/patches.txt | 1 - .../repair_work_order_material_transfer.py | 65 --------- .../material_request/material_request.py | 2 - .../stock/doctype/stock_entry/stock_entry.py | 122 ---------------- 9 files changed, 19 insertions(+), 399 deletions(-) delete mode 100644 erpnext/manufacturing/doctype/work_order/services/__init__.py delete mode 100644 erpnext/manufacturing/doctype/work_order/services/material_coverage.py delete mode 100644 erpnext/patches/v16_0/repair_work_order_material_transfer.py diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 9d50a57b8be..ceb5ce299eb 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -206,43 +206,6 @@ class TestJobCard(FrappeTestCase): # transfer was made for 2 fg qty in first transfer Stock Entry self.assertEqual(transfer_entry_2.fg_completed_qty, 0) - def test_material_request_stock_entry_uses_job_card_coverage(self): - from erpnext.stock.doctype.material_request.material_request import make_stock_entry - - self.transfer_material_against = "Job Card" - self.source_warehouse = "Stores - _TC" - job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) - mr = make_material_request(job_card.name) - mr.schedule_date = today() - for row in mr.items: - row.qty = flt(row.qty) / 2 - row.stock_qty = flt(row.stock_qty) / 2 - mr.submit() - - stock_entry = make_stock_entry(mr.name) - self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2) - - selected_row = mr.items[0] - try: - frappe.flags.selected_children = {"items": [selected_row.name]} - selected_stock_entry = make_stock_entry(mr.name) - finally: - frappe.flags.selected_children = None - - self.assertEqual( - [row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item] - ) - self.assertEqual(selected_stock_entry.fg_completed_qty, 0) - - for row in mr.items: - transferred_qty = flt(row.stock_qty) / 2 - frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty) - frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty) - mr.reload() - - repeated_stock_entry = make_stock_entry(mr.name) - self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4) - @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1}) def test_job_card_excess_material_transfer(self): "Test transferring more than required RM against Job Card." @@ -653,7 +616,6 @@ class TestJobCard(FrappeTestCase): self.assertEqual(ste.job_card, job_card_name) self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) - self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity")) def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( diff --git a/erpnext/manufacturing/doctype/work_order/services/__init__.py b/erpnext/manufacturing/doctype/work_order/services/__init__.py deleted file mode 100644 index 8b137891791..00000000000 --- a/erpnext/manufacturing/doctype/work_order/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/erpnext/manufacturing/doctype/work_order/services/material_coverage.py b/erpnext/manufacturing/doctype/work_order/services/material_coverage.py deleted file mode 100644 index 8363e0c1284..00000000000 --- a/erpnext/manufacturing/doctype/work_order/services/material_coverage.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -from collections.abc import Mapping - -from frappe.utils import flt - - -def get_minimum_material_coverage_fraction( - required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int -) -> float: - """Return the least-covered component ratio at the configured quantity precision.""" - coverage = [] - for item_code, required in required_qty.items(): - transferred = flt(transferred_qty.get(item_code)) - # Stored values can differ after the digits that the user can enter or see. - if flt(transferred, precision) == flt(required, precision): - coverage.append(1.0) - else: - coverage.append(transferred / required) - - return min(coverage, default=0.0) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 069907c1b05..43475b7fdb2 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1461,11 +1461,9 @@ class TestWorkOrder(FrappeTestCase): del transfer_entry.get("items")[0] # transfer only one RM transfer_entry.submit() - # One required item is still missing, so no finished-good quantity is covered yet. + # WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending work_order.reload() - self.assertEqual(transfer_entry.fg_completed_qty, 0) - self.assertEqual(work_order.material_transferred_for_manufacturing, 0) - self.assertEqual(work_order.status, "In Process") + self.assertEqual(work_order.material_transferred_for_manufacturing, 1) self.assertEqual(work_order.required_items[0].transferred_qty, 0) self.assertEqual(work_order.required_items[1].transferred_qty, 2) @@ -1485,47 +1483,6 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) - def test_material_transfer_claim_follows_actual_coverage(self): - work_order = make_wo_order_test_record(planned_start_date=now(), qty=4) - test_stock_entry.make_stock_entry( - item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 - ) - test_stock_entry.make_stock_entry( - item_code="_Test Item Home Desktop 100", - target="_Test Warehouse - _TC", - qty=20, - basic_rate=1000.0, - ) - - transfer_entry = frappe.get_doc( - make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4) - ) - for row in transfer_entry.items: - if row.item_code == "_Test Item": - row.qty = 1 - transfer_entry.submit() - - work_order.reload() - self.assertEqual(transfer_entry.fg_completed_qty, 1) - self.assertEqual(work_order.material_transferred_for_manufacturing, 1) - - remainder_entry = frappe.get_doc( - make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3) - ) - remainder_entry.submit() - - work_order.reload() - self.assertEqual(remainder_entry.fg_completed_qty, 3) - self.assertEqual(work_order.material_transferred_for_manufacturing, 4) - - def test_material_coverage_cap_skips_manufacture_entry(self): - work_order = make_wo_order_test_record(planned_start_date=now(), qty=1) - manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) - manufacture_entry.pro_doc = work_order - manufacture_entry._action = "submit" - - self.assertFalse(manufacture_entry._should_cap_completed_qty()) - def test_material_transferred_min_fraction_on_partial_pick_list(self): """Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing' must reflect the least-transferred required item (the bottleneck), instead of being @@ -1588,97 +1545,6 @@ class TestWorkOrder(FrappeTestCase): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) - def test_material_transferred_ignores_hidden_precision_difference(self): - work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) - test_stock_entry.make_stock_entry( - item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 - ) - test_stock_entry.make_stock_entry( - item_code="_Test Item Home Desktop 100", - target="_Test Warehouse - _TC", - qty=10, - basic_rate=1000.0, - ) - - precision = work_order.precision("required_qty", "required_items") - hidden_difference = 4 / (10 ** (precision + 1)) - row = work_order.required_items[0] - row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False) - work_order.reload() - required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items} - - transfer_entry = frappe.get_doc( - make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0) - ) - for item in transfer_entry.items: - item.qty = flt(required_qty[item.item_code], precision) - item.transfer_qty = item.qty - transfer_entry.submit() - - work_order.reload() - self.assertEqual( - flt(work_order.required_items[0].required_qty, precision), - flt(work_order.required_items[0].transferred_qty, precision), - ) - self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty) - - def test_repair_material_transfer_precision_patch(self): - from erpnext.patches.v16_0.repair_work_order_material_transfer import ( - execute, - get_precision_affected_work_orders, - ) - - precision = frappe.get_precision("Work Order Item", "required_qty") - hidden_difference = 4 / (10 ** (precision + 1)) - work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) - for index, row in enumerate(work_order.required_items): - required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0) - row.db_set( - { - "required_qty": required_qty, - "transferred_qty": flt(required_qty, precision), - }, - update_modified=False, - ) - work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False) - - partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) - for row in partial_work_order.required_items: - row.db_set("transferred_qty", row.required_qty, update_modified=False) - partial_row = partial_work_order.required_items[0] - partial_row.db_set( - "transferred_qty", - flt(partial_row.required_qty, precision) - (1 / (10**precision)), - update_modified=False, - ) - partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False) - - terminal_work_orders = [] - for status in ("Stopped", "Closed", "Completed"): - terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) - for row in terminal_work_order.required_items: - row.db_set("transferred_qty", row.required_qty, update_modified=False) - terminal_work_order.db_set( - {"material_transferred_for_manufacturing": 1.99, "status": status}, - update_modified=False, - ) - terminal_work_orders.append(terminal_work_order) - - updates = get_precision_affected_work_orders() - self.assertIn(work_order.name, updates) - self.assertNotIn(partial_work_order.name, updates) - for terminal_work_order in terminal_work_orders: - self.assertNotIn(terminal_work_order.name, updates) - - execute() - work_order.reload() - partial_work_order.reload() - self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty) - self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99) - for terminal_work_order in terminal_work_orders: - terminal_work_order.reload() - self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99) - def test_status_in_process_when_only_one_required_item_transferred(self): """Stock Entry created from a Pick List that picked only one of the required items: min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index dd5a66e096c..ab18ad60c9f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -31,9 +31,6 @@ from erpnext.manufacturing.doctype.bom.bom import ( from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( get_mins_between_operations, ) -from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( - get_minimum_material_coverage_fraction, -) from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life @@ -461,7 +458,8 @@ class WorkOrder(Document): return status def has_transferred_material(self): - """True if any raw material was transferred against this work order.""" + """True if any raw material was transferred against this work order via a pick list + (these leave material_transferred_for_manufacturing at 0 via the min-fraction rule).""" ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") qty = ( @@ -474,6 +472,7 @@ class WorkOrder(Document): & (ste.docstatus == 1) & (ste.purpose == "Material Transfer for Manufacture") & (ste.is_return == 0) + & (ste.pick_list.isnotnull()) ) ).run()[0][0] return flt(qty) > 0 @@ -1304,13 +1303,20 @@ class WorkOrder(Document): self.recompute_material_transferred_for_manufacturing(transferred_items) def recompute_material_transferred_for_manufacturing(self, transferred_items): - """Set transferred quantity from the raw materials that have actually moved.""" + """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty.""" # Job Card transfers use the minimum completed quantity across operations. if self.operations and self.transfer_material_against == "Job Card": return - claimed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture") + # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the + # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly. + sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture") + if sum_fg_completed_qty: + self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty) + return + # Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers + # so partial availability does not prematurely mark the work order as fully transferred. required_by_item = {} for row in self.required_items: if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: @@ -1320,13 +1326,12 @@ class WorkOrder(Document): if not required_by_item: return - min_fraction = get_minimum_material_coverage_fraction( - required_by_item, - transferred_items, - self.precision("required_qty", "required_items"), + min_fraction = min( + flt(transferred_items.get(item_code) or 0) / required_qty + for item_code, required_qty in required_by_item.items() ) - covered_qty = min_fraction * flt(self.qty) - material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty)) + min_fraction = min(min_fraction, 1.0) + material_transferred = min_fraction * flt(self.qty) self.db_set("material_transferred_for_manufacturing", material_transferred) def update_returned_qty(self): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d5a7ff156d3..755d274227f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -447,7 +447,6 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter erpnext.patches.v15_0.fix_titles erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root -erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file diff --git a/erpnext/patches/v16_0/repair_work_order_material_transfer.py b/erpnext/patches/v16_0/repair_work_order_material_transfer.py deleted file mode 100644 index 31e119f8442..00000000000 --- a/erpnext/patches/v16_0/repair_work_order_material_transfer.py +++ /dev/null @@ -1,65 +0,0 @@ -import frappe -from frappe.utils import flt -from pypika import functions as fn - -from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( - get_minimum_material_coverage_fraction, -) - - -def execute(): - updates = get_precision_affected_work_orders() - frappe.db.bulk_update("Work Order", updates, update_modified=False) - - -def get_precision_affected_work_orders(): - """Return Work Orders whose components cover the plan at quantity precision.""" - work_orders = {} - for row in _get_candidate_rows(): - work_order = work_orders.setdefault( - row.work_order, - {"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}}, - ) - item_code = row.item_code - work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt( - row.required_qty - ) - work_order["transferred_qty"][item_code] = max( - work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty) - ) - - precision = frappe.get_precision("Work Order Item", "required_qty") - return { - name: {"material_transferred_for_manufacturing": values["qty"]} - for name, values in work_orders.items() - if get_minimum_material_coverage_fraction( - values["required_qty"], values["transferred_qty"], precision - ) - >= 1.0 - } - - -def _get_candidate_rows(): - work_order = frappe.qb.DocType("Work Order") - required_item = frappe.qb.DocType("Work Order Item") - return ( - frappe.qb.from_(work_order) - .inner_join(required_item) - .on(required_item.parent == work_order.name) - .select( - work_order.name.as_("work_order"), - work_order.qty, - required_item.item_code, - required_item.required_qty, - required_item.transferred_qty, - ) - .where( - (work_order.docstatus == 1) - & (work_order.status.notin(["Stopped", "Closed", "Completed"])) - & (fn.Coalesce(work_order.skip_transfer, 0) == 0) - & (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty) - & (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card") - & (required_item.include_item_in_manufacturing == 1) - & (required_item.required_qty > 0) - ) - ).run(as_dict=True) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index e5d8dc0d474..12b1dfce346 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -806,8 +806,6 @@ def make_stock_entry(source_name, target_doc=None): target.fg_completed_qty = job_card_details[0].for_quantity target.from_bom = 1 - target.cap_completed_qty_to_material_coverage() - doclist = get_mapped_doc( "Material Request", source_name, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index c2f991321c2..fb0fff2a601 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -32,9 +32,6 @@ from erpnext.manufacturing.doctype.bom.bom import ( get_scrap_items_from_sub_assemblies, validate_bom_no, ) -from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( - get_minimum_material_coverage_fraction, -) from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -265,7 +262,6 @@ class StockEntry(StockController): self.calculate_rate_and_amount() self.validate_putaway_capacity() self.validate_component_and_quantities() - self._cap_completed_qty_to_material_coverage() self.validate_finished_good_serial_batch_for_work_order() if not self.get("purpose") == "Manufacture": @@ -1190,124 +1186,6 @@ class StockEntry(StockController): title=_("Missing Item"), ) - def _cap_completed_qty_to_material_coverage(self): - if not self._should_cap_completed_qty(): - return - # Keep an excessive claim intact so the Work Order allowance check can reject it. - max_qty = flt(self.pro_doc.qty) - overproduction_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") - ) - to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt( - self.fg_completed_qty - ) - transfer_limit_qty = max_qty + (max_qty * overproduction_percentage / 100) - if transfer_limit_qty < to_transfer_qty: - return - - self.cap_completed_qty_to_material_coverage() - - def cap_completed_qty_to_material_coverage(self): - required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data() - if not required_qty: - return - - covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) - for row in self.items: - if self.job_card: - material_reference = row.job_card_item - transferred = flt(row.qty) - else: - material_reference = row.original_item or row.item_code - transferred = flt(row.qty) * flt(row.conversion_factor or 1) - - if material_reference in required_qty and (self.job_card or row.s_warehouse): - transferred_qty[material_reference] += transferred - - covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) - covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty")) - self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry) - - def _should_cap_completed_qty(self): - if self.get("_action") != "submit": - return False - if self.purpose != "Material Transfer for Manufacture": - return False - if not self.pro_doc or not self.fg_completed_qty: - return False - if self.is_return or self.get("is_additional_transfer_entry"): - return False - return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card") - - def _get_material_coverage_data(self): - if self.job_card: - return self._get_job_card_material_qty() - return self._get_work_order_material_qty() - - def _get_job_card_material_qty(self): - job_card = frappe.get_doc("Job Card", self.job_card) - required_qty = {} - transferred_qty = {} - for row in job_card.items: - if flt(row.required_qty) <= 0: - continue - required_qty[row.name] = flt(row.required_qty) - transferred_qty[row.name] = flt(row.transferred_qty) - - return ( - required_qty, - transferred_qty, - self._get_job_card_target_qty(job_card), - job_card.precision("required_qty", "items"), - ) - - def _get_job_card_target_qty(self, job_card): - required_by_item = {} - for row in job_card.items: - required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) - - work_order_required_by_item = {} - work_order = frappe.get_doc("Work Order", job_card.work_order) - for row in work_order.required_items: - if job_card.operation != row.operation: - continue - work_order_required_by_item[row.item_code] = work_order_required_by_item.get( - row.item_code, 0.0 - ) + flt(row.required_qty) - - target_qty = [ - item_required * flt(work_order.qty) / work_order_required_by_item[item_code] - for item_code, item_required in required_by_item.items() - if work_order_required_by_item.get(item_code) - ] - return min(target_qty) if target_qty else job_card.for_quantity - - def _get_work_order_material_qty(self): - required_qty = {} - transferred_qty = {} - for row in self.pro_doc.required_items: - if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: - continue - required_qty[row.item_code] = required_qty.get(row.item_code, 0.0) + flt(row.required_qty) - # Duplicate required-item rows each hold the aggregate transferred quantity. - transferred_qty[row.item_code] = max( - transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty) - ) - return ( - required_qty, - transferred_qty, - self.pro_doc.qty, - self.pro_doc.precision("required_qty", "required_items"), - ) - - def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision): - min_fraction = get_minimum_material_coverage_fraction( - required_qty, - transferred_qty, - precision, - ) - return min_fraction * flt(target_qty) - def _validate_no_excess_transfer(self): if self.is_return: return From 2ee5f6b1c44fa455fd617e6f5fb8c67f035f6ff8 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:05:39 +0530 Subject: [PATCH 10/24] fix: add reconciliation after submit logic for bank transactions (#57330) Co-authored-by: Poovetha (cherry picked from commit c3319d74cf7d957bfe1669b8ba89de1941899f26) --- .../dialog_manager.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 16d4e9971d8..5606f991924 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -602,6 +602,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { }, callback: (r) => { const doc = frappe.model.sync(r.message); + track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name); frappe.set_route("Form", doc[0].doctype, doc[0].name); }, }); @@ -622,9 +623,77 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { }, callback: (r) => { var doc = frappe.model.sync(r.message); + track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name); frappe.set_route("Form", doc[0].doctype, doc[0].name); }, }); } } }; + +const pending_reconciliations = new Map(); + +const voucher_key = (doctype, docname) => `${doctype}:${docname}`; + +const track_voucher = (doctype, docname, bank_transaction_name) => { + pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name); +}; + +for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) { + frappe.ui.form.on(voucher_doctype, { + before_save(frm) { + frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name); + }, + + after_save(frm) { + const old_key = frm.__pending_reconciliation_key; + delete frm.__pending_reconciliation_key; + + const new_key = voucher_key(frm.doctype, frm.doc.name); + if (!old_key || old_key === new_key || !pending_reconciliations.has(old_key)) return; + + // Follow the rename so the voucher stays identifiable on submit + pending_reconciliations.set(new_key, pending_reconciliations.get(old_key)); + pending_reconciliations.delete(old_key); + }, + + on_submit(frm) { + const key = voucher_key(frm.doctype, frm.doc.name); + const bank_transaction_name = pending_reconciliations.get(key); + if (!bank_transaction_name) return; + + pending_reconciliations.delete(key); + + frappe.call({ + method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers", + args: { + bank_transaction_name: bank_transaction_name, + vouchers: [ + { + payment_doctype: frm.doctype, + payment_name: frm.doc.name, + }, + ], + is_new_voucher: true, + }, + callback: (r) => { + if (r.exc) return; + frappe.show_alert({ + message: __("Bank Transaction {0} Matched", [bank_transaction_name]), + indicator: "green", + }); + }, + error: () => { + frappe.msgprint({ + title: __("Reconciliation Failed"), + indicator: "red", + message: __( + "{0} {1} was submitted but could not be reconciled against Bank Transaction {2}. Match it manually from the Bank Reconciliation Tool.", + [__(frm.doctype), frm.doc.name, bank_transaction_name] + ), + }); + }, + }); + }, + }); +} From 684def8818026a25a6a77687ee7e18c9e3756366 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 14:43:45 +0530 Subject: [PATCH 11/24] test(manufacturing): refresh cached production plans before assertion (#58801) --- .../doctype/production_plan/test_production_plan.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 053c7ba7673..221ace4008d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1402,9 +1402,11 @@ class TestProductionPlan(FrappeTestCase): self.assertEqual(after_qty, before_qty) - completed_plans = get_non_completed_production_plans() + # Plan submission cached this list before the Work Orders updated ordered quantities. + frappe.local.request_cache.clear() + non_completed_plans = get_non_completed_production_plans() for plan in plans: - self.assertFalse(plan in completed_plans) + self.assertNotIn(plan, non_completed_plans) def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(self): from erpnext.stock.utils import get_or_make_bin From baa30e703d9910178c192b94c56aef89c818fa36 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 7 Sep 2026 15:20:22 +0530 Subject: [PATCH 12/24] fix: check write permission in whitelisted document methods (backport #58689) (#58702) * fix: check write permission in whitelisted document methods * test: permission coverage for production plan status roll-ups * fix: add type hints to whitelisted arguments and submit MR in test * test: clear request cache before reading non-completed production plans --- erpnext/crm/doctype/lead/lead.py | 4 +- .../production_plan/production_plan.py | 4 +- .../production_plan/test_production_plan.py | 57 +++++++++++++++++++ .../doctype/work_order/work_order.py | 2 + .../import_supplier_invoice.py | 2 + erpnext/stock/doctype/batch/batch.py | 2 + .../material_request/material_request.py | 1 + .../repost_item_valuation.py | 2 + .../stock_reposting_settings.py | 2 + 9 files changed, 74 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 79fc7c1c186..6b4c6c936b9 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -233,7 +233,9 @@ class Lead(SellingController, CRMNote): return frappe.db.get_value("Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"}) @frappe.whitelist() - def create_prospect_and_contact(self, data): + def create_prospect_and_contact(self, data: dict): + self.check_permission("write") + data = frappe._dict(data) if data.create_contact: self.create_contact() diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index c293b7d034a..90e791fc9ba 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -636,7 +636,9 @@ class ProductionPlan(Document): frappe.delete_doc("Work Order", d.name) @frappe.whitelist() - def set_status(self, close=None, update_bin=False): + def set_status(self, close: bool | None = None, update_bin: bool = False): + self.check_permission("write") + self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus) if close: diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 221ace4008d..c9c266df0bb 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1405,6 +1405,7 @@ class TestProductionPlan(FrappeTestCase): # Plan submission cached this list before the Work Orders updated ordered quantities. frappe.local.request_cache.clear() non_completed_plans = get_non_completed_production_plans() + for plan in plans: self.assertNotIn(plan, non_completed_plans) @@ -2199,6 +2200,46 @@ class TestProductionPlan(FrappeTestCase): for row in plan.sub_assembly_items: self.assertEqual(row.ordered_qty, 10.0) + def test_set_status_requires_write_permission(self): + pln = create_production_plan(item_code="Test Production Item 1") + + with self.set_user(create_user_without_production_plan_access()): + doc = frappe.get_doc("Production Plan", pln.name) + self.assertRaises(frappe.PermissionError, doc.set_status) + + def test_work_order_status_rollup_without_production_plan_permission(self): + pln = create_production_plan(item_code="Test Production Item 1") + pln.make_work_order() + + wo_name = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name") + frappe.db.set_value("Production Plan Item", pln.po_items[0].name, "ordered_qty", 99) + + with self.set_user(create_user_without_production_plan_access()): + frappe.get_doc("Work Order", wo_name).update_ordered_qty() + + pln.reload() + self.assertEqual(pln.po_items[0].ordered_qty, 0.0) + self.assertEqual(pln.status, "Submitted") + + def test_material_request_status_rollup_without_production_plan_permission(self): + pln = create_production_plan(item_code="Test Production Item 1") + pln.make_material_request() + + plan_item = pln.mr_items[0].name + mr_name = frappe.db.get_value( + "Material Request Item", {"material_request_plan_item": plan_item}, "parent" + ) + frappe.get_doc("Material Request", mr_name).submit() + frappe.db.set_value("Material Request Plan Item", plan_item, "requested_qty", 0) + + with self.set_user(create_user_without_production_plan_access()): + frappe.get_doc("Material Request", mr_name).update_requested_qty_in_production_plan() + + pln.reload() + requested_qty = frappe.db.get_value("Material Request Plan Item", plan_item, "requested_qty") + self.assertGreater(requested_qty, 0) + self.assertEqual(pln.status, "Material Requested") + def create_production_plan(**args): """ @@ -2304,3 +2345,19 @@ def make_bom(**args): bom.submit() return bom + + +def create_user_without_production_plan_access(): + user = "test_production_plan_no_access@example.com" + if not frappe.db.exists("User", user): + frappe.get_doc( + { + "doctype": "User", + "email": user, + "first_name": "Production Plan No Access", + "send_welcome_email": 0, + "roles": [{"doctype": "Has Role", "role": "Stock User"}], + } + ).insert(ignore_permissions=True) + + return user diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index ab18ad60c9f..dfddb857aab 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -558,6 +558,7 @@ class WorkOrder(Document): def update_production_plan_status(self): production_plan = frappe.get_doc("Production Plan", self.production_plan) + production_plan.flags.ignore_permissions = True produced_qty = 0 if self.production_plan_item: total_qty = frappe.get_all( @@ -900,6 +901,7 @@ class WorkOrder(Document): ) doc = frappe.get_doc("Production Plan", self.production_plan) + doc.flags.ignore_permissions = True doc.set_status() doc.db_set("status", doc.status) diff --git a/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py b/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py index fc2dc964c9d..2f947eb39f0 100644 --- a/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py +++ b/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py @@ -159,6 +159,8 @@ class ImportSupplierInvoice(Document): @frappe.whitelist() def process_file_data(self): + self.check_permission("write") + self.db_set("status", "Processing File Data", notify=True, commit=True) frappe.enqueue_doc(self.doctype, self.name, "import_xml_data", queue="long", timeout=3600) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index f7147f0fa59..36c30b933fe 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -159,6 +159,8 @@ class Batch(Document): @frappe.whitelist() def recalculate_batch_qty(self): + self.check_permission("write") + batches = get_batch_qty( batch_no=self.name, item_code=self.item, diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 12b1dfce346..77a0b06d267 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -428,6 +428,7 @@ class MaterialRequest(BuyingController): for production_plan in production_plans: doc = frappe.get_doc("Production Plan", production_plan) + doc.flags.ignore_permissions = True doc.set_status() doc.db_set("status", doc.status) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index f7cc4b90c36..20c9b58e09a 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -265,6 +265,8 @@ class RepostItemValuation(Document): @frappe.whitelist() def restart_reposting(self): + self.check_permission("write") + self.set_status("Queued", write=False) self.current_index = 0 self.distinct_item_and_warehouse = None diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index c8c97116e20..1f1671004fd 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -50,6 +50,8 @@ class StockRepostingSettings(Document): def convert_to_item_wh_reposting(self): """Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled.""" + self.check_permission("write") + reposting_data = get_reposting_entries() vouchers = [d.voucher_no for d in reposting_data] From 6c6ee7c52488cfb4090aaa3753f22e50b5102a9d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 15:52:57 +0530 Subject: [PATCH 13/24] fix(accounts): support drop-shipped items in Gross Profit report (#58686) * fix(reports): apportion drop-ship buying cost and fix return-matching bug in Gross Profit report * test(accounts): fix Gross Profit test setup --------- Co-authored-by: ervishnucs --- .../report/gross_profit/gross_profit.py | 173 ++++++-- .../report/gross_profit/test_gross_profit.py | 377 +++++++++++++++++- 2 files changed, 522 insertions(+), 28 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index f557635aac1..b5eaea64761 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -510,6 +510,7 @@ class GrossProfitGenerator: self.average_buying_rate = {} self.filters = frappe._dict(filters) self.load_invoice_items() + self.load_drop_ship_buying_rates() self.get_delivery_notes() self.load_product_bundle() @@ -518,6 +519,7 @@ class GrossProfitGenerator: self.load_non_stock_items() self.get_returned_invoice_items() + self.allocate_legacy_return_items() self.process() def process(self): @@ -534,6 +536,8 @@ class GrossProfitGenerator: base_amount = 0 for row in reversed(self.si_list): + sales_invoice_item = row.item_row + if self.filters.get("group_by") == "Monthly": row.monthly = formatdate(row.posting_date, "MMM YYYY") @@ -585,14 +589,18 @@ class GrossProfitGenerator: # get buying rate if flt(row.qty): - row.buying_rate = flt(row.buying_amount / flt(row.qty), self.float_precision) + row.buying_rate = ( + flt(row.buying_amount / flt(row.qty), self.float_precision) + if not row.delivered_by_supplier + else None + ) row.base_rate = flt(row.base_amount / flt(row.qty), self.float_precision) else: if self.is_not_invoice_row(row): row.buying_rate, row.base_rate = 0.0, 0.0 if self.is_not_invoice_row(row): - self.update_return_invoices(row) + self.update_return_invoices(row, sales_invoice_item) if grouped_by_invoice and row.indent == 1.0: buying_amount += row.buying_amount @@ -619,24 +627,31 @@ class GrossProfitGenerator: if self.grouped: self.get_average_rate_based_on_group_by() - def update_return_invoices(self, row): - if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]: - returned_item_rows = self.returned_invoices[row.parent][row.item_code] - for returned_item_row in returned_item_rows: - # returned_items 'qty' should be stateful - if returned_item_row.qty != 0: - if row.qty >= abs(returned_item_row.qty): - row.qty += returned_item_row.qty - row.base_amount += flt(returned_item_row.base_amount, self.currency_precision) - returned_item_row.qty = 0 - returned_item_row.base_amount = 0 + def update_return_invoices(self, row, sales_invoice_item): + returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item) + if not returned_item_rows: + return - else: - row.qty = 0 - row.base_amount = 0 - returned_item_row.qty += row.qty - returned_item_row.base_amount += row.base_amount + for returned_item_row in returned_item_rows: + # returned_items 'qty' should be stateful + if returned_item_row.qty != 0: + if row.qty >= abs(returned_item_row.qty): + row.qty += returned_item_row.qty + row.base_amount += flt(returned_item_row.base_amount, self.currency_precision) + returned_item_row.qty = 0 + returned_item_row.base_amount = 0 + else: + returned_item_row.qty += row.qty + returned_item_row.base_amount += row.base_amount + row.qty = 0 + row.base_amount = 0 + + if row.delivered_by_supplier: + buying_amount = self.get_drop_ship_buying_amount(row) + if buying_amount is not None: + row.buying_amount = flt(buying_amount, self.currency_precision) + else: row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision) def get_average_rate_based_on_group_by(self): @@ -718,7 +733,8 @@ class GrossProfitGenerator: returned_invoices = frappe.db.sql( """ select - si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against + si.name, si_item.item_code, si_item.sales_invoice_item, + si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against from `tabSales Invoice` si, `tabSales Invoice Item` si_item where @@ -732,17 +748,73 @@ class GrossProfitGenerator: ) self.returned_invoices = frappe._dict() + self.legacy_returned_invoices = frappe._dict() for inv in returned_invoices: - self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault( - inv.item_code, [] + invoice_returns = ( + self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices + ) + invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault( + inv.sales_invoice_item or inv.item_code, [] ).append(inv) - def skip_row(self, row): - if self.filters.get("group_by") != "Invoice": - if not row.get(scrub(self.filters.get("group_by", ""))): - return True + def allocate_legacy_return_items(self): + source_invoice_items = {} + for row in reversed(self.si_list): + if row.is_return or not row.parent or self.skip_row(row): + continue - return False + source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty) + + for invoice, legacy_invoice_items in self.legacy_returned_invoices.items(): + returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict()) + for item_code, legacy_item_rows in legacy_invoice_items.items(): + targets = self.get_legacy_return_targets( + source_invoice_items.get((invoice, item_code), {}), returned_invoice_items + ) + for legacy_item_row in legacy_item_rows: + self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items) + + def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items): + targets = [] + for item_row, qty in source_invoice_items.items(): + linked_return_qty = sum( + flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, []) + ) + if available_qty := max(flt(qty) + linked_return_qty, 0): + targets.append(frappe._dict(item_row=item_row, available_qty=available_qty)) + + targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row))) + return targets + + def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items): + remaining_qty = abs(flt(legacy_item_row.qty)) + remaining_base_amount = flt(legacy_item_row.base_amount) + if not remaining_qty: + return + + qty_sign = -1 if legacy_item_row.qty < 0 else 1 + for target in targets: + if not target.available_qty: + continue + + allocated_qty = min(target.available_qty, remaining_qty) + allocated_item_row = frappe._dict(legacy_item_row.copy()) + allocated_item_row.qty = qty_sign * allocated_qty + allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty + returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row) + + target.available_qty -= allocated_qty + remaining_qty -= allocated_qty + remaining_base_amount -= allocated_item_row.base_amount + if not remaining_qty: + break + + def skip_row(self, row): + group_by = self.filters.get("group_by") + if group_by in {"Invoice", "Monthly"}: + return False + + return not row.get(scrub(group_by)) def get_buying_amount_from_product_bundle(self, row, product_bundle): buying_amount = 0.0 @@ -776,6 +848,11 @@ class GrossProfitGenerator: # IMP NOTE # stock_ledger_entries should already be filtered by item_code and warehouse and # sorted by posting_date desc, posting_time desc + if row.delivered_by_supplier: + buying_amount = self.get_drop_ship_buying_amount(row) + if buying_amount is not None: + return buying_amount + if item_code in self.non_stock_items and (row.project or row.cost_center): # Issue 6089-Get last purchasing rate for non-stock item item_rate = self.get_last_purchase_rate(item_code, row) @@ -807,6 +884,49 @@ class GrossProfitGenerator: return flt(row.qty) * self.get_average_buying_rate(row, item_code) + def load_drop_ship_buying_rates(self): + self.drop_ship_buying_rates = {} + sales_order_items = { + row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail + } + if not sales_order_items: + return + + from frappe.query_builder.functions import Sum + + purchase_order_item = frappe.qb.DocType("Purchase Order Item") + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") + buying_amounts = ( + frappe.qb.from_(purchase_order_item) + .left_join(purchase_invoice_item) + .on( + (purchase_invoice_item.po_detail == purchase_order_item.name) + & (purchase_invoice_item.docstatus == 1) + ) + .select( + purchase_order_item.sales_order_item, + Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"), + Sum(purchase_invoice_item.stock_qty).as_("stock_qty"), + ) + .where( + (purchase_order_item.sales_order_item.isin(sales_order_items)) + & (purchase_order_item.docstatus == 1) + ) + .groupby(purchase_order_item.sales_order_item) + .run(as_dict=True) + ) + + for row in buying_amounts: + self.drop_ship_buying_rates[row.sales_order_item] = ( + flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0 + ) + + def get_drop_ship_buying_amount(self, row): + if row.so_detail not in self.drop_ship_buying_rates: + return + + return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail] + def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code): from frappe.query_builder.functions import Avg @@ -951,6 +1071,7 @@ class GrossProfitGenerator: SalesInvoice.is_return, SalesInvoiceItem.cost_center, SalesInvoiceItem.serial_and_batch_bundle, + SalesInvoiceItem.delivered_by_supplier, ) if self.filters.group_by == "Sales Person": diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index d24d472710d..c384e966897 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -1,16 +1,18 @@ import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice -from erpnext.accounts.report.gross_profit.gross_profit import execute +from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +test_dependencies = ["Purchase Invoice", "Sales Invoice"] + class TestGrossProfit(FrappeTestCase): def setUp(self): @@ -881,6 +883,377 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(gross_profit, 120.0) # 220 - 100 self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100 + def test_drop_ship(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = self.create_drop_ship_order() + si = make_sales_invoice(so.name).submit() + + filters = frappe._dict( + company=si.company, from_date=si.posting_date, to_date=si.posting_date, group_by="Invoice" + ) + + _, data = execute(filters=filters) + self.assertEqual(data[1].buying_amount, 800) + self.assertIsNone(data[1].buying_rate) + self.assertEqual(data[1]["gross_profit_%"], 20) + + def test_drop_ship_partial_billing_and_return(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = self.create_drop_ship_order() + first_invoice = make_sales_invoice(so.name) + first_invoice.items[0].qty = 4 + first_invoice.submit() + second_invoice = make_sales_invoice(so.name).submit() + + filters = frappe._dict( + company=first_invoice.company, + from_date=first_invoice.posting_date, + to_date=first_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_rows = { + row.parent_invoice: row + for row in data + if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1 + } + self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320) + self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480) + + sales_return = make_sales_return(first_invoice.name) + sales_return.items[0].qty = -2 + sales_return.submit() + + _, data = execute(filters=filters) + first_invoice_row = next( + row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1 + ) + self.assertEqual(first_invoice_row.qty, 2) + self.assertEqual(first_invoice_row.buying_amount, 160) + self.assertEqual(first_invoice_row.gross_profit, 40) + + def test_drop_ship_return_matches_sales_invoice_item(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item( + "_Test Drop Ship Consolidated Return Item", + properties={"is_stock_item": 1, "delivered_by_supplier": 1}, + ) + sales_orders = [] + for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]: + sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True) + sales_order.items[0].delivered_by_supplier = 1 + sales_order.items[0].supplier = "_Test Supplier" + sales_order.submit() + sales_orders.append(sales_order) + + purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]]) + purchase_order.items[0].rate = buying_rate + purchase_order.supplier = "_Test Supplier" + purchase_order.submit() + make_purchase_invoice(purchase_order.name).submit() + + sales_invoice = make_sales_invoice(sales_orders[0].name) + sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit() + sales_return = make_sales_return(sales_invoice.name) + sales_return.set("items", [sales_return.items[0]]) + sales_return.items[0].qty = -1 + sales_return.submit() + + filters = frappe._dict( + company=sales_invoice.company, + from_date=sales_invoice.posting_date, + to_date=sales_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1] + invoice_rows.sort(key=lambda row: row["avg._selling_rate"]) + self.assertEqual([row.qty for row in invoice_rows], [3, 6]) + self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480]) + + def test_return_matches_sales_invoice_item_for_delivery_note(self): + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=4, + basic_rate=50, + ) + delivery_note = self.create_delivery_note(qty=4, rate=100) + sales_invoice = make_sales_invoice(delivery_note.name).submit() + sales_return = make_sales_return(sales_invoice.name) + sales_return.items[0].qty = -1 + sales_return.submit() + + filters = frappe._dict( + company=sales_invoice.company, + from_date=sales_invoice.posting_date, + to_date=sales_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_row = next( + row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1 + ) + self.assertEqual(invoice_row.qty, 3) + self.assertEqual(invoice_row.selling_amount, 300) + + def test_return_combines_linked_and_legacy_item_buckets(self): + sales_invoice = self.create_sales_invoice(qty=4, rate=100) + linked_return = make_sales_return(sales_invoice.name) + linked_return.items[0].qty = -1 + linked_return.submit() + + legacy_return = make_sales_return(sales_invoice.name) + legacy_return.items[0].qty = -1 + legacy_return.submit() + frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None) + + filters = frappe._dict( + company=sales_invoice.company, + from_date=sales_invoice.posting_date, + to_date=sales_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_row = next( + row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1 + ) + self.assertEqual(invoice_row.qty, 2) + self.assertEqual(invoice_row.selling_amount, 200) + + @change_settings("Selling Settings", {"allow_multiple_items": True}) + def test_legacy_return_prefers_item_without_linked_return(self): + sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True) + second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False) + second_item.idx = None + second_item.rate = 200 + sales_invoice.append("items", second_item) + sales_invoice.submit() + + linked_return = make_sales_return(sales_invoice.name) + linked_return.set("items", [linked_return.items[0]]) + linked_return.items[0].qty = -1 + linked_return.submit() + + legacy_return = make_sales_return(sales_invoice.name) + legacy_return.set("items", [legacy_return.items[1]]) + legacy_return.items[0].qty = -1 + legacy_return.submit() + frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None) + + filters = frappe._dict( + company=sales_invoice.company, + from_date=sales_invoice.posting_date, + to_date=sales_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1] + invoice_rows.sort(key=lambda row: row["avg._selling_rate"]) + self.assertEqual([row.qty for row in invoice_rows], [1, 1]) + self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200]) + + def test_legacy_return_remainder_spills_into_linked_item(self): + invoice = "SINV-TEST-RETURN-ALLOCATION" + linked_item = "SINV-ITEM-LINKED" + unlinked_item = "SINV-ITEM-LEGACY" + generator = GrossProfitGenerator.__new__(GrossProfitGenerator) + generator.currency_precision = 3 + generator.filters = frappe._dict(group_by="Invoice") + generator.returned_invoices = frappe._dict( + {invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})} + ) + generator.legacy_returned_invoices = frappe._dict( + {invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})} + ) + linked_row = frappe._dict( + parent=invoice, + item_code=self.item, + item_row=linked_item, + is_return=False, + qty=3, + base_amount=300, + buying_rate=50, + delivered_by_supplier=False, + ) + unlinked_row = frappe._dict( + parent=invoice, + item_code=self.item, + item_row=unlinked_item, + is_return=False, + qty=1, + base_amount=100, + buying_rate=50, + delivered_by_supplier=False, + ) + + generator.si_list = [unlinked_row, linked_row] + generator.allocate_legacy_return_items() + generator.update_return_invoices(linked_row, linked_item) + generator.update_return_invoices(unlinked_row, unlinked_item) + + self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100)) + self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0)) + + def test_legacy_return_ignores_skipped_group_rows(self): + invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION" + visible_item = "SINV-ITEM-WITH-PROJECT" + skipped_item = "SINV-ITEM-WITHOUT-PROJECT" + generator = GrossProfitGenerator.__new__(GrossProfitGenerator) + generator.currency_precision = 3 + generator.filters = frappe._dict(group_by="Project") + generator.returned_invoices = frappe._dict( + {invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})} + ) + generator.legacy_returned_invoices = frappe._dict( + {invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})} + ) + visible_row = frappe._dict( + parent=invoice, + item_code=self.item, + item_row=visible_item, + is_return=False, + project="_Test Project", + qty=2, + base_amount=200, + buying_rate=50, + delivered_by_supplier=False, + ) + skipped_row = frappe._dict( + parent=invoice, + item_code=self.item, + item_row=skipped_item, + is_return=False, + project=None, + qty=1, + ) + + generator.si_list = [visible_row, skipped_row] + generator.allocate_legacy_return_items() + generator.update_return_invoices(visible_row, visible_item) + + self.assertNotIn(skipped_item, generator.returned_invoices[invoice]) + self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0)) + + def test_monthly_group_allocates_legacy_return(self): + invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION" + item_row = "SINV-ITEM-MONTHLY-RETURN" + generator = GrossProfitGenerator.__new__(GrossProfitGenerator) + generator.currency_precision = 3 + generator.filters = frappe._dict(group_by="Monthly") + generator.returned_invoices = frappe._dict() + generator.legacy_returned_invoices = frappe._dict( + {invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})} + ) + invoice_row = frappe._dict( + parent=invoice, + item_code=self.item, + item_row=item_row, + is_return=False, + posting_date=nowdate(), + qty=1, + base_amount=100, + buying_rate=50, + delivered_by_supplier=False, + ) + + generator.si_list = [invoice_row] + generator.allocate_legacy_return_items() + generator.update_return_invoices(invoice_row, item_row) + + self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0)) + + def test_return_remainder_stays_available_for_next_row(self): + invoice = "SINV-TEST-RETURN-REMAINDER" + item_row = "SINV-ITEM-RETURN-REMAINDER" + returned_item = frappe._dict(qty=-2, base_amount=-200) + generator = GrossProfitGenerator.__new__(GrossProfitGenerator) + generator.currency_precision = 3 + generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})}) + first_row = frappe._dict( + parent=invoice, + item_code=self.item, + qty=1, + base_amount=100, + buying_rate=50, + delivered_by_supplier=False, + ) + second_row = first_row.copy() + + generator.update_return_invoices(first_row, item_row) + self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100)) + + generator.update_return_invoices(second_row, item_row) + self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0)) + self.assertEqual((first_row.qty, second_row.qty), (0, 0)) + + @change_settings("Selling Settings", {"allow_multiple_items": True}) + def test_return_keeps_buying_amount_of_unreturned_row(self): + unreturned_item = create_item( + "_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company + ) + make_stock_entry( + company=self.company, + item_code=unreturned_item.name, + target=self.warehouse, + qty=40000, + basic_rate=33.33333, + ) + sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True) + second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False) + second_item.idx = None + second_item.item_code = unreturned_item.name + second_item.item_name = unreturned_item.name + second_item.qty = 30000 + sales_invoice.append("items", second_item) + sales_invoice.submit() + + sales_return = make_sales_return(sales_invoice.name) + sales_return.set("items", [sales_return.items[0]]) + sales_return.items[0].qty = -1 + sales_return.submit() + + filters = frappe._dict( + company=sales_invoice.company, + from_date=sales_invoice.posting_date, + to_date=sales_invoice.posting_date, + group_by="Invoice", + ) + _, data = execute(filters=filters) + invoice_row = next( + row + for row in data + if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name + ) + self.assertEqual(invoice_row.qty, 30000) + self.assertEqual(invoice_row.buying_amount, 999999.9) + + def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1}) + so = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True) + so.items[0].delivered_by_supplier = 1 + so.items[0].supplier = "_Test Supplier" + so.submit() + purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]]) + purchase_order.items[0].rate = buying_rate + purchase_order.supplier = "_Test Supplier" + purchase_order.submit() + make_purchase_invoice(purchase_order.name).submit() + + return so + def make_sales_person(sales_person_name="_Test Sales Person"): if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): From 4a3a2cbdc4aecb224b0975e55369196e3c8596bb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 20:52:12 +0530 Subject: [PATCH 14/24] test(stock): use existing warehouse fixtures in Pick List tests (v15) (#58840) --- erpnext/stock/doctype/pick_list/test_pick_list.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index d777e0862e7..93e824b30d4 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1734,7 +1734,7 @@ class TestPickList(FrappeTestCase): stock_entry.cancel() def test_packed_item_in_pick_list(self): - warehouse_1 = "RJ Warehouse - _TC" + warehouse_1 = "_Test Warehouse - _TC" warehouse_2 = "_Test Warehouse 2 - _TC" item_1 = make_item(properties={"is_stock_item": 0}).name item_2 = make_item().name @@ -1765,7 +1765,7 @@ class TestPickList(FrappeTestCase): def test_packed_item_multiple_times_in_so(self): frappe.db.delete("Item Price") - warehouse_1 = "RJ Warehouse - _TC" + warehouse_1 = "_Test Warehouse - _TC" warehouse_2 = "_Test Warehouse 2 - _TC" warehouse = "_Test Warehouse - _TC" item_1 = make_item(properties={"is_stock_item": 0}).name From a64b78d28311536711e983bdb5865999a18f0f12 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 8 Sep 2026 09:16:56 +0530 Subject: [PATCH 15/24] fix(selling): fetch orders within billing allowance (backport #58751) (#58822) --- .../accounts_settings/accounts_settings.json | 5 +- .../doctype/sales_invoice/sales_invoice.js | 3 +- .../doctype/sales_order/sales_order.js | 5 +- .../doctype/sales_order/sales_order.py | 125 +++++++++++++++++- .../doctype/sales_order/test_sales_order.py | 64 +++++++++ erpnext/stock/doctype/item/item.json | 6 +- 6 files changed, 197 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 595cc029e39..2bace6f90ee 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -223,7 +223,8 @@ "description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ", "fieldname": "over_billing_allowance", "fieldtype": "Currency", - "label": "Over Billing Allowance (%)" + "label": "Over Billing Allowance (%)", + "non_negative": 1 }, { "default": "1", @@ -679,7 +680,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-08-14 13:12:47.895908", + "modified": "2026-09-04 10:08:30.115003", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 259cd970941..3f90575ab4c 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -375,9 +375,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( get_query_filters: { docstatus: 1, status: ["not in", ["Closed", "On Hold"]], - per_billed: ["<", 99.99], company: me.frm.doc.company, }, + get_query_method: + "erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders", allow_child_item_selection: true, child_fieldname: "items", child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"], diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 635a43c3113..166acc58bfd 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -695,7 +695,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // sales invoice - if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) { + if ( + doc.__onload?.has_potentially_billable_items && + frappe.model.can_create("Sales Invoice") + ) { this.frm.add_custom_button( __("Sales Invoice"), () => me.make_sales_invoice(), diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index e664f69e14b..7710bb8c434 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -13,8 +13,10 @@ from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values -from frappe.query_builder.functions import Sum +from frappe.query_builder import Case, Criterion +from frappe.query_builder.functions import Abs, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html +from pypika import Order from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, @@ -22,6 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( validate_inter_company_party, ) from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.accounts.utils import build_qb_match_conditions from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( @@ -42,6 +45,18 @@ from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty form_grid_templates = {"items": "templates/form_grid/item_grid.html"} +LINK_SEARCH_FIELDTYPES = { + "Autocomplete", + "Data", + "Link", + "Long Text", + "Read Only", + "Select", + "Small Text", + "Text", + "Text Editor", +} + class WarehouseRequired(frappe.ValidationError): pass @@ -207,6 +222,12 @@ class SalesOrder(SellingController): if has_reserved_stock(self.doctype, self.name): self.set_onload("has_reserved_stock", True) + if self.docstatus == 1 and self.status not in {"Closed", "On Hold"}: + self.set_onload( + "has_potentially_billable_items", + has_potentially_billable_items(self.name), + ) + def before_validate(self): self.set_has_unit_price_items() self.flags.allow_zero_qty = self.has_unit_price_items @@ -1142,11 +1163,23 @@ def make_sales_invoice( has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") billed_qty_by_item = None pending_qty_by_item = {} + amount_allowance_by_item = {} mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail") def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + def is_amount_billable(source): + from erpnext.controllers.status_updater import get_allowance_for + + if source.item_code not in amount_allowance_by_item: + amount_allowance_by_item[source.item_code] = flt( + get_allowance_for(source.item_code, qty_or_amount="amount")[0] + ) + + allowance = amount_allowance_by_item[source.item_code] + return abs(flt(source.billed_amt)) < abs(flt(source.amount)) * (1 + allowance / 100) + def get_billed_qty_by_item(): nonlocal billed_qty_by_item @@ -1168,9 +1201,7 @@ def make_sales_invoice( def get_pending_qty(source): if source.name not in pending_qty_by_item: billable_qty = get_qty_net_of_returns(source) - if source.qty and source.billed_amt: - billable_qty -= get_billed_qty_by_item().get(source.name, 0) - + billable_qty -= get_billed_qty_by_item().get(source.name, 0) billable_qty -= mapped_qty_by_item.get(source.name, 0) pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) @@ -1255,7 +1286,7 @@ def make_sales_invoice( if is_unit_price_row(doc) else ( doc.qty - and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) + and (doc.base_amount == 0 or is_amount_billable(doc)) and get_pending_qty(doc) > 0 ) ), @@ -1940,3 +1971,87 @@ def get_work_order_items(sales_order, for_raw_material_request=0): @frappe.whitelist() def get_stock_reservation_status(): return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation") + + +def get_potentially_billable_item_criterion(sales_order, sales_order_item, item): + """Return the amount check for UI candidates. The mapper checks pending quantity.""" + global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance")) + allowance = ( + Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance) + ) + + has_amount_headroom = (sales_order_item.base_amount == 0) | ( + Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100) + ) + is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0) + + return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom) + + +def has_potentially_billable_items(sales_order: str) -> bool: + """Return whether a Sales Order has an item with billing amount headroom.""" + so = qb.DocType("Sales Order") + so_item = qb.DocType("Sales Order Item") + item = qb.DocType("Item") + + return bool( + qb.from_(so_item) + .inner_join(so) + .on(so.name == so_item.parent) + .left_join(item) + .on(item.name == so_item.item_code) + .select(so_item.name) + .where((so_item.parent == sales_order) & get_potentially_billable_item_criterion(so, so_item, item)) + .limit(1) + .run() + ) + + +def get_text_search_criterion(sales_order, txt: str): + """Match the search text the way the Sales Order link search does.""" + meta = frappe.get_meta("Sales Order") + conditions = [] + + for fieldname in dict.fromkeys(["name", meta.title_field, *meta.get_search_fields()]): + if not fieldname: + continue + + field = meta.get_field(fieldname) + if fieldname == "name" or (field and field.fieldtype in LINK_SEARCH_FIELDTYPES): + conditions.append(sales_order[fieldname].like(f"%{txt}%")) + + return Criterion.any(conditions) + + +@frappe.whitelist(methods=["GET"]) +@frappe.validate_and_sanitize_search_inputs +def get_potentially_billable_sales_orders( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict +): + """Return Sales Orders that have an item with billing amount headroom.""" + permission_type = "select" if frappe.only_has_select_perm("Sales Order") else "read" + frappe.has_permission("Sales Order", permission_type, throw=True) + + so = qb.DocType("Sales Order") + so_item = qb.DocType("Sales Order Item") + item = qb.DocType("Item") + + query = frappe.qb.get_query( + "Sales Order", fields=["name", "customer", "transaction_date"], filters=filters + ) + if txt: + query = query.where(get_text_search_criterion(so, txt)) + + return ( + query.inner_join(so_item) + .on(so_item.parent == so.name) + .left_join(item) + .on(item.name == so_item.item_code) + .where(get_potentially_billable_item_criterion(so, so_item, item)) + .where(Criterion.all(build_qb_match_conditions("Sales Order"))) + .distinct() + .orderby(so.transaction_date, order=Order.desc) + .limit(cint(page_len)) + .offset(cint(start)) + .run(as_dict=True) + ) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 8c0631c1b52..58df9766cc5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -22,6 +22,8 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod from erpnext.selling.doctype.sales_order.sales_order import ( WarehouseRequired, create_pick_list, + get_potentially_billable_sales_orders, + has_potentially_billable_items, make_delivery_note, make_material_request, make_purchase_order, @@ -226,6 +228,68 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): si1 = make_sales_invoice(so.name) self.assertEqual(len(si1.get("items")), 0) + def test_make_sales_invoice_for_pending_qty_with_item_billing_allowance(self): + item = make_item( + "_Test Over Billed Pending Qty Item", + {"is_stock_item": 1, "over_billing_allowance": 0}, + ).name + so = make_sales_order(item_code=item, qty=390, rate=100) + + for _ in range(2): + si = make_sales_invoice(so.name) + si.get("items")[0].qty = 120 + si.get("items")[0].rate = 162.50 + si.insert() + si.submit() + + so.load_from_db() + self.assertEqual(flt(so.per_billed), 100) + self.assertEqual(so.get("items")[0].billed_amt, so.get("items")[0].amount) + + filters = {"docstatus": 1, "company": so.company, "customer": so.customer} + + def is_offered(txt=""): + rows = get_potentially_billable_sales_orders("Sales Order", txt, "name", 0, 50, filters) + return so.name in [row.name for row in rows] + + def forget_cached_allowances(): + frappe.local.request_cache.clear() + + with change_settings("Accounts Settings", {"over_billing_allowance": 100}): + forget_cached_allowances() + self.assertTrue(has_potentially_billable_items(so.name)) + self.assertTrue(is_offered()) + self.assertEqual(make_sales_invoice(so.name).get("items")[0].qty, 150) + + with change_settings("Accounts Settings", {"over_billing_allowance": 0}): + forget_cached_allowances() + self.assertFalse(has_potentially_billable_items(so.name)) + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + frappe.db.set_value("Item", item, "over_billing_allowance", 100) + forget_cached_allowances() + + so.run_method("onload") + self.assertTrue(so.get_onload("has_potentially_billable_items")) + self.assertTrue(is_offered(so.customer)) + + si = make_sales_invoice(so.name) + self.assertEqual(len(si.get("items")), 1) + self.assertEqual(si.get("items")[0].qty, 150) + + def test_make_sales_invoice_skips_fully_invoiced_free_item(self): + free_item = make_item("_Test Free Item", {"is_stock_item": 1}).name + so = make_sales_order(qty=10, rate=100, do_not_submit=True) + so.append("items", {"item_code": free_item, "qty": 5, "rate": 0, "warehouse": so.items[0].warehouse}) + so.submit() + + si = make_sales_invoice(so.name) + self.assertEqual([row.qty for row in si.items], [10, 5]) + si.insert() + si.submit() + + self.assertEqual(len(make_sales_invoice(so.name).items), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index e8280bacf4a..bbbb4038be6 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -796,6 +796,7 @@ "fieldname": "over_delivery_receipt_allowance", "fieldtype": "Float", "label": "Over Delivery/Receipt Allowance (%)", + "non_negative": 1, "oldfieldname": "tolerance", "oldfieldtype": "Currency" }, @@ -803,7 +804,8 @@ "depends_on": "eval:!doc.__islocal && !doc.is_fixed_asset", "fieldname": "over_billing_allowance", "fieldtype": "Float", - "label": "Over Billing Allowance (%)" + "label": "Over Billing Allowance (%)", + "non_negative": 1 }, { "default": "0", @@ -898,7 +900,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-07-05 23:24:45.734144", + "modified": "2026-09-04 10:08:30.115003", "modified_by": "Administrator", "module": "Stock", "name": "Item", From 24cac30aa9935429173087857cdeeaf6d4f8c09f Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Tue, 8 Sep 2026 13:28:30 +0530 Subject: [PATCH 16/24] fix: persist rendered terms on transaction save (#58858) (cherry picked from commit cadc0ca86ddd87328786759021cdcb384b99b858) --- erpnext/buying/doctype/purchase_order/purchase_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 171a89e826c..7b76613d33a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -231,6 +231,7 @@ class PurchaseOrder(BuyingController): self.doctype, self.supplier, self.company, self.inter_company_order_reference ) self.reset_default_field_value("set_warehouse", "items", "warehouse") + self.set_missing_terms() def set_has_unit_price_items(self): """ From c752bb34a8db81902199ebb75d0efec5b3bbe58e Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:43:42 +0530 Subject: [PATCH 17/24] fix(gross-profit): include item_name in export (#58631) (cherry picked from commit 467f54162f7532459ea4937dcb1f0250e1a97616) --- erpnext/accounts/report/gross_profit/gross_profit.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index b5eaea64761..35b8606d501 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ columns[0]["fieldname"] = "sales_invoice" columns[0]["options"] = "Item" columns[0]["width"] = 300 - # removing Item Code and Item Name columns + # removing the duplicate Item Code column and moving Item Name before Customer 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") if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name": - del columns[4:6] + del columns[4] + columns.insert(1, columns.pop(4)) else: - del columns[5:7] + del columns[5] + columns.insert(1, columns.pop(5)) total_base_amount = 0 total_buying_amount = 0 From 95b203079aaa2f5460cc9c11f750e66c6cb3a810 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 8 Sep 2026 15:21:30 +0530 Subject: [PATCH 18/24] fix: resolve conflicts --- .../doctype/production_plan/production_plan.py | 5 +++-- .../production_plan/test_production_plan.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 90e791fc9ba..5a67725966d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -889,7 +889,8 @@ class ProductionPlan(Document): material_request_map = {} for item in self.mr_items: - if not item.quantity: + qty_to_request = flt(item.quantity, item.precision("quantity")) + if qty_to_request <= 0: continue item_doc = frappe.get_cached_doc("Item", item.item_code) @@ -925,7 +926,7 @@ class ProductionPlan(Document): "from_warehouse": item.from_warehouse if material_request_type == "Material Transfer" else None, - "qty": item.quantity, + "qty": qty_to_request, "schedule_date": schedule_date, "warehouse": item.warehouse, "sales_order": item.sales_order, diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index c9c266df0bb..3cd459cb574 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -120,6 +120,23 @@ class TestProductionPlan(FrappeTestCase): self.assertEqual(len(quantities), len(pln.mr_items) - 1) self.assertNotIn(0, quantities) + def test_production_plan_material_request_skips_zero_qty_items(self): + pln = create_production_plan(item_code="Test Production Item 1") + zero_qty_item, requested_item = pln.mr_items + zero_qty_item.quantity = "0" + + pln.make_material_request() + + material_request_items = frappe.get_all( + "Material Request Item", + filters={"production_plan": pln.name}, + fields=["item_code", "qty"], + ) + self.assertEqual( + material_request_items, + [{"item_code": requested_item.item_code, "qty": requested_item.quantity}], + ) + def test_production_plan_start_date(self): "Test if Work Order has same Planned Start Date as Prod Plan." planned_date = add_to_date(date=None, days=3) From 05a112758796a39f71cd7a4363e93db9f54e02f5 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Tue, 8 Sep 2026 16:29:20 +0530 Subject: [PATCH 19/24] fix(accounts): reject same-account internal transfers (#58529) (cherry picked from commit 36a4dfe7979c0fd31dc4a574ebe7be3b0558d17c) # Conflicts: # erpnext/accounts/doctype/payment_entry/test_payment_entry.py --- .../doctype/payment_entry/payment_entry.js | 32 ++++++++++++------- .../doctype/payment_entry/payment_entry.py | 5 +++ .../payment_entry/test_payment_entry.py | 17 ++++++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 5a5815ae4c7..50f50b553cc 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -45,23 +45,27 @@ frappe.ui.form.on("Payment Entry", { }, setup: function (frm) { - frm.set_query("paid_from", function () { + frm.set_query("paid_from", function (doc) { frm.events.validate_company(frm); var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type) ? ["Bank", "Cash"] : [frappe.boot.party_account_types[frm.doc.party_type]]; + let filters = { + account_type: ["in", account_types], + is_group: 0, + company: doc.company, + }; if (frm.doc.party_type == "Shareholder") { account_types.push("Equity"); } + if (doc.payment_type == "Internal Transfer" && doc.paid_to) { + filters.name = ["!=", doc.paid_to]; + } return { - filters: { - account_type: ["in", account_types], - is_group: 0, - company: frm.doc.company, - }, + filters, }; }); @@ -105,21 +109,25 @@ frappe.ui.form.on("Payment Entry", { } }); - frm.set_query("paid_to", function () { + frm.set_query("paid_to", function (doc) { frm.events.validate_company(frm); var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type) ? ["Bank", "Cash"] : [frappe.boot.party_account_types[frm.doc.party_type]]; + let filters = { + account_type: ["in", account_types], + is_group: 0, + company: doc.company, + }; if (frm.doc.party_type == "Shareholder") { account_types.push("Equity"); } + if (doc.payment_type == "Internal Transfer" && doc.paid_from) { + filters.name = ["!=", doc.paid_from]; + } return { - filters: { - account_type: ["in", account_types], - is_group: 0, - company: frm.doc.company, - }, + filters, }; }); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index a16e6d01639..31abb641760 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -90,6 +90,7 @@ class PaymentEntry(AccountsController): self.set_liability_account() self.set_missing_ref_details(force=True) self.validate_payment_type() + self.validate_internal_transfer_accounts() self.validate_party_details() self.set_exchange_rate() self.validate_mandatory() @@ -531,6 +532,10 @@ class PaymentEntry(AccountsController): if self.payment_type not in ("Receive", "Pay", "Internal Transfer"): frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer")) + def validate_internal_transfer_accounts(self): + if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to: + frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer.")) + def validate_party_details(self): if self.party and not frappe.db.exists(self.party_type, self.party): frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party)) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 1c010e7d74b..a274975b736 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -733,6 +733,23 @@ class TestPaymentEntry(FrappeTestCase): self.validate_gl_entries(pe.name, expected_gle) + def test_internal_transfer_rejects_same_account(self): + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Internal Transfer" + pe.company = "_Test Company" + pe.paid_from = "_Test Bank - _TC" + pe.paid_to = "_Test Bank - _TC" + pe.paid_amount = 100 + pe.received_amount = 100 + pe.reference_no = "same-account-transfer" + pe.reference_date = nowdate() + + self.assertRaisesRegex( + frappe.ValidationError, + "Paid From and Paid To accounts must be different", + pe.insert, + ) + def test_payment_against_negative_sales_invoice(self): si1 = create_sales_invoice() From 69f38a04c889d79fa517f75d7cb8a30150de4ca9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:33:31 +0530 Subject: [PATCH 20/24] fix: show cost center for unmatched payments in receivables (backport #58453) (#58880) Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com> --- .../accounts_receivable.py | 1 + .../test_accounts_receivable.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index ef8c9a193c4..61a6930af55 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -167,6 +167,7 @@ class ReceivablePayableReport: party_account=ple.account, posting_date=ple.posting_date, account_currency=ple.account_currency, + cost_center=ple.cost_center, remarks=ple.remarks, invoiced=0.0, paid=0.0, diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 3395ad3a34a..1771b7c4ae1 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1175,6 +1175,28 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): row = report[1][0] self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding]) + def test_cost_center_on_payment_before_invoice(self): + filters = { + "company": self.company, + "party_type": "Customer", + "party": [self.customer], + "report_date": today(), + "range": "30, 60, 90, 120", + } + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.posting_date = add_days(today(), 1) + si.due_date = si.posting_date + si.payment_schedule[0].due_date = si.posting_date + si.save().submit() + + pe = self.create_payment_entry(si.name, do_not_submit=True) + pe.cost_center = self.cost_center + pe.save().submit() + + row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name) + self.assertEqual(row.cost_center, pe.cost_center) + def test_payment_terms_template_filters(self): from erpnext.controllers.accounts_controller import get_payment_terms From b409ded2f6648852de43e51a2f830b0a1bf57125 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Tue, 8 Sep 2026 16:21:55 +0530 Subject: [PATCH 21/24] fix(stock): recalculate delivery note billing after return (#58869) (cherry picked from commit f864333afab6c3b1dec7519c2e66b0bb17e548e6) # Conflicts: # erpnext/stock/doctype/delivery_note/services/billing_status.py --- .../doctype/delivery_note/delivery_note.py | 10 +++- .../delivery_note/test_delivery_note.py | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index fe132d589d4..92c4dc38386 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -740,6 +740,9 @@ class DeliveryNote(SellingController): def update_billing_status(self, update_modified=True): updated_delivery_notes = [self.name] + if self.is_return and self.return_against: + updated_delivery_notes.append(self.return_against) + for d in self.get("items"): if d.si_detail and not d.so_detail: d.db_set("billed_amt", d.amount, update_modified=update_modified) @@ -748,7 +751,12 @@ class DeliveryNote(SellingController): for dn in set(updated_delivery_notes): dn_doc = self if (dn == self.name) else frappe.get_doc("Delivery Note", dn) - dn_doc.update_billing_percentage(update_modified=update_modified) + update_dn_modified = update_modified and dn != self.return_against + dn_doc.update_billing_percentage(update_modified=update_dn_modified) + if dn == self.return_against: + dn_doc.load_from_db() + dn_doc.set_status(update=True, update_modified=False) + dn_doc.notify_update() self.load_from_db() diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 56622562d7a..4ee8f3289af 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1049,6 +1049,56 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_billed, 100) self.assertEqual(dn.status, "Completed") + def test_dn_is_completed_when_unbilled_item_is_returned(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100) + make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100) + + dn = create_delivery_note(do_not_submit=True) + dn.append( + "items", + { + "item_code": "_Test Item 2", + "warehouse": "_Test Warehouse - _TC", + "qty": 1, + "rate": 100, + "conversion_factor": 1, + "allow_zero_valuation_rate": 1, + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }, + ) + dn.submit() + + si = make_sales_invoice(dn.name) + si.set("items", [item for item in si.items if item.item_code == "_Test Item"]) + si.insert() + si.submit() + + dn.reload() + self.assertEqual(dn.per_billed, 50) + self.assertEqual(dn.status, "Partially Billed") + + return_dn = make_sales_return(dn.name) + return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"]) + return_dn.insert() + # Mimic the submit request, which reconstructs the document from client data. + return_dn = frappe.get_doc(return_dn.as_dict()) + return_dn.submit() + + dn.reload() + self.assertEqual(dn.items[1].returned_qty, 1) + self.assertEqual(dn.per_billed, 100) + self.assertEqual(dn.status, "Completed") + + return_dn.cancel() + + dn.reload() + self.assertEqual(dn.items[1].returned_qty, 0) + self.assertEqual(dn.per_billed, 50) + self.assertEqual(dn.status, "Partially Billed") + def test_dn_billing_status_case2(self): # SO -> SI and SO -> DN1, DN2 from erpnext.selling.doctype.sales_order.sales_order import ( From 96357b4fdcf426da143a7ca0c126c155e5182b58 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:47:54 +0000 Subject: [PATCH 22/24] fix(accounts): round journal entry totals to field precision (backport #58629) (#58874) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Co-authored-by: diptanilsaha --- .../doctype/journal_entry/journal_entry.js | 4 +- .../doctype/journal_entry/journal_entry.py | 12 +++-- .../journal_entry/test_journal_entry.py | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index b4f7bfeefba..1c784bd864e 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -421,8 +421,8 @@ cur_frm.cscript.update_totals = function (doc) { tc += flt(accounts[i].credit, precision("credit", accounts[i])); } doc = locals[doc.doctype][doc.name]; - doc.total_debit = td; - doc.total_credit = tc; + doc.total_debit = flt(td, precision("total_debit")); + doc.total_credit = flt(tc, precision("total_credit")); doc.difference = flt(td - tc, precision("difference")); refresh_many(["total_debit", "total_credit", "difference"]); }; diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 2069593992d..0f7ab4e5a23 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -861,12 +861,14 @@ class JournalEntry(AccountsController): if d.debit and d.credit: frappe.throw(_("You cannot credit and debit same account at the same time")) - self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit")) - self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit")) + self.total_debit = flt( + self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit") + ) + self.total_credit = flt( + self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit") + ) - self.difference = flt(self.total_debit, self.precision("total_debit")) - flt( - self.total_credit, self.precision("total_credit") - ) + self.difference = flt(self.total_debit - self.total_credit, self.precision("difference")) def validate_multi_currency(self): alternate_currency = [] diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index 61ab04d2a8b..60b569f67f0 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -410,6 +410,59 @@ class TestJournalEntry(unittest.TestCase): self.check_gl_entries() + def make_jv_with_fractional_totals(self): + """0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against.""" + jv = frappe.new_doc("Journal Entry") + jv.posting_date = nowdate() + jv.company = "_Test Company" + jv.voucher_type = "Journal Entry" + jv.remark = "test" + for amount in (0.10, 0.20): + jv.append( + "accounts", + { + "account": "_Test Cash - _TC", + "cost_center": "_Test Cost Center - _TC", + "debit_in_account_currency": amount, + }, + ) + jv.append( + "accounts", + { + "account": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": 0.30, + }, + ) + jv.insert() + return jv + + def test_totals_are_rounded_to_precision(self): + jv = self.make_jv_with_fractional_totals() + jv.submit() + + stored = frappe.db.get_value( + "Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True + ) + self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit"))) + self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit"))) + self.assertEqual(jv.total_debit, stored.total_debit) + self.assertEqual(jv.total_credit, stored.total_credit) + self.assertEqual(jv.difference, stored.difference) + + def test_update_after_submit_with_fractional_totals(self): + """An unrounded total is stored rounded, so updating a submitted entry used to throw.""" + jv = self.make_jv_with_fractional_totals() + jv.submit() + + jv.pay_to_recd_from = "_Test Supplier" + jv.save() + + self.assertEqual(jv.docstatus, 1) + self.assertEqual( + jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from") + ) + def test_jv_account_and_party_balance_with_cost_centre(self): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.utils import get_balance_on From 2c2a3e9f1022fe0e6d0e8c56f404a776a830ceec Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:18:36 +0530 Subject: [PATCH 23/24] fix: fetch accounts by company (backport #58523) (#58878) Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> --- erpnext/accounts/report/cash_flow/cash_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index d476fd4b3ef..de39f5c1424 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -99,6 +99,7 @@ def execute(filters=None): filters={ "account_type": row["account_type"], "is_group": 0, + "company": filters.company, }, pluck="name", ) From c7d865ef3b4d25c7a1e587004b7e2228daaff86d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:23:45 +0000 Subject: [PATCH 24/24] fix(pos): use company-currency change amount when netting pos gl entries (backport #58599) (#58677) Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Co-authored-by: diptanilsaha --- .../doctype/sales_invoice/sales_invoice.py | 2 +- .../sales_invoice/test_sales_invoice.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 59e72ced503..8e03802f54b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1637,7 +1637,7 @@ class SalesInvoice(SellingController): for payment_mode in self.payments: if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount: - payment_mode.base_amount -= flt(self.change_amount) + payment_mode.base_amount -= flt(self.base_change_amount) against_voucher = self.name if self.is_return and self.return_against and not self.update_outstanding_for_self: diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index bca0d58a57d..e0da8487485 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1235,6 +1235,33 @@ class TestSalesInvoice(FrappeTestCase): frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1) + def test_pos_change_amount_multi_currency_gl_entry(self): + frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0) + + si = create_sales_invoice(do_not_save=True) + si.is_pos = 1 + si.currency = "USD" + si.conversion_rate = 50 + si.party_account_currency = "USD" + si.account_for_change_amount = "Cash - _TC" + si.change_amount = 50 + si.base_change_amount = 2500 + si.append( + "payments", + {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500}, + ) + + gl_entries = [] + si.make_pos_gl_entries(gl_entries) + + debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to) + cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC") + + self.assertEqual(flt(debtors_entry["credit"]), 5000.0) + self.assertEqual(flt(cash_entry["debit"]), 5000.0) + + frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1) + def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False): if validate_without_change_gle: cash_amount -= pos.change_amount