From 47ee801d373058a2739c2fd42d971d624c42d5a7 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Wed, 27 Dec 2023 13:09:23 +0530 Subject: [PATCH] fix: incorrect qty in serial batch bundle against pick list (#38964) --- .../js/utils/serial_no_batch_selector.js | 1 + .../doctype/delivery_note/delivery_note.py | 17 ++- erpnext/stock/doctype/pick_list/pick_list.js | 38 ++++++ erpnext/stock/doctype/pick_list/pick_list.py | 15 ++- .../stock/doctype/pick_list/test_pick_list.py | 118 +++++++++++++++++- .../serial_and_batch_bundle.py | 109 +++++++++++++++- 6 files changed, 289 insertions(+), 9 deletions(-) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 4abc8fa395..4cd1243413 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -502,6 +502,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { set_data(data) { data.forEach(d => { + d.qty = Math.abs(d.qty); this.dialog.fields_dict.entries.df.data.push(d); }); diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 675f8e9158..132f8f2e29 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -311,11 +311,13 @@ class DeliveryNote(SellingController): ) def set_serial_and_batch_bundle_from_pick_list(self): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + if not self.pick_list: return for item in self.items: - if item.pick_list_item: + if item.pick_list_item and not item.serial_and_batch_bundle: filters = { "item_code": item.item_code, "voucher_type": "Pick List", @@ -326,7 +328,17 @@ class DeliveryNote(SellingController): bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") if bundle_id: - item.serial_and_batch_bundle = bundle_id + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": bundle_id, + "item_code": item.get("item_code"), + } + ) + + cls_obj.duplicate_package() + + item.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle def validate_proj_cust(self): """check for does customer belong to same project as entered..""" @@ -408,6 +420,7 @@ class DeliveryNote(SellingController): self.update_stock_ledger() self.cancel_packing_slips() + self.update_pick_list_status() self.make_gl_entries_on_cancel() self.repost_future_sle_and_gle() diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 7cd171ea92..afd6ce8138 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -283,6 +283,7 @@ frappe.ui.form.on('Pick List Item', { }); } }, + uom: (frm, cdt, cdn) => { let row = frappe.get_doc(cdt, cdn); if (row.uom) { @@ -291,13 +292,50 @@ frappe.ui.form.on('Pick List Item', { }); } }, + qty: (frm, cdt, cdn) => { let row = frappe.get_doc(cdt, cdn); frappe.model.set_value(cdt, cdn, 'stock_qty', row.qty * row.conversion_factor); }, + conversion_factor: (frm, cdt, cdn) => { let row = frappe.get_doc(cdt, cdn); frappe.model.set_value(cdt, cdn, 'stock_qty', row.qty * row.conversion_factor); + }, + + pick_serial_and_batch(frm, cdt, cdn) { + let item = locals[cdt][cdn]; + let path = "assets/erpnext/js/utils/serial_no_batch_selector.js"; + + frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) + .then((r) => { + if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { + item.has_serial_no = r.message.has_serial_no; + item.has_batch_no = r.message.has_batch_no; + item.type_of_transaction = item.qty > 0 ? "Outward":"Inward"; + + item.title = item.has_serial_no ? + __("Select Serial No") : __("Select Batch No"); + + if (item.has_serial_no && item.has_batch_no) { + item.title = __("Select Serial and Batch"); + } + + frappe.require(path, function() { + new erpnext.SerialBatchPackageSelector( + frm, item, (r) => { + if (r) { + let qty = Math.abs(r.total_qty); + frappe.model.set_value(item.doctype, item.name, { + "serial_and_batch_bundle": r.name, + "qty": qty + }); + } + } + ); + }); + } + }); } }); diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 545e45f3d8..758448af79 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -21,6 +21,7 @@ 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_conversion_factor from erpnext.stock.serial_batch_bundle import SerialBatchCreation @@ -167,6 +168,9 @@ class PickList(Document): "Serial and Batch Bundle", row.serial_and_batch_bundle ).set_serial_and_batch_values(self, row) + def on_trash(self): + self.remove_serial_and_batch_bundle() + def remove_serial_and_batch_bundle(self): for row in self.locations: if row.serial_and_batch_bundle: @@ -723,13 +727,14 @@ def get_available_item_locations( def get_available_item_locations_for_serialized_item( item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): + picked_serial_nos = get_picked_serial_nos(item_code, from_warehouses) + sn = frappe.qb.DocType("Serial No") query = ( frappe.qb.from_(sn) .select(sn.name, sn.warehouse) .where((sn.item_code == item_code) & (sn.company == company)) .orderby(sn.creation) - .limit(cint(required_qty + total_picked_qty)) ) if from_warehouses: @@ -742,6 +747,9 @@ def get_available_item_locations_for_serialized_item( warehouse_serial_nos_map = frappe._dict() picked_qty = required_qty for serial_no, warehouse in serial_nos: + if serial_no in picked_serial_nos: + continue + if picked_qty <= 0: break @@ -786,7 +794,8 @@ def get_available_item_locations_for_batched_item( { "item_code": item_code, "warehouse": from_warehouses, - "qty": required_qty + total_picked_qty, + "qty": required_qty, + "is_pick_list": True, } ) ) @@ -1050,7 +1059,7 @@ def get_pending_work_orders(doctype, txt, searchfield, start, page_length, filte @frappe.whitelist() def target_document_exists(pick_list_name, purpose): if purpose == "Delivery": - return frappe.db.exists("Delivery Note", {"pick_list": pick_list_name}) + return frappe.db.exists("Delivery Note", {"pick_list": pick_list_name, "docstatus": 1}) return stock_entry_exists(pick_list_name) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 56c44bfd25..322b0b46ba 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -644,6 +644,122 @@ class TestPickList(FrappeTestCase): so.reload() self.assertEqual(so.per_picked, 50) + def test_picklist_for_batch_item(self): + warehouse = "_Test Warehouse - _TC" + item = make_item( + properties={"is_stock_item": 1, "has_batch_no": 1, "batch_no_series": "PICKLT-.######"} + ).name + + # create batch + for batch_id in ["PICKLT-000001", "PICKLT-000002"]: + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item, + } + ).insert() + + make_stock_entry( + item=item, + to_warehouse=warehouse, + qty=50, + basic_rate=100, + batches=frappe._dict({"PICKLT-000001": 30, "PICKLT-000002": 20}), + ) + + so = make_sales_order(item_code=item, qty=25.0, rate=100) + pl = create_pick_list(so.name) + # pick half the qty + for loc in pl.locations: + self.assertEqual(loc.qty, 25.0) + self.assertTrue(loc.serial_and_batch_bundle) + + data = frappe.get_all( + "Serial and Batch Entry", + fields=["qty", "batch_no"], + filters={"parent": loc.serial_and_batch_bundle}, + ) + + for d in data: + self.assertEqual(d.batch_no, "PICKLT-000001") + self.assertEqual(d.qty, 25.0 * -1) + + pl.save() + pl.submit() + + so1 = make_sales_order(item_code=item, qty=10.0, rate=100) + pl = create_pick_list(so1.name) + # pick half the qty + for loc in pl.locations: + self.assertEqual(loc.qty, 10.0) + self.assertTrue(loc.serial_and_batch_bundle) + + data = frappe.get_all( + "Serial and Batch Entry", + fields=["qty", "batch_no"], + filters={"parent": loc.serial_and_batch_bundle}, + ) + + for d in data: + self.assertTrue(d.batch_no in ["PICKLT-000001", "PICKLT-000002"]) + if d.batch_no == "PICKLT-000001": + self.assertEqual(d.qty, 5.0 * -1) + elif d.batch_no == "PICKLT-000002": + self.assertEqual(d.qty, 5.0 * -1) + + pl.save() + pl.submit() + pl.cancel() + + def test_picklist_for_serial_item(self): + warehouse = "_Test Warehouse - _TC" + item = make_item( + properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SN-PICKLT-.######"} + ).name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=50, basic_rate=100) + + so = make_sales_order(item_code=item, qty=25.0, rate=100) + pl = create_pick_list(so.name) + picked_serial_nos = [] + # pick half the qty + for loc in pl.locations: + self.assertEqual(loc.qty, 25.0) + self.assertTrue(loc.serial_and_batch_bundle) + + data = frappe.get_all( + "Serial and Batch Entry", fields=["serial_no"], filters={"parent": loc.serial_and_batch_bundle} + ) + + picked_serial_nos = [d.serial_no for d in data] + self.assertEqual(len(picked_serial_nos), 25) + + pl.save() + pl.submit() + + so1 = make_sales_order(item_code=item, qty=10.0, rate=100) + pl = create_pick_list(so1.name) + # pick half the qty + for loc in pl.locations: + self.assertEqual(loc.qty, 10.0) + self.assertTrue(loc.serial_and_batch_bundle) + + data = frappe.get_all( + "Serial and Batch Entry", + fields=["qty", "batch_no"], + filters={"parent": loc.serial_and_batch_bundle}, + ) + + self.assertEqual(len(data), 10) + for d in data: + self.assertTrue(d.serial_no not in picked_serial_nos) + + pl.save() + pl.submit() + pl.cancel() + def test_picklist_with_bundles(self): warehouse = "_Test Warehouse - _TC" @@ -732,7 +848,7 @@ class TestPickList(FrappeTestCase): dn.cancel() pl.reload() - self.assertEqual(pl.status, "Completed") + self.assertEqual(pl.status, "Open") pl.cancel() pl.reload() 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 afb53fb112..dd38e1127f 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 @@ -936,7 +936,7 @@ def parse_csv_file_to_get_serial_batch(reader): if index == 0: has_serial_no = row[0] == "Serial No" has_batch_no = row[0] == "Batch No" - if not has_batch_no: + if not has_batch_no and len(row) > 1: has_batch_no = row[1] == "Batch No" continue @@ -1611,10 +1611,17 @@ def get_auto_batch_nos(kwargs): stock_ledgers_batches = get_stock_ledgers_batches(kwargs) pos_invoice_batches = get_reserved_batches_for_pos(kwargs) sre_reserved_batches = get_reserved_batches_for_sre(kwargs) + picked_batches = frappe._dict() + if kwargs.get("is_pick_list"): + picked_batches = get_picked_batches(kwargs) - if stock_ledgers_batches or pos_invoice_batches or sre_reserved_batches: + if stock_ledgers_batches or pos_invoice_batches or sre_reserved_batches or picked_batches: update_available_batches( - available_batches, stock_ledgers_batches, pos_invoice_batches, sre_reserved_batches + available_batches, + stock_ledgers_batches, + pos_invoice_batches, + sre_reserved_batches, + picked_batches, ) if not kwargs.consider_negative_batches: @@ -1771,6 +1778,102 @@ def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> Dict[str, Dict]: return group_by_voucher +def get_picked_batches(kwargs) -> dict[str, dict]: + picked_batches = frappe._dict() + + table = frappe.qb.DocType("Serial and Batch Bundle") + child_table = frappe.qb.DocType("Serial and Batch Entry") + pick_list_table = frappe.qb.DocType("Pick List") + + query = ( + frappe.qb.from_(table) + .inner_join(child_table) + .on(table.name == child_table.parent) + .inner_join(pick_list_table) + .on(table.voucher_no == pick_list_table.name) + .select( + child_table.batch_no, + child_table.warehouse, + Sum(child_table.qty).as_("qty"), + ) + .where( + (table.docstatus != 2) + & (pick_list_table.status != "Completed") + & (table.type_of_transaction == "Outward") + & (table.is_cancelled == 0) + & (table.voucher_type == "Pick List") + & (table.voucher_no.isnotnull()) + ) + ) + + if kwargs.get("item_code"): + query = query.where(table.item_code == kwargs.get("item_code")) + + if kwargs.get("warehouse"): + if isinstance(kwargs.warehouse, list): + query = query.where(table.warehouse.isin(kwargs.warehouse)) + else: + query = query.where(table.warehouse == kwargs.get("warehouse")) + + data = query.run(as_dict=True) + for row in data: + if not row.qty: + continue + + key = (row.batch_no, row.warehouse) + if key not in picked_batches: + picked_batches[key] = frappe._dict( + { + "qty": row.qty, + "warehouse": row.warehouse, + } + ) + else: + picked_batches[key].qty += row.qty + + return picked_batches + + +def get_picked_serial_nos(item_code, warehouse=None) -> list[str]: + table = frappe.qb.DocType("Serial and Batch Bundle") + child_table = frappe.qb.DocType("Serial and Batch Entry") + pick_list_table = frappe.qb.DocType("Pick List") + + query = ( + frappe.qb.from_(table) + .inner_join(child_table) + .on(table.name == child_table.parent) + .inner_join(pick_list_table) + .on(table.voucher_no == pick_list_table.name) + .select( + child_table.serial_no, + ) + .where( + (table.docstatus != 2) + & (pick_list_table.status != "Completed") + & (table.type_of_transaction == "Outward") + & (table.is_cancelled == 0) + & (table.voucher_type == "Pick List") + & (table.voucher_no.isnotnull()) + ) + ) + + if item_code: + query = query.where(table.item_code == item_code) + + if warehouse: + if isinstance(warehouse, list): + query = query.where(table.warehouse.isin(warehouse)) + else: + query = query.where(table.warehouse == warehouse) + + data = query.run(as_dict=True) + if not data: + return [] + + return [row.serial_no for row in data if row.serial_no] + + def get_ledgers_from_serial_batch_bundle(**kwargs) -> List[frappe._dict]: bundle_table = frappe.qb.DocType("Serial and Batch Bundle") serial_batch_table = frappe.qb.DocType("Serial and Batch Entry")