fix: Pick List empty table and Serial-Batch items handling (#22426)
* chore: Pick List empty table and serial-batch items handling
* fix: Remove console statement
* chore: Added tests for batched and batched-serialised item
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 3a5ef76..ee218f2 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -3,6 +3,9 @@
frappe.ui.form.on('Pick List', {
setup: (frm) => {
+ frm.set_indicator_formatter('item_code',
+ function(doc) { return (doc.stock_qty === 0) ? "red" : "green"; });
+
frm.custom_make_buttons = {
'Delivery Note': 'Delivery Note',
'Stock Entry': 'Stock Entry',
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 4b8b594..0da57b7 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -26,11 +26,12 @@
continue
if not item.serial_no:
frappe.throw(_("Row #{0}: {1} does not have any available serial numbers in {2}".format(
- frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse))))
+ frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse))),
+ title=_("Serial Nos Required"))
if len(item.serial_no.split('\n')) == item.picked_qty:
continue
frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity')
- .format(frappe.bold(item.item_code), frappe.bold(item.idx)))
+ .format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch"))
def set_item_locations(self, save=False):
items = self.aggregate_item_qty()
@@ -40,6 +41,9 @@
if self.parent_warehouse:
from_warehouses = frappe.db.get_descendants('Warehouse', self.parent_warehouse)
+ # Create replica before resetting, to handle empty table on update after submit.
+ locations_replica = self.get('locations')
+
# reset
self.delete_key('locations')
for item_doc in items:
@@ -48,7 +52,7 @@
self.item_location_map.setdefault(item_code,
get_available_item_locations(item_code, from_warehouses, self.item_count_map.get(item_code), self.company))
- locations = get_items_with_location_and_quantity(item_doc, self.item_location_map)
+ locations = get_items_with_location_and_quantity(item_doc, self.item_location_map, self.docstatus)
item_doc.idx = None
item_doc.name = None
@@ -62,6 +66,16 @@
location.update(row)
self.append('locations', location)
+ # If table is empty on update after submit, set stock_qty, picked_qty to 0 so that indicator is red
+ # and give feedback to the user. This is to avoid empty Pick Lists.
+ if not self.get('locations') and self.docstatus == 1:
+ for location in locations_replica:
+ location.stock_qty = 0
+ location.picked_qty = 0
+ self.append('locations', location)
+ frappe.msgprint(_("Please Restock Items and Update the Pick List to continue. To discontinue, cancel the Pick List."),
+ title=_("Out of Stock"), indicator="red")
+
if save:
self.save()
@@ -97,11 +111,13 @@
if not pick_list.locations:
frappe.throw(_("Add items in the Item Locations table"))
-def get_items_with_location_and_quantity(item_doc, item_location_map):
+def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus):
available_locations = item_location_map.get(item_doc.item_code)
locations = []
- remaining_stock_qty = item_doc.stock_qty
+ # if stock qty is zero on submitted entry, show positive remaining qty to recalculate in case of restock.
+ remaining_stock_qty = item_doc.qty if (docstatus == 1 and item_doc.stock_qty == 0) else item_doc.stock_qty
+
while remaining_stock_qty > 0 and available_locations:
item_location = available_locations.pop(0)
item_location = frappe._dict(item_location)
@@ -119,13 +135,11 @@
if item_location.serial_no:
serial_nos = '\n'.join(item_location.serial_no[0: cint(stock_qty)])
- auto_set_serial_no = frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo")
-
locations.append(frappe._dict({
'qty': qty,
'stock_qty': stock_qty,
'warehouse': item_location.warehouse,
- 'serial_no': serial_nos if auto_set_serial_no else item_doc.serial_no,
+ 'serial_no': serial_nos,
'batch_no': item_location.batch_no
}))
@@ -137,7 +151,7 @@
item_location.qty = qty_diff
if item_location.serial_no:
# set remaining serial numbers
- item_location.serial_no = item_location.serial_no[-qty_diff:]
+ item_location.serial_no = item_location.serial_no[-int(qty_diff):]
available_locations = [item_location] + available_locations
# update available locations for the item
@@ -146,9 +160,14 @@
def get_available_item_locations(item_code, from_warehouses, required_qty, company, ignore_validation=False):
locations = []
- if frappe.get_cached_value('Item', item_code, 'has_serial_no'):
+ has_serial_no = frappe.get_cached_value('Item', item_code, 'has_serial_no')
+ has_batch_no = frappe.get_cached_value('Item', item_code, 'has_batch_no')
+
+ if has_batch_no and has_serial_no:
+ locations = get_available_item_locations_for_serial_and_batched_item(item_code, from_warehouses, required_qty, company)
+ elif has_serial_no:
locations = get_available_item_locations_for_serialized_item(item_code, from_warehouses, required_qty, company)
- elif frappe.get_cached_value('Item', item_code, 'has_batch_no'):
+ elif has_batch_no:
locations = get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty, company)
else:
locations = get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company)
@@ -158,8 +177,9 @@
remaining_qty = required_qty - total_qty_available
if remaining_qty > 0 and not ignore_validation:
- frappe.msgprint(_('{0} units of {1} is not available.')
- .format(remaining_qty, frappe.get_desk_link('Item', item_code)))
+ frappe.msgprint(_('{0} units of Item {1} is not available.')
+ .format(remaining_qty, frappe.get_desk_link('Item', item_code)),
+ title=_("Insufficient Stock"))
return locations
@@ -226,6 +246,34 @@
return batch_locations
+def get_available_item_locations_for_serial_and_batched_item(item_code, from_warehouses, required_qty, company):
+ # Get batch nos by FIFO
+ locations = get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty, company)
+
+ filters = frappe._dict({
+ 'item_code': item_code,
+ 'company': company,
+ 'warehouse': ['!=', ''],
+ 'batch_no': ''
+ })
+
+ # Get Serial Nos by FIFO for Batch No
+ for location in locations:
+ filters.batch_no = location.batch_no
+ filters.warehouse = location.warehouse
+ location.qty = required_qty if location.qty > required_qty else location.qty # if extra qty in batch
+
+ serial_nos = frappe.get_list('Serial No',
+ fields=['name'],
+ filters=filters,
+ limit=location.qty,
+ order_by='purchase_date')
+
+ serial_nos = [sn.name for sn in serial_nos]
+ location.serial_no = serial_nos
+
+ return locations
+
def get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company):
# gets all items available in different warehouses
warehouses = [x.get('name') for x in frappe.get_list("Warehouse", {'company': company}, "name")]
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 1b9ff41..8ea7f89d 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -7,6 +7,8 @@
import unittest
test_dependencies = ['Item', 'Sales Invoice', 'Stock Entry', 'Batch']
+from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation \
import EmptyStockReconciliationItemsError
@@ -49,7 +51,7 @@
self.assertEqual(pick_list.locations[0].warehouse, '_Test Warehouse - _TC')
self.assertEqual(pick_list.locations[0].qty, 5)
- def test_pick_list_splits_row_according_to_warhouse_availability(self):
+ def test_pick_list_splits_row_according_to_warehouse_availability(self):
try:
frappe.get_doc({
'doctype': 'Stock Reconciliation',
@@ -122,7 +124,10 @@
}]
})
- stock_reconciliation.submit()
+ try:
+ stock_reconciliation.submit()
+ except EmptyStockReconciliationItemsError:
+ pass
pick_list = frappe.get_doc({
'doctype': 'Pick List',
@@ -145,6 +150,85 @@
self.assertEqual(pick_list.locations[0].qty, 5)
self.assertEqual(pick_list.locations[0].serial_no, '123450\n123451\n123452\n123453\n123454')
+ def test_pick_list_shows_batch_no_for_batched_item(self):
+ # check if oldest batch no is picked
+ item = frappe.db.exists("Item", {'item_name': 'Batched Item'})
+ if not item:
+ item = create_item("Batched Item")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.batch_number_series = "B-BATCH-.##"
+ item.save()
+ else:
+ item = frappe.get_doc("Item", {'item_name': 'Batched Item'})
+
+ pr1 = make_purchase_receipt(item_code="Batched Item", qty=1, rate=100.0)
+
+ pr1.load_from_db()
+ oldest_batch_no = pr1.items[0].batch_no
+
+ pr2 = make_purchase_receipt(item_code="Batched Item", qty=2, rate=100.0)
+
+ pick_list = frappe.get_doc({
+ 'doctype': 'Pick List',
+ 'company': '_Test Company',
+ 'purpose': 'Material Transfer',
+ 'locations': [{
+ 'item_code': 'Batched Item',
+ 'qty': 1,
+ 'stock_qty': 1,
+ 'conversion_factor': 1,
+ }]
+ })
+ pick_list.set_item_locations()
+
+ self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
+
+ pr1.cancel()
+ pr2.cancel()
+
+
+ def test_pick_list_for_batched_and_serialised_item(self):
+ # check if oldest batch no and serial nos are picked
+ item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item'})
+ if not item:
+ item = create_item("Batched and Serialised Item")
+ item.has_batch_no = 1
+ item.create_new_batch = 1
+ item.has_serial_no = 1
+ item.batch_number_series = "B-BATCH-.##"
+ item.serial_no_series = "S-.####"
+ item.save()
+ else:
+ item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item'})
+
+ pr1 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
+
+ pr1.load_from_db()
+ oldest_batch_no = pr1.items[0].batch_no
+ oldest_serial_nos = pr1.items[0].serial_no
+
+ pr2 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
+
+ pick_list = frappe.get_doc({
+ 'doctype': 'Pick List',
+ 'company': '_Test Company',
+ 'purpose': 'Material Transfer',
+ 'locations': [{
+ 'item_code': 'Batched and Serialised Item',
+ 'qty': 2,
+ 'stock_qty': 2,
+ 'conversion_factor': 1,
+ }]
+ })
+ pick_list.set_item_locations()
+
+ self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
+ self.assertEqual(pick_list.locations[0].serial_no, oldest_serial_nos)
+
+ pr1.cancel()
+ pr2.cancel()
+
def test_pick_list_for_items_from_multiple_sales_orders(self):
try:
frappe.get_doc({
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index 71fbf9a..8665986 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -180,7 +180,7 @@
],
"istable": 1,
"links": [],
- "modified": "2020-03-13 19:08:21.995986",
+ "modified": "2020-06-24 17:18:57.357120",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",