Merge pull request #37754 from s-aga-r/VALIDATE-RESERVED-STOCK
fix: consider reserved stock while cancelling a stock transaction
This commit is contained in:
commit
e42a3e0084
@ -347,5 +347,6 @@ execute:frappe.db.set_single_value("Payment Reconciliation", "invoice_limit", 50
|
||||
execute:frappe.db.set_single_value("Payment Reconciliation", "payment_limit", 50)
|
||||
erpnext.patches.v15_0.rename_daily_depreciation_to_depreciation_amount_based_on_num_days_in_month
|
||||
erpnext.patches.v15_0.rename_depreciation_amount_based_on_num_days_in_month_to_daily_prorata_based
|
||||
erpnext.patches.v15_0.set_reserved_stock_in_bin
|
||||
# below migration patch should always run last
|
||||
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
|
||||
|
24
erpnext/patches/v15_0/set_reserved_stock_in_bin.py
Normal file
24
erpnext/patches/v15_0/set_reserved_stock_in_bin.py
Normal file
@ -0,0 +1,24 @@
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
|
||||
def execute():
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(
|
||||
sre.item_code,
|
||||
sre.warehouse,
|
||||
Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_stock"),
|
||||
)
|
||||
.where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"])))
|
||||
.groupby(sre.item_code, sre.warehouse)
|
||||
)
|
||||
|
||||
for d in query.run(as_dict=True):
|
||||
frappe.db.set_value(
|
||||
"Bin",
|
||||
{"item_code": d.item_code, "warehouse": d.warehouse},
|
||||
"reserved_stock",
|
||||
d.reserved_stock,
|
||||
)
|
@ -13,12 +13,13 @@
|
||||
"planned_qty",
|
||||
"indented_qty",
|
||||
"ordered_qty",
|
||||
"projected_qty",
|
||||
"column_break_xn5j",
|
||||
"reserved_qty",
|
||||
"reserved_qty_for_production",
|
||||
"reserved_qty_for_sub_contract",
|
||||
"reserved_qty_for_production_plan",
|
||||
"projected_qty",
|
||||
"reserved_stock",
|
||||
"section_break_pmrs",
|
||||
"stock_uom",
|
||||
"column_break_0slj",
|
||||
@ -173,13 +174,20 @@
|
||||
{
|
||||
"fieldname": "column_break_0slj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "reserved_stock",
|
||||
"fieldtype": "Float",
|
||||
"label": "Reserved Stock",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2023-11-01 15:35:51.722534",
|
||||
"modified": "2023-11-01 16:51:17.079107",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Bin",
|
||||
|
@ -148,6 +148,17 @@ class Bin(Document):
|
||||
self.set_projected_qty()
|
||||
self.db_set("projected_qty", self.projected_qty, update_modified=True)
|
||||
|
||||
def update_reserved_stock(self):
|
||||
"""Update `Reserved Stock` on change in Reserved Qty of Stock Reservation Entry"""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_for_item_and_warehouse,
|
||||
)
|
||||
|
||||
reserved_stock = get_sre_reserved_qty_for_item_and_warehouse(self.item_code, self.warehouse)
|
||||
|
||||
self.db_set("reserved_stock", flt(reserved_stock), update_modified=True)
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
|
||||
|
@ -365,6 +365,9 @@ class DeliveryNote(SellingController):
|
||||
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
|
||||
sre_doc.update_status()
|
||||
|
||||
# Update Reserved Stock in Bin.
|
||||
sre_doc.update_reserved_stock_in_bin()
|
||||
|
||||
qty_to_deliver -= qty_can_be_deliver
|
||||
|
||||
if self._action == "cancel":
|
||||
@ -427,6 +430,9 @@ class DeliveryNote(SellingController):
|
||||
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
|
||||
sre_doc.update_status()
|
||||
|
||||
# Update Reserved Stock in Bin.
|
||||
sre_doc.update_reserved_stock_in_bin()
|
||||
|
||||
qty_to_undelivered -= qty_can_be_undelivered
|
||||
|
||||
def validate_against_stock_reservation_entries(self):
|
||||
|
@ -9,6 +9,8 @@ from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
|
||||
class StockReservationEntry(Document):
|
||||
def validate(self) -> None:
|
||||
@ -31,6 +33,7 @@ class StockReservationEntry(Document):
|
||||
self.update_reserved_qty_in_voucher()
|
||||
self.update_reserved_qty_in_pick_list()
|
||||
self.update_status()
|
||||
self.update_reserved_stock_in_bin()
|
||||
|
||||
def on_update_after_submit(self) -> None:
|
||||
self.can_be_updated()
|
||||
@ -40,12 +43,14 @@ class StockReservationEntry(Document):
|
||||
self.validate_reservation_based_on_serial_and_batch()
|
||||
self.update_reserved_qty_in_voucher()
|
||||
self.update_status()
|
||||
self.update_reserved_stock_in_bin()
|
||||
self.reload()
|
||||
|
||||
def on_cancel(self) -> None:
|
||||
self.update_reserved_qty_in_voucher()
|
||||
self.update_reserved_qty_in_pick_list()
|
||||
self.update_status()
|
||||
self.update_reserved_stock_in_bin()
|
||||
|
||||
def validate_amended_doc(self) -> None:
|
||||
"""Raises an exception if document is amended."""
|
||||
@ -341,6 +346,13 @@ class StockReservationEntry(Document):
|
||||
update_modified=update_modified,
|
||||
)
|
||||
|
||||
def update_reserved_stock_in_bin(self) -> None:
|
||||
"""Updates `Reserved Stock` in Bin."""
|
||||
|
||||
bin_name = get_or_make_bin(self.item_code, self.warehouse)
|
||||
bin_doc = frappe.get_cached_doc("Bin", bin_name)
|
||||
bin_doc.update_reserved_stock()
|
||||
|
||||
def update_status(self, status: str = None, update_modified: bool = True) -> None:
|
||||
"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
|
||||
|
||||
@ -681,6 +693,68 @@ def get_sre_reserved_qty_for_voucher_detail_no(
|
||||
return flt(reserved_qty[0][0])
|
||||
|
||||
|
||||
def get_sre_reserved_serial_nos_details(
|
||||
item_code: str, warehouse: str, serial_nos: list = None
|
||||
) -> dict:
|
||||
"""Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}"""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.serial_no, sre.name)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == item_code)
|
||||
& (sre.warehouse == warehouse)
|
||||
& (sre.reserved_qty > sre.delivered_qty)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
.orderby(sb_entry.creation)
|
||||
)
|
||||
|
||||
if serial_nos:
|
||||
query = query.where(sb_entry.serial_no.isin(serial_nos))
|
||||
|
||||
return frappe._dict(query.run())
|
||||
|
||||
|
||||
def get_sre_reserved_batch_nos_details(
|
||||
item_code: str, warehouse: str, batch_nos: list = None
|
||||
) -> dict:
|
||||
"""Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}"""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(
|
||||
sb_entry.batch_no,
|
||||
Sum(sb_entry.qty - sb_entry.delivered_qty),
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == item_code)
|
||||
& (sre.warehouse == warehouse)
|
||||
& ((sre.reserved_qty - sre.delivered_qty) > 0)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
.groupby(sb_entry.batch_no)
|
||||
.orderby(sb_entry.creation)
|
||||
)
|
||||
|
||||
if batch_nos:
|
||||
query = query.where(sb_entry.batch_no.isin(batch_nos))
|
||||
|
||||
return frappe._dict(query.run())
|
||||
|
||||
|
||||
def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]:
|
||||
"""Returns a list of SREs for the provided voucher."""
|
||||
|
||||
|
@ -286,6 +286,7 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
|
||||
self.assertEqual(sre_details.status, "Partially Reserved")
|
||||
|
||||
cancel_stock_reservation_entries("Sales Order", so.name)
|
||||
se.cancel()
|
||||
|
||||
# Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
|
||||
@ -493,7 +494,7 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_stock_reservation_from_pick_list(self):
|
||||
def test_stock_reservation_from_pick_list(self) -> None:
|
||||
items_details = create_items()
|
||||
create_material_receipt(items_details, self.warehouse, qty=100)
|
||||
|
||||
@ -575,7 +576,7 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
"auto_reserve_stock_for_sales_order_on_purchase": 1,
|
||||
},
|
||||
)
|
||||
def test_stock_reservation_from_purchase_receipt(self):
|
||||
def test_stock_reservation_from_purchase_receipt(self) -> None:
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
|
||||
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
|
||||
@ -645,6 +646,40 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
# Test - 3: Reserved Serial/Batch Nos should be equal to PR Item Serial/Batch Nos.
|
||||
self.assertEqual(set(sb_details), set(reserved_sb_details))
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_consider_reserved_stock_while_cancelling_an_inward_transaction(self) -> None:
|
||||
items_details = create_items()
|
||||
se = create_material_receipt(items_details, self.warehouse, qty=100)
|
||||
|
||||
item_list = []
|
||||
for item_code, properties in items_details.items():
|
||||
item_list.append(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"qty": randint(11, 100),
|
||||
"uom": properties.stock_uom,
|
||||
"rate": randint(10, 400),
|
||||
}
|
||||
)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse=self.warehouse,
|
||||
)
|
||||
so.create_stock_reservation_entries()
|
||||
|
||||
# Test - 1: ValidationError should be thrown as the inwarded stock is reserved.
|
||||
self.assertRaises(frappe.ValidationError, se.cancel)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
cancel_all_stock_reservation_entries()
|
||||
return super().tearDown()
|
||||
|
@ -11,17 +11,22 @@ from frappe import _, scrub
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import CombineDatetime, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, parse_json
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_available_batches,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
|
||||
get_sre_reserved_batch_nos_details,
|
||||
get_sre_reserved_serial_nos_details,
|
||||
)
|
||||
from erpnext.stock.utils import (
|
||||
get_incoming_outgoing_rate_for_cancel,
|
||||
get_or_make_bin,
|
||||
get_stock_balance,
|
||||
get_valuation_method,
|
||||
)
|
||||
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
|
||||
@ -88,6 +93,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
is_stock_item = frappe.get_cached_value("Item", args.get("item_code"), "is_stock_item")
|
||||
if is_stock_item:
|
||||
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
|
||||
args.reserved_stock = flt(frappe.db.get_value("Bin", bin_name, "reserved_stock"))
|
||||
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
|
||||
update_bin_qty(bin_name, args)
|
||||
else:
|
||||
@ -114,6 +120,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou
|
||||
"voucher_no": args.get("voucher_no"),
|
||||
"sle_id": args.get("name"),
|
||||
"creation": args.get("creation"),
|
||||
"reserved_stock": args.get("reserved_stock"),
|
||||
},
|
||||
allow_negative_stock=allow_negative_stock,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
@ -511,7 +518,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.reserved_stock = flt(self.args.reserved_stock)
|
||||
|
||||
self.data = frappe._dict()
|
||||
self.initialize_previous_data(self.args)
|
||||
@ -1719,22 +1726,23 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
|
||||
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock"))
|
||||
|
||||
if not args.batch_no:
|
||||
return
|
||||
if args.batch_no:
|
||||
neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
|
||||
if is_negative_with_precision(neg_batch_sle, is_batch=True):
|
||||
message = _(
|
||||
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
|
||||
).format(
|
||||
abs(neg_batch_sle[0]["cumulative_total"]),
|
||||
frappe.get_desk_link("Batch", args.batch_no),
|
||||
frappe.get_desk_link("Warehouse", args.warehouse),
|
||||
neg_batch_sle[0]["posting_date"],
|
||||
neg_batch_sle[0]["posting_time"],
|
||||
frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]),
|
||||
)
|
||||
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
|
||||
|
||||
neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
|
||||
if is_negative_with_precision(neg_batch_sle, is_batch=True):
|
||||
message = _(
|
||||
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
|
||||
).format(
|
||||
abs(neg_batch_sle[0]["cumulative_total"]),
|
||||
frappe.get_desk_link("Batch", args.batch_no),
|
||||
frappe.get_desk_link("Warehouse", args.warehouse),
|
||||
neg_batch_sle[0]["posting_date"],
|
||||
neg_batch_sle[0]["posting_time"],
|
||||
frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]),
|
||||
)
|
||||
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
|
||||
if args.reserved_stock:
|
||||
validate_reserved_stock(args)
|
||||
|
||||
|
||||
def is_negative_with_precision(neg_sle, is_batch=False):
|
||||
@ -1801,6 +1809,96 @@ def get_future_sle_with_negative_batch_qty(args):
|
||||
)
|
||||
|
||||
|
||||
def validate_reserved_stock(kwargs):
|
||||
if kwargs.serial_no:
|
||||
serial_nos = kwargs.serial_no.split("\n")
|
||||
validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos)
|
||||
|
||||
elif kwargs.batch_no:
|
||||
validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no])
|
||||
|
||||
elif kwargs.serial_and_batch_bundle:
|
||||
sbb_entries = frappe.db.get_all(
|
||||
"Serial and Batch Entry",
|
||||
{
|
||||
"parenttype": "Serial and Batch Bundle",
|
||||
"parent": kwargs.serial_and_batch_bundle,
|
||||
"docstatus": 1,
|
||||
},
|
||||
["batch_no", "serial_no"],
|
||||
)
|
||||
|
||||
if serial_nos := [entry.serial_no for entry in sbb_entries if entry.serial_no]:
|
||||
validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos)
|
||||
elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]:
|
||||
validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, batch_nos)
|
||||
|
||||
# Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty.
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
balance_qty = get_stock_balance(kwargs.item_code, kwargs.warehouse)
|
||||
|
||||
diff = flt(balance_qty - kwargs.get("reserved_stock", 0), precision)
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Item", kwargs.item_code),
|
||||
frappe.get_desk_link("Warehouse", kwargs.warehouse),
|
||||
nowdate(),
|
||||
nowtime(),
|
||||
)
|
||||
frappe.throw(msg, title=_("Reserved Stock"))
|
||||
|
||||
|
||||
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
||||
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(
|
||||
item_code, warehouse, serial_nos
|
||||
):
|
||||
if common_serial_nos := list(
|
||||
set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))
|
||||
):
|
||||
msg = _(
|
||||
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
|
||||
)
|
||||
msg += "<br />"
|
||||
msg += _("Example: Serial No {0} reserved in {1}.").format(
|
||||
frappe.bold(common_serial_nos[0]),
|
||||
frappe.get_desk_link(
|
||||
"Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]]
|
||||
),
|
||||
)
|
||||
frappe.throw(msg, title=_("Reserved Serial No."))
|
||||
|
||||
|
||||
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
||||
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
|
||||
available_batches = get_available_batches(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": nowdate(),
|
||||
"posting_time": nowtime(),
|
||||
}
|
||||
)
|
||||
)
|
||||
available_batches_map = {row.batch_no: row.qty for row in available_batches}
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
|
||||
for batch_no in batch_nos:
|
||||
diff = flt(
|
||||
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
|
||||
)
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Batch", batch_no),
|
||||
frappe.get_desk_link("Warehouse", warehouse),
|
||||
nowdate(),
|
||||
nowtime(),
|
||||
)
|
||||
frappe.throw(msg, title=_("Reserved Stock for Batch"))
|
||||
|
||||
|
||||
def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool:
|
||||
if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)):
|
||||
return True
|
||||
|
Loading…
x
Reference in New Issue
Block a user