From 8d188cd32b74b839def62e1200f4573a404cab35 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 12 Jan 2026 18:28:34 +0530 Subject: [PATCH 1/6] refactor: sample retention stock entry --- .../stock/doctype/stock_entry/stock_entry.js | 6 +-- .../stock/doctype/stock_entry/stock_entry.py | 52 +++++++++++-------- erpnext/stock/serial_batch_bundle.py | 2 +- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index d106d097256..b62dce837f1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -568,10 +568,6 @@ frappe.ui.form.on("Stock Entry", { if (r.message) { var doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); - } else { - frappe.msgprint( - __("Retention Stock Entry already created or Sample Quantity not provided") - ); } }, }); @@ -1054,7 +1050,7 @@ frappe.ui.form.on("Stock Entry Detail", { var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; - if (d.sample_quantity && frm.doc.purpose == "Material Receipt") { + if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") { frappe.call({ method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity", args: { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8c200715397..9afb98b61d7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3408,10 +3408,10 @@ class StockEntry(StockController, SubcontractingInwardController): @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): - from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( - get_batch_from_bundle, + from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_batch_nos, ) - from erpnext.stock.serial_batch_bundle import SerialBatchCreation if isinstance(items, str): items = json.loads(items) @@ -3422,38 +3422,46 @@ def move_sample_to_retention_warehouse(company, items): stock_entry.set_stock_entry_type() for item in items: if item.get("sample_quantity") and item.get("serial_and_batch_bundle"): - batch_no = get_batch_from_bundle(item.get("serial_and_batch_bundle")) - sample_quantity = validate_sample_quantity( - item.get("item_code"), - item.get("sample_quantity"), - item.get("transfer_qty") or item.get("qty"), - batch_no, + warehouse = item.get("t_warehouse") or item.get("warehouse") + total_qty = 0 + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), + "item_code": item.get("item_code"), + "warehouse": warehouse, + "do_not_save": True, + } ) - - if sample_quantity: - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), - "item_code": item.get("item_code"), - "warehouse": item.get("t_warehouse"), - } + sabb = cls_obj.duplicate_package() + batches = get_batch_nos(item.get("serial_and_batch_bundle")) + for batch_no in batches.keys(): + sample_quantity = validate_sample_quantity( + item.get("item_code"), + item.get("sample_quantity"), + item.get("transfer_qty") or item.get("qty"), + batch_no, ) - cls_obj.duplicate_package() + if sample_quantity: + total_qty += sample_quantity + sabe = next(item for item in sabb.entries if item.batch_no == batch_no) + sabe.qty = -1 * sample_quantity + if total_qty: + sabb.save() stock_entry.append( "items", { "item_code": item.get("item_code"), - "s_warehouse": item.get("t_warehouse"), + "s_warehouse": warehouse, "t_warehouse": retention_warehouse, - "qty": item.get("sample_quantity"), + "qty": total_qty, "basic_rate": item.get("valuation_rate"), "uom": item.get("uom"), "stock_uom": item.get("stock_uom"), "conversion_factor": item.get("conversion_factor") or 1.0, - "serial_and_batch_bundle": cls_obj.serial_and_batch_bundle, + "serial_and_batch_bundle": sabb.name, }, ) if stock_entry.get("items"): diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 13d7b100855..9cb4c25d782 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -1116,7 +1116,7 @@ class SerialBatchCreation: id = self.serial_and_batch_bundle package = frappe.get_doc("Serial and Batch Bundle", id) - new_package = frappe.copy_doc(package) + new_package = frappe.copy_doc(package, ignore_no_copy=False) if self.get("returned_serial_nos"): self.remove_returned_serial_nos(new_package) From b54067e04d171d6c6b8d91760efa53a5270f4f93 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 12 Jan 2026 18:56:14 +0530 Subject: [PATCH 2/6] fix: remove already transferred batch --- erpnext/stock/doctype/stock_entry/stock_entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9afb98b61d7..7d5f756e49f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3443,10 +3443,12 @@ def move_sample_to_retention_warehouse(company, items): batch_no, ) + sabe = next(item for item in sabb.entries if item.batch_no == batch_no) if sample_quantity: total_qty += sample_quantity - sabe = next(item for item in sabb.entries if item.batch_no == batch_no) sabe.qty = -1 * sample_quantity + else: + sabb.entries.remove(sabe) if total_qty: sabb.save() From 3d0f6494115331ed4052e025da74eb212ab3b84c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 13 Jan 2026 12:28:56 +0530 Subject: [PATCH 3/6] feat: support for serial item --- erpnext/stock/doctype/stock_entry/stock_entry.py | 12 +++++++++++- erpnext/stock/serial_batch_bundle.py | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 7d5f756e49f..e92ab143588 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3435,6 +3435,7 @@ def move_sample_to_retention_warehouse(company, items): ) sabb = cls_obj.duplicate_package() batches = get_batch_nos(item.get("serial_and_batch_bundle")) + sabe_list = [] for batch_no in batches.keys(): sample_quantity = validate_sample_quantity( item.get("item_code"), @@ -3446,11 +3447,20 @@ def move_sample_to_retention_warehouse(company, items): sabe = next(item for item in sabb.entries if item.batch_no == batch_no) if sample_quantity: total_qty += sample_quantity - sabe.qty = -1 * sample_quantity + if sabb.has_serial_no: + sabe_list.extend( + [entry for entry in sabb.entries if entry.batch_no == batch_no][ + : int(sample_quantity) + ] + ) + else: + sabe.qty = -1 * sample_quantity else: sabb.entries.remove(sabe) if total_qty: + if sabe_list: + sabb.entries = sabe_list sabb.save() stock_entry.append( "items", diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 9cb4c25d782..50603eb609d 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -989,9 +989,10 @@ def get_batch_nos(serial_and_batch_bundle): entries = frappe.get_all( "Serial and Batch Entry", - fields=["batch_no", "qty", "name"], + fields=["batch_no", {"SUM": "qty", "as": "qty"}], filters={"parent": serial_and_batch_bundle, "batch_no": ("is", "set")}, order_by="idx", + group_by="batch_no", ) if not entries: From b567184dd707b3b5b8aad995007d67bd6f466029 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 16 Jan 2026 12:31:54 +0530 Subject: [PATCH 4/6] test: add test case --- .../stock/doctype/stock_entry/stock_entry.js | 12 ++++-- .../stock/doctype/stock_entry/stock_entry.py | 17 ++++++-- .../doctype/stock_entry/stock_entry_utils.py | 1 + .../doctype/stock_entry/test_stock_entry.py | 40 +++++++++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index b62dce837f1..7d2ae01357a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -440,12 +440,16 @@ frappe.ui.form.on("Stock Entry", { if ( frm.doc.docstatus == 1 && - frm.doc.purpose == "Material Receipt" && + ["Material Receipt", "Manufacture"].includes(frm.doc.purpose) && frm.get_sum("items", "sample_quantity") ) { - frm.add_custom_button(__("Create Sample Retention Stock Entry"), function () { - frm.trigger("make_retention_stock_entry"); - }); + frm.add_custom_button( + __("Sample Retention Stock Entry"), + function () { + frm.trigger("make_retention_stock_entry"); + }, + __("Create") + ); } frm.trigger("setup_quality_inspection"); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e92ab143588..fc38aff9e2e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2570,6 +2570,7 @@ class StockEntry(StockController, SubcontractingInwardController): "expense_account": expense_account, "cost_center": item.get("buying_cost_center"), "is_finished_item": 1, + "sample_quantity": item.get("sample_quantity"), } if ( @@ -3103,6 +3104,7 @@ class StockEntry(StockController, SubcontractingInwardController): se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") se_child.scio_detail = item_row.get("scio_detail") + se_child.sample_quantity = item_row.get("sample_quantity", 0) for field in [ self.subcontract_data.rm_detail_field, @@ -3415,6 +3417,7 @@ def move_sample_to_retention_warehouse(company, items): if isinstance(items, str): items = json.loads(items) + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") stock_entry = frappe.new_doc("Stock Entry") stock_entry.company = company @@ -3449,12 +3452,17 @@ def move_sample_to_retention_warehouse(company, items): total_qty += sample_quantity if sabb.has_serial_no: sabe_list.extend( - [entry for entry in sabb.entries if entry.batch_no == batch_no][ - : int(sample_quantity) - ] + [ + entry + for entry in sabb.entries + if entry.batch_no == batch_no + and frappe.db.exists( + "Serial No", {"name": entry.serial_no, "warehouse": warehouse} + ) + ][: int(sample_quantity)] ) else: - sabe.qty = -1 * sample_quantity + sabe.qty = sample_quantity else: sabb.entries.remove(sabe) @@ -3462,6 +3470,7 @@ def move_sample_to_retention_warehouse(company, items): if sabe_list: sabb.entries = sabe_list sabb.save() + stock_entry.append( "items", { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index 576d129ee2d..6e54fd4e3b9 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -190,6 +190,7 @@ def make_stock_entry(**args): "cost_center": args.cost_center, "expense_account": args.expense_account, "use_serial_batch_fields": args.use_serial_batch_fields, + "sample_quantity": frappe.get_value("Item", args.item, "sample_quantity") or 0, }, ) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index cf1b12a8f28..7b710514519 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2277,6 +2277,46 @@ class TestStockEntry(IntegrationTestCase): se.save() se.submit() + @IntegrationTestCase.change_settings( + "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} + ) + def test_sample_retention_stock_entry(self): + from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse + + warehouse = "_Test Warehouse - _TC" + retain_sample_item = make_item( + "Retain Sample Item", + properties={ + "is_stock_item": 1, + "retain_sample": 1, + "sample_quantity": 2, + "has_batch_no": 1, + "has_seral_no": 1, + "create_new_batch": 1, + "batch_number_series": "SAMPLE-RET-.#####", + "serial_no_series": "SAMPLE-RET-SN-.#####", + }, + ) + material_receipt = make_stock_entry( + item_code=retain_sample_item.item_code, target=warehouse, qty=10, purpose="Material Receipt" + ) + + source_sabb = frappe.get_doc( + "Serial and Batch Bundle", material_receipt.items[0].serial_and_batch_bundle + ) + batch = source_sabb.entries[0].batch_no + serial_nos = [entry.serial_no for entry in source_sabb.entries] + + sample_entry = frappe.get_doc( + move_sample_to_retention_warehouse(material_receipt.company, material_receipt.items) + ) + sample_entry.submit() + target_sabb = frappe.get_doc("Serial and Batch Bundle", sample_entry.items[0].serial_and_batch_bundle) + + self.assertEqual(sample_entry.items[0].transfer_qty, 2) + self.assertEqual(target_sabb.entries[0].batch_no, batch) + self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2]) + def make_serialized_item(self, **args): args = frappe._dict(args) From 19ae405742efd7e188fb560ed68731d7e6e3ec27 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 16 Jan 2026 13:15:22 +0530 Subject: [PATCH 5/6] fix: bugs --- .../stock/doctype/stock_entry/stock_entry.py | 22 +++++++++---------- .../doctype/stock_entry/test_stock_entry.py | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fc38aff9e2e..fbcc43231dd 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3449,19 +3449,19 @@ def move_sample_to_retention_warehouse(company, items): sabe = next(item for item in sabb.entries if item.batch_no == batch_no) if sample_quantity: - total_qty += sample_quantity if sabb.has_serial_no: - sabe_list.extend( - [ - entry - for entry in sabb.entries - if entry.batch_no == batch_no - and frappe.db.exists( - "Serial No", {"name": entry.serial_no, "warehouse": warehouse} - ) - ][: int(sample_quantity)] - ) + new_sabe = [ + entry + for entry in sabb.entries + if entry.batch_no == batch_no + and frappe.db.exists( + "Serial No", {"name": entry.serial_no, "warehouse": warehouse} + ) + ][: int(sample_quantity)] + sabe_list.extend(new_sabe) + total_qty += len(new_sabe) else: + total_qty += sample_quantity sabe.qty = sample_quantity else: sabb.entries.remove(sabe) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 7b710514519..a0ac4f180b2 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2291,7 +2291,7 @@ class TestStockEntry(IntegrationTestCase): "retain_sample": 1, "sample_quantity": 2, "has_batch_no": 1, - "has_seral_no": 1, + "has_serial_no": 1, "create_new_batch": 1, "batch_number_series": "SAMPLE-RET-.#####", "serial_no_series": "SAMPLE-RET-SN-.#####", From 8fd1d6aec8b7e76881b1394eac70fb6482526756 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 16 Jan 2026 13:38:38 +0530 Subject: [PATCH 6/6] chore: typo --- erpnext/stock/doctype/purchase_receipt/purchase_receipt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index b5c1c38729f..81c1b147697 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -266,7 +266,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend ); } cur_frm.add_custom_button( - __("Retention Stock Entry"), + __("Sample Retention Stock Entry"), this.make_retention_stock_entry, __("Create") );