From 38249f41708a5c955192c817d380eec38419d144 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 1 Jun 2024 18:30:33 +0530 Subject: [PATCH] feat: optional to reconcile all serial nos / batches in stock reconciliation (backport #41696) (#41713) * feat: optional to reconcile all serial nos / batches in stock reconciliation (#41696) feat: optional to reconcile all serial/batch (cherry picked from commit ee846f59504a7f1751c284971847e9e564231d4d) # Conflicts: # erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json * chore: fix conflicts --------- Co-authored-by: rohitwaghchaure --- .../stock/doctype/pick_list/test_pick_list.py | 1 + .../stock_reconciliation.js | 1 + .../stock_reconciliation.py | 89 ++++++++++++++- .../test_stock_reconciliation.py | 101 ++++++++++++++++++ .../stock_reconciliation_item.json | 12 ++- .../stock_reconciliation_item.py | 1 + erpnext/stock/serial_batch_bundle.py | 8 +- 7 files changed, 206 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 65fe853ec8d..116a0bd833d 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -167,6 +167,7 @@ class TestPickList(FrappeTestCase): "item_code": "_Test Serialized Item", "warehouse": "_Test Warehouse - _TC", "valuation_rate": 100, + "reconcile_all_serial_batch": 1, "qty": 5, "serial_and_batch_bundle": make_serial_batch_bundle( frappe._dict( diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 8532b60d59c..31985678009 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -206,6 +206,7 @@ frappe.ui.form.on("Stock Reconciliation", { posting_date: frm.doc.posting_date, posting_time: frm.doc.posting_time, batch_no: d.batch_no, + row: d, }, callback: function (r) { const row = frappe.model.get_doc(cdt, cdn); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index f92d7361f41..89e69c153d4 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -3,7 +3,7 @@ import frappe -from frappe import _, bold, msgprint +from frappe import _, bold, json, msgprint from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import add_to_date, cint, cstr, flt @@ -162,6 +162,11 @@ class StockReconciliation(StockController): def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None: """Set Serial and Batch Bundle for each item""" for item in self.items: + if not item.reconcile_all_serial_batch and item.serial_and_batch_bundle: + bundle = self.get_bundle_for_specific_serial_batch(item) + item.current_serial_and_batch_bundle = bundle + continue + if not save and item.use_serial_batch_fields: continue @@ -273,6 +278,75 @@ class StockReconciliation(StockController): } ) + def get_bundle_for_specific_serial_batch(self, row) -> str: + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + if row.current_serial_and_batch_bundle and not self.has_change_in_serial_batch(row): + return row.current_serial_and_batch_bundle + + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": row.serial_and_batch_bundle, + "item_code": row.get("item_code"), + "warehouse": row.get("warehouse"), + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "do_not_save": True, + } + ) + + reco_obj = cls_obj.duplicate_package() + + total_current_qty = 0.0 + for entry in reco_obj.entries: + if not entry.batch_no or entry.serial_no: + total_current_qty += entry.qty + entry.qty *= -1 + continue + + current_qty = get_batch_qty( + entry.batch_no, + row.warehouse, + row.item_code, + posting_date=self.posting_date, + posting_time=self.posting_time, + ) + + total_current_qty += current_qty + entry.qty = current_qty * -1 + + reco_obj.flags.ignore_validate = True + reco_obj.save() + + row.current_qty = total_current_qty + + return reco_obj.name + + def has_change_in_serial_batch(self, row) -> bool: + bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []} + + data = frappe.get_all( + "Serial and Batch Entry", + fields=["serial_no", "batch_no", "parent"], + filters={"parent": ("in", [row.serial_and_batch_bundle, row.current_serial_and_batch_bundle])}, + order_by="idx", + ) + + for d in data: + bundles[d.parent].append(d.serial_no or d.batch_no) + + diff = set(bundles[row.serial_and_batch_bundle]) - set(bundles[row.current_serial_and_batch_bundle]) + + if diff: + bundle = row.current_serial_and_batch_bundle + row.current_serial_and_batch_bundle = None + frappe.delete_doc("Serial and Batch Bundle", bundle) + + return True + + return False + def set_new_serial_and_batch_bundle(self): for item in self.items: if item.use_serial_batch_fields: @@ -340,6 +414,7 @@ class StockReconciliation(StockController): self.posting_time, batch_no=item.batch_no, inventory_dimensions_dict=inventory_dimensions_dict, + row=item, ) if ( @@ -840,6 +915,7 @@ class StockReconciliation(StockController): row.warehouse, self.posting_date, self.posting_time, + row=row, ) current_qty = item_dict.get("qty") @@ -1166,11 +1242,18 @@ def get_stock_balance_for( batch_no: str | None = None, with_valuation_rate: bool = True, inventory_dimensions_dict=None, + row=None, ): frappe.has_permission("Stock Reconciliation", "write", throw=True) item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) + if isinstance(row, str): + row = json.loads(row) + + if isinstance(row, dict): + row = frappe._dict(row) + if not item_dict: # In cases of data upload to Items table msg = _("Item {} does not exist.").format(item_code) @@ -1188,7 +1271,7 @@ def get_stock_balance_for( "qty": 0, "rate": 0, "serial_nos": None, - "use_serial_batch_fields": use_serial_batch_fields, + "use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields, } # TODO: fetch only selected batch's values @@ -1214,7 +1297,7 @@ def get_stock_balance_for( "qty": qty, "rate": rate, "serial_nos": serial_nos, - "use_serial_batch_fields": use_serial_batch_fields, + "use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields, } diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 92a931036e9..4397616e30c 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1070,6 +1070,103 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertTrue(sr.items[0].serial_and_batch_bundle) self.assertFalse(sr.items[0].current_serial_and_batch_bundle) + def test_not_reconcile_all_batch(self): + from erpnext.stock.doctype.batch.batch import get_batch_qty + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item = self.make_item( + "Test Batch Item Not Reconcile All Serial Batch", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-NRALL-SRCOSRWFEE-.###", + }, + ) + + warehouse = "_Test Warehouse - _TC" + + batches = [] + for qty in [10, 20, 30]: + se = make_stock_entry( + item_code=item.name, + target=warehouse, + qty=qty, + basic_rate=100 + qty, + posting_date=nowdate(), + ) + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + batches.append(frappe._dict({"batch_no": batch_no, "qty": qty})) + + sr = create_stock_reconciliation( + item_code=item.name, + warehouse=warehouse, + qty=100, + rate=1000, + reconcile_all_serial_batch=0, + batch_no=batches[0].batch_no, + ) + + sr.reload() + current_sabb = sr.items[0].current_serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", current_sabb) + for row in doc.entries: + self.assertEqual(row.batch_no, batches[0].batch_no) + self.assertEqual(row.qty, batches[0].qty * -1) + + batch_qty = get_batch_qty(batches[0].batch_no, warehouse, item.name) + self.assertEqual(batch_qty, 100) + + def test_not_reconcile_all_serial_nos(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + from erpnext.stock.utils import get_incoming_rate + + item = self.make_item( + "Test Serial NO Item Not Reconcile All Serial Batch", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SNN-TEST-BATCH-NRALL-S-.###", + }, + ) + + warehouse = "_Test Warehouse - _TC" + + serial_nos = [] + for qty in [5, 5, 5]: + se = make_stock_entry( + item_code=item.name, + target=warehouse, + qty=qty, + basic_rate=100 + qty, + posting_date=nowdate(), + ) + + serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)) + + sr = create_stock_reconciliation( + item_code=item.name, + warehouse=warehouse, + qty=5, + rate=1000, + reconcile_all_serial_batch=0, + serial_no=serial_nos[0:5], + ) + + sr.reload() + current_sabb = sr.items[0].current_serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", current_sabb) + for row in doc.entries: + self.assertEqual(row.serial_no, serial_nos[row.idx - 1]) + + sabb = sr.items[0].serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", sabb) + for row in doc.entries: + self.assertEqual(row.qty, 1) + self.assertAlmostEqual(row.incoming_rate, 1000.00) + self.assertEqual(row.serial_no, serial_nos[row.idx - 1]) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) @@ -1193,12 +1290,16 @@ def create_stock_reconciliation(**args): ) ).name + if args.reconcile_all_serial_batch is None: + args.reconcile_all_serial_batch = 1 + sr.append( "items", { "item_code": args.item_code or "_Test Item", "warehouse": args.warehouse or "_Test Warehouse - _TC", "qty": args.qty, + "reconcile_all_serial_batch": args.reconcile_all_serial_batch, "valuation_rate": args.rate, "serial_no": args.serial_no if args.use_serial_batch_fields else None, "batch_no": args.batch_no if args.use_serial_batch_fields else None, diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 734225972c7..bce819fe231 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -20,6 +20,7 @@ "serial_no_and_batch_section", "add_serial_batch_bundle", "use_serial_batch_fields", + "reconcile_all_serial_batch", "column_break_11", "serial_and_batch_bundle", "current_serial_and_batch_bundle", @@ -243,11 +244,18 @@ { "fieldname": "column_break_eefq", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.use_serial_batch_fields", + "fieldname": "reconcile_all_serial_batch", + "fieldtype": "Check", + "label": "Reconcile All Serial Nos / Batches" } ], "istable": 1, "links": [], - "modified": "2024-02-04 16:19:44.576022", + "modified": "2024-05-30 23:20:00.947243", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", @@ -258,4 +266,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py index 1938fec32b0..f2a9aeba8f4 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py @@ -33,6 +33,7 @@ class StockReconciliationItem(Document): parenttype: DF.Data qty: DF.Float quantity_difference: DF.ReadOnly | None + reconcile_all_serial_batch: DF.Check serial_and_batch_bundle: DF.Link | None serial_no: DF.LongText | None use_serial_batch_fields: DF.Check diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index fcebf0491ac..1fa5665c141 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -848,10 +848,14 @@ class SerialBatchCreation: new_package.docstatus = 0 new_package.warehouse = self.warehouse new_package.voucher_no = "" - new_package.posting_date = today() - new_package.posting_time = nowtime() + new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today() + new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime() new_package.type_of_transaction = self.type_of_transaction new_package.returned_against = self.get("returned_against") + + if self.get("do_not_save"): + return new_package + new_package.save() self.serial_and_batch_bundle = new_package.name