Merge pull request #47988 from rohitwaghchaure/fixed-support-40403

fix: stock reconciliation validation for serial nos
This commit is contained in:
rohitwaghchaure
2025-06-17 23:00:07 +05:30
committed by GitHub
3 changed files with 131 additions and 28 deletions

View File

@@ -226,7 +226,14 @@ class SerialandBatchBundle(Document):
if not (self.has_serial_no and self.type_of_transaction == "Outward"):
return
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate()
else:
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
if not serial_nos:
return
kwargs = {
"item_code": self.item_code,
"warehouse": self.warehouse,
@@ -239,6 +246,15 @@ class SerialandBatchBundle(Document):
if self.voucher_type == "POS Invoice":
kwargs["ignore_voucher_nos"] = [self.voucher_no]
if self.voucher_type == "Stock Reconciliation":
kwargs.update(
{
"voucher_no": self.voucher_no,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
serial_no_warehouse = {}
@@ -669,14 +685,17 @@ class SerialandBatchBundle(Document):
):
self.throw_error_message(f"The {self.voucher_type} # {self.voucher_no} should be submit first.")
def check_future_entries_exists(self):
def check_future_entries_exists(self, is_cancelled=False):
if self.flags and self.flags.via_landed_cost_voucher:
return
if not self.has_serial_no:
return
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate(is_cancelled=is_cancelled)
else:
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
if not serial_nos:
return
@@ -724,6 +743,36 @@ class SerialandBatchBundle(Document):
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
def get_serial_nos_for_validate(self, is_cancelled=False):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
skip_serial_nos = self.get_skip_serial_nos_for_stock_reconciliation(is_cancelled=is_cancelled)
serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos)))
return serial_nos
def get_skip_serial_nos_for_stock_reconciliation(self, is_cancelled=False):
data = get_stock_reco_details(self.voucher_detail_no)
if not data:
return []
if data.current_serial_no:
current_serial_nos = set(parse_serial_nos(data.current_serial_no))
serial_nos = set(parse_serial_nos(data.serial_no)) if data.serial_no else set([])
return list(serial_nos.intersection(current_serial_nos))
elif data.current_serial_and_batch_bundle:
current_serial_nos = set(get_serial_nos_from_bundle(data.current_serial_and_batch_bundle))
if is_cancelled:
return current_serial_nos
serial_nos = (
set(get_serial_nos_from_bundle(data.serial_and_batch_bundle))
if data.serial_and_batch_bundle
else set([])
)
return list(serial_nos.intersection(current_serial_nos))
return []
def reset_qty(self, row, qty_field=None):
qty_field = self.get_qty_field(row, qty_field=qty_field)
qty = abs(flt(row.get(qty_field), self.precision("total_qty")))
@@ -927,8 +976,6 @@ class SerialandBatchBundle(Document):
)
def validate_serial_and_batch_no_for_returned(self):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if not self.returned_against:
return
@@ -953,7 +1000,7 @@ class SerialandBatchBundle(Document):
if d.serial_and_batch_bundle:
serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle)
else:
serial_nos = get_serial_nos(d.serial_no)
serial_nos = parse_serial_nos(d.serial_no)
elif self.has_batch_no:
if d.serial_and_batch_bundle:
@@ -1115,7 +1162,7 @@ class SerialandBatchBundle(Document):
).run()
def validate_serial_and_batch_inventory(self):
self.check_future_entries_exists()
self.check_future_entries_exists(is_cancelled=True)
self.validate_batch_inventory()
def validate_batch_inventory(self):
@@ -1422,13 +1469,6 @@ def make_batch_nos(item_code, batch_nos):
frappe.msgprint(_("Batch Nos are created successfully"), alert=True)
def parse_serial_nos(data):
if isinstance(data, list):
return data
return [s.strip() for s in cstr(data).strip().replace(",", "\n").split("\n") if s.strip()]
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
@@ -1815,8 +1855,6 @@ def get_non_expired_batches(batches):
def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
serial_nos = set()
data = get_stock_ledgers_for_serial_nos(kwargs)
@@ -1830,13 +1868,14 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
serial_nos.difference_update(sns)
elif d.serial_no:
sns = get_serial_nos(d.serial_no)
sns = parse_serial_nos(d.serial_no)
if d.actual_qty > 0:
serial_nos.update(sns)
else:
serial_nos.difference_update(sns)
serial_nos = list(serial_nos)
for serial_no in ignore_serial_nos:
if serial_no in serial_nos:
serial_nos.remove(serial_no)
@@ -1942,7 +1981,6 @@ def get_reserved_voucher_details(kwargs):
def get_reserved_serial_nos_for_pos(kwargs):
from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
ignore_serial_nos = []
pos_invoices = frappe.get_all(
@@ -1978,7 +2016,7 @@ def get_reserved_serial_nos_for_pos(kwargs):
returned_serial_nos = []
for pos_invoice in pos_invoices:
if pos_invoice.serial_no:
ignore_serial_nos.extend(get_serial_nos(pos_invoice.serial_no))
ignore_serial_nos.extend(parse_serial_nos(pos_invoice.serial_no))
if pos_invoice.is_return:
continue
@@ -2515,12 +2553,14 @@ def get_stock_ledgers_for_serial_nos(kwargs):
query = (
frappe.qb.from_(stock_ledger_entry)
.select(
stock_ledger_entry.posting_datetime,
stock_ledger_entry.actual_qty,
stock_ledger_entry.serial_no,
stock_ledger_entry.serial_and_batch_bundle,
)
.where(stock_ledger_entry.is_cancelled == 0)
.orderby(stock_ledger_entry.posting_datetime)
.orderby(stock_ledger_entry.creation)
)
if kwargs.get("posting_date"):
@@ -2649,3 +2689,20 @@ def make_batch_no(batch_no, item_code):
@frappe.whitelist()
def is_duplicate_serial_no(bundle_id, serial_no):
return frappe.db.exists("Serial and Batch Entry", {"parent": bundle_id, "serial_no": serial_no})
def parse_serial_nos(serial_no):
if isinstance(serial_no, list):
return serial_no
return [s.strip() for s in cstr(serial_no).strip().replace(",", "\n").split("\n") if s.strip()]
@frappe.request_cache
def get_stock_reco_details(voucher_detail_no):
return frappe.db.get_value(
"Stock Reconciliation Item",
voucher_detail_no,
["current_serial_no", "serial_no", "serial_and_batch_bundle", "current_serial_and_batch_bundle"],
as_dict=True,
)

View File

@@ -373,16 +373,26 @@ class SerialBatchBundle:
if not self.sle.serial_and_batch_bundle and self.sle.serial_no:
serial_nos = get_parsed_serial_nos(self.sle.serial_no)
warehouse = self.warehouse if self.sle.actual_qty > 0 else None
if not serial_nos:
return
if self.sle.voucher_type == "Stock Reconciliation" and self.sle.actual_qty > 0:
self.update_serial_no_status_for_stock_reco(serial_nos)
return
self.update_serial_no_status_warehouse(self.sle, serial_nos)
def update_serial_no_status_warehouse(self, sle, serial_nos):
warehouse = self.warehouse if sle.actual_qty > 0 else None
if isinstance(serial_nos, str):
serial_nos = [serial_nos]
status = "Inactive"
if self.sle.actual_qty < 0:
if sle.actual_qty < 0:
status = "Delivered"
if self.sle.voucher_type == "Stock Entry":
purpose = frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose")
if sle.voucher_type == "Stock Entry":
purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose")
if purpose in [
"Manufacture",
"Material Issue",
@@ -401,17 +411,17 @@ class SerialBatchBundle:
"Active"
if warehouse
else status
if (sn_table.purchase_document_no != self.sle.voucher_no and self.sle.is_cancelled != 1)
if (sn_table.purchase_document_no != sle.voucher_no and sle.is_cancelled != 1)
else "Inactive",
)
.set(sn_table.company, self.sle.company)
.set(sn_table.company, sle.company)
.where(sn_table.name.isin(serial_nos))
)
if status == "Delivered":
warranty_period = frappe.get_cached_value("Item", self.sle.item_code, "warranty_period")
warranty_period = frappe.get_cached_value("Item", sle.item_code, "warranty_period")
if warranty_period:
warranty_expiry_date = add_days(self.sle.posting_date, cint(warranty_period))
warranty_expiry_date = add_days(sle.posting_date, cint(warranty_period))
query = query.set(sn_table.warranty_expiry_date, warranty_expiry_date)
query = query.set(sn_table.warranty_period, warranty_period)
else:
@@ -420,6 +430,39 @@ class SerialBatchBundle:
query.run()
def update_serial_no_status_for_stock_reco(self, serial_nos):
for serial_no in serial_nos:
sle_doctype = frappe.qb.DocType("Stock Ledger Entry")
sn_table = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sle_doctype)
.inner_join(sn_table)
.on(sle_doctype.serial_and_batch_bundle == sn_table.parent)
.select(
sle_doctype.warehouse,
sle_doctype.actual_qty,
sle_doctype.voucher_type,
sle_doctype.voucher_no,
sle_doctype.is_cancelled,
sle_doctype.item_code,
sle_doctype.posting_date,
sle_doctype.company,
)
.where(
(sn_table.serial_no == serial_no)
& (sle_doctype.is_cancelled == 0)
& (sn_table.docstatus == 1)
)
.orderby(sle_doctype.posting_datetime, order=Order.desc)
.orderby(sle_doctype.creation, order=Order.desc)
.limit(1)
)
sle = query.run(as_dict=1)
if sle:
self.update_serial_no_status_warehouse(sle[0], serial_no)
def set_batch_no_in_serial_nos(self):
entries = frappe.get_all(
"Serial and Batch Entry",

View File

@@ -1939,6 +1939,9 @@ def get_stock_reco_qty_shift(args):
stock_reco_qty_shift = 0
if args.get("is_cancelled"):
if args.get("previous_qty_after_transaction"):
if args.get("serial_and_batch_bundle"):
return args.get("previous_qty_after_transaction")
# get qty (balance) that was set at submission
last_balance = args.get("previous_qty_after_transaction")
stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance)