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 73d9efd7dc2..7c6a95ffc83 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 @@ -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, + ) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index eaea590971d..312f2799955 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -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", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8d3c92c8736..f4796a4f6e2 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1936,6 +1936,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)