Merge pull request #34805 from s-aga-r/stock-reservation
feat: Stock Reservation against Sales Order
This commit is contained in:
commit
c3fa1f7450
@ -2826,6 +2826,17 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
parent.update_billing_percentage()
|
||||
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
|
||||
def validate_regional(doc):
|
||||
|
@ -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_delete_rows', true);
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed'
|
||||
&& flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) {
|
||||
frm.add_custom_button(__('Update Items'), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
frm: frm,
|
||||
child_docname: "items",
|
||||
child_doctype: "Sales Order Detail",
|
||||
cannot_add_row: false,
|
||||
})
|
||||
});
|
||||
if(frm.doc.docstatus === 1) {
|
||||
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'), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
frm: frm,
|
||||
child_docname: "items",
|
||||
child_doctype: "Sales Order Detail",
|
||||
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) {
|
||||
frm.events.get_items_from_internal_purchase_order(frm);
|
||||
if (frm.doc.docstatus === 0) {
|
||||
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;
|
||||
});
|
||||
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();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -42,6 +42,7 @@
|
||||
"scan_barcode",
|
||||
"column_break_28",
|
||||
"set_warehouse",
|
||||
"reserve_stock",
|
||||
"items_section",
|
||||
"items",
|
||||
"section_break_31",
|
||||
@ -1625,13 +1626,24 @@
|
||||
"fieldname": "named_place",
|
||||
"fieldtype": "Data",
|
||||
"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",
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-04-20 11:14:01.036202",
|
||||
"modified": "2023-04-22 09:55:37.008190",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
@ -1664,7 +1676,6 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"set_user_permissions": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
|
@ -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.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.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.stock_balance import get_reserved_qty, update_bin_qty
|
||||
|
||||
@ -44,6 +49,14 @@ class SalesOrder(SellingController):
|
||||
def __init__(self, *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):
|
||||
super(SalesOrder, self).validate()
|
||||
self.validate_delivery_date()
|
||||
@ -241,6 +254,9 @@ class SalesOrder(SellingController):
|
||||
|
||||
update_coupon_code_count(self.coupon_code, "used")
|
||||
|
||||
if self.get("reserve_stock"):
|
||||
self.create_stock_reservation_entries()
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
|
||||
super(SalesOrder, self).on_cancel()
|
||||
@ -257,6 +273,7 @@ class SalesOrder(SellingController):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
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)
|
||||
if self.coupon_code:
|
||||
@ -485,6 +502,166 @@ class SalesOrder(SellingController):
|
||||
).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):
|
||||
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.set_onload("ignore_price_list", True)
|
||||
|
||||
return target_doc
|
||||
|
@ -11,6 +11,7 @@ def get_data():
|
||||
"Payment Request": "reference_name",
|
||||
"Auto Repeat": "reference_document",
|
||||
"Maintenance Visit": "prevdoc_docname",
|
||||
"Stock Reservation Entry": "voucher_no",
|
||||
},
|
||||
"internal_links": {
|
||||
"Quotation": ["items", "prevdoc_docname"],
|
||||
@ -23,7 +24,7 @@ def get_data():
|
||||
{"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]},
|
||||
{"label": _("Projects"), "items": ["Project"]},
|
||||
{"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"]},
|
||||
],
|
||||
}
|
||||
|
@ -1878,6 +1878,139 @@ class TestSalesOrder(FrappeTestCase):
|
||||
self.assertEqual(pe.references[1].reference_name, so.name)
|
||||
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):
|
||||
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
|
@ -10,6 +10,7 @@
|
||||
"item_code",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"reserve_stock",
|
||||
"col_break1",
|
||||
"delivery_date",
|
||||
"item_name",
|
||||
@ -27,6 +28,7 @@
|
||||
"uom",
|
||||
"conversion_factor",
|
||||
"stock_qty",
|
||||
"stock_reserved_qty",
|
||||
"section_break_16",
|
||||
"price_list_rate",
|
||||
"base_price_list_rate",
|
||||
@ -859,12 +861,33 @@
|
||||
"fieldname": "material_request_item",
|
||||
"fieldtype": "Data",
|
||||
"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,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-12-25 02:51:10.247569",
|
||||
"modified": "2023-04-04 10:44:05.707488",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
@ -151,6 +151,8 @@ class DeliveryNote(SellingController):
|
||||
|
||||
if not self.installation_status:
|
||||
self.installation_status = "Not Installed"
|
||||
|
||||
self.validate_against_stock_reservation_entries()
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
@ -243,6 +245,8 @@ class DeliveryNote(SellingController):
|
||||
self.update_prevdoc_status()
|
||||
self.update_billing_status()
|
||||
|
||||
self.update_stock_reservation_entries()
|
||||
|
||||
if not self.is_return:
|
||||
self.check_credit_limit()
|
||||
elif self.issue_credit_note:
|
||||
@ -272,6 +276,90 @@ class DeliveryNote(SellingController):
|
||||
self.repost_future_sle_and_gle()
|
||||
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):
|
||||
from erpnext.selling.doctype.customer.customer import check_credit_limit
|
||||
|
||||
|
@ -47,6 +47,7 @@ class StockReconciliation(StockController):
|
||||
self.validate_putaway_capacity()
|
||||
|
||||
if self._action == "submit":
|
||||
self.validate_reserved_stock()
|
||||
self.make_batches("warehouse")
|
||||
|
||||
def on_submit(self):
|
||||
@ -60,6 +61,7 @@ class StockReconciliation(StockController):
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
|
||||
self.validate_reserved_stock()
|
||||
self.make_sle_on_cancel()
|
||||
self.make_gl_entries_on_cancel()
|
||||
self.repost_future_sle_and_gle()
|
||||
@ -224,6 +226,46 @@ class StockReconciliation(StockController):
|
||||
except Exception as 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):
|
||||
"""find difference between current and expected entries
|
||||
and create stock ledger entries based on the difference"""
|
||||
|
@ -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()
|
||||
},
|
||||
});
|
@ -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": []
|
||||
}
|
@ -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")
|
@ -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];
|
||||
},
|
||||
};
|
@ -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
|
@ -31,6 +31,11 @@
|
||||
"action_if_quality_inspection_is_not_submitted",
|
||||
"column_break_23",
|
||||
"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",
|
||||
"section_break_7",
|
||||
"automatically_set_serial_nos_based_on_fifo",
|
||||
@ -339,6 +344,37 @@
|
||||
{
|
||||
"fieldname": "column_break_121",
|
||||
"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",
|
||||
@ -346,7 +382,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-02-05 15:33:43.692736",
|
||||
"modified": "2023-04-22 08:48:37.767646",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
@ -55,6 +55,7 @@ class StockSettings(Document):
|
||||
self.cant_change_valuation_method()
|
||||
self.validate_clean_description_html()
|
||||
self.validate_pending_reposts()
|
||||
self.validate_stock_reservation()
|
||||
|
||||
def validate_warehouses(self):
|
||||
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
|
||||
@ -99,6 +100,74 @@ class StockSettings(Document):
|
||||
if 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):
|
||||
self.toggle_warehouse_field_for_inter_warehouse_transfer()
|
||||
|
||||
|
@ -100,6 +100,7 @@ class StockBalanceReport(object):
|
||||
_func = itemgetter(1)
|
||||
|
||||
self.item_warehouse_map = self.get_item_warehouse_map()
|
||||
sre_details = self.get_sre_reserved_qty_details()
|
||||
|
||||
variant_values = {}
|
||||
if self.filters.get("show_variant_attributes"):
|
||||
@ -133,6 +134,9 @@ class StockBalanceReport(object):
|
||||
|
||||
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)
|
||||
|
||||
def get_item_warehouse_map(self):
|
||||
@ -159,6 +163,18 @@ class StockBalanceReport(object):
|
||||
|
||||
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):
|
||||
qty_dict = item_warehouse_map[group_by_key]
|
||||
for field in self.inventory_dimensions:
|
||||
@ -435,6 +451,13 @@ class StockBalanceReport(object):
|
||||
"convertible": "rate",
|
||||
"options": "currency",
|
||||
},
|
||||
{
|
||||
"label": _("Reserved Stock"),
|
||||
"fieldname": "reserved_stock",
|
||||
"fieldtype": "Float",
|
||||
"width": 80,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Company"),
|
||||
"fieldname": "company",
|
||||
|
@ -13,6 +13,9 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdat
|
||||
|
||||
import erpnext
|
||||
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 (
|
||||
get_incoming_outgoing_rate_for_cancel,
|
||||
get_or_make_bin,
|
||||
@ -380,6 +383,7 @@ class update_entries_after(object):
|
||||
self.new_items_found = False
|
||||
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
|
||||
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.initialize_previous_data(self.args)
|
||||
@ -628,7 +632,7 @@ class update_entries_after(object):
|
||||
validate negative stock for entries current datetime onwards
|
||||
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
|
||||
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
@ -1039,7 +1043,7 @@ class update_entries_after(object):
|
||||
) in frappe.local.flags.currently_saving:
|
||||
|
||||
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("Warehouse", warehouse),
|
||||
)
|
||||
@ -1047,7 +1051,7 @@ class update_entries_after(object):
|
||||
msg = _(
|
||||
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
|
||||
).format(
|
||||
abs(deficiency),
|
||||
frappe.bold(abs(deficiency)),
|
||||
frappe.get_desk_link("Item", exceptions[0]["item_code"]),
|
||||
frappe.get_desk_link("Warehouse", warehouse),
|
||||
exceptions[0]["posting_date"],
|
||||
@ -1056,6 +1060,12 @@ class update_entries_after(object):
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if msg_list:
|
||||
|
Loading…
x
Reference in New Issue
Block a user