From 4dd51f703b98e5f4d62166dd7c7e407bbf8d8114 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 4 Jan 2024 14:58:02 +0530 Subject: [PATCH] fix: serial / batch barcode scanner (#39114) (cherry picked from commit f09e2130a1a3ca999247f89f76c20efcc003b450) --- erpnext/controllers/accounts_controller.py | 14 ++ erpnext/public/js/controllers/transaction.js | 30 ++- erpnext/public/js/utils/barcode_scanner.js | 233 ++++++++++++++---- .../serial_and_batch_bundle.py | 63 ++++- erpnext/stock/serial_batch_bundle.py | 2 +- erpnext/stock/utils.py | 7 + 6 files changed, 284 insertions(+), 65 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 9c3135d6b108..5cc051b936ca 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -129,6 +129,17 @@ def onload(self): if self.doctype in relevant_docs: self.set_payment_schedule() + def remove_bundle_for_non_stock_invoices(self): + has_sabb = False + if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock: + for item in self.get("items"): + if item.serial_and_batch_bundle: + item.serial_and_batch_bundle = None + has_sabb = True + + if has_sabb: + self.remove_serial_and_batch_bundle() + def ensure_supplier_is_not_blocked(self): is_supplier_payment = self.doctype == "Payment Entry" and self.party_type == "Supplier" is_buying_invoice = self.doctype in ["Purchase Invoice", "Purchase Order"] @@ -156,6 +167,9 @@ def validate(self): if self.get("_action") and self._action != "update_after_submit": self.set_missing_values(for_validate=True) + if self.get("_action") == "submit": + self.remove_bundle_for_non_stock_invoices() + self.ensure_supplier_is_not_blocked() self.validate_date_with_fiscal_year() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 07b1e8f84772..43c9fd2104d2 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -454,7 +454,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe item.weight_uom = ''; item.conversion_factor = 0; - if(['Sales Invoice'].includes(this.frm.doc.doctype)) { + if(['Sales Invoice', 'Purchase Invoice'].includes(this.frm.doc.doctype)) { update_stock = cint(me.frm.doc.update_stock); show_batch_dialog = update_stock; @@ -545,7 +545,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, () => me.toggle_conversion_factor(item), () => { - if (show_batch_dialog) + if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner) return frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) .then((r) => { if (r.message && @@ -1239,6 +1239,20 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + sync_bundle_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.sync_bundle_data(); + barcode_scanner.remove_item_from_localstorage(); + } + } + + before_save(doc) { + this.sync_bundle_data(); + } + service_start_date(frm, cdt, cdn) { var child = locals[cdt][cdn]; @@ -1576,6 +1590,18 @@ 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/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index a1ebfe9aa4a7..cf7fab89ffbb 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -7,8 +7,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.scan_barcode_field = this.frm.fields_dict[this.scan_field_name]; this.barcode_field = opts.barcode_field || "barcode"; - this.serial_no_field = opts.serial_no_field || "serial_no"; - this.batch_no_field = opts.batch_no_field || "batch_no"; this.uom_field = opts.uom_field || "uom"; this.qty_field = opts.qty_field || "qty"; // field name on row which defines max quantity to be scanned e.g. picklist @@ -84,6 +82,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { update_table(data) { return new Promise((resolve, reject) => { let cur_grid = this.frm.fields_dict[this.items_table_name].grid; + frappe.flags.trigger_from_barcode_scanner = true; const {item_code, barcode, batch_no, serial_no, uom} = data; @@ -106,50 +105,38 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.frm.has_items = false; } - if (this.is_duplicate_serial_no(row, serial_no)) { + if (serial_no && this.is_duplicate_serial_no(row, item_code, serial_no)) { this.clean_up(); reject(); return; } frappe.run_serially([ - () => this.set_selector_trigger_flag(data), - () => this.set_serial_no(row, serial_no), - () => this.set_batch_no(row, batch_no), + () => this.set_serial_and_batch(row, item_code, serial_no, batch_no), () => this.set_barcode(row, barcode), () => this.set_item(row, item_code, barcode, batch_no, serial_no).then(qty => { this.show_scan_message(row.idx, row.item_code, qty); }), () => this.set_barcode_uom(row, uom), () => this.clean_up(), - () => this.revert_selector_flag(), - () => resolve(row) + () => resolve(row), + () => { + if (row.serial_and_batch_bundle && !this.frm.is_new()) { + this.frm.save(); + } + + frappe.flags.trigger_from_barcode_scanner = false; + } ]); }); } - // batch and serial selector is reduandant when all info can be added by scan - // this flag on item row is used by transaction.js to avoid triggering selector - set_selector_trigger_flag(data) { - const {has_batch_no, has_serial_no} = data; - - const require_selecting_batch = has_batch_no; - const require_selecting_serial = has_serial_no; - - if (!(require_selecting_batch || require_selecting_serial)) { - frappe.flags.hide_serial_batch_dialog = true; - } - } - - revert_selector_flag() { - frappe.flags.hide_serial_batch_dialog = false; - } - set_item(row, item_code, barcode, batch_no, serial_no) { return new Promise(resolve => { const increment = async (value = 1) => { const item_data = {item_code: item_code}; item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value); + frappe.flags.trigger_from_barcode_scanner = true; await frappe.model.set_value(row.doctype, row.name, item_data); return value; }; @@ -158,8 +145,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { frappe.prompt(__("Please enter quantity for item {0}", [item_code]), ({value}) => { increment(value).then((value) => resolve(value)); }); - } else if (this.frm.has_items) { - this.prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no); } else { increment().then((value) => resolve(value)); } @@ -182,9 +167,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { frappe.model.set_value(row.doctype, row.name, item_data); frappe.run_serially([ - () => this.set_batch_no(row, this.dialog.get_value("batch_no")), () => this.set_barcode(row, this.dialog.get_value("barcode")), - () => this.set_serial_no(row, this.dialog.get_value("serial_no")), + () => this.set_serial_and_batch(row, item_code, this.dialog.get_value("serial_no"), this.dialog.get_value("batch_no")), () => this.add_child_for_remaining_qty(row), () => this.clean_up() ]); @@ -338,29 +322,141 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } } - async set_serial_no(row, serial_no) { - if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) { - const existing_serial_nos = row[this.serial_no_field]; - let new_serial_nos = ""; + async set_serial_and_batch(row, item_code, serial_no, batch_no) { + if (this.frm.is_new() || !row.serial_and_batch_bundle) { + this.set_bundle_in_localstorage(row, item_code, serial_no, batch_no); + } else if(row.serial_and_batch_bundle) { + frappe.call({ + method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.update_serial_or_batch", + args: { + bundle_id: row.serial_and_batch_bundle, + serial_no: serial_no, + batch_no: batch_no, + }, + }) + } + } - if (!!existing_serial_nos) { - new_serial_nos = existing_serial_nos + "\n" + serial_no; - } else { - new_serial_nos = serial_no; + get_key_for_localstorage() { + let parts = this.frm.doc.name.split("-"); + return parts[parts.length - 1] + this.frm.doc.doctype; + } + + update_localstorage_scanned_data() { + let docname = this.frm.doc.name + if (localStorage[docname]) { + let items = JSON.parse(localStorage[docname]); + let existing_items = this.frm.doc.items.map(d => d.item_code); + if (!existing_items.length) { + localStorage.removeItem(docname); + return; } - await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos); + + for (let item_code in items) { + if (!existing_items.includes(item_code)) { + delete items[item_code]; + } + } + + localStorage[docname] = JSON.stringify(items); } } - async set_barcode_uom(row, uom) { - if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) { - await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom); + async set_bundle_in_localstorage(row, item_code, serial_no, batch_no) { + let docname = this.frm.doc.name + + let entries = JSON.parse(localStorage.getItem(docname)); + if (!entries) { + entries = {}; + } + + let key = item_code; + if (!entries[key]) { + entries[key] = []; + } + + let existing_row = []; + if (!serial_no && batch_no) { + existing_row = entries[key].filter((e) => e.batch_no === batch_no); + if (existing_row.length) { + existing_row[0].qty += 1; + } + } else if (serial_no) { + existing_row = entries[key].filter((e) => e.serial_no === serial_no); + if (existing_row.length) { + frappe.throw(__("Serial No {0} has already scanned.", [serial_no])); + } + } + + if (!existing_row.length) { + entries[key].push({ + "serial_no": serial_no, + "batch_no": batch_no, + "qty": 1 + }); } + + localStorage.setItem(docname, JSON.stringify(entries)); + + // Auto remove from localstorage after 1 hour + setTimeout(() => { + localStorage.removeItem(docname); + }, 3600000) } - async set_batch_no(row, batch_no) { - if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) { - await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no); + remove_item_from_localstorage() { + let docname = this.frm.doc.name; + if (localStorage[docname]) { + localStorage.removeItem(docname); + } + } + + async sync_bundle_data() { + let docname = this.frm.doc.name; + + if (localStorage[docname]) { + let entries = JSON.parse(localStorage[docname]); + if (entries) { + for (let entry in entries) { + let row = this.frm.doc.items.filter((item) => { + if (item.item_code === entry) { + return true; + } + })[0]; + + if (row) { + this.create_serial_and_batch_bundle(row, entries, entry) + .then(() => { + if (!entries) { + localStorage.removeItem(docname); + } + }); + } + } + } + } + } + + async create_serial_and_batch_bundle(row, entries, key) { + frappe.call({ + method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers", + args: { + entries: entries[key], + child_row: row, + doc: this.frm.doc, + warehouse: row.warehouse, + do_not_save: 1 + }, + callback: function(r) { + row.serial_and_batch_bundle = r.message.name; + delete entries[key]; + } + }) + } + + async set_barcode_uom(row, uom) { + if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) { + await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom); } } @@ -379,13 +475,52 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } } - is_duplicate_serial_no(row, serial_no) { - const is_duplicate = row[this.serial_no_field]?.includes(serial_no); + is_duplicate_serial_no(row, item_code, serial_no) { + if (this.frm.is_new() || !row.serial_and_batch_bundle) { + let is_duplicate = this.check_duplicate_serial_no_in_localstorage(item_code, serial_no); + if (is_duplicate) { + this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); + } - if (is_duplicate) { - this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); + return is_duplicate; + } else if (row.serial_and_batch_bundle) { + this.check_duplicate_serial_no_in_db(row, serial_no, (r) => { + if (r.message) { + this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange"); + } + + return r.message; + }) } - return is_duplicate; + } + + async check_duplicate_serial_no_in_db(row, serial_no, response) { + frappe.call({ + method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_duplicate_serial_no", + args: { + serial_no: serial_no, + bundle_id: row.serial_and_batch_bundle + }, + callback(r) { + response(r); + } + }) + } + + check_duplicate_serial_no_in_localstorage(item_code, serial_no) { + let docname = this.frm.doc.name + let entries = JSON.parse(localStorage.getItem(docname)); + + if (!entries) { + return false; + } + + let existing_row = []; + if (entries[item_code]) { + existing_row = entries[item_code].filter((e) => e.serial_no === serial_no); + } + + return existing_row.length; } get_row_to_modify_on_scan(item_code, batch_no, uom, barcode) { 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 218406f56fd5..eede92882705 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 @@ -729,19 +729,13 @@ def validate_duplicate_serial_and_batch_no(self): def before_cancel(self): self.delink_serial_and_batch_bundle() - self.clear_table() def delink_serial_and_batch_bundle(self): - self.voucher_no = None - sles = frappe.get_all("Stock Ledger Entry", filters={"serial_and_batch_bundle": self.name}) for sle in sles: frappe.db.set_value("Stock Ledger Entry", sle.name, "serial_and_batch_bundle", None) - def clear_table(self): - self.set("entries", []) - @property def child_table(self): if self.voucher_type == "Job Card": @@ -876,7 +870,6 @@ def on_trash(self): self.validate_voucher_no_docstatus() self.delink_refernce_from_voucher() self.delink_reference_from_batch() - self.clear_table() @frappe.whitelist() def add_serial_batch(self, data): @@ -1156,7 +1149,7 @@ def get_filters_for_bundle(item_code=None, docstatus=None, voucher_no=None, name @frappe.whitelist() -def add_serial_batch_ledgers(entries, child_row, doc, warehouse) -> object: +def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object: if isinstance(child_row, str): child_row = frappe._dict(parse_json(child_row)) @@ -1170,20 +1163,23 @@ def add_serial_batch_ledgers(entries, child_row, doc, warehouse) -> object: if frappe.db.exists("Serial and Batch Bundle", child_row.serial_and_batch_bundle): sb_doc = update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse) else: - sb_doc = create_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse) + sb_doc = create_serial_batch_no_ledgers( + entries, child_row, parent_doc, warehouse, do_not_save=do_not_save + ) return sb_doc -def create_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=None) -> object: +def create_serial_batch_no_ledgers( + entries, child_row, parent_doc, warehouse=None, do_not_save=False +) -> object: warehouse = warehouse or ( child_row.rejected_warehouse if child_row.is_rejected else child_row.warehouse ) - type_of_transaction = child_row.type_of_transaction + type_of_transaction = get_type_of_transaction(parent_doc, child_row) if parent_doc.get("doctype") == "Stock Entry": - type_of_transaction = "Outward" if child_row.s_warehouse else "Inward" warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse doc = frappe.get_doc( @@ -1214,13 +1210,30 @@ def create_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=Non doc.save() - frappe.db.set_value(child_row.doctype, child_row.name, "serial_and_batch_bundle", doc.name) + if do_not_save: + frappe.db.set_value(child_row.doctype, child_row.name, "serial_and_batch_bundle", doc.name) frappe.msgprint(_("Serial and Batch Bundle created"), alert=True) return doc +def get_type_of_transaction(parent_doc, child_row): + type_of_transaction = child_row.type_of_transaction + if parent_doc.get("doctype") == "Stock Entry": + type_of_transaction = "Outward" if child_row.s_warehouse else "Inward" + + if not type_of_transaction: + type_of_transaction = "Outward" + if parent_doc.get("doctype") in ["Purchase Receipt", "Purchase Invoice"]: + type_of_transaction = "Inward" + + if parent_doc.get("is_return"): + type_of_transaction = "Inward" if type_of_transaction == "Outward" else "Outward" + + return type_of_transaction + + def update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=None) -> object: doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle) doc.voucher_detail_no = child_row.name @@ -1247,6 +1260,25 @@ def update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=Non return doc +@frappe.whitelist() +def update_serial_or_batch(bundle_id, serial_no=None, batch_no=None): + if batch_no and not serial_no: + if qty := frappe.db.get_value( + "Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty" + ): + frappe.db.set_value( + "Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1 + ) + return + + doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id) + if not serial_no and not batch_no: + return + + doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1}) + doc.save(ignore_permissions=True) + + def get_serial_and_batch_ledger(**kwargs): kwargs = frappe._dict(kwargs) @@ -2032,3 +2064,8 @@ def get_stock_ledgers_batches(kwargs): @frappe.whitelist() def get_batch_no_from_serial_no(serial_no): return frappe.get_cached_value("Serial No", serial_no, "batch_no") + + +@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}) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 39df2279cd2e..4cfe5d817e6a 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -209,7 +209,7 @@ def delink_serial_and_batch_bundle(self): frappe.db.set_value( "Serial and Batch Bundle", {"voucher_no": self.sle.voucher_no, "voucher_type": self.sle.voucher_type}, - {"is_cancelled": 1, "voucher_no": ""}, + {"is_cancelled": 1}, ) if self.sle.serial_and_batch_bundle: diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index bd0d4697c94b..4b0e2845c446 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -591,6 +591,13 @@ def get_cache() -> Optional[BarcodeScanResult]: as_dict=True, ) if batch_no_data: + if frappe.get_cached_value("Item", batch_no_data.item_code, "has_serial_no"): + frappe.throw( + _( + "Batch No {0} is linked with Item {1} which has serial no. Please scan serial no instead." + ).format(search_value, batch_no_data.item_code) + ) + _update_item_info(batch_no_data) set_cache(batch_no_data) return batch_no_data