[enhance] automatic batch selection in Delivery Note and Stock Entry
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 703fe06..3649cc1 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -177,17 +177,18 @@
stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle)
return stock_ledger
- def make_batches(self):
+ def make_batches(self, warehouse_field):
'''Create batches if required. Called before submit'''
for d in self.items:
- has_batch_no, create_new_batch = frappe.db.get_value('Item', d.item_code, ['has_batch_no', 'create_new_batch'])
- if has_batch_no and not d.batch_no and create_new_batch:
- d.batch_no = frappe.get_doc(dict(
- doctype='Batch',
- item=d.item_code,
- supplier=getattr(self, 'supplier', None),
- reference_doctype=self.doctype,
- reference_name=self.name)).insert().name
+ if d.get(warehouse_field) and not d.batch_no:
+ has_batch_no, create_new_batch = frappe.db.get_value('Item', d.item_code, ['has_batch_no', 'create_new_batch'])
+ if has_batch_no and create_new_batch:
+ d.batch_no = frappe.get_doc(dict(
+ doctype='Batch',
+ item=d.item_code,
+ supplier=getattr(self, 'supplier', None),
+ reference_doctype=self.doctype,
+ reference_name=self.name)).insert().name
def make_adjustment_entry(self, expected_gle, voucher_obj):
from erpnext.accounts.utils import get_stock_and_account_difference
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 193aceb..8ef8e91 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -6,6 +6,8 @@
from frappe import _
from frappe.model.document import Document
+class UnableToSelectBatchError(frappe.ValidationError): pass
+
class Batch(Document):
def autoname(self):
'''Generate random ID for batch if not specified'''
@@ -34,8 +36,15 @@
frappe.throw(_("The selected item cannot have Batch"))
@frappe.whitelist()
-def get_batch_qty(batch_no, warehouse=None):
- '''Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None'''
+def get_batch_qty(batch_no=None, warehouse=None, item_code=None):
+ '''Returns batch actual qty if warehouse is passed,
+ or returns dict of qty by warehouse if warehouse is None
+
+ The user must pass either batch_no or batch_no + warehouse or item_code + warehouse
+
+ :param batch_no: Optional - give qty for this batch no
+ :param warehouse: Optional - give qty for this warehouse
+ :param item_code: Optional - give qty for this item'''
frappe.has_permission('Batch', throw=True)
out = 0
if batch_no and warehouse:
@@ -48,6 +57,11 @@
from `tabStock Ledger Entry`
where batch_no=%s
group by warehouse''', batch_no, as_dict=1)
+ if not batch_no and item_code and warehouse:
+ out = frappe.db.sql('''select batch_no, sum(actual_qty) as qty
+ from `tabStock Ledger Entry`
+ where item_code = %s and warehouse=%s
+ group by batch_no''', (item_code, warehouse), as_dict=1)
return out
@frappe.whitelist()
@@ -76,3 +90,30 @@
stock_entry.submit()
return batch.name
+
+def set_batch_nos(doc, warehouse_field, throw = False):
+ '''Automatically select `batch_no` for outgoing items in item table'''
+ for d in doc.items:
+ has_batch_no = frappe.db.get_value('Item', d.item_code, 'has_batch_no')
+ warehouse = d.get(warehouse_field, None)
+ if has_batch_no and not d.batch_no and warehouse:
+ d.batch_no = get_batch_no(d.item_code, warehouse, d.qty, throw)
+
+def get_batch_no(item_code, warehouse, qty, throw=False):
+ '''get the smallest batch with for the given item_code, warehouse and qty'''
+ batches = sorted(
+ get_batch_qty(item_code = item_code, warehouse = warehouse),
+ lambda a, b: 1 if a.qty > b.qty else -1)
+
+ batch_no = None
+ for b in batches:
+ if b.qty >= qty:
+ batch_no = b.batch_no
+ # found!
+ break
+
+ if not batch_no:
+ frappe.msgprint(_('Please select a Batch for Item {0}. Unable to find a single batch that fulfills this requirement').format(frappe.bold(item_code)))
+ if throw: raise UnableToSelectBatchError
+
+ return batch_no
\ No newline at end of file
diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py
index 29023bb..e63e949 100644
--- a/erpnext/stock/doctype/batch/test_batch.py
+++ b/erpnext/stock/doctype/batch/test_batch.py
@@ -6,7 +6,7 @@
from frappe.exceptions import ValidationError
import unittest
-from erpnext.stock.doctype.batch.batch import get_batch_qty
+from erpnext.stock.doctype.batch.batch import get_batch_qty, UnableToSelectBatchError
class TestBatch(unittest.TestCase):
def test_item_has_batch_enabled(self):
@@ -21,7 +21,7 @@
if not frappe.db.exists('ITEM-BATCH-1'):
make_item('ITEM-BATCH-1', dict(has_batch_no = 1, create_new_batch = 1))
- def test_purchase_receipt(self):
+ def test_purchase_receipt(self, batch_qty = 100):
'''Test automated batch creation from Purchase Receipt'''
self.make_batch_item()
@@ -31,7 +31,7 @@
items = [
dict(
item_code = 'ITEM-BATCH-1',
- qty = 100,
+ qty = batch_qty,
rate = 10
)
]
@@ -39,11 +39,12 @@
receipt.submit()
self.assertTrue(receipt.items[0].batch_no)
- self.assertEquals(get_batch_qty(receipt.items[0].batch_no, receipt.items[0].warehouse), 100)
+ self.assertEquals(get_batch_qty(receipt.items[0].batch_no,
+ receipt.items[0].warehouse), batch_qty)
return receipt
- def test_stock_entry(self):
+ def test_stock_entry_incoming(self):
'''Test batch creation via Stock Entry (Production Order)'''
self.make_batch_item()
@@ -67,6 +68,78 @@
self.assertTrue(stock_entry.items[0].batch_no)
self.assertEquals(get_batch_qty(stock_entry.items[0].batch_no, stock_entry.items[0].t_warehouse), 90)
+ def test_delivery_note(self):
+ '''Test automatic batch selection for outgoing items'''
+ batch_qty = 15
+ receipt = self.test_purchase_receipt(batch_qty)
+
+ delivery_note = frappe.get_doc(dict(
+ doctype = 'Delivery Note',
+ customer = '_Test Customer',
+ company = receipt.company,
+ items = [
+ dict(
+ item_code = 'ITEM-BATCH-1',
+ qty = batch_qty,
+ rate = 10,
+ warehouse = receipt.items[0].warehouse
+ )
+ ]
+ )).insert()
+ delivery_note.submit()
+
+ # shipped with same batch
+ self.assertEquals(delivery_note.items[0].batch_no, receipt.items[0].batch_no)
+
+ # balance is 0
+ self.assertEquals(get_batch_qty(receipt.items[0].batch_no,
+ receipt.items[0].warehouse), 0)
+
+ def test_delivery_note_fail(self):
+ '''Test automatic batch selection for outgoing items'''
+ receipt = self.test_purchase_receipt(100)
+ delivery_note = frappe.get_doc(dict(
+ doctype = 'Delivery Note',
+ customer = '_Test Customer',
+ company = receipt.company,
+ items = [
+ dict(
+ item_code = 'ITEM-BATCH-1',
+ qty = 5000,
+ rate = 10,
+ warehouse = receipt.items[0].warehouse
+ )
+ ]
+ ))
+ self.assertRaises(UnableToSelectBatchError, delivery_note.insert)
+
+ def test_stock_entry_outgoing(self):
+ '''Test automatic batch selection for outgoing stock entry'''
+
+ batch_qty = 16
+ receipt = self.test_purchase_receipt(batch_qty)
+
+ stock_entry = frappe.get_doc(dict(
+ doctype = 'Stock Entry',
+ purpose = 'Material Issue',
+ company = receipt.company,
+ items = [
+ dict(
+ item_code = 'ITEM-BATCH-1',
+ qty = batch_qty,
+ s_warehouse = receipt.items[0].warehouse,
+ )
+ ]
+ )).insert()
+ stock_entry.submit()
+
+ # assert same batch is selected
+ self.assertEqual(stock_entry.items[0].batch_no, receipt.items[0].batch_no)
+
+ # balance is 0
+ self.assertEquals(get_batch_qty(receipt.items[0].batch_no,
+ receipt.items[0].warehouse), 0)
+
def test_batch_split(self):
'''Test batch splitting'''
receipt = self.test_purchase_receipt()
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 5e8f5c9..a2a0115 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -11,7 +11,7 @@
from frappe.model.mapper import get_mapped_doc
from erpnext.controllers.selling_controller import SellingController
from frappe.desk.notifications import clear_doctype_notifications
-
+from erpnext.stock.doctype.batch.batch import set_batch_nos
form_grid_templates = {
"items": "templates/form_grid/item_grid.html"
@@ -106,6 +106,9 @@
self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc()
+ if self._action != 'submit':
+ set_batch_nos(self, 'warehouse', True)
+
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 5d90338..055b9c4 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -51,7 +51,7 @@
super(PurchaseReceipt, self).validate()
if self._action=="submit":
- self.make_batches()
+ self.make_batches('warehouse')
else:
self.set_status()
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index dcdd50d..169bfd9 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -9,6 +9,7 @@
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.stock_ledger import get_previous_sle, NegativeStockError
from erpnext.stock.get_item_details import get_bin_details, get_default_cost_center, get_conversion_factor
+from erpnext.stock.doctype.batch.batch import get_batch_no, set_batch_nos
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
import json
@@ -49,7 +50,9 @@
self.validate_batch()
if self._action == 'submit':
- self.make_batches()
+ self.make_batches('t_warehouse')
+ else:
+ set_batch_nos(self, 's_warehouse', True)
self.set_actual_qty()
self.calculate_rate_and_amount(update_finished_item_rate=False)
@@ -89,8 +92,10 @@
if item.item_code not in stock_items:
frappe.throw(_("{0} is not a stock Item").format(item.item_code))
- item_details = self.get_item_details(frappe._dict({"item_code": item.item_code,
- "company": self.company, "project": self.project, "uom": item.uom}), for_update=True)
+ item_details = self.get_item_details(frappe._dict(
+ {"item_code": item.item_code, "company": self.company,
+ "project": self.project, "uom": item.uom, 's_warehouse': item.s_warehouse}),
+ for_update=True)
for f in ("uom", "stock_uom", "description", "item_name", "expense_account",
"cost_center", "conversion_factor"):
@@ -465,7 +470,9 @@
def get_item_details(self, args=None, for_update=False):
item = frappe.db.sql("""select stock_uom, description, image, item_name,
- expense_account, buying_cost_center, item_group from `tabItem`
+ expense_account, buying_cost_center, item_group, has_serial_no,
+ has_batch_no
+ from `tabItem`
where name = %s
and disabled=0
and (end_of_life is null or end_of_life='0000-00-00' or end_of_life > %s)""",
@@ -475,7 +482,7 @@
item = item[0]
- ret = {
+ ret = frappe._dict({
'uom' : item.stock_uom,
'stock_uom' : item.stock_uom,
'description' : item.description,
@@ -489,8 +496,10 @@
'batch_no' : '',
'actual_qty' : 0,
'basic_rate' : 0,
- 'serial_no' : ''
- }
+ 'serial_no' : '',
+ 'has_serial_no' : item.has_serial_no,
+ 'has_batch_no' : item.has_batch_no
+ })
for d in [["Account", "expense_account", "default_expense_account"],
["Cost Center", "cost_center", "cost_center"]]:
company = frappe.db.get_value(d[0], ret.get(d[1]), "company")
@@ -510,6 +519,11 @@
stock_and_rate = args.get('warehouse') and get_warehouse_details(args) or {}
ret.update(stock_and_rate)
+ # automatically select batch for outgoing item
+ if (args.get('s_warehouse', None) and args.get('qty') and
+ ret.get('has_batch_no') and not args.get('batch_no')):
+ args.batch_no = get_batch_no(args['item_code'], args['s_warehouse'], args['qty'])
+
return ret
def get_items(self):
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index 2f7779c..43209e8 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -813,7 +813,7 @@
"collapsible": 0,
"columns": 0,
"fieldname": "serial_no",
- "fieldtype": "Text",
+ "fieldtype": "Small Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
@@ -1040,11 +1040,11 @@
"unique": 0
},
{
- "allow_on_submit": 0,
+ "allow_on_submit": 1,
"bold": 0,
"collapsible": 0,
"columns": 0,
- "fieldname": "allow_zero_valuation_rate",
+ "fieldname": "is_sample_item",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
@@ -1053,7 +1053,7 @@
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
- "label": "Allow Zero Valuation Rate",
+ "label": "Is Sample Item",
"length": 0,
"no_copy": 1,
"permlevel": 0,
@@ -1225,7 +1225,7 @@
"issingle": 0,
"istable": 1,
"max_attachments": 0,
- "modified": "2017-04-19 11:54:31.645381",
+ "modified": "2017-04-21 02:56:48.306626",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 787e4b5..a6459c5 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -9,6 +9,7 @@
from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item, set_transaction_type
from erpnext.setup.utils import get_exchange_rate
from frappe.model.meta import get_field_precision
+from erpnext.stock.doctype.batch.batch import get_batch_no
@frappe.whitelist()
def get_item_details(args):
@@ -74,7 +75,12 @@
out.update(get_pricing_rule_for_item(args))
if args.get("doctype") in ("Sales Invoice", "Delivery Note") and out.stock_qty > 0:
- out.serial_no = get_serial_no(out)
+ if out.has_serial_no:
+ out.serial_no = get_serial_no(out)
+
+ if out.has_batch_no:
+ out.batch_no = get_batch_no(out.item_code, out.warehouse, out.qty)
+
if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date,
@@ -154,6 +160,8 @@
"income_account": get_default_income_account(args, item),
"expense_account": get_default_expense_account(args, item),
"cost_center": get_default_cost_center(args, item),
+ 'has_serial_no': item.has_serial_no,
+ 'has_batch_no': item.has_batch_no,
"batch_no": None,
"item_tax_rate": json.dumps(dict(([d.tax_type, d.tax_rate] for d in
item.get("taxes")))),