mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-14 04:15:10 +00:00
fix: handle partial dn against reserved stock
(cherry picked from commit 9d979e34ab)
This commit is contained in:
@@ -17,6 +17,7 @@ from frappe.utils import cint, flt
|
|||||||
from erpnext.accounts.party import get_due_date
|
from erpnext.accounts.party import get_due_date
|
||||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||||
from erpnext.controllers.selling_controller import SellingController
|
from erpnext.controllers.selling_controller import SellingController
|
||||||
|
from erpnext.stock.stock_ledger import validate_reserved_stock
|
||||||
|
|
||||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||||
|
|
||||||
@@ -469,6 +470,10 @@ class DeliveryNote(SellingController):
|
|||||||
self.make_bundle_using_old_serial_batch_fields(table_name)
|
self.make_bundle_using_old_serial_batch_fields(table_name)
|
||||||
|
|
||||||
self.validate_standalone_serial_nos_customer()
|
self.validate_standalone_serial_nos_customer()
|
||||||
|
|
||||||
|
if not self.is_return:
|
||||||
|
self.validate_reserved_stock()
|
||||||
|
|
||||||
self.update_stock_reservation_entries()
|
self.update_stock_reservation_entries()
|
||||||
|
|
||||||
# Updating stock ledger should always be called after updating prevdoc status,
|
# Updating stock ledger should always be called after updating prevdoc status,
|
||||||
@@ -506,6 +511,66 @@ class DeliveryNote(SellingController):
|
|||||||
|
|
||||||
self.delete_auto_created_batches()
|
self.delete_auto_created_batches()
|
||||||
|
|
||||||
|
def validate_reserved_stock(self):
|
||||||
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
|
get_sre_against_so_for_dn,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# fetch reserved stock data from bin
|
||||||
|
reserved_stocks = self.get_reserved_stock_details()
|
||||||
|
|
||||||
|
for row in self.items:
|
||||||
|
if reserved_stocks.get((row.item_code, row.warehouse)) > 0:
|
||||||
|
args = frappe._dict(
|
||||||
|
{
|
||||||
|
"item_code": row.item_code,
|
||||||
|
"warehouse": row.warehouse,
|
||||||
|
"batch_nos": [row.batch_no] if row.batch_no else [],
|
||||||
|
"serial_nos": row.serial_no.split("\n") if row.serial_no else [],
|
||||||
|
"serial_and_batch_bundle": row.serial_and_batch_bundle,
|
||||||
|
"voucher_type": self.doctype,
|
||||||
|
"voucher_no": self.name,
|
||||||
|
"voucher_detail_no": row.name,
|
||||||
|
"actual_qty": row.qty * -1,
|
||||||
|
"posting_date": self.posting_date,
|
||||||
|
"posting_time": self.posting_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if row.against_sales_order and row.so_detail:
|
||||||
|
args.ignore_voucher_nos = get_sre_against_so_for_dn(
|
||||||
|
row.against_sales_order, row.so_detail
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_reserved_stock(args)
|
||||||
|
|
||||||
|
def get_reserved_stock_details(self):
|
||||||
|
"""
|
||||||
|
Create dict from bin based on item and warehouse:
|
||||||
|
{(item_code, warehouse): reserved_stock}
|
||||||
|
|
||||||
|
Use: to quickly retrieve/check reserved stock value instead of looping n times
|
||||||
|
"""
|
||||||
|
item_codes = set()
|
||||||
|
warehouses = set()
|
||||||
|
|
||||||
|
for row in self.items:
|
||||||
|
item_codes.add(row.item_code)
|
||||||
|
warehouses.add(row.warehouse)
|
||||||
|
|
||||||
|
bins = frappe.db.get_all(
|
||||||
|
"Bin",
|
||||||
|
{"item_code": ["in", item_codes], "warehouse": ["in", warehouses]},
|
||||||
|
["item_code", "warehouse", "reserved_stock"],
|
||||||
|
)
|
||||||
|
|
||||||
|
reserved_stock_lookup = {(b.item_code, b.warehouse): flt(b.reserved_stock) for b in bins}
|
||||||
|
|
||||||
|
return reserved_stock_lookup
|
||||||
|
|
||||||
def validate_against_stock_reservation_entries(self):
|
def validate_against_stock_reservation_entries(self):
|
||||||
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
|
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,9 @@ class SerialandBatchBundle(Document):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.voucher_type == "Delivery Note":
|
||||||
|
kwargs["ignore_voucher_nos"] = self.get_sre_against_dn()
|
||||||
|
|
||||||
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
|
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
|
||||||
|
|
||||||
serial_no_warehouse = {}
|
serial_no_warehouse = {}
|
||||||
@@ -1380,6 +1383,20 @@ class SerialandBatchBundle(Document):
|
|||||||
|
|
||||||
self.set("entries", [])
|
self.set("entries", [])
|
||||||
|
|
||||||
|
def get_sre_against_dn(self):
|
||||||
|
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||||
|
get_sre_against_so_for_dn,
|
||||||
|
)
|
||||||
|
|
||||||
|
so_name, so_detail_no = frappe.db.get_value(
|
||||||
|
"Delivery Note Item", self.voucher_detail_no, ["against_sales_order", "so_detail"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if so_name and so_detail_no:
|
||||||
|
sre_names = get_sre_against_so_for_dn(so_name, so_detail_no)
|
||||||
|
|
||||||
|
return sre_names
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def download_blank_csv_template(content):
|
def download_blank_csv_template(content):
|
||||||
|
|||||||
@@ -738,7 +738,7 @@ def get_sre_reserved_qty_for_voucher_detail_no(
|
|||||||
|
|
||||||
|
|
||||||
def get_sre_reserved_serial_nos_details(
|
def get_sre_reserved_serial_nos_details(
|
||||||
item_code: str, warehouse: str, serial_nos: list | None = None
|
item_code: str, warehouse: str, serial_nos: list | None = None, ignore_voucher_nos: list | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}"""
|
"""Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}"""
|
||||||
|
|
||||||
@@ -753,8 +753,7 @@ def get_sre_reserved_serial_nos_details(
|
|||||||
(sre.docstatus == 1)
|
(sre.docstatus == 1)
|
||||||
& (sre.item_code == item_code)
|
& (sre.item_code == item_code)
|
||||||
& (sre.warehouse == warehouse)
|
& (sre.warehouse == warehouse)
|
||||||
& (sre.reserved_qty > sre.delivered_qty)
|
& (sre.delivered_qty < sre.reserved_qty)
|
||||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
|
||||||
& (sre.reservation_based_on == "Serial and Batch")
|
& (sre.reservation_based_on == "Serial and Batch")
|
||||||
)
|
)
|
||||||
.orderby(sb_entry.creation)
|
.orderby(sb_entry.creation)
|
||||||
@@ -763,10 +762,15 @@ def get_sre_reserved_serial_nos_details(
|
|||||||
if serial_nos:
|
if serial_nos:
|
||||||
query = query.where(sb_entry.serial_no.isin(serial_nos))
|
query = query.where(sb_entry.serial_no.isin(serial_nos))
|
||||||
|
|
||||||
|
if ignore_voucher_nos:
|
||||||
|
query = query.where(sre.name.notin(ignore_voucher_nos))
|
||||||
|
|
||||||
return frappe._dict(query.run())
|
return frappe._dict(query.run())
|
||||||
|
|
||||||
|
|
||||||
def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos: list | None = None) -> dict:
|
def get_sre_reserved_batch_nos_details(
|
||||||
|
item_code: str, warehouse: str, batch_nos: list | None = None, ignore_voucher_nos: list | None = None
|
||||||
|
) -> dict:
|
||||||
"""Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}"""
|
"""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")
|
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||||
@@ -784,7 +788,7 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos
|
|||||||
& (sre.item_code == item_code)
|
& (sre.item_code == item_code)
|
||||||
& (sre.warehouse == warehouse)
|
& (sre.warehouse == warehouse)
|
||||||
& ((sre.reserved_qty - sre.delivered_qty) > 0)
|
& ((sre.reserved_qty - sre.delivered_qty) > 0)
|
||||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
& (sre.delivered_qty < sre.reserved_qty)
|
||||||
& (sre.reservation_based_on == "Serial and Batch")
|
& (sre.reservation_based_on == "Serial and Batch")
|
||||||
)
|
)
|
||||||
.groupby(sb_entry.batch_no)
|
.groupby(sb_entry.batch_no)
|
||||||
@@ -794,6 +798,9 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos
|
|||||||
if batch_nos:
|
if batch_nos:
|
||||||
query = query.where(sb_entry.batch_no.isin(batch_nos))
|
query = query.where(sb_entry.batch_no.isin(batch_nos))
|
||||||
|
|
||||||
|
if ignore_voucher_nos:
|
||||||
|
query = query.where(sre.name.notin(ignore_voucher_nos))
|
||||||
|
|
||||||
return frappe._dict(query.run())
|
return frappe._dict(query.run())
|
||||||
|
|
||||||
|
|
||||||
@@ -1175,3 +1182,24 @@ def get_stock_reservation_entries_for_voucher(
|
|||||||
query = query.where(sre.status.notin(["Delivered", "Cancelled"]))
|
query = query.where(sre.status.notin(["Delivered", "Cancelled"]))
|
||||||
|
|
||||||
return query.run(as_dict=True)
|
return query.run(as_dict=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.request_cache
|
||||||
|
def get_sre_against_so_for_dn(so_name: str, so_detail_no: str) -> list[str]:
|
||||||
|
"""Returns list of Stock Reservation Entries against Delivery Note with Sales Order Reference."""
|
||||||
|
|
||||||
|
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(sre)
|
||||||
|
.select(sre.name)
|
||||||
|
.where(
|
||||||
|
(sre.docstatus == 1)
|
||||||
|
& (sre.voucher_type == "Sales Order")
|
||||||
|
& (sre.voucher_no == so_name)
|
||||||
|
& (sre.voucher_detail_no == so_detail_no)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = query.run(as_list=True)
|
||||||
|
|
||||||
|
return result[0] if result else []
|
||||||
|
|||||||
@@ -2166,7 +2166,7 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
|
|||||||
)
|
)
|
||||||
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
|
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
|
||||||
|
|
||||||
if args.reserved_stock:
|
if args.reserved_stock and args.voucher_type != "Delivery Note":
|
||||||
validate_reserved_stock(args)
|
validate_reserved_stock(args)
|
||||||
|
|
||||||
|
|
||||||
@@ -2236,11 +2236,10 @@ def get_future_sle_with_negative_batch_qty(sle_args):
|
|||||||
|
|
||||||
def validate_reserved_stock(kwargs):
|
def validate_reserved_stock(kwargs):
|
||||||
if kwargs.serial_no:
|
if kwargs.serial_no:
|
||||||
serial_nos = kwargs.serial_no.split("\n")
|
validate_reserved_serial_nos(kwargs)
|
||||||
validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos)
|
|
||||||
|
|
||||||
elif kwargs.batch_no:
|
elif kwargs.batch_no:
|
||||||
validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no])
|
validate_reserved_batch_nos(kwargs)
|
||||||
|
|
||||||
elif kwargs.serial_and_batch_bundle:
|
elif kwargs.serial_and_batch_bundle:
|
||||||
sbb_entries = frappe.db.get_all(
|
sbb_entries = frappe.db.get_all(
|
||||||
@@ -2254,9 +2253,11 @@ def validate_reserved_stock(kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if serial_nos := [entry.serial_no for entry in sbb_entries if entry.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)
|
kwargs.serial_nos = serial_nos
|
||||||
|
validate_reserved_serial_nos(kwargs)
|
||||||
elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]:
|
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)
|
kwargs.batch_nos = batch_nos
|
||||||
|
validate_reserved_batch_nos(kwargs)
|
||||||
|
|
||||||
# Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty.
|
# 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
|
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||||
@@ -2274,9 +2275,13 @@ def validate_reserved_stock(kwargs):
|
|||||||
frappe.throw(msg, title=_("Reserved Stock"))
|
frappe.throw(msg, title=_("Reserved Stock"))
|
||||||
|
|
||||||
|
|
||||||
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
def validate_reserved_serial_nos(kwargs):
|
||||||
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(item_code, warehouse, serial_nos):
|
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(
|
||||||
if common_serial_nos := list(set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))):
|
kwargs.item_code, kwargs.warehouse, kwargs.serial_nos, kwargs.ignore_voucher_nos
|
||||||
|
):
|
||||||
|
if common_serial_nos := list(
|
||||||
|
set(kwargs.serial_nos).intersection(set(reserved_serial_nos_details.keys()))
|
||||||
|
):
|
||||||
msg = _(
|
msg = _(
|
||||||
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
|
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
|
||||||
)
|
)
|
||||||
@@ -2290,22 +2295,25 @@ def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
|||||||
frappe.throw(msg, title=_("Reserved Serial No."))
|
frappe.throw(msg, title=_("Reserved Serial No."))
|
||||||
|
|
||||||
|
|
||||||
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
def validate_reserved_batch_nos(kwargs):
|
||||||
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
|
if reserved_batches_map := get_sre_reserved_batch_nos_details(
|
||||||
|
kwargs.item_code, kwargs.warehouse, kwargs.batch_nos, kwargs.ignore_voucher_nos
|
||||||
|
):
|
||||||
available_batches = get_auto_batch_nos(
|
available_batches = get_auto_batch_nos(
|
||||||
frappe._dict(
|
frappe._dict(
|
||||||
{
|
{
|
||||||
"item_code": item_code,
|
"item_code": kwargs.item_code,
|
||||||
"warehouse": warehouse,
|
"warehouse": kwargs.warehouse,
|
||||||
"posting_date": nowdate(),
|
"posting_date": kwargs.posting_date,
|
||||||
"posting_time": nowtime(),
|
"posting_time": kwargs.posting_time,
|
||||||
|
"ignore_voucher_nos": kwargs.ignore_voucher_nos,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
available_batches_map = {row.batch_no: row.qty for row in available_batches}
|
available_batches_map = {row.batch_no: row.qty for row in available_batches}
|
||||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||||
|
|
||||||
for batch_no in batch_nos:
|
for batch_no in kwargs.batch_nos:
|
||||||
diff = flt(
|
diff = flt(
|
||||||
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
|
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
|
||||||
)
|
)
|
||||||
@@ -2313,7 +2321,7 @@ def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
|||||||
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
||||||
abs(diff),
|
abs(diff),
|
||||||
frappe.get_desk_link("Batch", batch_no),
|
frappe.get_desk_link("Batch", batch_no),
|
||||||
frappe.get_desk_link("Warehouse", warehouse),
|
frappe.get_desk_link("Warehouse", kwargs.warehouse),
|
||||||
nowdate(),
|
nowdate(),
|
||||||
nowtime(),
|
nowtime(),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user