From 085d9ce004d4d56ed870e6db24e550c28bc0c135 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 18 Mar 2023 11:20:29 +0530 Subject: [PATCH 01/79] feat: add DocType `Stock Reservation Entry` --- .../stock_reservation_entry/__init__.py | 0 .../stock_reservation_entry.js | 8 + .../stock_reservation_entry.json | 228 ++++++++++++++++++ .../stock_reservation_entry.py | 9 + .../test_stock_reservation_entry.py | 9 + 5 files changed, 254 insertions(+) create mode 100644 erpnext/stock/doctype/stock_reservation_entry/__init__.py create mode 100644 erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js create mode 100644 erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json create mode 100644 erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py create mode 100644 erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py 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..c898816d0e --- /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) { + +// }, +// }); 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..47360319c6 --- /dev/null +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -0,0 +1,228 @@ +{ + "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", + "posting_date", + "posting_time", + "column_break_6", + "voucher_type", + "voucher_no", + "voucher_detail_no", + "section_break_11", + "reserved_qty", + "column_break_17", + "valuation_rate", + "section_break_21", + "company", + "stock_uom", + "project", + "column_break_26", + "is_cancelled" + ], + "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": "posting_date", + "fieldtype": "Date", + "in_filter": 1, + "in_list_view": 1, + "label": "Posting Date", + "oldfieldname": "posting_date", + "oldfieldtype": "Date", + "print_width": "100px", + "read_only": 1, + "search_index": 1, + "width": "100px" + }, + { + "fieldname": "posting_time", + "fieldtype": "Time", + "label": "Posting Time", + "oldfieldname": "posting_time", + "oldfieldtype": "Time", + "print_width": "100px", + "read_only": 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": "valuation_rate", + "fieldtype": "Currency", + "label": "Valuation Rate", + "oldfieldname": "valuation_rate", + "oldfieldtype": "Currency", + "options": "Company:company:default_currency", + "print_width": "150px", + "read_only": 1, + "width": "150px" + }, + { + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" + }, + { + "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" + }, + { + "default": "0", + "fieldname": "is_cancelled", + "fieldtype": "Check", + "label": "Is Cancelled", + "report_hide": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_21", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, + { + "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" + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-03-20 11:17:35.898760", + "modified_by": "Administrator", + "module": "Stock", + "name": "Stock Reservation Entry", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 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..0b9f4a9357 --- /dev/null +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class StockReservationEntry(Document): + pass 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..e7b829e7c1 --- /dev/null +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestStockReservationEntry(FrappeTestCase): + pass From 7eb20752650966dd6d9abcf3ce4f947e13347b60 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 19 Mar 2023 11:45:35 +0530 Subject: [PATCH 02/79] feat: add settings for `Stock Reservation` in `Stock Settings` --- .../stock_settings/stock_settings.json | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ec7fb0f4a2..116e536310 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -31,6 +31,10 @@ "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", "serial_and_batch_item_settings_tab", "section_break_7", "automatically_set_serial_nos_based_on_fifo", @@ -339,6 +343,28 @@ { "fieldname": "column_break_121", "fieldtype": "Column Break" + }, + { + "fieldname": "stock_reservation_tab", + "fieldtype": "Tab Break", + "label": "Stock Reservation" + }, + { + "default": "1", + "fieldname": "enable_stock_reservation", + "fieldtype": "Check", + "label": "Enable Stock Reservation" + }, + { + "default": "1", + "depends_on": "eval: doc.enable_stock_reservation", + "fieldname": "reserve_stock_on_sales_order_submission", + "fieldtype": "Check", + "label": "Reserve Stock on Sales Order Submission" + }, + { + "fieldname": "column_break_rx3e", + "fieldtype": "Column Break" } ], "icon": "icon-cog", @@ -346,7 +372,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-02-05 15:33:43.692736", + "modified": "2023-03-20 11:42:29.769937", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From da1455198e18ce7acf1cf2817793a419c78e25e7 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 12:03:47 +0530 Subject: [PATCH 03/79] chore: field validation for `Stock Reservation Entry` --- .../stock_reservation_entry.js | 10 +++--- .../stock_reservation_entry.py | 35 ++++++++++++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) 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 c898816d0e..666fd24329 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js @@ -1,8 +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) { - -// }, -// }); +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.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 0b9f4a9357..50a05bce45 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1,9 +1,36 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe -from frappe.model.document import Document +import frappe +from frappe import _ + +from erpnext.utilities.transaction_base import TransactionBase -class StockReservationEntry(Document): - pass +class StockReservationEntry(TransactionBase): + def validate(self): + from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company + + self.validate_posting_time() + self.validate_mandatory() + validate_disabled_warehouse(self.warehouse) + validate_warehouse_company(self.warehouse, self.company) + + def validate_mandatory(self): + mandatory = [ + "item_code", + "warehouse", + "posting_date", + "posting_time", + "voucher_type", + "voucher_no", + "voucher_detail_no", + "reserved_qty", + "company", + ] + for d in mandatory: + if not self.get(d): + frappe.throw(_("{0} is required").format(self.meta.get_label(d))) + + def on_cancel(self): + frappe.db.set_value(self.doctype, self.name, "is_cancelled", 1) From 0d1332942ca783da76345e17273bdbdf3c2fc76b Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 19:01:18 +0530 Subject: [PATCH 04/79] feat: add `Status` and `Delivered Qty` fields in `Stock Reservation Entry` --- .../stock_reservation_entry.json | 18 +++++++++++++++- .../stock_reservation_entry.py | 21 +++++++++++++++++-- .../stock_reservation_entry_list.js | 14 +++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js 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 47360319c6..dcd7b1f2e6 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -18,6 +18,7 @@ "voucher_detail_no", "section_break_11", "reserved_qty", + "delivered_qty", "column_break_17", "valuation_rate", "section_break_21", @@ -25,6 +26,7 @@ "stock_uom", "project", "column_break_26", + "status", "is_cancelled" ], "fields": [ @@ -197,12 +199,26 @@ "print_width": "150px", "read_only": 1, "width": "150px" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "\nSubmitted\nPartially Delivered\nDelivered\nCancelled", + "read_only": 1 + }, + { + "fieldname": "delivered_qty", + "fieldtype": "Float", + "label": "Delivered Qty", + "read_only": 1 } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2023-03-20 11:17:35.898760", + "modified": "2023-03-20 18:52:46.414108", "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 50a05bce45..ed2c4a610c 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -16,6 +16,13 @@ class StockReservationEntry(TransactionBase): validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) + def on_submit(self): + self.update_status() + + def on_cancel(self): + frappe.db.set_value(self.doctype, self.name, "is_cancelled", 1) + self.update_status() + def validate_mandatory(self): mandatory = [ "item_code", @@ -32,5 +39,15 @@ class StockReservationEntry(TransactionBase): if not self.get(d): frappe.throw(_("{0} is required").format(self.meta.get_label(d))) - def on_cancel(self): - frappe.db.set_value(self.doctype, self.name, "is_cancelled", 1) + def update_status(self, status=None, update_modified=True): + if not status: + if self.is_cancelled: + status = "Cancelled" + elif self.reserved_qty == self.delivered_qty: + status = "Delivered" + elif self.delivered_qty and self.reserved_qty > self.delivered_qty: + status = "Partially Delivered" + else: + status = "Submitted" + + frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) 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..992c566989 --- /dev/null +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -0,0 +1,14 @@ +// 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 = { + 'Submitted': 'blue', + 'Partially Delivered': 'purple', + 'Delivered': 'green', + 'Cancelled': 'red', + }; + return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status]; + }, +}; \ No newline at end of file From 2946de40d88afa12b2d8c71df7afad436b3565da Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 21:14:01 +0530 Subject: [PATCH 05/79] fix: make `Project` and `Is Cancelled` field read-only in `SRE` --- .../stock_reservation_entry/stock_reservation_entry.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 dcd7b1f2e6..80d7f0a6ba 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -146,7 +146,8 @@ "fieldname": "project", "fieldtype": "Link", "label": "Project", - "options": "Project" + "options": "Project", + "read_only": 1 }, { "fieldname": "company", @@ -166,6 +167,7 @@ "fieldname": "is_cancelled", "fieldtype": "Check", "label": "Is Cancelled", + "read_only": 1, "report_hide": 1 }, { @@ -218,7 +220,7 @@ "hide_toolbar": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2023-03-20 18:52:46.414108", + "modified": "2023-03-20 21:18:03.025423", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", From 4848a054a8dae7444e977c2be8519c417f83ac6e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 22:40:06 +0530 Subject: [PATCH 06/79] chore: make `Submitted` default status for Stock Reservation Entry --- .../stock_reservation_entry/stock_reservation_entry.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 80d7f0a6ba..ebe5255b27 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -203,11 +203,12 @@ "width": "150px" }, { + "default": "Submitted", "fieldname": "status", "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "\nSubmitted\nPartially Delivered\nDelivered\nCancelled", + "options": "Submitted\nPartially Delivered\nDelivered\nCancelled", "read_only": 1 }, { @@ -218,9 +219,10 @@ } ], "hide_toolbar": 1, + "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2023-03-20 21:18:03.025423", + "modified": "2023-03-20 21:53:07.671437", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", From 1b7fb6d7e7dfaaa923f8288c46f443461d6584e2 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 22:41:22 +0530 Subject: [PATCH 07/79] chore: make `Stock UOM` required in `SRE` --- .../doctype/stock_reservation_entry/stock_reservation_entry.py | 1 + 1 file changed, 1 insertion(+) 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 ed2c4a610c..1dbda6a519 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -34,6 +34,7 @@ class StockReservationEntry(TransactionBase): "voucher_detail_no", "reserved_qty", "company", + "stock_uom", ] for d in mandatory: if not self.get(d): From 0700063379bf2abbc950c17daa17395e5407976e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 20 Mar 2023 23:55:33 +0530 Subject: [PATCH 08/79] chore: add `Reserve Stock` check field in Sales Order --- erpnext/selling/doctype/sales_order/sales_order.js | 12 ++++++++++++ erpnext/selling/doctype/sales_order/sales_order.json | 10 +++++++++- .../doctype/sales_order_item/sales_order_item.json | 10 +++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 449d461561..0a385c590c 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -46,6 +46,18 @@ 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); + + 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 (value) { + frm.set_value("reserve_stock", 1); + } + }) + } else { + frm.set_df_property("reserve_stock", "read_only", 1); + } + }) }, refresh: function(frm) { if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed' diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index ccea8407ab..40cb17df05 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -46,6 +46,7 @@ "scan_barcode", "column_break_28", "set_warehouse", + "reserve_stock", "items_section", "items", "section_break_31", @@ -1637,13 +1638,20 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock", + "no_copy": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:34:00.681780", + "modified": "2023-03-20 23:51:04.036757", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", 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..8786f6b904 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", @@ -859,12 +860,19 @@ "fieldname": "material_request_item", "fieldtype": "Data", "label": "Material Request Item" + }, + { + "default": "1", + "depends_on": "eval: parent.reserve_stock", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-12-25 02:51:10.247569", + "modified": "2023-03-20 23:43:15.099790", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", From 50de868285fd10fae213df1aee6477e6a6b9b48f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Mar 2023 13:17:58 +0530 Subject: [PATCH 09/79] chore: add `Stock Reserved Qty` field in SO Item --- .../doctype/sales_order_item/sales_order_item.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 8786f6b904..be85d9a99e 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -28,6 +28,7 @@ "uom", "conversion_factor", "stock_qty", + "stock_reserved_qty", "section_break_16", "price_list_rate", "base_price_list_rate", @@ -867,12 +868,21 @@ "fieldname": "reserve_stock", "fieldtype": "Check", "label": "Reserve Stock" + }, + { + "default": "0", + "depends_on": "eval: (parent.reserve_stock && doc.reserve_stock)", + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty (in Stock UOM)", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-03-20 23:43:15.099790", + "modified": "2023-03-21 13:14:47.915610", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", From f858f657a0744f01a4459bed28943605a7b5d9fa Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Mar 2023 13:28:31 +0530 Subject: [PATCH 10/79] feat: add `Stock Reservation Entry` ref in SO connections --- erpnext/selling/doctype/sales_order/sales_order_dashboard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"]}, ], } From c2ba8b1b54499a0428bac7f45fec8c6594c30880 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Mar 2023 20:16:07 +0530 Subject: [PATCH 11/79] chore: make `SRE` a submittable DocType --- .../stock_reservation_entry.json | 28 +++++++++++-------- .../stock_reservation_entry.py | 7 +++-- .../stock_reservation_entry_list.js | 1 + 3 files changed, 21 insertions(+), 15 deletions(-) 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 ebe5255b27..9332c56e75 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -27,7 +27,7 @@ "project", "column_break_26", "status", - "is_cancelled" + "amended_from" ], "fields": [ { @@ -162,14 +162,6 @@ "search_index": 1, "width": "150px" }, - { - "default": "0", - "fieldname": "is_cancelled", - "fieldtype": "Check", - "label": "Is Cancelled", - "read_only": 1, - "report_hide": 1 - }, { "fieldname": "column_break_6", "fieldtype": "Column Break" @@ -203,12 +195,12 @@ "width": "150px" }, { - "default": "Submitted", + "default": "Draft", "fieldname": "status", "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Submitted\nPartially Delivered\nDelivered\nCancelled", + "options": "Draft\nSubmitted\nPartially Delivered\nDelivered\nCancelled", "read_only": 1 }, { @@ -216,13 +208,23 @@ "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 } ], "hide_toolbar": 1, "in_create": 1, "index_web_pages_for_search": 1, + "is_submittable": 1, "links": [], - "modified": "2023-03-20 21:53:07.671437", + "modified": "2023-03-21 20:15:42.659789", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -230,6 +232,7 @@ "owner": "Administrator", "permissions": [ { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -239,6 +242,7 @@ "report": 1, "role": "System Manager", "share": 1, + "submit": 1, "write": 1 } ], 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 1dbda6a519..406fba8807 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -20,7 +20,6 @@ class StockReservationEntry(TransactionBase): self.update_status() def on_cancel(self): - frappe.db.set_value(self.doctype, self.name, "is_cancelled", 1) self.update_status() def validate_mandatory(self): @@ -42,13 +41,15 @@ class StockReservationEntry(TransactionBase): def update_status(self, status=None, update_modified=True): if not status: - if self.is_cancelled: + if self.docstatus == 2: status = "Cancelled" elif self.reserved_qty == self.delivered_qty: status = "Delivered" elif self.delivered_qty and self.reserved_qty > self.delivered_qty: status = "Partially Delivered" - else: + elif self.docstatus == 1: status = "Submitted" + else: + status = "Draft" frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) 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 index 992c566989..443350c032 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -4,6 +4,7 @@ frappe.listview_settings['Stock Reservation Entry'] = { get_indicator: function (doc) { const status_colors = { + 'Draft': 'red', 'Submitted': 'blue', 'Partially Delivered': 'purple', 'Delivered': 'green', From 30d566a787832ea0f5c1358b35486f5700b6ba0a Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Mar 2023 20:40:09 +0530 Subject: [PATCH 12/79] fix: update `Reserved Qty` in SO Item on SRE cancel --- .../stock_reservation_entry.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 406fba8807..fe65d259dc 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -17,9 +17,11 @@ class StockReservationEntry(TransactionBase): validate_warehouse_company(self.warehouse, self.company) def on_submit(self): + self.update_reserved_qty_in_voucher() self.update_status() def on_cancel(self): + self.update_reserved_qty_in_voucher() self.update_status() def validate_mandatory(self): @@ -53,3 +55,26 @@ class StockReservationEntry(TransactionBase): status = "Draft" frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) + + def update_reserved_qty_in_voucher(self, update_modified=True): + from frappe.query_builder.functions import Sum + + 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( + "Sales Order Item", + self.voucher_detail_no, + "stock_reserved_qty", + reserved_qty, + update_modified=update_modified, + ) From fd746288f8ace024a37daa932d38b4cacacd423e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 12:24:37 +0530 Subject: [PATCH 13/79] chore: add field `Available Qty to Reserve` in SRE --- .../stock_reservation_entry.json | 11 ++++++++++- .../stock_reservation_entry.py | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) 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 9332c56e75..ffd75dd8b8 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -17,6 +17,7 @@ "voucher_no", "voucher_detail_no", "section_break_11", + "available_qty", "reserved_qty", "delivered_qty", "column_break_17", @@ -217,6 +218,14 @@ "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 } ], "hide_toolbar": 1, @@ -224,7 +233,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-21 20:15:42.659789", + "modified": "2023-03-23 11:16:44.493852", "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 fe65d259dc..e25f9d6b54 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -33,6 +33,7 @@ class StockReservationEntry(TransactionBase): "voucher_type", "voucher_no", "voucher_detail_no", + "available_qty", "reserved_qty", "company", "stock_uom", From f8c477ca5c3ce01a75300a3f4819ebd85537da3b Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 12:57:49 +0530 Subject: [PATCH 14/79] chore: rename status from `Submitted` to `Reserved` --- .../stock_reservation_entry/stock_reservation_entry.json | 4 ++-- .../stock_reservation_entry/stock_reservation_entry.py | 2 +- .../stock_reservation_entry/stock_reservation_entry_list.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 ffd75dd8b8..8042799ca8 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -201,7 +201,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Draft\nSubmitted\nPartially Delivered\nDelivered\nCancelled", + "options": "Draft\nReserved\nPartially Delivered\nDelivered\nCancelled", "read_only": 1 }, { @@ -233,7 +233,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-23 11:16:44.493852", + "modified": "2023-03-23 12:54:14.168935", "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 e25f9d6b54..8e2b0678f0 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -51,7 +51,7 @@ class StockReservationEntry(TransactionBase): elif self.delivered_qty and self.reserved_qty > self.delivered_qty: status = "Partially Delivered" elif self.docstatus == 1: - status = "Submitted" + status = "Reserved" else: status = "Draft" 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 index 443350c032..f0414bba4f 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -5,7 +5,7 @@ frappe.listview_settings['Stock Reservation Entry'] = { get_indicator: function (doc) { const status_colors = { 'Draft': 'red', - 'Submitted': 'blue', + 'Reserved': 'blue', 'Partially Delivered': 'purple', 'Delivered': 'green', 'Cancelled': 'red', From 1ccdf588e2fb50e8d4a7d90da9440014c64c4779 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 13:34:40 +0530 Subject: [PATCH 15/79] fix(ux): `Reserve Stock` button behaviour in SO --- .../doctype/sales_order/sales_order.js | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 0a385c590c..6b3826b33e 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -47,17 +47,7 @@ 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); - 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 (value) { - frm.set_value("reserve_stock", 1); - } - }) - } else { - frm.set_df_property("reserve_stock", "read_only", 1); - } - }) + }, refresh: function(frm) { if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed' @@ -72,8 +62,25 @@ frappe.ui.form.on("Sales Order", { }); } - 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); + } + + 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 (value) { + frm.set_value("reserve_stock", 1); + } else { + frm.set_value("reserve_stock", 0); + } + }) + } else { + frm.set_value("reserve_stock", 0); + frm.set_df_property("reserve_stock", "read_only", 1); + } + }) } }, From 4ad55382cf5117d3a32eef034e530dfa08344e38 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 19:37:57 +0530 Subject: [PATCH 16/79] chore: add field `Voucher Qty` in SRE --- .../stock_reservation_entry.json | 63 +++++++++++-------- .../stock_reservation_entry.py | 3 +- 2 files changed, 38 insertions(+), 28 deletions(-) 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 8042799ca8..529a697a9b 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -12,21 +12,22 @@ "warehouse", "posting_date", "posting_time", - "column_break_6", + "column_break_elik", "voucher_type", "voucher_no", "voucher_detail_no", - "section_break_11", + "section_break_xt4m", "available_qty", + "voucher_qty", + "stock_uom", + "column_break_o6ex", "reserved_qty", "delivered_qty", - "column_break_17", "valuation_rate", - "section_break_21", + "section_break_3vb3", "company", - "stock_uom", + "column_break_jbyr", "project", - "column_break_26", "status", "amended_from" ], @@ -163,26 +164,6 @@ "search_index": 1, "width": "150px" }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_11", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_21", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_26", - "fieldtype": "Column Break" - }, { "fieldname": "reserved_qty", "fieldtype": "Float", @@ -226,6 +207,34 @@ "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, @@ -233,7 +242,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-23 12:54:14.168935", + "modified": "2023-03-23 19:35:55.479617", "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 8e2b0678f0..6aa45f6320 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -34,9 +34,10 @@ class StockReservationEntry(TransactionBase): "voucher_no", "voucher_detail_no", "available_qty", + "voucher_qty", + "stock_uom", "reserved_qty", "company", - "stock_uom", ] for d in mandatory: if not self.get(d): From ee074883bbc63fefb068092ff116b4cbb30a72d6 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 19:50:34 +0530 Subject: [PATCH 17/79] chore: add `Partially Reserved` status in `SRE` --- .../stock_reservation_entry.json | 4 ++-- .../stock_reservation_entry.py | 13 ++++++++----- .../stock_reservation_entry_list.js | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) 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 529a697a9b..04861d1b86 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -182,7 +182,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Draft\nReserved\nPartially Delivered\nDelivered\nCancelled", + "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled", "read_only": 1 }, { @@ -242,7 +242,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-23 19:35:55.479617", + "modified": "2023-03-23 19:41:37.140303", "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 6aa45f6320..2824a71b7a 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -47,12 +47,15 @@ class StockReservationEntry(TransactionBase): if not status: if self.docstatus == 2: status = "Cancelled" - elif self.reserved_qty == self.delivered_qty: - status = "Delivered" - elif self.delivered_qty and self.reserved_qty > self.delivered_qty: - status = "Partially Delivered" elif self.docstatus == 1: - status = "Reserved" + 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" 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 index f0414bba4f..442ac39f13 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -5,6 +5,7 @@ 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', From c80ce99972516140420b2742697d81d0b94d0a09 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 23 Mar 2023 19:53:17 +0530 Subject: [PATCH 18/79] feat: configuration to allow partial reservation --- .../stock/doctype/stock_settings/stock_settings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 116e536310..02ea3813e2 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -35,6 +35,7 @@ "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", @@ -365,6 +366,13 @@ { "fieldname": "column_break_rx3e", "fieldtype": "Column Break" + }, + { + "default": "1", + "depends_on": "eval: (doc.enable_stock_reservation && doc.reserve_stock_on_sales_order_submission)", + "fieldname": "allow_partial_reservation", + "fieldtype": "Check", + "label": "Allow Partial Reservation" } ], "icon": "icon-cog", @@ -372,7 +380,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-03-20 11:42:29.769937", + "modified": "2023-03-23 18:59:11.773360", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From 7b6e4d44b7a880c1ad8514e51fce198b15f3921f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 24 Mar 2023 16:09:38 +0530 Subject: [PATCH 19/79] chore: remove `Valuation Rate` field from SRE --- .../stock_reservation_entry.json | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 04861d1b86..3d1f6982bd 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -23,7 +23,6 @@ "column_break_o6ex", "reserved_qty", "delivered_qty", - "valuation_rate", "section_break_3vb3", "company", "column_break_jbyr", @@ -133,17 +132,6 @@ "read_only": 1, "width": "150px" }, - { - "fieldname": "valuation_rate", - "fieldtype": "Currency", - "label": "Valuation Rate", - "oldfieldname": "valuation_rate", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "print_width": "150px", - "read_only": 1, - "width": "150px" - }, { "fieldname": "project", "fieldtype": "Link", @@ -242,7 +230,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-23 19:41:37.140303", + "modified": "2023-03-24 16:08:14.352534", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", From 48108b5b419650cdebcbfc823468f9c64832e121 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 24 Mar 2023 16:23:38 +0530 Subject: [PATCH 20/79] chore: add fields `Serial No` and `Batch No` in SRE --- .../stock_reservation_entry.json | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 3d1f6982bd..8b1bc436d2 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -25,8 +25,10 @@ "delivered_qty", "section_break_3vb3", "company", - "column_break_jbyr", "project", + "column_break_jbyr", + "batch_no", + "serial_no", "status", "amended_from" ], @@ -223,6 +225,18 @@ { "fieldname": "column_break_jbyr", "fieldtype": "Column Break" + }, + { + "fieldname": "batch_no", + "fieldtype": "Data", + "label": "Batch No", + "read_only": 1 + }, + { + "fieldname": "serial_no", + "fieldtype": "Long Text", + "label": "Serial No", + "read_only": 1 } ], "hide_toolbar": 1, @@ -230,7 +244,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-24 16:08:14.352534", + "modified": "2023-03-24 16:22:08.859347", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", From be9fa8c047c479253e207589756b4ec6f13ce741 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 24 Mar 2023 21:39:36 +0530 Subject: [PATCH 21/79] fix: don't allow to disable Stock Reservation if SRE exists --- .../stock/doctype/stock_settings/stock_settings.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 50807a96ab..d761b663f5 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.cant_disable_stock_reservation() def validate_warehouses(self): warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] @@ -99,6 +100,19 @@ class StockSettings(Document): if self.stock_frozen_upto: check_pending_reposting(self.stock_frozen_upto) + def cant_disable_stock_reservation(self): + if not self.enable_stock_reservation: + db_enable_stock_reservation = frappe.db.get_single_value( + "Stock Settings", "enable_stock_reservation" + ) + + if db_enable_stock_reservation and frappe.db.count("Stock Reservation Entry"): + frappe.throw( + _("As there are existing {0}, you can not change the value of {1}.").format( + frappe.bold("Stock Reservation Entries"), frappe.bold("Enable Stock Reservation") + ) + ) + def on_update(self): self.toggle_warehouse_field_for_inter_warehouse_transfer() From 9652cb8de565d349e413fa349bdaadd81e8b0a82 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 25 Mar 2023 15:50:00 +0530 Subject: [PATCH 22/79] chore: create SRE on SO submission --- erpnext/controllers/stock_controller.py | 96 +++++++++++++++++++ .../doctype/sales_order/sales_order.py | 2 + 2 files changed, 98 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 1e4fabe0d2..6e2fb2eae4 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -783,6 +783,75 @@ class StockController(AccountsController): gl_entries.append(self.get_gl_dict(gl_entry, item=item)) + def make_sr_entries(self): + if not self.get("reserve_stock"): + return + + if self.doctype != "Sales Order": + frappe.throw( + _("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order")) + ) + + 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") + ) + ) + + if not frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission"): + frappe.throw( + _("Please enable {0} in the {1}.").format( + frappe.bold("Reserve Stock on Sales Order Submission"), frappe.bold("Stock Settings") + ) + ) + + for item in self.get("items"): + if not item.get("reserve_stock"): + continue + + available_qty = get_available_qty_to_reserve(item.item_code, item.warehouse) + reserved_qty = min(item.stock_qty, available_qty) + + if not reserved_qty: + frappe.msgprint( + _("Row {0}: No available stock to reserve for the Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + elif reserved_qty < item.stock_qty: + frappe.msgprint( + _("Row {0}: Only {1} available to reserve for the Item {2}").format( + item.idx, + frappe.bold(str(reserved_qty / item.conversion_factor) + " " + item.uom), + frappe.bold(item.item_code), + ), + title=_("Stock Reservation"), + indicator="orange", + ) + + if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"): + continue + + 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 + sre.voucher_qty = item.stock_qty + sre.reserved_qty = reserved_qty + sre.company = self.company + sre.stock_uom = item.stock_uom + sre.project = self.project + sre.save() + sre.submit() + def repost_required_for_queue(doc: StockController) -> bool: """check if stock document contains repeated item-warehouse with queue based valuation. @@ -952,6 +1021,33 @@ def get_conditions_to_validate_future_sle(sl_entries): return or_conditions +@frappe.whitelist() +def get_available_qty_to_reserve(item_code, warehouse): + from frappe.query_builder.functions import Sum + + 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 create_repost_item_valuation_entry(args): args = frappe._dict(args) repost_entry = frappe.new_doc("Repost Item Valuation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index ee9161bee4..d7541be95e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -241,6 +241,8 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code, "used") + self.make_sr_entries() + def on_cancel(self): self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") super(SalesOrder, self).on_cancel() From 744166da73e7c28c4bf60ad13d9e4c2b6e675b7f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 26 Mar 2023 12:14:00 +0530 Subject: [PATCH 23/79] fix(ux): unable to uncheck `Reserve Stock` button in SO --- .../doctype/sales_order/sales_order.js | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 6b3826b33e..d222c3eb31 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -67,20 +67,22 @@ frappe.ui.form.on("Sales Order", { frm.events.get_items_from_internal_purchase_order(frm); } - 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 (value) { - frm.set_value("reserve_stock", 1); - } else { - frm.set_value("reserve_stock", 0); - } - }) - } else { - frm.set_value("reserve_stock", 0); - frm.set_df_property("reserve_stock", "read_only", 1); - } - }) + 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 (value) { + frm.set_value("reserve_stock", 1); + } else { + frm.set_value("reserve_stock", 0); + } + }) + } else { + frm.set_value("reserve_stock", 0); + frm.set_df_property("reserve_stock", "read_only", 1); + } + }) + } } }, From cdc625806d2e2aa6bf967baf8941946dc5bc6b86 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 26 Mar 2023 16:00:25 +0530 Subject: [PATCH 24/79] chore: add field `Against Stock Reservation Entry` in DN Item --- erpnext/stock/doctype/delivery_note/delivery_note.js | 11 +++++++++++ .../delivery_note_item/delivery_note_item.json | 12 +++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index ae56645b73..ea22cf84e2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -77,6 +77,17 @@ frappe.ui.form.on("Delivery Note", { } }); + frm.set_query("against_sre", "items", (doc, cdt, cdn) => { + var row = locals[cdt][cdn]; + return { + filters: { + "voucher_type": "Sales Order", + "voucher_no": row.against_sales_order, + "voucher_detail_no": row.so_detail + } + } + }); + frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 916ab2a05b..a24f473a7b 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -76,6 +76,7 @@ "si_detail", "dn_detail", "pick_list_item", + "against_sre", "section_break_40", "batch_no", "serial_no", @@ -831,13 +832,22 @@ "fieldname": "material_request_item", "fieldtype": "Data", "label": "Material Request Item" + }, + { + "fieldname": "against_sre", + "fieldtype": "Link", + "label": "Against Stock Reservation Entry", + "no_copy": 1, + "options": "Stock Reservation Entry", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-09 12:17:50.850142", + "modified": "2023-03-26 16:53:08.283469", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", From 15cb99290c02c1d6cf4fbda6032c03eed58412d9 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 26 Mar 2023 16:04:11 +0530 Subject: [PATCH 25/79] chore: add SRE ref in DN dashboard --- .../stock/doctype/delivery_note/delivery_note_dashboard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index b6b5ff4296..9c64c17175 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -13,10 +13,14 @@ def get_data(): "Sales Order": ["items", "against_sales_order"], "Material Request": ["items", "material_request"], "Purchase Order": ["items", "purchase_order"], + "Stock Reservation Entry": ["items", "against_sre"], }, "transactions": [ {"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]}, - {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, + { + "label": _("Reference"), + "items": ["Sales Order", "Shipment", "Quality Inspection", "Stock Reservation Entry"], + }, {"label": _("Returns"), "items": ["Stock Entry"]}, {"label": _("Subscription"), "items": ["Auto Repeat"]}, {"label": _("Internal Transfer"), "items": ["Material Request", "Purchase Order"]}, From 3602d1909eb4aa7d7feab9cb6de72d5209ba1ead Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 26 Mar 2023 17:33:01 +0530 Subject: [PATCH 26/79] fix: map DN items based on SRE --- .../doctype/sales_order/sales_order.py | 33 +++++++++++- .../doctype/delivery_note/delivery_note.py | 14 +++++ .../stock_reservation_entry.py | 52 ++++++++++++++++++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index d7541be95e..10a12211e9 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -622,7 +622,36 @@ def make_project(source_name, target_doc=None): @frappe.whitelist() def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_stock_reservation_entry_for_voucher, + has_reserved_stock, + ) + def set_missing_values(source, target): + if not target.items and has_reserved_stock("Sales Order", source_name): + sre_list = get_stock_reservation_entry_for_voucher("Sales Order", source_name) + sre_dict = {d.pop("voucher_detail_no"): d for d in sre_list} + + for item in source.get("items"): + if item.name in sre_dict: + qty_to_deliver = ( + sre_dict[item.name]["reserved_qty"] - sre_dict[item.name]["delivered_qty"] + ) / item.conversion_factor + + row = frappe.new_doc("Delivery Note Item") + row.against_sales_order = source.name + row.against_sre = sre_dict[item.name]["name"] + row.so_detail = item.name + row.item_code = item.item_code + row.item_name = item.item_name + row.description = item.description + row.qty = qty_to_deliver + row.stock_uom = item.stock_uom + row.uom = item.uom + row.conversion_factor = item.conversion_factor + + target.append("items", row) + target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") @@ -651,6 +680,9 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): or item_group.get("buying_cost_center") ) + if has_reserved_stock("Sales Order", source_name): + skip_item_mapping = True + mapper = { "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, @@ -678,7 +710,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/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 9f9f5cbe2a..d6d51af886 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -239,6 +239,8 @@ class DeliveryNote(SellingController): self.update_prevdoc_status() self.update_billing_status() + self.update_stock_reservation_entry() + if not self.is_return: self.check_credit_limit() elif self.issue_credit_note: @@ -258,6 +260,8 @@ class DeliveryNote(SellingController): self.update_prevdoc_status() self.update_billing_status() + self.update_stock_reservation_entry() + # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO self.update_stock_ledger() @@ -268,6 +272,16 @@ 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_entry(self): + if not self.is_return: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + update_delivered_qty, + ) + + for item in self.get("items"): + if item.against_sre: + update_delivered_qty(item.doctype, item.against_sre) + def check_credit_limit(self): from erpnext.selling.doctype.customer.customer import check_credit_limit 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 2824a71b7a..82eebb4978 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from erpnext.utilities.transaction_base import TransactionBase @@ -62,8 +63,6 @@ class StockReservationEntry(TransactionBase): frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) def update_reserved_qty_in_voucher(self, update_modified=True): - from frappe.query_builder.functions import Sum - sre = frappe.qb.DocType("Stock Reservation Entry") reserved_qty = ( frappe.qb.from_(sre) @@ -83,3 +82,52 @@ class StockReservationEntry(TransactionBase): reserved_qty, update_modified=update_modified, ) + + +def get_stock_reservation_entry_for_voucher(voucher_type, voucher_no, voucher_detail_no=None): + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.name, + sre.item_code, + sre.warehouse, + sre.voucher_detail_no, + sre.reserved_qty, + sre.delivered_qty, + sre.stock_uom, + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + return query.run(as_dict=True) + + +def has_reserved_stock(voucher_type, voucher_no, voucher_detail_no=None): + if get_stock_reservation_entry_for_voucher(voucher_type, voucher_no, voucher_detail_no): + return True + + return False + + +def update_delivered_qty(doctype, sre_name, sre_field="against_sre", qty_field="stock_qty"): + table = frappe.qb.DocType(doctype) + delivered_qty = ( + frappe.qb.from_(table) + .select(Sum(table[qty_field])) + .where((table.docstatus == 1) & (table[sre_field] == sre_name)) + ).run(as_list=True)[0][0] or 0.0 + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name) + sre_doc.delivered_qty = delivered_qty + sre_doc.db_update() + sre_doc.update_status() From b95a49e4c2096ecd8d18a7de9b4261fbad79c144 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 27 Mar 2023 10:46:07 +0530 Subject: [PATCH 27/79] fix: validate DN against SRE --- .../doctype/delivery_note/delivery_note.py | 74 +++++++++++++++++++ .../stock_reservation_entry.py | 31 ++++++++ 2 files changed, 105 insertions(+) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index d6d51af886..55a49757fa 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -147,6 +147,8 @@ class DeliveryNote(SellingController): if not self.installation_status: self.installation_status = "Not Installed" + + self.validate_against_sre() self.reset_default_field_value("set_warehouse", "items", "warehouse") def validate_with_previous_doc(self): @@ -282,6 +284,78 @@ class DeliveryNote(SellingController): if item.against_sre: update_delivered_qty(item.doctype, item.against_sre) + def validate_against_sre(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_stock_reservation_entry_for_items, + has_reserved_stock, + ) + + sre_details = get_stock_reservation_entry_for_items(self.items) + + for item in self.items: + if item.against_sre: + sre = sre_details[item.against_sre] + + # SRE `docstatus` should be `1` (submitted) + if sre.docstatus == 0: + frappe.throw( + _("Row #{0}: Stock Reservation Entry {1} is not submitted").format( + item.idx, item.against_sre + ) + ) + elif sre.docstatus == 2: + frappe.throw( + _("Row #{0}: Stock Reservation Entry {0} is cancelled").format(item.idx, item.against_sre) + ) + + # SRE `status` should not be `Delivered` + if sre.status == "Delivered": + frappe.throw( + _("Row #{0}: Cannot deliver more against Stock Reservation Entry {1}").format( + item.idx, item.against_sre + ) + ) + + for field in ( + "item_code", + "warehouse", + ("against_sales_order", "voucher_no"), + ("so_detail", "voucher_detail_no"), + ): + item_field = sre_field = None + + if isinstance(field, tuple): + item_field, sre_field = field[0], field[1] + else: + item_field = sre_field = field + + if item.get(item_field) != sre.get(sre_field): + frappe.throw( + _("Row #{0}: {1} {2} does not match with Stock Reservation Entry {3}").format( + item.idx, + frappe.get_meta(item.doctype).get_label(item_field), + item.get(item_field), + item.against_sre, + ) + ) + + max_delivered_qty = (sre.reserved_qty - sre.delivered_qty) / item.conversion_factor + if item.qty > max_delivered_qty: + frappe.throw( + _("Row #{0}: Cannot deliver more than {1} {2} against Stock Reservation Entry {3}").format( + item.idx, max_delivered_qty, item.uom, item.against_sre + ) + ) + elif item.against_sales_order: + if not item.so_detail: + frappe.throw(_("Row #{0}: Sales Order Item reference is required").format(item.idx)) + elif has_reserved_stock("Sales Order", item.against_sales_order, item.so_detail): + frappe.throw( + _("Row #{0}: Cannot deliver against Sales Order {1} without Stock Reservation Entry").format( + item.idx, item.against_sales_order + ) + ) + def check_credit_limit(self): from erpnext.selling.doctype.customer.customer import check_credit_limit 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 82eebb4978..3c5b621401 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -131,3 +131,34 @@ def update_delivered_qty(doctype, sre_name, sre_field="against_sre", qty_field=" sre_doc.delivered_qty = delivered_qty sre_doc.db_update() sre_doc.update_status() + + +def get_stock_reservation_entry_for_items(items, sre_field="against_sre"): + sre_details = {} + + sre_list = [item.get(sre_field) for item in items if item.get(sre_field)] + + if sre_list: + sre = frappe.qb.DocType("Stock Reservation Entry") + sre_data = ( + frappe.qb.from_(sre) + .select( + sre.name, + sre.status, + sre.docstatus, + sre.item_code, + sre.warehouse, + sre.voucher_type, + sre.voucher_no, + sre.voucher_detail_no, + sre.reserved_qty, + sre.delivered_qty, + sre.stock_uom, + ) + .where(sre.name.isin(sre_list)) + .orderby(sre.creation) + ).run(as_dict=True) + + sre_details = {d.name: d for d in sre_data} + + return sre_details From 22a9c8ad552bd257e23ccd095ee3e6417ee99b86 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 27 Mar 2023 11:07:24 +0530 Subject: [PATCH 28/79] fix(ux): SRE filters in DN Items --- erpnext/stock/doctype/delivery_note/delivery_note.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index ea22cf84e2..53b35761b5 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -81,9 +81,11 @@ frappe.ui.form.on("Delivery Note", { var row = locals[cdt][cdn]; return { filters: { + "docstatus": 1, + "status": ["not in", ["Delivered", "Cancelled"]], "voucher_type": "Sales Order", "voucher_no": row.against_sales_order, - "voucher_detail_no": row.so_detail + "voucher_detail_no": row.so_detail, } } }); From 00ac49f81bd14f6c882256939626d610cf6f95f9 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 27 Mar 2023 12:18:40 +0530 Subject: [PATCH 29/79] refactor(minor): SRE functions --- .../doctype/sales_order/sales_order.py | 4 +- .../doctype/delivery_note/delivery_note.py | 8 +- .../stock_reservation_entry.py | 139 ++++++++++-------- 3 files changed, 85 insertions(+), 66 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 10a12211e9..5accaf61a9 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -623,13 +623,13 @@ def make_project(source_name, target_doc=None): @frappe.whitelist() def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_stock_reservation_entry_for_voucher, + get_stock_reservation_entries_for_voucher, has_reserved_stock, ) def set_missing_values(source, target): if not target.items and has_reserved_stock("Sales Order", source_name): - sre_list = get_stock_reservation_entry_for_voucher("Sales Order", source_name) + sre_list = get_stock_reservation_entries_for_voucher("Sales Order", source_name) sre_dict = {d.pop("voucher_detail_no"): d for d in sre_list} for item in source.get("items"): diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 55a49757fa..77c435eb68 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -277,20 +277,20 @@ class DeliveryNote(SellingController): def update_stock_reservation_entry(self): if not self.is_return: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - update_delivered_qty, + update_sre_delivered_qty, ) for item in self.get("items"): if item.against_sre: - update_delivered_qty(item.doctype, item.against_sre) + update_sre_delivered_qty(item.doctype, item.against_sre) def validate_against_sre(self): from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_stock_reservation_entry_for_items, + get_stock_reservation_entries_for_items, has_reserved_stock, ) - sre_details = get_stock_reservation_entry_for_items(self.items) + sre_details = get_stock_reservation_entries_for_items(self.items) for item in self.items: if item.against_sre: 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 3c5b621401..73cda721ca 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -9,7 +9,7 @@ from erpnext.utilities.transaction_base import TransactionBase class StockReservationEntry(TransactionBase): - def validate(self): + def validate(self) -> None: from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company self.validate_posting_time() @@ -17,15 +17,15 @@ class StockReservationEntry(TransactionBase): validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) - def on_submit(self): + def on_submit(self) -> None: self.update_reserved_qty_in_voucher() self.update_status() - def on_cancel(self): + def on_cancel(self) -> None: self.update_reserved_qty_in_voucher() self.update_status() - def validate_mandatory(self): + def validate_mandatory(self) -> None: mandatory = [ "item_code", "warehouse", @@ -44,7 +44,7 @@ class StockReservationEntry(TransactionBase): if not self.get(d): frappe.throw(_("{0} is required").format(self.meta.get_label(d))) - def update_status(self, status=None, update_modified=True): + def update_status(self, status: str = None, update_modified: bool = True) -> None: if not status: if self.docstatus == 2: status = "Cancelled" @@ -62,41 +62,50 @@ class StockReservationEntry(TransactionBase): frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) - def update_reserved_qty_in_voucher(self, update_modified=True): - 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) + def update_reserved_qty_in_voucher( + self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True + ) -> None: + 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, ) - ).run(as_list=True)[0][0] or 0 - - frappe.db.set_value( - "Sales Order Item", - self.voucher_detail_no, - "stock_reserved_qty", - reserved_qty, - update_modified=update_modified, - ) -def get_stock_reservation_entry_for_voucher(voucher_type, voucher_no, voucher_detail_no=None): +def get_stock_reservation_entries_for_voucher( + voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None +) -> list[dict]: + 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) - .select( - sre.name, - sre.item_code, - sre.warehouse, - sre.voucher_detail_no, - sre.reserved_qty, - sre.delivered_qty, - sre.stock_uom, - ) .where( (sre.docstatus == 1) & (sre.voucher_type == voucher_type) @@ -106,20 +115,27 @@ def get_stock_reservation_entry_for_voucher(voucher_type, voucher_no, voucher_de .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 has_reserved_stock(voucher_type, voucher_no, voucher_detail_no=None): - if get_stock_reservation_entry_for_voucher(voucher_type, voucher_no, voucher_detail_no): +def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: + if get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"] + ): return True return False -def update_delivered_qty(doctype, sre_name, sre_field="against_sre", qty_field="stock_qty"): +def update_sre_delivered_qty( + doctype: str, sre_name: str, sre_field: str = "against_sre", qty_field: str = "stock_qty" +) -> None: table = frappe.qb.DocType(doctype) delivered_qty = ( frappe.qb.from_(table) @@ -133,32 +149,35 @@ def update_delivered_qty(doctype, sre_name, sre_field="against_sre", qty_field=" sre_doc.update_status() -def get_stock_reservation_entry_for_items(items, sre_field="against_sre"): +def get_stock_reservation_entries_for_items( + items: list[dict | object], sre_field: str = "against_sre" +) -> dict[dict]: sre_details = {} - sre_list = [item.get(sre_field) for item in items if item.get(sre_field)] + if items: + sre_list = [item.get(sre_field) for item in items if item.get(sre_field)] - if sre_list: - sre = frappe.qb.DocType("Stock Reservation Entry") - sre_data = ( - frappe.qb.from_(sre) - .select( - sre.name, - sre.status, - sre.docstatus, - sre.item_code, - sre.warehouse, - sre.voucher_type, - sre.voucher_no, - sre.voucher_detail_no, - sre.reserved_qty, - sre.delivered_qty, - sre.stock_uom, - ) - .where(sre.name.isin(sre_list)) - .orderby(sre.creation) - ).run(as_dict=True) + if sre_list: + sre = frappe.qb.DocType("Stock Reservation Entry") + sre_data = ( + frappe.qb.from_(sre) + .select( + sre.name, + sre.status, + sre.docstatus, + sre.item_code, + sre.warehouse, + sre.voucher_type, + sre.voucher_no, + sre.voucher_detail_no, + sre.reserved_qty, + sre.delivered_qty, + sre.stock_uom, + ) + .where(sre.name.isin(sre_list)) + .orderby(sre.creation) + ).run(as_dict=True) - sre_details = {d.name: d for d in sre_data} + sre_details = {d.name: d for d in sre_data} return sre_details From 3d75e3f434b959faee70c236d1fce017af1f1238 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 27 Mar 2023 19:12:08 +0530 Subject: [PATCH 30/79] chore: add `Stock Reservation Qty` column in `Stock Projected Qty Report` --- .../stock_projected_qty.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py index f477d8f08f..8640710f4f 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from frappe.utils import flt, today from pypika.terms import ExistsCriterion @@ -20,6 +21,7 @@ def execute(filters=None): include_uom = filters.get("include_uom") columns = get_columns() bin_list = get_bin_list(filters) + sre_details = get_sre_reserved_qty_details(bin_list) item_map = get_item_map(filters.get("item_code"), include_uom) warehouse_company = {} @@ -75,6 +77,7 @@ def execute(filters=None): bin.indented_qty, bin.ordered_qty, bin.reserved_qty, + sre_details.get((bin.item_code, bin.warehouse), 0.0), bin.reserved_qty_for_production, bin.reserved_qty_for_sub_contract, reserved_qty_for_pos, @@ -166,6 +169,13 @@ def get_columns(): "width": 100, "convertible": "qty", }, + { + "label": _("Stock Reservation Qty"), + "fieldname": "stock_reservation_qty", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, { "label": _("Reserved for Production"), "fieldname": "reserved_qty_for_production", @@ -264,6 +274,36 @@ def get_bin_list(filters): return bin_list +def get_sre_reserved_qty_details(bin_list: list) -> dict: + sre_details = {} + + if bin_list: + item_code_list, warehouse_list = [], [] + for bin in bin_list: + item_code_list.append(bin["item_code"]) + warehouse_list.append(bin["warehouse"]) + + 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_item_map(item_code, include_uom): """Optimization: get only the item doc and re_order_levels table""" From beb425e1ff5c7dbd93c7d03aff3d87d4d2daad65 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 27 Mar 2023 21:03:35 +0530 Subject: [PATCH 31/79] chore: add `Stock Reservation Qty` column in `Stock Balance Report` --- .../stock_reservation_entry.py | 25 ++++++++++++++ .../report/stock_balance/stock_balance.py | 22 ++++++++++++ .../stock_projected_qty.py | 34 +++++-------------- 3 files changed, 55 insertions(+), 26 deletions(-) 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 73cda721ca..06e14da0fe 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -181,3 +181,28 @@ def get_stock_reservation_entries_for_items( sre_details = {d.name: d for d in sre_data} return sre_details + + +def get_sre_reserved_qty_details(item_code_list: list, warehouse_list: list) -> dict: + 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 diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 0fc642ef20..b8d6b6c7ce 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -58,6 +58,7 @@ def execute(filters: Optional[StockBalanceFilter] = None): return columns, [] iwb_map = get_item_warehouse_map(filters, sle) + sre_details = get_sre_reserved_qty_details(iwb_map) item_map = get_item_details(items, sle, filters) item_reorder_detail_map = get_item_reorder_details(item_map.keys()) @@ -88,6 +89,7 @@ def execute(filters: Optional[StockBalanceFilter] = None): "company": company, "reorder_level": item_reorder_level, "reorder_qty": item_reorder_qty, + "stock_reservation_qty": sre_details.get((item, warehouse), 0.0), } report_data.update(item_map[item]) report_data.update(qty_dict) @@ -229,6 +231,13 @@ def get_columns(filters: StockBalanceFilter): "width": 80, "convertible": "qty", }, + { + "label": _("Stock Reservation Qty"), + "fieldname": "stock_reservation_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, { "label": _("Company"), "fieldname": "company", @@ -388,6 +397,19 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): return iwb_map +def get_sre_reserved_qty_details(iwb_map: list) -> dict: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_details as get_reserved_qty_details, + ) + + item_code_list, warehouse_list = [], [] + for d in iwb_map: + item_code_list.append(d[1]) + warehouse_list.append(d[2]) + + return get_reserved_qty_details(item_code_list, warehouse_list) + + def get_group_by_key(row, filters, inventory_dimension_fields) -> tuple: group_by_key = [row.company, row.item_code, row.warehouse] diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py index 8640710f4f..d3046d2137 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py @@ -4,7 +4,6 @@ import frappe from frappe import _ -from frappe.query_builder.functions import Sum from frappe.utils import flt, today from pypika.terms import ExistsCriterion @@ -275,33 +274,16 @@ def get_bin_list(filters): def get_sre_reserved_qty_details(bin_list: list) -> dict: - sre_details = {} + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_details as get_reserved_qty_details, + ) - if bin_list: - item_code_list, warehouse_list = [], [] - for bin in bin_list: - item_code_list.append(bin["item_code"]) - warehouse_list.append(bin["warehouse"]) + item_code_list, warehouse_list = [], [] + for bin in bin_list: + item_code_list.append(bin["item_code"]) + warehouse_list.append(bin["warehouse"]) - 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 + return get_reserved_qty_details(item_code_list, warehouse_list) def get_item_map(item_code, include_uom): From 3fcaa21110fd77876692f230a508370ab5b737d4 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 29 Mar 2023 11:34:31 +0530 Subject: [PATCH 32/79] refactor(minor): `stock_reservation_entry.py` --- .../stock_reservation_entry.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 06e14da0fe..b79e8105c0 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -183,20 +183,27 @@ def get_stock_reservation_entries_for_items( return sre_details -def get_sre_reserved_qty_details(item_code_list: list, warehouse_list: list) -> dict: +def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict: sre_details = {} - if item_code_list and warehouse_list: + if item_code and warehouse: + if isinstance(item_code, str): + item_code = [item_code] + if isinstance(warehouse, str): + warehouse = [warehouse] + 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") + 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.item_code.isin(item_code)) + & (sre.warehouse.isin(warehouse)) & (sre.status.notin(["Delivered", "Cancelled"])) ) .groupby(sre.item_code, sre.warehouse) From 6b245516726840eb5e4b6526467ac708733407e6 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 29 Mar 2023 18:07:57 +0530 Subject: [PATCH 33/79] Revert "chore: add `Stock Reservation Qty` column in `Stock Projected Qty Report`" This reverts commit 3d75e3f434b959faee70c236d1fce017af1f1238. --- .../stock_projected_qty.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py index d3046d2137..f477d8f08f 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py @@ -20,7 +20,6 @@ def execute(filters=None): include_uom = filters.get("include_uom") columns = get_columns() bin_list = get_bin_list(filters) - sre_details = get_sre_reserved_qty_details(bin_list) item_map = get_item_map(filters.get("item_code"), include_uom) warehouse_company = {} @@ -76,7 +75,6 @@ def execute(filters=None): bin.indented_qty, bin.ordered_qty, bin.reserved_qty, - sre_details.get((bin.item_code, bin.warehouse), 0.0), bin.reserved_qty_for_production, bin.reserved_qty_for_sub_contract, reserved_qty_for_pos, @@ -168,13 +166,6 @@ def get_columns(): "width": 100, "convertible": "qty", }, - { - "label": _("Stock Reservation Qty"), - "fieldname": "stock_reservation_qty", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, { "label": _("Reserved for Production"), "fieldname": "reserved_qty_for_production", @@ -273,19 +264,6 @@ def get_bin_list(filters): return bin_list -def get_sre_reserved_qty_details(bin_list: list) -> dict: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details as get_reserved_qty_details, - ) - - item_code_list, warehouse_list = [], [] - for bin in bin_list: - item_code_list.append(bin["item_code"]) - warehouse_list.append(bin["warehouse"]) - - return get_reserved_qty_details(item_code_list, warehouse_list) - - def get_item_map(item_code, include_uom): """Optimization: get only the item doc and re_order_levels table""" From 252219812906beeda51a7a6218b22b776cbcdaf3 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 29 Mar 2023 18:22:24 +0530 Subject: [PATCH 34/79] Revert "chore: add fields `Serial No` and `Batch No` in SRE" This reverts commit 48108b5b419650cdebcbfc823468f9c64832e121. --- .../stock_reservation_entry.json | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) 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 8b1bc436d2..a170c4bdfb 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -25,10 +25,8 @@ "delivered_qty", "section_break_3vb3", "company", - "project", "column_break_jbyr", - "batch_no", - "serial_no", + "project", "status", "amended_from" ], @@ -176,6 +174,7 @@ "read_only": 1 }, { + "default": "0", "fieldname": "delivered_qty", "fieldtype": "Float", "label": "Delivered Qty", @@ -225,18 +224,6 @@ { "fieldname": "column_break_jbyr", "fieldtype": "Column Break" - }, - { - "fieldname": "batch_no", - "fieldtype": "Data", - "label": "Batch No", - "read_only": 1 - }, - { - "fieldname": "serial_no", - "fieldtype": "Long Text", - "label": "Serial No", - "read_only": 1 } ], "hide_toolbar": 1, @@ -244,7 +231,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-24 16:22:08.859347", + "modified": "2023-03-29 18:24:18.332719", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", From 72e32f1ae414ec47ed97eccd7c2015dc3f7df268 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 29 Mar 2023 18:38:43 +0530 Subject: [PATCH 35/79] refactor: remove `Posting Date` and `Posting Time` columns from SRE --- .../stock_reservation_entry.json | 27 +------------------ .../stock_reservation_entry.py | 8 ++---- 2 files changed, 3 insertions(+), 32 deletions(-) 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 a170c4bdfb..7c7abacd91 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -10,8 +10,6 @@ "field_order": [ "item_code", "warehouse", - "posting_date", - "posting_time", "column_break_elik", "voucher_type", "voucher_no", @@ -61,29 +59,6 @@ "search_index": 1, "width": "100px" }, - { - "fieldname": "posting_date", - "fieldtype": "Date", - "in_filter": 1, - "in_list_view": 1, - "label": "Posting Date", - "oldfieldname": "posting_date", - "oldfieldtype": "Date", - "print_width": "100px", - "read_only": 1, - "search_index": 1, - "width": "100px" - }, - { - "fieldname": "posting_time", - "fieldtype": "Time", - "label": "Posting Time", - "oldfieldname": "posting_time", - "oldfieldtype": "Time", - "print_width": "100px", - "read_only": 1, - "width": "100px" - }, { "fieldname": "voucher_type", "fieldtype": "Select", @@ -231,7 +206,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-29 18:24:18.332719", + "modified": "2023-03-29 18:36:26.752872", "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 b79e8105c0..efe99799b1 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -3,16 +3,14 @@ import frappe from frappe import _ +from frappe.model.document import Document from frappe.query_builder.functions import Sum -from erpnext.utilities.transaction_base import TransactionBase - -class StockReservationEntry(TransactionBase): +class StockReservationEntry(Document): def validate(self) -> None: from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company - self.validate_posting_time() self.validate_mandatory() validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) @@ -29,8 +27,6 @@ class StockReservationEntry(TransactionBase): mandatory = [ "item_code", "warehouse", - "posting_date", - "posting_time", "voucher_type", "voucher_no", "voucher_detail_no", From e286d0590471a29346c35ba55c5eb21122b1b212 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 30 Mar 2023 16:00:04 +0530 Subject: [PATCH 36/79] fix: SRE `Available Qty to Reserve` for Group Warehouse --- erpnext/controllers/stock_controller.py | 31 +++--------------- .../doctype/sales_order/sales_order.py | 12 +++++-- .../stock_reservation_entry.py | 32 +++++++++++++++++++ 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 6e2fb2eae4..507deae42c 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -784,6 +784,10 @@ class StockController(AccountsController): gl_entries.append(self.get_gl_dict(gl_entry, item=item)) def make_sr_entries(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_available_qty_to_reserve, + ) + if not self.get("reserve_stock"): return @@ -1021,33 +1025,6 @@ def get_conditions_to_validate_future_sle(sl_entries): return or_conditions -@frappe.whitelist() -def get_available_qty_to_reserve(item_code, warehouse): - from frappe.query_builder.functions import Sum - - 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 create_repost_item_valuation_entry(args): args = frappe._dict(args) repost_entry = frappe.new_doc("Repost Item Valuation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 5accaf61a9..403477b375 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -634,9 +634,12 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): for item in source.get("items"): if item.name in sre_dict: - qty_to_deliver = ( - sre_dict[item.name]["reserved_qty"] - sre_dict[item.name]["delivered_qty"] - ) / item.conversion_factor + reserved_qty, delivered_qty, warehouse = ( + sre_dict[item.name]["reserved_qty"], + sre_dict[item.name]["delivered_qty"], + sre_dict[item.name]["warehouse"], + ) + qty_to_deliver = (reserved_qty - delivered_qty) / item.conversion_factor row = frappe.new_doc("Delivery Note Item") row.against_sales_order = source.name @@ -650,6 +653,9 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): row.uom = item.uom row.conversion_factor = item.conversion_factor + if not frappe.get_cached_value("Warehouse", warehouse, "is_group"): + row.warehouse = warehouse + target.append("items", row) target.run_method("set_missing_values") 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 efe99799b1..c47049319d 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -85,6 +85,38 @@ class StockReservationEntry(Document): ) +@frappe.whitelist() +def get_available_qty_to_reserve(item_code, warehouse): + from frappe.query_builder.functions import Sum + + from erpnext.stock.get_item_details import get_bin_details + + available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get( + "actual_qty" + ) + + if available_qty: + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + warehouses = get_child_warehouses(warehouse) + sre = frappe.qb.DocType("Stock Reservation Entry") + reserved_qty = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty)) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse.isin(warehouses)) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + ).run()[0][0] or 0.0 + + if reserved_qty: + return available_qty - reserved_qty + + return available_qty + + def get_stock_reservation_entries_for_voucher( voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None ) -> list[dict]: From 1a84a0c411248ed4284f4ceab583fc76e784a8a2 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 30 Mar 2023 16:42:27 +0530 Subject: [PATCH 37/79] fix: DN Item group warehouse validation against SRE --- .../doctype/delivery_note/delivery_note.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 77c435eb68..b8d2186c32 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -316,9 +316,27 @@ class DeliveryNote(SellingController): ) ) + if not frappe.get_cached_value("Warehouse", sre.warehouse, "is_group"): + if item.warehouse != sre.warehouse: + frappe.throw( + _("Row #{0}: Warehouse {1} does not match with Stock Reservation Entry {2}").format( + item.idx, item.warehouse, item.against_sre + ) + ) + else: + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + warehouses = get_child_warehouses(sre.warehouse) + + if item.warehouse not in warehouses: + frappe.throw( + _("Row #{0}: Warehouse {1} should be a child of Warehouse {2}").format( + item.idx, item.warehouse, sre.warehouse + ) + ) + for field in ( "item_code", - "warehouse", ("against_sales_order", "voucher_no"), ("so_detail", "voucher_detail_no"), ): From 2d3997b2d7de58315b000302d91dc09fb32ab507 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 30 Mar 2023 23:27:45 +0530 Subject: [PATCH 38/79] refactor: remove `Against Stock Reservation Entry` field from DN Item --- .../doctype/sales_order/sales_order.py | 38 ----- .../doctype/delivery_note/delivery_note.js | 13 -- .../doctype/delivery_note/delivery_note.py | 156 ++++++++---------- .../delivery_note_item.json | 12 +- .../stock_reservation_entry.py | 68 ++------ 5 files changed, 87 insertions(+), 200 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 403477b375..d492f718e5 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -622,42 +622,7 @@ def make_project(source_name, target_doc=None): @frappe.whitelist() def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_stock_reservation_entries_for_voucher, - has_reserved_stock, - ) - def set_missing_values(source, target): - if not target.items and has_reserved_stock("Sales Order", source_name): - sre_list = get_stock_reservation_entries_for_voucher("Sales Order", source_name) - sre_dict = {d.pop("voucher_detail_no"): d for d in sre_list} - - for item in source.get("items"): - if item.name in sre_dict: - reserved_qty, delivered_qty, warehouse = ( - sre_dict[item.name]["reserved_qty"], - sre_dict[item.name]["delivered_qty"], - sre_dict[item.name]["warehouse"], - ) - qty_to_deliver = (reserved_qty - delivered_qty) / item.conversion_factor - - row = frappe.new_doc("Delivery Note Item") - row.against_sales_order = source.name - row.against_sre = sre_dict[item.name]["name"] - row.so_detail = item.name - row.item_code = item.item_code - row.item_name = item.item_name - row.description = item.description - row.qty = qty_to_deliver - row.stock_uom = item.stock_uom - row.uom = item.uom - row.conversion_factor = item.conversion_factor - - if not frappe.get_cached_value("Warehouse", warehouse, "is_group"): - row.warehouse = warehouse - - target.append("items", row) - target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") @@ -686,9 +651,6 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): or item_group.get("buying_cost_center") ) - if has_reserved_stock("Sales Order", source_name): - skip_item_mapping = True - mapper = { "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 53b35761b5..ae56645b73 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -77,19 +77,6 @@ frappe.ui.form.on("Delivery Note", { } }); - frm.set_query("against_sre", "items", (doc, cdt, cdn) => { - var row = locals[cdt][cdn]; - return { - filters: { - "docstatus": 1, - "status": ["not in", ["Delivered", "Cancelled"]], - "voucher_type": "Sales Order", - "voucher_no": row.against_sales_order, - "voucher_detail_no": row.so_detail, - } - } - }); - frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index b8d2186c32..f45f8bd6df 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -148,7 +148,7 @@ class DeliveryNote(SellingController): if not self.installation_status: self.installation_status = "Not Installed" - self.validate_against_sre() + self.validate_against_stock_reservation() self.reset_default_field_value("set_warehouse", "items", "warehouse") def validate_with_previous_doc(self): @@ -262,8 +262,6 @@ class DeliveryNote(SellingController): self.update_prevdoc_status() self.update_billing_status() - self.update_stock_reservation_entry() - # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO self.update_stock_ledger() @@ -275,105 +273,87 @@ class DeliveryNote(SellingController): self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") def update_stock_reservation_entry(self): - if not self.is_return: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - update_sre_delivered_qty, - ) - - for item in self.get("items"): - if item.against_sre: - update_sre_delivered_qty(item.doctype, item.against_sre) - - def validate_against_sre(self): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_stock_reservation_entries_for_items, - has_reserved_stock, - ) - - sre_details = get_stock_reservation_entries_for_items(self.items) + if self.is_return or self._action != "submit": + return for item in self.items: - if item.against_sre: - sre = sre_details[item.against_sre] + if not item.against_sales_order or not item.so_detail: + continue - # SRE `docstatus` should be `1` (submitted) - if sre.docstatus == 0: - frappe.throw( - _("Row #{0}: Stock Reservation Entry {1} is not submitted").format( - item.idx, item.against_sre - ) - ) - elif sre.docstatus == 2: - frappe.throw( - _("Row #{0}: Stock Reservation Entry {0} is cancelled").format(item.idx, item.against_sre) - ) + 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", + ) - # SRE `status` should not be `Delivered` - if sre.status == "Delivered": - frappe.throw( - _("Row #{0}: Cannot deliver more against Stock Reservation Entry {1}").format( - item.idx, item.against_sre - ) - ) + if not sre_list: + continue - if not frappe.get_cached_value("Warehouse", sre.warehouse, "is_group"): - if item.warehouse != sre.warehouse: + 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) + 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() + sre_doc.update_status() + + available_qty_to_deliver -= qty_to_be_deliver + + def validate_against_stock_reservation(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_details_for_voucher_detail_no, + ) + + if self.is_return: + return + + for item in self.items: + 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 + ) + + if not sre_data: + continue + + is_group_warehouse = frappe.get_cached_value("Warehouse", sre_data[0], "is_group") + + if not item.warehouse: + if not is_group_warehouse: + item.warehouse = sre_data[0] + else: + frappe.throw(_("Row #{0}: Warehouse is mandatory").format(item.idx, item.item_code)) + else: + if not is_group_warehouse: + if item.warehouse != sre_data[0]: frappe.throw( - _("Row #{0}: Warehouse {1} does not match with Stock Reservation Entry {2}").format( - item.idx, item.warehouse, item.against_sre - ) + _("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]), + title="Stock Reservation Warehouse Mismatch", ) else: from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses - warehouses = get_child_warehouses(sre.warehouse) - + warehouses = get_child_warehouses(sre_data[0]) if item.warehouse not in warehouses: frappe.throw( - _("Row #{0}: Warehouse {1} should be a child of Warehouse {2}").format( - item.idx, item.warehouse, sre.warehouse - ) + _( + "Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse" + ).format(item.idx, sre_data[0]), + title="Stock Reservation Group Warehouse", ) - for field in ( - "item_code", - ("against_sales_order", "voucher_no"), - ("so_detail", "voucher_detail_no"), - ): - item_field = sre_field = None - - if isinstance(field, tuple): - item_field, sre_field = field[0], field[1] - else: - item_field = sre_field = field - - if item.get(item_field) != sre.get(sre_field): - frappe.throw( - _("Row #{0}: {1} {2} does not match with Stock Reservation Entry {3}").format( - item.idx, - frappe.get_meta(item.doctype).get_label(item_field), - item.get(item_field), - item.against_sre, - ) - ) - - max_delivered_qty = (sre.reserved_qty - sre.delivered_qty) / item.conversion_factor - if item.qty > max_delivered_qty: - frappe.throw( - _("Row #{0}: Cannot deliver more than {1} {2} against Stock Reservation Entry {3}").format( - item.idx, max_delivered_qty, item.uom, item.against_sre - ) - ) - elif item.against_sales_order: - if not item.so_detail: - frappe.throw(_("Row #{0}: Sales Order Item reference is required").format(item.idx)) - elif has_reserved_stock("Sales Order", item.against_sales_order, item.so_detail): - frappe.throw( - _("Row #{0}: Cannot deliver against Sales Order {1} without Stock Reservation Entry").format( - item.idx, item.against_sales_order - ) - ) - def check_credit_limit(self): from erpnext.selling.doctype.customer.customer import check_credit_limit diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index faa7748c31..d3ed493714 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -76,7 +76,6 @@ "si_detail", "dn_detail", "pick_list_item", - "against_sre", "section_break_40", "batch_no", "serial_no", @@ -833,22 +832,13 @@ "fieldname": "material_request_item", "fieldtype": "Data", "label": "Material Request Item" - }, - { - "fieldname": "against_sre", - "fieldtype": "Link", - "label": "Against Stock Reservation Entry", - "no_copy": 1, - "options": "Stock Reservation Entry", - "print_hide": 1, - "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-03-26 16:53:08.283469", + "modified": "2023-03-30 23:27:30.943175", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", 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 c47049319d..c80ae577f3 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -152,6 +152,24 @@ def get_stock_reservation_entries_for_voucher( return query.run(as_dict=True) +def get_sre_reserved_qty_details_for_voucher_detail_no( + voucher_type: str, voucher_no: str, voucher_detail_no: str +) -> list: + sre = frappe.qb.DocType("Stock Reservation Entry") + return ( + frappe.qb.from_(sre) + .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty")) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.voucher_detail_no == voucher_detail_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.warehouse) + ).run(as_list=True)[0] + + def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: if get_stock_reservation_entries_for_voucher( voucher_type, voucher_no, voucher_detail_no, fields=["name"] @@ -161,56 +179,6 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False -def update_sre_delivered_qty( - doctype: str, sre_name: str, sre_field: str = "against_sre", qty_field: str = "stock_qty" -) -> None: - table = frappe.qb.DocType(doctype) - delivered_qty = ( - frappe.qb.from_(table) - .select(Sum(table[qty_field])) - .where((table.docstatus == 1) & (table[sre_field] == sre_name)) - ).run(as_list=True)[0][0] or 0.0 - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name) - sre_doc.delivered_qty = delivered_qty - sre_doc.db_update() - sre_doc.update_status() - - -def get_stock_reservation_entries_for_items( - items: list[dict | object], sre_field: str = "against_sre" -) -> dict[dict]: - sre_details = {} - - if items: - sre_list = [item.get(sre_field) for item in items if item.get(sre_field)] - - if sre_list: - sre = frappe.qb.DocType("Stock Reservation Entry") - sre_data = ( - frappe.qb.from_(sre) - .select( - sre.name, - sre.status, - sre.docstatus, - sre.item_code, - sre.warehouse, - sre.voucher_type, - sre.voucher_no, - sre.voucher_detail_no, - sre.reserved_qty, - sre.delivered_qty, - sre.stock_uom, - ) - .where(sre.name.isin(sre_list)) - .orderby(sre.creation) - ).run(as_dict=True) - - sre_details = {d.name: d for d in sre_data} - - return sre_details - - def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict: sre_details = {} From 06d1bc4d126095dbeb33e920ad060104a6f2e249 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 12:20:52 +0530 Subject: [PATCH 39/79] Revert "chore: add SRE ref in DN dashboard" This reverts commit 15cb99290c02c1d6cf4fbda6032c03eed58412d9. --- .../stock/doctype/delivery_note/delivery_note_dashboard.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index 9c64c17175..b6b5ff4296 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -13,14 +13,10 @@ def get_data(): "Sales Order": ["items", "against_sales_order"], "Material Request": ["items", "material_request"], "Purchase Order": ["items", "purchase_order"], - "Stock Reservation Entry": ["items", "against_sre"], }, "transactions": [ {"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]}, - { - "label": _("Reference"), - "items": ["Sales Order", "Shipment", "Quality Inspection", "Stock Reservation Entry"], - }, + {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, {"label": _("Returns"), "items": ["Stock Entry"]}, {"label": _("Subscription"), "items": ["Auto Repeat"]}, {"label": _("Internal Transfer"), "items": ["Material Request", "Purchase Order"]}, From 4d8ae41553fcda58477c24c6a8a65c7edfc2de32 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 12:50:13 +0530 Subject: [PATCH 40/79] chore: make `Reserve Stock on Sales Order Submission` disabled by default --- erpnext/stock/doctype/stock_settings/stock_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 02ea3813e2..4b390d3c34 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -357,7 +357,7 @@ "label": "Enable Stock Reservation" }, { - "default": "1", + "default": "0", "depends_on": "eval: doc.enable_stock_reservation", "fieldname": "reserve_stock_on_sales_order_submission", "fieldtype": "Check", @@ -380,7 +380,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-03-23 18:59:11.773360", + "modified": "2023-03-31 12:49:14.473312", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From 81fe5cfd726be3d64d14a429aa2283ff6e4fd581 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 13:06:30 +0530 Subject: [PATCH 41/79] chore: update `Reserve Stock` label to `Reserve Stock on Submit` in SO --- erpnext/selling/doctype/sales_order/sales_order.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 40cb17df05..d09f7c5556 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1643,7 +1643,7 @@ "default": "0", "fieldname": "reserve_stock", "fieldtype": "Check", - "label": "Reserve Stock", + "label": "Reserve Stock on Submit", "no_copy": 1 } ], @@ -1651,7 +1651,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-03-20 23:51:04.036757", + "modified": "2023-03-31 13:04:36.653260", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1684,7 +1684,6 @@ "read": 1, "report": 1, "role": "Sales Manager", - "set_user_permissions": 1, "share": 1, "submit": 1, "write": 1 From 26569b2162564903a116e85666576f35dfb746da Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 13:12:19 +0530 Subject: [PATCH 42/79] fix: Stock Reservation validation for SO --- erpnext/controllers/stock_controller.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 507deae42c..58891c113f 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -791,11 +791,6 @@ class StockController(AccountsController): if not self.get("reserve_stock"): return - if self.doctype != "Sales Order": - frappe.throw( - _("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order")) - ) - if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): frappe.throw( _("Please enable {0} in the {1}.").format( @@ -803,11 +798,9 @@ class StockController(AccountsController): ) ) - if not frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission"): + if self.doctype != "Sales Order": frappe.throw( - _("Please enable {0} in the {1}.").format( - frappe.bold("Reserve Stock on Sales Order Submission"), frappe.bold("Stock Settings") - ) + _("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order")) ) for item in self.get("items"): From 0ae400c9866ec9e6f125a9a7cce4df2f92fa980d Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 15:38:16 +0530 Subject: [PATCH 43/79] feat: add option to unreserve stock in SO --- .../doctype/sales_order/sales_order.js | 21 ++++++++++++ .../doctype/sales_order/sales_order.py | 8 +++++ .../stock_reservation_entry.py | 32 +++++++++++++------ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index d222c3eb31..1bb181b496 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -291,6 +291,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } this.frm.page.set_inner_btn_group_as_primary(__('Create')); } + + // Stock Reservation + if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) { + this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation')); + } } if (this.frm.doc.docstatus===0) { @@ -330,6 +335,22 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } + cancel_stock_reservation_entries() { + frappe.call({ + method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries", + args: { + voucher_type: this.frm.doctype, + voucher_no: this.frm.docname + }, + freeze: true, + freeze_message: __("Unreserving Stock..."), + callback: (r) => { + this.frm.doc.__onload.has_reserved_stock = false; + this.frm.refresh(); + } + }) + } + create_pick_list() { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index d492f718e5..06c84b0b78 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -44,6 +44,14 @@ class SalesOrder(SellingController): def __init__(self, *args, **kwargs): super(SalesOrder, self).__init__(*args, **kwargs) + def onload(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + has_reserved_stock, + ) + + 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() 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 c80ae577f3..1b6388d53d 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -85,7 +85,6 @@ class StockReservationEntry(Document): ) -@frappe.whitelist() def get_available_qty_to_reserve(item_code, warehouse): from frappe.query_builder.functions import Sum @@ -170,15 +169,6 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( ).run(as_list=True)[0] -def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: - if get_stock_reservation_entries_for_voucher( - voucher_type, voucher_no, voucher_detail_no, fields=["name"] - ): - return True - - return False - - def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict: sre_details = {} @@ -209,3 +199,25 @@ def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) - sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data} return sre_details + + +@frappe.whitelist() +def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: + 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 +) -> None: + sre_list = get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"] + ) + + for sre in sre_list: + frappe.get_doc("Stock Reservation Entry", sre.name).cancel() From de1492759d7761949e3d7236eb91522720016c42 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 21:42:13 +0530 Subject: [PATCH 44/79] feat: add option to reserve stock in SO --- erpnext/controllers/stock_controller.py | 66 ----------- .../doctype/sales_order/sales_order.js | 19 ++++ .../doctype/sales_order/sales_order.py | 38 ++++++- .../stock_reservation_entry.py | 103 +++++++++++++++++- 4 files changed, 157 insertions(+), 69 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 58891c113f..1e4fabe0d2 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -783,72 +783,6 @@ class StockController(AccountsController): gl_entries.append(self.get_gl_dict(gl_entry, item=item)) - def make_sr_entries(self): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_available_qty_to_reserve, - ) - - if not self.get("reserve_stock"): - return - - 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") - ) - ) - - if self.doctype != "Sales Order": - frappe.throw( - _("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order")) - ) - - for item in self.get("items"): - if not item.get("reserve_stock"): - continue - - available_qty = get_available_qty_to_reserve(item.item_code, item.warehouse) - reserved_qty = min(item.stock_qty, available_qty) - - if not reserved_qty: - frappe.msgprint( - _("Row {0}: No available stock to reserve for the Item {1}").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - indicator="orange", - ) - continue - - elif reserved_qty < item.stock_qty: - frappe.msgprint( - _("Row {0}: Only {1} available to reserve for the Item {2}").format( - item.idx, - frappe.bold(str(reserved_qty / item.conversion_factor) + " " + item.uom), - frappe.bold(item.item_code), - ), - title=_("Stock Reservation"), - indicator="orange", - ) - - if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"): - continue - - 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 - sre.voucher_qty = item.stock_qty - sre.reserved_qty = reserved_qty - sre.company = self.company - sre.stock_uom = item.stock_uom - sre.project = self.project - sre.save() - sre.submit() - def repost_required_for_queue(doc: StockController) -> bool: """check if stock document contains repeated item-warehouse with queue based valuation. diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 1bb181b496..a4578bcf2c 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -293,6 +293,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // Stock Reservation + if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) { + this.frm.add_custom_button(__('Reserve'), () => this.reserve_stock_against_sales_order(), __('Stock Reservation')); + } + if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) { this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation')); } @@ -335,6 +339,21 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } + reserve_stock_against_sales_order() { + frappe.call({ + method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.reserve_stock_against_sales_order", + args: { + sales_order: this.frm.docname + }, + freeze: true, + freeze_message: __("Reserving Stock..."), + callback: (r) => { + this.frm.doc.__onload.has_unreserved_stock = false; + this.frm.refresh(); + } + }) + } + cancel_stock_reservation_entries() { frappe.call({ method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 06c84b0b78..3a8b65a479 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -52,6 +52,9 @@ class SalesOrder(SellingController): if has_reserved_stock(self.doctype, self.name): self.set_onload("has_reserved_stock", True) + if self.has_unreserved_stock(): + self.set_onload("has_unreserved_stock", True) + def validate(self): super(SalesOrder, self).validate() self.validate_delivery_date() @@ -249,7 +252,12 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code, "used") - self.make_sr_entries() + if self.get("reserve_stock"): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + reserve_stock_against_sales_order, + ) + + reserve_stock_against_sales_order(self) def on_cancel(self): self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") @@ -495,6 +503,34 @@ class SalesOrder(SellingController): ).format(item.item_code) ) + def has_unreserved_stock(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_details_for_voucher_detail_no, + ) + + for item in self.items: + if not item.get("reserve_stock"): + continue + + reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no( + "Sales Order", self.name, item.name + ) + + existing_reserved_qty = 0.0 + if reserved_qty_details: + existing_reserved_qty = reserved_qty_details[1] + + unreserved_qty = ( + item.stock_qty + - flt(item.delivered_qty) * item.get("conversion_factor", 1) + - existing_reserved_qty + ) + + if unreserved_qty > 0: + return True + + return False + def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context 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 1b6388d53d..75aa2a65cc 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum +from frappe.utils import flt class StockReservationEntry(Document): @@ -85,6 +86,21 @@ class StockReservationEntry(Document): ) +def validate_stock_reservation_settings(voucher): + 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") + ) + ) + + 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, warehouse): from frappe.query_builder.functions import Sum @@ -155,7 +171,7 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( voucher_type: str, voucher_no: str, voucher_detail_no: str ) -> list: sre = frappe.qb.DocType("Stock Reservation Entry") - return ( + reserved_qty_details = ( frappe.qb.from_(sre) .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty")) .where( @@ -166,7 +182,12 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( & (sre.status.notin(["Delivered", "Cancelled"])) ) .groupby(sre.warehouse) - ).run(as_list=True)[0] + ).run(as_list=True) + + if reserved_qty_details: + return reserved_qty_details[0] + + return reserved_qty_details def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict: @@ -211,6 +232,84 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False +@frappe.whitelist() +def reserve_stock_against_sales_order(sales_order: object | str) -> None: + if isinstance(sales_order, str): + sales_order = frappe.get_doc("Sales Order", sales_order) + + validate_stock_reservation_settings(sales_order) + + for item in sales_order.get("items"): + if not item.get("reserve_stock"): + continue + + reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no( + "Sales Order", sales_order.name, item.name + ) + + existing_reserved_qty = 0.0 + if reserved_qty_details: + existing_reserved_qty = reserved_qty_details[1] + + unreserved_qty = ( + item.stock_qty + - flt(item.delivered_qty) * item.get("conversion_factor", 1) + - existing_reserved_qty + ) + + if unreserved_qty <= 0: + frappe.msgprint( + _("Row #{0}: Stock is already reserved for the Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + ) + continue + + available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) + + if available_qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: No available stock to reserve for the Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) + + if qty_to_be_reserved < unreserved_qty: + frappe.msgprint( + _("Row #{0}: Only {1} available to reserve for the Item {2}").format( + item.idx, + frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), + frappe.bold(item.item_code), + ), + title=_("Stock Reservation"), + indicator="orange", + ) + + if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"): + continue + + sre = frappe.new_doc("Stock Reservation Entry") + sre.item_code = item.item_code + sre.warehouse = item.warehouse + 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 = sales_order.company + sre.stock_uom = item.stock_uom + sre.project = sales_order.project + sre.save() + sre.submit() + + @frappe.whitelist() def cancel_stock_reservation_entries( voucher_type: str, voucher_no: str, voucher_detail_no: str = None From 632f27b10d13e0322bc46eb0802f77ea3526961a Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 21:58:21 +0530 Subject: [PATCH 45/79] fix(ux): `Reserve Stock` and `Reserved Stock Qty` in SO Item --- .../selling/doctype/sales_order_item/sales_order_item.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 be85d9a99e..ec3d7695d0 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -863,15 +863,15 @@ "label": "Material Request Item" }, { + "allow_on_submit": 1, "default": "1", - "depends_on": "eval: parent.reserve_stock", "fieldname": "reserve_stock", "fieldtype": "Check", "label": "Reserve Stock" }, { "default": "0", - "depends_on": "eval: (parent.reserve_stock && doc.reserve_stock)", + "depends_on": "eval: doc.stock_reserved_qty", "fieldname": "stock_reserved_qty", "fieldtype": "Float", "label": "Stock Reserved Qty (in Stock UOM)", @@ -882,7 +882,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-03-21 13:14:47.915610", + "modified": "2023-03-31 21:53:47.431882", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", From ef34f703d430163bec718325d8d8c470e1fb1896 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 22:08:12 +0530 Subject: [PATCH 46/79] fix(ux): don't show `Stock Reservation` btn if Stock Reservation is disabled --- .../selling/doctype/sales_order/sales_order.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 3a8b65a479..b24e4810a1 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -45,15 +45,16 @@ class SalesOrder(SellingController): super(SalesOrder, self).__init__(*args, **kwargs) def onload(self): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - has_reserved_stock, - ) + if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + has_reserved_stock, + ) - if has_reserved_stock(self.doctype, self.name): - self.set_onload("has_reserved_stock", True) + if has_reserved_stock(self.doctype, self.name): + self.set_onload("has_reserved_stock", True) - if self.has_unreserved_stock(): - self.set_onload("has_unreserved_stock", True) + if self.has_unreserved_stock(): + self.set_onload("has_unreserved_stock", True) def validate(self): super(SalesOrder, self).validate() From ee322c49049ffaa75b9139e821b2908502b9f2f6 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 1 Apr 2023 15:55:10 +0530 Subject: [PATCH 47/79] fix(ux): `Allow Partial Reservation` depends on `Enable Stock Reservation` --- erpnext/stock/doctype/stock_settings/stock_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 4b390d3c34..170dcb1aab 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -369,7 +369,7 @@ }, { "default": "1", - "depends_on": "eval: (doc.enable_stock_reservation && doc.reserve_stock_on_sales_order_submission)", + "depends_on": "eval: doc.enable_stock_reservation", "fieldname": "allow_partial_reservation", "fieldtype": "Check", "label": "Allow Partial Reservation" @@ -380,7 +380,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-03-31 12:49:14.473312", + "modified": "2023-04-01 15:52:28.717324", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From 8f3d5d24e1af0538254de509ec7310494a15d3ce Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 1 Apr 2023 16:21:50 +0530 Subject: [PATCH 48/79] chore: notify user on Reservation and Unreservation of Stock --- .../stock_reservation_entry.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 75aa2a65cc..6416631c5a 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -239,6 +239,7 @@ def reserve_stock_against_sales_order(sales_order: object | str) -> None: validate_stock_reservation_settings(sales_order) + sre_count = 0 for item in sales_order.get("items"): if not item.get("reserve_stock"): continue @@ -309,6 +310,11 @@ def reserve_stock_against_sales_order(sales_order: object | str) -> None: sre.save() sre.submit() + sre_count += 1 + + if sre_count: + frappe.msgprint(_("Stock Reservation Entry created"), alert=True, indicator="green") + @frappe.whitelist() def cancel_stock_reservation_entries( @@ -318,5 +324,8 @@ def cancel_stock_reservation_entries( voucher_type, voucher_no, voucher_detail_no, fields=["name"] ) - for sre in sre_list: - frappe.get_doc("Stock Reservation Entry", sre.name).cancel() + if sre_list: + for sre in sre_list: + frappe.get_doc("Stock Reservation Entry", sre.name).cancel() + + frappe.msgprint(_("Stock Reservation Entry cancelled"), alert=True, indicator="red") From d5f0a7fcbbbf53b5317d830bf07bead3bbcbfaf2 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 2 Apr 2023 19:23:22 +0530 Subject: [PATCH 49/79] refactor(minor): stock reservation entry --- .../stock_reservation_entry/stock_reservation_entry.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 6416631c5a..3e6c84032f 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -86,7 +86,7 @@ class StockReservationEntry(Document): ) -def validate_stock_reservation_settings(voucher): +def validate_stock_reservation_settings(voucher: object) -> None: if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): frappe.throw( _("Please enable {0} in the {1}.").format( @@ -101,9 +101,7 @@ def validate_stock_reservation_settings(voucher): ) -def get_available_qty_to_reserve(item_code, warehouse): - from frappe.query_builder.functions import Sum - +def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: from erpnext.stock.get_item_details import get_bin_details available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get( From 38e93671846e7fcf13adf4a2e080e314633db5d7 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 2 Apr 2023 21:24:59 +0530 Subject: [PATCH 50/79] fix: re-reserve stock on SO `Update Items` --- erpnext/controllers/accounts_controller.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3705fcf499..bd6b1f1216 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2784,6 +2784,17 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.update_billing_percentage() parent.set_status() + if parent_doctype == "Sales Order": + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + has_reserved_stock, + reserve_stock_against_sales_order, + ) + + if has_reserved_stock(parent.doctype, parent.name): + cancel_stock_reservation_entries(parent.doctype, parent.name) + reserve_stock_against_sales_order(parent.name) + @erpnext.allow_regional def validate_regional(doc): From ac24d778e85f9983cc293373198ae8a953720ed2 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 5 Apr 2023 19:48:15 +0530 Subject: [PATCH 51/79] refactor: add `Docstrings` for functions --- erpnext/controllers/accounts_controller.py | 4 +- .../doctype/sales_order/sales_order.js | 28 ++- .../doctype/sales_order/sales_order.json | 9 +- .../doctype/sales_order/sales_order.py | 133 +++++++++--- .../sales_order_item/sales_order_item.json | 11 +- .../doctype/delivery_note/delivery_note.py | 29 ++- .../stock_reservation_entry.py | 196 ++++++++---------- .../stock_settings/stock_settings.json | 5 +- .../doctype/stock_settings/stock_settings.py | 2 + .../report/stock_balance/stock_balance.py | 4 +- 10 files changed, 253 insertions(+), 168 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index bd6b1f1216..bab6bb7b7b 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2784,16 +2784,16 @@ 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, - reserve_stock_against_sales_order, ) if has_reserved_stock(parent.doctype, parent.name): cancel_stock_reservation_entries(parent.doctype, parent.name) - reserve_stock_against_sales_order(parent.name) + parent.create_stock_reservation_entries() @erpnext.allow_regional diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index a4578bcf2c..37c229417f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -46,8 +46,6 @@ 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' @@ -71,13 +69,11 @@ frappe.ui.form.on("Sales Order", { 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 (value) { - frm.set_value("reserve_stock", 1); - } else { - frm.set_value("reserve_stock", 0); - } + // 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); } @@ -292,11 +288,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.frm.page.set_inner_btn_group_as_primary(__('Create')); } - // Stock Reservation + // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) { - this.frm.add_custom_button(__('Reserve'), () => this.reserve_stock_against_sales_order(), __('Stock Reservation')); + this.frm.add_custom_button(__('Reserve'), () => this.create_stock_reservation_entries(), __('Stock Reservation')); } + // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock. if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) { this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation')); } @@ -339,14 +336,15 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } - reserve_stock_against_sales_order() { + create_stock_reservation_entries() { frappe.call({ - method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.reserve_stock_against_sales_order", + doc: this.frm.doc, + method: 'create_stock_reservation_entries', args: { - sales_order: this.frm.docname + notify: true }, freeze: true, - freeze_message: __("Reserving Stock..."), + freeze_message: __('Reserving Stock...'), callback: (r) => { this.frm.doc.__onload.has_unreserved_stock = false; this.frm.refresh(); @@ -356,13 +354,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex cancel_stock_reservation_entries() { frappe.call({ - method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries", + method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries', args: { voucher_type: this.frm.doctype, voucher_no: this.frm.docname }, freeze: true, - freeze_message: __("Unreserving Stock..."), + freeze_message: __('Unreserving Stock...'), callback: (r) => { this.frm.doc.__onload.has_reserved_stock = false; this.frm.refresh(); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index d09f7c5556..47bb37c91f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1641,17 +1641,20 @@ }, { "default": "0", + "description": "If checked, Stock Reservation Entries will be created on Submit", "fieldname": "reserve_stock", "fieldtype": "Check", - "label": "Reserve Stock on Submit", - "no_copy": 1 + "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-03-31 13:04:36.653260", + "modified": "2023-04-04 10:39:34.129343", "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 b24e4810a1..9ed57df175 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -30,6 +30,9 @@ 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 ( + get_sre_reserved_qty_details_for_voucher, +) 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,7 +47,7 @@ class SalesOrder(SellingController): def __init__(self, *args, **kwargs): super(SalesOrder, self).__init__(*args, **kwargs) - def onload(self): + def onload(self) -> None: if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( has_reserved_stock, @@ -254,11 +257,7 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code, "used") if self.get("reserve_stock"): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - reserve_stock_against_sales_order, - ) - - reserve_stock_against_sales_order(self) + self.create_stock_reservation_entries() def on_cancel(self): self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") @@ -504,34 +503,118 @@ class SalesOrder(SellingController): ).format(item.item_code) ) - def has_unreserved_stock(self): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_voucher_detail_no, - ) + def has_unreserved_stock(self) -> bool: + """Returns True if there is any unreserved item in the Sales Order.""" - for item in self.items: + 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 - reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no( - "Sales Order", self.name, item.name - ) - - existing_reserved_qty = 0.0 - if reserved_qty_details: - existing_reserved_qty = reserved_qty_details[1] - - unreserved_qty = ( - item.stock_qty - - flt(item.delivered_qty) * item.get("conversion_factor", 1) - - existing_reserved_qty - ) - + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) if unreserved_qty > 0: return True return False + @frappe.whitelist() + def create_stock_reservation_entries(self, notify=True): + """Creates Stock Reservation Entries for Sales Order Items.""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_available_qty_to_reserve, + validate_stock_reservation_settings, + ) + + validate_stock_reservation_settings(self) + + allow_partial_reservation = frappe.db.get_single_value( + "Stock Settings", "allow_partial_reservation" + ) + + sre_count = 0 + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) + for item in self.get("items"): + if not item.get("reserve_stock"): + continue + + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) + + # Stock is already reserved for the item, notify the user and skip the item. + if unreserved_qty <= 0: + frappe.msgprint( + _("Row #{0}: Stock is already reserved for the Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + ) + continue + + available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) + + # No stock available to reserve, notify the user and skip the item. + if available_qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: No available stock to reserve for the Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + # The quantity which can be reserved. + qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) + + # Partial Reservation + if qty_to_be_reserved < unreserved_qty: + frappe.msgprint( + _("Row #{0}: Only {1} available to reserve for the Item {2}").format( + item.idx, + frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), + frappe.bold(item.item_code), + ), + title=_("Stock Reservation"), + indicator="orange", + ) + + # Skip the item if `Partial Reservation` is disabled in the Stock Settings. + if not allow_partial_reservation: + continue + + # Create and Submit Stock Reservation Entry + sre = frappe.new_doc("Stock Reservation Entry") + sre.item_code = item.item_code + sre.warehouse = item.warehouse + sre.voucher_type = self.doctype + sre.voucher_no = self.name + sre.voucher_detail_no = item.name + sre.available_qty = available_qty_to_reserve + sre.voucher_qty = item.stock_qty + sre.reserved_qty = qty_to_be_reserved + sre.company = self.company + sre.stock_uom = item.stock_uom + sre.project = self.project + sre.save() + sre.submit() + + sre_count += 1 + + if sre_count and notify: + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + + +def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: + """Returns the unreserved quantity for the Sales Order Item.""" + + existing_reserved_qty = reserved_qty_details.get((item.name, item.warehouse), 0) + return ( + item.stock_qty + - flt(item.delivered_qty) * item.get("conversion_factor", 1) + - existing_reserved_qty + ) + def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context 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 ec3d7695d0..5c7e10a232 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -867,7 +867,9 @@ "default": "1", "fieldname": "reserve_stock", "fieldtype": "Check", - "label": "Reserve Stock" + "label": "Reserve Stock", + "print_hide": 1, + "report_hide": 1 }, { "default": "0", @@ -876,13 +878,16 @@ "fieldtype": "Float", "label": "Stock Reserved Qty (in Stock UOM)", "no_copy": 1, - "read_only": 1 + "non_negative": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-03-31 21:53:47.431882", + "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 f45f8bd6df..3c6286e197 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -148,7 +148,7 @@ class DeliveryNote(SellingController): if not self.installation_status: self.installation_status = "Not Installed" - self.validate_against_stock_reservation() + self.validate_against_stock_reservation_entries() self.reset_default_field_value("set_warehouse", "items", "warehouse") def validate_with_previous_doc(self): @@ -241,7 +241,7 @@ class DeliveryNote(SellingController): self.update_prevdoc_status() self.update_billing_status() - self.update_stock_reservation_entry() + self.update_stock_reservation_entries() if not self.is_return: self.check_credit_limit() @@ -272,11 +272,15 @@ 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_entry(self): - if self.is_return or self._action != "submit": + 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.items: + 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 @@ -293,6 +297,7 @@ class DeliveryNote(SellingController): order_by="creation", ) + # Skip if no Stock Reservation Entries. if not sre_list: continue @@ -302,22 +307,31 @@ class DeliveryNote(SellingController): 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(self): + 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.items: + 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 @@ -325,6 +339,7 @@ class DeliveryNote(SellingController): "Sales Order", item.against_sales_order, item.so_detail ) + # Skip if stock is not reserved. if not sre_data: continue 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 3e6c84032f..f770059131 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -5,7 +5,6 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum -from frappe.utils import flt class StockReservationEntry(Document): @@ -25,6 +24,8 @@ class StockReservationEntry(Document): self.update_status() def validate_mandatory(self) -> None: + """Raises exception if mandatory fields are not set.""" + mandatory = [ "item_code", "warehouse", @@ -42,6 +43,8 @@ class StockReservationEntry(Document): frappe.throw(_("{0} is required").format(self.meta.get_label(d))) def update_status(self, status: str = None, update_modified: bool = True) -> None: + """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty.""" + if not status: if self.docstatus == 2: status = "Cancelled" @@ -62,6 +65,8 @@ class StockReservationEntry(Document): 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: @@ -87,6 +92,8 @@ class StockReservationEntry(Document): 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( @@ -94,7 +101,9 @@ def validate_stock_reservation_settings(voucher: object) -> None: ) ) + # 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)) @@ -102,6 +111,8 @@ def validate_stock_reservation_settings(voucher: object) -> None: def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: + """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination.""" + from erpnext.stock.get_item_details import get_bin_details available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get( @@ -133,6 +144,8 @@ def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: 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", @@ -165,30 +178,11 @@ def get_stock_reservation_entries_for_voucher( return query.run(as_dict=True) -def get_sre_reserved_qty_details_for_voucher_detail_no( - voucher_type: str, voucher_no: str, voucher_detail_no: str -) -> list: - 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)).as_("reserved_qty")) - .where( - (sre.docstatus == 1) - & (sre.voucher_type == voucher_type) - & (sre.voucher_no == voucher_no) - & (sre.voucher_detail_no == voucher_detail_no) - & (sre.status.notin(["Delivered", "Cancelled"])) - ) - .groupby(sre.warehouse) - ).run(as_list=True) +def get_sre_reserved_qty_details_for_item_and_warehouse( + item_code: str | list, warehouse: str | list +) -> dict: + """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" - if reserved_qty_details: - return reserved_qty_details[0] - - return reserved_qty_details - - -def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict: sre_details = {} if item_code and warehouse: @@ -220,8 +214,69 @@ def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) - return sre_details -@frappe.whitelist() +def get_sre_reserved_qty_details_for_voucher( + voucher_type: str, voucher_no: str, voucher_detail_no: str = None +) -> dict: + """Returns a dict like {("voucher_detail_no", "warehouse"): "reserved_qty", ... }.""" + + reserved_qty_details = {} + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.voucher_detail_no, + sre.warehouse, + (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"), + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.voucher_detail_no, sre.warehouse) + ) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + data = query.run(as_dict=True) + + for d in data: + reserved_qty_details[(d["voucher_detail_no"], d["warehouse"])] = d["reserved_qty"] + + return reserved_qty_details + + +def get_sre_reserved_qty_details_for_voucher_detail_no( + voucher_type: str, voucher_no: str, voucher_detail_no: str +) -> list: + """Returns a list like ["warehouse", "reserved_qty"].""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + reserved_qty_details = ( + frappe.qb.from_(sre) + .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty))) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.voucher_detail_no == voucher_detail_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.warehouse) + ).run(as_list=True) + + if reserved_qty_details: + return reserved_qty_details[0] + + return reserved_qty_details + + def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: + """Returns True if there is any Stock Reservation Entry for the given voucher.""" + if get_stock_reservation_entries_for_voucher( voucher_type, voucher_no, voucher_detail_no, fields=["name"] ): @@ -230,94 +285,12 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False -@frappe.whitelist() -def reserve_stock_against_sales_order(sales_order: object | str) -> None: - if isinstance(sales_order, str): - sales_order = frappe.get_doc("Sales Order", sales_order) - - validate_stock_reservation_settings(sales_order) - - sre_count = 0 - for item in sales_order.get("items"): - if not item.get("reserve_stock"): - continue - - reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no( - "Sales Order", sales_order.name, item.name - ) - - existing_reserved_qty = 0.0 - if reserved_qty_details: - existing_reserved_qty = reserved_qty_details[1] - - unreserved_qty = ( - item.stock_qty - - flt(item.delivered_qty) * item.get("conversion_factor", 1) - - existing_reserved_qty - ) - - if unreserved_qty <= 0: - frappe.msgprint( - _("Row #{0}: Stock is already reserved for the Item {1}").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - ) - continue - - available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) - - if available_qty_to_reserve <= 0: - frappe.msgprint( - _("Row #{0}: No available stock to reserve for the Item {1}").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - indicator="orange", - ) - continue - - qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) - - if qty_to_be_reserved < unreserved_qty: - frappe.msgprint( - _("Row #{0}: Only {1} available to reserve for the Item {2}").format( - item.idx, - frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), - frappe.bold(item.item_code), - ), - title=_("Stock Reservation"), - indicator="orange", - ) - - if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"): - continue - - sre = frappe.new_doc("Stock Reservation Entry") - sre.item_code = item.item_code - sre.warehouse = item.warehouse - 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 = sales_order.company - sre.stock_uom = item.stock_uom - sre.project = sales_order.project - sre.save() - sre.submit() - - sre_count += 1 - - if sre_count: - frappe.msgprint(_("Stock Reservation Entry created"), alert=True, indicator="green") - - @frappe.whitelist() def cancel_stock_reservation_entries( - voucher_type: str, voucher_no: str, voucher_detail_no: str = None + 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"] ) @@ -326,4 +299,5 @@ def cancel_stock_reservation_entries( for sre in sre_list: frappe.get_doc("Stock Reservation Entry", sre.name).cancel() - frappe.msgprint(_("Stock Reservation Entry cancelled"), alert=True, indicator="red") + if notify: + frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red") diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 170dcb1aab..9ce5e9f599 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -352,6 +352,7 @@ }, { "default": "1", + "description": "Allows to create Stock Reservations against Sales Order", "fieldname": "enable_stock_reservation", "fieldtype": "Check", "label": "Enable Stock Reservation" @@ -359,6 +360,7 @@ { "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" @@ -370,6 +372,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. ", "fieldname": "allow_partial_reservation", "fieldtype": "Check", "label": "Allow Partial Reservation" @@ -380,7 +383,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-04-01 15:52:28.717324", + "modified": "2023-04-04 22:46:42.287425", "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 d761b663f5..f041e796d6 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -101,6 +101,8 @@ class StockSettings(Document): check_pending_reposting(self.stock_frozen_upto) def cant_disable_stock_reservation(self): + """Raises an exception if user tries to disable Stock Reservation and there are existing Stock Reservation Entries.""" + if not self.enable_stock_reservation: db_enable_stock_reservation = frappe.db.get_single_value( "Stock Settings", "enable_stock_reservation" diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index b8d6b6c7ce..a6a630fbf5 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -398,8 +398,10 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): def get_sre_reserved_qty_details(iwb_map: list) -> dict: + """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details as get_reserved_qty_details, + get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details, ) item_code_list, warehouse_list = [], [] From a918adaa33eccaf16a8a6fca46b7e36428287d9c Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 9 Apr 2023 08:28:10 +0530 Subject: [PATCH 52/79] test: add test cases for SRE --- .../test_stock_reservation_entry.py | 246 +++++++++++++++++- 1 file changed, 244 insertions(+), 2 deletions(-) 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 e7b829e7c1..f64da92a85 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 @@ -1,9 +1,251 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe +import frappe from frappe.tests.utils import FrappeTestCase class TestStockReservationEntry(FrappeTestCase): - pass + 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` + update_stock_settings("enable_stock_reservation", 0) + self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher) + + # Case - 2: When `Voucher Type` is not allowed for `Stock Reservation`, throw `ValidationError` + update_stock_settings("enable_stock_reservation", 1) + voucher.doctype = "NOT ALLOWED" + self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher) + + # Case - 3: When `Stock Reservation` is enabled and `Voucher Type` is allowed + update_stock_settings("enable_stock_reservation", 1) + 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, + ) + from erpnext.stock.utils import get_stock_balance + + 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") + + def test_update_reserved_qty_in_voucher(self) -> None: + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" + + # Step - 1: Enable `Stock Reservation` + update_stock_settings("enable_stock_reservation", 1) + + # Step - 2: 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 - 3: 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 - 4: 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 - 5: 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 - 6: 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) + + +def update_stock_settings(field: str, value: any) -> None: + frappe.db.set_single_value("Stock Settings", field, value) + + +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}, + "SR Batch Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, + # Serial Items + "SR Serial Item 1": {"is_stock_item": 1, "valuation_rate": 100}, + "SR Serial Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, + # Batch and Serial Items + "SR Batch and Serial Item 1": {"is_stock_item": 1, "valuation_rate": 100}, + "SR Batch and Serial Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, + } + + 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: + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + 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 From bc3cb6bff694dc03fbf22ee431149865004e6da9 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 10 Apr 2023 08:32:37 +0530 Subject: [PATCH 53/79] fix: cancel SRE on SO cancel --- erpnext/selling/doctype/sales_order/sales_order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 9ed57df175..13310f965b 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -31,6 +31,7 @@ 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, ) from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate @@ -275,6 +276,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: From efcb84cedf5f0900ec09b9bb17e5d7bbbc597f5b Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 10 Apr 2023 09:00:24 +0530 Subject: [PATCH 54/79] test: add test cases for SO --- .../doctype/sales_order/test_sales_order.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 627914f0c7..03b5c4d9f3 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1878,6 +1878,140 @@ class TestSalesOrder(FrappeTestCase): self.assertEqual(pe.references[1].reference_name, so.name) self.assertEqual(pe.references[1].allocated_amount, 300) + def test_stock_reservation_against_sales_order(self) -> None: + from random import randint, uniform + + from erpnext.controllers.status_updater import OverAllowanceError + 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, + update_stock_settings, + ) + + 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: `ValidationError` should be thrown, if Stock Reservation is disabled in Stock Settings. + update_stock_settings("enable_stock_reservation", 0) + self.assertRaises(frappe.ValidationError, so.create_stock_reservation_entries) + + # Enable Stock Reservation. + update_stock_settings("enable_stock_reservation", 1) + + # Test - 2: 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. + update_stock_settings("allow_partial_reservation", 0) + so.create_stock_reservation_entries() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + # Test - 3: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings. + update_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 - 4: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty. + create_material_receipts(items_details, warehouse, qty=100) + so.create_stock_reservation_entries() + so.load_from_db() + + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) + for item in so.items: + reserved_qty = reserved_qty_details[(item.name, item.warehouse)] + self.assertEqual(item.stock_reserved_qty, reserved_qty) + self.assertEqual(item.stock_qty, item.stock_reserved_qty) + + # Test - 5: 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 - 6: Re-reserve the stock. + so.create_stock_reservation_entries() + self.assertTrue(has_reserved_stock("Sales Order", so.name)) + + # Test - 7: 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 - 8: 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 - 9: Over Delivery against Sales Order, should throw `OverAllowanceError` if Over Allowance is not set. + update_stock_settings("over_delivery_receipt_allowance", 0) + 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() + self.assertRaises(OverAllowanceError, dn2.submit) + + # Test - 10: Over Delivery against Sales Order when Over Allowance is set. + update_stock_settings("over_delivery_receipt_allowance", 100) + dn2.submit() + update_stock_settings("over_delivery_receipt_allowance", 0) + + # Test - 11: SRE Delivered Qty should be equal to the SRE Reserved Qty. + so.load_from_db() + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") From 51946c55288dce1123077949bc00519abcb19045 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 11 Apr 2023 12:46:59 +0530 Subject: [PATCH 55/79] chore: `linter` --- erpnext/stock/doctype/delivery_note/delivery_note.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 3c6286e197..b22256066c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -355,7 +355,7 @@ class DeliveryNote(SellingController): if item.warehouse != sre_data[0]: frappe.throw( _("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]), - title="Stock Reservation Warehouse Mismatch", + title=_("Stock Reservation Warehouse Mismatch"), ) else: from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses @@ -366,7 +366,7 @@ class DeliveryNote(SellingController): _( "Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse" ).format(item.idx, sre_data[0]), - title="Stock Reservation Group Warehouse", + title=_("Stock Reservation Group Warehouse"), ) def check_credit_limit(self): From e7491d117dc7f02065c78a19f75c11a1a0613eb0 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 11 Apr 2023 17:25:13 +0530 Subject: [PATCH 56/79] test: add test case for Stock Reservation against SO --- .../doctype/sales_order/test_sales_order.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 03b5c4d9f3..bee1e6374c 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1881,7 +1881,6 @@ class TestSalesOrder(FrappeTestCase): def test_stock_reservation_against_sales_order(self) -> None: from random import randint, uniform - from erpnext.controllers.status_updater import OverAllowanceError from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( cancel_stock_reservation_entries, get_sre_reserved_qty_details_for_voucher, @@ -1994,23 +1993,27 @@ class TestSalesOrder(FrappeTestCase): self.assertGreater(sre_details[0].delivered_qty, 0) self.assertEqual(sre_details[0].status, "Partially Delivered") - # Test - 9: Over Delivery against Sales Order, should throw `OverAllowanceError` if Over Allowance is not set. - update_stock_settings("over_delivery_receipt_allowance", 0) + # Test - 9: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty. + update_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() - self.assertRaises(OverAllowanceError, dn2.submit) - - # Test - 10: Over Delivery against Sales Order when Over Allowance is set. - update_stock_settings("over_delivery_receipt_allowance", 100) dn2.submit() update_stock_settings("over_delivery_receipt_allowance", 0) - # Test - 11: SRE Delivered Qty should be equal to the SRE Reserved Qty. - so.load_from_db() + for item in so.items: + sre_details = frappe.db.get_all( + "Stock Reservation Entry", + filters={"voucher_type": "Sales Order", "voucher_no": so.name, "voucher_detail_no": item.name}, + fields=["status", "reserved_qty", "delivered_qty"], + ) + + for sre_detail in sre_details: + self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty) + self.assertEqual(sre_detail.status, "Delivered") def automatically_fetch_payment_terms(enable=1): From 56097807b4749e3d96d4b3f1a97dc81a82a01033 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 13:58:11 +0530 Subject: [PATCH 57/79] fix: `Stock Reservation` validation in `Stock Settings` --- erpnext/stock/doctype/stock_settings/stock_settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index f041e796d6..6e1d02c128 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -108,7 +108,9 @@ class StockSettings(Document): "Stock Settings", "enable_stock_reservation" ) - if db_enable_stock_reservation and frappe.db.count("Stock Reservation Entry"): + if db_enable_stock_reservation and frappe.db.exists( + "Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]} + ): frappe.throw( _("As there are existing {0}, you can not change the value of {1}.").format( frappe.bold("Stock Reservation Entries"), frappe.bold("Enable Stock Reservation") From f0acb2049b6c85f1a9d36021702d155a21daba8c Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 14:13:54 +0530 Subject: [PATCH 58/79] fix: don't allow to deliver/transfer reserved stock --- .../stock_reservation_entry.py | 34 ++++++++++++++----- erpnext/stock/stock_ledger.py | 14 ++++++-- 2 files changed, 36 insertions(+), 12 deletions(-) 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 f770059131..f55e6405b9 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -179,18 +179,13 @@ def get_stock_reservation_entries_for_voucher( def get_sre_reserved_qty_details_for_item_and_warehouse( - item_code: str | list, warehouse: str | list + item_code_list: list, warehouse_list: list ) -> dict: """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" sre_details = {} - if item_code and warehouse: - if isinstance(item_code, str): - item_code = [item_code] - if isinstance(warehouse, str): - warehouse = [warehouse] - + if item_code_list and warehouse_list: sre = frappe.qb.DocType("Stock Reservation Entry") sre_data = ( frappe.qb.from_(sre) @@ -201,8 +196,8 @@ def get_sre_reserved_qty_details_for_item_and_warehouse( ) .where( (sre.docstatus == 1) - & (sre.item_code.isin(item_code)) - & (sre.warehouse.isin(warehouse)) + & (sre.item_code.isin(item_code_list)) + & (sre.warehouse.isin(warehouse_list)) & (sre.status.notin(["Delivered", "Cancelled"])) ) .groupby(sre.item_code, sre.warehouse) @@ -214,6 +209,27 @@ def get_sre_reserved_qty_details_for_item_and_warehouse( return sre_details +def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float: + """Returns `Reserved Qty` for Item and Warehouse combination.""" + + reserved_qty = 0.0 + + if item_code and warehouse: + sre = frappe.qb.DocType("Stock Reservation Entry") + return ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty)) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + ).run(as_list=True)[0][0] or 0.0 + + return reserved_qty + + def get_sre_reserved_qty_details_for_voucher( voucher_type: str, voucher_no: str, voucher_detail_no: str = None ) -> dict: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c954befdc2..33e7a039d8 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) @@ -610,7 +614,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: @@ -1006,6 +1010,7 @@ class update_entries_after(object): msg_list = [] for warehouse, exceptions in self.exceptions.items(): deficiency = min(e["diff"] for e in exceptions) + msg_prefix = _("As {} units are reserved, ").format(frappe.bold(self.reserved_stock)) if ( exceptions[0]["voucher_type"], @@ -1013,7 +1018,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), ) @@ -1021,7 +1026,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"], @@ -1030,6 +1035,9 @@ class update_entries_after(object): ) if msg: + if self.reserved_stock: + msg = msg_prefix + msg + msg_list.append(msg) if msg_list: From e65b6d47e43d21129461ef192fa1becbc1c2808b Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 15:12:56 +0530 Subject: [PATCH 59/79] fix: disable `Stock Reservation` by default --- erpnext/stock/doctype/stock_settings/stock_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 9ce5e9f599..7897333bd8 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -351,7 +351,7 @@ "label": "Stock Reservation" }, { - "default": "1", + "default": "0", "description": "Allows to create Stock Reservations against Sales Order", "fieldname": "enable_stock_reservation", "fieldtype": "Check", @@ -383,7 +383,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-04-04 22:46:42.287425", + "modified": "2023-04-12 15:11:35.744375", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From 6c9e419fecbbc4a724eccc9ca89c579e354d8dea Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 15:50:42 +0530 Subject: [PATCH 60/79] fix: validation for `Non-Stock` item in Sales Order Reservation --- erpnext/selling/doctype/sales_order/sales_order.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 13310f965b..d6afd10ee1 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -538,9 +538,22 @@ class SalesOrder(SellingController): sre_count = 0 reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) for item in self.get("items"): + # Skip if `Reserved Stock` is not checked for the item. if not item.get("reserve_stock"): continue + # Skip if Non-Stock Item. + if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + item.db_set("reserve_stock", 0) + continue + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) # Stock is already reserved for the item, notify the user and skip the item. From 866f98ac159ad90108ff44286a94a4a245eeb262 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 16:46:37 +0530 Subject: [PATCH 61/79] test: Stock Reservation for Serial and Batch Items --- .../test_stock_reservation_entry.py | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) 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 f64da92a85..8dfd9d903e 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 @@ -184,14 +184,38 @@ def create_items() -> dict: "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}, - "SR Batch Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, - # Serial Items - "SR Serial Item 1": {"is_stock_item": 1, "valuation_rate": 100}, - "SR Serial Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, - # Batch and Serial Items - "SR Batch and Serial Item 1": {"is_stock_item": 1, "valuation_rate": 100}, - "SR Batch and Serial Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, + "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 = {} From 73f16752a6531d81687acf518025058498316275 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 12 Apr 2023 18:58:04 +0530 Subject: [PATCH 62/79] chore: `conflicts` --- erpnext/stock/stock_ledger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 33e7a039d8..463706e388 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1010,7 +1010,7 @@ class update_entries_after(object): msg_list = [] for warehouse, exceptions in self.exceptions.items(): deficiency = min(e["diff"] for e in exceptions) - msg_prefix = _("As {} units are reserved, ").format(frappe.bold(self.reserved_stock)) + msg_prefix = _("As {} units are reserved").format(frappe.bold(self.reserved_stock)) if ( exceptions[0]["voucher_type"], @@ -1036,7 +1036,7 @@ class update_entries_after(object): if msg: if self.reserved_stock: - msg = msg_prefix + msg + msg = "{0}, {1}".format(msg_prefix, msg) msg_list.append(msg) From a87bb78d72d77d82054f1070d36c67c3bf21f8d1 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 13 Apr 2023 19:48:48 +0530 Subject: [PATCH 63/79] fix: don't allow to enable `Stock Reservation` and `Negative Stock` simultaneously --- .../doctype/stock_settings/stock_settings.py | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 6e1d02c128..d35711868f 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -55,7 +55,7 @@ class StockSettings(Document): self.cant_change_valuation_method() self.validate_clean_description_html() self.validate_pending_reposts() - self.cant_disable_stock_reservation() + self.validate_stock_reservation() def validate_warehouses(self): warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] @@ -100,23 +100,64 @@ class StockSettings(Document): if self.stock_frozen_upto: check_pending_reposting(self.stock_frozen_upto) - def cant_disable_stock_reservation(self): - """Raises an exception if user tries to disable Stock Reservation and there are existing Stock Reservation Entries.""" + 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`.""" - if not self.enable_stock_reservation: - db_enable_stock_reservation = frappe.db.get_single_value( - "Stock Settings", "enable_stock_reservation" - ) + 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" + ) - if db_enable_stock_reservation and frappe.db.exists( - "Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]} - ): + # 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 there are existing {0}, you can not change the value of {1}.").format( - frappe.bold("Stock Reservation Entries"), frappe.bold("Enable Stock Reservation") + _("As {0} is enabled, you can not enable {1}.").format( + frappe.bold("Stock Reservation"), frappe.bold("Allow Negative Stock") ) ) + # Change in value of `Enable Stock Reservation` + if db_enable_stock_reservation != self.enable_stock_reservation: + + # Disable -> Enable + if self.enable_stock_reservation: + + # Don't allow if `Allow Negative Stock` is enabled + if self.allow_negative_stock: + frappe.throw( + _("As {0} is enabled, you can not enable {1}.").format( + frappe.bold("Allow Negative Stock"), frappe.bold("Stock Reservation") + ) + ) + + else: + # Don't allow if there are negative stock + has_negative_stock = frappe.db.exists("Bin", {"actual_qty": ["<", 0]}) + + if has_negative_stock: + frappe.throw( + _("As there are negative stock, you can not enable {0}.").format( + frappe.bold("Stock Reservation") + ) + ) + + # Enable -> Disable + else: + # Don't allow if there are open Stock Reservation Entries + has_reserved_stock = frappe.db.exists( + "Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]} + ) + + if has_reserved_stock: + frappe.throw( + _("As there are reserved stock, you cannot disable {0}.").format( + frappe.bold("Stock Reservation") + ) + ) + def on_update(self): self.toggle_warehouse_field_for_inter_warehouse_transfer() From dc9bb772cba1c6d8299793c251ea5b989cf89d1d Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 14 Apr 2023 14:34:24 +0530 Subject: [PATCH 64/79] refactor(test): use `change_settings` instead of `update_stock_settings` --- .../doctype/sales_order/test_sales_order.py | 170 +++++++++--------- .../test_stock_reservation_entry.py | 39 ++-- .../doctype/stock_settings/stock_settings.py | 4 + 3 files changed, 103 insertions(+), 110 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index bee1e6374c..51b791f59c 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1878,6 +1878,7 @@ 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 @@ -1890,7 +1891,6 @@ class TestSalesOrder(FrappeTestCase): from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import ( create_items, create_material_receipts, - update_stock_settings, ) items_details, warehouse = create_items(), "_Test Warehouse - _TC" @@ -1914,106 +1914,102 @@ class TestSalesOrder(FrappeTestCase): warehouse="_Test Warehouse - _TC", ) - # Test - 1: `ValidationError` should be thrown, if Stock Reservation is disabled in Stock Settings. - update_stock_settings("enable_stock_reservation", 0) - self.assertRaises(frappe.ValidationError, so.create_stock_reservation_entries) + # 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)) - # Enable Stock Reservation. - update_stock_settings("enable_stock_reservation", 1) + # 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)) - # Test - 2: 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. - update_stock_settings("allow_partial_reservation", 0) - so.create_stock_reservation_entries() - self.assertFalse(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 Partially Reserved if the Partial Reservation is enabled in Stock Settings. - update_stock_settings("allow_partial_reservation", 1) - so.create_stock_reservation_entries() - so.load_from_db() - self.assertTrue(has_reserved_stock("Sales Order", so.name)) + # 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() - for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( - "Sales Order", so.name, item.name, fields=["reserved_qty", "status"] + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) + for item in so.items: + reserved_qty = reserved_qty_details[(item.name, item.warehouse)] + self.assertEqual(item.stock_reserved_qty, reserved_qty) + self.assertEqual(item.stock_qty, item.stock_reserved_qty) + + # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries. + cancel_stock_reservation_entries("Sales Order", so.name) + so.load_from_db() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + for item in so.items: + self.assertEqual(item.stock_reserved_qty, 0) + + # Test - 5: Re-reserve the stock. + so.create_stock_reservation_entries() + self.assertTrue(has_reserved_stock("Sales Order", so.name)) + + # Test - 6: Stock should get unreserved on cancellation of Sales Order. + so.cancel() + so.load_from_db() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + for item in so.items: + self.assertEqual(item.stock_reserved_qty, 0) + + # Create Sales Order and Reserve Stock. + so = make_sales_order( + item_list=item_list, + warehouse="_Test Warehouse - _TC", ) - self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty) - self.assertEqual(sre_details[0].status, "Partially Reserved") + so.create_stock_reservation_entries() - # Test - 4: 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() + # Test - 7: Partial Delivery against Sales Order. + dn1 = make_delivery_note(so.name) - reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) - for item in so.items: - reserved_qty = reserved_qty_details[(item.name, item.warehouse)] - self.assertEqual(item.stock_reserved_qty, reserved_qty) - self.assertEqual(item.stock_qty, item.stock_reserved_qty) + for item in dn1.items: + item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3) - # Test - 5: 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)) + dn1.save() + dn1.submit() - for item in so.items: - self.assertEqual(item.stock_reserved_qty, 0) + 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 - 6: Re-reserve the stock. - so.create_stock_reservation_entries() - self.assertTrue(has_reserved_stock("Sales Order", so.name)) + # 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) - # Test - 7: 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 dn2.items: + item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3) - for item in so.items: - self.assertEqual(item.stock_reserved_qty, 0) + dn2.save() + dn2.submit() - # Create Sales Order and Reserve Stock. - so = make_sales_order( - item_list=item_list, - warehouse="_Test Warehouse - _TC", - ) - so.create_stock_reservation_entries() + 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"], + ) - # Test - 8: 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 - 9: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty. - update_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() - update_stock_settings("over_delivery_receipt_allowance", 0) - - 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") + for sre_detail in sre_details: + self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty) + self.assertEqual(sre_detail.status, "Delivered") def automatically_fetch_payment_terms(enable=1): 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 8dfd9d903e..4d922befb4 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 @@ -2,7 +2,7 @@ # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings class TestStockReservationEntry(FrappeTestCase): @@ -25,18 +25,17 @@ class TestStockReservationEntry(FrappeTestCase): ) # Case - 1: When `Stock Reservation` is disabled in `Stock Settings`, throw `ValidationError` - update_stock_settings("enable_stock_reservation", 0) - self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher) + with change_settings("Stock Settings", {"enable_stock_reservation": 0}): + self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher) - # Case - 2: When `Voucher Type` is not allowed for `Stock Reservation`, throw `ValidationError` - update_stock_settings("enable_stock_reservation", 1) - voucher.doctype = "NOT ALLOWED" - 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 `Stock Reservation` is enabled and `Voucher Type` is allowed - update_stock_settings("enable_stock_reservation", 1) - voucher.doctype = "Sales Order" - self.assertIsNone(validate_stock_reservation_settings(voucher), None) + # 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 ( @@ -106,15 +105,13 @@ class TestStockReservationEntry(FrappeTestCase): 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: from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" - # Step - 1: Enable `Stock Reservation` - update_stock_settings("enable_stock_reservation", 1) - - # Step - 2: Create a `Sales Order` + # Step - 1: Create a `Sales Order` so = make_sales_order( item_code=item_code, warehouse=warehouse, @@ -127,7 +124,7 @@ class TestStockReservationEntry(FrappeTestCase): so.save() so.submit() - # Step - 3: Create a `Stock Reservation Entry[1]` for the `Sales Order Item` + # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item` sre1 = make_stock_reservation_entry( item_code=item_code, warehouse=warehouse, @@ -142,7 +139,7 @@ class TestStockReservationEntry(FrappeTestCase): self.assertEqual(sre1.status, "Partially Reserved") self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty) - # Step - 4: Create a `Stock Reservation Entry[2]` for the `Sales Order Item` + # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item` sre2 = make_stock_reservation_entry( item_code=item_code, warehouse=warehouse, @@ -157,14 +154,14 @@ class TestStockReservationEntry(FrappeTestCase): self.assertEqual(sre1.status, "Partially Reserved") self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty + sre2.reserved_qty) - # Step - 5: Cancel `Stock Reservation Entry[1]` + # 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 - 6: Cancel `Stock Reservation Entry[2]` + # Step - 5: Cancel `Stock Reservation Entry[2]` sre2.cancel() so.load_from_db() sre2.load_from_db() @@ -172,10 +169,6 @@ class TestStockReservationEntry(FrappeTestCase): self.assertEqual(so.items[0].stock_reserved_qty, 0) -def update_stock_settings(field: str, value: any) -> None: - frappe.db.set_single_value("Stock Settings", field, value) - - def create_items() -> dict: from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index d35711868f..c9b75a1d97 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -103,6 +103,10 @@ class StockSettings(Document): 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" From 2ed7d8a1fbdedbaa99e0c6d4bd91f28b23610982 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 14 Apr 2023 15:22:41 +0530 Subject: [PATCH 65/79] fix: don't allow `Stock Reconciliation` for items having reserved stock --- .../stock_reconciliation.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 482b103d1e..484ec71d90 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""" From a527221709054027e51b0c462da4c45c78706c63 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 17 Apr 2023 15:28:21 +0530 Subject: [PATCH 66/79] test: add test case for consumption of reserved stock --- .../test_stock_reservation_entry.py | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) 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 4d922befb4..5a082ddfe6 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 @@ -4,6 +4,10 @@ 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: @@ -41,7 +45,6 @@ class TestStockReservationEntry(FrappeTestCase): from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( get_available_qty_to_reserve, ) - from erpnext.stock.utils import get_stock_balance item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" @@ -107,8 +110,6 @@ class TestStockReservationEntry(FrappeTestCase): @change_settings("Stock Settings", {"enable_stock_reservation": 1}) def test_update_reserved_qty_in_voucher(self) -> None: - from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order - item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" # Step - 1: Create a `Sales Order` @@ -168,6 +169,56 @@ class TestStockReservationEntry(FrappeTestCase): 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 @@ -221,8 +272,6 @@ def create_items() -> dict: def create_material_receipts( items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100 ) -> None: - from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry - for item in items.values(): if item.is_stock_item: make_stock_entry( From 7e8fd8f324ab2e446f98418dc12a328e668f8515 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 21 Apr 2023 17:44:44 +0530 Subject: [PATCH 67/79] chore: update reserved stock SLE validation --- erpnext/stock/stock_ledger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 463706e388..5d14c21e02 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1010,7 +1010,6 @@ class update_entries_after(object): msg_list = [] for warehouse, exceptions in self.exceptions.items(): deficiency = min(e["diff"] for e in exceptions) - msg_prefix = _("As {} units are reserved").format(frappe.bold(self.reserved_stock)) if ( exceptions[0]["voucher_type"], @@ -1036,7 +1035,10 @@ class update_entries_after(object): if msg: if self.reserved_stock: - msg = "{0}, {1}".format(msg_prefix, msg) + 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) From b70273b988a4007065df94e690cdaf3b6954736c Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 22 Apr 2023 08:49:31 +0530 Subject: [PATCH 68/79] chore: remove `Enable Stock Reservation` field description --- erpnext/stock/doctype/stock_settings/stock_settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 7897333bd8..35970b154b 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -352,7 +352,6 @@ }, { "default": "0", - "description": "Allows to create Stock Reservations against Sales Order", "fieldname": "enable_stock_reservation", "fieldtype": "Check", "label": "Enable Stock Reservation" @@ -383,7 +382,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-04-12 15:11:35.744375", + "modified": "2023-04-22 08:48:37.767646", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From cdb31816918c8d07344f9af4b7cdf43d0f2b959e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 22 Apr 2023 09:49:47 +0530 Subject: [PATCH 69/79] fix: Reserve and Unreserve buttons visibility in SO --- .../doctype/sales_order/sales_order.js | 22 +++++++++---------- .../doctype/sales_order/sales_order.py | 18 +++++++-------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 37c229417f..417e93b4b0 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -66,18 +66,16 @@ frappe.ui.form.on("Sales Order", { } 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); + if (frm.doc.__onload && frm.doc.__onload.enable_stock_reservation) { + if (frm.doc.__onload.reserve_stock_on_so_submission) { + // If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0. + frm.set_value("reserve_stock", value ? 1 : 0); } - }) + } else { + // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and read only. + frm.set_value("reserve_stock", 0); + frm.set_df_property("reserve_stock", "read_only", 1); + } } } }, @@ -289,7 +287,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. - if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) { + if (this.frm.doc.__onload && this.frm.doc.__onload.enable_stock_reservation && this.frm.doc.__onload.has_unreserved_stock) { this.frm.add_custom_button(__('Reserve'), () => this.create_stock_reservation_entries(), __('Stock Reservation')); } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index d6afd10ee1..6abb8edd87 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -33,6 +33,7 @@ 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 @@ -49,16 +50,13 @@ class SalesOrder(SellingController): super(SalesOrder, self).__init__(*args, **kwargs) def onload(self) -> None: - if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - has_reserved_stock, - ) - - if has_reserved_stock(self.doctype, self.name): - self.set_onload("has_reserved_stock", True) - - if self.has_unreserved_stock(): - self.set_onload("has_unreserved_stock", True) + stock_settings = frappe.get_doc("Stock Settings") + self.set_onload("enable_stock_reservation", stock_settings.enable_stock_reservation) + self.set_onload( + "reserve_stock_on_so_submission", stock_settings.reserve_stock_on_sales_order_submission + ) + self.set_onload("has_reserved_stock", has_reserved_stock(self.doctype, self.name)) + self.set_onload("has_unreserved_stock", self.has_unreserved_stock()) def validate(self): super(SalesOrder, self).validate() From 28d0629df12e910cad20e745fbc85d5f5d3cd899 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 22 Apr 2023 10:01:00 +0530 Subject: [PATCH 70/79] chore: add `depends_on` condition for `Reserve Stock` field in SO --- erpnext/selling/doctype/sales_order/sales_order.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 47bb37c91f..289c9de0fe 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1641,6 +1641,7 @@ }, { "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", @@ -1654,7 +1655,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-04-04 10:39:34.129343", + "modified": "2023-04-22 09:55:37.008190", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", From 0b5f03e88cfa2cdfef5f4a3541c0e8a5693fdab7 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 23 Apr 2023 15:31:53 +0530 Subject: [PATCH 71/79] Revert "fix: Reserve and Unreserve buttons visibility in SO" This reverts commit cdb31816918c8d07344f9af4b7cdf43d0f2b959e. --- .../doctype/sales_order/sales_order.js | 22 ++++++++++--------- .../doctype/sales_order/sales_order.py | 13 +++++------ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 417e93b4b0..37c229417f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -66,16 +66,18 @@ frappe.ui.form.on("Sales Order", { } if (frm.is_new()) { - if (frm.doc.__onload && frm.doc.__onload.enable_stock_reservation) { - if (frm.doc.__onload.reserve_stock_on_so_submission) { - // If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0. - frm.set_value("reserve_stock", value ? 1 : 0); + 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); } - } 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); - } + }) } } }, @@ -287,7 +289,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. - if (this.frm.doc.__onload && this.frm.doc.__onload.enable_stock_reservation && this.frm.doc.__onload.has_unreserved_stock) { + if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) { this.frm.add_custom_button(__('Reserve'), () => this.create_stock_reservation_entries(), __('Stock Reservation')); } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 6abb8edd87..e9b5c0f966 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -50,13 +50,12 @@ class SalesOrder(SellingController): super(SalesOrder, self).__init__(*args, **kwargs) def onload(self) -> None: - stock_settings = frappe.get_doc("Stock Settings") - self.set_onload("enable_stock_reservation", stock_settings.enable_stock_reservation) - self.set_onload( - "reserve_stock_on_so_submission", stock_settings.reserve_stock_on_sales_order_submission - ) - self.set_onload("has_reserved_stock", has_reserved_stock(self.doctype, self.name)) - self.set_onload("has_unreserved_stock", self.has_unreserved_stock()) + 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() From e517c06847c233d3b544c41b60123e7a6b763001 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sun, 23 Apr 2023 15:45:58 +0530 Subject: [PATCH 72/79] chore: add warehouse info in SRE msg --- erpnext/selling/doctype/sales_order/sales_order.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index e9b5c0f966..131a091cbc 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -556,8 +556,8 @@ class SalesOrder(SellingController): # 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) + _("Row #{0}: Stock is already reserved for the Item {1} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) ), title=_("Stock Reservation"), ) @@ -568,8 +568,8 @@ class SalesOrder(SellingController): # No stock available to reserve, notify the user and skip the item. if available_qty_to_reserve <= 0: frappe.msgprint( - _("Row #{0}: No available stock to reserve for the Item {1}").format( - item.idx, frappe.bold(item.item_code) + _("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", From 388f85b109131c88104c6013b43bf8fa7a57bafb Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 24 Apr 2023 12:04:22 +0530 Subject: [PATCH 73/79] refactor: `Stock Reservation` code in `sales_order.js` --- .../doctype/sales_order/sales_order.js | 106 +++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 37c229417f..acde31efae 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -47,17 +47,29 @@ 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) { @@ -154,6 +166,38 @@ 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) { + frappe.call({ + doc: frm.doc, + method: 'create_stock_reservation_entries', + args: { + notify: true + }, + freeze: true, + freeze_message: __('Reserving Stock...'), + callback: (r) => { + frm.doc.__onload.has_unreserved_stock = false; + frm.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.refresh(); + } + }) } }); @@ -287,16 +331,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } this.frm.page.set_inner_btn_group_as_primary(__('Create')); } - - // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. - if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) { - this.frm.add_custom_button(__('Reserve'), () => this.create_stock_reservation_entries(), __('Stock Reservation')); - } - - // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock. - if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) { - this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation')); - } } if (this.frm.doc.docstatus===0) { @@ -336,38 +370,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } - create_stock_reservation_entries() { - frappe.call({ - doc: this.frm.doc, - method: 'create_stock_reservation_entries', - args: { - notify: true - }, - freeze: true, - freeze_message: __('Reserving Stock...'), - callback: (r) => { - this.frm.doc.__onload.has_unreserved_stock = false; - this.frm.refresh(); - } - }) - } - - cancel_stock_reservation_entries() { - frappe.call({ - method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries', - args: { - voucher_type: this.frm.doctype, - voucher_no: this.frm.docname - }, - freeze: true, - freeze_message: __('Unreserving Stock...'), - callback: (r) => { - this.frm.doc.__onload.has_reserved_stock = false; - this.frm.refresh(); - } - }) - } - create_pick_list() { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", From bf4a57a37c7211049d191b44e1cf0da97786ce28 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 24 Apr 2023 19:27:08 +0530 Subject: [PATCH 74/79] fix: miscellaneous fix: don't reserve stock in group warehouse fix: partial reservation in multiple warehouses feat: add prompt to select warehouse and qty for reservation in SO --- .../doctype/sales_order/sales_order.js | 89 ++++++++++++++++--- .../doctype/sales_order/sales_order.py | 63 ++++++++++--- .../doctype/sales_order/test_sales_order.py | 2 +- .../doctype/delivery_note/delivery_note.py | 33 +++---- .../stock_reservation_entry.py | 47 +++++----- 5 files changed, 156 insertions(+), 78 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index acde31efae..6203a560d1 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -169,19 +169,82 @@ frappe.ui.form.on("Sales Order", { }, create_stock_reservation_entries(frm) { - frappe.call({ - doc: frm.doc, - method: 'create_stock_reservation_entries', - args: { - notify: true - }, - freeze: true, - freeze_message: __('Reserving Stock...'), - callback: (r) => { - frm.doc.__onload.has_unreserved_stock = false; - frm.refresh(); + 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 + }, + { + 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) { @@ -195,7 +258,7 @@ frappe.ui.form.on("Sales Order", { freeze_message: __('Unreserving Stock...'), callback: (r) => { frm.doc.__onload.has_reserved_stock = false; - frm.refresh(); + frm.reload_doc(); } }) } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 131a091cbc..5a8810b028 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -518,7 +518,7 @@ class SalesOrder(SellingController): return False @frappe.whitelist() - def create_stock_reservation_entries(self, notify=True): + 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 ( @@ -532,9 +532,18 @@ class SalesOrder(SellingController): "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 self.get("items"): + 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 @@ -551,15 +560,27 @@ class SalesOrder(SellingController): 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} in Warehouse {2}.").format( - item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) + _("Row #{0}: Stock is already reserved for the Item {1}.").format( + item.idx, frappe.bold(item.item_code) ), title=_("Stock Reservation"), + indicator="yellow", ) continue @@ -579,17 +600,31 @@ class SalesOrder(SellingController): # 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: - 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", - ) + 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: @@ -620,7 +655,7 @@ class SalesOrder(SellingController): def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: """Returns the unreserved quantity for the Sales Order Item.""" - existing_reserved_qty = reserved_qty_details.get((item.name, item.warehouse), 0) + existing_reserved_qty = reserved_qty_details.get(item.name, 0) return ( item.stock_qty - flt(item.delivered_qty) * item.get("conversion_factor", 1) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 51b791f59c..aa0d5e8329 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1939,7 +1939,7 @@ class TestSalesOrder(FrappeTestCase): reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) for item in so.items: - reserved_qty = reserved_qty_details[(item.name, item.warehouse)] + reserved_qty = reserved_qty_details[item.name] self.assertEqual(item.stock_reserved_qty, reserved_qty) self.assertEqual(item.stock_qty, item.stock_reserved_qty) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index b22256066c..1a728e1204 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -343,31 +343,18 @@ class DeliveryNote(SellingController): if not sre_data: continue - is_group_warehouse = frappe.get_cached_value("Warehouse", sre_data[0], "is_group") - + # Set `Warehouse` from SRE if not set. if not item.warehouse: - if not is_group_warehouse: - item.warehouse = sre_data[0] - else: - frappe.throw(_("Row #{0}: Warehouse is mandatory").format(item.idx, item.item_code)) + item.warehouse = sre_data[0] else: - if not is_group_warehouse: - if item.warehouse != sre_data[0]: - frappe.throw( - _("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]), - title=_("Stock Reservation Warehouse Mismatch"), - ) - else: - from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses - - warehouses = get_child_warehouses(sre_data[0]) - if item.warehouse not in warehouses: - frappe.throw( - _( - "Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse" - ).format(item.idx, sre_data[0]), - title=_("Stock Reservation Group Warehouse"), - ) + # 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_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index f55e6405b9..5819dd7342 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -12,6 +12,7 @@ class StockReservationEntry(Document): 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) @@ -42,6 +43,15 @@ class StockReservationEntry(Document): 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.""" @@ -113,16 +123,11 @@ def validate_stock_reservation_settings(voucher: object) -> None: def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination.""" - from erpnext.stock.get_item_details import get_bin_details + from erpnext.stock.utils import get_stock_balance - available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get( - "actual_qty" - ) + available_qty = get_stock_balance(item_code, warehouse) if available_qty: - from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses - - warehouses = get_child_warehouses(warehouse) sre = frappe.qb.DocType("Stock Reservation Entry") reserved_qty = ( frappe.qb.from_(sre) @@ -130,7 +135,7 @@ def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: .where( (sre.docstatus == 1) & (sre.item_code == item_code) - & (sre.warehouse.isin(warehouses)) + & (sre.warehouse == warehouse) & (sre.status.notin(["Delivered", "Cancelled"])) ) ).run()[0][0] or 0.0 @@ -230,19 +235,14 @@ def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) return reserved_qty -def get_sre_reserved_qty_details_for_voucher( - voucher_type: str, voucher_no: str, voucher_detail_no: str = None -) -> dict: - """Returns a dict like {("voucher_detail_no", "warehouse"): "reserved_qty", ... }.""" - - reserved_qty_details = {} +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") - query = ( + data = ( frappe.qb.from_(sre) .select( sre.voucher_detail_no, - sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"), ) .where( @@ -251,18 +251,10 @@ def get_sre_reserved_qty_details_for_voucher( & (sre.voucher_no == voucher_no) & (sre.status.notin(["Delivered", "Cancelled"])) ) - .groupby(sre.voucher_detail_no, sre.warehouse) - ) + .groupby(sre.voucher_detail_no) + ).run(as_list=True) - if voucher_detail_no: - query = query.where(sre.voucher_detail_no == voucher_detail_no) - - data = query.run(as_dict=True) - - for d in data: - reserved_qty_details[(d["voucher_detail_no"], d["warehouse"])] = d["reserved_qty"] - - return reserved_qty_details + return frappe._dict(data) def get_sre_reserved_qty_details_for_voucher_detail_no( @@ -281,6 +273,7 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( & (sre.voucher_detail_no == voucher_detail_no) & (sre.status.notin(["Delivered", "Cancelled"])) ) + .orderby(sre.creation) .groupby(sre.warehouse) ).run(as_list=True) From fefd788eb5e3331e3fb5b01aaf15fe536d968962 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Thu, 25 May 2023 11:49:47 +0530 Subject: [PATCH 75/79] fix(ux): add non-group warehouse filter --- erpnext/selling/doctype/sales_order/sales_order.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 602bc8cd5a..5d43a07d96 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -195,7 +195,14 @@ frappe.ui.form.on("Sales Order", { label: __('Warehouse'), options: 'Warehouse', reqd: 1, - in_list_view: 1 + in_list_view: 1, + get_query: function () { + return { + filters: [ + ["Warehouse", "is_group", "!=", 1] + ] + }; + }, }, { fieldtype: 'Float', From 82f0bd9ee95091e2825f434b433eadc8165d7fac Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Thu, 25 May 2023 15:57:06 +0530 Subject: [PATCH 76/79] feat: add `Reserved Stock` column in `Stock Balance` Report --- .../report/stock_balance/stock_balance.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 68df918e83..8eb1c74537 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", From 9e5e2de5d50c7b8eebbb73ac348d4990d9eab767 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 25 May 2023 23:41:56 +0530 Subject: [PATCH 77/79] fix: incorrect available quantity in BIN --- erpnext/stock/stock_ledger.py | 15 +++++++++++++-- erpnext/stock/utils.py | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 6106809273..9f81a8cd3f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -443,12 +443,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() @@ -1065,6 +1064,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) + frappe.db.set_value( + "Bin", + bin_name, + { + "actual_qty": sle.qty_after_transaction, + "valuation_rate": sle.valuation_rate, + "stock_value": sle.stock_value, + }, + ) + 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) From 761f8a9f05b6d25cdfea97abc3b8129d013ca9e4 Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Fri, 26 May 2023 04:15:27 +0530 Subject: [PATCH 78/79] fix: use `float_precision` while checking for negative stock in BIN --- erpnext/stock/doctype/stock_settings/stock_settings.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index c9b75a1d97..e25c8439ca 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -139,9 +139,15 @@ class StockSettings(Document): else: # Don't allow if there are negative stock - has_negative_stock = frappe.db.exists("Bin", {"actual_qty": ["<", 0]}) + from frappe.query_builder.functions import Round - if has_negative_stock: + 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") From 718ad3f24048b5acb32655a31a4965866cb429b0 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 26 May 2023 11:29:22 +0530 Subject: [PATCH 79/79] fix: travis --- erpnext/stock/stock_ledger.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9f81a8cd3f..2f64eddb30 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1066,15 +1066,15 @@ class update_entries_after(object): def update_bin_data(self, sle): bin_name = get_or_make_bin(sle.item_code, sle.warehouse) - frappe.db.set_value( - "Bin", - bin_name, - { - "actual_qty": sle.qty_after_transaction, - "valuation_rate": sle.valuation_rate, - "stock_value": sle.stock_value, - }, - ) + 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