mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-13 10:11:20 +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"
|
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":
|
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.
|
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
|
||||||
if not item.get(so_field) or not item.so_detail:
|
if not item.get(so_field) or not item.so_detail:
|
||||||
continue
|
continue
|
||||||
@@ -927,7 +950,7 @@ class SellingController(StockController):
|
|||||||
if not sre_list:
|
if not sre_list:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
qty_to_deliver = item.stock_qty
|
qty_to_deliver = item.get("stock_qty") or item.qty
|
||||||
for sre in sre_list:
|
for sre in sre_list:
|
||||||
if qty_to_deliver <= 0:
|
if qty_to_deliver <= 0:
|
||||||
break
|
break
|
||||||
@@ -974,7 +997,7 @@ class SellingController(StockController):
|
|||||||
qty_to_deliver -= qty_can_be_deliver
|
qty_to_deliver -= qty_can_be_deliver
|
||||||
|
|
||||||
if self._action == "cancel":
|
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.
|
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
|
||||||
if not item.get(so_field) or not item.so_detail:
|
if not item.get(so_field) or not item.so_detail:
|
||||||
continue
|
continue
|
||||||
@@ -996,7 +1019,7 @@ class SellingController(StockController):
|
|||||||
if not sre_list:
|
if not sre_list:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
qty_to_undelivered = item.stock_qty
|
qty_to_undelivered = item.get("stock_qty") or item.qty
|
||||||
for sre in sre_list:
|
for sre in sre_list:
|
||||||
if qty_to_undelivered <= 0:
|
if qty_to_undelivered <= 0:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2252,7 +2252,7 @@ def make_stock_reservation_entries(
|
|||||||
if table_name and table_name != child_table_name:
|
if table_name and table_name != child_table_name:
|
||||||
continue
|
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:
|
if doc.docstatus == 1:
|
||||||
sre_created = sre.make_stock_reservation_entries()
|
sre_created = sre.make_stock_reservation_entries()
|
||||||
if sre_created:
|
if sre_created:
|
||||||
|
|||||||
@@ -2126,7 +2126,7 @@ def make_stock_reservation_entries(
|
|||||||
if items and isinstance(items, str):
|
if items and isinstance(items, str):
|
||||||
items = parse_json(items)
|
items = parse_json(items)
|
||||||
|
|
||||||
sre = StockReservation(doc, items=items, notify=notify)
|
sre = StockReservation(doc, items=items)
|
||||||
if doc.docstatus == 2 or doc.status == "Closed":
|
if doc.docstatus == 2 or doc.status == "Closed":
|
||||||
sre.cancel_stock_reservation_entries()
|
sre.cancel_stock_reservation_entries()
|
||||||
elif doc.docstatus == 1:
|
elif doc.docstatus == 1:
|
||||||
|
|||||||
@@ -123,22 +123,12 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
() => frm.events.cancel_stock_reservation_entries(frm),
|
() => frm.events.cancel_stock_reservation_entries(frm),
|
||||||
__("Stock Reservation")
|
__("Stock Reservation")
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (!frm.doc.is_subcontracted) {
|
frm.add_custom_button(
|
||||||
frm.doc.items.forEach((item) => {
|
__("Reserved Stock"),
|
||||||
if (
|
() => frm.events.show_reserved_stock(frm),
|
||||||
flt(item.stock_reserved_qty) > 0 &&
|
__("Stock Reservation")
|
||||||
frappe.model.can_read("Stock Reservation Entry")
|
);
|
||||||
) {
|
|
||||||
frm.add_custom_button(
|
|
||||||
__("Reserved Stock"),
|
|
||||||
() => frm.events.show_reserved_stock(frm),
|
|
||||||
__("Stock Reservation")
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +256,10 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
default: frm.doc.set_warehouse,
|
default: frm.doc.set_warehouse,
|
||||||
get_query: () => {
|
get_query: () => {
|
||||||
return {
|
return {
|
||||||
filters: [["Warehouse", "is_group", "!=", 1]],
|
filters: [
|
||||||
|
["Warehouse", "is_group", "!=", 1],
|
||||||
|
["Warehouse", "company", "=", frm.doc.company],
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onchange: () => {
|
onchange: () => {
|
||||||
@@ -320,6 +313,7 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
item_code: item.item_code,
|
item_code: item.item_code,
|
||||||
warehouse: dialog.get_value("set_warehouse") || item.warehouse,
|
warehouse: dialog.get_value("set_warehouse") || item.warehouse,
|
||||||
qty_to_reserve: Math.max(unreserved_qty, 0),
|
qty_to_reserve: Math.max(unreserved_qty, 0),
|
||||||
|
is_packed_item: 0,
|
||||||
});
|
});
|
||||||
dialog.fields_dict.items.grid.refresh();
|
dialog.fields_dict.items.grid.refresh();
|
||||||
dialog.set_value("add_item", undefined);
|
dialog.set_value("add_item", undefined);
|
||||||
@@ -340,9 +334,8 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
fieldname: "sales_order_item",
|
fieldname: "sales_order_item",
|
||||||
fieldtype: "Link",
|
fieldtype: "Data",
|
||||||
label: __("Sales Order Item"),
|
label: __("Item"),
|
||||||
options: "Sales Order Item",
|
|
||||||
reqd: 1,
|
reqd: 1,
|
||||||
in_list_view: 1,
|
in_list_view: 1,
|
||||||
get_query: () => {
|
get_query: () => {
|
||||||
@@ -386,9 +379,13 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
options: "Warehouse",
|
options: "Warehouse",
|
||||||
reqd: 1,
|
reqd: 1,
|
||||||
in_list_view: 1,
|
in_list_view: 1,
|
||||||
|
read_only_depends_on: "eval:doc.is_packed_item",
|
||||||
get_query: () => {
|
get_query: () => {
|
||||||
return {
|
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,
|
reqd: 1,
|
||||||
in_list_view: 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,
|
item_code: item.item_code,
|
||||||
warehouse: item.warehouse,
|
warehouse: item.warehouse,
|
||||||
qty_to_reserve: unreserved_qty,
|
qty_to_reserve: unreserved_qty,
|
||||||
|
is_packed_item: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.fields_dict.items.grid.refresh();
|
frappe.call({
|
||||||
dialog.show();
|
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) {
|
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", {
|
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.selling.doctype.customer.customer import check_credit_limit
|
||||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
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.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 (
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
|
get_sre_details_for_voucher,
|
||||||
get_sre_reserved_qty_details_for_voucher,
|
get_sre_reserved_qty_details_for_voucher,
|
||||||
|
get_ssb_bundle_for_voucher,
|
||||||
has_reserved_stock,
|
has_reserved_stock,
|
||||||
)
|
)
|
||||||
from erpnext.stock.get_item_details import (
|
from erpnext.stock.get_item_details import (
|
||||||
@@ -218,7 +221,7 @@ class SalesOrder(SellingController):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if frappe.get_single_value("Stock Settings", "enable_stock_reservation"):
|
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)
|
self.set_onload("has_unreserved_stock", True)
|
||||||
|
|
||||||
if has_reserved_stock(self.doctype, self.name):
|
if has_reserved_stock(self.doctype, self.name):
|
||||||
@@ -259,8 +262,6 @@ class SalesOrder(SellingController):
|
|||||||
|
|
||||||
validate_coupon_code(self.coupon_code)
|
validate_coupon_code(self.coupon_code)
|
||||||
|
|
||||||
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
|
||||||
|
|
||||||
make_packing_list(self)
|
make_packing_list(self)
|
||||||
|
|
||||||
self.validate_with_previous_doc()
|
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)):
|
if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)):
|
||||||
item.reserve_stock = 0
|
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."""
|
"""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)
|
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"):
|
if not item.get("reserve_stock"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
|
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
|
||||||
if unreserved_qty > 0:
|
if unreserved_qty > 0:
|
||||||
return True
|
data[item.name] = unreserved_qty
|
||||||
|
|
||||||
return False
|
return data
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_stock_reservation_entries(
|
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_for_so_items as create_stock_reservation_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
create_stock_reservation_entries(
|
packed_items = []
|
||||||
sales_order=self,
|
if items_details:
|
||||||
items_details=items_details,
|
for idx, item in enumerate(items_details):
|
||||||
from_voucher_type=from_voucher_type,
|
if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")):
|
||||||
notify=notify,
|
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()
|
@frappe.whitelist()
|
||||||
def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None:
|
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."""
|
"""Returns the unreserved quantity for the Sales Order Item."""
|
||||||
|
|
||||||
existing_reserved_qty = reserved_qty_details.get(item.name, 0)
|
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):
|
def get_list_context(context=None):
|
||||||
@@ -1153,15 +1191,58 @@ def make_project(source_name: str, target_doc: str | Document | None = None):
|
|||||||
return doc
|
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()
|
@frappe.whitelist()
|
||||||
def make_delivery_note(
|
def make_delivery_note(
|
||||||
source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
|
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 (
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
get_sre_details_for_voucher,
|
|
||||||
get_sre_reserved_qty_details_for_voucher,
|
get_sre_reserved_qty_details_for_voucher,
|
||||||
get_ssb_bundle_for_voucher,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not kwargs:
|
if not kwargs:
|
||||||
@@ -1184,6 +1265,7 @@ def make_delivery_note(
|
|||||||
|
|
||||||
# 0 qty is accepted, as the qty is uncertain for some items
|
# 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")
|
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):
|
def is_unit_price_row(source):
|
||||||
return has_unit_price_items and source.qty == 0
|
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)
|
so = frappe.get_doc("Sales Order", source_name)
|
||||||
target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc)
|
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:
|
if not kwargs.skip_item_mapping and kwargs.for_reserved_stock:
|
||||||
sre_list = get_sre_details_for_voucher("Sales Order", source_name)
|
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}
|
so_items = {d.name: d for d in so.items if d.stock_reserved_qty}
|
||||||
|
|
||||||
for sre in sre_list:
|
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]):
|
if not condition(so_items[sre.voucher_detail_no]):
|
||||||
continue
|
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.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
|
||||||
dn_item.warehouse = sre.warehouse
|
dn_item.warehouse = sre.warehouse
|
||||||
|
|
||||||
use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields")
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not use_serial_batch_fields
|
not use_serial_batch_fields
|
||||||
and sre.reservation_based_on == "Serial and Batch"
|
and sre.reservation_based_on == "Serial and Batch"
|
||||||
and (sre.has_serial_no or sre.has_batch_no)
|
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)
|
target_doc.append("items", dn_item)
|
||||||
else:
|
else:
|
||||||
@@ -1323,7 +1408,9 @@ def make_delivery_note(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Should be called after mapping items.
|
# Should be called after mapping items.
|
||||||
|
target_doc.packed_items = []
|
||||||
set_missing_values(so, target_doc)
|
set_missing_values(so, target_doc)
|
||||||
|
set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre)
|
||||||
|
|
||||||
return target_doc
|
return target_doc
|
||||||
|
|
||||||
@@ -1352,6 +1439,14 @@ def make_sales_invoice(
|
|||||||
if target.get("allocate_advances_automatically"):
|
if target.get("allocate_advances_automatically"):
|
||||||
target.set_advances()
|
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):
|
def set_missing_values(source, target):
|
||||||
target.flags.ignore_permissions = True
|
target.flags.ignore_permissions = True
|
||||||
target.run_method("set_missing_values")
|
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)
|
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)
|
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):
|
def compare_payment_schedules(doc, doc1, doc2):
|
||||||
for index, schedule in enumerate(doc1.get("payment_schedule")):
|
for index, schedule in enumerate(doc1.get("payment_schedule")):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"autoname": "hash",
|
"autoname": "hash",
|
||||||
"creation": "2013-03-07 11:42:58",
|
"creation": "2013-03-07 11:42:58",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -1035,7 +1036,7 @@
|
|||||||
"idx": 1,
|
"idx": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-02-22 16:40:00.200328",
|
"modified": "2026-05-06 12:03:40.472277",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Sales Order Item",
|
"name": "Sales Order Item",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from frappe.utils import cint, flt
|
|||||||
from erpnext.accounts.party import get_due_date
|
from erpnext.accounts.party import get_due_date
|
||||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||||
from erpnext.controllers.selling_controller import SellingController
|
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"}
|
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_uom_is_integer("uom", "qty")
|
||||||
self.validate_with_previous_doc()
|
self.validate_with_previous_doc()
|
||||||
self.set_serial_and_batch_bundle_from_pick_list()
|
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)
|
make_packing_list(self)
|
||||||
self.update_current_stock()
|
self.update_current_stock()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
"creation": "2013-02-22 01:28:00",
|
"creation": "2026-05-05 11:19:24.699669",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"item_code",
|
"item_code",
|
||||||
"item_name",
|
"item_name",
|
||||||
"delivered_by_supplier",
|
"delivered_by_supplier",
|
||||||
|
"reserve_stock",
|
||||||
"column_break_5",
|
"column_break_5",
|
||||||
"description",
|
"description",
|
||||||
"section_break_6",
|
"section_break_6",
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"fieldname": "warehouse",
|
"fieldname": "warehouse",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "From Warehouse",
|
"label": "From Warehouse",
|
||||||
|
"mandatory_depends_on": "eval:doc.reserve_stock",
|
||||||
"oldfieldname": "warehouse",
|
"oldfieldname": "warehouse",
|
||||||
"oldfieldtype": "Link",
|
"oldfieldtype": "Link",
|
||||||
"options": "Warehouse"
|
"options": "Warehouse"
|
||||||
@@ -309,13 +311,23 @@
|
|||||||
"non_negative": 1,
|
"non_negative": 1,
|
||||||
"print_hide": 1,
|
"print_hide": 1,
|
||||||
"read_only": 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,
|
"idx": 1,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-04-27 14:12:53.236906",
|
"modified": "2026-05-05 16:16:12.856629",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Packed Item",
|
"name": "Packed Item",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class PackedItem(Document):
|
|||||||
qty: DF.Float
|
qty: DF.Float
|
||||||
rate: DF.Currency
|
rate: DF.Currency
|
||||||
requested_qty: DF.Float
|
requested_qty: DF.Float
|
||||||
|
reserve_stock: DF.Check
|
||||||
serial_and_batch_bundle: DF.Link | None
|
serial_and_batch_bundle: DF.Link | None
|
||||||
serial_no: DF.Text | None
|
serial_no: DF.Text | None
|
||||||
target_warehouse: DF.Link | None
|
target_warehouse: DF.Link | None
|
||||||
@@ -125,7 +126,7 @@ def get_indexed_packed_items_table(doc):
|
|||||||
key = (
|
key = (
|
||||||
packed_item.parent_item,
|
packed_item.parent_item,
|
||||||
packed_item.item_code,
|
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
|
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.idx, pi_row.name = None, None
|
||||||
pi_row = doc.append("packed_items", pi_row)
|
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
|
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):
|
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_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_code = packing_item.item_code
|
||||||
pi_row.item_name = item_data.item_name
|
pi_row.item_name = item_data.item_name
|
||||||
pi_row.uom = item_data.stock_uom
|
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):
|
def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data, doc):
|
||||||
# TODO batch_no, actual_batch_qty, incoming_rate
|
# 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:
|
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
|
fetch_warehouse = doc.get("is_pos") or item_data.is_stock_item or not item_data.default_warehouse
|
||||||
pi_row.warehouse = (
|
pi_row.warehouse = (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_copy": 1,
|
"allow_copy": 1,
|
||||||
"autoname": "MAT-SRE-.YYYY.-.#####",
|
"autoname": "MAT-SRE-.YYYY.-.#####",
|
||||||
"creation": "2023-06-06 15:20:48.016846",
|
"creation": "2023-06-06 15:20:48.016846",
|
||||||
@@ -152,7 +153,6 @@
|
|||||||
"width": "150px"
|
"width": "150px"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
|
||||||
"fieldname": "reserved_qty",
|
"fieldname": "reserved_qty",
|
||||||
"fieldtype": "Float",
|
"fieldtype": "Float",
|
||||||
"in_filter": 1,
|
"in_filter": 1,
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
"oldfieldname": "actual_qty",
|
"oldfieldname": "actual_qty",
|
||||||
"oldfieldtype": "Currency",
|
"oldfieldtype": "Currency",
|
||||||
"print_width": "150px",
|
"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"
|
"width": "150px"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-02-19 10:17:28.695394",
|
"modified": "2026-05-05 11:46:28.992976",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Stock Reservation Entry",
|
"name": "Stock Reservation Entry",
|
||||||
|
|||||||
@@ -595,8 +595,14 @@ class StockReservationEntry(Document):
|
|||||||
|
|
||||||
voucher_delivered_qty = 0
|
voucher_delivered_qty = 0
|
||||||
if self.voucher_type == "Sales Order":
|
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(
|
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)
|
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)
|
).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."""
|
"""Returns a list of `Serial and Batch Entries` for the provided voucher."""
|
||||||
|
|
||||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
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.batch_no,
|
||||||
(sb_entry.qty - sb_entry.delivered_qty).as_("qty"),
|
(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)
|
.where(sb_entry.qty > sb_entry.delivered_qty)
|
||||||
.orderby(sb_entry.creation)
|
.orderby(sb_entry.creation)
|
||||||
).run(as_dict=True)
|
).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."""
|
"""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:
|
if sb_entries:
|
||||||
bundle = frappe.new_doc("Serial and Batch Bundle")
|
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()
|
bundle.posting_time = nowtime()
|
||||||
|
|
||||||
for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"):
|
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:
|
for sb_entry in sb_entries:
|
||||||
bundle.append("entries", sb_entry)
|
bundle.append("entries", sb_entry)
|
||||||
|
|
||||||
|
if frappe.flags.in_test:
|
||||||
|
bundle.flags.ignore_mandatory = True
|
||||||
|
|
||||||
bundle.save()
|
bundle.save()
|
||||||
|
|
||||||
return bundle.name
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str | None = None) -> bool:
|
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:
|
class StockReservation:
|
||||||
def __init__(self, doc, items=None, kwargs=None, notify=True):
|
def __init__(self, doc, items=None, kwargs=None):
|
||||||
if isinstance(doc, str):
|
if isinstance(doc, str):
|
||||||
doc = parse_json(doc)
|
doc = parse_json(doc)
|
||||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
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,
|
items_details: list[dict] | None = None,
|
||||||
from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
|
from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
|
||||||
notify=True,
|
notify=True,
|
||||||
) -> None:
|
):
|
||||||
"""Creates Stock Reservation Entries for Sales Order Items."""
|
"""Creates Stock Reservation Entries for Sales Order Items."""
|
||||||
|
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty
|
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:
|
if sre_count and notify:
|
||||||
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
|
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
|
||||||
|
|
||||||
|
return sre_count
|
||||||
|
|
||||||
|
|
||||||
def cancel_stock_reservation_entries(
|
def cancel_stock_reservation_entries(
|
||||||
voucher_type: str | None = None,
|
voucher_type: str | None = None,
|
||||||
|
|||||||
@@ -120,75 +120,6 @@ class TestStockReservationEntry(ERPNextTestSuite):
|
|||||||
sre.load_from_db()
|
sre.load_from_db()
|
||||||
self.assertEqual(sre.status, "Cancelled")
|
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(
|
@ERPNextTestSuite.change_settings(
|
||||||
"Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}
|
"Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ class SubcontractingOrder(SubcontractingController):
|
|||||||
|
|
||||||
reservation_items.append(data)
|
reservation_items.append(data)
|
||||||
|
|
||||||
sre = StockReservation(self, items=reservation_items, notify=True)
|
sre = StockReservation(self, items=reservation_items)
|
||||||
if is_transfer:
|
if is_transfer:
|
||||||
sre.transfer_reservation_entries_to(
|
sre.transfer_reservation_entries_to(
|
||||||
self.production_plan, from_doctype="Production Plan", to_doctype="Subcontracting Order"
|
self.production_plan, from_doctype="Production Plan", to_doctype="Subcontracting Order"
|
||||||
|
|||||||
Reference in New Issue
Block a user