feat: added negative inventory validation and restrict to make backdated entry for serial nos
diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py
index 33b8955..f2d266a 100644
--- a/erpnext/stock/deprecated_serial_batch.py
+++ b/erpnext/stock/deprecated_serial_batch.py
@@ -69,7 +69,8 @@
def calculate_avg_rate_from_deprecarated_ledgers(self):
ledgers = self.get_sle_for_batches()
for ledger in ledgers:
- self.batch_avg_rate[ledger.batch_no] += flt(ledger.incoming_rate) / flt(ledger.qty)
+ self.batch_avg_rate[ledger.batch_no] += flt(ledger.batch_value) / flt(ledger.batch_qty)
+ self.available_qty[ledger.batch_no] += flt(ledger.batch_qty)
def get_sle_for_batches(self):
batch_nos = list(self.batch_nos.keys())
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index a647a17..ce0684a 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -1044,8 +1044,6 @@
"field_map": {
source_document_warehouse_field: target_document_warehouse_field,
"name": "delivery_note_item",
- "batch_no": "batch_no",
- "serial_no": "serial_no",
"purchase_order": "purchase_order",
"purchase_order_item": "purchase_order_item",
"material_request": "material_request",
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index 98da0af..824691c 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -7,19 +7,24 @@
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.query_builder.functions import Sum
-from frappe.utils import cint, flt, today
+from frappe.query_builder.functions import CombineDatetime, Sum
+from frappe.utils import cint, flt, get_link_to_form, today
from pypika import Case
from erpnext.stock.serial_batch_bundle import BatchNoBundleValuation, SerialNoBundleValuation
+class SerialNoExistsInFutureTransactionError(frappe.ValidationError):
+ pass
+
+
class SerialandBatchBundle(Document):
def validate(self):
self.validate_serial_and_batch_no()
self.validate_duplicate_serial_and_batch_no()
# self.validate_voucher_no()
- self.validate_serial_nos()
+ self.check_future_entries_exists()
+ self.validate_serial_nos_inventory()
def before_save(self):
self.set_total_qty()
@@ -31,6 +36,26 @@
if self.ledgers:
self.set_avg_rate()
+ def validate_serial_nos_inventory(self):
+ if not (self.has_serial_no and self.type_of_transaction == "Outward"):
+ return
+
+ serial_nos = [d.serial_no for d in self.ledgers if d.serial_no]
+ serial_no_warehouse = frappe._dict(
+ frappe.get_all(
+ "Serial No",
+ filters={"name": ("in", serial_nos)},
+ fields=["name", "warehouse"],
+ as_list=1,
+ )
+ )
+
+ for serial_no in serial_nos:
+ if serial_no_warehouse.get(serial_no) != self.warehouse:
+ frappe.throw(
+ _(f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.")
+ )
+
def set_incoming_rate(self, row=None, save=False):
if self.type_of_transaction == "Outward":
self.set_incoming_rate_for_outward_transaction(row, save)
@@ -65,10 +90,14 @@
)
for d in self.ledgers:
+ available_qty = 0
if self.has_serial_no:
d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0))
else:
d.incoming_rate = abs(sn_obj.batch_avg_rate.get(d.batch_no))
+ available_qty = sn_obj.batch_available_qty.get(d.batch_no) + d.qty
+
+ self.validate_negative_batch(d.batch_no, available_qty)
if self.has_batch_no:
d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate)
@@ -78,6 +107,14 @@
{"incoming_rate": d.incoming_rate, "stock_value_difference": d.stock_value_difference}
)
+ def validate_negative_batch(self, batch_no, available_qty):
+ if available_qty < 0:
+ msg = f"""Batch No {bold(batch_no)} has negative stock
+ of quantity {bold(available_qty)} in the
+ warehouse {self.warehouse}"""
+
+ frappe.throw(_(msg))
+
def get_sle_for_outward_transaction(self, row):
return frappe._dict(
{
@@ -169,10 +206,54 @@
)
)
- def validate_serial_nos(self):
+ def check_future_entries_exists(self):
if not self.has_serial_no:
return
+ serial_nos = [d.serial_no for d in self.ledgers if d.serial_no]
+
+ parent = frappe.qb.DocType("Serial and Batch Bundle")
+ child = frappe.qb.DocType("Serial and Batch Ledger")
+
+ timestamp_condition = CombineDatetime(
+ parent.posting_date, parent.posting_time
+ ) > CombineDatetime(self.posting_date, self.posting_time)
+
+ future_entries = (
+ frappe.qb.from_(parent)
+ .inner_join(child)
+ .on(parent.name == child.parent)
+ .select(
+ child.serial_no,
+ parent.voucher_type,
+ parent.voucher_no,
+ )
+ .where(
+ (child.serial_no.isin(serial_nos))
+ & (child.parent != self.name)
+ & (parent.item_code == self.item_code)
+ & (parent.docstatus == 1)
+ & (parent.is_cancelled == 0)
+ )
+ .where(timestamp_condition)
+ ).run(as_dict=True)
+
+ if future_entries:
+ msg = """The serial nos has been used in the future
+ transactions so you need to cancel them first.
+ The list of serial nos and their respective
+ transactions are as below."""
+
+ msg += "<br><br><ul>"
+
+ for d in future_entries:
+ msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
+ msg += "</li></ul>"
+
+ title = "Serial No Exists In Future Transaction(s)"
+
+ frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
+
def validate_quantity(self, row):
self.set_total_qty(save=True)
@@ -429,8 +510,19 @@
doc.posting_date = parent_doc.posting_date
doc.posting_time = parent_doc.posting_time
doc.set("ledgers", [])
- doc.set("ledgers", ledgers)
- doc.save()
+
+ for d in ledgers:
+ doc.append(
+ "ledgers",
+ {
+ "qty": 1 if doc.type_of_transaction == "Inward" else -1,
+ "warehouse": d.get("warehouse"),
+ "batch_no": d.get("batch_no"),
+ "serial_no": d.get("serial_no"),
+ },
+ )
+
+ doc.save(ignore_permissions=True)
frappe.msgprint(_("Serial and Batch Bundle updated"), alert=True)
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index a7f5b80..5e8aff3 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1219,8 +1219,16 @@
if cstr(d.s_warehouse) or (finished_item_row and d.name == finished_item_row.name):
sle.recalculate_rate = 1
- if d.serial_and_batch_bundle and self.docstatus == 1:
- d.serial_and_batch_bundle = self.copy_serial_and_batch_bundle(sle)
+ allowed_types = [
+ "Material Transfer",
+ "Send to Subcontractor",
+ "Material Transfer for Manufacture",
+ ]
+
+ if self.purpose in allowed_types and d.serial_and_batch_bundle and self.docstatus == 1:
+ d.serial_and_batch_bundle = self.make_package_for_transfer(
+ d.serial_and_batch_bundle, d.t_warehouse
+ )
if d.serial_and_batch_bundle and self.docstatus == 2:
bundle_id = frappe.get_cached_value(
@@ -1239,36 +1247,6 @@
sl_entries.append(sle)
- def copy_serial_and_batch_bundle(self, child):
- allowed_types = [
- "Material Transfer",
- "Send to Subcontractor",
- "Material Transfer for Manufacture",
- ]
-
- if self.purpose in allowed_types:
- bundle_doc = frappe.get_doc("Serial and Batch Bundle", child.serial_and_batch_bundle)
-
- bundle_doc = frappe.copy_doc(bundle_doc)
- bundle_doc.warehouse = child.t_warehouse
- bundle_doc.type_of_transaction = "Inward"
-
- for row in bundle_doc.ledgers:
- if row.qty < 0:
- row.qty = abs(row.qty)
-
- if row.stock_value_difference < 0:
- row.stock_value_difference = abs(row.stock_value_difference)
-
- row.warehouse = child.t_warehouse
- row.is_outward = 0
-
- bundle_doc.set_total_qty()
- bundle_doc.set_avg_rate()
- bundle_doc.flags.ignore_permissions = True
- bundle_doc.submit()
- return bundle_doc.name
-
def get_gl_entries(self, warehouse_account):
gl_entries = super(StockEntry, self).get_gl_entries(warehouse_account)
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index a4fac4d..2b88e8b 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _, bold
from frappe.model.naming import make_autoname
-from frappe.query_builder.functions import Sum
+from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import cint, flt, now
from erpnext.stock.deprecated_serial_batch import (
@@ -255,7 +255,7 @@
data = frappe.db.get_value(
"Serial and Batch Bundle",
self.sle.serial_and_batch_bundle,
- ["item_code", "warehouse", "voucher_no"],
+ ["item_code", "warehouse", "voucher_no", "name"],
as_dict=1,
)
@@ -408,7 +408,7 @@
parent.name = child.parent
AND child.serial_no IN ({', '.join([frappe.db.escape(s) for s in serial_nos])})
AND child.is_outward = 0
- AND parent.docstatus < 2
+ AND parent.docstatus = 1
AND parent.is_cancelled = 0
AND child.warehouse = {frappe.db.escape(self.sle.warehouse)}
AND parent.item_code = {frappe.db.escape(self.sle.item_code)}
@@ -511,8 +511,10 @@
ledgers = self.get_batch_no_ledgers()
self.batch_avg_rate = defaultdict(float)
+ self.available_qty = defaultdict(float)
for ledger in ledgers:
self.batch_avg_rate[ledger.batch_no] += flt(ledger.incoming_rate) / flt(ledger.qty)
+ self.available_qty[ledger.batch_no] += flt(ledger.qty)
self.calculate_avg_rate_from_deprecarated_ledgers()
self.set_stock_value_difference()
@@ -523,6 +525,10 @@
batch_nos = list(self.batch_nos.keys())
+ timestamp_condition = CombineDatetime(
+ parent.posting_date, parent.posting_time
+ ) < CombineDatetime(self.sle.posting_date, self.sle.posting_time)
+
return (
frappe.qb.from_(parent)
.inner_join(child)
@@ -537,8 +543,10 @@
& (child.parent != self.sle.serial_and_batch_bundle)
& (parent.warehouse == self.sle.warehouse)
& (parent.item_code == self.sle.item_code)
+ & (parent.docstatus == 1)
& (parent.is_cancelled == 0)
)
+ .where(timestamp_condition)
.groupby(child.batch_no)
).run(as_dict=True)