[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) stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle)
return stock_ledger return stock_ledger
def make_batches(self): def make_batches(self, warehouse_field):
'''Create batches if required. Called before submit''' '''Create batches if required. Called before submit'''
for d in self.items: 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 d.get(warehouse_field) and not d.batch_no:
if has_batch_no and not d.batch_no and create_new_batch: has_batch_no, create_new_batch = frappe.db.get_value('Item', d.item_code, ['has_batch_no', 'create_new_batch'])
d.batch_no = frappe.get_doc(dict( if has_batch_no and create_new_batch:
doctype='Batch', d.batch_no = frappe.get_doc(dict(
item=d.item_code, doctype='Batch',
supplier=getattr(self, 'supplier', None), item=d.item_code,
reference_doctype=self.doctype, supplier=getattr(self, 'supplier', None),
reference_name=self.name)).insert().name reference_doctype=self.doctype,
reference_name=self.name)).insert().name
def make_adjustment_entry(self, expected_gle, voucher_obj): def make_adjustment_entry(self, expected_gle, voucher_obj):
from erpnext.accounts.utils import get_stock_and_account_difference from erpnext.accounts.utils import get_stock_and_account_difference

View File

@ -6,6 +6,8 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
class UnableToSelectBatchError(frappe.ValidationError): pass
class Batch(Document): class Batch(Document):
def autoname(self): def autoname(self):
'''Generate random ID for batch if not specified''' '''Generate random ID for batch if not specified'''
@ -34,8 +36,15 @@ class Batch(Document):
frappe.throw(_("The selected item cannot have Batch")) frappe.throw(_("The selected item cannot have Batch"))
@frappe.whitelist() @frappe.whitelist()
def get_batch_qty(batch_no, warehouse=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''' '''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) frappe.has_permission('Batch', throw=True)
out = 0 out = 0
if batch_no and warehouse: if batch_no and warehouse:
@ -48,6 +57,11 @@ def get_batch_qty(batch_no, warehouse=None):
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where batch_no=%s where batch_no=%s
group by warehouse''', batch_no, as_dict=1) 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 return out
@frappe.whitelist() @frappe.whitelist()
@ -76,3 +90,30 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id = None):
stock_entry.submit() stock_entry.submit()
return batch.name 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 from frappe.exceptions import ValidationError
import unittest 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): class TestBatch(unittest.TestCase):
def test_item_has_batch_enabled(self): def test_item_has_batch_enabled(self):
@ -21,7 +21,7 @@ class TestBatch(unittest.TestCase):
if not frappe.db.exists('ITEM-BATCH-1'): if not frappe.db.exists('ITEM-BATCH-1'):
make_item('ITEM-BATCH-1', dict(has_batch_no = 1, create_new_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''' '''Test automated batch creation from Purchase Receipt'''
self.make_batch_item() self.make_batch_item()
@ -31,7 +31,7 @@ class TestBatch(unittest.TestCase):
items = [ items = [
dict( dict(
item_code = 'ITEM-BATCH-1', item_code = 'ITEM-BATCH-1',
qty = 100, qty = batch_qty,
rate = 10 rate = 10
) )
] ]
@ -39,11 +39,12 @@ class TestBatch(unittest.TestCase):
receipt.submit() receipt.submit()
self.assertTrue(receipt.items[0].batch_no) 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 return receipt
def test_stock_entry(self): def test_stock_entry_incoming(self):
'''Test batch creation via Stock Entry (Production Order)''' '''Test batch creation via Stock Entry (Production Order)'''
self.make_batch_item() self.make_batch_item()
@ -67,6 +68,78 @@ class TestBatch(unittest.TestCase):
self.assertTrue(stock_entry.items[0].batch_no) 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) 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): def test_batch_split(self):
'''Test batch splitting''' '''Test batch splitting'''
receipt = self.test_purchase_receipt() receipt = self.test_purchase_receipt()

View File

@ -11,7 +11,7 @@ import frappe.defaults
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from frappe.desk.notifications import clear_doctype_notifications from frappe.desk.notifications import clear_doctype_notifications
from erpnext.stock.doctype.batch.batch import set_batch_nos
form_grid_templates = { form_grid_templates = {
"items": "templates/form_grid/item_grid.html" "items": "templates/form_grid/item_grid.html"
@ -106,6 +106,9 @@ class DeliveryNote(SellingController):
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc() 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 from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self) make_packing_list(self)

View File

@ -51,7 +51,7 @@ class PurchaseReceipt(BuyingController):
super(PurchaseReceipt, self).validate() super(PurchaseReceipt, self).validate()
if self._action=="submit": if self._action=="submit":
self.make_batches() self.make_batches('warehouse')
else: else:
self.set_status() 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.utils import get_incoming_rate
from erpnext.stock.stock_ledger import get_previous_sle, NegativeStockError 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.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 from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
import json import json
@ -49,7 +50,9 @@ class StockEntry(StockController):
self.validate_batch() self.validate_batch()
if self._action == 'submit': 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.set_actual_qty()
self.calculate_rate_and_amount(update_finished_item_rate=False) 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: if item.item_code not in stock_items:
frappe.throw(_("{0} is not a stock Item").format(item.item_code)) 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, item_details = self.get_item_details(frappe._dict(
"company": self.company, "project": self.project, "uom": item.uom}), for_update=True) {"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", for f in ("uom", "stock_uom", "description", "item_name", "expense_account",
"cost_center", "conversion_factor"): "cost_center", "conversion_factor"):
@ -465,7 +470,9 @@ class StockEntry(StockController):
def get_item_details(self, args=None, for_update=False): def get_item_details(self, args=None, for_update=False):
item = frappe.db.sql("""select stock_uom, description, image, item_name, 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 where name = %s
and disabled=0 and disabled=0
and (end_of_life is null or end_of_life='0000-00-00' or end_of_life > %s)""", 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] item = item[0]
ret = { ret = frappe._dict({
'uom' : item.stock_uom, 'uom' : item.stock_uom,
'stock_uom' : item.stock_uom, 'stock_uom' : item.stock_uom,
'description' : item.description, 'description' : item.description,
@ -489,8 +496,10 @@ class StockEntry(StockController):
'batch_no' : '', 'batch_no' : '',
'actual_qty' : 0, 'actual_qty' : 0,
'basic_rate' : 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"], for d in [["Account", "expense_account", "default_expense_account"],
["Cost Center", "cost_center", "cost_center"]]: ["Cost Center", "cost_center", "cost_center"]]:
company = frappe.db.get_value(d[0], ret.get(d[1]), "company") 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 {} stock_and_rate = args.get('warehouse') and get_warehouse_details(args) or {}
ret.update(stock_and_rate) 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 return ret
def get_items(self): def get_items(self):

View File

@ -813,7 +813,7 @@
"collapsible": 0, "collapsible": 0,
"columns": 0, "columns": 0,
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Text", "fieldtype": "Small Text",
"hidden": 0, "hidden": 0,
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
@ -1040,11 +1040,11 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_on_submit": 0, "allow_on_submit": 1,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
"columns": 0, "columns": 0,
"fieldname": "allow_zero_valuation_rate", "fieldname": "is_sample_item",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0, "hidden": 0,
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
@ -1053,7 +1053,7 @@
"in_global_search": 0, "in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Allow Zero Valuation Rate", "label": "Is Sample Item",
"length": 0, "length": 0,
"no_copy": 1, "no_copy": 1,
"permlevel": 0, "permlevel": 0,
@ -1225,7 +1225,7 @@
"issingle": 0, "issingle": 0,
"istable": 1, "istable": 1,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-04-19 11:54:31.645381", "modified": "2017-04-21 02:56:48.306626",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry Detail", "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.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item, set_transaction_type
from erpnext.setup.utils import get_exchange_rate from erpnext.setup.utils import get_exchange_rate
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from erpnext.stock.doctype.batch.batch import get_batch_no
@frappe.whitelist() @frappe.whitelist()
def get_item_details(args): def get_item_details(args):
@ -74,7 +75,12 @@ def get_item_details(args):
out.update(get_pricing_rule_for_item(args)) out.update(get_pricing_rule_for_item(args))
if args.get("doctype") in ("Sales Invoice", "Delivery Note") and out.stock_qty > 0: 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: if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date, 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), "income_account": get_default_income_account(args, item),
"expense_account": get_default_expense_account(args, item), "expense_account": get_default_expense_account(args, item),
"cost_center": get_default_cost_center(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, "batch_no": None,
"item_tax_rate": json.dumps(dict(([d.tax_type, d.tax_rate] for d in "item_tax_rate": json.dumps(dict(([d.tax_type, d.tax_rate] for d in
item.get("taxes")))), item.get("taxes")))),