Merge pull request #34805 from s-aga-r/stock-reservation

feat: Stock Reservation against Sales Order
This commit is contained in:
Sagar Sharma 2023-05-26 17:13:44 +05:30 committed by GitHub
commit c3fa1f7450
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1662 additions and 21 deletions

View File

@ -2826,6 +2826,17 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_billing_percentage() parent.update_billing_percentage()
parent.set_status() parent.set_status()
# Cancel and Recreate Stock Reservation Entries.
if parent_doctype == "Sales Order":
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
has_reserved_stock,
)
if has_reserved_stock(parent.doctype, parent.name):
cancel_stock_reservation_entries(parent.doctype, parent.name)
parent.create_stock_reservation_entries()
@erpnext.allow_regional @erpnext.allow_regional
def validate_regional(doc): def validate_regional(doc):

View File

@ -47,21 +47,50 @@ frappe.ui.form.on("Sales Order", {
frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_add_rows', true);
frm.set_df_property('packed_items', 'cannot_delete_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true);
}, },
refresh: function(frm) { refresh: function(frm) {
if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed' if(frm.doc.docstatus === 1) {
&& flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) { if (frm.doc.status !== 'Closed' && flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) {
frm.add_custom_button(__('Update Items'), () => { frm.add_custom_button(__('Update Items'), () => {
erpnext.utils.update_child_items({ erpnext.utils.update_child_items({
frm: frm, frm: frm,
child_docname: "items", child_docname: "items",
child_doctype: "Sales Order Detail", child_doctype: "Sales Order Detail",
cannot_add_row: false, cannot_add_row: false,
}) })
}); });
// Stock Reservation > Reserve button will be only visible if the SO has unreserved stock.
if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
}
}
// Stock Reservation > Unreserve button will be only visible if the SO has reserved stock.
if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation'));
}
} }
if (frm.doc.docstatus === 0 && frm.doc.is_internal_customer) { if (frm.doc.docstatus === 0) {
frm.events.get_items_from_internal_purchase_order(frm); if (frm.doc.is_internal_customer) {
frm.events.get_items_from_internal_purchase_order(frm);
}
if (frm.is_new()) {
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
if (value) {
frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((value) => {
// If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0.
frm.set_value("reserve_stock", value ? 1 : 0);
})
} else {
// If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and read only.
frm.set_value("reserve_stock", 0);
frm.set_df_property("reserve_stock", "read_only", 1);
}
})
}
} }
}, },
@ -137,6 +166,108 @@ frappe.ui.form.on("Sales Order", {
if(!d.delivery_date) d.delivery_date = frm.doc.delivery_date; if(!d.delivery_date) d.delivery_date = frm.doc.delivery_date;
}); });
refresh_field("items"); refresh_field("items");
},
create_stock_reservation_entries(frm) {
let items_data = [];
const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
fields: [
{
fieldtype: 'Data',
fieldname: 'name',
label: __('Name'),
reqd: 1,
read_only: 1,
},
{
fieldtype: 'Link',
fieldname: 'item_code',
label: __('Item Code'),
options: 'Item',
reqd: 1,
read_only: 1,
in_list_view: 1,
},
{
fieldtype: 'Link',
fieldname: 'warehouse',
label: __('Warehouse'),
options: 'Warehouse',
reqd: 1,
in_list_view: 1,
get_query: function () {
return {
filters: [
["Warehouse", "is_group", "!=", 1]
]
};
},
},
{
fieldtype: 'Float',
fieldname: 'qty_to_reserve',
label: __('Qty'),
reqd: 1,
in_list_view: 1
}
],
data: items_data,
in_place_edit: true,
get_data: function() {
return items_data;
}
}, function(data) {
if (data.items.length > 0) {
frappe.call({
doc: frm.doc,
method: 'create_stock_reservation_entries',
args: {
items_details: data.items,
notify: true
},
freeze: true,
freeze_message: __('Reserving Stock...'),
callback: (r) => {
frm.doc.__onload.has_unreserved_stock = false;
frm.reload_doc();
}
});
}
}, __("Stock Reservation"), __("Reserve Stock"));
frm.doc.items.forEach(item => {
if (item.reserve_stock) {
let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty))
if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({
'name': item.name,
'item_code': item.item_code,
'warehouse': item.warehouse,
'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor))
});
}
}
});
dialog.fields_dict.items.grid.refresh();
},
cancel_stock_reservation_entries(frm) {
frappe.call({
method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries',
args: {
voucher_type: frm.doctype,
voucher_no: frm.docname
},
freeze: true,
freeze_message: __('Unreserving Stock...'),
callback: (r) => {
frm.doc.__onload.has_reserved_stock = false;
frm.reload_doc();
}
})
} }
}); });

View File

@ -42,6 +42,7 @@
"scan_barcode", "scan_barcode",
"column_break_28", "column_break_28",
"set_warehouse", "set_warehouse",
"reserve_stock",
"items_section", "items_section",
"items", "items",
"section_break_31", "section_break_31",
@ -1625,13 +1626,24 @@
"fieldname": "named_place", "fieldname": "named_place",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Named Place" "label": "Named Place"
},
{
"default": "0",
"depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
"description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock",
"no_copy": 1,
"print_hide": 1,
"report_hide": 1
} }
], ],
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-04-20 11:14:01.036202", "modified": "2023-04-22 09:55:37.008190",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order", "name": "Sales Order",
@ -1664,7 +1676,6 @@
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "Sales Manager", "role": "Sales Manager",
"set_user_permissions": 1,
"share": 1, "share": 1,
"submit": 1, "submit": 1,
"write": 1 "write": 1

View File

@ -30,6 +30,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.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
get_sre_reserved_qty_details_for_voucher,
has_reserved_stock,
)
from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
@ -44,6 +49,14 @@ class SalesOrder(SellingController):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SalesOrder, self).__init__(*args, **kwargs) super(SalesOrder, self).__init__(*args, **kwargs)
def onload(self) -> None:
if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"):
if self.has_unreserved_stock():
self.set_onload("has_unreserved_stock", True)
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
def validate(self): def validate(self):
super(SalesOrder, self).validate() super(SalesOrder, self).validate()
self.validate_delivery_date() self.validate_delivery_date()
@ -241,6 +254,9 @@ class SalesOrder(SellingController):
update_coupon_code_count(self.coupon_code, "used") update_coupon_code_count(self.coupon_code, "used")
if self.get("reserve_stock"):
self.create_stock_reservation_entries()
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
super(SalesOrder, self).on_cancel() super(SalesOrder, self).on_cancel()
@ -257,6 +273,7 @@ class SalesOrder(SellingController):
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
self.update_blanket_order() self.update_blanket_order()
cancel_stock_reservation_entries("Sales Order", self.name)
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference) unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
if self.coupon_code: if self.coupon_code:
@ -485,6 +502,166 @@ class SalesOrder(SellingController):
).format(item.item_code) ).format(item.item_code)
) )
def has_unreserved_stock(self) -> 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"):
if not item.get("reserve_stock"):
continue
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
if unreserved_qty > 0:
return True
return False
@frappe.whitelist()
def create_stock_reservation_entries(self, items_details=None, notify=True):
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_available_qty_to_reserve,
validate_stock_reservation_settings,
)
validate_stock_reservation_settings(self)
allow_partial_reservation = frappe.db.get_single_value(
"Stock Settings", "allow_partial_reservation"
)
items = []
if items_details:
for item in items_details:
so_item = frappe.get_doc("Sales Order Item", item["name"])
so_item.reserve_stock = 1
so_item.warehouse = item["warehouse"]
so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor)
items.append(so_item)
sre_count = 0
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
for item in items or self.get("items"):
# Skip if `Reserved Stock` is not checked for the item.
if not item.get("reserve_stock"):
continue
# Skip if Non-Stock Item.
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
frappe.msgprint(
_("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format(
item.idx, frappe.bold(item.item_code)
),
title=_("Stock Reservation"),
indicator="yellow",
)
item.db_set("reserve_stock", 0)
continue
# Skip if Group Warehouse.
if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
frappe.msgprint(
_("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format(
item.idx, frappe.bold(item.warehouse)
),
title=_("Stock Reservation"),
indicator="yellow",
)
continue
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
# Stock is already reserved for the item, notify the user and skip the item.
if unreserved_qty <= 0:
frappe.msgprint(
_("Row #{0}: Stock is already reserved for the Item {1}.").format(
item.idx, frappe.bold(item.item_code)
),
title=_("Stock Reservation"),
indicator="yellow",
)
continue
available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
# No stock available to reserve, notify the user and skip the item.
if available_qty_to_reserve <= 0:
frappe.msgprint(
_("Row #{0}: No available stock to reserve for the Item {1} in Warehouse {2}.").format(
item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
),
title=_("Stock Reservation"),
indicator="orange",
)
continue
# The quantity which can be reserved.
qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
if hasattr(item, "qty_to_reserve"):
if item.qty_to_reserve <= 0:
frappe.msgprint(
_("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format(
item.idx, frappe.bold(item.item_code)
),
title=_("Stock Reservation"),
indicator="orange",
)
continue
else:
qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve)
# Partial Reservation
if qty_to_be_reserved < unreserved_qty:
if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
frappe.msgprint(
_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
item.idx,
frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
frappe.bold(item.item_code),
),
title=_("Stock Reservation"),
indicator="orange",
)
# Skip the item if `Partial Reservation` is disabled in the Stock Settings.
if not allow_partial_reservation:
continue
# Create and Submit Stock Reservation Entry
sre = frappe.new_doc("Stock Reservation Entry")
sre.item_code = item.item_code
sre.warehouse = item.warehouse
sre.voucher_type = self.doctype
sre.voucher_no = self.name
sre.voucher_detail_no = item.name
sre.available_qty = available_qty_to_reserve
sre.voucher_qty = item.stock_qty
sre.reserved_qty = qty_to_be_reserved
sre.company = self.company
sre.stock_uom = item.stock_uom
sre.project = self.project
sre.save()
sre.submit()
sre_count += 1
if sre_count and notify:
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
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
)
def get_list_context(context=None): def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context from erpnext.controllers.website_list_for_contact import get_list_context
@ -680,7 +857,6 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
} }
target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values) target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
target_doc.set_onload("ignore_price_list", True) target_doc.set_onload("ignore_price_list", True)
return target_doc return target_doc

View File

@ -11,6 +11,7 @@ def get_data():
"Payment Request": "reference_name", "Payment Request": "reference_name",
"Auto Repeat": "reference_document", "Auto Repeat": "reference_document",
"Maintenance Visit": "prevdoc_docname", "Maintenance Visit": "prevdoc_docname",
"Stock Reservation Entry": "voucher_no",
}, },
"internal_links": { "internal_links": {
"Quotation": ["items", "prevdoc_docname"], "Quotation": ["items", "prevdoc_docname"],
@ -23,7 +24,7 @@ def get_data():
{"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]}, {"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]},
{"label": _("Projects"), "items": ["Project"]}, {"label": _("Projects"), "items": ["Project"]},
{"label": _("Manufacturing"), "items": ["Work Order"]}, {"label": _("Manufacturing"), "items": ["Work Order"]},
{"label": _("Reference"), "items": ["Quotation", "Auto Repeat"]}, {"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]},
{"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]}, {"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]},
], ],
} }

View File

@ -1878,6 +1878,139 @@ class TestSalesOrder(FrappeTestCase):
self.assertEqual(pe.references[1].reference_name, so.name) self.assertEqual(pe.references[1].reference_name, so.name)
self.assertEqual(pe.references[1].allocated_amount, 300) self.assertEqual(pe.references[1].allocated_amount, 300)
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_stock_reservation_against_sales_order(self) -> None:
from random import randint, uniform
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
get_sre_reserved_qty_details_for_voucher,
get_stock_reservation_entries_for_voucher,
has_reserved_stock,
)
from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
create_items,
create_material_receipts,
)
items_details, warehouse = create_items(), "_Test Warehouse - _TC"
create_material_receipts(items_details, warehouse, qty=10)
item_list = []
for item_code, properties in items_details.items():
stock_uom = properties.stock_uom
item_list.append(
{
"item_code": item_code,
"warehouse": warehouse,
"qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3),
"uom": stock_uom,
"rate": randint(10, 200),
}
)
so = make_sales_order(
item_list=item_list,
warehouse="_Test Warehouse - _TC",
)
# Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
so.create_stock_reservation_entries()
self.assertFalse(has_reserved_stock("Sales Order", so.name))
# Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
so.create_stock_reservation_entries()
so.load_from_db()
self.assertTrue(has_reserved_stock("Sales Order", so.name))
for item in so.items:
sre_details = get_stock_reservation_entries_for_voucher(
"Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
)
self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty)
self.assertEqual(sre_details[0].status, "Partially Reserved")
# Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
create_material_receipts(items_details, warehouse, qty=100)
so.create_stock_reservation_entries()
so.load_from_db()
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
for item in so.items:
reserved_qty = reserved_qty_details[item.name]
self.assertEqual(item.stock_reserved_qty, reserved_qty)
self.assertEqual(item.stock_qty, item.stock_reserved_qty)
# Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
cancel_stock_reservation_entries("Sales Order", so.name)
so.load_from_db()
self.assertFalse(has_reserved_stock("Sales Order", so.name))
for item in so.items:
self.assertEqual(item.stock_reserved_qty, 0)
# Test - 5: Re-reserve the stock.
so.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so.name))
# Test - 6: Stock should get unreserved on cancellation of Sales Order.
so.cancel()
so.load_from_db()
self.assertFalse(has_reserved_stock("Sales Order", so.name))
for item in so.items:
self.assertEqual(item.stock_reserved_qty, 0)
# Create Sales Order and Reserve Stock.
so = make_sales_order(
item_list=item_list,
warehouse="_Test Warehouse - _TC",
)
so.create_stock_reservation_entries()
# Test - 7: Partial Delivery against Sales Order.
dn1 = make_delivery_note(so.name)
for item in dn1.items:
item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
dn1.save()
dn1.submit()
for item in so.items:
sre_details = get_stock_reservation_entries_for_voucher(
"Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
)
self.assertGreater(sre_details[0].delivered_qty, 0)
self.assertEqual(sre_details[0].status, "Partially Delivered")
# Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
dn2 = make_delivery_note(so.name)
for item in dn2.items:
item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
dn2.save()
dn2.submit()
for item in so.items:
sre_details = frappe.db.get_all(
"Stock Reservation Entry",
filters={
"voucher_type": "Sales Order",
"voucher_no": so.name,
"voucher_detail_no": item.name,
},
fields=["status", "reserved_qty", "delivered_qty"],
)
for sre_detail in sre_details:
self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
self.assertEqual(sre_detail.status, "Delivered")
def test_delivered_item_material_request(self): def test_delivered_item_material_request(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import ( from erpnext.manufacturing.doctype.work_order.work_order import (

View File

@ -10,6 +10,7 @@
"item_code", "item_code",
"customer_item_code", "customer_item_code",
"ensure_delivery_based_on_produced_serial_no", "ensure_delivery_based_on_produced_serial_no",
"reserve_stock",
"col_break1", "col_break1",
"delivery_date", "delivery_date",
"item_name", "item_name",
@ -27,6 +28,7 @@
"uom", "uom",
"conversion_factor", "conversion_factor",
"stock_qty", "stock_qty",
"stock_reserved_qty",
"section_break_16", "section_break_16",
"price_list_rate", "price_list_rate",
"base_price_list_rate", "base_price_list_rate",
@ -859,12 +861,33 @@
"fieldname": "material_request_item", "fieldname": "material_request_item",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Material Request Item" "label": "Material Request Item"
},
{
"allow_on_submit": 1,
"default": "1",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock",
"print_hide": 1,
"report_hide": 1
},
{
"default": "0",
"depends_on": "eval: doc.stock_reserved_qty",
"fieldname": "stock_reserved_qty",
"fieldtype": "Float",
"label": "Stock Reserved Qty (in Stock UOM)",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1,
"report_hide": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-12-25 02:51:10.247569", "modified": "2023-04-04 10:44:05.707488",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@ -151,6 +151,8 @@ class DeliveryNote(SellingController):
if not self.installation_status: if not self.installation_status:
self.installation_status = "Not Installed" self.installation_status = "Not Installed"
self.validate_against_stock_reservation_entries()
self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self): def validate_with_previous_doc(self):
@ -243,6 +245,8 @@ class DeliveryNote(SellingController):
self.update_prevdoc_status() self.update_prevdoc_status()
self.update_billing_status() self.update_billing_status()
self.update_stock_reservation_entries()
if not self.is_return: if not self.is_return:
self.check_credit_limit() self.check_credit_limit()
elif self.issue_credit_note: elif self.issue_credit_note:
@ -272,6 +276,90 @@ class DeliveryNote(SellingController):
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries."""
# Don't update Delivered Qty on Return or Cancellation.
if self.is_return or self._action == "cancel":
return
for item in self.get("items"):
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
if not item.against_sales_order or not item.so_detail:
continue
sre_list = frappe.db.get_all(
"Stock Reservation Entry",
{
"docstatus": 1,
"voucher_type": "Sales Order",
"voucher_no": item.against_sales_order,
"voucher_detail_no": item.so_detail,
"warehouse": item.warehouse,
"status": ["not in", ["Delivered", "Cancelled"]],
},
order_by="creation",
)
# Skip if no Stock Reservation Entries.
if not sre_list:
continue
available_qty_to_deliver = item.stock_qty
for sre in sre_list:
if available_qty_to_deliver <= 0:
break
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver)
sre_doc.delivered_qty += qty_to_be_deliver
sre_doc.db_update()
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
sre_doc.update_status()
available_qty_to_deliver -= qty_to_be_deliver
def validate_against_stock_reservation_entries(self):
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_details_for_voucher_detail_no,
)
# Don't validate if Return
if self.is_return:
return
for item in self.get("items"):
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
if not item.against_sales_order or not item.so_detail:
continue
sre_data = get_sre_reserved_qty_details_for_voucher_detail_no(
"Sales Order", item.against_sales_order, item.so_detail
)
# Skip if stock is not reserved.
if not sre_data:
continue
# Set `Warehouse` from SRE if not set.
if not item.warehouse:
item.warehouse = sre_data[0]
else:
# Throw if `Warehouse` is different from SRE.
if item.warehouse != sre_data[0]:
frappe.throw(
_("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
),
title=_("Stock Reservation Warehouse Mismatch"),
)
def check_credit_limit(self): def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.selling.doctype.customer.customer import check_credit_limit

View File

@ -47,6 +47,7 @@ class StockReconciliation(StockController):
self.validate_putaway_capacity() self.validate_putaway_capacity()
if self._action == "submit": if self._action == "submit":
self.validate_reserved_stock()
self.make_batches("warehouse") self.make_batches("warehouse")
def on_submit(self): def on_submit(self):
@ -60,6 +61,7 @@ class StockReconciliation(StockController):
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
self.validate_reserved_stock()
self.make_sle_on_cancel() self.make_sle_on_cancel()
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
@ -224,6 +226,46 @@ class StockReconciliation(StockController):
except Exception as e: except Exception as e:
self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e)) self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e))
def validate_reserved_stock(self) -> None:
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details,
)
item_code_list, warehouse_list = [], []
for item in self.items:
item_code_list.append(item.item_code)
warehouse_list.append(item.warehouse)
sre_reserved_qty_details = get_sre_reserved_qty_details(item_code_list, warehouse_list)
if sre_reserved_qty_details:
data = []
for (item_code, warehouse), reserved_qty in sre_reserved_qty_details.items():
data.append([item_code, warehouse, reserved_qty])
msg = ""
if len(data) == 1:
msg = _(
"{0} units are reserved for Item {1} in Warehouse {2}, please un-reserve the same to {3} the Stock Reconciliation."
).format(bold(data[0][2]), bold(data[0][0]), bold(data[0][1]), self._action)
else:
items_html = ""
for d in data:
items_html += "<li>{0} units of Item {1} in Warehouse {2}</li>".format(
bold(d[2]), bold(d[0]), bold(d[1])
)
msg = _(
"The stock has been reserved for the following Items and Warehouses, un-reserve the same to {0} the Stock Reconciliation: <br /><br /> {1}"
).format(self._action, items_html)
frappe.throw(
msg,
title=_("Stock Reservation"),
)
def update_stock_ledger(self): def update_stock_ledger(self):
"""find difference between current and expected entries """find difference between current and expected entries
and create stock ledger entries based on the difference""" and create stock ledger entries based on the difference"""

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Stock Reservation Entry", {
refresh(frm) {
frm.page.btn_primary.hide()
},
});

View File

@ -0,0 +1,234 @@
{
"actions": [],
"allow_copy": 1,
"autoname": "MAT-SRE-.YYYY.-.#####",
"creation": "2023-03-20 10:45:59.258959",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"warehouse",
"column_break_elik",
"voucher_type",
"voucher_no",
"voucher_detail_no",
"section_break_xt4m",
"available_qty",
"voucher_qty",
"stock_uom",
"column_break_o6ex",
"reserved_qty",
"delivered_qty",
"section_break_3vb3",
"company",
"column_break_jbyr",
"project",
"status",
"amended_from"
],
"fields": [
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_filter": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Item Code",
"oldfieldname": "item_code",
"oldfieldtype": "Link",
"options": "Item",
"print_width": "100px",
"read_only": 1,
"search_index": 1,
"width": "100px"
},
{
"fieldname": "warehouse",
"fieldtype": "Link",
"in_filter": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Warehouse",
"oldfieldname": "warehouse",
"oldfieldtype": "Link",
"options": "Warehouse",
"print_width": "100px",
"read_only": 1,
"search_index": 1,
"width": "100px"
},
{
"fieldname": "voucher_type",
"fieldtype": "Select",
"in_filter": 1,
"label": "Voucher Type",
"oldfieldname": "voucher_type",
"oldfieldtype": "Data",
"options": "\nSales Order",
"print_width": "150px",
"read_only": 1,
"width": "150px"
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_filter": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Voucher No",
"oldfieldname": "voucher_no",
"oldfieldtype": "Data",
"options": "voucher_type",
"print_width": "150px",
"read_only": 1,
"width": "150px"
},
{
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
"label": "Voucher Detail No",
"oldfieldname": "voucher_detail_no",
"oldfieldtype": "Data",
"print_width": "150px",
"read_only": 1,
"search_index": 1,
"width": "150px"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"oldfieldname": "stock_uom",
"oldfieldtype": "Data",
"options": "UOM",
"print_width": "150px",
"read_only": 1,
"width": "150px"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project",
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_filter": 1,
"label": "Company",
"oldfieldname": "company",
"oldfieldtype": "Data",
"options": "Company",
"print_width": "150px",
"read_only": 1,
"search_index": 1,
"width": "150px"
},
{
"fieldname": "reserved_qty",
"fieldtype": "Float",
"in_filter": 1,
"in_list_view": 1,
"label": "Reserved Qty",
"oldfieldname": "actual_qty",
"oldfieldtype": "Currency",
"print_width": "150px",
"read_only": 1,
"width": "150px"
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"hidden": 1,
"label": "Status",
"options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
"read_only": 1
},
{
"default": "0",
"fieldname": "delivered_qty",
"fieldtype": "Float",
"label": "Delivered Qty",
"read_only": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Stock Reservation Entry",
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "available_qty",
"fieldtype": "Float",
"label": "Available Qty to Reserve",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "voucher_qty",
"fieldtype": "Float",
"label": "Voucher Qty",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_elik",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_xt4m",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_o6ex",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_3vb3",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_jbyr",
"fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-03-29 18:36:26.752872",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reservation Entry",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,312 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
class StockReservationEntry(Document):
def validate(self) -> None:
from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
self.validate_mandatory()
self.validate_for_group_warehouse()
validate_disabled_warehouse(self.warehouse)
validate_warehouse_company(self.warehouse, self.company)
def on_submit(self) -> None:
self.update_reserved_qty_in_voucher()
self.update_status()
def on_cancel(self) -> None:
self.update_reserved_qty_in_voucher()
self.update_status()
def validate_mandatory(self) -> None:
"""Raises exception if mandatory fields are not set."""
mandatory = [
"item_code",
"warehouse",
"voucher_type",
"voucher_no",
"voucher_detail_no",
"available_qty",
"voucher_qty",
"stock_uom",
"reserved_qty",
"company",
]
for d in mandatory:
if not self.get(d):
frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
def validate_for_group_warehouse(self) -> None:
"""Raises exception if `Warehouse` is a Group Warehouse."""
if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"):
frappe.throw(
_("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)),
title=_("Invalid Warehouse"),
)
def update_status(self, status: str = None, update_modified: bool = True) -> None:
"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
if not status:
if self.docstatus == 2:
status = "Cancelled"
elif self.docstatus == 1:
if self.reserved_qty == self.delivered_qty:
status = "Delivered"
elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
status = "Partially Delivered"
elif self.reserved_qty == self.voucher_qty:
status = "Reserved"
else:
status = "Partially Reserved"
else:
status = "Draft"
frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified)
def update_reserved_qty_in_voucher(
self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
) -> None:
"""Updates total reserved qty in the voucher."""
item_doctype = "Sales Order Item" if self.voucher_type == "Sales Order" else None
if item_doctype:
sre = frappe.qb.DocType("Stock Reservation Entry")
reserved_qty = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty))
.where(
(sre.docstatus == 1)
& (sre.voucher_type == self.voucher_type)
& (sre.voucher_no == self.voucher_no)
& (sre.voucher_detail_no == self.voucher_detail_no)
)
).run(as_list=True)[0][0] or 0
frappe.db.set_value(
item_doctype,
self.voucher_detail_no,
reserved_qty_field,
reserved_qty,
update_modified=update_modified,
)
def validate_stock_reservation_settings(voucher: object) -> None:
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
frappe.throw(
_("Please enable {0} in the {1}.").format(
frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
)
)
# Voucher types allowed for stock reservation
allowed_voucher_types = ["Sales Order"]
if voucher.doctype not in allowed_voucher_types:
frappe.throw(
_("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types))
)
def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
"""Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
from erpnext.stock.utils import get_stock_balance
available_qty = get_stock_balance(item_code, warehouse)
if available_qty:
sre = frappe.qb.DocType("Stock Reservation Entry")
reserved_qty = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty - sre.delivered_qty))
.where(
(sre.docstatus == 1)
& (sre.item_code == item_code)
& (sre.warehouse == warehouse)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
).run()[0][0] or 0.0
if reserved_qty:
return available_qty - reserved_qty
return available_qty
def get_stock_reservation_entries_for_voucher(
voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None
) -> list[dict]:
"""Returns list of Stock Reservation Entries against a Voucher."""
if not fields or not isinstance(fields, list):
fields = [
"name",
"item_code",
"warehouse",
"voucher_detail_no",
"reserved_qty",
"delivered_qty",
"stock_uom",
]
sre = frappe.qb.DocType("Stock Reservation Entry")
query = (
frappe.qb.from_(sre)
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
& (sre.voucher_no == voucher_no)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
.orderby(sre.creation)
)
for field in fields:
query = query.select(sre[field])
if voucher_detail_no:
query = query.where(sre.voucher_detail_no == voucher_detail_no)
return query.run(as_dict=True)
def get_sre_reserved_qty_details_for_item_and_warehouse(
item_code_list: list, warehouse_list: list
) -> dict:
"""Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
sre_details = {}
if item_code_list and warehouse_list:
sre = frappe.qb.DocType("Stock Reservation Entry")
sre_data = (
frappe.qb.from_(sre)
.select(
sre.item_code,
sre.warehouse,
Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"),
)
.where(
(sre.docstatus == 1)
& (sre.item_code.isin(item_code_list))
& (sre.warehouse.isin(warehouse_list))
& (sre.status.notin(["Delivered", "Cancelled"]))
)
.groupby(sre.item_code, sre.warehouse)
).run(as_dict=True)
if sre_data:
sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data}
return sre_details
def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float:
"""Returns `Reserved Qty` for Item and Warehouse combination."""
reserved_qty = 0.0
if item_code and warehouse:
sre = frappe.qb.DocType("Stock Reservation Entry")
return (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty - sre.delivered_qty))
.where(
(sre.docstatus == 1)
& (sre.item_code == item_code)
& (sre.warehouse == warehouse)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
).run(as_list=True)[0][0] or 0.0
return reserved_qty
def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict:
"""Returns a dict like {"voucher_detail_no": "reserved_qty", ... }."""
sre = frappe.qb.DocType("Stock Reservation Entry")
data = (
frappe.qb.from_(sre)
.select(
sre.voucher_detail_no,
(Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"),
)
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
& (sre.voucher_no == voucher_no)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
.groupby(sre.voucher_detail_no)
).run(as_list=True)
return frappe._dict(data)
def get_sre_reserved_qty_details_for_voucher_detail_no(
voucher_type: str, voucher_no: str, voucher_detail_no: str
) -> list:
"""Returns a list like ["warehouse", "reserved_qty"]."""
sre = frappe.qb.DocType("Stock Reservation Entry")
reserved_qty_details = (
frappe.qb.from_(sre)
.select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)))
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
& (sre.voucher_no == voucher_no)
& (sre.voucher_detail_no == voucher_detail_no)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
.orderby(sre.creation)
.groupby(sre.warehouse)
).run(as_list=True)
if reserved_qty_details:
return reserved_qty_details[0]
return reserved_qty_details
def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool:
"""Returns True if there is any Stock Reservation Entry for the given voucher."""
if get_stock_reservation_entries_for_voucher(
voucher_type, voucher_no, voucher_detail_no, fields=["name"]
):
return True
return False
@frappe.whitelist()
def cancel_stock_reservation_entries(
voucher_type: str, voucher_no: str, voucher_detail_no: str = None, notify: bool = True
) -> None:
"""Cancel Stock Reservation Entries for the given voucher."""
sre_list = get_stock_reservation_entries_for_voucher(
voucher_type, voucher_no, voucher_detail_no, fields=["name"]
)
if sre_list:
for sre in sre_list:
frappe.get_doc("Stock Reservation Entry", sre.name).cancel()
if notify:
frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red")

View File

@ -0,0 +1,16 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.listview_settings['Stock Reservation Entry'] = {
get_indicator: function (doc) {
const status_colors = {
'Draft': 'red',
'Partially Reserved': 'orange',
'Reserved': 'blue',
'Partially Delivered': 'purple',
'Delivered': 'green',
'Cancelled': 'red',
};
return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status];
},
};

View File

@ -0,0 +1,317 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.utils import get_stock_balance
class TestStockReservationEntry(FrappeTestCase):
def setUp(self) -> None:
self.items = create_items()
create_material_receipts(self.items)
def tearDown(self) -> None:
return super().tearDown()
def test_validate_stock_reservation_settings(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
validate_stock_reservation_settings,
)
voucher = frappe._dict(
{
"doctype": "Sales Order",
}
)
# Case - 1: When `Stock Reservation` is disabled in `Stock Settings`, throw `ValidationError`
with change_settings("Stock Settings", {"enable_stock_reservation": 0}):
self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
with change_settings("Stock Settings", {"enable_stock_reservation": 1}):
# Case - 2: When `Voucher Type` is not allowed for `Stock Reservation`, throw `ValidationError`
voucher.doctype = "NOT ALLOWED"
self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
# Case - 3: When `Voucher Type` is allowed for `Stock Reservation`
voucher.doctype = "Sales Order"
self.assertIsNone(validate_stock_reservation_settings(voucher), None)
def test_get_available_qty_to_reserve(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_available_qty_to_reserve,
)
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
# Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty
cancel_all_stock_reservation_entries()
available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
# Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
sre = make_stock_reservation_entry(
item_code=item_code,
warehouse=warehouse,
ignore_validate=True,
)
available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
def test_update_status(self) -> None:
sre = make_stock_reservation_entry(
reserved_qty=30,
ignore_validate=True,
do_not_submit=True,
)
# Draft: When DocStatus is `0`
sre.load_from_db()
self.assertEqual(sre.status, "Draft")
# Partially Reserved: When DocStatus is `1` and `Reserved Qty` < `Voucher Qty`
sre.submit()
sre.load_from_db()
self.assertEqual(sre.status, "Partially Reserved")
# Reserved: When DocStatus is `1` and `Reserved Qty` = `Voucher Qty`
sre.reserved_qty = sre.voucher_qty
sre.db_update()
sre.update_status()
sre.load_from_db()
self.assertEqual(sre.status, "Reserved")
# Partially Delivered: When DocStatus is `1` and (0 < `Delivered Qty` < `Voucher Qty`)
sre.delivered_qty = 10
sre.db_update()
sre.update_status()
sre.load_from_db()
self.assertEqual(sre.status, "Partially Delivered")
# Delivered: When DocStatus is `1` and `Delivered Qty` = `Voucher Qty`
sre.delivered_qty = sre.voucher_qty
sre.db_update()
sre.update_status()
sre.load_from_db()
self.assertEqual(sre.status, "Delivered")
# Cancelled: When DocStatus is `2`
sre.cancel()
sre.load_from_db()
self.assertEqual(sre.status, "Cancelled")
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_update_reserved_qty_in_voucher(self) -> None:
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
# Step - 1: Create a `Sales Order`
so = make_sales_order(
item_code=item_code,
warehouse=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=item_code,
warehouse=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=item_code,
warehouse=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: 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)
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_cant_consume_reserved_stock(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
)
from erpnext.stock.stock_ledger import NegativeStockError
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
# Step - 1: Create a `Sales Order`
so = make_sales_order(
item_code=item_code,
warehouse=warehouse,
qty=50,
rate=100,
do_not_submit=True,
)
so.reserve_stock = 1 # Stock Reservation Entries will be created on submit
so.items[0].reserve_stock = 1
so.save()
so.submit()
actual_qty = get_stock_balance(item_code, warehouse)
# Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
se = make_stock_entry(
item_code=item_code,
qty=actual_qty,
from_warehouse=warehouse,
rate=100,
purpose="Material Issue",
do_not_submit=True,
)
self.assertRaises(NegativeStockError, se.submit)
se.cancel()
# Step - 3: Unreserve the stock and consume the Available Qty via Stock Entry.
cancel_stock_reservation_entries(so.doctype, so.name)
se = make_stock_entry(
item_code=item_code,
qty=actual_qty,
from_warehouse=warehouse,
rate=100,
purpose="Material Issue",
do_not_submit=True,
)
se.submit()
se.cancel()
def create_items() -> dict:
from erpnext.stock.doctype.item.test_item import make_item
items_details = {
# Stock Items
"SR Item 1": {"is_stock_item": 1, "valuation_rate": 100},
"SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"},
# Batch Items
"SR Batch Item 1": {
"is_stock_item": 1,
"valuation_rate": 100,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBI-1-.#####.",
},
"SR Batch Item 2": {
"is_stock_item": 1,
"valuation_rate": 200,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBI-2-.#####.",
"stock_uom": "Kg",
},
# Serial Item
"SR Serial Item 1": {
"is_stock_item": 1,
"valuation_rate": 100,
"has_serial_no": 1,
"serial_no_series": "SRSI-1-.#####",
},
# Batch and Serial Item
"SR Batch and Serial Item 1": {
"is_stock_item": 1,
"valuation_rate": 100,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBSI-1-.#####.",
"has_serial_no": 1,
"serial_no_series": "SRBSI-1-.#####",
},
}
items = {}
for item_code, properties in items_details.items():
items[item_code] = make_item(item_code, properties)
return items
def create_material_receipts(
items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
) -> None:
for item in items.values():
if item.is_stock_item:
make_stock_entry(
item_code=item.item_code,
qty=qty,
to_warehouse=warehouse,
rate=item.valuation_rate,
purpose="Material Receipt",
)
def cancel_all_stock_reservation_entries() -> None:
sre_list = frappe.db.get_all("Stock Reservation Entry", filters={"docstatus": 1}, pluck="name")
for sre in sre_list:
frappe.get_doc("Stock Reservation Entry", sre).cancel()
def make_stock_reservation_entry(**args):
doc = frappe.new_doc("Stock Reservation Entry")
args = frappe._dict(args)
doc.item_code = args.item_code or "SR Item 1"
doc.warehouse = args.warehouse or "_Test Warehouse - _TC"
doc.voucher_type = args.voucher_type
doc.voucher_no = args.voucher_no
doc.voucher_detail_no = args.voucher_detail_no
doc.available_qty = args.available_qty or 100
doc.voucher_qty = args.voucher_qty or 50
doc.stock_uom = args.stock_uom or "Nos"
doc.reserved_qty = args.reserved_qty or 50
doc.delivered_qty = args.delivered_qty or 0
doc.company = args.company or "_Test Company"
if args.ignore_validate:
doc.flags.ignore_validate = True
if not args.do_not_save:
doc.save()
if not args.do_not_submit:
doc.submit()
return doc

View File

@ -31,6 +31,11 @@
"action_if_quality_inspection_is_not_submitted", "action_if_quality_inspection_is_not_submitted",
"column_break_23", "column_break_23",
"action_if_quality_inspection_is_rejected", "action_if_quality_inspection_is_rejected",
"stock_reservation_tab",
"enable_stock_reservation",
"column_break_rx3e",
"reserve_stock_on_sales_order_submission",
"allow_partial_reservation",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"section_break_7", "section_break_7",
"automatically_set_serial_nos_based_on_fifo", "automatically_set_serial_nos_based_on_fifo",
@ -339,6 +344,37 @@
{ {
"fieldname": "column_break_121", "fieldname": "column_break_121",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "stock_reservation_tab",
"fieldtype": "Tab Break",
"label": "Stock Reservation"
},
{
"default": "0",
"fieldname": "enable_stock_reservation",
"fieldtype": "Check",
"label": "Enable Stock Reservation"
},
{
"default": "0",
"depends_on": "eval: doc.enable_stock_reservation",
"description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
"fieldname": "reserve_stock_on_sales_order_submission",
"fieldtype": "Check",
"label": "Reserve Stock on Sales Order Submission"
},
{
"fieldname": "column_break_rx3e",
"fieldtype": "Column Break"
},
{
"default": "1",
"depends_on": "eval: doc.enable_stock_reservation",
"description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a <b>Sales Order</b> of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
"fieldname": "allow_partial_reservation",
"fieldtype": "Check",
"label": "Allow Partial Reservation"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@ -346,7 +382,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-02-05 15:33:43.692736", "modified": "2023-04-22 08:48:37.767646",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@ -55,6 +55,7 @@ class StockSettings(Document):
self.cant_change_valuation_method() self.cant_change_valuation_method()
self.validate_clean_description_html() self.validate_clean_description_html()
self.validate_pending_reposts() self.validate_pending_reposts()
self.validate_stock_reservation()
def validate_warehouses(self): def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
@ -99,6 +100,74 @@ class StockSettings(Document):
if self.stock_frozen_upto: if self.stock_frozen_upto:
check_pending_reposting(self.stock_frozen_upto) check_pending_reposting(self.stock_frozen_upto)
def validate_stock_reservation(self):
"""Raises an exception if the user tries to enable/disable `Stock Reservation` with `Negative Stock` or `Open Stock Reservation Entries`."""
# Skip validation for tests
if frappe.flags.in_test:
return
db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
db_enable_stock_reservation = frappe.db.get_single_value(
"Stock Settings", "enable_stock_reservation"
)
# Change in value of `Allow Negative Stock`
if db_allow_negative_stock != self.allow_negative_stock:
# Disable -> Enable: Don't allow if `Stock Reservation` is enabled
if self.allow_negative_stock and self.enable_stock_reservation:
frappe.throw(
_("As {0} is enabled, you can not enable {1}.").format(
frappe.bold("Stock Reservation"), frappe.bold("Allow Negative Stock")
)
)
# Change in value of `Enable Stock Reservation`
if db_enable_stock_reservation != self.enable_stock_reservation:
# Disable -> Enable
if self.enable_stock_reservation:
# Don't allow if `Allow Negative Stock` is enabled
if self.allow_negative_stock:
frappe.throw(
_("As {0} is enabled, you can not enable {1}.").format(
frappe.bold("Allow Negative Stock"), frappe.bold("Stock Reservation")
)
)
else:
# Don't allow if there are negative stock
from frappe.query_builder.functions import Round
precision = frappe.db.get_single_value("System Settings", "float_precision") or 3
bin = frappe.qb.DocType("Bin")
bin_with_negative_stock = (
frappe.qb.from_(bin).select(bin.name).where(Round(bin.actual_qty, precision) < 0).limit(1)
).run()
if bin_with_negative_stock:
frappe.throw(
_("As there are negative stock, you can not enable {0}.").format(
frappe.bold("Stock Reservation")
)
)
# Enable -> Disable
else:
# Don't allow if there are open Stock Reservation Entries
has_reserved_stock = frappe.db.exists(
"Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]}
)
if has_reserved_stock:
frappe.throw(
_("As there are reserved stock, you cannot disable {0}.").format(
frappe.bold("Stock Reservation")
)
)
def on_update(self): def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer() self.toggle_warehouse_field_for_inter_warehouse_transfer()

View File

@ -100,6 +100,7 @@ class StockBalanceReport(object):
_func = itemgetter(1) _func = itemgetter(1)
self.item_warehouse_map = self.get_item_warehouse_map() self.item_warehouse_map = self.get_item_warehouse_map()
sre_details = self.get_sre_reserved_qty_details()
variant_values = {} variant_values = {}
if self.filters.get("show_variant_attributes"): if self.filters.get("show_variant_attributes"):
@ -133,6 +134,9 @@ class StockBalanceReport(object):
report_data.update(stock_ageing_data) report_data.update(stock_ageing_data)
report_data.update(
{"reserved_stock": sre_details.get((report_data.item_code, report_data.warehouse), 0.0)}
)
self.data.append(report_data) self.data.append(report_data)
def get_item_warehouse_map(self): def get_item_warehouse_map(self):
@ -159,6 +163,18 @@ class StockBalanceReport(object):
return item_warehouse_map return item_warehouse_map
def get_sre_reserved_qty_details(self) -> dict:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details,
)
item_code_list, warehouse_list = [], []
for d in self.item_warehouse_map:
item_code_list.append(d[1])
warehouse_list.append(d[2])
return get_reserved_qty_details(item_code_list, warehouse_list)
def prepare_item_warehouse_map(self, item_warehouse_map, entry, group_by_key): def prepare_item_warehouse_map(self, item_warehouse_map, entry, group_by_key):
qty_dict = item_warehouse_map[group_by_key] qty_dict = item_warehouse_map[group_by_key]
for field in self.inventory_dimensions: for field in self.inventory_dimensions:
@ -435,6 +451,13 @@ class StockBalanceReport(object):
"convertible": "rate", "convertible": "rate",
"options": "currency", "options": "currency",
}, },
{
"label": _("Reserved Stock"),
"fieldname": "reserved_stock",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{ {
"label": _("Company"), "label": _("Company"),
"fieldname": "company", "fieldname": "company",

View File

@ -13,6 +13,9 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdat
import erpnext import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
)
from erpnext.stock.utils import ( from erpnext.stock.utils import (
get_incoming_outgoing_rate_for_cancel, get_incoming_outgoing_rate_for_cancel,
get_or_make_bin, get_or_make_bin,
@ -380,6 +383,7 @@ class update_entries_after(object):
self.new_items_found = False self.new_items_found = False
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
self.affected_transactions: Set[Tuple[str, str]] = set() self.affected_transactions: Set[Tuple[str, str]] = set()
self.reserved_stock = get_reserved_stock(self.args.item_code, self.args.warehouse)
self.data = frappe._dict() self.data = frappe._dict()
self.initialize_previous_data(self.args) self.initialize_previous_data(self.args)
@ -628,7 +632,7 @@ class update_entries_after(object):
validate negative stock for entries current datetime onwards validate negative stock for entries current datetime onwards
will not consider cancelled entries will not consider cancelled entries
""" """
diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) - flt(self.reserved_stock)
diff = flt(diff, self.flt_precision) # respect system precision diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001: if diff < 0 and abs(diff) > 0.0001:
@ -1039,7 +1043,7 @@ class update_entries_after(object):
) in frappe.local.flags.currently_saving: ) in frappe.local.flags.currently_saving:
msg = _("{0} units of {1} needed in {2} to complete this transaction.").format( msg = _("{0} units of {1} needed in {2} to complete this transaction.").format(
abs(deficiency), frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]), frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse), frappe.get_desk_link("Warehouse", warehouse),
) )
@ -1047,7 +1051,7 @@ class update_entries_after(object):
msg = _( msg = _(
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
).format( ).format(
abs(deficiency), frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]), frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse), frappe.get_desk_link("Warehouse", warehouse),
exceptions[0]["posting_date"], exceptions[0]["posting_date"],
@ -1056,6 +1060,12 @@ class update_entries_after(object):
) )
if msg: if msg:
if self.reserved_stock:
allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"])
msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format(
msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
)
msg_list.append(msg) msg_list.append(msg)
if msg_list: if msg_list: