diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 0e039b9d1b..c337f0dcf6 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -7,7 +7,7 @@ from typing import List, Tuple import frappe from frappe import _, bold -from frappe.utils import cint, flt, get_link_to_form, getdate +from frappe.utils import cint, cstr, flt, get_link_to_form, getdate import erpnext from erpnext.accounts.general_ledger import ( @@ -174,13 +174,16 @@ class StockController(AccountsController): table_name = "stock_items" for row in self.get(table_name): + if row.serial_and_batch_bundle and (row.serial_no or row.batch_no): + self.validate_serial_nos_and_batches_with_bundle(row) + if not row.serial_no and not row.batch_no and not row.get("rejected_serial_no"): continue if not row.use_serial_batch_fields and ( row.serial_no or row.batch_no or row.get("rejected_serial_no") ): - frappe.throw(_("Please enable Use Old Serial / Batch Fields to make_bundle")) + row.use_serial_batch_fields = 1 if row.use_serial_batch_fields and ( not row.serial_and_batch_bundle and not row.get("rejected_serial_and_batch_bundle") @@ -232,6 +235,41 @@ class StockController(AccountsController): } ) + def validate_serial_nos_and_batches_with_bundle(self, row): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + throw_error = False + if row.serial_no: + serial_nos = frappe.get_all( + "Serial and Batch Entry", fields=["serial_no"], filters={"parent": row.serial_and_batch_bundle} + ) + serial_nos = sorted([cstr(d.serial_no) for d in serial_nos]) + parsed_serial_nos = get_serial_nos(row.serial_no) + + if len(serial_nos) != len(parsed_serial_nos): + throw_error = True + elif serial_nos != parsed_serial_nos: + for serial_no in serial_nos: + if serial_no not in parsed_serial_nos: + throw_error = True + break + + elif row.batch_no: + batches = frappe.get_all( + "Serial and Batch Entry", fields=["batch_no"], filters={"parent": row.serial_and_batch_bundle} + ) + batches = sorted([d.batch_no for d in batches]) + + if batches != [row.batch_no]: + throw_error = True + + if throw_error: + frappe.throw( + _( + "At row {0}: Serial and Batch Bundle {1} has already created. Please remove the values from the serial no or batch no fields." + ).format(row.idx, row.serial_and_batch_bundle) + ) + def set_use_serial_batch_fields(self): if frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields"): for row in self.items: diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index 47762ac4cf..95a7bcb398 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -401,7 +401,7 @@ class TestSubcontractingController(FrappeTestCase): { "main_item_code": "Subcontracted Item SA4", "item_code": "Subcontracted SRM Item 3", - "qty": 1.0, + "qty": 3.0, "rate": 100.0, "stock_uom": "Nos", "warehouse": "_Test Warehouse - _TC", @@ -914,12 +914,6 @@ def update_item_details(child_row, details): else child_row.get("consumed_qty") ) - if child_row.serial_no: - details.serial_no.extend(get_serial_nos(child_row.serial_no)) - - if child_row.batch_no: - details.batch_no[child_row.batch_no] += child_row.get("qty") or child_row.get("consumed_qty") - if child_row.serial_and_batch_bundle: doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle) for row in doc.get("entries"): @@ -928,6 +922,12 @@ def update_item_details(child_row, details): if row.batch_no: details.batch_no[row.batch_no] += row.qty * (-1 if doc.type_of_transaction == "Outward" else 1) + else: + if child_row.serial_no: + details.serial_no.extend(get_serial_nos(child_row.serial_no)) + + if child_row.batch_no: + details.batch_no[child_row.batch_no] += child_row.get("qty") or child_row.get("consumed_qty") def make_stock_transfer_entry(**args): diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index b3d301d988..1d0d47ec3d 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -368,6 +368,7 @@ erpnext.buying = { let update_values = { "serial_and_batch_bundle": r.name, + "use_serial_batch_fields": 0, "qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)) } @@ -408,6 +409,7 @@ erpnext.buying = { let update_values = { "serial_and_batch_bundle": r.name, + "use_serial_batch_fields": 0, "rejected_qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)) } diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index d08c36c1d0..c0298a4f14 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -145,6 +145,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } + if(this.frm.fields_dict['items'].grid.get_field('batch_no')) { + this.frm.set_query('batch_no', 'items', function(doc, cdt, cdn) { + return me.set_query_for_batch(doc, cdt, cdn); + }); + } + if( this.frm.docstatus < 2 && this.frm.fields_dict["payment_terms_template"] @@ -1633,18 +1639,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe return item_list; } - items_delete() { - this.update_localstorage_scanned_data(); - } - - update_localstorage_scanned_data() { - let doctypes = ["Sales Invoice", "Purchase Invoice", "Delivery Note", "Purchase Receipt"]; - if (this.frm.is_new() && doctypes.includes(this.frm.doc.doctype)) { - const barcode_scanner = new erpnext.utils.BarcodeScanner({frm:this.frm}); - barcode_scanner.update_localstorage_scanned_data(); - } - } - _set_values_for_item_list(children) { const items_rule_dict = {}; diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index 4bb78433ae..a957530ec8 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -339,6 +339,7 @@ erpnext.sales_common = { frappe.model.set_value(item.doctype, item.name, { "serial_and_batch_bundle": r.name, + "use_serial_batch_fields": 0, "qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)) }); } diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 80ade7086c..fccaf88c71 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -71,6 +71,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { let warehouse = this.item?.type_of_transaction === "Outward" ? (this.item.warehouse || this.item.s_warehouse) : ""; + if (this.frm.doc.doctype === 'Stock Entry') { + warehouse = this.item.s_warehouse || this.item.t_warehouse; + } + if (!warehouse && this.frm.doc.doctype === 'Stock Reconciliation') { warehouse = this.get_warehouse(); } @@ -367,19 +371,11 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { label: __('Batch No'), in_list_view: 1, get_query: () => { - if (this.item.type_of_transaction !== "Outward") { - return { - filters: { - 'item': this.item.item_code, - } - } - } else { - return { - query : "erpnext.controllers.queries.get_batch_no", - filters: { - 'item_code': this.item.item_code, - 'warehouse': this.get_warehouse() - } + return { + query : "erpnext.controllers.queries.get_batch_no", + filters: { + 'item_code': this.item.item_code, + 'warehouse': this.item.s_warehouse || this.item.t_warehouse, } } }, diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 056cd5cc99..3a5daa1bb7 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -330,6 +330,7 @@ frappe.ui.form.on('Pick List Item', { let qty = Math.abs(r.total_qty); frappe.model.set_value(item.doctype, item.name, { "serial_and_batch_bundle": r.name, + "use_serial_batch_fields": 0, "qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item)) }); } diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 2b18507a5f..e906d4986b 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2280,6 +2280,30 @@ class TestPurchaseReceipt(FrappeTestCase): serial_no_status = frappe.db.get_value("Serial No", sn, "status") self.assertTrue(serial_no_status != "Active") + def test_auto_set_batch_based_on_bundle(self): + item_code = make_item( + "_Test Auto Set Batch Based on Bundle", + properties={ + "has_batch_no": 1, + "batch_number_series": "BATCH-BNU-TASBBB-.#####", + "create_new_batch": 1, + }, + ).name + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) + + pr = make_purchase_receipt( + item_code=item_code, + qty=5, + rate=100, + ) + + self.assertTrue(pr.items[0].batch_no) + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + self.assertEqual(pr.items[0].batch_no, batch_no) + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js index 91b743016b..1f7bb4d245 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js @@ -207,13 +207,24 @@ frappe.ui.form.on('Serial and Batch Bundle', { }; }); - frm.set_query('batch_no', 'entries', () => { - return { - filters: { - item: frm.doc.item_code, - disabled: 0, + frm.set_query('batch_no', 'entries', (doc) => { + + if (doc.type_of_transaction ==="Outward") { + return { + query : "erpnext.controllers.queries.get_batch_no", + filters: { + item_code: doc.item_code, + warehouse: doc.warehouse, + } } - }; + } else { + return { + filters: { + item: doc.item_code, + disabled: 0, + } + }; + } }); frm.set_query('warehouse', 'entries', () => { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 427147c4e4..7f79f04aae 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -1180,6 +1180,7 @@ erpnext.stock.select_batch_and_serial_no = (frm, item) => { if (r) { frappe.model.set_value(item.doctype, item.name, { "serial_and_batch_bundle": r.name, + "use_serial_batch_fields": 0, "qty": Math.abs(r.total_qty) / flt(item.conversion_factor || 1, precision("conversion_factor", item)) }); } diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 81be3d1fc1..cbad472377 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -834,6 +834,7 @@ class StockEntry(StockController): currency=erpnext.get_company_currency(self.company), company=self.company, raise_error_if_no_rate=raise_error_if_no_rate, + batch_no=d.batch_no, serial_and_batch_bundle=d.serial_and_batch_bundle, ) @@ -862,7 +863,7 @@ class StockEntry(StockController): if reset_outgoing_rate: args = self.get_args_for_incoming_rate(d) rate = get_incoming_rate(args, raise_error_if_no_rate) - if rate > 0: + if rate >= 0: d.basic_rate = rate d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) @@ -885,6 +886,8 @@ class StockEntry(StockController): "allow_zero_valuation": item.allow_zero_valuation_rate, "serial_and_batch_bundle": item.serial_and_batch_bundle, "voucher_detail_no": item.name, + "batch_no": item.batch_no, + "serial_no": item.serial_no, } ) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index ce08615ed5..c69b20b7d9 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -834,6 +834,7 @@ class StockReconciliation(StockController): if voucher_detail_no != row.name: continue + val_rate = 0.0 current_qty = 0.0 if row.current_serial_and_batch_bundle: current_qty = self.get_current_qty_for_serial_or_batch(row) @@ -843,7 +844,6 @@ class StockReconciliation(StockController): row.warehouse, self.posting_date, self.posting_time, - voucher_no=self.name, ) current_qty = item_dict.get("qty") @@ -885,7 +885,7 @@ class StockReconciliation(StockController): {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, "name", ) - and (not row.current_serial_and_batch_bundle and not row.batch_no) + and (not row.current_serial_and_batch_bundle) ): self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True) row.reload() diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index b00b422a67..2ec757b205 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -91,6 +91,12 @@ frappe.query_reports["Stock Ledger"] = { "options": "Currency\nFloat", "default": "Currency" }, + { + "fieldname": "segregate_serial_batch_bundle", + "label": __("Segregate Serial / Batch Bundle"), + "fieldtype": "Check", + "default": 0 + } ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 86af9e9a06..50764357fe 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -2,6 +2,8 @@ # License: GNU General Public License v3. See license.txt +import copy + import frappe from frappe import _ from frappe.query_builder.functions import CombineDatetime @@ -26,6 +28,10 @@ def execute(filters=None): item_details = get_item_details(items, sl_entries, include_uom) opening_row = get_opening_balance(filters, columns, sl_entries) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + bundle_details = {} + + if filters.get("segregate_serial_batch_bundle"): + bundle_details = get_serial_batch_bundle_details(sl_entries) data = [] conversion_factors = [] @@ -45,6 +51,9 @@ def execute(filters=None): item_detail = item_details[sle.item_code] sle.update(item_detail) + if bundle_info := bundle_details.get(sle.serial_and_batch_bundle): + data.extend(get_segregated_bundle_entries(sle, bundle_info)) + continue if filters.get("batch_no") or inventory_dimension_filters_applied: actual_qty += flt(sle.actual_qty, precision) @@ -76,6 +85,60 @@ def execute(filters=None): return columns, data +def get_segregated_bundle_entries(sle, bundle_details): + segregated_entries = [] + qty_before_transaction = sle.qty_after_transaction - sle.actual_qty + stock_value_before_transaction = sle.stock_value - sle.stock_value_difference + + for row in bundle_details: + new_sle = copy.deepcopy(sle) + new_sle.update(row) + + new_sle.update( + { + "in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty else 0, + "in_qty": row.qty if row.qty > 0 else 0, + "out_qty": row.qty if row.qty < 0 else 0, + "qty_after_transaction": qty_before_transaction + row.qty, + "stock_value": stock_value_before_transaction + new_sle.stock_value_difference, + "incoming_rate": row.incoming_rate if row.qty > 0 else 0, + } + ) + + qty_before_transaction += row.qty + stock_value_before_transaction += new_sle.stock_value_difference + + new_sle.valuation_rate = ( + stock_value_before_transaction / qty_before_transaction if qty_before_transaction else 0 + ) + + segregated_entries.append(new_sle) + + return segregated_entries + + +def get_serial_batch_bundle_details(sl_entries): + bundle_details = [] + for sle in sl_entries: + if sle.serial_and_batch_bundle: + bundle_details.append(sle.serial_and_batch_bundle) + + if not bundle_details: + return frappe._dict({}) + + _bundle_details = frappe._dict({}) + batch_entries = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", bundle_details)}, + fields=["parent", "qty", "incoming_rate", "stock_value_difference", "batch_no", "serial_no"], + order_by="parent, idx", + ) + for entry in batch_entries: + _bundle_details.setdefault(entry.parent, []).append(entry) + + return _bundle_details + + def update_available_serial_nos(available_serial_nos, sle): serial_nos = get_serial_nos(sle.serial_no) key = (sle.item_code, sle.warehouse) @@ -256,7 +319,6 @@ def get_columns(filters): "options": "Serial and Batch Bundle", "width": 100, }, - {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100}, { "label": _("Project"), "fieldname": "project", diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index d8b5b34d44..fc1cca46f6 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -5,7 +5,7 @@ import frappe from frappe import _, bold from frappe.model.naming import make_autoname from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import cint, flt, get_link_to_form, now, nowtime, today +from frappe.utils import cint, cstr, flt, get_link_to_form, now, nowtime, today from erpnext.stock.deprecated_serial_batch import ( DeprecatedBatchNoValuation, @@ -138,9 +138,17 @@ class SerialBatchBundle: self.child_doctype, self.sle.voucher_detail_no, "rejected_serial_and_batch_bundle", sn_doc.name ) else: - frappe.db.set_value( - self.child_doctype, self.sle.voucher_detail_no, "serial_and_batch_bundle", sn_doc.name - ) + values_to_update = { + "serial_and_batch_bundle": sn_doc.name, + } + + if frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields"): + if sn_doc.has_serial_no: + values_to_update["serial_no"] = "\n".join(cstr(d.serial_no) for d in sn_doc.entries) + elif sn_doc.has_batch_no and len(sn_doc.entries) == 1: + values_to_update["batch_no"] = sn_doc.entries[0].batch_no + + frappe.db.set_value(self.child_doctype, self.sle.voucher_detail_no, values_to_update) @property def child_doctype(self): @@ -905,8 +913,6 @@ class SerialBatchCreation: self.batches = get_available_batches(kwargs) def set_auto_serial_batch_entries_for_inward(self): - print(self.get("serial_nos")) - if (self.get("batches") and self.has_batch_no) or ( self.get("serial_nos") and self.has_serial_no ): diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 54e0ab5acf..5410e692c4 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -262,7 +262,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): item_code=args.get("item_code"), ) - in_rate = sn_obj.get_incoming_rate() + return sn_obj.get_incoming_rate() elif item_details and item_details.has_batch_no and args.get("serial_and_batch_bundle"): args.actual_qty = args.qty @@ -272,23 +272,33 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): item_code=args.get("item_code"), ) - in_rate = batch_obj.get_incoming_rate() + return batch_obj.get_incoming_rate() elif (args.get("serial_no") or "").strip() and not args.get("serial_and_batch_bundle"): - in_rate = get_avg_purchase_rate(args.get("serial_no")) + args.actual_qty = args.qty + args.serial_nos = get_serial_nos_data(args.get("serial_no")) + + sn_obj = SerialNoValuation( + sle=args, warehouse=args.get("warehouse"), item_code=args.get("item_code") + ) + + return sn_obj.get_incoming_rate() elif ( args.get("batch_no") and frappe.db.get_value("Batch", args.get("batch_no"), "use_batchwise_valuation", cache=True) and not args.get("serial_and_batch_bundle") ): - in_rate = get_batch_incoming_rate( - item_code=args.get("item_code"), + + args.actual_qty = args.qty + args.batch_nos = frappe._dict({args.batch_no: args}) + + batch_obj = BatchNoValuation( + sle=args, warehouse=args.get("warehouse"), - batch_no=args.get("batch_no"), - posting_date=args.get("posting_date"), - posting_time=args.get("posting_time"), + item_code=args.get("item_code"), ) + return batch_obj.get_incoming_rate() else: valuation_method = get_valuation_method(args.get("item_code")) previous_sle = get_previous_sle(args)