diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 85624d5afb..b55574fb4a 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -38,6 +38,7 @@ class BuyingController(SubcontractingController): self.set_supplier_address() self.validate_asset_return() self.validate_auto_repeat_subscription_dates() + self.create_package_for_transfer() if self.doctype == "Purchase Invoice": self.validate_purchase_receipt_if_update_stock() @@ -69,6 +70,36 @@ class BuyingController(SubcontractingController): ), ) + def create_package_for_transfer(self) -> None: + """Create serial and batch package for Sourece Warehouse in case of inter transfer.""" + + if self.is_internal_transfer() and ( + self.doctype == "Purchase Receipt" or (self.doctype == "Purchase Invoice" and self.update_stock) + ): + field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item" + + doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item" + + ids = [d.get(field) for d in self.get("items") if d.get(field)] + bundle_ids = {} + if ids: + for bundle in frappe.get_all( + doctype, filters={"name": ("in", ids)}, fields=["serial_and_batch_bundle", "name"] + ): + bundle_ids[bundle.name] = bundle.serial_and_batch_bundle + + if not bundle_ids: + return + + for item in self.get("items"): + if item.get(field) and not item.serial_and_batch_bundle: + item.serial_and_batch_bundle = self.make_package_for_transfer( + bundle_ids.get(item.get(field)), + item.from_warehouse, + type_of_transaction="Outward", + do_not_submit=True, + ) + def set_missing_values(self, for_validate=False): super(BuyingController, self).set_missing_values(for_validate) @@ -467,7 +498,11 @@ class BuyingController(SubcontractingController): { "actual_qty": flt(pr_qty), "serial_no": cstr(d.serial_no).strip(), - "serial_and_batch_bundle": d.serial_and_batch_bundle, + "serial_and_batch_bundle": ( + d.serial_and_batch_bundle + if not self.is_internal_transfer() + else self.get_package_for_target_warehouse(d) + ), }, ) @@ -494,7 +529,6 @@ class BuyingController(SubcontractingController): "recalculate_rate": 1 if (self.is_subcontracted and (d.bom or d.fg_item)) or d.from_warehouse else 0, - "serial_and_batch_bundle": d.serial_and_batch_bundle, } ) sl_entries.append(sle) @@ -531,6 +565,15 @@ class BuyingController(SubcontractingController): via_landed_cost_voucher=via_landed_cost_voucher, ) + def get_package_for_target_warehouse(self, item) -> str: + if not item.serial_and_batch_bundle: + return "" + + return self.make_package_for_transfer( + item.serial_and_batch_bundle, + item.warehouse, + ) + def update_ordered_and_reserved_qty(self): po_map = {} for d in self.get("items"): diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 15c84a96c8..1dd7209b16 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -531,6 +531,11 @@ class SellingController(StockController): if item_row.warehouse: sle.dependant_sle_voucher_detail_no = item_row.name + if item_row.serial_and_batch_bundle: + sle["serial_and_batch_bundle"] = self.make_package_for_transfer( + item_row.serial_and_batch_bundle, item_row.target_warehouse + ) + return sle def set_po_nos(self, for_validate=False): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2e705eaf2c..2048a42323 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -372,6 +372,44 @@ class StockController(AccountsController): row.db_set("serial_and_batch_bundle", None) + def make_package_for_transfer( + self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None + ): + bundle_doc = frappe.get_doc("Serial and Batch Bundle", serial_and_batch_bundle) + + if not type_of_transaction: + type_of_transaction = "Inward" + + bundle_doc = frappe.copy_doc(bundle_doc) + bundle_doc.warehouse = warehouse + bundle_doc.type_of_transaction = type_of_transaction + bundle_doc.voucher_type = self.doctype + bundle_doc.voucher_no = self.name + bundle_doc.is_cancelled = 0 + + for row in bundle_doc.ledgers: + row.is_outward = 0 + row.qty = abs(row.qty) + row.stock_value_difference = abs(row.stock_value_difference) + if type_of_transaction == "Outward": + row.qty *= -1 + row.stock_value_difference *= row.stock_value_difference + row.is_outward = 1 + + row.warehouse = warehouse + + bundle_doc.set_total_qty() + bundle_doc.set_avg_rate() + bundle_doc.flags.ignore_permissions = True + + if not do_not_submit: + bundle_doc.submit() + else: + bundle_doc.save(ignore_permissions=True) + + print(bundle_doc.name) + return bundle_doc.name + def get_sl_entries(self, d, args): sl_dict = frappe._dict( { diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 33b89553b8..f2d266afbc 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -69,7 +69,8 @@ class DeprecatedBatchNoValuation: 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 a647a17f80..ce0684a69b 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -1044,8 +1044,6 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "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 98da0afdee..824691cafc 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 @@ from typing import Dict, List 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 @@ class SerialandBatchBundle(Document): 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 @@ class SerialandBatchBundle(Document): ) 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 @@ class SerialandBatchBundle(Document): {"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 @@ class SerialandBatchBundle(Document): ) ) - 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 += "

" + + 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 @@ def update_serial_batch_no_ledgers(ledgers, child_row, parent_doc) -> object: 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 a7f5b801a5..5e8aff373f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1219,8 +1219,16 @@ class StockEntry(StockController): 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 @@ class StockEntry(StockController): 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 a4fac4d68f..2b88e8b8e4 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -4,7 +4,7 @@ from typing import List 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 @@ class SerialBatchBundle: 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 @@ class SerialNoBundleValuation(DeprecatedSerialNoValuation): 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 @@ class BatchNoBundleValuation(DeprecatedBatchNoValuation): 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 @@ class BatchNoBundleValuation(DeprecatedBatchNoValuation): 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 @@ class BatchNoBundleValuation(DeprecatedBatchNoValuation): & (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)