feat: stock reservation for product bundle (#54750)

* feat: stock reservation for product bundle

* test: add test case
This commit is contained in:
Mihir Kandoi
2026-05-06 16:39:04 +05:30
committed by GitHub
parent 75804a364b
commit d5549e2f6c
14 changed files with 401 additions and 138 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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", {

View File

@@ -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")

View File

@@ -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")):

View File

@@ -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",

View File

@@ -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()

View File

@@ -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",

View File

@@ -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 = (

View File

@@ -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",

View File

@@ -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,

View File

@@ -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}
)

View File

@@ -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"