diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 465fe96b58b5..7c40aafbe05a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -556,6 +556,8 @@ def update_item(obj, target, source_parent): "bom": "bom", "material_request": "material_request", "material_request_item": "material_request_item", + "sales_order": "sales_order", + "sales_order_item": "sales_order_item", }, "postprocess": update_item, "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d59fe0ec4c8f..53bddb562c03 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -340,5 +340,6 @@ erpnext.patches.v14_0.update_invoicing_period_in_subscription execute:frappe.delete_doc("Page", "welcome-to-erpnext") erpnext.patches.v15_0.delete_payment_gateway_doctypes erpnext.patches.v14_0.create_accounting_dimensions_in_sales_order_item +erpnext.patches.v15_0.update_sre_from_voucher_details # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger \ No newline at end of file diff --git a/erpnext/patches/v15_0/update_sre_from_voucher_details.py b/erpnext/patches/v15_0/update_sre_from_voucher_details.py new file mode 100644 index 000000000000..a9653ccbf432 --- /dev/null +++ b/erpnext/patches/v15_0/update_sre_from_voucher_details.py @@ -0,0 +1,15 @@ +import frappe +from frappe.query_builder.functions import IfNull + + +def execute(): + sre = frappe.qb.DocType("Stock Reservation Entry") + ( + frappe.qb.update(sre) + .set(sre.from_voucher_type, "Pick List") + .set(sre.from_voucher_no, sre.against_pick_list) + .set(sre.from_voucher_detail_no, sre.against_pick_list_item) + .where( + (IfNull(sre.against_pick_list, "") != "") & (IfNull(sre.against_pick_list_item, "") != "") + ) + ).run() diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index ba8bc339f38f..3ad18daf1930 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -87,17 +87,13 @@ frappe.ui.form.on("Sales Order", { frm.events.get_items_from_internal_purchase_order(frm); } - if (frm.is_new()) { + if (frm.doc.docstatus === 0) { frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => { - if (value) { - frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order").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. + if (!value) { + // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and make the field read-only and hidden. frm.set_value("reserve_stock", 0); frm.set_df_property("reserve_stock", "read_only", 1); + frm.set_df_property("reserve_stock", "hidden", 1); } }) } diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index a74084d21fdd..01d047ceadba 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1631,10 +1631,9 @@ { "default": "0", "depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)", - "description": "If checked, Stock Reservation Entries will be created on Submit", + "description": "If checked, Stock will be reserved on Submit", "fieldname": "reserve_stock", "fieldtype": "Check", - "hidden": 1, "label": "Reserve Stock", "no_copy": 1, "print_hide": 1, @@ -1645,7 +1644,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-07-24 08:59:11.599875", + "modified": "2023-10-18 12:41:54.813462", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index b91002eb8667..94f9d6e37c4d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -3,6 +3,7 @@ import json +from typing import Literal import frappe import frappe.utils @@ -534,14 +535,24 @@ def has_unreserved_stock(self) -> bool: return False @frappe.whitelist() - def create_stock_reservation_entries(self, items_details=None, notify=True) -> None: + def create_stock_reservation_entries( + self, + items_details: list[dict] = None, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, + notify=True, + ) -> None: """Creates Stock Reservation Entries for Sales Order Items.""" from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, ) - create_stock_reservation_entries(so=self, items_details=items_details, notify=notify) + create_stock_reservation_entries( + sales_order=self, + items_details=items_details, + from_voucher_type=from_voucher_type, + notify=notify, + ) @frappe.whitelist() def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None: diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index ae05b80727f2..7cd171ea92ed 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -265,7 +265,8 @@ frappe.ui.form.on('Pick List', { from_date: moment(frm.doc.creation).format('YYYY-MM-DD'), to_date: to_date, voucher_type: "Sales Order", - against_pick_list: frm.doc.name, + from_voucher_type: "Pick List", + from_voucher_no: frm.doc.name, } frappe.set_route("query-report", "Reserved Stock"); } diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 2fcd1025a0e4..ed2020957749 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -229,20 +229,27 @@ def update_sales_order_picking_status(self) -> None: def create_stock_reservation_entries(self, notify=True) -> None: """Creates Stock Reservation Entries for Sales Order Items against Pick List.""" - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - create_stock_reservation_entries_for_so_items, - ) - - so_details = {} + so_items_details_map = {} for location in self.locations: if location.warehouse and location.sales_order and location.sales_order_item: - so_details.setdefault(location.sales_order, []).append(location) + item_details = { + "name": location.sales_order_item, + "item_code": location.item_code, + "warehouse": location.warehouse, + "qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)), + "from_voucher_no": location.parent, + "from_voucher_detail_no": location.name, + "serial_and_batch_bundle": location.serial_and_batch_bundle, + } + so_items_details_map.setdefault(location.sales_order, []).append(item_details) - if so_details: - for so, locations in so_details.items(): + if so_items_details_map: + for so, items_details in so_items_details_map.items(): so_doc = frappe.get_doc("Sales Order", so) - create_stock_reservation_entries_for_so_items( - so=so_doc, items_details=locations, against_pick_list=True, notify=notify + so_doc.create_stock_reservation_entries( + items_details=items_details, + from_voucher_type="Pick List", + notify=notify, ) @frappe.whitelist() @@ -253,7 +260,9 @@ def cancel_stock_reservation_entries(self, notify=True) -> None: cancel_stock_reservation_entries, ) - cancel_stock_reservation_entries(against_pick_list=self.name, notify=notify) + cancel_stock_reservation_entries( + from_voucher_type="Pick List", from_voucher_no=self.name, notify=notify + ) def validate_picked_qty(self, data): over_delivery_receipt_allowance = 100 + flt( diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index 0830fa21430f..29571a54007f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -2,7 +2,7 @@ def get_data(): return { "fieldname": "pick_list", "non_standard_fieldnames": { - "Stock Reservation Entry": "against_pick_list", + "Stock Reservation Entry": "from_voucher_no", }, "internal_links": { "Sales Order": ["locations", "sales_order"], diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index d89d8057a8b0..029d89c1794e 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -263,6 +263,7 @@ def on_submit(self): self.make_gl_entries() self.repost_future_sle_and_gle() self.set_consumed_qty_in_subcontract_order() + self.reserve_stock_for_sales_order() def check_next_docstatus(self): submit_rv = frappe.db.sql( @@ -759,6 +760,37 @@ def update_billing_status(self, update_modified=True): self.load_from_db() + def reserve_stock_for_sales_order(self): + if self.is_return or not cint( + frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order_on_purchase") + ): + return + + self.reload() # reload to get the Serial and Batch Bundle Details + + so_items_details_map = {} + for item in self.items: + if item.sales_order and item.sales_order_item: + item_details = { + "name": item.sales_order_item, + "item_code": item.item_code, + "warehouse": item.warehouse, + "qty_to_reserve": item.stock_qty, + "from_voucher_no": item.parent, + "from_voucher_detail_no": item.name, + "serial_and_batch_bundle": item.serial_and_batch_bundle, + } + so_items_details_map.setdefault(item.sales_order, []).append(item_details) + + if so_items_details_map: + for so, items_details in so_items_details_map.items(): + so_doc = frappe.get_doc("Sales Order", so) + so_doc.create_stock_reservation_entries( + items_details=items_details, + from_voucher_type="Purchase Receipt", + notify=True, + ) + def get_stock_value_difference(voucher_no, voucher_detail_no, warehouse): return frappe.db.get_value( diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py index b3ae7b58b498..71489fbb4948 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py @@ -10,6 +10,7 @@ def get_data(): "Landed Cost Voucher": "receipt_document", "Auto Repeat": "reference_document", "Purchase Receipt": "return_against", + "Stock Reservation Entry": "from_voucher_no", }, "internal_links": { "Material Request": ["items", "material_request"], @@ -18,7 +19,10 @@ def get_data(): "Quality Inspection": ["items", "quality_inspection"], }, "transactions": [ - {"label": _("Related"), "items": ["Purchase Invoice", "Landed Cost Voucher", "Asset"]}, + { + "label": _("Related"), + "items": ["Purchase Invoice", "Landed Cost Voucher", "Asset", "Stock Reservation Entry"], + }, { "label": _("Reference"), "items": ["Material Request", "Purchase Order", "Quality Inspection", "Project"], diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index d93d21c1f204..f5240a609419 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -125,7 +125,9 @@ "dimension_col_break", "cost_center", "section_break_80", - "page_break" + "page_break", + "sales_order", + "sales_order_item" ], "fields": [ { @@ -1062,12 +1064,32 @@ "fieldtype": "Link", "label": "WIP Composite Asset", "options": "Asset" + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "no_copy": 1, + "options": "Sales Order", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Sales Order Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "search_index": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-10-03 21:11:50.547261", + "modified": "2023-10-19 10:50:58.071735", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", @@ -1078,4 +1100,4 @@ "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.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js index c5df319e224c..f60a0378b60e 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js @@ -92,7 +92,7 @@ frappe.ui.form.on('Stock Reservation Entry', { 'qty', 'read_only', frm.doc.has_serial_no ); - frm.set_df_property('sb_entries', 'allow_on_submit', frm.doc.against_pick_list ? 0 : 1); + frm.set_df_property('sb_entries', 'allow_on_submit', frm.doc.from_voucher_type == "Pick List" ? 0 : 1); }, hide_rate_related_fields(frm) { diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 5c3018f73423..76cedd4b1e2c 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -17,8 +17,9 @@ "voucher_no", "voucher_detail_no", "column_break_7dxj", - "against_pick_list", - "against_pick_list_item", + "from_voucher_type", + "from_voucher_no", + "from_voucher_detail_no", "section_break_xt4m", "stock_uom", "column_break_grdt", @@ -158,7 +159,7 @@ "oldfieldname": "actual_qty", "oldfieldtype": "Currency", "print_width": "150px", - "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.against_pick_list) || (doc.delivered_qty > 0))", + "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.from_voucher_type == \"Pick List\") || (doc.delivered_qty > 0))", "width": "150px" }, { @@ -268,35 +269,45 @@ "label": "Reservation Based On", "no_copy": 1, "options": "Qty\nSerial and Batch", - "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.against_pick_list)" + "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.from_voucher_type == \"Pick List\")" }, { - "fieldname": "against_pick_list", - "fieldtype": "Link", - "label": "Against Pick List", + "fieldname": "column_break_7dxj", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_grdt", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_voucher_type", + "fieldtype": "Select", + "label": "From Voucher Type", "no_copy": 1, - "options": "Pick List", + "options": "\nPick List\nPurchase Receipt", "print_hide": 1, "read_only": 1, - "report_hide": 1, - "search_index": 1 + "report_hide": 1 }, { - "fieldname": "against_pick_list_item", + "fieldname": "from_voucher_detail_no", "fieldtype": "Data", - "label": "Against Pick List Item", + "label": "From Voucher Detail No", "no_copy": 1, "print_hide": 1, "read_only": 1, "report_hide": 1 }, { - "fieldname": "column_break_7dxj", - "fieldtype": "Column Break" - }, - { - "fieldname": "column_break_grdt", - "fieldtype": "Column Break" + "fieldname": "from_voucher_no", + "fieldtype": "Dynamic Link", + "label": "From Voucher No", + "no_copy": 1, + "options": "from_voucher_type", + "print_hide": 1, + "read_only": 1, + "report_hide": 1, + "search_index": 1 } ], "hide_toolbar": 1, @@ -304,7 +315,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-08-08 17:15:13.317706", + "modified": "2023-10-19 16:41:16.545416", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 936be3f73b4b..81e9dfa69baf 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1,6 +1,8 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from typing import Literal + import frappe from frappe import _ from frappe.model.document import Document @@ -113,7 +115,7 @@ def auto_reserve_serial_and_batch(self, based_on: str = None) -> None: """Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`.""" if ( - not self.against_pick_list + not self.from_voucher_type and (self.get("_action") == "submit") and (self.has_serial_no or self.has_batch_no) and cint(frappe.db.get_single_value("Stock Settings", "auto_reserve_serial_and_batch")) @@ -316,21 +318,24 @@ def update_reserved_qty_in_pick_list( ) -> None: """Updates total reserved qty in the Pick List.""" - if self.against_pick_list and self.against_pick_list_item: + if ( + self.from_voucher_type == "Pick List" and self.from_voucher_no and self.from_voucher_detail_no + ): sre = frappe.qb.DocType("Stock Reservation Entry") reserved_qty = ( frappe.qb.from_(sre) .select(Sum(sre.reserved_qty)) .where( (sre.docstatus == 1) - & (sre.against_pick_list == self.against_pick_list) - & (sre.against_pick_list_item == self.against_pick_list_item) + & (sre.from_voucher_type == "Pick List") + & (sre.from_voucher_no == self.from_voucher_no) + & (sre.from_voucher_detail_no == self.from_voucher_detail_no) ) ).run(as_list=True)[0][0] or 0 frappe.db.set_value( "Pick List Item", - self.against_pick_list_item, + self.from_voucher_detail_no, reserved_qty_field, reserved_qty, update_modified=update_modified, @@ -365,7 +370,7 @@ def can_be_updated(self) -> None: ).format(self.status, self.doctype) frappe.throw(msg) - if self.against_pick_list: + if self.from_voucher_type == "Pick List": msg = _( "Stock Reservation Entry created against a Pick List cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one." ) @@ -761,25 +766,27 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st def create_stock_reservation_entries_for_so_items( - so: object, + sales_order: object, items_details: list[dict] = None, - against_pick_list: bool = False, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, notify=True, ) -> None: """Creates Stock Reservation Entries for Sales Order Items.""" from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty - if not against_pick_list and ( - so.get("_action") == "submit" - and so.set_warehouse - and cint(frappe.get_cached_value("Warehouse", so.set_warehouse, "is_group")) + if not from_voucher_type and ( + sales_order.get("_action") == "submit" + and sales_order.set_warehouse + and cint(frappe.get_cached_value("Warehouse", sales_order.set_warehouse, "is_group")) ): return frappe.msgprint( - _("Stock cannot be reserved in the group warehouse {0}.").format(frappe.bold(so.set_warehouse)) + _("Stock cannot be reserved in the group warehouse {0}.").format( + frappe.bold(sales_order.set_warehouse) + ) ) - validate_stock_reservation_settings(so) + validate_stock_reservation_settings(sales_order) allow_partial_reservation = frappe.db.get_single_value( "Stock Settings", "allow_partial_reservation" @@ -788,38 +795,36 @@ def create_stock_reservation_entries_for_so_items( items = [] if items_details: for item in items_details: - so_item = frappe.get_doc( - "Sales Order Item", item.get("sales_order_item") if against_pick_list else item.get("name") - ) - so_item.reserve_stock = 1 + so_item = frappe.get_doc("Sales Order Item", item.get("name")) so_item.warehouse = item.get("warehouse") so_item.qty_to_reserve = ( - item.get("picked_qty") - item.get("stock_reserved_qty", 0) - if against_pick_list - else (flt(item.get("qty_to_reserve")) * flt(so_item.conversion_factor, 1)) + flt(item.get("qty_to_reserve")) + if from_voucher_type in ["Pick List", "Purchase Receipt"] + else ( + flt(item.get("qty_to_reserve")) + * (flt(item.get("conversion_factor")) or flt(so_item.conversion_factor) or 1) + ) ) - - if against_pick_list: - so_item.pick_list = item.get("parent") - so_item.pick_list_item = item.get("name") - so_item.pick_list_sbb = item.get("serial_and_batch_bundle") + so_item.from_voucher_no = item.get("from_voucher_no") + so_item.from_voucher_detail_no = item.get("from_voucher_detail_no") + so_item.serial_and_batch_bundle = item.get("serial_and_batch_bundle") items.append(so_item) sre_count = 0 - reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", sales_order.name) - for item in items if items_details else so.get("items"): + for item in items if items_details else sales_order.get("items"): # Skip if `Reserved Stock` is not checked for the item. if not item.get("reserve_stock"): continue # Stock should be reserved from the Pick List if has Picked Qty. - if not against_pick_list and flt(item.picked_qty) > 0: + if not from_voucher_type == "Pick List" and flt(item.picked_qty) > 0: frappe.throw( - _( - "Row #{0}: Item {1} has been picked, please create a Stock Reservation from the Pick List." - ).format(item.idx, frappe.bold(item.item_code)) + _("Row #{0}: Item {1} has been picked, please reserve stock from the Pick List.").format( + item.idx, frappe.bold(item.item_code) + ) ) is_stock_item, has_serial_no, has_batch_no = frappe.get_cached_value( @@ -828,13 +833,15 @@ def create_stock_reservation_entries_for_so_items( # Skip if Non-Stock Item. if not 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", - ) + if not from_voucher_type: + 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 @@ -853,13 +860,15 @@ def create_stock_reservation_entries_for_so_items( # 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", - ) + if not from_voucher_type: + 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) @@ -867,7 +876,7 @@ def create_stock_reservation_entries_for_so_items( # 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( + _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) ), title=_("Stock Reservation"), @@ -893,7 +902,9 @@ def create_stock_reservation_entries_for_so_items( # 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")): + if not from_voucher_type and ( + not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")) + ): msg = _("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), @@ -915,33 +926,42 @@ def create_stock_reservation_entries_for_so_items( sre.warehouse = item.warehouse sre.has_serial_no = has_serial_no sre.has_batch_no = has_batch_no - sre.voucher_type = so.doctype - sre.voucher_no = so.name + sre.voucher_type = sales_order.doctype + sre.voucher_no = sales_order.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 = so.company + sre.company = sales_order.company sre.stock_uom = item.stock_uom - sre.project = so.project - - if against_pick_list: - sre.against_pick_list = item.pick_list - sre.against_pick_list_item = item.pick_list_item + sre.project = sales_order.project + + if from_voucher_type: + sre.from_voucher_type = from_voucher_type + sre.from_voucher_no = item.from_voucher_no + sre.from_voucher_detail_no = item.from_voucher_detail_no + + if item.get("serial_and_batch_bundle"): + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + sre.reservation_based_on = "Serial and Batch" + + index, picked_qty = 0, 0 + while index < len(sbb.entries) and picked_qty < qty_to_be_reserved: + entry = sbb.entries[index] + qty = 1 if has_serial_no else min(abs(entry.qty), qty_to_be_reserved - picked_qty) + + sre.append( + "sb_entries", + { + "serial_no": entry.serial_no, + "batch_no": entry.batch_no, + "qty": qty, + "warehouse": entry.warehouse, + }, + ) - if item.pick_list_sbb: - sbb = frappe.get_doc("Serial and Batch Bundle", item.pick_list_sbb) - sre.reservation_based_on = "Serial and Batch" - for entry in sbb.entries: - sre.append( - "sb_entries", - { - "serial_no": entry.serial_no, - "batch_no": entry.batch_no, - "qty": 1 if has_serial_no else abs(entry.qty), - "warehouse": entry.warehouse, - }, - ) + index += 1 + picked_qty += qty sre.save() sre.submit() @@ -956,29 +976,37 @@ def cancel_stock_reservation_entries( voucher_type: str = None, voucher_no: str = None, voucher_detail_no: str = None, - against_pick_list: str = None, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, + from_voucher_no: str = None, + from_voucher_detail_no: str = None, sre_list: list[dict] = None, notify: bool = True, ) -> None: """Cancel Stock Reservation Entries.""" - if not sre_list and against_pick_list: - sre = frappe.qb.DocType("Stock Reservation Entry") - sre_list = ( - frappe.qb.from_(sre) - .select(sre.name) - .where( - (sre.docstatus == 1) - & (sre.against_pick_list == against_pick_list) - & (sre.status.notin(["Delivered", "Cancelled"])) + if not sre_list: + if voucher_type and voucher_no: + sre_list = get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"] + ) + elif from_voucher_type and from_voucher_no: + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.name) + .where( + (sre.docstatus == 1) + & (sre.from_voucher_type == from_voucher_type) + & (sre.from_voucher_no == from_voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) ) - .orderby(sre.creation) - ).run(as_dict=True) - elif not sre_list and (voucher_type and voucher_no): - sre_list = get_stock_reservation_entries_for_voucher( - voucher_type, voucher_no, voucher_detail_no, fields=["name"] - ) + if from_voucher_detail_no: + query = query.where(sre.from_voucher_detail_no == from_voucher_detail_no) + + sre_list = query.run(as_dict=True) if sre_list: for sre in sre_list: 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 index 1168a4e1c618..f4c74a8aacbd 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -5,6 +5,7 @@ import frappe from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import today from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order @@ -28,10 +29,6 @@ def setUp(self) -> None: items={self.sr_item.name: self.sr_item}, warehouse=self.warehouse, qty=100 ) - def tearDown(self) -> None: - cancel_all_stock_reservation_entries() - return super().tearDown() - @change_settings("Stock Settings", {"allow_negative_stock": 0}) def test_validate_stock_reservation_settings(self) -> None: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( @@ -555,8 +552,9 @@ def test_stock_reservation_from_pick_list(self): (sre.voucher_type == "Sales Order") & (sre.voucher_no == location.sales_order) & (sre.voucher_detail_no == location.sales_order_item) - & (sre.against_pick_list == pl.name) - & (sre.against_pick_list_item == location.name) + & (sre.from_voucher_type == "Pick List") + & (sre.from_voucher_no == pl.name) + & (sre.from_voucher_detail_no == location.name) ) ).run(as_dict=True) reserved_sb_details: set[tuple] = { @@ -567,6 +565,90 @@ def test_stock_reservation_from_pick_list(self): # Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos. self.assertSetEqual(picked_sb_details, reserved_sb_details) + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 1, + "pick_serial_and_batch_based_on": "FIFO", + "auto_reserve_stock_for_sales_order_on_purchase": 1, + }, + ) + def test_stock_reservation_from_purchase_receipt(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.selling.doctype.sales_order.sales_order import make_material_request + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + + items_details = create_items() + create_material_receipt(items_details, self.warehouse, qty=10) + + item_list = [] + for item_code, properties in items_details.items(): + item_list.append( + { + "item_code": item_code, + "warehouse": self.warehouse, + "qty": randint(11, 100), + "uom": properties.stock_uom, + "rate": randint(10, 400), + } + ) + + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + + mr = make_material_request(so.name) + mr.schedule_date = today() + mr.save().submit() + + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.save().submit() + + pr = make_purchase_receipt(po.name) + pr.save().submit() + + for item in pr.items: + sre, status, reserved_qty = frappe.db.get_value( + "Stock Reservation Entry", + { + "from_voucher_type": "Purchase Receipt", + "from_voucher_no": pr.name, + "from_voucher_detail_no": item.name, + }, + ["name", "status", "reserved_qty"], + ) + + # Test - 1: SRE status should be `Reserved`. + self.assertEqual(status, "Reserved") + + # Test - 2: SRE Reserved Qty should be equal to PR Item Qty. + self.assertEqual(reserved_qty, item.qty) + + if item.serial_and_batch_bundle: + sb_details = frappe.db.get_all( + "Serial and Batch Entry", + filters={"parent": item.serial_and_batch_bundle}, + fields=["serial_no", "batch_no", "qty"], + as_list=True, + ) + reserved_sb_details = frappe.db.get_all( + "Serial and Batch Entry", + filters={"parent": sre}, + fields=["serial_no", "batch_no", "qty"], + as_list=True, + ) + + # Test - 3: Reserved Serial/Batch Nos should be equal to PR Item Serial/Batch Nos. + self.assertEqual(set(sb_details), set(reserved_sb_details)) + + def tearDown(self) -> None: + cancel_all_stock_reservation_entries() + return super().tearDown() + def create_items() -> dict: items_properties = [ diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 2052daafedb3..122829032def 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -38,8 +38,8 @@ "stock_reservation_tab", "enable_stock_reservation", "column_break_rx3e", - "auto_reserve_stock_for_sales_order", "allow_partial_reservation", + "auto_reserve_stock_for_sales_order_on_purchase", "serial_and_batch_reservation_section", "auto_reserve_serial_and_batch", "serial_and_batch_item_settings_tab", @@ -65,8 +65,7 @@ "stock_frozen_upto_days", "column_break_26", "role_allowed_to_create_edit_back_dated_transactions", - "stock_auth_role", - "section_break_plhx" + "stock_auth_role" ], "fields": [ { @@ -356,7 +355,7 @@ { "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. ", + "description": "Partial stock can be reserved. 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" @@ -383,7 +382,7 @@ { "default": "1", "depends_on": "eval: doc.enable_stock_reservation", - "description": "If enabled, Serial and Batch Nos will be auto-reserved based on Pick Serial / Batch Based On", + "description": "Serial and Batch Nos will be auto-reserved based on Pick Serial / Batch Based On", "fieldname": "auto_reserve_serial_and_batch", "fieldtype": "Check", "label": "Auto Reserve Serial and Batch Nos" @@ -393,14 +392,6 @@ "fieldtype": "Section Break", "label": "Serial and Batch 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": "auto_reserve_stock_for_sales_order", - "fieldtype": "Check", - "label": "Auto Reserve Stock for Sales Order" - }, { "fieldname": "conversion_factor_section", "fieldtype": "Section Break", @@ -421,6 +412,14 @@ "fieldname": "allow_to_edit_stock_uom_qty_for_purchase", "fieldtype": "Check", "label": "Allow to Edit Stock UOM Qty for Purchase Documents" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_stock_reservation", + "description": "Stock will be reserved on submission of Purchase Receipt created against Material Receipt for Sales Order.", + "fieldname": "auto_reserve_stock_for_sales_order_on_purchase", + "fieldtype": "Check", + "label": "Auto Reserve Stock for Sales Order on Purchase" } ], "icon": "icon-cog", @@ -428,7 +427,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-01 14:22:36.136111", + "modified": "2023-10-18 12:35:30.068799", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", @@ -453,4 +452,4 @@ "sort_order": "ASC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.js b/erpnext/stock/report/reserved_stock/reserved_stock.js index 2199f52df039..68727411d5a4 100644 --- a/erpnext/stock/report/reserved_stock/reserved_stock.js +++ b/erpnext/stock/report/reserved_stock/reserved_stock.js @@ -91,16 +91,30 @@ frappe.query_reports["Reserved Stock"] = { }, }, { - fieldname: "against_pick_list", - label: __("Against Pick List"), + fieldname: "from_voucher_type", + label: __("From Voucher Type"), fieldtype: "Link", - options: "Pick List", + options: "DocType", + get_query: () => ({ + filters: { + name: ["in", ["Pick List", "Purchase Receipt"]], + } + }), + }, + { + fieldname: "from_voucher_no", + label: __("From Voucher No"), + fieldtype: "Dynamic Link", + options: "from_voucher_type", get_query: () => ({ filters: { docstatus: 1, company: frappe.query_report.get_filter_value("company"), }, }), + get_options: function () { + return frappe.query_report.get_filter_value("from_voucher_type"); + }, }, { fieldname: "reservation_based_on", diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.py b/erpnext/stock/report/reserved_stock/reserved_stock.py index d93ee1c88f8e..21ce203ad6d0 100644 --- a/erpnext/stock/report/reserved_stock/reserved_stock.py +++ b/erpnext/stock/report/reserved_stock/reserved_stock.py @@ -44,7 +44,8 @@ def get_data(filters): (sre.available_qty - sre.reserved_qty).as_("available_qty"), sre.voucher_type, sre.voucher_no, - sre.against_pick_list, + sre.from_voucher_type, + sre.from_voucher_no, sre.name.as_("stock_reservation_entry"), sre.status, sre.project, @@ -65,7 +66,8 @@ def get_data(filters): "warehouse", "voucher_type", "voucher_no", - "against_pick_list", + "from_voucher_type", + "from_voucher_no", "reservation_based_on", "status", "project", @@ -142,7 +144,6 @@ def get_columns(): "fieldname": "voucher_type", "label": _("Voucher Type"), "fieldtype": "Data", - "options": "Warehouse", "width": 110, }, { @@ -153,11 +154,17 @@ def get_columns(): "width": 120, }, { - "fieldname": "against_pick_list", - "label": _("Against Pick List"), - "fieldtype": "Link", - "options": "Pick List", - "width": 130, + "fieldname": "from_voucher_type", + "label": _("From Voucher Type"), + "fieldtype": "Data", + "width": 110, + }, + { + "fieldname": "from_voucher_no", + "label": _("From Voucher No"), + "fieldtype": "Dynamic Link", + "options": "from_voucher_type", + "width": 120, }, { "fieldname": "stock_reservation_entry",