Merge pull request #37754 from s-aga-r/VALIDATE-RESERVED-STOCK
fix: consider reserved stock while cancelling a stock transaction
This commit is contained in:
		
						commit
						e42a3e0084
					
				| @ -347,5 +347,6 @@ execute:frappe.db.set_single_value("Payment Reconciliation", "invoice_limit", 50 | ||||
| execute:frappe.db.set_single_value("Payment Reconciliation", "payment_limit", 50) | ||||
| erpnext.patches.v15_0.rename_daily_depreciation_to_depreciation_amount_based_on_num_days_in_month | ||||
| erpnext.patches.v15_0.rename_depreciation_amount_based_on_num_days_in_month_to_daily_prorata_based | ||||
| erpnext.patches.v15_0.set_reserved_stock_in_bin | ||||
| # below migration patch should always run last | ||||
| erpnext.patches.v14_0.migrate_gl_to_payment_ledger | ||||
|  | ||||
							
								
								
									
										24
									
								
								erpnext/patches/v15_0/set_reserved_stock_in_bin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								erpnext/patches/v15_0/set_reserved_stock_in_bin.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| import frappe | ||||
| from frappe.query_builder.functions import Sum | ||||
| 
 | ||||
| 
 | ||||
| def execute(): | ||||
| 	sre = frappe.qb.DocType("Stock Reservation Entry") | ||||
| 	query = ( | ||||
| 		frappe.qb.from_(sre) | ||||
| 		.select( | ||||
| 			sre.item_code, | ||||
| 			sre.warehouse, | ||||
| 			Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_stock"), | ||||
| 		) | ||||
| 		.where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"]))) | ||||
| 		.groupby(sre.item_code, sre.warehouse) | ||||
| 	) | ||||
| 
 | ||||
| 	for d in query.run(as_dict=True): | ||||
| 		frappe.db.set_value( | ||||
| 			"Bin", | ||||
| 			{"item_code": d.item_code, "warehouse": d.warehouse}, | ||||
| 			"reserved_stock", | ||||
| 			d.reserved_stock, | ||||
| 		) | ||||
| @ -13,12 +13,13 @@ | ||||
|   "planned_qty", | ||||
|   "indented_qty", | ||||
|   "ordered_qty", | ||||
|   "projected_qty", | ||||
|   "column_break_xn5j", | ||||
|   "reserved_qty", | ||||
|   "reserved_qty_for_production", | ||||
|   "reserved_qty_for_sub_contract", | ||||
|   "reserved_qty_for_production_plan", | ||||
|   "projected_qty", | ||||
|   "reserved_stock", | ||||
|   "section_break_pmrs", | ||||
|   "stock_uom", | ||||
|   "column_break_0slj", | ||||
| @ -173,13 +174,20 @@ | ||||
|   { | ||||
|    "fieldname": "column_break_0slj", | ||||
|    "fieldtype": "Column Break" | ||||
|   }, | ||||
|   { | ||||
|    "default": "0", | ||||
|    "fieldname": "reserved_stock", | ||||
|    "fieldtype": "Float", | ||||
|    "label": "Reserved Stock", | ||||
|    "read_only": 1 | ||||
|   } | ||||
|  ], | ||||
|  "hide_toolbar": 1, | ||||
|  "idx": 1, | ||||
|  "in_create": 1, | ||||
|  "links": [], | ||||
|  "modified": "2023-11-01 15:35:51.722534", | ||||
|  "modified": "2023-11-01 16:51:17.079107", | ||||
|  "modified_by": "Administrator", | ||||
|  "module": "Stock", | ||||
|  "name": "Bin", | ||||
|  | ||||
| @ -148,6 +148,17 @@ class Bin(Document): | ||||
| 		self.set_projected_qty() | ||||
| 		self.db_set("projected_qty", self.projected_qty, update_modified=True) | ||||
| 
 | ||||
| 	def update_reserved_stock(self): | ||||
| 		"""Update `Reserved Stock` on change in Reserved Qty of Stock Reservation Entry""" | ||||
| 
 | ||||
| 		from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( | ||||
| 			get_sre_reserved_qty_for_item_and_warehouse, | ||||
| 		) | ||||
| 
 | ||||
| 		reserved_stock = get_sre_reserved_qty_for_item_and_warehouse(self.item_code, self.warehouse) | ||||
| 
 | ||||
| 		self.db_set("reserved_stock", flt(reserved_stock), update_modified=True) | ||||
| 
 | ||||
| 
 | ||||
| def on_doctype_update(): | ||||
| 	frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse") | ||||
|  | ||||
| @ -365,6 +365,9 @@ class DeliveryNote(SellingController): | ||||
| 					# Update Stock Reservation Entry `Status` based on `Delivered Qty`. | ||||
| 					sre_doc.update_status() | ||||
| 
 | ||||
| 					# Update Reserved Stock in Bin. | ||||
| 					sre_doc.update_reserved_stock_in_bin() | ||||
| 
 | ||||
| 					qty_to_deliver -= qty_can_be_deliver | ||||
| 
 | ||||
| 		if self._action == "cancel": | ||||
| @ -427,6 +430,9 @@ class DeliveryNote(SellingController): | ||||
| 					# Update Stock Reservation Entry `Status` based on `Delivered Qty`. | ||||
| 					sre_doc.update_status() | ||||
| 
 | ||||
| 					# Update Reserved Stock in Bin. | ||||
| 					sre_doc.update_reserved_stock_in_bin() | ||||
| 
 | ||||
| 					qty_to_undelivered -= qty_can_be_undelivered | ||||
| 
 | ||||
| 	def validate_against_stock_reservation_entries(self): | ||||
|  | ||||
| @ -9,6 +9,8 @@ from frappe.model.document import Document | ||||
| from frappe.query_builder.functions import Sum | ||||
| from frappe.utils import cint, flt | ||||
| 
 | ||||
| from erpnext.stock.utils import get_or_make_bin | ||||
| 
 | ||||
| 
 | ||||
| class StockReservationEntry(Document): | ||||
| 	def validate(self) -> None: | ||||
| @ -31,6 +33,7 @@ class StockReservationEntry(Document): | ||||
| 		self.update_reserved_qty_in_voucher() | ||||
| 		self.update_reserved_qty_in_pick_list() | ||||
| 		self.update_status() | ||||
| 		self.update_reserved_stock_in_bin() | ||||
| 
 | ||||
| 	def on_update_after_submit(self) -> None: | ||||
| 		self.can_be_updated() | ||||
| @ -40,12 +43,14 @@ class StockReservationEntry(Document): | ||||
| 		self.validate_reservation_based_on_serial_and_batch() | ||||
| 		self.update_reserved_qty_in_voucher() | ||||
| 		self.update_status() | ||||
| 		self.update_reserved_stock_in_bin() | ||||
| 		self.reload() | ||||
| 
 | ||||
| 	def on_cancel(self) -> None: | ||||
| 		self.update_reserved_qty_in_voucher() | ||||
| 		self.update_reserved_qty_in_pick_list() | ||||
| 		self.update_status() | ||||
| 		self.update_reserved_stock_in_bin() | ||||
| 
 | ||||
| 	def validate_amended_doc(self) -> None: | ||||
| 		"""Raises an exception if document is amended.""" | ||||
| @ -341,6 +346,13 @@ class StockReservationEntry(Document): | ||||
| 				update_modified=update_modified, | ||||
| 			) | ||||
| 
 | ||||
| 	def update_reserved_stock_in_bin(self) -> None: | ||||
| 		"""Updates `Reserved Stock` in Bin.""" | ||||
| 
 | ||||
| 		bin_name = get_or_make_bin(self.item_code, self.warehouse) | ||||
| 		bin_doc = frappe.get_cached_doc("Bin", bin_name) | ||||
| 		bin_doc.update_reserved_stock() | ||||
| 
 | ||||
| 	def update_status(self, status: str = None, update_modified: bool = True) -> None: | ||||
| 		"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty.""" | ||||
| 
 | ||||
| @ -681,6 +693,68 @@ def get_sre_reserved_qty_for_voucher_detail_no( | ||||
| 	return flt(reserved_qty[0][0]) | ||||
| 
 | ||||
| 
 | ||||
| def get_sre_reserved_serial_nos_details( | ||||
| 	item_code: str, warehouse: str, serial_nos: list = None | ||||
| ) -> dict: | ||||
| 	"""Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}""" | ||||
| 
 | ||||
| 	sre = frappe.qb.DocType("Stock Reservation Entry") | ||||
| 	sb_entry = frappe.qb.DocType("Serial and Batch Entry") | ||||
| 	query = ( | ||||
| 		frappe.qb.from_(sre) | ||||
| 		.inner_join(sb_entry) | ||||
| 		.on(sre.name == sb_entry.parent) | ||||
| 		.select(sb_entry.serial_no, sre.name) | ||||
| 		.where( | ||||
| 			(sre.docstatus == 1) | ||||
| 			& (sre.item_code == item_code) | ||||
| 			& (sre.warehouse == warehouse) | ||||
| 			& (sre.reserved_qty > sre.delivered_qty) | ||||
| 			& (sre.status.notin(["Delivered", "Cancelled"])) | ||||
| 			& (sre.reservation_based_on == "Serial and Batch") | ||||
| 		) | ||||
| 		.orderby(sb_entry.creation) | ||||
| 	) | ||||
| 
 | ||||
| 	if serial_nos: | ||||
| 		query = query.where(sb_entry.serial_no.isin(serial_nos)) | ||||
| 
 | ||||
| 	return frappe._dict(query.run()) | ||||
| 
 | ||||
| 
 | ||||
| def get_sre_reserved_batch_nos_details( | ||||
| 	item_code: str, warehouse: str, batch_nos: list = None | ||||
| ) -> dict: | ||||
| 	"""Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}""" | ||||
| 
 | ||||
| 	sre = frappe.qb.DocType("Stock Reservation Entry") | ||||
| 	sb_entry = frappe.qb.DocType("Serial and Batch Entry") | ||||
| 	query = ( | ||||
| 		frappe.qb.from_(sre) | ||||
| 		.inner_join(sb_entry) | ||||
| 		.on(sre.name == sb_entry.parent) | ||||
| 		.select( | ||||
| 			sb_entry.batch_no, | ||||
| 			Sum(sb_entry.qty - sb_entry.delivered_qty), | ||||
| 		) | ||||
| 		.where( | ||||
| 			(sre.docstatus == 1) | ||||
| 			& (sre.item_code == item_code) | ||||
| 			& (sre.warehouse == warehouse) | ||||
| 			& ((sre.reserved_qty - sre.delivered_qty) > 0) | ||||
| 			& (sre.status.notin(["Delivered", "Cancelled"])) | ||||
| 			& (sre.reservation_based_on == "Serial and Batch") | ||||
| 		) | ||||
| 		.groupby(sb_entry.batch_no) | ||||
| 		.orderby(sb_entry.creation) | ||||
| 	) | ||||
| 
 | ||||
| 	if batch_nos: | ||||
| 		query = query.where(sb_entry.batch_no.isin(batch_nos)) | ||||
| 
 | ||||
| 	return frappe._dict(query.run()) | ||||
| 
 | ||||
| 
 | ||||
| def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]: | ||||
| 	"""Returns a list of SREs for the provided voucher.""" | ||||
| 
 | ||||
|  | ||||
| @ -286,6 +286,7 @@ class TestStockReservationEntry(FrappeTestCase): | ||||
| 				self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) | ||||
| 				self.assertEqual(sre_details.status, "Partially Reserved") | ||||
| 
 | ||||
| 			cancel_stock_reservation_entries("Sales Order", so.name) | ||||
| 			se.cancel() | ||||
| 
 | ||||
| 			# Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty. | ||||
| @ -493,7 +494,7 @@ class TestStockReservationEntry(FrappeTestCase): | ||||
| 			"pick_serial_and_batch_based_on": "FIFO", | ||||
| 		}, | ||||
| 	) | ||||
| 	def test_stock_reservation_from_pick_list(self): | ||||
| 	def test_stock_reservation_from_pick_list(self) -> None: | ||||
| 		items_details = create_items() | ||||
| 		create_material_receipt(items_details, self.warehouse, qty=100) | ||||
| 
 | ||||
| @ -575,7 +576,7 @@ class TestStockReservationEntry(FrappeTestCase): | ||||
| 			"auto_reserve_stock_for_sales_order_on_purchase": 1, | ||||
| 		}, | ||||
| 	) | ||||
| 	def test_stock_reservation_from_purchase_receipt(self): | ||||
| 	def test_stock_reservation_from_purchase_receipt(self) -> None: | ||||
| 		from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt | ||||
| 		from erpnext.selling.doctype.sales_order.sales_order import make_material_request | ||||
| 		from erpnext.stock.doctype.material_request.material_request import make_purchase_order | ||||
| @ -645,6 +646,40 @@ class TestStockReservationEntry(FrappeTestCase): | ||||
| 				# Test - 3: Reserved Serial/Batch Nos should be equal to PR Item Serial/Batch Nos. | ||||
| 				self.assertEqual(set(sb_details), set(reserved_sb_details)) | ||||
| 
 | ||||
| 	@change_settings( | ||||
| 		"Stock Settings", | ||||
| 		{ | ||||
| 			"allow_negative_stock": 0, | ||||
| 			"enable_stock_reservation": 1, | ||||
| 			"auto_reserve_serial_and_batch": 1, | ||||
| 			"pick_serial_and_batch_based_on": "FIFO", | ||||
| 		}, | ||||
| 	) | ||||
| 	def test_consider_reserved_stock_while_cancelling_an_inward_transaction(self) -> None: | ||||
| 		items_details = create_items() | ||||
| 		se = create_material_receipt(items_details, self.warehouse, qty=100) | ||||
| 
 | ||||
| 		item_list = [] | ||||
| 		for item_code, properties in items_details.items(): | ||||
| 			item_list.append( | ||||
| 				{ | ||||
| 					"item_code": item_code, | ||||
| 					"warehouse": self.warehouse, | ||||
| 					"qty": randint(11, 100), | ||||
| 					"uom": properties.stock_uom, | ||||
| 					"rate": randint(10, 400), | ||||
| 				} | ||||
| 			) | ||||
| 
 | ||||
| 		so = make_sales_order( | ||||
| 			item_list=item_list, | ||||
| 			warehouse=self.warehouse, | ||||
| 		) | ||||
| 		so.create_stock_reservation_entries() | ||||
| 
 | ||||
| 		# Test - 1: ValidationError should be thrown as the inwarded stock is reserved. | ||||
| 		self.assertRaises(frappe.ValidationError, se.cancel) | ||||
| 
 | ||||
| 	def tearDown(self) -> None: | ||||
| 		cancel_all_stock_reservation_entries() | ||||
| 		return super().tearDown() | ||||
|  | ||||
| @ -11,17 +11,22 @@ from frappe import _, scrub | ||||
| from frappe.model.meta import get_field_precision | ||||
| from frappe.query_builder import Case | ||||
| from frappe.query_builder.functions import CombineDatetime, Sum | ||||
| from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, parse_json | ||||
| from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json | ||||
| 
 | ||||
| import erpnext | ||||
| from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty | ||||
| from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions | ||||
| from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( | ||||
| 	get_available_batches, | ||||
| ) | ||||
| from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( | ||||
| 	get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, | ||||
| 	get_sre_reserved_batch_nos_details, | ||||
| 	get_sre_reserved_serial_nos_details, | ||||
| ) | ||||
| from erpnext.stock.utils import ( | ||||
| 	get_incoming_outgoing_rate_for_cancel, | ||||
| 	get_or_make_bin, | ||||
| 	get_stock_balance, | ||||
| 	get_valuation_method, | ||||
| ) | ||||
| from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero | ||||
| @ -88,6 +93,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc | ||||
| 			is_stock_item = frappe.get_cached_value("Item", args.get("item_code"), "is_stock_item") | ||||
| 			if is_stock_item: | ||||
| 				bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) | ||||
| 				args.reserved_stock = flt(frappe.db.get_value("Bin", bin_name, "reserved_stock")) | ||||
| 				repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher) | ||||
| 				update_bin_qty(bin_name, args) | ||||
| 			else: | ||||
| @ -114,6 +120,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou | ||||
| 					"voucher_no": args.get("voucher_no"), | ||||
| 					"sle_id": args.get("name"), | ||||
| 					"creation": args.get("creation"), | ||||
| 					"reserved_stock": args.get("reserved_stock"), | ||||
| 				}, | ||||
| 				allow_negative_stock=allow_negative_stock, | ||||
| 				via_landed_cost_voucher=via_landed_cost_voucher, | ||||
| @ -511,7 +518,7 @@ class update_entries_after(object): | ||||
| 		self.new_items_found = False | ||||
| 		self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) | ||||
| 		self.affected_transactions: Set[Tuple[str, str]] = set() | ||||
| 		self.reserved_stock = get_reserved_stock(self.args.item_code, self.args.warehouse) | ||||
| 		self.reserved_stock = flt(self.args.reserved_stock) | ||||
| 
 | ||||
| 		self.data = frappe._dict() | ||||
| 		self.initialize_previous_data(self.args) | ||||
| @ -1719,22 +1726,23 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): | ||||
| 
 | ||||
| 		frappe.throw(message, NegativeStockError, title=_("Insufficient Stock")) | ||||
| 
 | ||||
| 	if not args.batch_no: | ||||
| 		return | ||||
| 	if args.batch_no: | ||||
| 		neg_batch_sle = get_future_sle_with_negative_batch_qty(args) | ||||
| 		if is_negative_with_precision(neg_batch_sle, is_batch=True): | ||||
| 			message = _( | ||||
| 				"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." | ||||
| 			).format( | ||||
| 				abs(neg_batch_sle[0]["cumulative_total"]), | ||||
| 				frappe.get_desk_link("Batch", args.batch_no), | ||||
| 				frappe.get_desk_link("Warehouse", args.warehouse), | ||||
| 				neg_batch_sle[0]["posting_date"], | ||||
| 				neg_batch_sle[0]["posting_time"], | ||||
| 				frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]), | ||||
| 			) | ||||
| 			frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) | ||||
| 
 | ||||
| 	neg_batch_sle = get_future_sle_with_negative_batch_qty(args) | ||||
| 	if is_negative_with_precision(neg_batch_sle, is_batch=True): | ||||
| 		message = _( | ||||
| 			"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." | ||||
| 		).format( | ||||
| 			abs(neg_batch_sle[0]["cumulative_total"]), | ||||
| 			frappe.get_desk_link("Batch", args.batch_no), | ||||
| 			frappe.get_desk_link("Warehouse", args.warehouse), | ||||
| 			neg_batch_sle[0]["posting_date"], | ||||
| 			neg_batch_sle[0]["posting_time"], | ||||
| 			frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]), | ||||
| 		) | ||||
| 		frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) | ||||
| 	if args.reserved_stock: | ||||
| 		validate_reserved_stock(args) | ||||
| 
 | ||||
| 
 | ||||
| def is_negative_with_precision(neg_sle, is_batch=False): | ||||
| @ -1801,6 +1809,96 @@ def get_future_sle_with_negative_batch_qty(args): | ||||
| 	) | ||||
| 
 | ||||
| 
 | ||||
| def validate_reserved_stock(kwargs): | ||||
| 	if kwargs.serial_no: | ||||
| 		serial_nos = kwargs.serial_no.split("\n") | ||||
| 		validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) | ||||
| 
 | ||||
| 	elif kwargs.batch_no: | ||||
| 		validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no]) | ||||
| 
 | ||||
| 	elif kwargs.serial_and_batch_bundle: | ||||
| 		sbb_entries = frappe.db.get_all( | ||||
| 			"Serial and Batch Entry", | ||||
| 			{ | ||||
| 				"parenttype": "Serial and Batch Bundle", | ||||
| 				"parent": kwargs.serial_and_batch_bundle, | ||||
| 				"docstatus": 1, | ||||
| 			}, | ||||
| 			["batch_no", "serial_no"], | ||||
| 		) | ||||
| 
 | ||||
| 		if serial_nos := [entry.serial_no for entry in sbb_entries if entry.serial_no]: | ||||
| 			validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) | ||||
| 		elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]: | ||||
| 			validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, batch_nos) | ||||
| 
 | ||||
| 	# Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty. | ||||
| 	precision = cint(frappe.db.get_default("float_precision")) or 2 | ||||
| 	balance_qty = get_stock_balance(kwargs.item_code, kwargs.warehouse) | ||||
| 
 | ||||
| 	diff = flt(balance_qty - kwargs.get("reserved_stock", 0), precision) | ||||
| 	if diff < 0 and abs(diff) > 0.0001: | ||||
| 		msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format( | ||||
| 			abs(diff), | ||||
| 			frappe.get_desk_link("Item", kwargs.item_code), | ||||
| 			frappe.get_desk_link("Warehouse", kwargs.warehouse), | ||||
| 			nowdate(), | ||||
| 			nowtime(), | ||||
| 		) | ||||
| 		frappe.throw(msg, title=_("Reserved Stock")) | ||||
| 
 | ||||
| 
 | ||||
| def validate_reserved_serial_nos(item_code, warehouse, serial_nos): | ||||
| 	if reserved_serial_nos_details := get_sre_reserved_serial_nos_details( | ||||
| 		item_code, warehouse, serial_nos | ||||
| 	): | ||||
| 		if common_serial_nos := list( | ||||
| 			set(serial_nos).intersection(set(reserved_serial_nos_details.keys())) | ||||
| 		): | ||||
| 			msg = _( | ||||
| 				"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding." | ||||
| 			) | ||||
| 			msg += "<br />" | ||||
| 			msg += _("Example: Serial No {0} reserved in {1}.").format( | ||||
| 				frappe.bold(common_serial_nos[0]), | ||||
| 				frappe.get_desk_link( | ||||
| 					"Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]] | ||||
| 				), | ||||
| 			) | ||||
| 			frappe.throw(msg, title=_("Reserved Serial No.")) | ||||
| 
 | ||||
| 
 | ||||
| def validate_reserved_batch_nos(item_code, warehouse, batch_nos): | ||||
| 	if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos): | ||||
| 		available_batches = get_available_batches( | ||||
| 			frappe._dict( | ||||
| 				{ | ||||
| 					"item_code": item_code, | ||||
| 					"warehouse": warehouse, | ||||
| 					"posting_date": nowdate(), | ||||
| 					"posting_time": nowtime(), | ||||
| 				} | ||||
| 			) | ||||
| 		) | ||||
| 		available_batches_map = {row.batch_no: row.qty for row in available_batches} | ||||
| 		precision = cint(frappe.db.get_default("float_precision")) or 2 | ||||
| 
 | ||||
| 		for batch_no in batch_nos: | ||||
| 			diff = flt( | ||||
| 				available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision | ||||
| 			) | ||||
| 			if diff < 0 and abs(diff) > 0.0001: | ||||
| 				msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format( | ||||
| 					abs(diff), | ||||
| 					frappe.get_desk_link("Batch", batch_no), | ||||
| 					frappe.get_desk_link("Warehouse", warehouse), | ||||
| 					nowdate(), | ||||
| 					nowtime(), | ||||
| 				) | ||||
| 				frappe.throw(msg, title=_("Reserved Stock for Batch")) | ||||
| 
 | ||||
| 
 | ||||
| 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 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user