diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 5a062e19daf..01d9231ccb5 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -901,8 +901,31 @@ class SellingController(StockController): so_field = "sales_order" if self.doctype == "Sales Invoice" else "against_sales_order" + items = [] + for item in self.items: + packed_items = [ + packed_item + for packed_item in self.packed_items + if packed_item.parent_detail_docname == item.name + ] + if not packed_items: + items.append(item) + else: + for d in packed_items: + d.set(so_field, item.get(so_field)) + d.so_detail = frappe.get_value( + "Packed Item", + { + "parent_detail_docname": item.so_detail, + "parent_item": item.item_code, + "item_code": d.item_code, + "warehouse": d.warehouse, + }, + ) + items.append(d) + if self._action == "submit": - for item in self.get("items"): + for item in items: # Skip if `Sales Order` or `Sales Order Item` reference is not set. if not item.get(so_field) or not item.so_detail: continue @@ -927,7 +950,7 @@ class SellingController(StockController): if not sre_list: continue - qty_to_deliver = item.stock_qty + qty_to_deliver = item.get("stock_qty") or item.qty for sre in sre_list: if qty_to_deliver <= 0: break @@ -974,7 +997,7 @@ class SellingController(StockController): qty_to_deliver -= qty_can_be_deliver if self._action == "cancel": - for item in self.get("items"): + for item in items: # Skip if `Sales Order` or `Sales Order Item` reference is not set. if not item.get(so_field) or not item.so_detail: continue @@ -996,7 +1019,7 @@ class SellingController(StockController): if not sre_list: continue - qty_to_undelivered = item.stock_qty + qty_to_undelivered = item.get("stock_qty") or item.qty for sre in sre_list: if qty_to_undelivered <= 0: break diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 9fe075472ab..5c345666df0 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -2252,7 +2252,7 @@ def make_stock_reservation_entries( if table_name and table_name != child_table_name: continue - sre = StockReservation(doc, items=items, kwargs=mapper[child_table_name], notify=notify) + sre = StockReservation(doc, items=items, kwargs=mapper[child_table_name]) if doc.docstatus == 1: sre_created = sre.make_stock_reservation_entries() if sre_created: diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 61f7a7d87c2..70d9863b876 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2126,7 +2126,7 @@ def make_stock_reservation_entries( if items and isinstance(items, str): items = parse_json(items) - sre = StockReservation(doc, items=items, notify=notify) + sre = StockReservation(doc, items=items) if doc.docstatus == 2 or doc.status == "Closed": sre.cancel_stock_reservation_entries() elif doc.docstatus == 1: diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 98d730f9b0a..23b7165c17a 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -123,22 +123,12 @@ frappe.ui.form.on("Sales Order", { () => frm.events.cancel_stock_reservation_entries(frm), __("Stock Reservation") ); - } - if (!frm.doc.is_subcontracted) { - frm.doc.items.forEach((item) => { - if ( - flt(item.stock_reserved_qty) > 0 && - frappe.model.can_read("Stock Reservation Entry") - ) { - frm.add_custom_button( - __("Reserved Stock"), - () => frm.events.show_reserved_stock(frm), - __("Stock Reservation") - ); - return; - } - }); + frm.add_custom_button( + __("Reserved Stock"), + () => frm.events.show_reserved_stock(frm), + __("Stock Reservation") + ); } } @@ -266,7 +256,10 @@ frappe.ui.form.on("Sales Order", { default: frm.doc.set_warehouse, get_query: () => { return { - filters: [["Warehouse", "is_group", "!=", 1]], + filters: [ + ["Warehouse", "is_group", "!=", 1], + ["Warehouse", "company", "=", frm.doc.company], + ], }; }, onchange: () => { @@ -320,6 +313,7 @@ frappe.ui.form.on("Sales Order", { item_code: item.item_code, warehouse: dialog.get_value("set_warehouse") || item.warehouse, qty_to_reserve: Math.max(unreserved_qty, 0), + is_packed_item: 0, }); dialog.fields_dict.items.grid.refresh(); dialog.set_value("add_item", undefined); @@ -340,9 +334,8 @@ frappe.ui.form.on("Sales Order", { fields: [ { fieldname: "sales_order_item", - fieldtype: "Link", - label: __("Sales Order Item"), - options: "Sales Order Item", + fieldtype: "Data", + label: __("Item"), reqd: 1, in_list_view: 1, get_query: () => { @@ -386,9 +379,13 @@ frappe.ui.form.on("Sales Order", { options: "Warehouse", reqd: 1, in_list_view: 1, + read_only_depends_on: "eval:doc.is_packed_item", get_query: () => { return { - filters: [["Warehouse", "is_group", "!=", 1]], + filters: [ + ["Warehouse", "is_group", "!=", 1], + ["Warehouse", "company", "=", frm.doc.company], + ], }; }, }, @@ -399,6 +396,12 @@ frappe.ui.form.on("Sales Order", { reqd: 1, in_list_view: 1, }, + { + fieldname: "is_packed_item", + fieldtype: "Check", + label: __("Is Packed Item"), + hidden: 1, + }, ], }, ], @@ -445,13 +448,40 @@ frappe.ui.form.on("Sales Order", { item_code: item.item_code, warehouse: item.warehouse, qty_to_reserve: unreserved_qty, + is_packed_item: 0, }); } } }); - dialog.fields_dict.items.grid.refresh(); - dialog.show(); + frappe.call({ + doc: frm.doc, + method: "has_unreserved_stock", + args: { + table_name: "packed_items", + }, + callback: (r) => { + if (r.message) { + frm.doc.packed_items.forEach((item) => { + if (item.reserve_stock && r.message[item.name]) { + const unreserved_qty = r.message[item.name]; + if (unreserved_qty > 0) { + dialog.fields_dict.items.df.data.push({ + __checked: 1, + sales_order_item: item.name, + item_code: item.item_code, + warehouse: item.warehouse, + qty_to_reserve: unreserved_qty, + is_packed_item: 1, + }); + } + } + }); + } + dialog.fields_dict.items.grid.refresh(); + dialog.show(); + }, + }); }, cancel_stock_reservation_entries(frm) { @@ -795,6 +825,14 @@ frappe.ui.form.on("Sales Order", { }, }); }, + + reserve_stock(frm) { + ["items", "packed_items"].forEach((table) => { + (frm.doc[table] || []).forEach((row) => { + frappe.model.set_value(row.doctype, row.name, "reserve_stock", frm.doc.reserve_stock); + }); + }); + }, }); frappe.ui.form.on("Sales Order Item", { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 0a7afd41ba4..9626f47163a 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -33,8 +33,11 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, get_sre_reserved_qty_details_for_voucher, + get_ssb_bundle_for_voucher, has_reserved_stock, ) from erpnext.stock.get_item_details import ( @@ -218,7 +221,7 @@ class SalesOrder(SellingController): return if frappe.get_single_value("Stock Settings", "enable_stock_reservation"): - if self.has_unreserved_stock(): + if self.has_unreserved_stock() or self.has_unreserved_stock("packed_items"): self.set_onload("has_unreserved_stock", True) if has_reserved_stock(self.doctype, self.name): @@ -259,8 +262,6 @@ class SalesOrder(SellingController): validate_coupon_code(self.coupon_code) - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) self.validate_with_previous_doc() @@ -822,20 +823,22 @@ class SalesOrder(SellingController): if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)): item.reserve_stock = 0 - def has_unreserved_stock(self) -> bool: + @frappe.whitelist() + def has_unreserved_stock(self, table_name: str = "items") -> bool: """Returns True if there is any unreserved item in the Sales Order.""" reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) - for item in self.get("items"): + data = {} + for item in self.get(table_name): if not item.get("reserve_stock"): continue unreserved_qty = get_unreserved_qty(item, reserved_qty_details) if unreserved_qty > 0: - return True + data[item.name] = unreserved_qty - return False + return data @frappe.whitelist() def create_stock_reservation_entries( @@ -850,12 +853,31 @@ class SalesOrder(SellingController): create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, ) - create_stock_reservation_entries( - sales_order=self, - items_details=items_details, - from_voucher_type=from_voucher_type, - notify=notify, - ) + packed_items = [] + if items_details: + for idx, item in enumerate(items_details): + if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")): + packed_items.append(items_details.pop(idx)) + + sre_count = 0 + if items_details != []: + sre_count = create_stock_reservation_entries( + sales_order=self, + items_details=items_details, + from_voucher_type=from_voucher_type, + notify=notify, + ) + + if items := packed_items or [item for item in self.packed_items if item.reserve_stock]: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation + + stock_reservation = StockReservation(doc=self, items=items) + stock_reservation.table_name = "packed_items" + stock_reservation.qty_field = "qty" + is_sre_created = stock_reservation.make_stock_reservation_entries() + + if notify and is_sre_created and not sre_count: + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") @frappe.whitelist() def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None: @@ -958,7 +980,23 @@ def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: """Returns the unreserved quantity for the Sales Order Item.""" existing_reserved_qty = reserved_qty_details.get(item.name, 0) - return item.stock_qty - flt(item.delivered_qty) * item.get("conversion_factor", 1) - existing_reserved_qty + if item.get("delivered_qty") is not None: + return ( + item.stock_qty + - flt(item.delivered_qty) * item.get("conversion_factor", 1) + - existing_reserved_qty + ) + else: + stock_qty, delivered_qty, conversion_factor = frappe.get_value( + "Sales Order Item", + item.parent_detail_docname, + ["stock_qty", "delivered_qty", "conversion_factor"], + ) + bundle_conversion_factor = ( + item.qty / stock_qty + ) # ratio of packed item qty to main item qty in product bundle + delivered_qty = delivered_qty * conversion_factor * bundle_conversion_factor + return item.qty - delivered_qty - existing_reserved_qty def get_list_context(context=None): @@ -1153,15 +1191,58 @@ def make_project(source_name: str, target_doc: str | Document | None = None): return doc +def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre): + for item in source.packed_items: + target_item = next( + ( + d + for d in target.packed_items + if (d.parent_item, d.item_code, d.warehouse) + == (item.parent_item, item.item_code, item.warehouse) + ), + None, + ) + if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]): + if sre[0].reservation_based_on == "Serial and Batch": + qty = 0 + serial_nos = [] + batch_nos = [] + if use_serial_batch_fields: + target_item.use_serial_batch_fields = 1 + for item in sre: + qty += item.reserved_qty + if item.has_serial_no: + serial_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="serial_no", + ) + ) + if item.has_batch_no: + batch_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="batch_no", + ) + ) + + if len(batch_nos) == 1: + target_item.batch_no = batch_nos[0] if batch_nos else None + if serial_nos and len(batch_nos) < 2: + target_item.serial_no = "\n".join(serial_nos) + + if not use_serial_batch_fields or len(batch_nos) > 1: + target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name + + @frappe.whitelist() def make_delivery_note( source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None ): - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_details_for_voucher, get_sre_reserved_qty_details_for_voucher, - get_ssb_bundle_for_voucher, ) if not kwargs: @@ -1184,6 +1265,7 @@ def make_delivery_note( # 0 qty is accepted, as the qty is uncertain for some items has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 @@ -1268,6 +1350,7 @@ def make_delivery_note( so = frappe.get_doc("Sales Order", source_name) target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc) + packed_sre = [] if not kwargs.skip_item_mapping and kwargs.for_reserved_stock: sre_list = get_sre_details_for_voucher("Sales Order", source_name) @@ -1279,6 +1362,10 @@ def make_delivery_note( so_items = {d.name: d for d in so.items if d.stock_reserved_qty} for sre in sre_list: + if not so_items.get(sre.voucher_detail_no): + packed_sre.append(sre) + continue + if not condition(so_items[sre.voucher_detail_no]): continue @@ -1302,14 +1389,12 @@ def make_delivery_note( dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1)) dn_item.warehouse = sre.warehouse - use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") - if ( not use_serial_batch_fields and sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no) ): - dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre) + dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name target_doc.append("items", dn_item) else: @@ -1323,7 +1408,9 @@ def make_delivery_note( return # Should be called after mapping items. + target_doc.packed_items = [] set_missing_values(so, target_doc) + set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre) return target_doc @@ -1352,6 +1439,14 @@ def make_sales_invoice( if target.get("allocate_advances_automatically"): target.set_advances() + make_packing_list(target) + set_serial_batch_for_bundle_reservation( + source, + target, + frappe.get_single_value("Stock Settings", "use_serial_batch_fields"), + get_sre_details_for_voucher("Sales Order", source_name), + ) + def set_missing_values(source, target): target.flags.ignore_permissions = True target.run_method("set_missing_values") diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 6a8abea1543..1998dbc4f8e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2757,6 +2757,145 @@ class TestSalesOrder(ERPNextTestSuite): so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1) self.assertRaises(frappe.ValidationError, so.save) + @ERPNextTestSuite.change_settings( + "Stock Settings", {"enable_stock_reservation": 1, "use_serial_batch_fields": 0} + ) + def test_product_bundle_reservation(self): + pb_item = make_item("Product Bundle Item", {"is_stock_item": 0}) + simple_item = make_item("Simple Item", {"is_stock_item": 1}) + sb_item = make_item( + "Serial Batch Item", + { + "is_stock_item": 1, + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BAT-TSBIFRM-.#####", + "serial_no_series": "SN-TSBIFRM-.#####", + }, + ) + make_product_bundle(pb_item.name, [simple_item.name, sb_item.name]) + + warehouse = "_Test Warehouse - _TC" + + make_stock_entry( + item_code=simple_item.name, + target=warehouse, + qty=10, + ) + + # two different stock entries on purpose to get two batches + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + + so = make_sales_order(item_code=pb_item.name, do_not_submit=1) + so.reserve_stock = 1 + for item in so.packed_items: + item.reserve_stock = 1 + so.submit() + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_batch_nos_details, + get_sre_reserved_qty_for_voucher_detail_no, + get_sre_reserved_serial_nos_details, + ) + + for item in so.packed_items: + self.assertEqual( + get_sre_reserved_qty_for_voucher_detail_no(item.item_code, "Sales Order", so.name, item.name), + item.qty, + ) + + sre_serial_nos = list(get_sre_reserved_serial_nos_details(sb_item.name, warehouse).keys()) + sre_batch_nos = list(get_sre_reserved_batch_nos_details(sb_item.name, warehouse).keys()) + + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn.save() + + self.assertTrue(dn.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + dn.items[0].qty = 5 + dn.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", dn.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = dn.company + sabb_doc.save() + dn.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + dn1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn1.save() + + self.assertTrue(dn1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(dn1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(dn1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + + dn.cancel() + + # test the same thing with sales invoice as well + + si = make_sales_invoice(so.name) + si.update_stock = 1 + si.save() + + self.assertTrue(si.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + si.items[0].qty = 5 + si.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", si.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = si.company + sabb_doc.save() + si.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + si1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + si1.save() + + self.assertTrue(si1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(si1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(si1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")): 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 0f043a73fa4..ec9aaf2381a 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "hash", "creation": "2013-03-07 11:42:58", "doctype": "DocType", @@ -1035,7 +1036,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-02-22 16:40:00.200328", + "modified": "2026-05-06 12:03:40.472277", "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 b1d689fc26c..fe03e3218b7 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -19,6 +19,7 @@ from frappe.utils import cint, flt from erpnext.accounts.party import get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController +from erpnext.stock.doctype.packed_item.packed_item import make_packing_list form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -297,9 +298,6 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() self.set_serial_and_batch_bundle_from_pick_list() - - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list - make_packing_list(self) self.update_current_stock() diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index f77661c4245..dedd8e03a7d 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -1,6 +1,6 @@ { "actions": [], - "creation": "2013-02-22 01:28:00", + "creation": "2026-05-05 11:19:24.699669", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -9,6 +9,7 @@ "item_code", "item_name", "delivered_by_supplier", + "reserve_stock", "column_break_5", "description", "section_break_6", @@ -94,6 +95,7 @@ "fieldname": "warehouse", "fieldtype": "Link", "label": "From Warehouse", + "mandatory_depends_on": "eval:doc.reserve_stock", "oldfieldname": "warehouse", "oldfieldtype": "Link", "options": "Warehouse" @@ -309,13 +311,23 @@ "non_negative": 1, "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:parent.reserve_stock", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock", + "no_copy": 1, + "print_hide": 1, + "report_hide": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-27 14:12:53.236906", + "modified": "2026-05-05 16:16:12.856629", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 0cc2cc6fb9e..162fc342df8 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -46,6 +46,7 @@ class PackedItem(Document): qty: DF.Float rate: DF.Currency requested_qty: DF.Float + reserve_stock: DF.Check serial_and_batch_bundle: DF.Link | None serial_no: DF.Text | None target_warehouse: DF.Link | None @@ -125,7 +126,7 @@ def get_indexed_packed_items_table(doc): key = ( packed_item.parent_item, packed_item.item_code, - packed_item.idx if doc.is_new() else packed_item.parent_detail_docname, + packed_item.parent_detail_docname, ) indexed_table[key] = packed_item @@ -202,6 +203,9 @@ def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, re pi_row.idx, pi_row.name = None, None pi_row = doc.append("packed_items", pi_row) + if doc.is_new() and doc.get("reserve_stock"): + pi_row.reserve_stock = 1 + return pi_row @@ -227,7 +231,7 @@ def get_packed_item_details(item_code, company): def update_packed_item_basic_data(main_item_row, pi_row, packing_item, item_data): pi_row.parent_item = main_item_row.item_code - pi_row.parent_detail_docname = main_item_row.name + pi_row.parent_detail_docname = main_item_row.name or main_item_row.idx pi_row.item_code = packing_item.item_code pi_row.item_name = item_data.item_name pi_row.uom = item_data.stock_uom @@ -241,6 +245,17 @@ def update_packed_item_basic_data(main_item_row, pi_row, packing_item, item_data def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data, doc): # TODO batch_no, actual_batch_qty, incoming_rate + if main_item_row.get("so_detail"): + pi_row.warehouse = frappe.get_value( + "Packed Item", + { + "parent_detail_docname": main_item_row.so_detail, + "parent_item": main_item_row.item_code, + "item_code": packing_item.item_code, + }, + "warehouse", + ) + if not pi_row.warehouse and not doc.amended_from: fetch_warehouse = doc.get("is_pos") or item_data.is_stock_item or not item_data.default_warehouse pi_row.warehouse = ( 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 a959cc4c14e..b0be80f97dd 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_copy": 1, "autoname": "MAT-SRE-.YYYY.-.#####", "creation": "2023-06-06 15:20:48.016846", @@ -152,7 +153,6 @@ "width": "150px" }, { - "allow_on_submit": 1, "fieldname": "reserved_qty", "fieldtype": "Float", "in_filter": 1, @@ -163,7 +163,7 @@ "oldfieldname": "actual_qty", "oldfieldtype": "Currency", "print_width": "150px", - "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.from_voucher_type == \"Pick List\") || (doc.delivered_qty > 0))", + "read_only": 1, "width": "150px" }, { @@ -342,7 +342,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-02-19 10:17:28.695394", + "modified": "2026-05-05 11:46:28.992976", "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 ca8d49fe8ef..b477fee2228 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -595,8 +595,14 @@ class StockReservationEntry(Document): voucher_delivered_qty = 0 if self.voucher_type == "Sales Order": + voucher_detail_no = self.voucher_detail_no + if not frappe.db.exists("Sales Order Item", self.voucher_detail_no): + voucher_detail_no = frappe.get_value( + "Packed Item", self.voucher_detail_no, "parent_detail_docname" + ) + delivered_qty, conversion_factor = frappe.db.get_value( - "Sales Order Item", self.voucher_detail_no, ["delivered_qty", "conversion_factor"] + "Sales Order Item", voucher_detail_no, ["delivered_qty", "conversion_factor"] ) voucher_delivered_qty = flt(delivered_qty) * flt(conversion_factor) @@ -1015,7 +1021,7 @@ def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict ).run(as_dict=True) -def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]: +def get_serial_batch_entries_for_voucher(sre_names: list[str]) -> list[dict]: """Returns a list of `Serial and Batch Entries` for the provided voucher.""" sre = frappe.qb.DocType("Stock Reservation Entry") @@ -1030,16 +1036,16 @@ def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]: sb_entry.batch_no, (sb_entry.qty - sb_entry.delivered_qty).as_("qty"), ) - .where((sre.docstatus == 1) & (sre.name == sre_name) & (sre.delivered_qty < sre.reserved_qty)) + .where((sre.docstatus == 1) & (sre.name.isin(sre_names)) & (sre.delivered_qty < sre.reserved_qty)) .where(sb_entry.qty > sb_entry.delivered_qty) .orderby(sb_entry.creation) ).run(as_dict=True) -def get_ssb_bundle_for_voucher(sre: dict) -> object: +def get_ssb_bundle_for_voucher(sre_list) -> object: """Returns a new `Serial and Batch Bundle` against the provided SRE.""" - sb_entries = get_serial_batch_entries_for_voucher(sre["name"]) + sb_entries = get_serial_batch_entries_for_voucher([sre.name for sre in sre_list]) if sb_entries: bundle = frappe.new_doc("Serial and Batch Bundle") @@ -1049,14 +1055,17 @@ def get_ssb_bundle_for_voucher(sre: dict) -> object: bundle.posting_time = nowtime() for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"): - setattr(bundle, field, sre[field]) + setattr(bundle, field, sre_list[0][field]) for sb_entry in sb_entries: bundle.append("entries", sb_entry) + if frappe.flags.in_test: + bundle.flags.ignore_mandatory = True + bundle.save() - return bundle.name + return bundle def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str | None = None) -> bool: @@ -1071,7 +1080,7 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st class StockReservation: - def __init__(self, doc, items=None, kwargs=None, notify=True): + def __init__(self, doc, items=None, kwargs=None): if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) @@ -1575,7 +1584,7 @@ def create_stock_reservation_entries_for_so_items( items_details: list[dict] | None = None, from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, notify=True, -) -> None: +): """Creates Stock Reservation Entries for Sales Order Items.""" from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty @@ -1776,6 +1785,8 @@ def create_stock_reservation_entries_for_so_items( if sre_count and notify: frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + return sre_count + def cancel_stock_reservation_entries( voucher_type: str | None = None, 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 3f33b0a2da8..d11f33992ea 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 @@ -120,75 +120,6 @@ class TestStockReservationEntry(ERPNextTestSuite): sre.load_from_db() self.assertEqual(sre.status, "Cancelled") - @ERPNextTestSuite.change_settings( - "Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1} - ) - def test_update_reserved_qty_in_voucher(self) -> None: - # Step - 1: Create a `Sales Order` - so = make_sales_order( - item_code=self.sr_item.name, - warehouse=self.warehouse, - qty=50, - rate=100, - do_not_submit=True, - ) - so.reserve_stock = 0 # Stock Reservation Entries won't be created on submit - so.items[0].reserve_stock = 1 - so.save() - so.submit() - - # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item` - sre1 = make_stock_reservation_entry( - item_code=self.sr_item.name, - warehouse=self.warehouse, - voucher_type="Sales Order", - voucher_no=so.name, - voucher_detail_no=so.items[0].name, - reserved_qty=30, - ) - - so.load_from_db() - sre1.load_from_db() - self.assertEqual(sre1.status, "Partially Reserved") - self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty) - - # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item` - sre2 = make_stock_reservation_entry( - item_code=self.sr_item.name, - warehouse=self.warehouse, - voucher_type="Sales Order", - voucher_no=so.name, - voucher_detail_no=so.items[0].name, - reserved_qty=20, - ) - - so.load_from_db() - sre2.load_from_db() - self.assertEqual(sre1.status, "Partially Reserved") - self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty + sre2.reserved_qty) - - # Step - 4: Cancel `Stock Reservation Entry[1]` - sre1.cancel() - so.load_from_db() - sre1.load_from_db() - self.assertEqual(sre1.status, "Cancelled") - self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty) - - # Step - 5: Update `Stock Reservation Entry[2]` Reserved Qty - sre2.reserved_qty += sre1.reserved_qty - sre2.save() - so.load_from_db() - sre1.load_from_db() - self.assertEqual(sre2.status, "Reserved") - 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) - @ERPNextTestSuite.change_settings( "Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1} ) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 0f87c9f21a2..4e74a714977 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -399,7 +399,7 @@ class SubcontractingOrder(SubcontractingController): reservation_items.append(data) - sre = StockReservation(self, items=reservation_items, notify=True) + sre = StockReservation(self, items=reservation_items) if is_transfer: sre.transfer_reservation_entries_to( self.production_plan, from_doctype="Production Plan", to_doctype="Subcontracting Order"