diff --git a/erpnext/hooks.py b/erpnext/hooks.py index f9ac629922e..6ef3c43b711 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -42,7 +42,7 @@ doc_events = { scheduler_events = { "daily": [ "erpnext.controllers.recurring_document.create_recurring_documents", - "erpnext.stock.utils.reorder_item", + "erpnext.stock.reorder_item.reorder_item", "erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.support.doctype.support_ticket.support_ticket.auto_close_tickets" ], diff --git a/erpnext/manufacturing/doctype/production_order/test_production_order.py b/erpnext/manufacturing/doctype/production_order/test_production_order.py index 8a0dbe1e65a..96fe6e4dbcb 100644 --- a/erpnext/manufacturing/doctype/production_order/test_production_order.py +++ b/erpnext/manufacturing/doctype/production_order/test_production_order.py @@ -20,8 +20,10 @@ class TestProductionOrder(unittest.TestCase): pro_doc.submit() # add raw materials to stores - test_stock_entry.make_stock_entry("_Test Item", None, "Stores - _TC", 100, 100) - test_stock_entry.make_stock_entry("_Test Item Home Desktop 100", None, "Stores - _TC", 100, 100) + test_stock_entry.make_stock_entry(item_code="_Test Item", + target="Stores - _TC", qty=100, incoming_rate=100) + test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="Stores - _TC", qty=100, incoming_rate=100) # from stores to wip s = frappe.get_doc(make_stock_entry(pro_doc.name, "Material Transfer", 4)) @@ -46,8 +48,10 @@ class TestProductionOrder(unittest.TestCase): from erpnext.manufacturing.doctype.production_order.production_order import StockOverProductionError pro_doc = self.test_planned_qty() - test_stock_entry.make_stock_entry("_Test Item", None, "_Test Warehouse - _TC", 100, 100) - test_stock_entry.make_stock_entry("_Test Item Home Desktop 100", None, "_Test Warehouse - _TC", 100, 100) + test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=100, incoming_rate=100) + test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=100, incoming_rate=100) s = frappe.get_doc(make_stock_entry(pro_doc.name, "Manufacture", 7)) s.insert() diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index eb0d7c9daa8..7f5271e7b69 100644 --- a/erpnext/stock/doctype/item/test_records.json +++ b/erpnext/stock/doctype/item/test_records.json @@ -9,7 +9,7 @@ "income_account": "Sales - _TC", "inspection_required": "No", "is_asset_item": "No", - "is_pro_applicable": "Yes", + "is_pro_applicable": "No", "is_purchase_item": "Yes", "is_sales_item": "Yes", "is_service_item": "No", @@ -20,9 +20,7 @@ "item_name": "_Test Item", "item_reorder": [ { - "doctype": "Item Reorder", "material_request_type": "Purchase", - "parentfield": "item_reorder", "warehouse": "_Test Warehouse - _TC", "warehouse_reorder_level": 20, "warehouse_reorder_qty": 20 @@ -105,7 +103,7 @@ "item_code": "_Test Item Home Desktop 200", "item_group": "_Test Item Group Desktops", "item_name": "_Test Item Home Desktop 200", - "stock_uom": "_Test UOM" + "stock_uom": "_Test UOM 1" }, { "description": "_Test Sales BOM Item 5", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 5562e5a141d..bf7b2ebd77c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -230,7 +230,7 @@ class StockEntry(StockController): "posting_date": self.posting_date, "posting_time": self.posting_time, "qty": d.s_warehouse and -1*d.transfer_qty or d.transfer_qty, - "serial_no": d.serial_no + "serial_no": d.serial_no, }) # get actual stock at source warehouse @@ -243,7 +243,7 @@ class StockEntry(StockController): self.posting_date, self.posting_time, d.actual_qty, d.transfer_qty)) # get incoming rate - if not d.bom_no: + if not d.t_warehouse: if not flt(d.incoming_rate) or d.s_warehouse or self.purpose == "Sales Return" or force: incoming_rate = flt(self.get_incoming_rate(args), self.precision("incoming_rate", d)) if incoming_rate > 0: @@ -253,7 +253,7 @@ class StockEntry(StockController): raw_material_cost += flt(d.amount) # set incoming rate for fg item - if self.purpose in ["Manufacture", "Repack"]: + if self.purpose in ("Manufacture", "Repack"): number_of_fg_items = len([t.t_warehouse for t in self.get("mtn_details") if t.t_warehouse]) for d in self.get("mtn_details"): if d.bom_no or (d.t_warehouse and number_of_fg_items == 1): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 6d8dc8be129..95e4a894a1e 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -10,7 +10,6 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_per from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError class TestStockEntry(unittest.TestCase): - def tearDown(self): frappe.set_user("Administrator") set_perpetual_inventory(0) @@ -18,60 +17,34 @@ class TestStockEntry(unittest.TestCase): frappe.db.set_default("company", self.old_default_company) def test_auto_material_request(self): - frappe.db.sql("""delete from `tabMaterial Request Item`""") - frappe.db.sql("""delete from `tabMaterial Request`""") - self._clear_stock_account_balance() - - frappe.db.set_value("Stock Settings", None, "auto_indent", 1) - - st1 = frappe.copy_doc(test_records[0]) - st1.insert() - st1.submit() - st2 = frappe.copy_doc(test_records[1]) - st2.insert() - st2.submit() - - from erpnext.stock.utils import reorder_item - reorder_item() - - mr_name = frappe.db.sql("""select parent from `tabMaterial Request Item` - where item_code='_Test Item'""") - - frappe.db.set_value("Stock Settings", None, "auto_indent", 0) - - self.assertTrue(mr_name) + self._test_auto_material_request("_Test Item") def test_auto_material_request_for_variant(self): - item_code = "_Test Variant Item-S" + self._test_auto_material_request("_Test Variant Item-S") + + def _test_auto_material_request(self, item_code): item = frappe.get_doc("Item", item_code) - template = frappe.get_doc("Item", item.variant_of) + + if item.variant_of: + template = frappe.get_doc("Item", item.variant_of) + else: + template = item warehouse = "_Test Warehouse - _TC" # stock entry reqd for auto-reorder - se = frappe.new_doc("Stock Entry") - se.purpose = "Material Receipt" - se.company = "_Test Company" - se.append("mtn_details", { - "item_code": item_code, - "t_warehouse": "_Test Warehouse - _TC", - "qty": 1, - "incoming_rate": 1 - }) - se.insert() - se.submit() + make_stock_entry(item_code=item_code, target="_Test Warehouse 1 - _TC", qty=1) frappe.db.set_value("Stock Settings", None, "auto_indent", 1) projected_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "projected_qty") or 0 - # update re-level qty so that it is more than projected_qty if projected_qty > template.item_reorder[0].warehouse_reorder_level: template.item_reorder[0].warehouse_reorder_level += projected_qty template.save() - from erpnext.stock.utils import reorder_item + from erpnext.stock.reorder_item import reorder_item mr_list = reorder_item() frappe.db.set_value("Stock Settings", None, "auto_indent", 0) @@ -897,6 +870,7 @@ class TestStockEntry(unittest.TestCase): "total_fixed_cost": 1000 }) stock_entry.get_items() + fg_rate = [d.amount for d in stock_entry.get("mtn_details") if d.item_code=="_Test FG Item 2"][0] self.assertEqual(fg_rate, 1200.00) fg_rate = [d.amount for d in stock_entry.get("mtn_details") if d.item_code=="_Test Item"][0] @@ -939,21 +913,27 @@ def make_serialized_item(item_code=None, serial_no=None, target_warehouse=None): se.submit() return se -def make_stock_entry(item, source, target, qty, incoming_rate=None): +def make_stock_entry(**args): s = frappe.new_doc("Stock Entry") - if source and target: - s.purpose = "Material Transfer" - elif source: - s.purpose = "Material Issue" - else: - s.purpose = "Material Receipt" - s.company = "_Test Company" + args = frappe._dict(args) + if args.posting_date: + s.posting_date = args.posting_date + if args.posting_time: + s.posting_time = args.posting_time + if not args.purpose: + if args.source and args.target: + s.purpose = "Material Transfer" + elif args.source: + s.purpose = "Material Issue" + else: + s.purpose = "Material Receipt" + s.company = args.company or "_Test Company" s.append("mtn_details", { - "item_code": item, - "s_warehouse": source, - "t_warehouse": target, - "qty": qty, - "incoming_rate": incoming_rate, + "item_code": args.item, + "s_warehouse": args.from_warehouse or args.source, + "t_warehouse": args.to_warehouse or args.target, + "qty": args.qty, + "incoming_rate": args.incoming_rate, "conversion_factor": 1.0 }) s.insert() diff --git a/erpnext/stock/doctype/stock_uom_replace_utility/stock_uom_replace_utility.py b/erpnext/stock/doctype/stock_uom_replace_utility/stock_uom_replace_utility.py index a14c21e865d..864a2c66957 100644 --- a/erpnext/stock/doctype/stock_uom_replace_utility/stock_uom_replace_utility.py +++ b/erpnext/stock/doctype/stock_uom_replace_utility/stock_uom_replace_utility.py @@ -33,7 +33,7 @@ class StockUOMReplaceUtility(Document): item_doc.stock_uom = self.new_stock_uom item_doc.save() - frappe.msgprint(_("Stock UOM updatd for Item {0}").format(self.item_code)) + frappe.msgprint(_("Stock UOM updated for Item {0}").format(self.item_code)) def update_bin(self): # update bin diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py new file mode 100644 index 00000000000..3afbb4b6dc0 --- /dev/null +++ b/erpnext/stock/reorder_item.py @@ -0,0 +1,196 @@ +# Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt, cstr, nowdate, add_days, cint +from erpnext.accounts.utils import get_fiscal_year, FiscalYearError + +def reorder_item(): + """ Reorder item if stock reaches reorder level""" + # if initial setup not completed, return + if not frappe.db.sql("select name from `tabFiscal Year` limit 1"): + return + + if getattr(frappe.local, "auto_indent", None) is None: + frappe.local.auto_indent = cint(frappe.db.get_value('Stock Settings', None, 'auto_indent')) + + if frappe.local.auto_indent: + return _reorder_item() + +def _reorder_item(): + material_requests = {"Purchase": {}, "Transfer": {}} + + item_warehouse_projected_qty = get_item_warehouse_projected_qty() + + warehouse_company = frappe._dict(frappe.db.sql("""select name, company + from `tabWarehouse`""")) + default_company = (frappe.defaults.get_defaults().get("company") or + frappe.db.sql("""select name from tabCompany limit 1""")[0][0]) + + def add_to_material_request(item_code, warehouse, reorder_level, reorder_qty, material_request_type): + if warehouse not in item_warehouse_projected_qty[item_code]: + # likely a disabled warehouse or a warehouse where BIN does not exist + return + + reorder_level = flt(reorder_level) + reorder_qty = flt(reorder_qty) + projected_qty = item_warehouse_projected_qty[item_code][warehouse] + + if reorder_level and projected_qty < reorder_level: + deficiency = reorder_level - projected_qty + if deficiency > reorder_qty: + reorder_qty = deficiency + + company = warehouse_company.get(warehouse) or default_company + + material_requests[material_request_type].setdefault(company, []).append({ + "item_code": item_code, + "warehouse": warehouse, + "reorder_qty": reorder_qty + }) + + for item_code in item_warehouse_projected_qty: + item = frappe.get_doc("Item", item_code) + + if item.variant_of and not item.get("item_reorder"): + item.update_template_tables() + + if item.get("item_reorder"): + for d in item.get("item_reorder"): + add_to_material_request(item_code, d.warehouse, d.warehouse_reorder_level, + d.warehouse_reorder_qty, d.material_request_type) + + else: + # raise for default warehouse + add_to_material_request(item_code, item.default_warehouse, item.re_order_level, item.re_order_qty, "Purchase") + + if material_requests: + return create_material_request(material_requests) + +def get_item_warehouse_projected_qty(): + item_warehouse_projected_qty = {} + + for item_code, warehouse, projected_qty in frappe.db.sql("""select item_code, warehouse, projected_qty + from tabBin where ifnull(item_code, '') != '' and ifnull(warehouse, '') != '' + and exists (select name from `tabItem` + where `tabItem`.name = `tabBin`.item_code and + is_stock_item='Yes' and (is_purchase_item='Yes' or is_sub_contracted_item='Yes') and + (ifnull(end_of_life, '0000-00-00')='0000-00-00' or end_of_life > %s)) + and exists (select name from `tabWarehouse` + where `tabWarehouse`.name = `tabBin`.warehouse + and ifnull(disabled, 0)=0)""", nowdate()): + + item_warehouse_projected_qty.setdefault(item_code, {})[warehouse] = flt(projected_qty) + + return item_warehouse_projected_qty + +def create_material_request(material_requests): + """ Create indent on reaching reorder level """ + mr_list = [] + defaults = frappe.defaults.get_defaults() + exceptions_list = [] + + def _log_exception(): + if frappe.local.message_log: + exceptions_list.extend(frappe.local.message_log) + frappe.local.message_log = [] + else: + exceptions_list.append(frappe.get_traceback()) + + try: + current_fiscal_year = get_fiscal_year(nowdate())[0] or defaults.fiscal_year + + except FiscalYearError: + _log_exception() + notify_errors(exceptions_list) + return + + for request_type in material_requests: + for company in material_requests[request_type]: + try: + items = material_requests[request_type][company] + if not items: + continue + + mr = frappe.new_doc("Material Request") + mr.update({ + "company": company, + "fiscal_year": current_fiscal_year, + "transaction_date": nowdate(), + "material_request_type": request_type + }) + + for d in items: + d = frappe._dict(d) + item = frappe.get_doc("Item", d.item_code) + mr.append("indent_details", { + "doctype": "Material Request Item", + "item_code": d.item_code, + "schedule_date": add_days(nowdate(),cint(item.lead_time_days)), + "uom": item.stock_uom, + "warehouse": d.warehouse, + "item_name": item.item_name, + "description": item.description, + "item_group": item.item_group, + "qty": d.reorder_qty, + "brand": item.brand, + }) + + mr.insert() + mr.submit() + mr_list.append(mr) + + except: + _log_exception() + + if mr_list: + if getattr(frappe.local, "reorder_email_notify", None) is None: + frappe.local.reorder_email_notify = cint(frappe.db.get_value('Stock Settings', None, + 'reorder_email_notify')) + + if(frappe.local.reorder_email_notify): + send_email_notification(mr_list) + + if exceptions_list: + notify_errors(exceptions_list) + + return mr_list + +def send_email_notification(mr_list): + """ Notify user about auto creation of indent""" + + email_list = frappe.db.sql_list("""select distinct r.parent + from tabUserRole r, tabUser p + where p.name = r.parent and p.enabled = 1 and p.docstatus < 2 + and r.role in ('Purchase Manager','Material Manager') + and p.name not in ('Administrator', 'All', 'Guest')""") + + msg="""
" + mr.name + """
| Item Code | Warehouse | Qty | UOM |
|---|---|---|---|
| " + item.item_code + " | " + item.warehouse + " | " + \ + cstr(item.qty) + " | " + cstr(item.uom) + " |
+%s ++--- +Regards, +Administrator""" % ("\n\n".join(exceptions_list),) + + from frappe.email import sendmail_to_system_managers + sendmail_to_system_managers(subject, content) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 0a4be40db21..092f58056f7 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -4,24 +4,30 @@ import frappe from frappe import _ import json -from frappe.utils import flt, cstr, nowdate, add_days, cint +from frappe.utils import flt, cstr, nowdate, nowtime from frappe.defaults import get_global_default -from erpnext.accounts.utils import get_fiscal_year, FiscalYearError class InvalidWarehouseCompany(frappe.ValidationError): pass -def get_stock_balance_on(warehouse, posting_date=None): +def get_stock_value_on(warehouse=None, posting_date=None, item_code=None): if not posting_date: posting_date = nowdate() + values, condition = [posting_date], "" + + if warehouse: + values.append(warehouse) + condition += " AND warehouse = %s" + + if item_code: + values.append(item_code) + condition.append(" AND item_code = %s") + stock_ledger_entries = frappe.db.sql(""" - SELECT - item_code, stock_value - FROM - `tabStock Ledger Entry` - WHERE - warehouse=%s AND posting_date <= %s + SELECT item_code, stock_value + FROM `tabStock Ledger Entry` + WHERE posting_date <= %s {0} ORDER BY timestamp(posting_date, posting_time) DESC, name DESC - """, (warehouse, posting_date), as_dict=1) + """.format(condition), values, as_dict=1) sle_map = {} for sle in stock_ledger_entries: @@ -29,6 +35,20 @@ def get_stock_balance_on(warehouse, posting_date=None): return sum(sle_map.values()) +def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None): + if not posting_date: posting_date = nowdate() + if not posting_time: posting_time = nowtime() + last_entry = frappe.db.sql("""select qty_after_transaction from `tabStock Ledger Entry` + where item_code=%s and warehouse=%s + and timestamp(posting_date, posting_time) < timestamp(%s, %s) + order by timestamp(posting_date, posting_time) limit 1""", + (item_code, warehouse, posting_date, posting_time)) + + if last_entry: + return last_entry[0][0] + else: + return 0.0 + def get_latest_stock_balance(): bin_map = {} for d in frappe.db.sql("""SELECT item_code, warehouse, stock_value as stock_value @@ -181,192 +201,3 @@ def get_buying_amount(item_code, item_qty, voucher_type, voucher_no, item_row, s return 0.0 - -def reorder_item(): - """ Reorder item if stock reaches reorder level""" - # if initial setup not completed, return - if not frappe.db.sql("select name from `tabFiscal Year` limit 1"): - return - - if getattr(frappe.local, "auto_indent", None) is None: - frappe.local.auto_indent = cint(frappe.db.get_value('Stock Settings', None, 'auto_indent')) - - if frappe.local.auto_indent: - return _reorder_item() - -def _reorder_item(): - material_requests = {"Purchase": {}, "Transfer": {}} - - item_warehouse_projected_qty = get_item_warehouse_projected_qty() - - warehouse_company = frappe._dict(frappe.db.sql("""select name, company - from `tabWarehouse`""")) - default_company = (frappe.defaults.get_defaults().get("company") or - frappe.db.sql("""select name from tabCompany limit 1""")[0][0]) - - def add_to_material_request(item_code, warehouse, reorder_level, reorder_qty, material_request_type): - if warehouse not in item_warehouse_projected_qty[item_code]: - # likely a disabled warehouse or a warehouse where BIN does not exist - return - - reorder_level = flt(reorder_level) - reorder_qty = flt(reorder_qty) - projected_qty = item_warehouse_projected_qty[item_code][warehouse] - - if reorder_level and projected_qty < reorder_level: - deficiency = reorder_level - projected_qty - if deficiency > reorder_qty: - reorder_qty = deficiency - - company = warehouse_company.get(warehouse) or default_company - - material_requests[material_request_type].setdefault(company, []).append({ - "item_code": item_code, - "warehouse": warehouse, - "reorder_qty": reorder_qty - }) - - for item_code in item_warehouse_projected_qty: - item = frappe.get_doc("Item", item_code) - - if item.variant_of and not item.get("item_reorder"): - item.update_template_tables() - - if item.get("item_reorder"): - for d in item.get("item_reorder"): - add_to_material_request(item_code, d.warehouse, d.warehouse_reorder_level, - d.warehouse_reorder_qty, d.material_request_type) - - else: - # raise for default warehouse - add_to_material_request(item_code, item.default_warehouse, item.re_order_level, item.re_order_qty, "Purchase") - - if material_requests: - return create_material_request(material_requests) - -def get_item_warehouse_projected_qty(): - item_warehouse_projected_qty = {} - - for item_code, warehouse, projected_qty in frappe.db.sql("""select item_code, warehouse, projected_qty - from tabBin where ifnull(item_code, '') != '' and ifnull(warehouse, '') != '' - and exists (select name from `tabItem` - where `tabItem`.name = `tabBin`.item_code and - is_stock_item='Yes' and (is_purchase_item='Yes' or is_sub_contracted_item='Yes') and - (ifnull(end_of_life, '0000-00-00')='0000-00-00' or end_of_life > %s)) - and exists (select name from `tabWarehouse` - where `tabWarehouse`.name = `tabBin`.warehouse - and ifnull(disabled, 0)=0)""", nowdate()): - - item_warehouse_projected_qty.setdefault(item_code, {})[warehouse] = flt(projected_qty) - - return item_warehouse_projected_qty - -def create_material_request(material_requests): - """ Create indent on reaching reorder level """ - mr_list = [] - defaults = frappe.defaults.get_defaults() - exceptions_list = [] - - def _log_exception(): - if frappe.local.message_log: - exceptions_list.extend(frappe.local.message_log) - frappe.local.message_log = [] - else: - exceptions_list.append(frappe.get_traceback()) - - try: - current_fiscal_year = get_fiscal_year(nowdate())[0] or defaults.fiscal_year - - except FiscalYearError: - _log_exception() - notify_errors(exceptions_list) - return - - for request_type in material_requests: - for company in material_requests[request_type]: - try: - items = material_requests[request_type][company] - if not items: - continue - - mr = frappe.new_doc("Material Request") - mr.update({ - "company": company, - "fiscal_year": current_fiscal_year, - "transaction_date": nowdate(), - "material_request_type": request_type - }) - - for d in items: - d = frappe._dict(d) - item = frappe.get_doc("Item", d.item_code) - mr.append("indent_details", { - "doctype": "Material Request Item", - "item_code": d.item_code, - "schedule_date": add_days(nowdate(),cint(item.lead_time_days)), - "uom": item.stock_uom, - "warehouse": d.warehouse, - "item_name": item.item_name, - "description": item.description, - "item_group": item.item_group, - "qty": d.reorder_qty, - "brand": item.brand, - }) - - mr.insert() - mr.submit() - mr_list.append(mr) - - except: - _log_exception() - - if mr_list: - if getattr(frappe.local, "reorder_email_notify", None) is None: - frappe.local.reorder_email_notify = cint(frappe.db.get_value('Stock Settings', None, - 'reorder_email_notify')) - - if(frappe.local.reorder_email_notify): - send_email_notification(mr_list) - - if exceptions_list: - notify_errors(exceptions_list) - - return mr_list - -def send_email_notification(mr_list): - """ Notify user about auto creation of indent""" - - email_list = frappe.db.sql_list("""select distinct r.parent - from tabUserRole r, tabUser p - where p.name = r.parent and p.enabled = 1 and p.docstatus < 2 - and r.role in ('Purchase Manager','Material Manager') - and p.name not in ('Administrator', 'All', 'Guest')""") - - msg="""
" + mr.name + """
| Item Code | Warehouse | Qty | UOM |
|---|---|---|---|
| " + item.item_code + " | " + item.warehouse + " | " + \ - cstr(item.qty) + " | " + cstr(item.uom) + " |
-%s ----- -Regards, -Administrator""" % ("\n\n".join(exceptions_list),) - - from frappe.email import sendmail_to_system_managers - sendmail_to_system_managers(subject, content)