Merge branch 'develop' into stock-reservation
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 642d51c..6839abb 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -2815,6 +2815,17 @@
parent.update_billing_percentage()
parent.set_status()
+ # Cancel and Recreate Stock Reservation Entries.
+ if parent_doctype == "Sales Order":
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ has_reserved_stock,
+ )
+
+ if has_reserved_stock(parent.doctype, parent.name):
+ cancel_stock_reservation_entries(parent.doctype, parent.name)
+ parent.create_stock_reservation_entries()
+
@erpnext.allow_regional
def validate_regional(doc):
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 449d461..417e93b 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -60,8 +60,23 @@
});
}
- if (frm.doc.docstatus === 0 && frm.doc.is_internal_customer) {
- frm.events.get_items_from_internal_purchase_order(frm);
+ if (frm.doc.docstatus === 0) {
+ if (frm.doc.is_internal_customer) {
+ frm.events.get_items_from_internal_purchase_order(frm);
+ }
+
+ if (frm.is_new()) {
+ if (frm.doc.__onload && frm.doc.__onload.enable_stock_reservation) {
+ if (frm.doc.__onload.reserve_stock_on_so_submission) {
+ // If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0.
+ frm.set_value("reserve_stock", value ? 1 : 0);
+ }
+ } else {
+ // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and read only.
+ frm.set_value("reserve_stock", 0);
+ frm.set_df_property("reserve_stock", "read_only", 1);
+ }
+ }
}
},
@@ -270,6 +285,16 @@
}
this.frm.page.set_inner_btn_group_as_primary(__('Create'));
}
+
+ // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock.
+ if (this.frm.doc.__onload && this.frm.doc.__onload.enable_stock_reservation && this.frm.doc.__onload.has_unreserved_stock) {
+ this.frm.add_custom_button(__('Reserve'), () => this.create_stock_reservation_entries(), __('Stock Reservation'));
+ }
+
+ // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock.
+ if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) {
+ this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation'));
+ }
}
if (this.frm.doc.docstatus===0) {
@@ -309,6 +334,38 @@
this.order_type(doc);
}
+ create_stock_reservation_entries() {
+ frappe.call({
+ doc: this.frm.doc,
+ method: 'create_stock_reservation_entries',
+ args: {
+ notify: true
+ },
+ freeze: true,
+ freeze_message: __('Reserving Stock...'),
+ callback: (r) => {
+ this.frm.doc.__onload.has_unreserved_stock = false;
+ this.frm.refresh();
+ }
+ })
+ }
+
+ cancel_stock_reservation_entries() {
+ frappe.call({
+ method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries',
+ args: {
+ voucher_type: this.frm.doctype,
+ voucher_no: this.frm.docname
+ },
+ freeze: true,
+ freeze_message: __('Unreserving Stock...'),
+ callback: (r) => {
+ this.frm.doc.__onload.has_reserved_stock = false;
+ this.frm.refresh();
+ }
+ })
+ }
+
create_pick_list() {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 4f498fb..f7143d7 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -42,6 +42,7 @@
"scan_barcode",
"column_break_28",
"set_warehouse",
+ "reserve_stock",
"items_section",
"items",
"section_break_31",
@@ -1625,13 +1626,24 @@
"fieldname": "named_place",
"fieldtype": "Data",
"label": "Named Place"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
+ "description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock",
+ "no_copy": 1,
+ "print_hide": 1,
+ "report_hide": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-04-20 11:14:01.036202",
+ "modified": "2023-04-22 09:55:37.008190",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1664,7 +1676,6 @@
"read": 1,
"report": 1,
"role": "Sales Manager",
- "set_user_permissions": 1,
"share": 1,
"submit": 1,
"write": 1
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index ee9161b..6abb8ed 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -30,6 +30,11 @@
from erpnext.selling.doctype.customer.customer import check_credit_limit
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ get_sre_reserved_qty_details_for_voucher,
+ has_reserved_stock,
+)
from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
@@ -44,6 +49,15 @@
def __init__(self, *args, **kwargs):
super(SalesOrder, self).__init__(*args, **kwargs)
+ def onload(self) -> None:
+ stock_settings = frappe.get_doc("Stock Settings")
+ self.set_onload("enable_stock_reservation", stock_settings.enable_stock_reservation)
+ self.set_onload(
+ "reserve_stock_on_so_submission", stock_settings.reserve_stock_on_sales_order_submission
+ )
+ self.set_onload("has_reserved_stock", has_reserved_stock(self.doctype, self.name))
+ self.set_onload("has_unreserved_stock", self.has_unreserved_stock())
+
def validate(self):
super(SalesOrder, self).validate()
self.validate_delivery_date()
@@ -241,6 +255,9 @@
update_coupon_code_count(self.coupon_code, "used")
+ if self.get("reserve_stock"):
+ self.create_stock_reservation_entries()
+
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
super(SalesOrder, self).on_cancel()
@@ -257,6 +274,7 @@
self.db_set("status", "Cancelled")
self.update_blanket_order()
+ cancel_stock_reservation_entries("Sales Order", self.name)
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
if self.coupon_code:
@@ -485,6 +503,131 @@
).format(item.item_code)
)
+ def has_unreserved_stock(self) -> bool:
+ """Returns True if there is any unreserved item in the Sales Order."""
+
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
+
+ for item in self.get("items"):
+ if not item.get("reserve_stock"):
+ continue
+
+ unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
+ if unreserved_qty > 0:
+ return True
+
+ return False
+
+ @frappe.whitelist()
+ def create_stock_reservation_entries(self, notify=True):
+ """Creates Stock Reservation Entries for Sales Order Items."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_available_qty_to_reserve,
+ validate_stock_reservation_settings,
+ )
+
+ validate_stock_reservation_settings(self)
+
+ allow_partial_reservation = frappe.db.get_single_value(
+ "Stock Settings", "allow_partial_reservation"
+ )
+
+ sre_count = 0
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
+ for item in self.get("items"):
+ # Skip if `Reserved Stock` is not checked for the item.
+ if not item.get("reserve_stock"):
+ continue
+
+ # Skip if Non-Stock Item.
+ if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
+ frappe.msgprint(
+ _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="yellow",
+ )
+ item.db_set("reserve_stock", 0)
+ continue
+
+ unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
+
+ # Stock is already reserved for the item, notify the user and skip the item.
+ if unreserved_qty <= 0:
+ frappe.msgprint(
+ _("Row #{0}: Stock is already reserved for the Item {1}").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ )
+ continue
+
+ available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
+
+ # No stock available to reserve, notify the user and skip the item.
+ if available_qty_to_reserve <= 0:
+ frappe.msgprint(
+ _("Row #{0}: No available stock to reserve for the Item {1}").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+ continue
+
+ # The quantity which can be reserved.
+ qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
+
+ # Partial Reservation
+ if qty_to_be_reserved < unreserved_qty:
+ frappe.msgprint(
+ _("Row #{0}: Only {1} available to reserve for the Item {2}").format(
+ item.idx,
+ frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
+ frappe.bold(item.item_code),
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+
+ # Skip the item if `Partial Reservation` is disabled in the Stock Settings.
+ if not allow_partial_reservation:
+ continue
+
+ # Create and Submit Stock Reservation Entry
+ sre = frappe.new_doc("Stock Reservation Entry")
+ sre.item_code = item.item_code
+ sre.warehouse = item.warehouse
+ sre.voucher_type = self.doctype
+ sre.voucher_no = self.name
+ sre.voucher_detail_no = item.name
+ sre.available_qty = available_qty_to_reserve
+ sre.voucher_qty = item.stock_qty
+ sre.reserved_qty = qty_to_be_reserved
+ sre.company = self.company
+ sre.stock_uom = item.stock_uom
+ sre.project = self.project
+ sre.save()
+ sre.submit()
+
+ sre_count += 1
+
+ if sre_count and notify:
+ frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
+
+
+def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
+ """Returns the unreserved quantity for the Sales Order Item."""
+
+ existing_reserved_qty = reserved_qty_details.get((item.name, item.warehouse), 0)
+ return (
+ item.stock_qty
+ - flt(item.delivered_qty) * item.get("conversion_factor", 1)
+ - existing_reserved_qty
+ )
+
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -676,7 +819,6 @@
}
target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
-
target_doc.set_onload("ignore_price_list", True)
return target_doc
diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
index cbc40bb..c840097 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
+++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
@@ -11,6 +11,7 @@
"Payment Request": "reference_name",
"Auto Repeat": "reference_document",
"Maintenance Visit": "prevdoc_docname",
+ "Stock Reservation Entry": "voucher_no",
},
"internal_links": {
"Quotation": ["items", "prevdoc_docname"],
@@ -23,7 +24,7 @@
{"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]},
{"label": _("Projects"), "items": ["Project"]},
{"label": _("Manufacturing"), "items": ["Work Order"]},
- {"label": _("Reference"), "items": ["Quotation", "Auto Repeat"]},
+ {"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]},
{"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]},
],
}
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 627914f..51b791f 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1878,6 +1878,139 @@
self.assertEqual(pe.references[1].reference_name, so.name)
self.assertEqual(pe.references[1].allocated_amount, 300)
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_stock_reservation_against_sales_order(self) -> None:
+ from random import randint, uniform
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ get_sre_reserved_qty_details_for_voucher,
+ get_stock_reservation_entries_for_voucher,
+ has_reserved_stock,
+ )
+ from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
+ create_items,
+ create_material_receipts,
+ )
+
+ items_details, warehouse = create_items(), "_Test Warehouse - _TC"
+ create_material_receipts(items_details, warehouse, qty=10)
+
+ item_list = []
+ for item_code, properties in items_details.items():
+ stock_uom = properties.stock_uom
+ item_list.append(
+ {
+ "item_code": item_code,
+ "warehouse": warehouse,
+ "qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3),
+ "uom": stock_uom,
+ "rate": randint(10, 200),
+ }
+ )
+
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse="_Test Warehouse - _TC",
+ )
+
+ # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
+ so.create_stock_reservation_entries()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
+ )
+ self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty)
+ self.assertEqual(sre_details[0].status, "Partially Reserved")
+
+ # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
+ create_material_receipts(items_details, warehouse, qty=100)
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
+ for item in so.items:
+ reserved_qty = reserved_qty_details[(item.name, item.warehouse)]
+ self.assertEqual(item.stock_reserved_qty, reserved_qty)
+ self.assertEqual(item.stock_qty, item.stock_reserved_qty)
+
+ # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
+ cancel_stock_reservation_entries("Sales Order", so.name)
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Test - 5: Re-reserve the stock.
+ so.create_stock_reservation_entries()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 6: Stock should get unreserved on cancellation of Sales Order.
+ so.cancel()
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Create Sales Order and Reserve Stock.
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse="_Test Warehouse - _TC",
+ )
+ so.create_stock_reservation_entries()
+
+ # Test - 7: Partial Delivery against Sales Order.
+ dn1 = make_delivery_note(so.name)
+
+ for item in dn1.items:
+ item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
+
+ dn1.save()
+ dn1.submit()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
+ )
+ self.assertGreater(sre_details[0].delivered_qty, 0)
+ self.assertEqual(sre_details[0].status, "Partially Delivered")
+
+ # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
+ with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
+ dn2 = make_delivery_note(so.name)
+
+ for item in dn2.items:
+ item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
+
+ dn2.save()
+ dn2.submit()
+
+ for item in so.items:
+ sre_details = frappe.db.get_all(
+ "Stock Reservation Entry",
+ filters={
+ "voucher_type": "Sales Order",
+ "voucher_no": so.name,
+ "voucher_detail_no": item.name,
+ },
+ fields=["status", "reserved_qty", "delivered_qty"],
+ )
+
+ for sre_detail in sre_details:
+ self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
+ self.assertEqual(sre_detail.status, "Delivered")
+
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index d0dabad..5c7e10a 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -10,6 +10,7 @@
"item_code",
"customer_item_code",
"ensure_delivery_based_on_produced_serial_no",
+ "reserve_stock",
"col_break1",
"delivery_date",
"item_name",
@@ -27,6 +28,7 @@
"uom",
"conversion_factor",
"stock_qty",
+ "stock_reserved_qty",
"section_break_16",
"price_list_rate",
"base_price_list_rate",
@@ -859,12 +861,33 @@
"fieldname": "material_request_item",
"fieldtype": "Data",
"label": "Material Request Item"
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "1",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock",
+ "print_hide": 1,
+ "report_hide": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.stock_reserved_qty",
+ "fieldname": "stock_reserved_qty",
+ "fieldtype": "Float",
+ "label": "Stock Reserved Qty (in Stock UOM)",
+ "no_copy": 1,
+ "non_negative": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-12-25 02:51:10.247569",
+ "modified": "2023-04-04 10:44:05.707488",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 9f9f5cb..b222560 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -147,6 +147,8 @@
if not self.installation_status:
self.installation_status = "Not Installed"
+
+ self.validate_against_stock_reservation_entries()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self):
@@ -239,6 +241,8 @@
self.update_prevdoc_status()
self.update_billing_status()
+ self.update_stock_reservation_entries()
+
if not self.is_return:
self.check_credit_limit()
elif self.issue_credit_note:
@@ -268,6 +272,103 @@
self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ def update_stock_reservation_entries(self) -> None:
+ """Updates Delivered Qty in Stock Reservation Entries."""
+
+ # Don't update Delivered Qty on Return or Cancellation.
+ if self.is_return or self._action == "cancel":
+ return
+
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
+
+ sre_list = frappe.db.get_all(
+ "Stock Reservation Entry",
+ {
+ "docstatus": 1,
+ "voucher_type": "Sales Order",
+ "voucher_no": item.against_sales_order,
+ "voucher_detail_no": item.so_detail,
+ "warehouse": item.warehouse,
+ "status": ["not in", ["Delivered", "Cancelled"]],
+ },
+ order_by="creation",
+ )
+
+ # Skip if no Stock Reservation Entries.
+ if not sre_list:
+ continue
+
+ available_qty_to_deliver = item.stock_qty
+ for sre in sre_list:
+ if available_qty_to_deliver <= 0:
+ break
+
+ sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
+
+ # `Delivered Qty` should be less than or equal to `Reserved Qty`.
+ qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver)
+
+ sre_doc.delivered_qty += qty_to_be_deliver
+ sre_doc.db_update()
+
+ # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
+ sre_doc.update_status()
+
+ available_qty_to_deliver -= qty_to_be_deliver
+
+ def validate_against_stock_reservation_entries(self):
+ """Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_voucher_detail_no,
+ )
+
+ # Don't validate if Return
+ if self.is_return:
+ return
+
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
+
+ sre_data = get_sre_reserved_qty_details_for_voucher_detail_no(
+ "Sales Order", item.against_sales_order, item.so_detail
+ )
+
+ # Skip if stock is not reserved.
+ if not sre_data:
+ continue
+
+ is_group_warehouse = frappe.get_cached_value("Warehouse", sre_data[0], "is_group")
+
+ if not item.warehouse:
+ if not is_group_warehouse:
+ item.warehouse = sre_data[0]
+ else:
+ frappe.throw(_("Row #{0}: Warehouse is mandatory").format(item.idx, item.item_code))
+ else:
+ if not is_group_warehouse:
+ if item.warehouse != sre_data[0]:
+ frappe.throw(
+ _("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]),
+ title=_("Stock Reservation Warehouse Mismatch"),
+ )
+ else:
+ from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
+
+ warehouses = get_child_warehouses(sre_data[0])
+ if item.warehouse not in warehouses:
+ frappe.throw(
+ _(
+ "Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse"
+ ).format(item.idx, sre_data[0]),
+ title=_("Stock Reservation Group Warehouse"),
+ )
+
def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 3fd4cec..8d8b69d 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -47,6 +47,7 @@
self.validate_putaway_capacity()
if self._action == "submit":
+ self.validate_reserved_stock()
self.make_batches("warehouse")
def on_submit(self):
@@ -60,6 +61,7 @@
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ self.validate_reserved_stock()
self.make_sle_on_cancel()
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()
@@ -224,6 +226,46 @@
except Exception as e:
self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e))
+ def validate_reserved_stock(self) -> None:
+ """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for item in self.items:
+ item_code_list.append(item.item_code)
+ warehouse_list.append(item.warehouse)
+
+ sre_reserved_qty_details = get_sre_reserved_qty_details(item_code_list, warehouse_list)
+
+ if sre_reserved_qty_details:
+ data = []
+ for (item_code, warehouse), reserved_qty in sre_reserved_qty_details.items():
+ data.append([item_code, warehouse, reserved_qty])
+
+ msg = ""
+ if len(data) == 1:
+ msg = _(
+ "{0} units are reserved for Item {1} in Warehouse {2}, please un-reserve the same to {3} the Stock Reconciliation."
+ ).format(bold(data[0][2]), bold(data[0][0]), bold(data[0][1]), self._action)
+ else:
+ items_html = ""
+ for d in data:
+ items_html += "<li>{0} units of Item {1} in Warehouse {2}</li>".format(
+ bold(d[2]), bold(d[0]), bold(d[1])
+ )
+
+ msg = _(
+ "The stock has been reserved for the following Items and Warehouses, un-reserve the same to {0} the Stock Reconciliation: <br /><br /> {1}"
+ ).format(self._action, items_html)
+
+ frappe.throw(
+ msg,
+ title=_("Stock Reservation"),
+ )
+
def update_stock_ledger(self):
"""find difference between current and expected entries
and create stock ledger entries based on the difference"""
diff --git a/erpnext/stock/doctype/stock_reservation_entry/__init__.py b/erpnext/stock/doctype/stock_reservation_entry/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/__init__.py
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
new file mode 100644
index 0000000..666fd24
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Stock Reservation Entry", {
+ refresh(frm) {
+ frm.page.btn_primary.hide()
+ },
+});
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
new file mode 100644
index 0000000..7c7abac
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
@@ -0,0 +1,234 @@
+{
+ "actions": [],
+ "allow_copy": 1,
+ "autoname": "MAT-SRE-.YYYY.-.#####",
+ "creation": "2023-03-20 10:45:59.258959",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "warehouse",
+ "column_break_elik",
+ "voucher_type",
+ "voucher_no",
+ "voucher_detail_no",
+ "section_break_xt4m",
+ "available_qty",
+ "voucher_qty",
+ "stock_uom",
+ "column_break_o6ex",
+ "reserved_qty",
+ "delivered_qty",
+ "section_break_3vb3",
+ "company",
+ "column_break_jbyr",
+ "project",
+ "status",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Item Code",
+ "oldfieldname": "item_code",
+ "oldfieldtype": "Link",
+ "options": "Item",
+ "print_width": "100px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Warehouse",
+ "oldfieldname": "warehouse",
+ "oldfieldtype": "Link",
+ "options": "Warehouse",
+ "print_width": "100px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Select",
+ "in_filter": 1,
+ "label": "Voucher Type",
+ "oldfieldname": "voucher_type",
+ "oldfieldtype": "Data",
+ "options": "\nSales Order",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Voucher No",
+ "oldfieldname": "voucher_no",
+ "oldfieldtype": "Data",
+ "options": "voucher_type",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "voucher_detail_no",
+ "fieldtype": "Data",
+ "label": "Voucher Detail No",
+ "oldfieldname": "voucher_detail_no",
+ "oldfieldtype": "Data",
+ "print_width": "150px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "stock_uom",
+ "fieldtype": "Link",
+ "label": "Stock UOM",
+ "oldfieldname": "stock_uom",
+ "oldfieldtype": "Data",
+ "options": "UOM",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project",
+ "read_only": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "label": "Company",
+ "oldfieldname": "company",
+ "oldfieldtype": "Data",
+ "options": "Company",
+ "print_width": "150px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "reserved_qty",
+ "fieldtype": "Float",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "label": "Reserved Qty",
+ "oldfieldname": "actual_qty",
+ "oldfieldtype": "Currency",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "default": "Draft",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Status",
+ "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "delivered_qty",
+ "fieldtype": "Float",
+ "label": "Delivered Qty",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Stock Reservation Entry",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "available_qty",
+ "fieldtype": "Float",
+ "label": "Available Qty to Reserve",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "voucher_qty",
+ "fieldtype": "Float",
+ "label": "Voucher Qty",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_elik",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_xt4m",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_o6ex",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_3vb3",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_jbyr",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "hide_toolbar": 1,
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-03-29 18:36:26.752872",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Stock Reservation Entry",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
new file mode 100644
index 0000000..f55e640
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -0,0 +1,319 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
+
+
+class StockReservationEntry(Document):
+ def validate(self) -> None:
+ from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
+
+ self.validate_mandatory()
+ validate_disabled_warehouse(self.warehouse)
+ validate_warehouse_company(self.warehouse, self.company)
+
+ def on_submit(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+
+ def on_cancel(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+
+ def validate_mandatory(self) -> None:
+ """Raises exception if mandatory fields are not set."""
+
+ mandatory = [
+ "item_code",
+ "warehouse",
+ "voucher_type",
+ "voucher_no",
+ "voucher_detail_no",
+ "available_qty",
+ "voucher_qty",
+ "stock_uom",
+ "reserved_qty",
+ "company",
+ ]
+ for d in mandatory:
+ if not self.get(d):
+ frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
+
+ def update_status(self, status: str = None, update_modified: bool = True) -> None:
+ """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
+
+ if not status:
+ if self.docstatus == 2:
+ status = "Cancelled"
+ elif self.docstatus == 1:
+ if self.reserved_qty == self.delivered_qty:
+ status = "Delivered"
+ elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
+ status = "Partially Delivered"
+ elif self.reserved_qty == self.voucher_qty:
+ status = "Reserved"
+ else:
+ status = "Partially Reserved"
+ else:
+ status = "Draft"
+
+ frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified)
+
+ def update_reserved_qty_in_voucher(
+ self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
+ ) -> None:
+ """Updates total reserved qty in the voucher."""
+
+ item_doctype = "Sales Order Item" if self.voucher_type == "Sales Order" else None
+
+ if item_doctype:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty = (
+ frappe.qb.from_(sre)
+ .select(Sum(sre.reserved_qty))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == self.voucher_type)
+ & (sre.voucher_no == self.voucher_no)
+ & (sre.voucher_detail_no == self.voucher_detail_no)
+ )
+ ).run(as_list=True)[0][0] or 0
+
+ frappe.db.set_value(
+ item_doctype,
+ self.voucher_detail_no,
+ reserved_qty_field,
+ reserved_qty,
+ update_modified=update_modified,
+ )
+
+
+def validate_stock_reservation_settings(voucher: object) -> None:
+ """Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
+
+ if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
+ frappe.throw(
+ _("Please enable {0} in the {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
+ )
+ )
+
+ # Voucher types allowed for stock reservation
+ allowed_voucher_types = ["Sales Order"]
+
+ if voucher.doctype not in allowed_voucher_types:
+ frappe.throw(
+ _("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types))
+ )
+
+
+def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
+ """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
+
+ from erpnext.stock.get_item_details import get_bin_details
+
+ available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get(
+ "actual_qty"
+ )
+
+ if available_qty:
+ from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
+
+ warehouses = get_child_warehouses(warehouse)
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty = (
+ frappe.qb.from_(sre)
+ .select(Sum(sre.reserved_qty - sre.delivered_qty))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code == item_code)
+ & (sre.warehouse.isin(warehouses))
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ ).run()[0][0] or 0.0
+
+ if reserved_qty:
+ return available_qty - reserved_qty
+
+ return available_qty
+
+
+def get_stock_reservation_entries_for_voucher(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None
+) -> list[dict]:
+ """Returns list of Stock Reservation Entries against a Voucher."""
+
+ if not fields or not isinstance(fields, list):
+ fields = [
+ "name",
+ "item_code",
+ "warehouse",
+ "voucher_detail_no",
+ "reserved_qty",
+ "delivered_qty",
+ "stock_uom",
+ ]
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == voucher_type)
+ & (sre.voucher_no == voucher_no)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .orderby(sre.creation)
+ )
+
+ for field in fields:
+ query = query.select(sre[field])
+
+ if voucher_detail_no:
+ query = query.where(sre.voucher_detail_no == voucher_detail_no)
+
+ return query.run(as_dict=True)
+
+
+def get_sre_reserved_qty_details_for_item_and_warehouse(
+ item_code_list: list, warehouse_list: list
+) -> dict:
+ """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
+
+ sre_details = {}
+
+ if item_code_list and warehouse_list:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sre_data = (
+ frappe.qb.from_(sre)
+ .select(
+ sre.item_code,
+ sre.warehouse,
+ Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"),
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code.isin(item_code_list))
+ & (sre.warehouse.isin(warehouse_list))
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .groupby(sre.item_code, sre.warehouse)
+ ).run(as_dict=True)
+
+ if sre_data:
+ sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data}
+
+ return sre_details
+
+
+def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float:
+ """Returns `Reserved Qty` for Item and Warehouse combination."""
+
+ reserved_qty = 0.0
+
+ if item_code and warehouse:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ return (
+ frappe.qb.from_(sre)
+ .select(Sum(sre.reserved_qty - sre.delivered_qty))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code == item_code)
+ & (sre.warehouse == warehouse)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ ).run(as_list=True)[0][0] or 0.0
+
+ return reserved_qty
+
+
+def get_sre_reserved_qty_details_for_voucher(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None
+) -> dict:
+ """Returns a dict like {("voucher_detail_no", "warehouse"): "reserved_qty", ... }."""
+
+ reserved_qty_details = {}
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .select(
+ sre.voucher_detail_no,
+ sre.warehouse,
+ (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"),
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == voucher_type)
+ & (sre.voucher_no == voucher_no)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .groupby(sre.voucher_detail_no, sre.warehouse)
+ )
+
+ if voucher_detail_no:
+ query = query.where(sre.voucher_detail_no == voucher_detail_no)
+
+ data = query.run(as_dict=True)
+
+ for d in data:
+ reserved_qty_details[(d["voucher_detail_no"], d["warehouse"])] = d["reserved_qty"]
+
+ return reserved_qty_details
+
+
+def get_sre_reserved_qty_details_for_voucher_detail_no(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str
+) -> list:
+ """Returns a list like ["warehouse", "reserved_qty"]."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty_details = (
+ frappe.qb.from_(sre)
+ .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == voucher_type)
+ & (sre.voucher_no == voucher_no)
+ & (sre.voucher_detail_no == voucher_detail_no)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .groupby(sre.warehouse)
+ ).run(as_list=True)
+
+ if reserved_qty_details:
+ return reserved_qty_details[0]
+
+ return reserved_qty_details
+
+
+def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool:
+ """Returns True if there is any Stock Reservation Entry for the given voucher."""
+
+ if get_stock_reservation_entries_for_voucher(
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ ):
+ return True
+
+ return False
+
+
+@frappe.whitelist()
+def cancel_stock_reservation_entries(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None, notify: bool = True
+) -> None:
+ """Cancel Stock Reservation Entries for the given voucher."""
+
+ sre_list = get_stock_reservation_entries_for_voucher(
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ )
+
+ if sre_list:
+ for sre in sre_list:
+ frappe.get_doc("Stock Reservation Entry", sre.name).cancel()
+
+ if notify:
+ frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red")
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js
new file mode 100644
index 0000000..442ac39
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js
@@ -0,0 +1,16 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.listview_settings['Stock Reservation Entry'] = {
+ get_indicator: function (doc) {
+ const status_colors = {
+ 'Draft': 'red',
+ 'Partially Reserved': 'orange',
+ 'Reserved': 'blue',
+ 'Partially Delivered': 'purple',
+ 'Delivered': 'green',
+ 'Cancelled': 'red',
+ };
+ return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status];
+ },
+};
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
new file mode 100644
index 0000000..5a082dd
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
@@ -0,0 +1,317 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+from erpnext.stock.utils import get_stock_balance
+
+
+class TestStockReservationEntry(FrappeTestCase):
+ def setUp(self) -> None:
+ self.items = create_items()
+ create_material_receipts(self.items)
+
+ def tearDown(self) -> None:
+ return super().tearDown()
+
+ def test_validate_stock_reservation_settings(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ validate_stock_reservation_settings,
+ )
+
+ voucher = frappe._dict(
+ {
+ "doctype": "Sales Order",
+ }
+ )
+
+ # Case - 1: When `Stock Reservation` is disabled in `Stock Settings`, throw `ValidationError`
+ with change_settings("Stock Settings", {"enable_stock_reservation": 0}):
+ self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
+
+ with change_settings("Stock Settings", {"enable_stock_reservation": 1}):
+ # Case - 2: When `Voucher Type` is not allowed for `Stock Reservation`, throw `ValidationError`
+ voucher.doctype = "NOT ALLOWED"
+ self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
+
+ # Case - 3: When `Voucher Type` is allowed for `Stock Reservation`
+ voucher.doctype = "Sales Order"
+ self.assertIsNone(validate_stock_reservation_settings(voucher), None)
+
+ def test_get_available_qty_to_reserve(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_available_qty_to_reserve,
+ )
+
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty
+ cancel_all_stock_reservation_entries()
+ available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
+ expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
+
+ self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
+
+ # Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
+ sre = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ ignore_validate=True,
+ )
+ available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
+ expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty
+
+ self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
+
+ def test_update_status(self) -> None:
+ sre = make_stock_reservation_entry(
+ reserved_qty=30,
+ ignore_validate=True,
+ do_not_submit=True,
+ )
+
+ # Draft: When DocStatus is `0`
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Draft")
+
+ # Partially Reserved: When DocStatus is `1` and `Reserved Qty` < `Voucher Qty`
+ sre.submit()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Partially Reserved")
+
+ # Reserved: When DocStatus is `1` and `Reserved Qty` = `Voucher Qty`
+ sre.reserved_qty = sre.voucher_qty
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Reserved")
+
+ # Partially Delivered: When DocStatus is `1` and (0 < `Delivered Qty` < `Voucher Qty`)
+ sre.delivered_qty = 10
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Partially Delivered")
+
+ # Delivered: When DocStatus is `1` and `Delivered Qty` = `Voucher Qty`
+ sre.delivered_qty = sre.voucher_qty
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Delivered")
+
+ # Cancelled: When DocStatus is `2`
+ sre.cancel()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Cancelled")
+
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_update_reserved_qty_in_voucher(self) -> None:
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Step - 1: Create a `Sales Order`
+ so = make_sales_order(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=50,
+ rate=100,
+ do_not_submit=True,
+ )
+ so.reserve_stock = 0 # Stock Reservation Entries won't be created on submit
+ so.items[0].reserve_stock = 1
+ so.save()
+ so.submit()
+
+ # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item`
+ sre1 = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ voucher_type="Sales Order",
+ voucher_no=so.name,
+ voucher_detail_no=so.items[0].name,
+ reserved_qty=30,
+ )
+
+ so.load_from_db()
+ sre1.load_from_db()
+ self.assertEqual(sre1.status, "Partially Reserved")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty)
+
+ # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item`
+ sre2 = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ voucher_type="Sales Order",
+ voucher_no=so.name,
+ voucher_detail_no=so.items[0].name,
+ reserved_qty=20,
+ )
+
+ so.load_from_db()
+ sre2.load_from_db()
+ self.assertEqual(sre1.status, "Partially Reserved")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty + sre2.reserved_qty)
+
+ # Step - 4: Cancel `Stock Reservation Entry[1]`
+ sre1.cancel()
+ so.load_from_db()
+ sre1.load_from_db()
+ self.assertEqual(sre1.status, "Cancelled")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
+
+ # Step - 5: Cancel `Stock Reservation Entry[2]`
+ sre2.cancel()
+ so.load_from_db()
+ sre2.load_from_db()
+ self.assertEqual(sre1.status, "Cancelled")
+ self.assertEqual(so.items[0].stock_reserved_qty, 0)
+
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_cant_consume_reserved_stock(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ )
+ from erpnext.stock.stock_ledger import NegativeStockError
+
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Step - 1: Create a `Sales Order`
+ so = make_sales_order(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=50,
+ rate=100,
+ do_not_submit=True,
+ )
+ so.reserve_stock = 1 # Stock Reservation Entries will be created on submit
+ so.items[0].reserve_stock = 1
+ so.save()
+ so.submit()
+
+ actual_qty = get_stock_balance(item_code, warehouse)
+
+ # Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=actual_qty,
+ from_warehouse=warehouse,
+ rate=100,
+ purpose="Material Issue",
+ do_not_submit=True,
+ )
+ self.assertRaises(NegativeStockError, se.submit)
+ se.cancel()
+
+ # Step - 3: Unreserve the stock and consume the Available Qty via Stock Entry.
+ cancel_stock_reservation_entries(so.doctype, so.name)
+
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=actual_qty,
+ from_warehouse=warehouse,
+ rate=100,
+ purpose="Material Issue",
+ do_not_submit=True,
+ )
+ se.submit()
+ se.cancel()
+
+
+def create_items() -> dict:
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ items_details = {
+ # Stock Items
+ "SR Item 1": {"is_stock_item": 1, "valuation_rate": 100},
+ "SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"},
+ # Batch Items
+ "SR Batch Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBI-1-.#####.",
+ },
+ "SR Batch Item 2": {
+ "is_stock_item": 1,
+ "valuation_rate": 200,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBI-2-.#####.",
+ "stock_uom": "Kg",
+ },
+ # Serial Item
+ "SR Serial Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_serial_no": 1,
+ "serial_no_series": "SRSI-1-.#####",
+ },
+ # Batch and Serial Item
+ "SR Batch and Serial Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBSI-1-.#####.",
+ "has_serial_no": 1,
+ "serial_no_series": "SRBSI-1-.#####",
+ },
+ }
+
+ items = {}
+ for item_code, properties in items_details.items():
+ items[item_code] = make_item(item_code, properties)
+
+ return items
+
+
+def create_material_receipts(
+ items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
+) -> None:
+ for item in items.values():
+ if item.is_stock_item:
+ make_stock_entry(
+ item_code=item.item_code,
+ qty=qty,
+ to_warehouse=warehouse,
+ rate=item.valuation_rate,
+ purpose="Material Receipt",
+ )
+
+
+def cancel_all_stock_reservation_entries() -> None:
+ sre_list = frappe.db.get_all("Stock Reservation Entry", filters={"docstatus": 1}, pluck="name")
+
+ for sre in sre_list:
+ frappe.get_doc("Stock Reservation Entry", sre).cancel()
+
+
+def make_stock_reservation_entry(**args):
+ doc = frappe.new_doc("Stock Reservation Entry")
+ args = frappe._dict(args)
+
+ doc.item_code = args.item_code or "SR Item 1"
+ doc.warehouse = args.warehouse or "_Test Warehouse - _TC"
+ doc.voucher_type = args.voucher_type
+ doc.voucher_no = args.voucher_no
+ doc.voucher_detail_no = args.voucher_detail_no
+ doc.available_qty = args.available_qty or 100
+ doc.voucher_qty = args.voucher_qty or 50
+ doc.stock_uom = args.stock_uom or "Nos"
+ doc.reserved_qty = args.reserved_qty or 50
+ doc.delivered_qty = args.delivered_qty or 0
+ doc.company = args.company or "_Test Company"
+
+ if args.ignore_validate:
+ doc.flags.ignore_validate = True
+
+ if not args.do_not_save:
+ doc.save()
+ if not args.do_not_submit:
+ doc.submit()
+
+ return doc
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index ec7fb0f..35970b1 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -31,6 +31,11 @@
"action_if_quality_inspection_is_not_submitted",
"column_break_23",
"action_if_quality_inspection_is_rejected",
+ "stock_reservation_tab",
+ "enable_stock_reservation",
+ "column_break_rx3e",
+ "reserve_stock_on_sales_order_submission",
+ "allow_partial_reservation",
"serial_and_batch_item_settings_tab",
"section_break_7",
"automatically_set_serial_nos_based_on_fifo",
@@ -339,6 +344,37 @@
{
"fieldname": "column_break_121",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "stock_reservation_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Reservation"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_stock_reservation",
+ "fieldtype": "Check",
+ "label": "Enable Stock Reservation"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
+ "fieldname": "reserve_stock_on_sales_order_submission",
+ "fieldtype": "Check",
+ "label": "Reserve Stock on Sales Order Submission"
+ },
+ {
+ "fieldname": "column_break_rx3e",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a <b>Sales Order</b> of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
+ "fieldname": "allow_partial_reservation",
+ "fieldtype": "Check",
+ "label": "Allow Partial Reservation"
}
],
"icon": "icon-cog",
@@ -346,7 +382,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-02-05 15:33:43.692736",
+ "modified": "2023-04-22 08:48:37.767646",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 50807a9..c9b75a1 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -55,6 +55,7 @@
self.cant_change_valuation_method()
self.validate_clean_description_html()
self.validate_pending_reposts()
+ self.validate_stock_reservation()
def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
@@ -99,6 +100,68 @@
if self.stock_frozen_upto:
check_pending_reposting(self.stock_frozen_upto)
+ def validate_stock_reservation(self):
+ """Raises an exception if the user tries to enable/disable `Stock Reservation` with `Negative Stock` or `Open Stock Reservation Entries`."""
+
+ # Skip validation for tests
+ if frappe.flags.in_test:
+ return
+
+ db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
+ db_enable_stock_reservation = frappe.db.get_single_value(
+ "Stock Settings", "enable_stock_reservation"
+ )
+
+ # Change in value of `Allow Negative Stock`
+ if db_allow_negative_stock != self.allow_negative_stock:
+
+ # Disable -> Enable: Don't allow if `Stock Reservation` is enabled
+ if self.allow_negative_stock and self.enable_stock_reservation:
+ frappe.throw(
+ _("As {0} is enabled, you can not enable {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Allow Negative Stock")
+ )
+ )
+
+ # Change in value of `Enable Stock Reservation`
+ if db_enable_stock_reservation != self.enable_stock_reservation:
+
+ # Disable -> Enable
+ if self.enable_stock_reservation:
+
+ # Don't allow if `Allow Negative Stock` is enabled
+ if self.allow_negative_stock:
+ frappe.throw(
+ _("As {0} is enabled, you can not enable {1}.").format(
+ frappe.bold("Allow Negative Stock"), frappe.bold("Stock Reservation")
+ )
+ )
+
+ else:
+ # Don't allow if there are negative stock
+ has_negative_stock = frappe.db.exists("Bin", {"actual_qty": ["<", 0]})
+
+ if has_negative_stock:
+ frappe.throw(
+ _("As there are negative stock, you can not enable {0}.").format(
+ frappe.bold("Stock Reservation")
+ )
+ )
+
+ # Enable -> Disable
+ else:
+ # Don't allow if there are open Stock Reservation Entries
+ has_reserved_stock = frappe.db.exists(
+ "Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]}
+ )
+
+ if has_reserved_stock:
+ frappe.throw(
+ _("As there are reserved stock, you cannot disable {0}.").format(
+ frappe.bold("Stock Reservation")
+ )
+ )
+
def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer()
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 66991a9..d6febfe 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -58,6 +58,7 @@
return columns, []
iwb_map = get_item_warehouse_map(filters, sle)
+ sre_details = get_sre_reserved_qty_details(iwb_map)
item_map = get_item_details(items, sle, filters)
item_reorder_detail_map = get_item_reorder_details(item_map.keys())
@@ -88,6 +89,7 @@
"company": company,
"reorder_level": item_reorder_level,
"reorder_qty": item_reorder_qty,
+ "stock_reservation_qty": sre_details.get((item, warehouse), 0.0),
}
report_data.update(item_map[item])
report_data.update(qty_dict)
@@ -230,6 +232,13 @@
"convertible": "qty",
},
{
+ "label": _("Stock Reservation Qty"),
+ "fieldname": "stock_reservation_qty",
+ "fieldtype": "Float",
+ "width": 80,
+ "convertible": "qty",
+ },
+ {
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
@@ -411,6 +420,21 @@
return iwb_map
+def get_sre_reserved_qty_details(iwb_map: list) -> dict:
+ """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for d in iwb_map:
+ item_code_list.append(d[1])
+ warehouse_list.append(d[2])
+
+ return get_reserved_qty_details(item_code_list, warehouse_list)
+
+
def get_group_by_key(row, filters, inventory_dimension_fields) -> tuple:
group_by_key = [row.company, row.item_code, row.warehouse]
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 0f12987..4657cac 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -13,6 +13,9 @@
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
+)
from erpnext.stock.utils import (
get_incoming_outgoing_rate_for_cancel,
get_or_make_bin,
@@ -380,6 +383,7 @@
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.data = frappe._dict()
self.initialize_previous_data(self.args)
@@ -628,7 +632,7 @@
validate negative stock for entries current datetime onwards
will not consider cancelled entries
"""
- diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty)
+ diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) - flt(self.reserved_stock)
diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001:
@@ -1031,7 +1035,7 @@
) in frappe.local.flags.currently_saving:
msg = _("{0} units of {1} needed in {2} to complete this transaction.").format(
- abs(deficiency),
+ frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse),
)
@@ -1039,7 +1043,7 @@
msg = _(
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
).format(
- abs(deficiency),
+ frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse),
exceptions[0]["posting_date"],
@@ -1048,6 +1052,12 @@
)
if msg:
+ if self.reserved_stock:
+ allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"])
+ msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format(
+ msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
+ )
+
msg_list.append(msg)
if msg_list: