From ee9debe581432dbaec3c83e780a6e61c0360e89a Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sun, 28 Dec 2025 10:36:43 +0530 Subject: [PATCH] perf: SABB taking time to save the record (cherry picked from commit 20320c4a6c161d9d2c63fa71199dad4d074a1515) # Conflicts: # erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py # erpnext/stock/serial_batch_bundle.py --- .../doctype/pos_invoice/test_pos_invoice.py | 3 + .../tests/test_subcontracting_controller.py | 3 +- .../serial_and_batch_bundle.py | 45 ++++++- .../test_serial_and_batch_bundle.py | 1 + erpnext/stock/serial_batch_bundle.py | 110 +++++++++++++++--- 5 files changed, 142 insertions(+), 20 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index b0c16ac27d1..97b3e87770f 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -481,6 +481,7 @@ class TestPOSInvoice(unittest.TestCase): rate=1000, serial_no=[serial_nos[0]], do_not_save=1, + ignore_sabb_validation=True, ) pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000}) @@ -956,6 +957,7 @@ class TestPOSInvoice(unittest.TestCase): qty=1, rate=100, do_not_submit=True, + ignore_sabb_validation=True, ) self.assertRaises(frappe.ValidationError, pos_inv.submit) @@ -1097,6 +1099,7 @@ def create_pos_invoice(**args): "posting_time": pos_inv.posting_time, "type_of_transaction": type_of_transaction, "do_not_submit": True, + "ignore_sabb_validation": args.ignore_sabb_validation, } ) ).name diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index dc80a23198a..106b1cd0c42 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -778,9 +778,8 @@ class TestSubcontractingController(FrappeTestCase): row.serial_no = "ABC" break - bundle.save() + self.assertRaises(frappe.ValidationError, bundle.save) - self.assertRaises(frappe.ValidationError, scr1.save) bundle.load_from_db() for row in bundle.entries: if row.idx == 1: diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index ffd0a0a137d..fb8430b7437 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -116,10 +116,24 @@ class SerialandBatchBundle(Document): return self.allow_existing_serial_nos() +<<<<<<< HEAD if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test: self.validate_serial_nos_duplicate() +======= + if self.docstatus == 1: + if not self.flags.ignore_validate_serial_batch or frappe.in_test: + self.validate_serial_nos_duplicate() + + self.check_future_entries_exists() + elif ( + self.has_serial_no + and self.type_of_transaction == "Outward" + and self.voucher_type != "Stock Reconciliation" + and self.voucher_no + ): + self.validate_serial_no_status() +>>>>>>> 20320c4a6c (perf: SABB taking time to save the record) - self.check_future_entries_exists() self.set_is_outward() self.calculate_total_qty() self.set_warehouse() @@ -129,6 +143,25 @@ class SerialandBatchBundle(Document): self.calculate_qty_and_amount() + def validate_serial_no_status(self): + serial_nos = [d.serial_no for d in self.entries if d.serial_no] + invalid_serial_nos = frappe.get_all( + "Serial No", + filters={ + "name": ("in", serial_nos), + "warehouse": ("!=", self.warehouse), + }, + pluck="name", + ) + + if invalid_serial_nos: + msg = _( + "You cannot outward following {0} as either they are Delivered, Inactive or located in a different warehouse." + ).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No")) + msg += "
" + msg += ", ".join(sn for sn in invalid_serial_nos) + frappe.throw(msg) + def validate_voucher_detail_no(self): if self.type_of_transaction not in ["Inward", "Outward"] or self.voucher_type in [ "Installation Note", @@ -702,10 +735,16 @@ class SerialandBatchBundle(Document): "Buying Settings", "set_valuation_rate_for_rejected_materials" ) + precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate") for d in self.entries: if self.is_rejected and not set_valuation_rate_for_rejected_materials: rate = 0.0 - elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference: + elif ( + (flt(d.incoming_rate, precision) == flt(rate, precision)) + and not stock_queue + and d.qty + and d.stock_value_difference + ): continue if is_packed_item and d.incoming_rate: @@ -766,7 +805,7 @@ class SerialandBatchBundle(Document): self.calculate_total_qty(save=True) # If user has changed the rate in the child table - if self.docstatus == 0: + if self.docstatus == 0 and self.type_of_transaction == "Inward": self.set_incoming_rate(parent=parent, row=row, save=True) if self.docstatus == 0 and parent.get("is_return") and parent.is_new(): diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index eec91b2c282..64563625297 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -982,6 +982,7 @@ def make_serial_batch_bundle(kwargs): "type_of_transaction": type_of_transaction, "company": kwargs.company or "_Test Company", "do_not_submit": kwargs.do_not_submit, + "ignore_sabb_validation": kwargs.ignore_sabb_validation or False, } ) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 44d54141e09..df29ec03b73 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -4,8 +4,13 @@ import frappe from frappe import _, bold from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series from frappe.query_builder import Case +<<<<<<< HEAD from frappe.query_builder.functions import CombineDatetime, Sum, Timestamp from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, now, nowtime, today +======= +from frappe.query_builder.functions import Max, Sum +from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today +>>>>>>> 20320c4a6c (perf: SABB taking time to save the record) from pypika import Order from pypika.terms import ExistsCriterion @@ -616,8 +621,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation): self.old_serial_nos = [] serial_nos = self.get_serial_nos() + result = self.get_serial_no_wise_incoming_rate(serial_nos) for serial_no in serial_nos: - incoming_rate = self.get_incoming_rate_from_bundle(serial_no) + incoming_rate = result.get(serial_no) if incoming_rate is None: self.old_serial_nos.append(serial_no) continue @@ -627,32 +633,100 @@ class SerialNoValuation(DeprecatedSerialNoValuation): self.calculate_stock_value_from_deprecarated_ledgers() - def get_incoming_rate_from_bundle(self, serial_no) -> float: + def get_serial_no_wise_incoming_rate(self, serial_nos): bundle = frappe.qb.DocType("Serial and Batch Bundle") bundle_child = frappe.qb.DocType("Serial and Batch Entry") + def get_latest_based_on_posting_datetime(): + # Get latest inward record based on posting datetime for each serial no + + latest_posting = ( + frappe.qb.from_(bundle) + .inner_join(bundle_child) + .on(bundle.name == bundle_child.parent) + .select( + bundle_child.serial_no, + Max(bundle.posting_datetime).as_("max_posting_dt"), + ) + .where( + (bundle.is_cancelled == 0) + & (bundle.docstatus == 1) + & (bundle.type_of_transaction == "Inward") + & (bundle_child.qty > 0) + & (bundle.item_code == self.sle.item_code) + & (bundle_child.warehouse == self.sle.warehouse) + & (bundle_child.serial_no.isin(serial_nos)) + ) + .groupby(bundle_child.serial_no) + ) + + # Important to exclude the current voucher to calculate correct the stock value difference + if self.sle.voucher_no: + latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no) + + if self.sle.posting_datetime: + timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime + + latest_posting = latest_posting.where(timestamp_condition) + + latest_posting = latest_posting.as_("latest_posting") + + return latest_posting + + def get_latest_based_on_creation(latest_posting): + # Get latest inward record based on creation for each serial no + latest_creation = ( + frappe.qb.from_(bundle) + .join(bundle_child) + .on(bundle.name == bundle_child.parent) + .join(latest_posting) + .on( + (latest_posting.serial_no == bundle_child.serial_no) + & (latest_posting.max_posting_dt == bundle.posting_datetime) + ) + .select( + bundle_child.serial_no, + Max(bundle.creation).as_("max_creation"), + ) + .where( + (bundle.is_cancelled == 0) + & (bundle.docstatus == 1) + & (bundle.type_of_transaction == "Inward") + & (bundle_child.qty > 0) + & (bundle.item_code == self.sle.item_code) + & (bundle_child.warehouse == self.sle.warehouse) + ) + .groupby(bundle_child.serial_no) + ).as_("latest_creation") + + return latest_creation + + latest_posting = get_latest_based_on_posting_datetime() + latest_creation = get_latest_based_on_creation(latest_posting) + query = ( frappe.qb.from_(bundle) - .inner_join(bundle_child) + .join(bundle_child) .on(bundle.name == bundle_child.parent) - .select((bundle_child.incoming_rate * bundle_child.qty).as_("incoming_rate")) - .where( - (bundle.is_cancelled == 0) - & (bundle.docstatus == 1) - & (bundle_child.serial_no == serial_no) - & (bundle.type_of_transaction == "Inward") - & (bundle_child.qty > 0) - & (bundle.item_code == self.sle.item_code) - & (bundle_child.warehouse == self.sle.warehouse) + .join(latest_creation) + .on( + (latest_creation.serial_no == bundle_child.serial_no) + & (latest_creation.max_creation == bundle.creation) ) + .select( + bundle_child.serial_no, + (bundle_child.incoming_rate * bundle_child.qty).as_("incoming_rate"), + ) +<<<<<<< HEAD .orderby(Timestamp(bundle.posting_date, bundle.posting_time), order=Order.desc) .limit(1) +======= +>>>>>>> 20320c4a6c (perf: SABB taking time to save the record) ) - # Important to exclude the current voucher to calculate correct the stock value difference - if self.sle.voucher_no: - query = query.where(bundle.voucher_no != self.sle.voucher_no) + result = query.run(as_list=1) +<<<<<<< HEAD if self.sle.posting_date: if self.sle.posting_time is None: self.sle.posting_time = nowtime() @@ -665,6 +739,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation): incoming_rate = query.run() return flt(incoming_rate[0][0]) if incoming_rate else None +======= + return frappe._dict(result) if result else frappe._dict({}) +>>>>>>> 20320c4a6c (perf: SABB taking time to save the record) def get_serial_nos(self): if self.sle.get("serial_nos"): @@ -1131,6 +1208,9 @@ class SerialBatchCreation: doc.submit() else: + if self.get("ignore_sabb_validation"): + doc.flags.ignore_validate = True + doc.save() self.validate_qty(doc)