From f4cfc589c6cdc7cd27e4316c905dca87fa434c69 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 11 Apr 2023 13:22:15 +0530 Subject: [PATCH] fix: serial and batch selector --- .../doctype/work_order/test_work_order.py | 1 - .../js/utils/serial_no_batch_selector.js | 41 +++- erpnext/stock/deprecated_serial_batch.py | 37 ++-- .../serial_and_batch_bundle.js | 96 +++++++++ .../serial_and_batch_bundle.json | 13 +- .../serial_and_batch_bundle.py | 186 +++++++++++++++++- 6 files changed, 341 insertions(+), 33 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 49ce6b95fd..3c7c787df8 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1527,7 +1527,6 @@ class TestWorkOrder(FrappeTestCase): ste_doc.load_from_db() # Create a stock entry to manufacture the item - print("remove 2 qty from each item") ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 5)) for row in ste_doc.items: if row.s_warehouse and not row.t_warehouse: diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 382ae2c964..6d3af42b4c 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -12,12 +12,12 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { } make() { - let label = this.item?.has_serial_no ? __('Serial No') : __('Batch No'); + let label = this.item?.has_serial_no ? __('Serial Nos') : __('Batch Nos'); let primary_label = this.bundle ? __('Update') : __('Add'); if (this.item?.has_serial_no && this.item?.batch_no) { - label = __('Serial No / Batch No'); + label = __('Serial Nos / Batch Nos'); } primary_label += ' ' + label; @@ -26,7 +26,9 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { title: this.item?.title || primary_label, fields: this.get_dialog_fields(), primary_action_label: primary_label, - primary_action: () => this.update_ledgers() + primary_action: () => this.update_ledgers(), + secondary_action_label: __('Edit Full Form'), + secondary_action: () => this.edit_full_form(), }); this.dialog.set_value("qty", this.item.qty); @@ -48,7 +50,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { if (this.item.has_serial_no) { fields.push({ - fieldtype: 'Link', + fieldtype: 'Data', fieldname: 'scan_serial_no', label: __('Scan Serial No'), options: 'Serial No', @@ -279,6 +281,37 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { }) } + edit_full_form() { + let bundle_id = this.item.serial_and_batch_bundle + if (!bundle_id) { + _new = frappe.model.get_new_doc( + "Serial and Batch Bundle", null, null, true + ); + + _new.item_code = this.item.item_code; + _new.warehouse = this.get_warehouse(); + _new.has_serial_no = this.item.has_serial_no; + _new.has_batch_no = this.item.has_batch_no; + _new.type_of_transaction = this.get_type_of_transaction(); + _new.company = this.frm.doc.company; + _new.voucher_type = this.frm.doc.doctype; + bundle_id = _new.name; + } + + frappe.set_route("Form", "Serial and Batch Bundle", bundle_id); + this.dialog.hide(); + } + + get_warehouse() { + return (this.item?.outward ? + (this.item.warehouse || this.item.s_warehouse) + : (this.item.warehouse || this.item.t_warehouse)); + } + + get_type_of_transaction() { + return (this.item?.outward ? 'Outward' : 'Inward'); + } + render_data() { if (!this.frm.is_new() && this.bundle) { frappe.call({ diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 9e15015aa5..76202ed7b0 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -4,6 +4,7 @@ import frappe from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import flt from frappe.utils.deprecations import deprecated +from pypika import Order class DeprecatedSerialNoValuation: @@ -39,25 +40,25 @@ class DeprecatedSerialNoValuation: # Get rate for serial nos which has been transferred to other company invalid_serial_nos = [d.name for d in all_serial_nos if d.company != self.sle.company] for serial_no in invalid_serial_nos: - incoming_rate = frappe.db.sql( - """ - select incoming_rate - from `tabStock Ledger Entry` - where - company = %s - and serial_and_batch_bundle IS NULL - and actual_qty > 0 - and is_cancelled = 0 - and (serial_no = %s - or serial_no like %s - or serial_no like %s - or serial_no like %s + table = frappe.qb.DocType("Stock Ledger Entry") + incoming_rate = ( + frappe.qb.from_(table) + .select(table.incoming_rate) + .where( + ( + (table.serial_no == serial_no) + | (table.serial_no.like(serial_no + "\n%")) + | (table.serial_no.like("%\n" + serial_no)) + | (table.serial_no.like("%\n" + serial_no + "\n%")) ) - order by posting_date desc - limit 1 - """, - (self.sle.company, serial_no, serial_no + "\n%", "%\n" + serial_no, "%\n" + serial_no + "\n%"), - ) + & (table.company == self.sle.company) + & (table.serial_and_batch_bundle.isnull()) + & (table.actual_qty > 0) + & (table.is_cancelled == 0) + ) + .orderby(table.posting_date, order=Order.desc) + .limit(1) + ).run() self.serial_no_incoming_rate[serial_no] += flt(incoming_rate[0][0]) if incoming_rate else 0 incoming_values += self.serial_no_incoming_rate[serial_no] 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 858b3335d3..b02ad71b16 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 @@ -8,6 +8,17 @@ frappe.ui.form.on('Serial and Batch Bundle', { refresh(frm) { frm.trigger('toggle_fields'); + frm.trigger('prepare_serial_batch_prompt'); + }, + + item_code(frm) { + frm.clear_custom_buttons(); + frm.trigger('prepare_serial_batch_prompt'); + }, + + type_of_transaction(frm) { + frm.clear_custom_buttons(); + frm.trigger('prepare_serial_batch_prompt'); }, warehouse(frm) { @@ -30,6 +41,91 @@ frappe.ui.form.on('Serial and Batch Bundle', { frm.trigger('toggle_fields'); }, + prepare_serial_batch_prompt(frm) { + if (frm.doc.docstatus === 0 && frm.doc.item_code + && frm.doc.type_of_transaction === "Inward") { + let label = frm.doc?.has_serial_no === 1 + ? __('Serial Nos') : __('Batch Nos'); + + if (frm.doc?.has_serial_no === 1 && frm.doc?.has_batch_no === 1) { + label = __('Serial and Batch Nos'); + } + + let fields = frm.events.get_prompt_fields(frm); + + frm.add_custom_button(__("Make " + label), () => { + frappe.prompt(fields, (data) => { + frm.events.add_serial_batch(frm, data); + }, "Add " + label, "Make " + label); + }); + } + }, + + get_prompt_fields(frm) { + let attach_field = { + "label": __("Attach CSV File"), + "fieldname": "csv_file", + "fieldtype": "Attach" + } + + if (!frm.doc.has_batch_no) { + attach_field.depends_on = "eval:doc.using_csv_file === 1" + } + + let fields = [ + { + "label": __("Using CSV File"), + "fieldname": "using_csv_file", + "default": 1, + "fieldtype": "Check", + }, + attach_field, + { + "fieldtype": "Section Break", + } + ] + + if (frm.doc.has_serial_no) { + fields.push({ + "label": "Serial Nos", + "fieldname": "serial_nos", + "fieldtype": "Small Text", + "depends_on": "eval:doc.using_csv_file === 0" + }) + } + + if (frm.doc.has_batch_no) { + fields = attach_field + } + + return fields; + }, + + add_serial_batch(frm, prompt_data) { + frm.events.validate_prompt_data(frm, prompt_data); + + frm.call({ + method: "add_serial_batch", + doc: frm.doc, + args: { + "data": prompt_data, + }, + callback(r) { + refresh_field("entries"); + } + }); + }, + + validate_prompt_data(frm, prompt_data) { + if (prompt_data.using_csv_file && !prompt_data.csv_file) { + frappe.throw(__("Please attach CSV file")); + } + + if (frm.doc.has_serial_no && !prompt_data.using_csv_file && !prompt_data.serial_nos) { + frappe.throw(__("Please enter serial nos")); + } + }, + toggle_fields(frm) { frm.fields_dict.entries.grid.update_docfield_property( 'serial_no', 'read_only', !frm.doc.has_serial_no diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json index 77ba13a0ef..6955c761e1 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json @@ -9,13 +9,13 @@ "item_details_tab", "naming_series", "company", - "warehouse", - "type_of_transaction", - "column_break_4", - "item_code", "item_name", "has_serial_no", "has_batch_no", + "column_break_4", + "item_code", + "warehouse", + "type_of_transaction", "serial_no_and_batch_no_tab", "entries", "quantity_and_rate_section", @@ -84,7 +84,8 @@ "fetch_from": "item_code.item_name", "fieldname": "item_name", "fieldtype": "Data", - "label": "Item Name" + "label": "Item Name", + "read_only": 1 }, { "default": "0", @@ -243,7 +244,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-04-06 02:35:38.404537", + "modified": "2023-04-10 20:02:42.964309", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Bundle", 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 0b7eda90d5..f787caae87 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 @@ -2,6 +2,7 @@ # For license information, please see license.txt import collections +import csv from collections import defaultdict from typing import Dict, List @@ -9,7 +10,17 @@ import frappe from frappe import _, _dict, bold from frappe.model.document import Document from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import add_days, cint, flt, get_link_to_form, nowtime, today +from frappe.utils import ( + add_days, + cint, + cstr, + flt, + get_link_to_form, + now, + nowtime, + parse_json, + today, +) from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle @@ -626,6 +637,173 @@ class SerialandBatchBundle(Document): self.delink_reference_from_batch() self.clear_table() + @frappe.whitelist() + def add_serial_batch(self, data): + serial_nos, batch_nos = [], [] + if isinstance(data, str): + data = parse_json(data) + + if data.get("csv_file"): + serial_nos, batch_nos = get_serial_batch_from_csv(self.item_code, data.get("csv_file")) + else: + serial_nos, batch_nos = get_serial_batch_from_data(self.item_code, data) + + if not serial_nos and not batch_nos: + return + + if serial_nos: + self.set("entries", serial_nos) + elif batch_nos: + self.set("entries", batch_nos) + + +def get_serial_batch_from_csv(item_code, file_path): + file_path = frappe.get_site_path() + file_path + serial_nos = [] + batch_nos = [] + + with open(file_path, "r") as f: + reader = csv.reader(f) + serial_nos, batch_nos = parse_csv_file_to_get_serial_batch(reader) + + if serial_nos: + make_serial_nos(item_code, serial_nos) + + print(batch_nos) + if batch_nos: + make_batch_nos(item_code, batch_nos) + + return serial_nos, batch_nos + + +def parse_csv_file_to_get_serial_batch(reader): + has_serial_no, has_batch_no = False, False + serial_nos = [] + batch_nos = [] + + for index, row in enumerate(reader): + if index == 0: + has_serial_no = row[0] == "Serial No" + has_batch_no = row[0] == "Batch No" + continue + + if not row[0]: + continue + + if has_serial_no or (has_serial_no and has_batch_no): + _dict = {"serial_no": row[0], "qty": 1} + + if has_batch_no: + _dict.update( + { + "batch_no": row[1], + "qty": row[2], + } + ) + + serial_nos.append(_dict) + elif has_batch_no: + batch_nos.append( + { + "batch_no": row[0], + "qty": row[1], + } + ) + + return serial_nos, batch_nos + + +def get_serial_batch_from_data(item_code, kwargs): + serial_nos = [] + batch_nos = [] + if kwargs.get("serial_nos"): + data = parse_serial_nos(kwargs.get("serial_nos")) + for serial_no in data: + if not serial_no: + continue + serial_nos.append({"serial_no": serial_no, "qty": 1}) + + make_serial_nos(item_code, serial_nos) + + return serial_nos, batch_nos + + +def make_serial_nos(item_code, serial_nos): + item = frappe.get_cached_value("Item", item_code, ["description", "item_code"], as_dict=1) + + serial_nos = [d.get("serial_no") for d in serial_nos if d.get("serial_no")] + + serial_nos_details = [] + user = frappe.session.user + for serial_no in serial_nos: + serial_nos_details.append( + ( + serial_no, + serial_no, + now(), + now(), + user, + user, + item.item_code, + item.item_name, + item.description, + "Inactive", + ) + ) + + fields = [ + "name", + "serial_no", + "creation", + "modified", + "owner", + "modified_by", + "item_code", + "item_name", + "description", + "status", + ] + + frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) + + frappe.msgprint(_("Serial Nos are created successfully")) + + +def make_batch_nos(item_code, batch_nos): + item = frappe.get_cached_value("Item", item_code, ["description", "item_code"], as_dict=1) + + batch_nos = [d.get("batch_no") for d in batch_nos if d.get("batch_no")] + + batch_nos_details = [] + user = frappe.session.user + for batch_no in batch_nos: + batch_nos_details.append( + (batch_no, batch_no, now(), now(), user, user, item.item_code, item.item_name, item.description) + ) + + fields = [ + "name", + "batch_id", + "creation", + "modified", + "owner", + "modified_by", + "item", + "item_name", + "description", + ] + + frappe.db.bulk_insert("Batch", fields=fields, values=set(batch_nos_details)) + + frappe.msgprint(_("Batch Nos are created successfully")) + + +def parse_serial_nos(data): + if isinstance(data, list): + return data + + return [s.strip() for s in cstr(data).strip().upper().replace(",", "\n").split("\n") if s.strip()] + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -690,13 +868,13 @@ def get_filters_for_bundle(item_code, docstatus=None, voucher_no=None, name=None @frappe.whitelist() def add_serial_batch_ledgers(entries, child_row, doc) -> object: if isinstance(child_row, str): - child_row = frappe._dict(frappe.parse_json(child_row)) + child_row = frappe._dict(parse_json(child_row)) if isinstance(entries, str): - entries = frappe.parse_json(entries) + entries = parse_json(entries) if doc and isinstance(doc, str): - parent_doc = frappe.parse_json(doc) + parent_doc = parse_json(doc) if frappe.db.exists("Serial and Batch Bundle", child_row.serial_and_batch_bundle): doc = update_serial_batch_no_ledgers(entries, child_row, parent_doc)