From 767e4762bb7dd7d854bcd5071e187af6efe0bcab Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 10 Dec 2025 17:46:06 +0530 Subject: [PATCH 01/12] feat: run parallel reposting --- erpnext/hooks.py | 4 +- .../repost_item_valuation.py | 121 ++++++++++++++---- .../stock_reposting_settings.json | 17 ++- .../stock_reposting_settings.py | 12 ++ 4 files changed, 126 insertions(+), 28 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 9a65062e7c4..22a8bd0ae89 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -411,7 +411,9 @@ scheduler_events = { "0/15 * * * *": [ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", ], - "0/30 * * * *": [], + "0/30 * * * *": [ + "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting", + ], # Hourly but offset by 30 minutes "30 * * * *": [ "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index a6899207281..e74901584e3 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -9,7 +9,7 @@ from frappe.desk.form.load import get_attachments from frappe.exceptions import QueryDeadlockError, QueryTimeoutError from frappe.model.document import Document from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import Max, Now +from frappe.query_builder.functions import CombineDatetime, Max, Now from frappe.utils import cint, get_link_to_form, get_weekday, getdate, now, nowtime from frappe.utils.user import get_users_with_role from rq.timeouts import JobTimeoutException @@ -539,41 +539,105 @@ def get_recipients(): return recipients +def run_parallel_reposting(): + # This function is called every 15 minutes via hooks.py + + if not frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"): + return + + if not in_configured_timeslot(): + return + + items = set() + no_of_parallel_reposting = ( + frappe.db.get_single_value("Stock Reposting Settings", "no_of_parallel_reposting") or 4 + ) + + riv_entries = get_repost_item_valuation_entries("Item and Warehouse") + + for row in riv_entries: + if row.repost_only_accounting_ledgers: + execute_reposting_entry(row.name) + continue + + if frappe.db.get_value( + "Repost Item Valuation", + { + "based_on": "Item and Warehouse", + "item_code": row.item_code, + "docstatus": 1, + "status": "In Progress", + }, + "name", + ): + continue + + if row.item_code in items: + continue + + items.add(row.item_code) + if len(items) > no_of_parallel_reposting: + break + + frappe.enqueue( + execute_reposting_entry, + name=row.name, + queue="long", + timeout=1800, + ) + + def repost_entries(): - """ - Reposts 'Repost Item Valuation' entries in queue. - Called hourly via hooks.py. - """ + # This function is called every hour via hooks.py + + if frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"): + return + if not in_configured_timeslot(): return riv_entries = get_repost_item_valuation_entries() for row in riv_entries: - doc = frappe.get_doc("Repost Item Valuation", row.name) - if ( - doc.repost_only_accounting_ledgers - and doc.reposting_reference - and frappe.db.get_value("Repost Item Valuation", doc.reposting_reference, "status") - not in ["Completed", "Skipped"] - ): - continue - - if doc.status in ("Queued", "In Progress"): - repost(doc) - doc.deduplicate_similar_repost() + execute_reposting_entry(row.name) -def get_repost_item_valuation_entries(): - return frappe.db.sql( - """ SELECT name from `tabRepost Item Valuation` - WHERE status in ('Queued', 'In Progress') and creation <= %s and docstatus = 1 - ORDER BY timestamp(posting_date, posting_time) asc, creation asc, status asc - """, - now(), - as_dict=1, +def execute_reposting_entry(name): + doc = frappe.get_doc("Repost Item Valuation", name) + if ( + doc.repost_only_accounting_ledgers + and doc.reposting_reference + and frappe.db.get_value("Repost Item Valuation", doc.reposting_reference, "status") + not in ["Completed", "Skipped"] + ): + return + + if doc.status in ("Queued", "In Progress"): + repost(doc) + doc.deduplicate_similar_repost() + + +def get_repost_item_valuation_entries(based_on=None): + doctype = frappe.qb.DocType("Repost Item Valuation") + + query = ( + frappe.qb.from_(doctype) + .select(doctype.name, doctype.based_on, doctype.item_code, doctype.repost_only_accounting_ledgers) + .where( + (doctype.status.isin(["Queued", "In Progress"])) + & (doctype.creation <= now()) + & (doctype.docstatus == 1) + ) + .orderby(CombineDatetime(doctype.posting_date, doctype.posting_time), order=frappe.qb.asc) + .orderby(doctype.creation, order=frappe.qb.asc) + .orderby(doctype.status, order=frappe.qb.asc) ) + if based_on: + query = query.where((doctype.based_on == based_on) | (doctype.repost_only_accounting_ledgers == 1)) + + return query.run(as_dict=True) + def in_configured_timeslot(repost_settings=None, current_time=None): """Check if current time is in configured timeslot for reposting.""" @@ -601,9 +665,14 @@ def in_configured_timeslot(repost_settings=None, current_time=None): @frappe.whitelist() def execute_repost_item_valuation(): """Execute repost item valuation via scheduler.""" + + method = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries" + if frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"): + method = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting" + if name := frappe.db.get_value( "Scheduled Job Type", - {"method": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"}, + {"method": method}, "name", ): frappe.get_doc("Scheduled Job Type", name).enqueue(force=True) diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index faf70b6cb0d..a06456e0c9c 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -13,6 +13,8 @@ "end_time", "limits_dont_apply_on", "item_based_reposting", + "enable_parallel_reposting", + "no_of_parallel_reposting", "errors_notification_section", "notify_reposting_error_to_role" ], @@ -65,12 +67,25 @@ "fieldname": "errors_notification_section", "fieldtype": "Section Break", "label": "Errors Notification" + }, + { + "default": "0", + "depends_on": "eval: doc.item_based_reposting", + "fieldname": "enable_parallel_reposting", + "fieldtype": "Check", + "label": "Enable Parallel Reposting" + }, + { + "default": "4", + "fieldname": "no_of_parallel_reposting", + "fieldtype": "Int", + "label": "No of Parallel Reposting (Per Item)" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-07-08 11:27:46.659056", + "modified": "2025-12-10 17:45:56.597514", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 8b47cd88df6..7924c9042c0 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -16,12 +16,14 @@ class StockRepostingSettings(Document): if TYPE_CHECKING: from frappe.types import DF + enable_parallel_reposting: DF.Check end_time: DF.Time | None item_based_reposting: DF.Check limit_reposting_timeslot: DF.Check limits_dont_apply_on: DF.Literal[ "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ] + no_of_parallel_reposting: DF.Int notify_reposting_error_to_role: DF.Link | None start_time: DF.Time | None # end: auto-generated types @@ -29,6 +31,16 @@ class StockRepostingSettings(Document): def validate(self): self.set_minimum_reposting_time_slot() + def before_save(self): + self.reset_parallel_reposting_settings() + + def reset_parallel_reposting_settings(self): + if not self.item_based_reposting and self.enable_parallel_reposting: + self.enable_parallel_reposting = 0 + + if self.enable_parallel_reposting and not self.no_of_parallel_reposting: + self.no_of_parallel_reposting = 4 + def set_minimum_reposting_time_slot(self): """Ensure that timeslot for reposting is at least 12 hours.""" if not self.limit_reposting_timeslot: From e32f898dd706e8f04bf15636355752d63bdc01b4 Mon Sep 17 00:00:00 2001 From: sudarshan-g Date: Wed, 17 Dec 2025 12:09:22 +0530 Subject: [PATCH 02/12] fix: show company currency in asset depreciation schedule --- erpnext/assets/doctype/asset/asset.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 6eda855bbfb..55c71729bd8 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -270,8 +270,14 @@ frappe.ui.form.on("Asset", { const row = [ sch["idx"], frappe.format(sch["schedule_date"], { fieldtype: "Date" }), - frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }), - frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }), + frappe.format(sch["depreciation_amount"], { + fieldtype: "Currency", + options: "Company:company:default_currency", + }), + frappe.format(sch["accumulated_depreciation_amount"], { + fieldtype: "Currency", + options: "Company:company:default_currency", + }), sch["journal_entry"] || "", ]; From 0b3e40b1556feb46a8ddf2b8356c812aa36f1e2c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 17 Dec 2025 12:34:46 +0530 Subject: [PATCH 03/12] perf!: Avoid updating sales data on every transaction (#51151) --- .../selling/doctype/selling_settings/selling_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 4ed1419ac82..12cdc0ed4d7 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -94,7 +94,7 @@ "options": "No\nYes" }, { - "default": "Each Transaction", + "default": "Daily", "description": "How often should Project and Company be updated based on Sales Transactions?", "fieldname": "sales_update_frequency", "fieldtype": "Select", @@ -296,7 +296,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-10-12 16:08:48.865885", + "modified": "2025-12-17 16:08:48.865885", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", From f3142c4af608f893fa5b8adc4c54195c5c8074a8 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 17 Dec 2025 12:39:34 +0530 Subject: [PATCH 04/12] fix: allow rename for market segment doctype --- erpnext/crm/doctype/market_segment/market_segment.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/market_segment/market_segment.json b/erpnext/crm/doctype/market_segment/market_segment.json index 0ad3d0e4b59..85200018848 100644 --- a/erpnext/crm/doctype/market_segment/market_segment.json +++ b/erpnext/crm/doctype/market_segment/market_segment.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_rename": 1, "autoname": "field:market_segment", "creation": "2018-10-01 09:59:14.479509", "doctype": "DocType", @@ -17,10 +18,11 @@ } ], "links": [], - "modified": "2024-08-16 19:24:55.811760", + "modified": "2025-12-17 12:09:34.687368", "modified_by": "Administrator", "module": "CRM", "name": "Market Segment", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -37,9 +39,10 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1, "translated_doctype": 1 -} \ No newline at end of file +} From dec474ef3a48ea1e0daa4dd6d14257bbf30b81bc Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Wed, 17 Dec 2025 13:15:00 +0530 Subject: [PATCH 05/12] fix: incorrect current qty in stock reco (#51152) --- erpnext/stock/doctype/batch/batch.py | 4 ++- .../stock_reconciliation.py | 34 +++++++------------ 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 217a5c15806..80171c6ddf3 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -166,7 +166,9 @@ class Batch(Document): for row in batches: batch_qty += row.get("qty") - self.db_set("batch_qty", batch_qty) + if self.batch_qty != batch_qty: + self.db_set("batch_qty", batch_qty) + frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True) def set_batchwise_valuation(self): diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b9355a2beb7..ccc79dece4e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1221,32 +1221,24 @@ class StockReconciliation(StockController): def get_batch_qty_for_stock_reco( item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation ): - ledger = frappe.qb.DocType("Stock Ledger Entry") posting_datetime = get_combine_datetime(posting_date, posting_time) - query = ( - frappe.qb.from_(ledger) - .select( - Sum(ledger.actual_qty).as_("batch_qty"), + qty = ( + get_batch_qty( + batch_no, + warehouse, + item_code, + creation=sle_creation, + posting_datetime=posting_datetime, + ignore_voucher_nos=[voucher_no], + for_stock_levels=True, + consider_negative_batches=True, + do_not_check_future_batches=True, ) - .where( - (ledger.item_code == item_code) - & (ledger.warehouse == warehouse) - & (ledger.docstatus == 1) - & (ledger.is_cancelled == 0) - & (ledger.batch_no == batch_no) - & (ledger.voucher_no != voucher_no) - & ( - (ledger.posting_datetime < posting_datetime) - | ((ledger.posting_datetime == posting_datetime) & (ledger.creation < sle_creation)) - ) - ) - .groupby(ledger.batch_no) + or 0 ) - sle = query.run(as_dict=True) - - return flt(sle[0].batch_qty) if sle else 0 + return flt(qty) @frappe.whitelist() From f4c0611cc55c069244a2d8f9b42ffa8297e1fa08 Mon Sep 17 00:00:00 2001 From: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:53:10 +0530 Subject: [PATCH 06/12] feat: update item button addition for quotation (#50976) * feat: update item button addition for quotation * feat: update item button addition for supplier quotation * fix: test case --------- Co-authored-by: Nishka Gosalia Co-authored-by: Mihir Kandoi --- .../supplier_quotation/supplier_quotation.js | 7 + .../supplier_quotation/supplier_quotation.py | 12 ++ .../test_supplier_quotation.py | 102 +++++++++++- erpnext/controllers/accounts_controller.py | 148 +++++++++++------- .../selling/doctype/quotation/quotation.js | 7 + .../selling/doctype/quotation/quotation.py | 1 + .../doctype/quotation/test_quotation.py | 99 +++++++++++- 7 files changed, 321 insertions(+), 55 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index 26f439ba03c..93f52d6ec42 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -32,6 +32,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e this.make_purchase_order.bind(this), __("Create") ); + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); this.frm.page.set_inner_btn_group_as_primary(__("Create")); this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create")); } else if (this.frm.doc.docstatus === 0) { diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 5923d48d736..c3ba8c40cf3 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -348,3 +348,15 @@ def set_expired_status(): """, (nowdate()), ) + + +def get_purchased_items(supplier_quotation: str): + return frappe._dict( + frappe.get_all( + "Purchase Order Item", + filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, + fields=["supplier_quotation_item", {"SUM": "qty"}], + group_by="supplier_quotation_item", + as_list=1, + ) + ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index b0e5c29fdb9..5b054491aa1 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -2,15 +2,115 @@ # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests import IntegrationTestCase, change_settings from frappe.utils import add_days, today from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate class TestPurchaseOrder(IntegrationTestCase): + def test_update_child_supplier_quotation_add_item(self): + sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) + sq.submit() + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": 5, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + 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): + sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) + sq.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 300, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) + self.assertRaises( + frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + + def test_update_supplier_quotation_child_remove_item(self): + sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) + sq.submit() + po = make_purchase_order(sq.name) + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + po.get("items")[0].schedule_date = add_days(today(), 1) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + po.submit() + sq.reload() + + trans_item = json.dumps( + [ + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + } + ] + ) + + frappe.db.savepoint("before_cancel") + # check if item having purchase order can be removed + self.assertRaises( + frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + frappe.db.rollback(save_point="before_cancel") + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + } + ] + ) + + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(len(sq.get("items")), 1) + def test_supplier_quotation_qty(self): sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) sq.items[0].qty = 0 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index cc4d6592da2..f80cf4aaf53 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3707,7 +3707,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - if child_doctype == "Purchase Order Item": + if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 @@ -3725,7 +3725,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child return child_item -def validate_child_on_delete(row, parent): +def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": if flt(row.delivered_qty): @@ -3753,13 +3753,17 @@ def validate_child_on_delete(row, parent): row.idx, row.item_code ) ) - - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code + if parent.doctype in ["Purchase Order", "Sales Order"]: + if flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) ) - ) + + if parent.doctype == "Quotation": + if ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) def update_bin_on_delete(row, doctype): @@ -3785,7 +3789,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data) -> bool: +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -3793,7 +3797,7 @@ def validate_and_delete_children(parent, data) -> bool: deleted_children.append(item) for d in deleted_children: - validate_child_on_delete(d, parent) + validate_child_on_delete(d, parent, ordered_item) d.cancel() d.delete() @@ -3802,16 +3806,19 @@ def validate_and_delete_children(parent, data) -> bool: # need to update ordered qty in Material Request first # bin uses Material Request Items to recalculate & update - parent.update_prevdoc_status() - - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) + if parent.doctype not in ["Quotation", "Supplier Quotation"]: + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) return bool(deleted_children) @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.quotation import get_ordered_items + def check_doc_permissions(doc, perm_type="create"): try: doc.check_permission(perm_type) @@ -3850,7 +3857,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + child_doctype = parent_doctype + " Item" return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def is_allowed_zero_qty(): @@ -3875,6 +3882,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil 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 ["Quotation", "Supplier Quotation"]: + if (parent_doctype == "Quotation" and not ordered_items) or ( + parent_doctype == "Supplier Quotation" and not purchased_items + ): + return + + qty_to_check = ( + ordered_items.get(child_item.name) + if parent_doctype == "Quotation" + else purchased_items.get(child_item.name) + ) + if qty_to_check: + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + def should_update_supplied_items(doc) -> bool: """Subcontracted PO can allow following changes *after submit*: @@ -3917,7 +3939,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) data = json.loads(trans_items) - any_qty_changed = False # updated to true if any item's qty changes items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False @@ -3925,7 +3946,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - _removed_items = validate_and_delete_children(parent, data) + + if parent_doctype == "Quotation": + ordered_items = get_ordered_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, ordered_items) + elif parent_doctype == "Supplier Quotation": + purchased_items = get_purchased_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, purchased_items) + else: + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: @@ -3965,7 +3995,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( - prev_date == getdate(new_date) if prev_date and new_date else False + (prev_date == getdate(new_date) if prev_date and new_date else False) + if parent_doctype not in ["Quotation", "Supplier Quotation"] + else None ) # in case of delivery note etc if ( rate_unchanged @@ -3978,6 +4010,10 @@ 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")) + if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True @@ -4001,18 +4037,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - # Amount cannot be lesser than billed amount, except for negative amounts row_rate = flt(d.get("rate"), rate_precision) - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(d.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) + + if parent_doctype in ["Purchase Order", "Sales Order"]: + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + else: + child_item.rate = row_rate else: child_item.rate = row_rate @@ -4040,26 +4079,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if d.get("bom_no") and parent_doctype == "Sales Order": child_item.bom_no = d.get("bom_no") - if flt(child_item.price_list_rate): - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin - # or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + if parent_doctype in ["Sales Order", "Purchase Order"]: + if flt(child_item.price_list_rate): + if flt(child_item.rate) > flt(child_item.price_list_rate): + # if rate is greater than price_list_rate, set margin + # or set discount + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -4081,13 +4121,14 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.doctype, parent.company, parent.base_grand_total ) - parent.set_payment_schedule() + if parent_doctype != "Supplier Quotation": + parent.set_payment_schedule() if parent_doctype == "Purchase Order": parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): parent.update_status_updater() - else: + elif parent_doctype == "Sales Order": parent.check_credit_limit() # reset index of child table @@ -4120,7 +4161,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." ).format(frappe.bold(parent.name)) ) - else: # Sales Order + elif parent_doctype == "Sales Order": # Sales Order if parent.is_subcontracted and not parent.can_update_items(): frappe.throw( _( @@ -4138,9 +4179,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.reload() validate_workflow_conditions(parent) - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() + if parent_doctype in ["Purchase Order", "Sales Order"]: + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("stock_uom", "stock_qty") diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index f0061c016bd..480ca04b6a9 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } if (doc.status !== "Ordered" && this.frm.has_perm("write")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index db037ff46fb..05a8fb08e50 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -613,6 +613,7 @@ def handle_mandatory_error(e, customer, lead_name): frappe.throw(message, title=_("Mandatory Missing")) +@frappe.whitelist() def get_ordered_items(quotation: str): """ Returns a dict of ordered items with their total qty based on quotation row name. diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index f979b031cdd..069843518d7 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1,17 +1,114 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests import IntegrationTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate +from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.setup.utils import get_exchange_rate EXTRA_TEST_RECORD_DEPENDENCIES = ["Product Bundle"] class TestQuotation(IntegrationTestCase): + def test_update_child_quotation_add_item(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": "", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + first_item = qo.get("items")[0] + second_item = qo.get("items")[1] + trans_item = json.dumps( + [ + { + "item_code": first_item.item_code, + "rate": first_item.rate, + "qty": 11, + "docname": first_item.name, + }, + { + "item_code": second_item.item_code, + "rate": second_item.rate, + "qty": second_item.qty, + "docname": second_item.name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + 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) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": 5000, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + def test_update_child_removing_item(self): + qo = make_quotation(qty=10) + sales_order = make_sales_order(qo.name) + sales_order.delivery_date = nowdate() + + 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": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + sales_order.submit() + qo.reload() + self.assertEqual(qo.status, "Partially Ordered") + + trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}]) + + # check if items having a sales order can be removed + 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, + } + ] + ) + + # remove item with no sales order + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(len(qo.get("items")), 1) + def test_quotation_qty(self): qo = make_quotation(qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): From 5f80857bc939fc570ec3e51ab2e8dd4a88f0a30d Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:06:54 +0530 Subject: [PATCH 07/12] fix: show correct PR amount in email template (#51033) * fix: show correct PR amount in email template * fix: add translation string --- .../payment_request/payment_request.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index da72518c855..1de9ff936e5 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -427,6 +427,7 @@ class PaymentRequest(Document): context = { "doc": frappe.get_doc(self.reference_doctype, self.reference_name), "payment_url": self.payment_url, + "payment_request": self, } if self.message: @@ -892,22 +893,25 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False): def get_dummy_message(doc): - return frappe.render_template( - """{% if doc.contact_person -%} -

Dear {{ doc.contact_person }},

-{%- else %}

Hello,

{% endif %} + return """ + {% if doc.contact_person -%} +

Dear {{ doc.contact_person }},

+ {%- else %}

Hello,

{% endif %} -

{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype, - doc.name, doc.get_formatted("grand_total")) }}

+

+ {{ _("Requesting payment against {0} {1} for amount {2}").format( + doc.doctype, + doc.name, + payment_request.get_formatted("grand_total") + ) }} +

-{{ _("Make Payment") }} + {{ _("Make Payment") }} -

{{ _("If you have any questions, please get back to us.") }}

+

{{ _("If you have any questions, please get back to us.") }}

-

{{ _("Thank you for your business!") }}

-""", - dict(doc=doc, payment_url="{{ payment_url }}"), - ) +

{{ _("Thank you for your business!") }}

+ """ @frappe.whitelist() From 1562e9b8282311364f04d1676ae13407f77ce6bf Mon Sep 17 00:00:00 2001 From: Nishka Gosalia Date: Wed, 17 Dec 2025 14:44:24 +0530 Subject: [PATCH 08/12] fix: Disallow due date to be before the posting date in Quotation --- erpnext/controllers/accounts_controller.py | 4 +++- erpnext/selling/doctype/quotation/test_quotation.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f80cf4aaf53..f134f588ed8 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2707,7 +2707,9 @@ class AccountsController(TransactionBase): for d in self.get("payment_schedule"): d.validate_from_to_dates("discount_date", "due_date") - if self.doctype == "Sales Order" and getdate(d.due_date) < getdate(self.transaction_date): + if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( + self.transaction_date + ): frappe.throw( _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( d.idx diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 069843518d7..d0813762ac4 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -52,6 +52,11 @@ class TestQuotation(IntegrationTestCase): self.assertEqual(qo.get("items")[0].qty, 11) self.assertEqual(qo.get("items")[-1].rate, 100) + def test_disallow_due_date_before_transaction_date(self): + qo = make_quotation(qty=3, do_not_submit=1) + qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2) + self.assertRaises(frappe.ValidationError, qo.save) + def test_update_child_disallow_rate_change(self): qo = make_quotation(qty=4) trans_item = json.dumps( From 848f8d6b1ff7624e917b7914ffaa53630c2892e2 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 17 Dec 2025 15:12:45 +0530 Subject: [PATCH 09/12] fix(payment entry): set row id for 'On Previous Row Amount' or 'On Previous Row Total' charge type on tax table --- erpnext/accounts/doctype/payment_entry/payment_entry.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index a09edc69469..dbcd17df4fb 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1494,18 +1494,14 @@ frappe.ui.form.on("Payment Entry", { "Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'" ); d.row_id = ""; - } else if ( - (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") && - d.row_id - ) { + } else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") { if (d.idx == 1) { msg = __( "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" ); d.charge_type = ""; } else if (!d.row_id) { - msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); - d.row_id = ""; + d.row_id = d.idx - 1; } else if (d.row_id && d.row_id >= d.idx) { msg = __( "Cannot refer row number greater than or equal to current row number for this Charge type" From fe66bd4dc24baa2bbcf16bb8c2a2e32aad84e507 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 18 Dec 2025 14:43:05 +0530 Subject: [PATCH 10/12] test: fix tests failing due to dependent state (#51187) * test: Use fixture instead of hardcoded employee * test: create user before assigning --- erpnext/manufacturing/doctype/job_card/test_job_card.py | 5 ++--- erpnext/support/doctype/issue/test_issue.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 425367c519d..e6eaea39578 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -32,10 +32,9 @@ class TestJobCard(ERPNextTestSuite): @classmethod def setUpClass(cls): super().setUpClass() - # used in job card time log - cls.make_employees() def setUp(self): + self.make_employees() # used in job card time log self.make_bom_for_jc_tests() self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order" self.source_warehouse = None @@ -128,7 +127,7 @@ class TestJobCard(ERPNextTestSuite): jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name}) - employee = "_T-Employee-00001" # from test records + employee = self.employees[0].name jc1.append( "time_logs", diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 1f8ec6afa3d..92979839d1b 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -219,6 +219,7 @@ class TestIssue(TestSetUp): frappe.flags.current_time = get_datetime("2021-11-01 19:00") issue = make_issue(frappe.flags.current_time, index=1) + create_user("test@admin.com") create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) add_assignment({"doctype": issue.doctype, "name": issue.name, "assign_to": ["test@admin.com"]}) issue.reload() From 17320d106275bbbc6dbe5b5de7eeacfc64826ce4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Dec 2025 14:41:09 +0530 Subject: [PATCH 11/12] refactor: pick list from material request --- erpnext/controllers/status_updater.py | 36 +++++++++++-------- .../material_request/material_request.js | 12 ++++--- .../material_request/material_request.py | 16 ++++----- .../material_request_item.json | 12 ++++++- .../material_request_item.py | 1 + erpnext/stock/doctype/pick_list/pick_list.py | 18 +++++++++- 6 files changed, 65 insertions(+), 30 deletions(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 00c424200b8..b12741487cd 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -393,12 +393,16 @@ class StatusUpdater(Document): self.item_allowance, self.global_qty_allowance, self.global_amount_allowance, - ) = get_allowance_for( - item["item_code"], - self.item_allowance, - self.global_qty_allowance, - self.global_amount_allowance, - qty_or_amount, + ) = ( + get_allowance_for( + item["item_code"], + self.item_allowance, + self.global_qty_allowance, + self.global_amount_allowance, + qty_or_amount, + ) + if args["source_dt"] != "Pick List Item" + else (0, {}, None, None) ) role_allowed_to_over_deliver_receive = frappe.get_single_value( @@ -436,14 +440,17 @@ class StatusUpdater(Document): ): return - if qty_or_amount == "qty": - action_msg = _( - 'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.' - ) + if args["source_dt"] != "Pick List Item": + if qty_or_amount == "qty": + action_msg = _( + 'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.' + ) + else: + action_msg = _( + 'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.' + ) else: - action_msg = _( - 'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.' - ) + action_msg = None frappe.throw( _( @@ -455,8 +462,7 @@ class StatusUpdater(Document): frappe.bold(_(self.doctype)), frappe.bold(item.get("item_code")), ) - + "

" - + action_msg, + + ("

" + action_msg if action_msg else ""), OverAllowanceError, title=_("Limit Crossed"), ) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 1a269648ce3..1975343c90b 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -139,11 +139,13 @@ frappe.ui.form.on("Material Request", { if (flt(frm.doc.per_ordered, precision) < 100) { let add_create_pick_list_button = () => { - frm.add_custom_button( - __("Pick List"), - () => frm.events.create_pick_list(frm), - __("Create") - ); + if (frm.doc.items.some((item) => item.stock_qty - item.picked_qty > 0)) { + frm.add_custom_button( + __("Pick List"), + () => frm.events.create_pick_list(frm), + __("Create") + ); + } }; if (frm.doc.material_request_type === "Material Transfer") { diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 3eac6f94f04..b98870788a5 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -911,11 +911,7 @@ def raise_work_orders(material_request, company): @frappe.whitelist() def create_pick_list(source_name, target_doc=None): def update_item(obj, target, source_parent): - qty = ( - flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor - if flt(obj.stock_qty) > flt(obj.ordered_qty) - else 0 - ) + qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty")) target.qty = qty target.stock_qty = qty * obj.conversion_factor target.conversion_factor = obj.conversion_factor @@ -931,11 +927,15 @@ def create_pick_list(source_name, target_doc=None): }, "Material Request Item": { "doctype": "Pick List Item", - "field_map": {"name": "material_request_item", "stock_qty": "stock_qty"}, + "field_map": { + "name": "material_request_item", + "stock_qty": "stock_qty", + "from_warehouse": "warehouse", + }, "postprocess": update_item, "condition": lambda doc: ( - flt(doc.ordered_qty, doc.precision("ordered_qty")) - < flt(doc.stock_qty, doc.precision("ordered_qty")) + flt(doc.picked_qty, doc.precision("picked_qty")) + < flt(doc.stock_qty, doc.precision("stock_qty")) ), }, }, diff --git a/erpnext/stock/doctype/material_request_item/material_request_item.json b/erpnext/stock/doctype/material_request_item/material_request_item.json index 51eca0d3892..d8db1d9ce01 100644 --- a/erpnext/stock/doctype/material_request_item/material_request_item.json +++ b/erpnext/stock/doctype/material_request_item/material_request_item.json @@ -29,6 +29,7 @@ "qty_info_sec_break", "min_order_qty", "projected_qty", + "picked_qty", "qty_info_col_break", "actual_qty", "ordered_qty", @@ -518,13 +519,22 @@ "fieldtype": "Float", "label": "Projected On Hand", "read_only": 1 + }, + { + "fieldname": "picked_qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Picked Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-12-02 14:14:45.972664", + "modified": "2025-12-17 13:47:27.317226", "modified_by": "Administrator", "module": "Stock", "name": "Material Request Item", diff --git a/erpnext/stock/doctype/material_request_item/material_request_item.py b/erpnext/stock/doctype/material_request_item/material_request_item.py index e36d4e66549..d801cf5a246 100644 --- a/erpnext/stock/doctype/material_request_item/material_request_item.py +++ b/erpnext/stock/doctype/material_request_item/material_request_item.py @@ -41,6 +41,7 @@ class MaterialRequestItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + picked_qty: DF.Float price_list_rate: DF.Currency production_plan: DF.Link | None project: DF.Link | None diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 1bf954323b2..83149143791 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -19,7 +19,6 @@ from erpnext.selling.doctype.sales_order.sales_order import ( ) from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, - get_picked_serial_nos, ) from erpnext.stock.get_item_details import get_company_total_stock, get_conversion_factor from erpnext.stock.serial_batch_bundle import ( @@ -66,6 +65,21 @@ class PickList(TransactionBase): work_order: DF.Link | None # end: auto-generated types + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.status_updater = [ + { + "source_dt": "Pick List Item", + "target_dt": "Material Request Item", + "target_field": "picked_qty", + "target_parent_dt": "Material Request", + "target_parent_field": "", + "join_field": "material_request_item", + "target_ref_field": "stock_qty", + "source_field": "stock_qty", + } + ] + def onload(self) -> None: if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): if self.has_unreserved_stock(): @@ -228,6 +242,7 @@ class PickList(TransactionBase): self.update_bundle_picked_qty() self.update_reference_qty() self.update_sales_order_picking_status() + self.update_prevdoc_status() def validate_expired_batches(self): batches = [] @@ -305,6 +320,7 @@ class PickList(TransactionBase): self.update_reference_qty() self.update_sales_order_picking_status() self.delink_serial_and_batch_bundle() + self.update_prevdoc_status() def delink_serial_and_batch_bundle(self): for row in self.locations: From 333169e52b4604d45b908a11d4d7d1731c2997cb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Dec 2025 20:16:13 +0530 Subject: [PATCH 12/12] test: patch: add test case and patch --- erpnext/patches.txt | 3 ++- erpnext/patches/v16_0/set_mr_picked_qty.py | 12 +++++++++++ .../material_request/test_material_request.py | 20 ++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 erpnext/patches/v16_0/set_mr_picked_qty.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ab96c014601..8080b3a7e9a 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -451,4 +451,5 @@ erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table erpnext.patches.v16_0.migrate_budget_records_to_new_structure erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11 erpnext.patches.v16_0.migrate_account_freezing_settings_to_company -erpnext.patches.v16_0.populate_budget_distribution_total \ No newline at end of file +erpnext.patches.v16_0.populate_budget_distribution_total +erpnext.patches.v16_0.set_mr_picked_qty \ No newline at end of file diff --git a/erpnext/patches/v16_0/set_mr_picked_qty.py b/erpnext/patches/v16_0/set_mr_picked_qty.py new file mode 100644 index 00000000000..3e4f19bee79 --- /dev/null +++ b/erpnext/patches/v16_0/set_mr_picked_qty.py @@ -0,0 +1,12 @@ +import frappe + + +def execute(): + if data := frappe.get_all( + "Pick List Item", + filters={"material_request_item": ["is", "set"], "docstatus": 1}, + fields=["material_request_item", {"SUM": "picked_qty", "as": "picked_qty"}], + group_by="material_request_item", + ): + data = {d.material_request_item: {"picked_qty": d.picked_qty} for d in data} + frappe.db.bulk_update("Material Request Item", data) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 578e1622cfb..34ff6c46208 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -6,7 +6,6 @@ import frappe -import frappe.model from frappe.tests import IntegrationTestCase from frappe.utils import flt, today @@ -1004,6 +1003,25 @@ class TestMaterialRequest(IntegrationTestCase): pl_for_pending = create_pick_list(mr.name) self.assertEqual(pl_for_pending.locations[0].qty, 5) + def test_mr_pick_list_qty_validation(self): + """Test for checking pick list qty validation from Material Request""" + + mr = make_material_request(material_request_type="Material Transfer") + pl = create_pick_list(mr.name) + pl.locations[0].qty = 9 + pl.locations[0].stock_qty = 9 + pl.submit() + + mr.reload() + self.assertEqual(mr.items[0].picked_qty, 9) + + pl = create_pick_list(mr.name) + self.assertEqual(pl.locations[0].qty, 1) + + pl.locations[0].qty = 2 + pl.locations[0].stock_qty = 2 + self.assertRaises(frappe.ValidationError, pl.submit) + def test_mr_status_with_partial_and_excess_end_transit(self): material_request = make_material_request( material_request_type="Material Transfer",