From bf4a57a37c7211049d191b44e1cf0da97786ce28 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 24 Apr 2023 19:27:08 +0530 Subject: [PATCH] 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 acde31efae3..6203a560d1d 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 131a091cbcc..5a8810b0283 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 51b791f59cf..aa0d5e83296 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 b22256066c6..1a728e1204b 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 f55e6405b90..5819dd73423 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)