diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 7dc4c194bec63..81080f026656f 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -8,6 +8,8 @@ from frappe.utils import flt, format_datetime, get_datetime import erpnext +from erpnext.stock.serial_batch_bundle import get_batches_from_bundle +from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle from erpnext.stock.utils import get_incoming_rate @@ -375,74 +377,36 @@ def set_missing_values(source, target): else: doc.run_method("calculate_taxes_and_totals") - def update_serial_batch_no(source_doc, target_doc, source_parent): + def update_serial_batch_no(source_doc, target_doc, source_parent, item_details, qty_field): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.serial_batch_bundle import SerialBatchCreation - item_details = frappe.get_cached_value( - "Item", source_doc.item_code, ["has_batch_no", "has_serial_no"], as_dict=1 + returned_serial_nos = [] + returned_batches = frappe._dict() + serial_and_batch_field = ( + "serial_and_batch_bundle" if qty_field == "stock_qty" else "rejected_serial_and_batch_bundle" ) + old_serial_no_field = "serial_no" if qty_field == "stock_qty" else "rejected_serial_no" + old_batch_no_field = "batch_no" - returned_serial_nos = [] if ( - source_doc.get("serial_and_batch_bundle") - or source_doc.get("serial_no") - or source_doc.get("batch_no") + source_doc.get(serial_and_batch_field) + or source_doc.get(old_serial_no_field) + or source_doc.get(old_batch_no_field) ): if item_details.has_serial_no: - returned_serial_nos = get_returned_serial_nos(source_doc, source_parent) - - type_of_transaction = "Inward" - if source_doc.serial_and_batch_bundle and ( - frappe.db.get_value( - "Serial and Batch Bundle", source_doc.serial_and_batch_bundle, "type_of_transaction" + returned_serial_nos = get_returned_serial_nos( + source_doc, source_parent, serial_no_field=serial_and_batch_field ) - == "Inward" - ): - type_of_transaction = "Outward" - elif source_parent.doctype in [ - "Purchase Invoice", - "Purchase Receipt", - "Subcontracting Receipt", - ]: - type_of_transaction = "Outward" - - cls_obj = SerialBatchCreation( - { - "type_of_transaction": type_of_transaction, - "serial_and_batch_bundle": source_doc.serial_and_batch_bundle, - "returned_against": source_doc.name, - "item_code": source_doc.item_code, - "returned_serial_nos": returned_serial_nos, - "voucher_type": source_parent.doctype, - "do_not_submit": True, - "warehouse": source_doc.warehouse, - } - ) - - if source_doc.get("serial_and_batch_bundle"): - cls_obj.duplicate_package() - if cls_obj.serial_and_batch_bundle: - target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle else: - if source_doc.get("batch_no"): - cls_obj.batches = frappe._dict({source_doc.batch_no: source_doc.qty}) - elif source_doc.get("serial_no"): - serial_nos = get_serial_nos(source_doc.get("serial_no")) - cls_obj.serial_nos = list(set(serial_nos) - set(returned_serial_nos)) - - target_doc.serial_and_batch_bundle = cls_obj.make_serial_and_batch_bundle().name - - if source_doc.get("rejected_serial_and_batch_bundle") or source_doc.get("rejected_serial_no"): - if item_details.has_serial_no: - returned_serial_nos = get_returned_serial_nos( - source_doc, source_parent, serial_no_field="rejected_serial_and_batch_bundle" + returned_batches = get_returned_batches( + source_doc, source_parent, batch_no_field=serial_and_batch_field ) type_of_transaction = "Inward" - if source_doc.rejected_serial_and_batch_bundle and ( + if source_doc.get(serial_and_batch_field) and ( frappe.db.get_value( - "Serial and Batch Bundle", source_doc.rejected_serial_and_batch_bundle, "type_of_transaction" + "Serial and Batch Bundle", source_doc.get(serial_and_batch_field), "type_of_transaction" ) == "Inward" ): @@ -457,36 +421,48 @@ def update_serial_batch_no(source_doc, target_doc, source_parent): cls_obj = SerialBatchCreation( { "type_of_transaction": type_of_transaction, - "serial_and_batch_bundle": source_doc.rejected_serial_and_batch_bundle, + "serial_and_batch_bundle": source_doc.get(serial_and_batch_field), "returned_against": source_doc.name, "item_code": source_doc.item_code, "returned_serial_nos": returned_serial_nos, "voucher_type": source_parent.doctype, "do_not_submit": True, - "warehouse": source_doc.rejected_warehouse, - "is_rejected": True, + "warehouse": source_doc.warehouse, + "has_serial_no": item_details.has_serial_no, + "has_batch_no": item_details.has_batch_no, } ) - if source_doc.get("rejected_serial_and_batch_bundle"): + serial_nos = [] + batches = frappe._dict() + if source_doc.get(old_batch_no_field): + batches = frappe._dict({source_doc.batch_no: source_doc.get(qty_field)}) + elif source_doc.get(old_serial_no_field): + serial_nos = get_serial_nos(source_doc.get(old_serial_no_field)) + elif source_doc.get(serial_and_batch_field): + if item_details.has_serial_no: + serial_nos = get_serial_nos_from_bundle(source_doc.get(serial_and_batch_field)) + else: + batches = get_batches_from_bundle(source_doc.get(serial_and_batch_field)) + + if serial_nos: + cls_obj.serial_nos = sorted(list(set(serial_nos) - set(returned_serial_nos))) + elif batches: + for batch in batches: + if batch in returned_batches: + batches[batch] -= flt(returned_batches.get(batch)) + + cls_obj.batches = batches + + if source_doc.get(serial_and_batch_field): cls_obj.duplicate_package() + if cls_obj.serial_and_batch_bundle: + target_doc.set(serial_and_batch_field, cls_obj.serial_and_batch_bundle) else: - if source_doc.get("batch_no"): - cls_obj.batches = frappe._dict({source_doc.batch_no: source_doc.rejected_qty}) - elif source_doc.get("rejected_serial_no"): - serial_nos = get_serial_nos(source_doc.get("rejected_serial_no")) - cls_obj.serial_nos = list(set(serial_nos) - set(returned_serial_nos)) - - target_doc.rejected_serial_and_batch_bundle = cls_obj.make_serial_and_batch_bundle().name - - if cls_obj.serial_and_batch_bundle: - target_doc.rejected_serial_and_batch_bundle = cls_obj.serial_and_batch_bundle + target_doc.set(serial_and_batch_field, cls_obj.make_serial_and_batch_bundle().name) def update_item(source_doc, target_doc, source_parent): target_doc.qty = -1 * source_doc.qty - - update_serial_batch_no(source_doc, target_doc, source_parent) - if doctype in ["Purchase Receipt", "Subcontracting Receipt"]: returned_qty_map = get_returned_qty_map_for_row( source_parent.name, source_parent.supplier, source_doc.name, doctype @@ -586,6 +562,17 @@ def update_item(source_doc, target_doc, source_parent): if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return + item_details = frappe.get_cached_value( + "Item", source_doc.item_code, ["has_batch_no", "has_serial_no"], as_dict=1 + ) + + if not item_details.has_batch_no and not item_details.has_serial_no: + return + + for qty_field in ["stock_qty", "rejected_qty"]: + if target_doc.get(qty_field): + update_serial_batch_no(source_doc, target_doc, source_parent, item_details, qty_field) + def update_terms(source_doc, target_doc, source_parent): target_doc.payment_amount = -source_doc.payment_amount @@ -741,6 +728,9 @@ def get_returned_serial_nos( [parent_doc.doctype, "docstatus", "=", 1], ] + if serial_no_field == "rejected_serial_and_batch_bundle": + filters.append([child_doc.doctype, "rejected_qty", ">", 0]) + # Required for POS Invoice if ignore_voucher_detail_no: filters.append([child_doc.doctype, "name", "!=", ignore_voucher_detail_no]) @@ -748,9 +738,57 @@ def get_returned_serial_nos( ids = [] for row in frappe.get_all(parent_doc.doctype, fields=fields, filters=filters): ids.append(row.get("serial_and_batch_bundle")) - if row.get(old_field): + if row.get(old_field) and not row.get(serial_no_field): serial_nos.extend(get_serial_nos_from_serial_no(row.get(old_field))) - serial_nos.extend(get_serial_nos(ids)) + if ids: + serial_nos.extend(get_serial_nos(ids)) return serial_nos + + +def get_returned_batches( + child_doc, parent_doc, batch_no_field=None, ignore_voucher_detail_no=None +): + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle + + batches = frappe._dict() + + old_field = "batch_no" + if not batch_no_field: + batch_no_field = "serial_and_batch_bundle" + + return_ref_field = frappe.scrub(child_doc.doctype) + if child_doc.doctype == "Delivery Note Item": + return_ref_field = "dn_detail" + + fields = [ + f"`{'tab' + child_doc.doctype}`.`{batch_no_field}`", + f"`{'tab' + child_doc.doctype}`.`batch_no`", + f"`{'tab' + child_doc.doctype}`.`stock_qty`", + ] + + filters = [ + [parent_doc.doctype, "return_against", "=", parent_doc.name], + [parent_doc.doctype, "is_return", "=", 1], + [child_doc.doctype, return_ref_field, "=", child_doc.name], + [parent_doc.doctype, "docstatus", "=", 1], + ] + + if batch_no_field == "rejected_serial_and_batch_bundle": + filters.append([child_doc.doctype, "rejected_qty", ">", 0]) + + # Required for POS Invoice + if ignore_voucher_detail_no: + filters.append([child_doc.doctype, "name", "!=", ignore_voucher_detail_no]) + + ids = [] + for row in frappe.get_all(parent_doc.doctype, fields=fields, filters=filters): + ids.append(row.get("serial_and_batch_bundle")) + if row.get(old_field) and not row.get(batch_no_field): + batches.setdefault(row.get(old_field), row.get("stock_qty")) + + if ids: + batches.update(get_batches_from_bundle(ids)) + + return batches diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index fdadb30e93790..e8bae8cda52dd 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -308,6 +308,8 @@ def get_item_list(self): "warehouse": p.warehouse or d.warehouse, "item_code": p.item_code, "qty": flt(p.qty), + "serial_no": p.serial_no if self.docstatus == 2 else None, + "batch_no": p.batch_no if self.docstatus == 2 else None, "uom": p.uom, "serial_and_batch_bundle": p.serial_and_batch_bundle or get_serial_and_batch_bundle(p, self), @@ -330,6 +332,8 @@ def get_item_list(self): "warehouse": d.warehouse, "item_code": d.item_code, "qty": d.stock_qty, + "serial_no": d.serial_no if self.docstatus == 2 else None, + "batch_no": d.batch_no if self.docstatus == 2 else None, "uom": d.uom, "stock_uom": d.stock_uom, "conversion_factor": d.conversion_factor, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2fda9ccd6e2c3..e5c7afc064712 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -455,6 +455,12 @@ def get_sl_entries(self, d, args): sl_dict.update(args) self.update_inventory_dimensions(d, sl_dict) + if self.docstatus == 2: + # To handle denormalized serial no records, will br deprecated in v16 + for field in ["serial_no", "batch_no"]: + if d.get(field): + sl_dict[field] = d.get(field) + return sl_dict def update_inventory_dimensions(self, row, sl_dict) -> None: diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 94655747e430c..3a581226cab7d 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -174,6 +174,115 @@ def check_serial_no_values(self, serial_no, field_values): for field, value in field_values.items(): self.assertEqual(cstr(serial_no.get(field)), value) + def test_delivery_note_return_against_denormalized_serial_no(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + frappe.flags.ignore_serial_batch_bundle_validation = True + sn_item = "Old Serial NO Item Return Test - 1" + make_item( + sn_item, + { + "has_serial_no": 1, + "serial_no_series": "OSN-.####", + "is_stock_item": 1, + }, + ) + + frappe.flags.ignore_serial_batch_bundle_validation = True + serial_nos = [ + "OSN-1", + "OSN-2", + "OSN-3", + "OSN-4", + "OSN-5", + "OSN-6", + "OSN-7", + "OSN-8", + "OSN-9", + "OSN-10", + "OSN-11", + "OSN-12", + ] + + for sn in serial_nos: + if not frappe.db.exists("Serial No", sn): + sn_doc = frappe.get_doc( + { + "doctype": "Serial No", + "item_code": sn_item, + "serial_no": sn, + } + ) + sn_doc.insert() + + warehouse = "_Test Warehouse - _TC" + company = frappe.db.get_value("Warehouse", warehouse, "company") + se_doc = make_stock_entry( + item_code=sn_item, + company=company, + target="_Test Warehouse - _TC", + qty=12, + basic_rate=100, + do_not_submit=1, + ) + + se_doc.items[0].serial_no = "\n".join(serial_nos) + se_doc.submit() + + self.assertEqual(sorted(get_serial_nos(se_doc.items[0].serial_no)), sorted(serial_nos)) + + dn = create_delivery_note( + item_code=sn_item, + qty=12, + rate=500, + warehouse=warehouse, + company=company, + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + do_not_submit=1, + ) + + dn.items[0].serial_no = "\n".join(serial_nos) + dn.submit() + dn.reload() + + self.assertTrue(dn.items[0].serial_no) + + frappe.flags.ignore_serial_batch_bundle_validation = False + + # return entry + dn1 = make_sales_return(dn.name) + + dn1.items[0].qty = -2 + + bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) + bundle_doc.set("entries", bundle_doc.entries[:2]) + bundle_doc.save() + + dn1.save() + dn1.submit() + + returned_serial_nos1 = get_serial_nos_from_bundle(dn1.items[0].serial_and_batch_bundle) + for serial_no in returned_serial_nos1: + self.assertTrue(serial_no in serial_nos) + + dn2 = make_sales_return(dn.name) + + dn2.items[0].qty = -2 + + bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn2.items[0].serial_and_batch_bundle) + bundle_doc.set("entries", bundle_doc.entries[:2]) + bundle_doc.save() + + dn2.save() + dn2.submit() + + returned_serial_nos2 = get_serial_nos_from_bundle(dn2.items[0].serial_and_batch_bundle) + for serial_no in returned_serial_nos2: + self.assertTrue(serial_no in serial_nos) + self.assertFalse(serial_no in returned_serial_nos1) + def test_sales_return_for_non_bundled_items_partial(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") 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 a37fdde9b2851..345e79024bc1a 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 @@ -23,7 +23,11 @@ ) from frappe.utils.csvutils import build_csv_response -from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation +from erpnext.stock.serial_batch_bundle import ( + BatchNoValuation, + SerialNoValuation, + get_batches_from_bundle, +) from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle @@ -82,7 +86,6 @@ class SerialandBatchBundle(Document): def validate(self): self.validate_serial_and_batch_no() - self.validate_serial_and_batch_no_for_returned() self.validate_duplicate_serial_and_batch_no() self.validate_voucher_no() if self.type_of_transaction == "Maintenance": @@ -124,6 +127,8 @@ def validate_serial_nos_inventory(self): ) def validate_serial_nos_duplicate(self): + # Don't inward same serial number multiple times + if not self.warehouse: return @@ -330,6 +335,19 @@ def set_serial_and_batch_values(self, parent, row, qty_field=None): ): values_to_set["posting_time"] = parent.posting_time + if parent.doctype in [ + "Delivery Note", + "Purchase Receipt", + "Purchase Invoice", + "Sales Invoice", + ] and parent.get("is_return"): + return_ref_field = frappe.scrub(parent.doctype) + "_item" + if parent.doctype == "Delivery Note": + return_ref_field = "dn_detail" + + if row.get(return_ref_field): + values_to_set["returned_against"] = row.get(return_ref_field) + if values_to_set: self.db_set(values_to_set) @@ -593,6 +611,11 @@ def validate_incorrect_batch_nos(self, batch_nos): ) def validate_serial_and_batch_no_for_returned(self): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + if not self.returned_against: + return + if self.voucher_type not in [ "Purchase Receipt", "Purchase Invoice", @@ -601,8 +624,52 @@ def validate_serial_and_batch_no_for_returned(self): ]: return - if self.returned_against: - pass + data = self.get_orignal_document_data() + if not data: + return + + serial_nos, batches = [], [] + current_serial_nos = [d.serial_no for d in self.entries if d.serial_no] + current_batches = [d.batch_no for d in self.entries if d.batch_no] + + for d in data: + if self.has_serial_no: + if d.serial_and_batch_bundle: + serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) + else: + serial_nos = get_serial_nos(d.serial_no) + + elif self.has_batch_no: + if d.serial_and_batch_bundle: + batches = get_batches_from_bundle(d.serial_and_batch_bundle) + else: + batches = frappe._dict({d.batch_no: d.stock_qty}) + + if batches: + batches = [d for d in batches if batches[d] > 0] + + if serial_nos: + if not set(current_serial_nos).issubset(set(serial_nos)): + self.throw_error_message( + f"Serial Nos {bold(', '.join(serial_nos))} are not part of the original document." + ) + + if batches: + if not set(current_batches).issubset(set(batches)): + self.throw_error_message( + f"Batch Nos {bold(', '.join(batches))} are not part of the original document." + ) + + def get_orignal_document_data(self): + fields = ["serial_and_batch_bundle", "stock_qty"] + if self.has_serial_no: + fields.append("serial_no") + + elif self.has_batch_no: + fields.append("batch_no") + + child_doc = self.voucher_type + " Item" + return frappe.get_all(child_doc, fields=fields, filters={"name": self.returned_against}) def validate_duplicate_serial_and_batch_no(self): serial_nos = [] @@ -702,6 +769,9 @@ def delink_reference_from_batch(self): for batch in batches: frappe.db.set_value("Batch", batch.name, {"reference_name": None, "reference_doctype": None}) + def before_submit(self): + self.validate_serial_and_batch_no_for_returned() + def on_submit(self): self.validate_serial_nos_inventory() diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 23788cf46b661..6e7af6815f407 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -181,6 +181,9 @@ def validate_mandatory(self): frappe.throw(_("Actual Qty is mandatory")) def validate_serial_batch_no_bundle(self): + if self.is_cancelled == 1: + return + item_detail = frappe.get_cached_value( "Item", self.item_code, diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 0c187923e3c86..f6e8694be98f4 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -218,15 +218,16 @@ def delink_serial_and_batch_bundle(self): ).validate_serial_and_batch_inventory() def post_process(self): - if not self.sle.serial_and_batch_bundle: + if not self.sle.serial_and_batch_bundle and not self.sle.serial_no and not self.sle.batch_no: return - docstatus = frappe.get_cached_value( - "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "docstatus" - ) + if self.sle.serial_and_batch_bundle: + docstatus = frappe.get_cached_value( + "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "docstatus" + ) - if docstatus != 1: - self.submit_serial_and_batch_bundle() + if docstatus != 1: + self.submit_serial_and_batch_bundle() if self.item_details.has_serial_no == 1: self.set_warehouse_and_status_in_serial_nos() @@ -249,7 +250,12 @@ def submit_serial_and_batch_bundle(self): doc.submit() def set_warehouse_and_status_in_serial_nos(self): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos + serial_nos = get_serial_nos(self.sle.serial_and_batch_bundle) + if not self.sle.serial_and_batch_bundle and self.sle.serial_no: + serial_nos = get_parsed_serial_nos(self.sle.serial_no) + warehouse = self.warehouse if self.sle.actual_qty > 0 else None if not serial_nos: @@ -290,6 +296,8 @@ def update_batch_qty(self): from erpnext.stock.doctype.batch.batch import get_available_batches batches = get_batch_nos(self.sle.serial_and_batch_bundle) + if not self.sle.serial_and_batch_bundle and self.sle.batch_no: + batches = frappe._dict({self.sle.batch_no: self.sle.actual_qty}) batches_qty = get_available_batches( frappe._dict( @@ -312,13 +320,35 @@ def get_serial_nos(serial_and_batch_bundle, serial_nos=None): if serial_nos: filters["serial_no"] = ("in", serial_nos) - entries = frappe.get_all("Serial and Batch Entry", fields=["serial_no"], filters=filters) + entries = frappe.get_all( + "Serial and Batch Entry", fields=["serial_no"], filters=filters, order_by="idx" + ) if not entries: return [] return [d.serial_no for d in entries if d.serial_no] +def get_batches_from_bundle(serial_and_batch_bundle, batches=None): + if not serial_and_batch_bundle: + return [] + + filters = {"parent": serial_and_batch_bundle, "batch_no": ("is", "set")} + if isinstance(serial_and_batch_bundle, list): + filters = {"parent": ("in", serial_and_batch_bundle)} + + if batches: + filters["batch_no"] = ("in", batches) + + entries = frappe.get_all( + "Serial and Batch Entry", fields=["batch_no", "qty"], filters=filters, order_by="idx", as_list=1 + ) + if not entries: + return frappe._dict({}) + + return frappe._dict(entries) + + def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None): return get_serial_nos(serial_and_batch_bundle, serial_nos=serial_nos)