mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-06 23:10:26 +00:00
feat: stock reservation for product bundle (#54750)
* feat: stock reservation for product bundle * test: add test case
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user