diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 78bb05671d..20b332e782 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2826,6 +2826,17 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil 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 e9a6cc385d..5d43a07d96 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -47,21 +47,50 @@ frappe.ui.form.on("Sales Order", { frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, + refresh: function(frm) { - if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed' - && flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) { - frm.add_custom_button(__('Update Items'), () => { - erpnext.utils.update_child_items({ - frm: frm, - child_docname: "items", - child_doctype: "Sales Order Detail", - cannot_add_row: false, - }) - }); + if(frm.doc.docstatus === 1) { + if (frm.doc.status !== 'Closed' && flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) { + frm.add_custom_button(__('Update Items'), () => { + erpnext.utils.update_child_items({ + frm: frm, + child_docname: "items", + child_doctype: "Sales Order Detail", + cannot_add_row: false, + }) + }); + + // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. + if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { + frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation')); + } + } + + // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock. + if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) { + frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation')); + } } - 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()) { + frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => { + if (value) { + frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((value) => { + // 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); + } + }) + } } }, @@ -137,6 +166,108 @@ frappe.ui.form.on("Sales Order", { if(!d.delivery_date) d.delivery_date = frm.doc.delivery_date; }); refresh_field("items"); + }, + + create_stock_reservation_entries(frm) { + let items_data = []; + + const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'), + fields: [ + { + fieldtype: 'Data', + fieldname: 'name', + label: __('Name'), + reqd: 1, + read_only: 1, + }, + { + fieldtype: 'Link', + fieldname: 'item_code', + label: __('Item Code'), + options: 'Item', + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldtype: 'Link', + fieldname: 'warehouse', + label: __('Warehouse'), + options: 'Warehouse', + reqd: 1, + in_list_view: 1, + get_query: function () { + return { + filters: [ + ["Warehouse", "is_group", "!=", 1] + ] + }; + }, + }, + { + fieldtype: 'Float', + fieldname: 'qty_to_reserve', + label: __('Qty'), + reqd: 1, + in_list_view: 1 + } + ], + data: items_data, + in_place_edit: true, + get_data: function() { + return items_data; + } + }, function(data) { + if (data.items.length > 0) { + frappe.call({ + doc: frm.doc, + method: 'create_stock_reservation_entries', + args: { + items_details: data.items, + notify: true + }, + freeze: true, + freeze_message: __('Reserving Stock...'), + callback: (r) => { + frm.doc.__onload.has_unreserved_stock = false; + frm.reload_doc(); + } + }); + } + }, __("Stock Reservation"), __("Reserve Stock")); + + frm.doc.items.forEach(item => { + if (item.reserve_stock) { + let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty)) + + if (unreserved_qty > 0) { + dialog.fields_dict.items.df.data.push({ + 'name': item.name, + 'item_code': item.item_code, + 'warehouse': item.warehouse, + 'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor)) + }); + } + } + }); + + dialog.fields_dict.items.grid.refresh(); + }, + + cancel_stock_reservation_entries(frm) { + frappe.call({ + method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries', + args: { + voucher_type: frm.doctype, + voucher_no: frm.docname + }, + freeze: true, + freeze_message: __('Unreserving Stock...'), + callback: (r) => { + frm.doc.__onload.has_reserved_stock = false; + frm.reload_doc(); + } + }) } }); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 4f498fb20d..f7143d7594 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 Submit", + "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 06467e51a6..353fa9bb29 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.manufacturing.doctype.production_plan.production_plan import ( 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,14 @@ class SalesOrder(SellingController): def __init__(self, *args, **kwargs): super(SalesOrder, self).__init__(*args, **kwargs) + def onload(self) -> None: + if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): + if self.has_unreserved_stock(): + self.set_onload("has_unreserved_stock", True) + + if has_reserved_stock(self.doctype, self.name): + self.set_onload("has_reserved_stock", True) + def validate(self): super(SalesOrder, self).validate() self.validate_delivery_date() @@ -241,6 +254,9 @@ class SalesOrder(SellingController): 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 +273,7 @@ class SalesOrder(SellingController): 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 +502,166 @@ class SalesOrder(SellingController): ).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, items_details=None, 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" + ) + + items = [] + if items_details: + for item in items_details: + so_item = frappe.get_doc("Sales Order Item", item["name"]) + so_item.reserve_stock = 1 + so_item.warehouse = item["warehouse"] + so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor) + items.append(so_item) + + sre_count = 0 + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) + for item in items or 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 + + # Skip if Group Warehouse. + if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format( + item.idx, frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + 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"), + indicator="yellow", + ) + 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} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + # The quantity which can be reserved. + qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) + + if hasattr(item, "qty_to_reserve"): + if item.qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + else: + qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve) + + # Partial Reservation + if qty_to_be_reserved < unreserved_qty: + if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")): + 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, 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 @@ -680,7 +857,6 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): } 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 cbc40bbf90..c84009725b 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 @@ def get_data(): "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 @@ def get_data(): {"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 9854f159cf..88bc4bd3ce 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 @@ class TestSalesOrder(FrappeTestCase): 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] + 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 test_delivered_item_material_request(self): "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." from erpnext.manufacturing.doctype.work_order.work_order import ( 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 d0dabad5c9..5c7e10a232 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 e404d0b469..2ee372e155 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -151,6 +151,8 @@ class DeliveryNote(SellingController): 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): @@ -243,6 +245,8 @@ class DeliveryNote(SellingController): 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: @@ -272,6 +276,90 @@ class DeliveryNote(SellingController): 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 + + # Set `Warehouse` from SRE if not set. + if not item.warehouse: + item.warehouse = sre_data[0] + else: + # Throw if `Warehouse` is different from SRE. + if item.warehouse != sre_data[0]: + frappe.throw( + _("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0]) + ), + title=_("Stock Reservation Warehouse Mismatch"), + ) + 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 3fd4cec5d8..8d8b69de01 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -47,6 +47,7 @@ class StockReconciliation(StockController): self.validate_putaway_capacity() if self._action == "submit": + self.validate_reserved_stock() self.make_batches("warehouse") def on_submit(self): @@ -60,6 +61,7 @@ class StockReconciliation(StockController): 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 @@ class StockReconciliation(StockController): 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 += "
  • {0} units of Item {1} in Warehouse {2}
  • ".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:

    {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 0000000000..e69de29bb2 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 0000000000..666fd24329 --- /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 0000000000..7c7abacd91 --- /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 0000000000..5819dd7342 --- /dev/null +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -0,0 +1,312 @@ +# 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() + self.validate_for_group_warehouse() + 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 validate_for_group_warehouse(self) -> None: + """Raises exception if `Warehouse` is a Group Warehouse.""" + + if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"): + frappe.throw( + _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)), + title=_("Invalid Warehouse"), + ) + + 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.utils import get_stock_balance + + available_qty = get_stock_balance(item_code, warehouse) + + if available_qty: + 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 == warehouse) + & (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) -> dict: + """Returns a dict like {"voucher_detail_no": "reserved_qty", ... }.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + data = ( + frappe.qb.from_(sre) + .select( + sre.voucher_detail_no, + (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) + ).run(as_list=True) + + return frappe._dict(data) + + +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"])) + ) + .orderby(sre.creation) + .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 0000000000..442ac39f13 --- /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 0000000000..5a082ddfe6 --- /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 ec7fb0f4a2..35970b154b 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, Stock Reservation Entries will be created on submission of Sales Order", + "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, Partial Stock Reservation Entries can be created. For example, If you have a Sales Order 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 50807a96ab..e25c8439ca 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -55,6 +55,7 @@ class StockSettings(Document): 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,74 @@ class StockSettings(Document): 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 + from frappe.query_builder.functions import Round + + precision = frappe.db.get_single_value("System Settings", "float_precision") or 3 + bin = frappe.qb.DocType("Bin") + bin_with_negative_stock = ( + frappe.qb.from_(bin).select(bin.name).where(Round(bin.actual_qty, precision) < 0).limit(1) + ).run() + + if bin_with_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 7c821700df..f2c2e27cd9 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -100,6 +100,7 @@ class StockBalanceReport(object): _func = itemgetter(1) self.item_warehouse_map = self.get_item_warehouse_map() + sre_details = self.get_sre_reserved_qty_details() variant_values = {} if self.filters.get("show_variant_attributes"): @@ -133,6 +134,9 @@ class StockBalanceReport(object): report_data.update(stock_ageing_data) + report_data.update( + {"reserved_stock": sre_details.get((report_data.item_code, report_data.warehouse), 0.0)} + ) self.data.append(report_data) def get_item_warehouse_map(self): @@ -159,6 +163,18 @@ class StockBalanceReport(object): return item_warehouse_map + def get_sre_reserved_qty_details(self) -> dict: + 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 self.item_warehouse_map: + item_code_list.append(d[1]) + warehouse_list.append(d[2]) + + return get_reserved_qty_details(item_code_list, warehouse_list) + def prepare_item_warehouse_map(self, item_warehouse_map, entry, group_by_key): qty_dict = item_warehouse_map[group_by_key] for field in self.inventory_dimensions: @@ -435,6 +451,13 @@ class StockBalanceReport(object): "convertible": "rate", "options": "currency", }, + { + "label": _("Reserved Stock"), + "fieldname": "reserved_stock", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, { "label": _("Company"), "fieldname": "company", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 6106809273..711694b23a 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -13,6 +13,9 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdat 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 @@ 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.data = frappe._dict() self.initialize_previous_data(self.args) @@ -443,12 +447,11 @@ class update_entries_after(object): i += 1 self.process_sle(sle) + self.update_bin_data(sle) if sle.dependant_sle_voucher_detail_no: entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) - self.update_bin() - if self.exceptions: self.raise_exceptions() @@ -628,7 +631,7 @@ class update_entries_after(object): 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: @@ -1039,7 +1042,7 @@ class update_entries_after(object): ) 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), ) @@ -1047,7 +1050,7 @@ class update_entries_after(object): 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"], @@ -1056,6 +1059,12 @@ class update_entries_after(object): ) 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: @@ -1065,6 +1074,18 @@ class update_entries_after(object): else: raise NegativeStockError(message) + def update_bin_data(self, sle): + bin_name = get_or_make_bin(sle.item_code, sle.warehouse) + values_to_update = { + "actual_qty": sle.qty_after_transaction, + "stock_value": sle.stock_value, + } + + if sle.valuation_rate is not None: + values_to_update["valuation_rate"] = sle.valuation_rate + + frappe.db.set_value("Bin", bin_name, values_to_update) + def update_bin(self): # update bin for each warehouse for warehouse, data in self.data.items(): diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 10654ddc21..ba36983150 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -220,7 +220,7 @@ def get_bin(item_code, warehouse): def get_or_make_bin(item_code: str, warehouse: str) -> str: - bin_record = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + bin_record = frappe.get_cached_value("Bin", {"item_code": item_code, "warehouse": warehouse}) if not bin_record: bin_obj = _create_bin(item_code, warehouse)