From 6ad84d66cc38e20eba9135ac03e20bbb61e03b88 Mon Sep 17 00:00:00 2001 From: Luis Mendoza Date: Wed, 11 Feb 2026 20:49:15 +0000 Subject: [PATCH 01/30] fix(accounts): compute tax net_amount in JS controller (cherry picked from commit 153ad99f85ad5ef8a1b52663cd121328bf589f73) # Conflicts: # erpnext/public/js/controllers/taxes_and_totals.js --- .../public/js/controllers/taxes_and_totals.js | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index a4b4de19303..44281e80c93 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -173,9 +173,21 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (!tax.dont_recompute_tax) { tax.item_wise_tax_detail = {}; } +<<<<<<< HEAD var tax_fields = ["total", "tax_amount_after_discount_amount", "tax_amount_for_current_item", "grand_total_for_current_item", "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"]; +======= + var tax_fields = [ + "net_amount", + "total", + "tax_amount_after_discount_amount", + "tax_amount_for_current_item", + "grand_total_for_current_item", + "tax_fraction_for_current_item", + "grand_total_fraction_for_current_item", + ]; +>>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) if (cstr(tax.charge_type) != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { @@ -363,7 +375,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); $.each(doc.taxes, function(i, tax) { // tax_amount represents the amount of tax for the current step - var current_tax_amount = me.get_current_tax_amount(item, tax, item_tax_map); + var [current_net_amount, current_tax_amount] = me.get_current_tax_amount(item, tax, item_tax_map); if (frappe.flags.round_row_wise_tax) { current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax)); } @@ -380,6 +392,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (tax.charge_type != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { tax.tax_amount += current_tax_amount; + tax.net_amount += current_net_amount; } // store tax_amount for current item as it will be used for @@ -494,11 +507,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { current_tax_amount = tax_rate * item.qty; } +<<<<<<< HEAD if (!tax.dont_recompute_tax) { this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); } return current_tax_amount; +======= + return [current_net_amount, current_tax_amount]; +>>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) } set_item_wise_tax(item, tax, tax_rate, current_tax_amount) { @@ -532,7 +549,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax)); +<<<<<<< HEAD tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, precision("tax_amount", tax)); +======= + tax.net_amount = flt(tax.net_amount, precision("net_amount", tax)); + tax.tax_amount_after_discount_amount = flt( + tax.tax_amount_after_discount_amount, + precision("tax_amount", tax) + ); +>>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) } round_off_base_values(tax) { From 86c628521e4cb4dad15327530a66faf141888503 Mon Sep 17 00:00:00 2001 From: Luis Mendoza Date: Wed, 11 Feb 2026 21:18:44 +0000 Subject: [PATCH 02/30] style: prettier formatting (cherry picked from commit 485166b668b372ecff00e6638ced930b73bff3bc) --- erpnext/public/js/controllers/taxes_and_totals.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 44281e80c93..35f2b90f695 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -375,7 +375,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); $.each(doc.taxes, function(i, tax) { // tax_amount represents the amount of tax for the current step - var [current_net_amount, current_tax_amount] = me.get_current_tax_amount(item, tax, item_tax_map); + var [current_net_amount, current_tax_amount] = me.get_current_tax_amount( + item, + tax, + item_tax_map + ); if (frappe.flags.round_row_wise_tax) { current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax)); } From 516ad9021b4ea102f2571da49c7bb2793a24f13f Mon Sep 17 00:00:00 2001 From: Luis Mendoza Date: Wed, 18 Feb 2026 16:44:56 +0000 Subject: [PATCH 03/30] fix(accounts): round and convert net_amount to company currency in JS tax controller (cherry picked from commit b10b20539471879da0fcaf64648cf22d598b7f82) # Conflicts: # erpnext/public/js/controllers/taxes_and_totals.js --- erpnext/public/js/controllers/taxes_and_totals.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 35f2b90f695..5f069539754 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -382,6 +382,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { ); if (frappe.flags.round_row_wise_tax) { current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax)); + current_net_amount = flt(current_net_amount, precision("net_amount", tax)); } // Adjust divisional loss to the last item @@ -447,8 +448,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { for (const [i, tax] of doc.taxes.entries()) { me.round_off_totals(tax); +<<<<<<< HEAD me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]); +======= + me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]); +>>>>>>> b10b205394 (fix(accounts): round and convert net_amount to company currency in JS tax controller) me.round_off_base_values(tax); From e5282a48aef0a24ba3412e4bd6fa0ddc1c56f073 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 26 Feb 2026 13:52:23 +0530 Subject: [PATCH 04/30] chore: resolve conflicts --- .../public/js/controllers/taxes_and_totals.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 5f069539754..7c210476737 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -173,11 +173,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (!tax.dont_recompute_tax) { tax.item_wise_tax_detail = {}; } -<<<<<<< HEAD - var tax_fields = ["total", "tax_amount_after_discount_amount", - "tax_amount_for_current_item", "grand_total_for_current_item", - "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"]; -======= var tax_fields = [ "net_amount", "total", @@ -187,7 +182,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { "tax_fraction_for_current_item", "grand_total_fraction_for_current_item", ]; ->>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) if (cstr(tax.charge_type) != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { @@ -448,12 +442,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { for (const [i, tax] of doc.taxes.entries()) { me.round_off_totals(tax); -<<<<<<< HEAD - me.set_in_company_currency(tax, - ["tax_amount", "tax_amount_after_discount_amount"]); -======= me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]); ->>>>>>> b10b205394 (fix(accounts): round and convert net_amount to company currency in JS tax controller) me.round_off_base_values(tax); @@ -516,15 +505,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { current_tax_amount = tax_rate * item.qty; } -<<<<<<< HEAD if (!tax.dont_recompute_tax) { this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); } - return current_tax_amount; -======= return [current_net_amount, current_tax_amount]; ->>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) } set_item_wise_tax(item, tax, tax_rate, current_tax_amount) { @@ -558,15 +543,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax)); -<<<<<<< HEAD - tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, precision("tax_amount", tax)); -======= tax.net_amount = flt(tax.net_amount, precision("net_amount", tax)); tax.tax_amount_after_discount_amount = flt( tax.tax_amount_after_discount_amount, precision("tax_amount", tax) ); ->>>>>>> 153ad99f85 (fix(accounts): compute tax net_amount in JS controller) } round_off_base_values(tax) { From 717c5b25eb22a1936ecfde6dc979ab8f20670bd4 Mon Sep 17 00:00:00 2001 From: David Arnold Date: Wed, 4 Dec 2024 14:08:50 +0100 Subject: [PATCH 05/30] fix: client-side taxes calculation (#44510) closes: #44328 --- erpnext/public/js/controllers/taxes_and_totals.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 7c210476737..4f327cebd5a 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -475,6 +475,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { get_current_tax_amount(item, tax, item_tax_map) { var tax_rate = this._get_tax_rate(tax, item_tax_map); var current_tax_amount = 0.0; + var current_net_amount = 0.0; // To set row_id by default as previous row. if(["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) { @@ -487,21 +488,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } if(tax.charge_type == "Actual") { + current_net_amount = item.net_amount // distribute the tax amount proportionally to each item row var actual = flt(tax.tax_amount, precision("tax_amount", tax)); current_tax_amount = this.frm.doc.net_total ? ((item.net_amount / this.frm.doc.net_total) * actual) : 0.0; } else if(tax.charge_type == "On Net Total") { + if (tax.account_head in item_tax_map) { + current_net_amount = item.net_amount + }; current_tax_amount = (tax_rate / 100.0) * item.net_amount; } else if(tax.charge_type == "On Previous Row Amount") { + current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item current_tax_amount = (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; - } else if(tax.charge_type == "On Previous Row Total") { + current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item current_tax_amount = (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item; } else if (tax.charge_type == "On Item Quantity") { + // don't sum current net amount due to the field being a currency field current_tax_amount = tax_rate * item.qty; } From 072ab8d5f3492501c64797ba7348c442e12e9e4d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:29:46 +0000 Subject: [PATCH 06/30] feat: allowing rate modification in update item in quotation (backport #53147) (#53150) Co-authored-by: Nishka Gosalia --- .../test_supplier_quotation.py | 18 +++++++- erpnext/controllers/accounts_controller.py | 33 +++++++++----- .../doctype/quotation/test_quotation.py | 45 +++++++++++++++++-- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index da4c78347f3..47e2cbee338 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -37,7 +37,7 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(sq.get("items")[0].qty, 5) self.assertEqual(sq.get("items")[1].rate, 300) - def test_update_supplier_quotation_child_rate_disallow(self): + def test_update_supplier_quotation_child_rate(self): sq = frappe.copy_doc(test_records[0]) sq.submit() trans_item = json.dumps( @@ -50,6 +50,22 @@ class TestPurchaseOrder(FrappeTestCase): }, ] ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(sq.get("items")[0].rate, 300) + po = make_purchase_order(sq.name) + po.schedule_date = add_days(today(), 1) + po.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 20, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) self.assertRaises( frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 51f19b0dd27..008402eeb53 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3838,20 +3838,28 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False return False - def validate_quantity(child_item, new_data): + def validate_quantity_and_rate(child_item, new_data): if not flt(new_data.get("qty")) and not is_allowed_zero_qty(): frappe.throw( - _("Row #{0}: Quantity for Item {1} cannot be zero.").format( + _("Row #{0}:Quantity for Item {1} cannot be zero.").format( new_data.get("idx"), frappe.bold(new_data.get("item_code")) ), title=_("Invalid Qty"), ) - if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty): - frappe.throw(_("Cannot set quantity less than delivered quantity")) + qty_limits = { + "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity")), + "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity")), + } - if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): - frappe.throw(_("Cannot set quantity less than received quantity")) + if parent_doctype in qty_limits: + qty_field, error_message = qty_limits[parent_doctype] + if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): + frappe.throw( + _("Row #{0}:").format(new_data.get("idx")) + + error_message.format(frappe.bold(new_data.get("item_code"))), + title=_("Invalid Qty"), + ) if parent_doctype in ["Quotation", "Supplier Quotation"]: if (parent_doctype == "Quotation" and not ordered_items) or ( @@ -3864,7 +3872,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Quotation" else purchased_items.get(child_item.name) ) + if qty_to_check: + if not rate_unchanged: + frappe.throw( + _( + "Cannot update rate as item {0} is already ordered or purchased against this quotation" + ).format(frappe.bold(new_data.get("item_code"))) + ) + if flt(new_data.get("qty")) < qty_to_check: frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) @@ -3980,10 +3996,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ): continue - validate_quantity(child_item, d) - if parent_doctype in ["Quotation", "Supplier Quotation"]: - if not rate_unchanged: - frappe.throw(_("Rates cannot be modified for quoted items")) + validate_quantity_and_rate(child_item, d) if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 96d26b3e703..2d1da049653 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -52,8 +52,22 @@ class TestQuotation(FrappeTestCase): self.assertEqual(qo.get("items")[0].qty, 11) self.assertEqual(qo.get("items")[-1].rate, 100) - def test_update_child_disallow_rate_change(self): - qo = make_quotation(qty=4) + def test_update_child_rate_change(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_1 = make_item("_Test Item") + item_2 = make_item("_Test Item 1") + + item_list = [ + {"item_code": item_1.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + so = make_sales_order(qo.name, args={"filtered_children": [qo.items[0].name]}) + so.delivery_date = nowdate() + so.submit() + qo.reload() trans_item = json.dumps( [ { @@ -61,10 +75,35 @@ class TestQuotation(FrappeTestCase): "rate": 5000, "qty": qo.items[0].qty, "docname": qo.items[0].name, - } + }, + { + "item_code": qo.items[1].item_code, + "rate": qo.items[1].rate, + "qty": qo.items[1].qty, + "docname": qo.items[1].name, + }, ] ) self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + }, + { + "item_code": qo.items[1].item_code, + "rate": 50, + "qty": qo.items[1].qty, + "docname": qo.items[1].name, + }, + ] + ) + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(qo.items[1].rate, 50) def test_update_child_removing_item(self): qo = make_quotation(qty=10) From 68bac20198bea634c4aca55e554b89d3c834da87 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:33:02 +0000 Subject: [PATCH 07/30] Merge pull request #53095 from frappe/mergify/bp/version-15-hotfix/pr-52838 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: correct fields being updated on material request and purchase or… (backport #52838) --- erpnext/patches.txt | 1 + ...ty_and_requested_qty_based_on_mr_and_po.py | 33 +++++++++++++ .../sales_order_item/sales_order_item.json | 47 ++++++++++++++++++- .../sales_order_item/sales_order_item.py | 1 + .../material_request/material_request.py | 4 +- 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9e36329daa4..afb1201087a 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -432,3 +432,4 @@ erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges erpnext.patches.v16_0.set_ordered_qty_in_quotation_item erpnext.patches.v15_0.replace_http_with_https_in_sales_partner erpnext.patches.v16_0.add_portal_redirects +erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po diff --git a/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py b/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py new file mode 100644 index 00000000000..59a84ec11d0 --- /dev/null +++ b/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py @@ -0,0 +1,33 @@ +import frappe +from frappe.query_builder import DocType +from frappe.query_builder.functions import Sum + + +def execute(): + PurchaseOrderItem = DocType("Purchase Order Item") + MaterialRequestItem = DocType("Material Request Item") + + poi_query = ( + frappe.qb.from_(PurchaseOrderItem) + .select(PurchaseOrderItem.sales_order_item, Sum(PurchaseOrderItem.stock_qty)) + .where(PurchaseOrderItem.sales_order_item.isnotnull() & PurchaseOrderItem.docstatus == 1) + .groupby(PurchaseOrderItem.sales_order_item) + ) + + mri_query = ( + frappe.qb.from_(MaterialRequestItem) + .select(MaterialRequestItem.sales_order_item, Sum(MaterialRequestItem.stock_qty)) + .where(MaterialRequestItem.sales_order_item.isnotnull() & MaterialRequestItem.docstatus == 1) + .groupby(MaterialRequestItem.sales_order_item) + ) + + poi_data = poi_query.run() + mri_data = mri_query.run() + + updates_against_poi = {data[0]: {"ordered_qty": data[1]} for data in poi_data} + updates_against_mri = {data[0]: {"requested_qty": data[1], "ordered_qty": 0} for data in mri_data} + + frappe.db.auto_commit_on_many_writes = 1 + frappe.db.bulk_update("Sales Order Item", updates_against_mri) + frappe.db.bulk_update("Sales Order Item", updates_against_poi) + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 7edb81de48b..b8ca7343a4d 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -90,6 +90,7 @@ "ordered_qty", "planned_qty", "production_plan_qty", + "requested_qty", "column_break_69", "work_order_qty", "delivered_qty", @@ -966,12 +967,56 @@ "label": "Project", "options": "Project", "search_index": 1 + }, + { + "fieldname": "sales_order_schedule_section", + "fieldtype": "Section Break", + "label": "Sales Order Schedule" + }, + { + "fieldname": "add_schedule", + "fieldtype": "Button", + "label": "Add Schedule" + }, + { + "allow_on_submit": 1, + "default": "0", + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "subcontracted_qty", + "fieldtype": "Float", + "label": "Subcontracted Quantity", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 + }, + { + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "fg_item", + "fieldtype": "Link", + "label": "Finished Good", + "mandatory_depends_on": "eval:parent.is_subcontracted", + "options": "Item" + }, + { + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "fg_item_qty", + "fieldtype": "Float", + "label": "Finished Good Qty", + "mandatory_depends_on": "eval:parent.is_subcontracted" + }, + { + "fieldname": "requested_qty", + "fieldtype": "Float", + "label": "Requested Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2026-02-20 16:39:00.200328", + "modified": "2026-02-21 16:39:00.200328", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 731cff665da..07a81c81f02 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -78,6 +78,7 @@ class SalesOrderItem(Document): quotation_item: DF.Data | None rate: DF.Currency rate_with_margin: DF.Currency + requested_qty: DF.Float reserve_stock: DF.Check returned_qty: DF.Float stock_qty: DF.Float diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index b9aa7065c5d..068daeae4f1 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -81,7 +81,7 @@ class MaterialRequest(BuyingController): { "source_dt": "Material Request Item", "target_dt": "Sales Order Item", - "target_field": "ordered_qty", + "target_field": "requested_qty", "target_parent_dt": "Sales Order", "target_parent_field": "", "join_field": "sales_order_item", @@ -248,6 +248,8 @@ class MaterialRequest(BuyingController): def on_cancel(self): self.update_requested_qty_in_production_plan() self.update_requested_qty() + if self.material_request_type == "Purchase": + self.update_prevdoc_status() def get_mr_items_ordered_qty(self, mr_items): mr_items_ordered_qty = {} From be598108b672f75cfabab310f421ac6df1d822d7 Mon Sep 17 00:00:00 2001 From: Nishka Gosalia Date: Wed, 4 Mar 2026 14:40:24 +0530 Subject: [PATCH 08/30] fix: updating costing based on employee change in timesheet (cherry picked from commit e37d4a6f7c0f13d420c2844368192cdd59eadedf) --- .../projects/doctype/timesheet/timesheet.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index 9b01d1c429f..e9d868e108a 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -260,6 +260,33 @@ frappe.ui.form.on("Timesheet", { parent_project: function (frm) { set_project_in_timelog(frm); }, + + employee: function (frm) { + if (frm.doc.employee && frm.doc.time_logs) { + const selected_employee = frm.doc.employee; + frm.doc.time_logs.forEach((row) => { + if (row.activity_type) { + frappe.call({ + method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost", + args: { + employee: frm.doc.employee, + activity_type: row.activity_type, + currency: frm.doc.currency, + }, + callback: function (r) { + if (r.message) { + if (selected_employee !== frm.doc.employee) return; + row.billing_rate = r.message["billing_rate"]; + row.costing_rate = r.message["costing_rate"]; + frm.refresh_fields("time_logs"); + calculate_billing_costing_amount(frm, row.doctype, row.name); + } + }, + }); + } + }); + } + }, }); frappe.ui.form.on("Timesheet Detail", { From a7e8f31f5605c85f79069f62f46078a79e93039d Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 4 Mar 2026 14:20:14 +0530 Subject: [PATCH 09/30] fix: skip asset sale processing for internal transfer invoices (cherry picked from commit 9cb3dad079a4c06637091b20388464cad8f1e435) --- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 802705f3470..cf4377bd0df 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1199,6 +1199,9 @@ class SalesInvoice(SellingController): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) def process_asset_depreciation(self): + if self.is_internal_transfer(): + return + if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): self.depreciate_asset_on_sale() else: From 7b2e4832aa5c306431d63d343bdeafd876f2974c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 4 Mar 2026 16:05:24 +0530 Subject: [PATCH 10/30] fix: disallow all actions on job card if work order is closed (cherry picked from commit ee19c32c3aa0c4b5fa1ec44b2af46b651a80a853) --- erpnext/manufacturing/doctype/job_card/job_card.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index e3ee2baaa53..549493d4b92 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1076,9 +1076,9 @@ class JobCard(Document): def is_work_order_closed(self): if self.work_order: - status = frappe.get_value("Work Order", self.work_order) + status = frappe.get_value("Work Order", self.work_order, "status") - if status == "Closed": + if status in ["Closed", "Stopped"]: return True return False From 624d1d47591eb57f14cc2b077875caf6f07e32a8 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 2 Mar 2026 12:26:55 +0530 Subject: [PATCH 11/30] fix(manufacturing): ignore sales order validation for subassembly item (cherry picked from commit 6b1aac4aee35df0d8b339f3389e8330fee7bf2cd) --- .../doctype/work_order/work_order.py | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 14c458015be..590708ac275 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -220,39 +220,52 @@ class WorkOrder(Document): ) def validate_sales_order(self): + if self.production_plan_sub_assembly_item: + return + if self.sales_order: self.check_sales_order_on_hold_or_close() - so = frappe.db.sql( - """ - select so.name, so_item.delivery_date, so.project - from `tabSales Order` so - inner join `tabSales Order Item` so_item on so_item.parent = so.name - left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent - where so.name=%s and so.docstatus = 1 - and so.skip_delivery_note = 0 and ( - so_item.item_code=%s or - pk_item.item_code=%s ) - """, - (self.sales_order, self.production_item, self.production_item), - as_dict=1, + + SalesOrder = frappe.qb.DocType("Sales Order") + SalesOrderItem = frappe.qb.DocType("Sales Order Item") + PackedItem = frappe.qb.DocType("Packed Item") + ProductBundleItem = frappe.qb.DocType("Product Bundle Item") + + so = ( + frappe.qb.from_(SalesOrder) + .inner_join(SalesOrderItem) + .on(SalesOrderItem.parent == SalesOrder.name) + .left_join(ProductBundleItem) + .on(ProductBundleItem.parent == SalesOrderItem.item_code) + .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) + .where( + (SalesOrder.skip_delivery_note == 0) + & (SalesOrder.docstatus == 1) + & (SalesOrder.name == self.sales_order) + & ( + (SalesOrderItem.item_code == self.production_item) + | (ProductBundleItem.item_code == self.production_item) + ) + ) + .run(as_dict=1) ) if not so: - so = frappe.db.sql( - """ - select - so.name, so_item.delivery_date, so.project - from - `tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item - where so.name=%s - and so.name=so_item.parent - and so.name=packed_item.parent - and so.skip_delivery_note = 0 - and so_item.item_code = packed_item.parent_item - and so.docstatus = 1 and packed_item.item_code=%s - """, - (self.sales_order, self.production_item), - as_dict=1, + so = ( + frappe.qb.from_(SalesOrder) + .inner_join(SalesOrderItem) + .on(SalesOrderItem.parent == SalesOrder.name) + .inner_join(PackedItem) + .on(PackedItem.parent == SalesOrder.name) + .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) + .where( + (SalesOrder.name == self.sales_order) + & (SalesOrder.skip_delivery_note == 0) + & (SalesOrderItem.item_code == PackedItem.parent_item) + & (SalesOrder.docstatus == 1) + & (PackedItem.item_code == self.production_item) + ) + .run(as_dict=1) ) if len(so): @@ -426,7 +439,7 @@ class WorkOrder(Document): from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item - if self.sales_order and self.sales_order_item: + if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item: update_produced_qty_in_so_item(self.sales_order, self.sales_order_item) if self.production_plan: @@ -818,7 +831,7 @@ class WorkOrder(Document): doc.db_set("status", doc.status) def update_work_order_qty_in_so(self): - if not self.sales_order and not self.sales_order_item: + if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item: return total_bundle_qty = 1 From 6898d703821b8c488fb30da1d8666f424f656ec6 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 17 Feb 2026 17:58:44 +0530 Subject: [PATCH 12/30] fix: balance qty for inv dimension (cherry picked from commit a3eafe5b188ee160bc8507d298f987cb1ec4b894) --- .../stock/report/stock_ledger/stock_ledger.py | 153 +++++++++++++++++- erpnext/stock/stock_ledger.py | 3 + 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index b9275417847..b9dadfbb62d 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -27,10 +27,23 @@ def execute(filters=None): items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) item_details = get_item_details(items, sl_entries, include_uom) + + inv_dimension_key = [] + inv_dimension_wise_value = get_inv_dimension_wise_value(filters) + if inv_dimension_wise_value: + for key in inv_dimension_wise_value: + value = inv_dimension_wise_value[key] + if isinstance(value, list): + inv_dimension_key.extend(value) + else: + inv_dimension_key.append(value) + if filters.get("batch_no"): opening_row = get_opening_balance_from_batch(filters, columns, sl_entries) + elif inv_dimension_wise_value: + opening_row = get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value) else: - opening_row = get_opening_balance(filters, columns, sl_entries) + opening_row = get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) bundle_details = {} @@ -50,12 +63,16 @@ def execute(filters=None): stock_value = opening_row.get("stock_value") available_serial_nos = {} - inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters) batch_balance_dict = frappe._dict({}) if actual_qty and filters.get("batch_no"): batch_balance_dict[filters.batch_no] = [actual_qty, stock_value] + inv_dimension_wise_dict = frappe._dict({}) + set_opening_row_for_inv_dimension( + inv_dimension_wise_dict, filters, inv_dimension_key=inv_dimension_key, opening_row=opening_row + ) + for sle in sl_entries: item_detail = item_details[sle.item_code] @@ -64,7 +81,10 @@ def execute(filters=None): data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict, filters)) continue - if filters.get("batch_no") or inventory_dimension_filters_applied: + if inv_dimension_key: + set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle) + + if filters.get("batch_no"): actual_qty += flt(sle.actual_qty, precision) stock_value += sle.stock_value_difference if sle.batch_no: @@ -103,6 +123,50 @@ def execute(filters=None): return columns, data +def set_opening_row_for_inv_dimension( + inv_dimension_wise_dict, filters, inv_dimension_key=None, opening_row=None +): + if ( + not inv_dimension_key + or not opening_row + or not filters.get("item_code") + or not filters.get("warehouse") + ): + return + + if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1: + return + + if inv_dimension_key and opening_row and filters.get("item_code") and filters.get("warehouse"): + new_key = copy.deepcopy(inv_dimension_key) + new_key.extend([filters.item_code[0], filters.warehouse[0]]) + + opening_key = tuple(new_key) + inv_dimension_wise_dict[opening_key] = { + "qty_after_transaction": flt(opening_row.get("qty_after_transaction")), + "dimension_stock_value": flt(opening_row.get("stock_value")), + } + + +def set_balance_value_for_inv_dimesion(inv_dimension_key, inv_dimension_wise_dict, sle): + new_key = copy.deepcopy(inv_dimension_key) + new_key.extend([sle.item_code, sle.warehouse]) + new_key = tuple(new_key) + + if new_key not in inv_dimension_wise_dict: + inv_dimension_wise_dict[new_key] = {"qty_after_transaction": 0, "dimension_stock_value": 0} + + inv_dimesion_value = inv_dimension_wise_dict[new_key] + inv_dimesion_value["qty_after_transaction"] += sle.actual_qty + inv_dimesion_value["dimension_stock_value"] += sle.stock_value_difference + sle.update( + { + "qty_after_transaction": inv_dimesion_value["qty_after_transaction"], + "stock_value": inv_dimesion_value["dimension_stock_value"], + } + ) + + def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filters): segregated_entries = [] qty_before_transaction = sle.qty_after_transaction - sle.actual_qty @@ -602,19 +666,26 @@ def get_opening_balance_from_batch(filters, columns, sl_entries): } -def get_opening_balance(filters, columns, sl_entries): +def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=None): if not (filters.item_code and filters.warehouse and filters.from_date): return from erpnext.stock.stock_ledger import get_previous_sle + project = None + if filters.get("project") and not frappe.get_all( + "Inventory Dimension", filters={"reference_document": "Project"} + ): + project = filters.get("project") + last_entry = get_previous_sle( { "item_code": filters.item_code, "warehouse_condition": get_warehouse_condition(filters.warehouse), "posting_date": filters.from_date, "posting_time": "00:00:00", - } + "project": project, + }, ) # check if any SLEs are actually Opening Stock Reconciliation @@ -686,9 +757,75 @@ def get_item_group_condition(item_group, item_table=None): where ig.lft >= {item_group_details.lft} and ig.rgt <= {item_group_details.rgt} and item.item_group = ig.name)" -def check_inventory_dimension_filters_applied(filters) -> bool: +def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): + if not filters.item_code or not filters.warehouse or not filters.from_date: + return + + if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1: + return + + sl_doctype = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(sl_doctype) + .select( + sl_doctype.item_code, + sl_doctype.warehouse, + Sum(sl_doctype.actual_qty).as_("qty_after_transaction"), + Sum(sl_doctype.stock_value_difference).as_("stock_value"), + ) + .where( + (sl_doctype.posting_date < filters.from_date) + & (sl_doctype.docstatus < 2) + & (sl_doctype.is_cancelled == 0) + ) + ) + + if filters.get("item_code"): + if isinstance(filters.item_code, list | tuple): + query = query.where(sl_doctype.item_code.isin(filters.item_code)) + else: + query = query.where(sl_doctype.item_code == filters.item_code) + + if filters.get("warehouse"): + if isinstance(filters.warehouse, list | tuple): + query = query.where(sl_doctype.warehouse.isin(filters.warehouse)) + else: + query = query.where(sl_doctype.warehouse == filters.warehouse) + + for key, value in inv_dimension_wise_value.items(): + if isinstance(value, list | tuple): + query = query.where(sl_doctype[key].isin(value)) + else: + query = query.where(sl_doctype[key] == value) + + opening_data = query.run(as_dict=True) + + if opening_data: + return frappe._dict( + { + "item_code": _("'Opening'"), + "qty_after_transaction": opening_data[0].qty_after_transaction, + "stock_value": opening_data[0].stock_value, + "valuation_rate": flt(opening_data[0].stock_value) + / flt(opening_data[0].qty_after_transaction) + if opening_data[0].qty_after_transaction + else 0, + } + ) + + return frappe._dict({}) + + +def get_inv_dimension_wise_value(filters) -> list: + inv_dimension_key = frappe._dict({}) for dimension in get_inventory_dimensions(): if dimension.fieldname in filters and filters.get(dimension.fieldname): - return True + inv_dimension_key[dimension.fieldname] = filters.get(dimension.fieldname) - return False + if filters.get("project") and not frappe.get_all( + "Inventory Dimension", filters={"reference_document": "Project"} + ): + inv_dimension_key["project"] = filters.get("project") + + return inv_dimension_key diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 883f791d7f8..69c94afe30d 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1847,6 +1847,9 @@ def get_stock_ledger_entries( if extra_cond: conditions += f"{extra_cond}" + if previous_sle.get("project"): + conditions += " and project = %(project)s" + # nosemgrep return frappe.db.sql( """ From ba4a99b22c782bace427bfbd11b6b17fab09aabc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:40:49 +0530 Subject: [PATCH 13/30] fix(help): escape query (backport #53192) (#53194) fix(help): escape query (#53192) (cherry picked from commit 702adda00046fc0a1f6501bba9c2d1d03438798a) Signed-off-by: Akhil Narang Co-authored-by: Akhil Narang --- erpnext/templates/pages/help.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/templates/pages/help.html b/erpnext/templates/pages/help.html index 1cfe358efd6..726d5e1b881 100644 --- a/erpnext/templates/pages/help.html +++ b/erpnext/templates/pages/help.html @@ -8,7 +8,7 @@
From 180e232eb0451b5f10aa6792a2ca5c7ebc668e1c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 6 Mar 2026 10:34:24 +0530 Subject: [PATCH 14/30] fix: stock balance report qty (cherry picked from commit a15e5fdc4eea302970e3790caf71c69163bf3306) # Conflicts: # erpnext/stock/report/stock_balance/stock_balance.py --- .../report/stock_balance/stock_balance.py | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 701035d8187..1c60b534672 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -378,6 +378,244 @@ class StockBalanceReport: self.sle_query = query +<<<<<<< HEAD +======= + def prepare_item_warehouse_map_for_current_period(self): + self.opening_vouchers = self.get_opening_vouchers() + + if self.filters.get("show_stock_ageing_data"): + self.sle_entries = self.sle_query.run(as_dict=True) + + self.prepare_stock_reco_voucher_wise_count() + + # HACK: This is required to avoid causing db query in flt + _system_settings = frappe.get_cached_doc("System Settings") + with frappe.db.unbuffered_cursor(): + if not self.filters.get("show_stock_ageing_data"): + self.sle_entries = self.sle_query.run(as_dict=True, as_iterator=True) + + for entry in self.sle_entries: + group_by_key = self.get_group_by_key(entry) + if group_by_key not in self.item_warehouse_map: + self.initialize_data(group_by_key, entry) + + self.prepare_item_warehouse_map(entry, group_by_key) + + self.item_warehouse_map = filter_items_with_no_transactions( + self.item_warehouse_map, self.float_precision, self.inventory_dimensions + ) + + def prepare_stock_reco_voucher_wise_count(self): + self.stock_reco_voucher_wise_count = frappe._dict() + + doctype = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(item) + .on(doctype.item_code == item.name) + .select(doctype.voucher_detail_no, Count(doctype.name).as_("count")) + .where( + (doctype.voucher_type == "Stock Reconciliation") + & (doctype.docstatus < 2) + & (doctype.is_cancelled == 0) + & (item.has_serial_no == 1) + ) + .groupby(doctype.voucher_detail_no) + ) + + if items := self.filters.item_code: + if isinstance(items, str): + items = [items] + + query = query.where(item.name.isin(items)) + + if self.filters.item_group: + childrens = [] + childrens.append(self.filters.item_group) + if item_group_childrens := get_descendants_of( + "Item Group", self.filters.item_group, ignore_permissions=True + ): + childrens.extend(item_group_childrens) + + if childrens: + query = query.where(item.item_group.isin(childrens)) + + if warehouses := self.filters.get("warehouse"): + if isinstance(warehouses, str): + warehouses = [warehouses] + + childrens = [] + for warehouse in warehouses: + childrens.append(warehouse) + if warehouse_childrens := get_descendants_of("Warehouse", warehouse, ignore_permissions=True): + childrens.extend(warehouse_childrens) + + if childrens: + query = query.where(doctype.warehouse.isin(childrens)) + + data = query.run(as_dict=True) + if not data: + return + + for row in data: + if row.count != 1: + continue + + sr_item = frappe.db.get_value( + "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True + ) + + if sr_item.qty and sr_item.current_qty: + self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty + + def prepare_new_data(self): + if self.filters.get("show_stock_ageing_data"): + self.filters["show_warehouse_wise_stock"] = True + item_wise_fifo_queue = FIFOSlots(self.filters).generate() + + _func = itemgetter(1) + + del self.sle_entries + + sre_details = self.get_sre_reserved_qty_details() + + variant_values = {} + if self.filters.get("show_variant_attributes"): + variant_values = self.get_variant_values_for() + + for _key, report_data in self.item_warehouse_map.items(): + if variant_data := variant_values.get(report_data.item_code): + report_data.update(variant_data) + + if self.filters.get("show_stock_ageing_data"): + opening_fifo_queue = self.get_opening_fifo_queue(report_data) or [] + + fifo_queue = [] + if fifo_queue := item_wise_fifo_queue.get((report_data.item_code, report_data.warehouse)): + fifo_queue = fifo_queue.get("fifo_queue") + + if fifo_queue: + opening_fifo_queue.extend(fifo_queue) + + stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} + + if opening_fifo_queue: + fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) + if not fifo_queue: + continue + + to_date = self.to_date + stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) + stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) + stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue + + report_data.update(stock_ageing_data) + + report_data.update( + {"reserved_stock": sre_details.get((report_data.item_code, report_data.warehouse), 0.0)} + ) + + if ( + not self.filters.get("include_zero_stock_items") + and report_data + and report_data.bal_qty == 0 + and report_data.bal_val == 0 + ): + continue + + self.data.append(report_data) + + def get_sre_reserved_qty_details(self) -> dict: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_for_items_and_warehouses as get_reserved_qty_details, + ) + + item_code_list, warehouse_list = [], [] + for d in self.item_warehouse_map: + item_code_list.append(d[0]) + warehouse_list.append(d[1]) + + return get_reserved_qty_details(item_code_list, warehouse_list) + + def prepare_item_warehouse_map(self, entry, group_by_key): + qty_dict = self.item_warehouse_map[group_by_key] + for field in self.inventory_dimensions: + qty_dict[field] = entry.get(field) + + if entry.voucher_type == "Stock Reconciliation" and ( + not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle + ): + if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count: + qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0) + qty_dict.bal_qty = 0.0 + qty_diff = flt(entry.actual_qty) + else: + qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) + else: + qty_diff = flt(entry.actual_qty) + + value_diff = flt(entry.stock_value_difference) + + if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get( + entry.voucher_type, [] + ): + qty_dict.opening_qty += qty_diff + qty_dict.opening_val += value_diff + + elif entry.posting_date >= self.from_date and entry.posting_date <= self.to_date: + if flt(qty_diff, self.float_precision) >= 0: + qty_dict.in_qty += qty_diff + else: + qty_dict.out_qty += abs(qty_diff) + + if flt(value_diff, self.float_precision) >= 0: + qty_dict.in_val += value_diff + else: + qty_dict.out_val += abs(value_diff) + + qty_dict.val_rate = entry.valuation_rate + qty_dict.bal_qty += qty_diff + qty_dict.bal_val += value_diff + + def initialize_data(self, group_by_key, entry): + self.item_warehouse_map[group_by_key] = frappe._dict( + { + "item_code": entry.item_code, + "warehouse": entry.warehouse, + "item_group": entry.item_group, + "company": entry.company, + "currency": self.company_currency, + "stock_uom": entry.stock_uom, + "item_name": entry.item_name, + "opening_qty": 0.0, + "opening_val": 0.0, + "opening_fifo_queue": [], + "in_qty": 0.0, + "in_val": 0.0, + "out_qty": 0.0, + "out_val": 0.0, + "bal_qty": 0.0, + "bal_val": 0.0, + "val_rate": 0.0, + } + ) + + def get_group_by_key(self, row) -> tuple: + group_by_key = [row.item_code, row.warehouse] + + for fieldname in self.inventory_dimensions: + if not row.get(fieldname): + continue + + if self.filters.get(fieldname) or self.filters.get("show_dimension_wise_stock"): + group_by_key.append(row.get(fieldname)) + + return tuple(group_by_key) + +>>>>>>> a15e5fdc4e (fix: stock balance report qty) def apply_inventory_dimensions_filters(self, query, sle) -> str: inventory_dimension_fields = self.get_inventory_dimension_fields() if inventory_dimension_fields: From 54fdce648e552f40b179b5e50655d2c77b2db562 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 6 Mar 2026 12:47:18 +0530 Subject: [PATCH 15/30] chore: fix conflicts --- .../report/stock_balance/stock_balance.py | 276 +++--------------- 1 file changed, 35 insertions(+), 241 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 1c60b534672..f219cc23dce 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -203,6 +203,36 @@ class StockBalanceReport: .groupby(doctype.voucher_detail_no) ) + if items := self.filters.item_code: + if isinstance(items, str): + items = [items] + + query = query.where(item.name.isin(items)) + + if self.filters.item_group: + childrens = [] + childrens.append(self.filters.item_group) + if item_group_childrens := get_descendants_of( + "Item Group", self.filters.item_group, ignore_permissions=True + ): + childrens.extend(item_group_childrens) + + if childrens: + query = query.where(item.item_group.isin(childrens)) + + if warehouses := self.filters.get("warehouse"): + if isinstance(warehouses, str): + warehouses = [warehouses] + + childrens = [] + for warehouse in warehouses: + childrens.append(warehouse) + if warehouse_childrens := get_descendants_of("Warehouse", warehouse, ignore_permissions=True): + childrens.extend(warehouse_childrens) + + if childrens: + query = query.where(doctype.warehouse.isin(childrens)) + data = query.run(as_dict=True) if not data: return @@ -211,10 +241,12 @@ class StockBalanceReport: if row.count != 1: continue - current_qty = frappe.db.get_value( - "Stock Reconciliation Item", row.voucher_detail_no, "current_qty" + sr_item = frappe.db.get_value( + "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True ) - self.stock_reco_voucher_wise_count[row.voucher_detail_no] = current_qty + + if sr_item.qty and sr_item.current_qty: + self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty def get_sre_reserved_qty_details(self) -> dict: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( @@ -378,244 +410,6 @@ class StockBalanceReport: self.sle_query = query -<<<<<<< HEAD -======= - def prepare_item_warehouse_map_for_current_period(self): - self.opening_vouchers = self.get_opening_vouchers() - - if self.filters.get("show_stock_ageing_data"): - self.sle_entries = self.sle_query.run(as_dict=True) - - self.prepare_stock_reco_voucher_wise_count() - - # HACK: This is required to avoid causing db query in flt - _system_settings = frappe.get_cached_doc("System Settings") - with frappe.db.unbuffered_cursor(): - if not self.filters.get("show_stock_ageing_data"): - self.sle_entries = self.sle_query.run(as_dict=True, as_iterator=True) - - for entry in self.sle_entries: - group_by_key = self.get_group_by_key(entry) - if group_by_key not in self.item_warehouse_map: - self.initialize_data(group_by_key, entry) - - self.prepare_item_warehouse_map(entry, group_by_key) - - self.item_warehouse_map = filter_items_with_no_transactions( - self.item_warehouse_map, self.float_precision, self.inventory_dimensions - ) - - def prepare_stock_reco_voucher_wise_count(self): - self.stock_reco_voucher_wise_count = frappe._dict() - - doctype = frappe.qb.DocType("Stock Ledger Entry") - item = frappe.qb.DocType("Item") - - query = ( - frappe.qb.from_(doctype) - .inner_join(item) - .on(doctype.item_code == item.name) - .select(doctype.voucher_detail_no, Count(doctype.name).as_("count")) - .where( - (doctype.voucher_type == "Stock Reconciliation") - & (doctype.docstatus < 2) - & (doctype.is_cancelled == 0) - & (item.has_serial_no == 1) - ) - .groupby(doctype.voucher_detail_no) - ) - - if items := self.filters.item_code: - if isinstance(items, str): - items = [items] - - query = query.where(item.name.isin(items)) - - if self.filters.item_group: - childrens = [] - childrens.append(self.filters.item_group) - if item_group_childrens := get_descendants_of( - "Item Group", self.filters.item_group, ignore_permissions=True - ): - childrens.extend(item_group_childrens) - - if childrens: - query = query.where(item.item_group.isin(childrens)) - - if warehouses := self.filters.get("warehouse"): - if isinstance(warehouses, str): - warehouses = [warehouses] - - childrens = [] - for warehouse in warehouses: - childrens.append(warehouse) - if warehouse_childrens := get_descendants_of("Warehouse", warehouse, ignore_permissions=True): - childrens.extend(warehouse_childrens) - - if childrens: - query = query.where(doctype.warehouse.isin(childrens)) - - data = query.run(as_dict=True) - if not data: - return - - for row in data: - if row.count != 1: - continue - - sr_item = frappe.db.get_value( - "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True - ) - - if sr_item.qty and sr_item.current_qty: - self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty - - def prepare_new_data(self): - if self.filters.get("show_stock_ageing_data"): - self.filters["show_warehouse_wise_stock"] = True - item_wise_fifo_queue = FIFOSlots(self.filters).generate() - - _func = itemgetter(1) - - del self.sle_entries - - sre_details = self.get_sre_reserved_qty_details() - - variant_values = {} - if self.filters.get("show_variant_attributes"): - variant_values = self.get_variant_values_for() - - for _key, report_data in self.item_warehouse_map.items(): - if variant_data := variant_values.get(report_data.item_code): - report_data.update(variant_data) - - if self.filters.get("show_stock_ageing_data"): - opening_fifo_queue = self.get_opening_fifo_queue(report_data) or [] - - fifo_queue = [] - if fifo_queue := item_wise_fifo_queue.get((report_data.item_code, report_data.warehouse)): - fifo_queue = fifo_queue.get("fifo_queue") - - if fifo_queue: - opening_fifo_queue.extend(fifo_queue) - - stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} - - if opening_fifo_queue: - fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) - if not fifo_queue: - continue - - to_date = self.to_date - stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) - stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) - stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) - stock_ageing_data["fifo_queue"] = fifo_queue - - report_data.update(stock_ageing_data) - - report_data.update( - {"reserved_stock": sre_details.get((report_data.item_code, report_data.warehouse), 0.0)} - ) - - if ( - not self.filters.get("include_zero_stock_items") - and report_data - and report_data.bal_qty == 0 - and report_data.bal_val == 0 - ): - continue - - self.data.append(report_data) - - def get_sre_reserved_qty_details(self) -> dict: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_for_items_and_warehouses as get_reserved_qty_details, - ) - - item_code_list, warehouse_list = [], [] - for d in self.item_warehouse_map: - item_code_list.append(d[0]) - warehouse_list.append(d[1]) - - return get_reserved_qty_details(item_code_list, warehouse_list) - - def prepare_item_warehouse_map(self, entry, group_by_key): - qty_dict = self.item_warehouse_map[group_by_key] - for field in self.inventory_dimensions: - qty_dict[field] = entry.get(field) - - if entry.voucher_type == "Stock Reconciliation" and ( - not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle - ): - if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count: - qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0) - qty_dict.bal_qty = 0.0 - qty_diff = flt(entry.actual_qty) - else: - qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) - else: - qty_diff = flt(entry.actual_qty) - - value_diff = flt(entry.stock_value_difference) - - if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get( - entry.voucher_type, [] - ): - qty_dict.opening_qty += qty_diff - qty_dict.opening_val += value_diff - - elif entry.posting_date >= self.from_date and entry.posting_date <= self.to_date: - if flt(qty_diff, self.float_precision) >= 0: - qty_dict.in_qty += qty_diff - else: - qty_dict.out_qty += abs(qty_diff) - - if flt(value_diff, self.float_precision) >= 0: - qty_dict.in_val += value_diff - else: - qty_dict.out_val += abs(value_diff) - - qty_dict.val_rate = entry.valuation_rate - qty_dict.bal_qty += qty_diff - qty_dict.bal_val += value_diff - - def initialize_data(self, group_by_key, entry): - self.item_warehouse_map[group_by_key] = frappe._dict( - { - "item_code": entry.item_code, - "warehouse": entry.warehouse, - "item_group": entry.item_group, - "company": entry.company, - "currency": self.company_currency, - "stock_uom": entry.stock_uom, - "item_name": entry.item_name, - "opening_qty": 0.0, - "opening_val": 0.0, - "opening_fifo_queue": [], - "in_qty": 0.0, - "in_val": 0.0, - "out_qty": 0.0, - "out_val": 0.0, - "bal_qty": 0.0, - "bal_val": 0.0, - "val_rate": 0.0, - } - ) - - def get_group_by_key(self, row) -> tuple: - group_by_key = [row.item_code, row.warehouse] - - for fieldname in self.inventory_dimensions: - if not row.get(fieldname): - continue - - if self.filters.get(fieldname) or self.filters.get("show_dimension_wise_stock"): - group_by_key.append(row.get(fieldname)) - - return tuple(group_by_key) - ->>>>>>> a15e5fdc4e (fix: stock balance report qty) def apply_inventory_dimensions_filters(self, query, sle) -> str: inventory_dimension_fields = self.get_inventory_dimension_fields() if inventory_dimension_fields: From e9ae156323536d7eea675ff6cc33839f9638870b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:59:15 +0530 Subject: [PATCH 16/30] refactor: use postprocess in mapped_doc to update items in subcontracting controller (backport #52724) (#52936) * refactor: use postprocess in mapped_doc to update items in subcontracting controller (cherry picked from commit 1d3d09f48cfbac85d8ac59f7a116086d7a5fa32f) # Conflicts: # erpnext/controllers/subcontracting_controller.py * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: ljain112 --- .../controllers/subcontracting_controller.py | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 246693d2d01..66109479d8c 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -1297,6 +1297,55 @@ def make_rm_stock_entry( if target_doc and target_doc.get("items"): target_doc.items = [] + def post_process(source_doc, target_doc): + target_doc.purpose = "Send to Subcontractor" + + if order_doctype == "Purchase Order": + target_doc.purchase_order = source_doc.name + else: + target_doc.subcontracting_order = source_doc.name + + target_doc.set_stock_entry_type() + + for fg_item_code in fg_item_code_list: + for rm_item in rm_items: + if ( + rm_item.get("main_item_code") == fg_item_code + or rm_item.get("item_code") == fg_item_code + ): + rm_item_code = rm_item.get("rm_item_code") + + items_dict = { + rm_item_code: { + rm_detail_field: rm_item.get("name"), + "item_name": rm_item.get("item_name") + or item_wh.get(rm_item_code, {}).get("item_name", ""), + "description": item_wh.get(rm_item_code, {}).get("description", ""), + "qty": rm_item.get("qty") + or max( + rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0 + ), + "from_warehouse": rm_item.get("warehouse") + or rm_item.get("reserve_warehouse"), + "to_warehouse": source_doc.supplier_warehouse, + "stock_uom": rm_item.get("stock_uom"), + "serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"), + "main_item_code": fg_item_code, + "allow_alternative_item": item_wh.get(rm_item_code, {}).get( + "allow_alternative_item" + ), + "use_serial_batch_fields": rm_item.get("use_serial_batch_fields"), + "serial_no": rm_item.get("serial_no") + if rm_item.get("use_serial_batch_fields") + else None, + "batch_no": rm_item.get("batch_no") + if rm_item.get("use_serial_batch_fields") + else None, + } + } + + target_doc.add_to_stock_entry_detail(items_dict) + stock_entry = get_mapped_doc( order_doctype, subcontract_order.name, @@ -1317,53 +1366,9 @@ def make_rm_stock_entry( }, target_doc, ignore_child_tables=True, + postprocess=post_process, ) - stock_entry.purpose = "Send to Subcontractor" - - if order_doctype == "Purchase Order": - stock_entry.purchase_order = subcontract_order.name - else: - stock_entry.subcontracting_order = subcontract_order.name - - stock_entry.set_stock_entry_type() - - for fg_item_code in fg_item_code_list: - for rm_item in rm_items: - if ( - rm_item.get("main_item_code") == fg_item_code - or rm_item.get("item_code") == fg_item_code - ): - rm_item_code = rm_item.get("rm_item_code") - items_dict = { - rm_item_code: { - rm_detail_field: rm_item.get("name"), - "item_name": rm_item.get("item_name") - or item_wh.get(rm_item_code, {}).get("item_name", ""), - "description": item_wh.get(rm_item_code, {}).get("description", ""), - "qty": rm_item.get("qty") - or max(rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0), - "from_warehouse": rm_item.get("warehouse") - or rm_item.get("reserve_warehouse"), - "to_warehouse": subcontract_order.supplier_warehouse, - "stock_uom": rm_item.get("stock_uom"), - "serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"), - "main_item_code": fg_item_code, - "allow_alternative_item": item_wh.get(rm_item_code, {}).get( - "allow_alternative_item" - ), - "use_serial_batch_fields": rm_item.get("use_serial_batch_fields"), - "serial_no": rm_item.get("serial_no") - if rm_item.get("use_serial_batch_fields") - else None, - "batch_no": rm_item.get("batch_no") - if rm_item.get("use_serial_batch_fields") - else None, - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - if target_doc: return stock_entry else: @@ -1395,6 +1400,8 @@ def add_items_in_ste(ste_doc, row, qty, rm_details, rm_detail_field="sco_rm_deta def make_return_stock_entry_for_subcontract( available_materials, order_doc, rm_details, order_doctype="Subcontracting Order" ): + rm_detail_field = "po_detail" if order_doctype == "Purchase Order" else "sco_rm_detail" + def post_process(source_doc, target_doc): target_doc.purpose = "Material Transfer" @@ -1405,6 +1412,21 @@ def make_return_stock_entry_for_subcontract( target_doc.company = source_doc.company target_doc.is_return = 1 + for _key, value in available_materials.items(): + if not value.qty: + continue + + if item_details := value.get("item_details"): + item_details["serial_and_batch_bundle"] = None + + if value.batch_no: + for batch_no, qty in value.batch_no.items(): + if qty > 0: + add_items_in_ste(target_doc, value, qty, rm_details, rm_detail_field, batch_no) + else: + add_items_in_ste(target_doc, value, value.qty, rm_details, rm_detail_field) + + target_doc.set_stock_entry_type() ste_doc = get_mapped_doc( order_doctype, @@ -1419,27 +1441,6 @@ def make_return_stock_entry_for_subcontract( postprocess=post_process, ) - if order_doctype == "Purchase Order": - rm_detail_field = "po_detail" - else: - rm_detail_field = "sco_rm_detail" - - for _key, value in available_materials.items(): - if not value.qty: - continue - - if item_details := value.get("item_details"): - item_details["serial_and_batch_bundle"] = None - - if value.batch_no: - for batch_no, qty in value.batch_no.items(): - if qty > 0: - add_items_in_ste(ste_doc, value, qty, rm_details, rm_detail_field, batch_no) - else: - add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field) - - ste_doc.set_stock_entry_type() - return ste_doc From 37e750e8775f90455b530c108e86b86b87c5f938 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sat, 7 Mar 2026 17:39:03 +0530 Subject: [PATCH 17/30] refactor: party type and party filter for comparison report (cherry picked from commit b6f9c0844eeb154a7cc4ab52547cf1c33d589d75) --- .../general_and_payment_ledger_comparison.js | 14 ++++++++++++++ .../general_and_payment_ledger_comparison.py | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js index 4eadf342be8..fa651541696 100644 --- a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js @@ -37,6 +37,20 @@ function get_filters() { }); }, }, + { + fieldname: "party_type", + label: __("Party Type"), + fieldtype: "Link", + options: "Party Type", + width: 100, + }, + { + fieldname: "party", + label: __("Party"), + fieldtype: "Dynamic Link", + options: "party_type", + width: 100, + }, { fieldname: "voucher_no", label: __("Voucher No"), diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py index 9d079eb9ebd..e28bd4edefe 100644 --- a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py @@ -68,6 +68,12 @@ class General_Payment_Ledger_Comparison: if self.filters.period_end_date: filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date)) + if self.filters.party_type: + filter_criterion.append(gle.party_type.eq(self.filters.party_type)) + + if self.filters.party: + filter_criterion.append(gle.party.eq(self.filters.party)) + if acc_type == "receivable": outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding") else: @@ -111,6 +117,12 @@ class General_Payment_Ledger_Comparison: if self.filters.period_end_date: filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date)) + if self.filters.party_type: + filter_criterion.append(ple.party_type.eq(self.filters.party_type)) + + if self.filters.party: + filter_criterion.append(ple.party.eq(self.filters.party)) + self.account_types[acc_type].ple = ( qb.from_(ple) .select( From 260d87a80c086669324a4726b9b5797ce17a5b16 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 8 Mar 2026 19:12:51 +0530 Subject: [PATCH 18/30] fix(manufacturing): show returned qty in progress bar (cherry picked from commit 8027f5aafdadd2293357f158f0610d011e51ae7e) --- .../manufacturing/doctype/work_order/work_order.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 971b811fbcc..a1f23e6e145 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -445,10 +445,21 @@ frappe.ui.form.on("Work Order", { if (pending_complete > 0) { var width = (pending_complete / frm.doc.qty) * 100 - added_min; title = __("{0} items in progress", [pending_complete]); + let progress_class = "progress-bar-warning"; + if (frm.doc.status == "Closed") { + if (frm.doc.required_items.find((d) => d.returned_qty > 0)) { + title = __("{0} items returned", [pending_complete]); + progress_class = "progress-bar-warning"; + } else { + title = __("{0} items to return", [pending_complete]); + progress_class = "progress-bar-info"; + } + } + bars.push({ title: title, width: (width > 100 ? "99.5" : width) + "%", - progress_class: "progress-bar-warning", + progress_class: progress_class, }); message = message + ". " + title; } From c572a019b4d1b3d80ad53cb27187b6942e3341f4 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 8 Mar 2026 19:14:01 +0530 Subject: [PATCH 19/30] feat(manufacturing): show disassembled qty in progress bar (cherry picked from commit ae9ff767fa657e909253ff4512189c85c780b367) --- .../doctype/work_order/work_order.js | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index a1f23e6e145..b6206cefcbb 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -425,10 +425,11 @@ frappe.ui.form.on("Work Order", { var added_min = false; // produced qty - var title = __("{0} items produced", [frm.doc.produced_qty]); + let produced_qty = frm.doc.produced_qty - frm.doc.disassembled_qty; + var title = __("{0} items produced", [produced_qty]); bars.push({ title: title, - width: (frm.doc.produced_qty / frm.doc.qty) * 100 + "%", + width: (flt(produced_qty) / frm.doc.qty) * 100 + "%", progress_class: "progress-bar-success", }); if (bars[0].width == "0%") { @@ -464,6 +465,8 @@ frappe.ui.form.on("Work Order", { message = message + ". " + title; } } + + //process loss qty if (frm.doc.process_loss_qty) { var process_loss_width = (frm.doc.process_loss_qty / frm.doc.qty) * 100; title = __("{0} items lost during process.", [frm.doc.process_loss_qty]); @@ -474,6 +477,19 @@ frappe.ui.form.on("Work Order", { }); message = message + ". " + title; } + + // disassembled qty + if (frm.doc.disassembled_qty) { + var disassembled_width = (frm.doc.disassembled_qty / frm.doc.qty) * 100; + title = __("{0} items disassembled", [frm.doc.disassembled_qty]); + bars.push({ + title: title, + width: disassembled_width + "%", + progress_class: "progress-bar-secondary", + }); + message = message + ". " + title; + } + frm.dashboard.add_progress(__("Status"), bars, message); }, From c142a2be9c8270175c007e0b530de8f2a39425a3 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 9 Mar 2026 12:05:46 +0530 Subject: [PATCH 20/30] fix: validation for cancellation (cherry picked from commit 8de272a8a1c9295184febc773d028493d49f56ce) --- erpnext/stock/serial_batch_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 896b43088ad..84535372b4b 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -333,7 +333,7 @@ class SerialBatchBundle: "Serial and Batch Entry", {"parent": self.sle.serial_and_batch_bundle, "docstatus": 0} ) > 0 - ): + ) and not self.sle.is_cancelled: frappe.throw( _("Serial and Batch Bundle {0} is not submitted").format( bold(self.sle.serial_and_batch_bundle) From c71557f4321259e15877c97a3f9862f8dcba0793 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 9 Mar 2026 18:03:59 +0530 Subject: [PATCH 21/30] fix: correct logic for repair cost in asset repair --- .../doctype/asset_repair/asset_repair.js | 8 +- .../doctype/asset_repair/asset_repair.py | 91 ++++++++++++++++++- .../doctype/asset_repair/test_asset_repair.py | 48 ++++++++++ 3 files changed, 139 insertions(+), 8 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 48bf4ff478d..a3ddd066ebd 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -111,15 +111,13 @@ frappe.ui.form.on("Asset Repair", { purchase_invoice: function (frm) { if (frm.doc.purchase_invoice) { frappe.call({ - method: "frappe.client.get_value", + method: "erpnext.assets.doctype.asset_repair.asset_repair.get_repair_cost_for_purchase_invoice", args: { - doctype: "Purchase Invoice", - fieldname: "base_net_total", - filters: { name: frm.doc.purchase_invoice }, + purchase_invoice: frm.doc.purchase_invoice, }, callback: function (r) { if (r.message) { - frm.set_value("repair_cost", r.message.base_net_total); + frm.set_value("repair_cost", r.message); } }, }); diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index e0d41919c4a..4d59ba5b96c 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours import erpnext @@ -308,9 +309,14 @@ class AssetRepair(AccountsController): if flt(self.repair_cost) <= 0: return - pi_expense_account = ( - frappe.get_doc("Purchase Invoice", self.purchase_invoice).items[0].expense_account - ) + expense_accounts = _get_expense_accounts_for_purchase_invoice(self.purchase_invoice) + + if not expense_accounts: + frappe.throw( + _("No expense accounts found for Purchase Invoice {0}").format(self.purchase_invoice) + ) + + pi_expense_account = expense_accounts[0] gl_entries.append( self.get_gl_dict( @@ -473,3 +479,82 @@ class AssetRepair(AccountsController): def get_downtime(failure_date, completion_date): downtime = time_diff_in_hours(completion_date, failure_date) return round(downtime, 2) + + +@frappe.whitelist() +def get_repair_cost_for_purchase_invoice(purchase_invoice: str) -> float: + """ + Get the total repair cost from GL entries for a purchase invoice. + Only considers expense accounts for non-stock, non-fixed-asset items. + """ + if not purchase_invoice: + return 0.0 + + expense_accounts = _get_expense_accounts_for_purchase_invoice(purchase_invoice) + + if not expense_accounts: + return 0.0 + + return _get_total_expense_amount(purchase_invoice, expense_accounts) + + +def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[str]: + """ + Get expense accounts for non-stock items from the purchase invoice. + """ + pi_items = frappe.db.get_list( + "Purchase Invoice Item", + filters={"parent": purchase_invoice}, + fields=["item_code", "expense_account", "is_fixed_asset"], + ) + + if not pi_items: + return [] + + # Get list of stock item codes from the invoice + item_codes = {item.item_code for item in pi_items if item.item_code} + stock_items = set() + if item_codes: + stock_items = set( + frappe.db.get_all( + "Item", filters={"name": ["in", list(item_codes)], "is_stock_item": 1}, pluck="name" + ) + ) + + expense_accounts = set() + + for item in pi_items: + # Skip stock items - they use warehouse accounts + if item.item_code and item.item_code in stock_items: + continue + + # Skip fixed assets - they use asset accounts + if item.is_fixed_asset: + continue + + # Use expense account from Purchase Invoice Item + if item.expense_account: + expense_accounts.add(item.expense_account) + + return list(expense_accounts) + + +def _get_total_expense_amount(purchase_invoice: str, expense_accounts: list[str]) -> float: + """Get the total expense amount from GL entries for a purchase invoice and accounts.""" + if not expense_accounts: + return 0.0 + + gl_entry = frappe.qb.DocType("GL Entry") + + result = ( + frappe.qb.from_(gl_entry) + .select((Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("total")) + .where( + (gl_entry.voucher_type == "Purchase Invoice") + & (gl_entry.voucher_no == purchase_invoice) + & (gl_entry.account.isin(expense_accounts)) + & (gl_entry.is_cancelled == 0) + ) + ).run(as_dict=True) + + return flt(result[0].total) if result else 0.0 diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 4cd304fbfd0..1b5290ca8ea 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -8,6 +8,7 @@ from frappe import qb from frappe.query_builder.functions import Sum from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowtime, today +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.assets.doctype.asset.asset import ( get_asset_account, get_asset_value_after_depreciation, @@ -21,6 +22,7 @@ from erpnext.assets.doctype.asset.test_asset import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( get_asset_depr_schedule_doc, ) +from erpnext.assets.doctype.asset_repair.asset_repair import get_repair_cost_for_purchase_invoice from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_serial_nos_from_bundle, @@ -321,6 +323,52 @@ class TestAssetRepair(unittest.TestCase): self.assertEqual(asset.additional_asset_cost, asset_repair.repair_cost) self.assertEqual(booked_value, asset_repair.repair_cost) + def test_repair_cost_fetches_only_service_item_amount(self): + """Test that repair cost only includes service (non-stock) item amounts from purchase invoice.""" + + service_item = create_item( + "_Test Service Item for Repair", + is_stock_item=0, + company="_Test Company", + ) + + stock_item = create_item( + "_Test Stock Item for Repair", + is_stock_item=1, + company="_Test Company", + ) + + expense_account = frappe.db.get_value("Company", "_Test Company", "default_expense_account") + cost_center = frappe.db.get_value("Company", "_Test Company", "cost_center") + + pi = make_purchase_invoice( + item_code=service_item.name, + qty=1, + rate=500, + expense_account=expense_account, + cost_center=cost_center, + update_stock=0, + do_not_submit=1, + ) + + pi.update_stock = 1 + pi.append( + "items", + { + "item_code": stock_item.name, + "qty": 2, + "rate": 300, + "warehouse": "_Test Warehouse - _TC", + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + repair_cost = get_repair_cost_for_purchase_invoice(pi.name) + + self.assertEqual(repair_cost, 500) + def num_of_depreciations(asset): return asset.finance_books[0].total_number_of_depreciations From 0b1746a4c8e4d4109c06ada55a16d3b7cca95027 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 9 Mar 2026 18:34:46 +0530 Subject: [PATCH 22/30] fix: set default repair cost to 0 if no value is returned --- erpnext/assets/doctype/asset_repair/asset_repair.js | 4 +--- .../assets/doctype/asset_repair/test_asset_repair.py | 12 +++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index a3ddd066ebd..646a7eee7ef 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -116,9 +116,7 @@ frappe.ui.form.on("Asset Repair", { purchase_invoice: frm.doc.purchase_invoice, }, callback: function (r) { - if (r.message) { - frm.set_value("repair_cost", r.message); - } + frm.set_value("repair_cost", r.message || 0); }, }); } else { diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 1b5290ca8ea..10540e462a9 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -326,20 +326,21 @@ class TestAssetRepair(unittest.TestCase): def test_repair_cost_fetches_only_service_item_amount(self): """Test that repair cost only includes service (non-stock) item amounts from purchase invoice.""" + company = "_Test Company with perpetual inventory" service_item = create_item( "_Test Service Item for Repair", is_stock_item=0, - company="_Test Company", + company=company, ) stock_item = create_item( "_Test Stock Item for Repair", is_stock_item=1, - company="_Test Company", + company=company, ) - expense_account = frappe.db.get_value("Company", "_Test Company", "default_expense_account") - cost_center = frappe.db.get_value("Company", "_Test Company", "cost_center") + expense_account = frappe.db.get_value("Company", company, "default_expense_account") + cost_center = frappe.db.get_value("Company", company, "cost_center") pi = make_purchase_invoice( item_code=service_item.name, @@ -349,6 +350,7 @@ class TestAssetRepair(unittest.TestCase): cost_center=cost_center, update_stock=0, do_not_submit=1, + company=company, ) pi.update_stock = 1 @@ -358,7 +360,7 @@ class TestAssetRepair(unittest.TestCase): "item_code": stock_item.name, "qty": 2, "rate": 300, - "warehouse": "_Test Warehouse - _TC", + "warehouse": "Stores - TCP1", "cost_center": cost_center, }, ) From ed428ceb1c271da5f6018583377258e0b7c2ce7c Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 9 Mar 2026 18:54:43 +0530 Subject: [PATCH 23/30] fix(test): ensure warehouse is consistently referenced in asset repair tests --- erpnext/assets/doctype/asset_repair/test_asset_repair.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 10540e462a9..d39ce225952 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -327,15 +327,19 @@ class TestAssetRepair(unittest.TestCase): """Test that repair cost only includes service (non-stock) item amounts from purchase invoice.""" company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + service_item = create_item( "_Test Service Item for Repair", is_stock_item=0, + warehouse=warehouse, company=company, ) stock_item = create_item( "_Test Stock Item for Repair", is_stock_item=1, + warehouse=warehouse, company=company, ) @@ -360,7 +364,7 @@ class TestAssetRepair(unittest.TestCase): "item_code": stock_item.name, "qty": 2, "rate": 300, - "warehouse": "Stores - TCP1", + "warehouse": warehouse, "cost_center": cost_center, }, ) From bcc542b1f941ed77220e8d0bc8ca0806d54dca92 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 9 Mar 2026 19:11:02 +0530 Subject: [PATCH 24/30] fix(test): include warehouse parameter in asset repair test case --- erpnext/assets/doctype/asset_repair/test_asset_repair.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index d39ce225952..f59dc49b1a7 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -352,6 +352,7 @@ class TestAssetRepair(unittest.TestCase): rate=500, expense_account=expense_account, cost_center=cost_center, + warehouse=warehouse, update_stock=0, do_not_submit=1, company=company, From a6dd07802ac0569577826357d42977acf32aebc5 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 9 Mar 2026 19:53:57 +0530 Subject: [PATCH 25/30] fix: enforce permission check for purchase invoice and update test to use service expense account --- erpnext/assets/doctype/asset_repair/asset_repair.py | 4 +++- erpnext/assets/doctype/asset_repair/test_asset_repair.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 4d59ba5b96c..7e17dd70e46 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -490,6 +490,8 @@ def get_repair_cost_for_purchase_invoice(purchase_invoice: str) -> float: if not purchase_invoice: return 0.0 + frappe.has_permission("Purchase Invoice", "read", purchase_invoice, throw=True) + expense_accounts = _get_expense_accounts_for_purchase_invoice(purchase_invoice) if not expense_accounts: @@ -502,7 +504,7 @@ def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[st """ Get expense accounts for non-stock items from the purchase invoice. """ - pi_items = frappe.db.get_list( + pi_items = frappe.get_all( "Purchase Invoice Item", filters={"parent": purchase_invoice}, fields=["item_code", "expense_account", "is_fixed_asset"], diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index f59dc49b1a7..3a92f0ec71a 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -343,14 +343,14 @@ class TestAssetRepair(unittest.TestCase): company=company, ) - expense_account = frappe.db.get_value("Company", company, "default_expense_account") + service_expense_account = "Miscellaneous Expenses - TCP1" cost_center = frappe.db.get_value("Company", company, "cost_center") pi = make_purchase_invoice( item_code=service_item.name, qty=1, rate=500, - expense_account=expense_account, + expense_account=service_expense_account, cost_center=cost_center, warehouse=warehouse, update_stock=0, @@ -466,6 +466,7 @@ def create_asset_repair(**args): if asset.calculate_depreciation: asset_repair.increase_in_asset_life = 12 pi = make_purchase_invoice( + item=args.item or "_Test Non Stock Item", company=asset.company, expense_account=frappe.db.get_value("Company", asset.company, "default_expense_account"), cost_center=asset_repair.cost_center, From dfbb3e97a8280636bf8d14ae88b7ffc436699a37 Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Mon, 9 Mar 2026 01:14:24 +0530 Subject: [PATCH 26/30] fix(selling): update delivery date in line items (cherry picked from commit 77367b55171f31e366f90f72255bcbc738610487) --- .../doctype/sales_order/sales_order.js | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 18b4bab4188..bbad2fe4fae 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -56,6 +56,13 @@ frappe.ui.form.on("Sales Order", { frm.set_df_property("packed_items", "cannot_add_rows", true); frm.set_df_property("packed_items", "cannot_delete_rows", true); }, + delivery_date(frm) { + if (frm.doc.delivery_date) { + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "delivery_date", frm.doc.delivery_date); + }); + } + }, refresh: function (frm) { if (frm.doc.docstatus === 1) { @@ -145,7 +152,7 @@ frappe.ui.form.on("Sales Order", { }); } } - + prevent_past_delivery_dates(frm); // Hide `Reserve Stock` field description in submitted or cancelled Sales Order. if (frm.doc.docstatus > 0) { frm.set_df_property("reserve_stock", "description", null); @@ -224,13 +231,6 @@ frappe.ui.form.on("Sales Order", { ]; }, - delivery_date: function (frm) { - $.each(frm.doc.items || [], function (i, d) { - if (!d.delivery_date) d.delivery_date = frm.doc.delivery_date; - }); - refresh_field("items"); - }, - create_stock_reservation_entries(frm) { const dialog = new frappe.ui.Dialog({ title: __("Stock Reservation"), @@ -1400,3 +1400,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex }; extend_cscript(cur_frm.cscript, new erpnext.selling.SalesOrderController({ frm: cur_frm })); + +function prevent_past_delivery_dates(frm) { + if (frm.doc.transaction_date) { + frm.fields_dict["delivery_date"].datepicker?.update({ + minDate: new Date(frm.doc.transaction_date), + }); + } +} From b7fd9aea6a5e1c37baace45874e38467d8f9f6cb Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 10 Mar 2026 13:35:22 +0530 Subject: [PATCH 27/30] fix: better validation message for Purchase Invoice with Update Stock (cherry picked from commit cfb06cf247b804dd62733dabf1a00ae1f8a2acc7) --- .../accounts/doctype/purchase_invoice/purchase_invoice.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 43c72938f64..a8e073c353c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -728,9 +728,10 @@ class PurchaseInvoice(BuyingController): for item in self.get("items"): if item.purchase_receipt: frappe.throw( - _("Stock cannot be updated against Purchase Receipt {0}").format( - item.purchase_receipt - ) + _( + "Stock cannot be updated for Purchase Invoice {0} because a Purchase Receipt {1} has already been created for this transaction. Please disable the 'Update Stock' checkbox in the Purchase Invoice and save the invoice." + ).format(self.name, item.purchase_receipt), + title=_("Stock Update Not Allowed"), ) def validate_for_repost(self): From fd8fac7d40a90a29f7ca40e0e1b88469cbe1467e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 10 Mar 2026 13:55:36 +0530 Subject: [PATCH 28/30] fix: removed non existent patch (cherry picked from commit c4b3080eae3e32cb337c59c0ae969f1dae3e7afc) --- erpnext/patches.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index afb1201087a..7d2c1757bda 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -75,7 +75,6 @@ erpnext.patches.v12_0.make_item_manufacturer erpnext.patches.v12_0.move_item_tax_to_item_tax_template erpnext.patches.v11_1.set_variant_based_on erpnext.patches.v11_1.woocommerce_set_creation_user -erpnext.patches.v11_1.rename_depends_on_lwp execute:frappe.delete_doc("Report", "Inactive Items") erpnext.patches.v11_1.delete_scheduling_tool erpnext.patches.v12_0.rename_tolerance_fields From fcfadf9deae15eb772777e46be0be879378c5813 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:12:20 +0000 Subject: [PATCH 29/30] Merge pull request #53286 from frappe/mergify/bp/version-15-hotfix/pr-53282 fix: allow user to make QI after submission not working (backport #53282) --- erpnext/controllers/stock_controller.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 5d1af9ea394..a4ee4daadb9 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1135,6 +1135,16 @@ class StockController(AccountsController): continue if qi_required: # validate row only if inspection is required on item level + if self.doctype in [ + "Purchase Receipt", + "Purchase Invoice", + "Sales Invoice", + "Delivery Note", + ] and frappe.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ): + return + self.validate_qi_presence(row) if self.docstatus == 1: self.validate_qi_submission(row) @@ -1142,16 +1152,6 @@ class StockController(AccountsController): def validate_qi_presence(self, row): """Check if QI is present on row level. Warn on save and stop on submit if missing.""" - if self.doctype in [ - "Purchase Receipt", - "Purchase Invoice", - "Sales Invoice", - "Delivery Note", - ] and frappe.db.get_single_value( - "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" - ): - return - if not row.quality_inspection: msg = _("Row #{0}: Quality Inspection is required for Item {1}").format( row.idx, frappe.bold(row.item_code) From b59dc173b8cf340b3ccc06a21d3f2561dad35c4d Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sun, 1 Mar 2026 23:54:41 +0530 Subject: [PATCH 30/30] fix(gross-profit): apply precision-based rounding to grouped totals (cherry picked from commit 52dd7665e76bc57c6f46f6fed01800286be8b21f) --- .../accounts/report/gross_profit/gross_profit.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 55ab95ac662..a53c2134e3f 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -649,7 +649,7 @@ class GrossProfitGenerator: new_row = row self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion) else: - new_row.qty += flt(row.qty) + new_row.qty = flt((new_row.qty + row.qty), self.float_precision) self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True) new_row = self.set_average_rate(new_row) @@ -659,11 +659,17 @@ class GrossProfitGenerator: if i == 0: new_row = row else: - new_row.qty += flt(row.qty) - new_row.buying_amount += flt(row.buying_amount, self.currency_precision) - new_row.base_amount += flt(row.base_amount, self.currency_precision) + new_row.qty = flt((new_row.qty + row.qty), self.float_precision) + new_row.buying_amount = flt( + (new_row.buying_amount + row.buying_amount), self.currency_precision + ) + new_row.base_amount = flt( + (new_row.base_amount + row.base_amount), self.currency_precision + ) if self.filters.get("group_by") == "Sales Person": - new_row.allocated_amount += flt(row.allocated_amount, self.currency_precision) + new_row.allocated_amount = flt( + (new_row.allocated_amount + row.allocated_amount), self.currency_precision + ) new_row = self.set_average_rate(new_row) self.grouped_data.append(new_row)