[enhance] automatic batch selection in Delivery Note and Stock Entry

This commit is contained in:
Rushabh Mehta 2017-04-21 12:40:19 +05:30 committed by Nabin Hait
parent e385b5b97b
commit 551406ab11
8 changed files with 171 additions and 31 deletions

View File

@ -177,17 +177,18 @@ class StockController(AccountsController):
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

View File

@ -6,6 +6,8 @@ import frappe
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 @@ class Batch(Document):
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 @@ def get_batch_qty(batch_no, warehouse=None):
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 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id = None):
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

View File

@ -6,7 +6,7 @@ import frappe
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 @@ class TestBatch(unittest.TestCase):
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 @@ class TestBatch(unittest.TestCase):
items = [
dict(
item_code = 'ITEM-BATCH-1',
qty = 100,
qty = batch_qty,
rate = 10
)
]
@ -39,11 +39,12 @@ class TestBatch(unittest.TestCase):
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 @@ class TestBatch(unittest.TestCase):
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()

View File

@ -11,7 +11,7 @@ import frappe.defaults
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 @@ class DeliveryNote(SellingController):
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)

View File

@ -51,7 +51,7 @@ class PurchaseReceipt(BuyingController):
super(PurchaseReceipt, self).validate()
if self._action=="submit":
self.make_batches()
self.make_batches('warehouse')
else:
self.set_status()

View File

@ -9,6 +9,7 @@ from frappe.utils import cstr, cint, flt, comma_or, getdate, nowdate, formatdate
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
item = item[0]
ret = {
ret = frappe._dict({
'uom' : item.stock_uom,
'stock_uom' : item.stock_uom,
'description' : item.description,
@ -489,8 +496,10 @@ class StockEntry(StockController):
'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 @@ class StockEntry(StockController):
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):

View File

@ -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",

View File

@ -9,6 +9,7 @@ import json
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 @@ def get_item_details(args):
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 @@ def get_basic_details(args, item):
"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")))),