Skip to content

Commit

Permalink
Merge pull request #37754 from s-aga-r/VALIDATE-RESERVED-STOCK
Browse files Browse the repository at this point in the history
fix: consider reserved stock while cancelling a stock transaction
  • Loading branch information
s-aga-r authored Nov 4, 2023
2 parents 56e9a46 + 54b323e commit e42a3e0
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 22 deletions.
1 change: 1 addition & 0 deletions erpnext/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 24 additions & 0 deletions erpnext/patches/v15_0/set_reserved_stock_in_bin.py
Original file line number Diff line number Diff line change
@@ -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,
)
12 changes: 10 additions & 2 deletions erpnext/stock/doctype/bin/bin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions erpnext/stock/doctype/bin/bin.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ def update_reserved_qty_for_sub_contracting(self, subcontract_doctype="Subcontra
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")
Expand Down
6 changes: 6 additions & 0 deletions erpnext/stock/doctype/delivery_note/delivery_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ def update_stock_reservation_entries(self) -> None:
# 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":
Expand Down Expand Up @@ -427,6 +430,9 @@ def update_stock_reservation_entries(self) -> None:
# 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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
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:
Expand All @@ -31,6 +33,7 @@ def on_submit(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 on_update_after_submit(self) -> None:
self.can_be_updated()
Expand All @@ -40,12 +43,14 @@ def on_update_after_submit(self) -> None:
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."""
Expand Down Expand Up @@ -341,6 +346,13 @@ def update_reserved_qty_in_pick_list(
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."""

Expand Down Expand Up @@ -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."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ def test_stock_reservation_against_sales_order(self) -> None:
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.
Expand Down Expand Up @@ -493,7 +494,7 @@ def test_auto_reserve_serial_and_batch(self) -> None:
"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)

Expand Down Expand Up @@ -575,7 +576,7 @@ def test_stock_reservation_from_pick_list(self):
"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
Expand Down Expand Up @@ -645,6 +646,40 @@ def test_stock_reservation_from_purchase_receipt(self):
# 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()
Expand Down
Loading

0 comments on commit e42a3e0

Please sign in to comment.