fix: miscellaneous
fix: don't reserve stock in group warehouse fix: partial reservation in multiple warehouses feat: add prompt to select warehouse and qty for reservation in SO
This commit is contained in:
parent
388f85b109
commit
bf4a57a37c
@ -169,19 +169,82 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create_stock_reservation_entries(frm) {
|
create_stock_reservation_entries(frm) {
|
||||||
frappe.call({
|
let items_data = [];
|
||||||
doc: frm.doc,
|
|
||||||
method: 'create_stock_reservation_entries',
|
const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
|
||||||
args: {
|
fields: [
|
||||||
notify: true
|
{
|
||||||
},
|
fieldtype: 'Data',
|
||||||
freeze: true,
|
fieldname: 'name',
|
||||||
freeze_message: __('Reserving Stock...'),
|
label: __('Name'),
|
||||||
callback: (r) => {
|
reqd: 1,
|
||||||
frm.doc.__onload.has_unreserved_stock = false;
|
read_only: 1,
|
||||||
frm.refresh();
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) {
|
cancel_stock_reservation_entries(frm) {
|
||||||
@ -195,7 +258,7 @@ frappe.ui.form.on("Sales Order", {
|
|||||||
freeze_message: __('Unreserving Stock...'),
|
freeze_message: __('Unreserving Stock...'),
|
||||||
callback: (r) => {
|
callback: (r) => {
|
||||||
frm.doc.__onload.has_reserved_stock = false;
|
frm.doc.__onload.has_reserved_stock = false;
|
||||||
frm.refresh();
|
frm.reload_doc();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -518,7 +518,7 @@ class SalesOrder(SellingController):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_stock_reservation_entries(self, notify=True):
|
def create_stock_reservation_entries(self, items_details=None, notify=True):
|
||||||
"""Creates Stock Reservation Entries for Sales Order Items."""
|
"""Creates Stock Reservation Entries for Sales Order Items."""
|
||||||
|
|
||||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
@ -532,9 +532,18 @@ class SalesOrder(SellingController):
|
|||||||
"Stock Settings", "allow_partial_reservation"
|
"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
|
sre_count = 0
|
||||||
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
|
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
|
||||||
for item in self.get("items"):
|
for item in items or self.get("items"):
|
||||||
# Skip if `Reserved Stock` is not checked for the item.
|
# Skip if `Reserved Stock` is not checked for the item.
|
||||||
if not item.get("reserve_stock"):
|
if not item.get("reserve_stock"):
|
||||||
continue
|
continue
|
||||||
@ -551,15 +560,27 @@ class SalesOrder(SellingController):
|
|||||||
item.db_set("reserve_stock", 0)
|
item.db_set("reserve_stock", 0)
|
||||||
continue
|
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)
|
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
|
||||||
|
|
||||||
# Stock is already reserved for the item, notify the user and skip the item.
|
# Stock is already reserved for the item, notify the user and skip the item.
|
||||||
if unreserved_qty <= 0:
|
if unreserved_qty <= 0:
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
_("Row #{0}: Stock is already reserved for the Item {1} in Warehouse {2}.").format(
|
_("Row #{0}: Stock is already reserved for the Item {1}.").format(
|
||||||
item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
|
item.idx, frappe.bold(item.item_code)
|
||||||
),
|
),
|
||||||
title=_("Stock Reservation"),
|
title=_("Stock Reservation"),
|
||||||
|
indicator="yellow",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -579,17 +600,31 @@ class SalesOrder(SellingController):
|
|||||||
# The quantity which can be reserved.
|
# The quantity which can be reserved.
|
||||||
qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
|
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
|
# Partial Reservation
|
||||||
if qty_to_be_reserved < unreserved_qty:
|
if qty_to_be_reserved < unreserved_qty:
|
||||||
frappe.msgprint(
|
if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
|
||||||
_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
|
frappe.msgprint(
|
||||||
item.idx,
|
_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
|
||||||
frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
|
item.idx,
|
||||||
frappe.bold(item.item_code),
|
frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
|
||||||
),
|
frappe.bold(item.item_code),
|
||||||
title=_("Stock Reservation"),
|
),
|
||||||
indicator="orange",
|
title=_("Stock Reservation"),
|
||||||
)
|
indicator="orange",
|
||||||
|
)
|
||||||
|
|
||||||
# Skip the item if `Partial Reservation` is disabled in the Stock Settings.
|
# Skip the item if `Partial Reservation` is disabled in the Stock Settings.
|
||||||
if not allow_partial_reservation:
|
if not allow_partial_reservation:
|
||||||
@ -620,7 +655,7 @@ class SalesOrder(SellingController):
|
|||||||
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
|
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
|
||||||
"""Returns the unreserved quantity for the Sales Order Item."""
|
"""Returns the unreserved quantity for the Sales Order Item."""
|
||||||
|
|
||||||
existing_reserved_qty = reserved_qty_details.get((item.name, item.warehouse), 0)
|
existing_reserved_qty = reserved_qty_details.get(item.name, 0)
|
||||||
return (
|
return (
|
||||||
item.stock_qty
|
item.stock_qty
|
||||||
- flt(item.delivered_qty) * item.get("conversion_factor", 1)
|
- flt(item.delivered_qty) * item.get("conversion_factor", 1)
|
||||||
|
|||||||
@ -1939,7 +1939,7 @@ class TestSalesOrder(FrappeTestCase):
|
|||||||
|
|
||||||
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
|
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
|
||||||
for item in so.items:
|
for item in so.items:
|
||||||
reserved_qty = reserved_qty_details[(item.name, item.warehouse)]
|
reserved_qty = reserved_qty_details[item.name]
|
||||||
self.assertEqual(item.stock_reserved_qty, reserved_qty)
|
self.assertEqual(item.stock_reserved_qty, reserved_qty)
|
||||||
self.assertEqual(item.stock_qty, item.stock_reserved_qty)
|
self.assertEqual(item.stock_qty, item.stock_reserved_qty)
|
||||||
|
|
||||||
|
|||||||
@ -343,31 +343,18 @@ class DeliveryNote(SellingController):
|
|||||||
if not sre_data:
|
if not sre_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_group_warehouse = frappe.get_cached_value("Warehouse", sre_data[0], "is_group")
|
# Set `Warehouse` from SRE if not set.
|
||||||
|
|
||||||
if not item.warehouse:
|
if not item.warehouse:
|
||||||
if not is_group_warehouse:
|
item.warehouse = sre_data[0]
|
||||||
item.warehouse = sre_data[0]
|
|
||||||
else:
|
|
||||||
frappe.throw(_("Row #{0}: Warehouse is mandatory").format(item.idx, item.item_code))
|
|
||||||
else:
|
else:
|
||||||
if not is_group_warehouse:
|
# Throw if `Warehouse` is different from SRE.
|
||||||
if item.warehouse != sre_data[0]:
|
if item.warehouse != sre_data[0]:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]),
|
_("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
|
||||||
title=_("Stock Reservation Warehouse Mismatch"),
|
item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
|
||||||
)
|
),
|
||||||
else:
|
title=_("Stock Reservation Warehouse Mismatch"),
|
||||||
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
|
)
|
||||||
|
|
||||||
warehouses = get_child_warehouses(sre_data[0])
|
|
||||||
if item.warehouse not in warehouses:
|
|
||||||
frappe.throw(
|
|
||||||
_(
|
|
||||||
"Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse"
|
|
||||||
).format(item.idx, sre_data[0]),
|
|
||||||
title=_("Stock Reservation Group Warehouse"),
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -12,6 +12,7 @@ class StockReservationEntry(Document):
|
|||||||
from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
|
from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
|
||||||
|
|
||||||
self.validate_mandatory()
|
self.validate_mandatory()
|
||||||
|
self.validate_for_group_warehouse()
|
||||||
validate_disabled_warehouse(self.warehouse)
|
validate_disabled_warehouse(self.warehouse)
|
||||||
validate_warehouse_company(self.warehouse, self.company)
|
validate_warehouse_company(self.warehouse, self.company)
|
||||||
|
|
||||||
@ -42,6 +43,15 @@ class StockReservationEntry(Document):
|
|||||||
if not self.get(d):
|
if not self.get(d):
|
||||||
frappe.throw(_("{0} is required").format(self.meta.get_label(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:
|
def update_status(self, status: str = None, update_modified: bool = True) -> None:
|
||||||
"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
|
"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
|
||||||
|
|
||||||
@ -113,16 +123,11 @@ def validate_stock_reservation_settings(voucher: object) -> None:
|
|||||||
def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
|
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."""
|
"""Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
|
||||||
|
|
||||||
from erpnext.stock.get_item_details import get_bin_details
|
from erpnext.stock.utils import get_stock_balance
|
||||||
|
|
||||||
available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get(
|
available_qty = get_stock_balance(item_code, warehouse)
|
||||||
"actual_qty"
|
|
||||||
)
|
|
||||||
|
|
||||||
if available_qty:
|
if available_qty:
|
||||||
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
|
|
||||||
|
|
||||||
warehouses = get_child_warehouses(warehouse)
|
|
||||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||||
reserved_qty = (
|
reserved_qty = (
|
||||||
frappe.qb.from_(sre)
|
frappe.qb.from_(sre)
|
||||||
@ -130,7 +135,7 @@ def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
|
|||||||
.where(
|
.where(
|
||||||
(sre.docstatus == 1)
|
(sre.docstatus == 1)
|
||||||
& (sre.item_code == item_code)
|
& (sre.item_code == item_code)
|
||||||
& (sre.warehouse.isin(warehouses))
|
& (sre.warehouse == warehouse)
|
||||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||||
)
|
)
|
||||||
).run()[0][0] or 0.0
|
).run()[0][0] or 0.0
|
||||||
@ -230,19 +235,14 @@ def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str)
|
|||||||
return reserved_qty
|
return reserved_qty
|
||||||
|
|
||||||
|
|
||||||
def get_sre_reserved_qty_details_for_voucher(
|
def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict:
|
||||||
voucher_type: str, voucher_no: str, voucher_detail_no: str = None
|
"""Returns a dict like {"voucher_detail_no": "reserved_qty", ... }."""
|
||||||
) -> dict:
|
|
||||||
"""Returns a dict like {("voucher_detail_no", "warehouse"): "reserved_qty", ... }."""
|
|
||||||
|
|
||||||
reserved_qty_details = {}
|
|
||||||
|
|
||||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||||
query = (
|
data = (
|
||||||
frappe.qb.from_(sre)
|
frappe.qb.from_(sre)
|
||||||
.select(
|
.select(
|
||||||
sre.voucher_detail_no,
|
sre.voucher_detail_no,
|
||||||
sre.warehouse,
|
|
||||||
(Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"),
|
(Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
@ -251,18 +251,10 @@ def get_sre_reserved_qty_details_for_voucher(
|
|||||||
& (sre.voucher_no == voucher_no)
|
& (sre.voucher_no == voucher_no)
|
||||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||||
)
|
)
|
||||||
.groupby(sre.voucher_detail_no, sre.warehouse)
|
.groupby(sre.voucher_detail_no)
|
||||||
)
|
).run(as_list=True)
|
||||||
|
|
||||||
if voucher_detail_no:
|
return frappe._dict(data)
|
||||||
query = query.where(sre.voucher_detail_no == voucher_detail_no)
|
|
||||||
|
|
||||||
data = query.run(as_dict=True)
|
|
||||||
|
|
||||||
for d in data:
|
|
||||||
reserved_qty_details[(d["voucher_detail_no"], d["warehouse"])] = d["reserved_qty"]
|
|
||||||
|
|
||||||
return reserved_qty_details
|
|
||||||
|
|
||||||
|
|
||||||
def get_sre_reserved_qty_details_for_voucher_detail_no(
|
def get_sre_reserved_qty_details_for_voucher_detail_no(
|
||||||
@ -281,6 +273,7 @@ def get_sre_reserved_qty_details_for_voucher_detail_no(
|
|||||||
& (sre.voucher_detail_no == voucher_detail_no)
|
& (sre.voucher_detail_no == voucher_detail_no)
|
||||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||||
)
|
)
|
||||||
|
.orderby(sre.creation)
|
||||||
.groupby(sre.warehouse)
|
.groupby(sre.warehouse)
|
||||||
).run(as_list=True)
|
).run(as_list=True)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user