diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 97d34e0a714a..5229d87017b1 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -172,9 +172,10 @@ def validate_invalid_serial_nos(self, item): frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) def validate_stock_availablility(self): + from erpnext.stock.stock_ledger import is_negative_stock_allowed + if self.is_return or self.docstatus != 1: return - allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') for d in self.get('items'): is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item')) if is_service_item: @@ -186,7 +187,7 @@ def validate_stock_availablility(self): elif d.batch_no: self.validate_pos_reserved_batch_qty(d) else: - if allow_negative_stock: + if is_negative_stock_allowed(item_code=d.item_code): return available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index b05f58a98261..c79718785068 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -48,6 +48,7 @@ "warranty_period", "weight_per_unit", "weight_uom", + "allow_negative_stock", "reorder_section", "reorder_levels", "unit_of_measure_conversion", @@ -907,6 +908,12 @@ "fieldname": "is_grouped_asset", "fieldtype": "Check", "label": "Create Grouped Asset" + }, + { + "default": "0", + "fieldname": "allow_negative_stock", + "fieldtype": "Check", + "label": "Allow Negative Stock" } ], "icon": "fa fa-tag", @@ -914,7 +921,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-01-18 12:57:54.273202", + "modified": "2022-02-11 08:07:46.663220", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index fc45ba99c43f..fd4df421878a 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -6,6 +6,7 @@ import frappe from frappe.test_runner import make_test_objects +from frappe.utils import add_days, today from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, @@ -608,6 +609,45 @@ def test_autoname_series(self): item.item_group = "All Item Groups" item.save() # if item code saved without item_code then series worked + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_item_wise_negative_stock(self): + """ When global settings are disabled check that item that allows + negative stock can still consume material in all known stock + transactions that consume inventory.""" + from erpnext.stock.stock_ledger import is_negative_stock_allowed + + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + self.assertTrue(is_negative_stock_allowed(item_code=item.name)) + + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_backdated_negative_stock(self): + """ same as test above but backdated entries """ + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + + # create a future entry so all new entries are backdated + make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5)) + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + + def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + typical_args = {"item_code": item_code, "warehouse": warehouse} + + create_delivery_note(**typical_args) + create_sales_invoice(update_stock=1, **typical_args) + make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue") + make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1) + # standalone return + make_purchase_receipt(is_return=True, qty=-1, **typical_args) + + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 782fcf04a5cb..9ba007a186ee 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -433,9 +433,10 @@ def check_duplicate_entry_for_work_order(self): ) def set_actual_qty(self): - allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock")) + from erpnext.stock.stock_ledger import is_negative_stock_allowed for d in self.get('items'): + allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code) previous_sle = get_previous_sle({ "item_code": d.item_code, "warehouse": d.s_warehouse or d.t_warehouse, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 41c4002e3f97..00ca81f2b421 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -3,6 +3,7 @@ import copy import json +from typing import Optional import frappe from frappe import _ @@ -268,11 +269,10 @@ def __init__(self, args, allow_zero_rate=False, allow_negative_stock=None, via_l self.verbose = verbose self.allow_zero_rate = allow_zero_rate self.via_landed_cost_voucher = via_landed_cost_voucher - self.allow_negative_stock = allow_negative_stock \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + self.item_code = args.get("item_code") + self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code) self.args = frappe._dict(args) - self.item_code = args.get("item_code") if self.args.sle_id: self.args['name'] = self.args.sle_id @@ -1049,10 +1049,7 @@ def get_datetime_limit_condition(detail): )""" def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): - allow_negative_stock = cint(allow_negative_stock) \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - - if allow_negative_stock: + if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code): return if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"): return @@ -1121,3 +1118,11 @@ def get_future_sle_with_negative_batch_qty(args): and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) limit 1 """, args, as_dict=1) + + +def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool: + if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)): + return True + if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)): + return True + return False