From 551406ab11c5ce2e48e2d33ca6e187d6173b79bc Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Fri, 21 Apr 2017 12:40:19 +0530 Subject: [PATCH] [enhance] automatic batch selection in Delivery Note and Stock Entry --- erpnext/controllers/stock_controller.py | 19 +++-- erpnext/stock/doctype/batch/batch.py | 45 +++++++++- erpnext/stock/doctype/batch/test_batch.py | 83 +++++++++++++++++-- .../doctype/delivery_note/delivery_note.py | 5 +- .../purchase_receipt/purchase_receipt.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 28 +++++-- .../stock_entry_detail.json | 10 +-- erpnext/stock/get_item_details.py | 10 ++- 8 files changed, 171 insertions(+), 31 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 703fe06db2..3649cc119d 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -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 diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 193acebfeb..8ef8e915ee 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -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 \ 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 29023bb2a9..e63e949bde 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -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() diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 5e8f5c9b46..a2a0115c1b 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -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) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 5d90338566..055b9c47f9 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -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() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index dcdd50d22e..169bfd9121 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -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): 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 2f7779c132..43209e82e3 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 787e4b580e..a6459c53ec 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -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")))),