From 42b229435c568a0e4a9081ef5beb91792348b9a0 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 27 May 2023 19:18:03 +0530 Subject: [PATCH] fix: stock reco test case for serial and batch bundle --- .../doctype/asset_repair/test_asset_repair.py | 1 - .../js/utils/serial_no_batch_selector.js | 77 +++++++++++++++++-- erpnext/stock/doctype/batch/batch.py | 8 +- .../serial_and_batch_bundle.py | 40 ++++++++-- .../test_serial_and_batch_bundle.py | 36 ++++++++- erpnext/stock/doctype/serial_no/serial_no.py | 37 +++------ .../stock/doctype/stock_entry/stock_entry.js | 5 +- .../stock_reconciliation.py | 57 +++++++++++--- .../test_stock_reconciliation.py | 28 +++++-- .../stock_reconciliation_item.json | 8 +- erpnext/stock/serial_batch_bundle.py | 11 ++- erpnext/stock/stock_ledger.py | 20 +++-- 12 files changed, 258 insertions(+), 70 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index c537143dcf..b3e09541e5 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -88,7 +88,6 @@ class TestAssetRepair(unittest.TestCase): self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity) def test_serialized_item_consumption(self): - from erpnext.stock.doctype.serial_no.serial_no import SerialNoRequiredError from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item stock_entry = make_serialized_item() diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 6d3af42b4c..217f568db0 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -53,7 +53,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { fieldtype: 'Data', fieldname: 'scan_serial_no', label: __('Scan Serial No'), - options: 'Serial No', get_query: () => { return { filters: this.get_serial_no_filters() @@ -71,10 +70,9 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { if (this.item.has_batch_no) { fields.push({ - fieldtype: 'Link', + fieldtype: 'Data', fieldname: 'scan_batch_no', label: __('Scan Batch No'), - options: 'Batch', get_query: () => { return { filters: { @@ -104,6 +102,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { if (this.item?.outward) { fields = [...this.get_filter_fields(), ...fields]; + } else { + fields = [...fields, ...this.get_attach_field()]; } fields.push({ @@ -121,6 +121,73 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { return fields; } + get_attach_field() { + 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?.has_batch_no) { + label = __('Serial Nos / Batch Nos'); + } + + return [ + { + fieldtype: 'Section Break', + label: __('{0} {1} via CSV File', [primary_label, label]) + }, + { + fieldtype: 'Button', + fieldname: 'download_csv', + label: __('Download CSV Template'), + click: () => this.download_csv_file() + }, + { + fieldtype: 'Column Break', + }, + { + fieldtype: 'Attach', + fieldname: 'attach_serial_batch_csv', + label: __('Attach CSV File'), + onchange: () => this.upload_csv_file() + } + ] + } + + download_csv_file() { + let csvFileData = ['Serial No']; + + if (this.item.has_serial_no && this.item.has_batch_no) { + csvFileData = ['Serial No', 'Batch No', 'Quantity']; + } else if (this.item.has_batch_no) { + csvFileData = ['Batch No', 'Quantity']; + } + + const method = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.download_blank_csv_template?content=${encodeURIComponent(JSON.stringify(csvFileData))}`; + const w = window.open(frappe.urllib.get_full_url(method)); + if (!w) { + frappe.msgprint(__("Please enable pop-ups")); + } + } + + upload_csv_file() { + const file_path = this.dialog.get_value("attach_serial_batch_csv") + + frappe.call({ + method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.upload_csv_file', + args: { + item_code: this.item.item_code, + file_path: file_path + }, + callback: (r) => { + if (r.message.serial_nos && r.message.serial_nos.length) { + this.set_data(r.message.serial_nos); + } else if (r.message.batch_nos && r.message.batch_nos.length) { + this.set_data(r.message.batch_nos); + } + } + }); + } + get_filter_fields() { return [ { @@ -213,10 +280,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { get_auto_data() { const { qty, based_on } = this.dialog.get_values(); - if (!qty) { - frappe.throw(__('Please enter Qty to Fetch')); - } - if (!based_on) { based_on = 'FIFO'; } diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 3edcbe0b23..98987aea98 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -168,7 +168,12 @@ class Batch(Document): @frappe.whitelist() def get_batch_qty( - batch_no=None, warehouse=None, item_code=None, posting_date=None, posting_time=None + batch_no=None, + warehouse=None, + item_code=None, + posting_date=None, + posting_time=None, + ignore_voucher_nos=None, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -191,6 +196,7 @@ def get_batch_qty( "posting_date": posting_date, "posting_time": posting_time, "batch_no": batch_no, + "ignore_voucher_nos": ignore_voucher_nos, } ) 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 f787caae87..ce5801fb5d 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 @@ -21,6 +21,7 @@ from frappe.utils import ( parse_json, today, ) +from frappe.utils.csvutils import build_csv_response 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 @@ -152,15 +153,15 @@ class SerialandBatchBundle(Document): if self.has_serial_no: sn_obj = SerialNoValuation( sle=sle, - warehouse=self.item_code, - item_code=self.warehouse, + item_code=self.item_code, + warehouse=self.warehouse, ) else: sn_obj = BatchNoValuation( sle=sle, - warehouse=self.item_code, - item_code=self.warehouse, + item_code=self.item_code, + warehouse=self.warehouse, ) for d in self.entries: @@ -657,6 +658,31 @@ class SerialandBatchBundle(Document): self.set("entries", batch_nos) +@frappe.whitelist() +def download_blank_csv_template(content): + csv_data = [] + if isinstance(content, str): + content = parse_json(content) + + csv_data.append(content) + csv_data.append([]) + csv_data.append([]) + + filename = "serial_and_batch_bundle" + build_csv_response(csv_data, filename) + + +@frappe.whitelist() +def upload_csv_file(item_code, file_path): + serial_nos, batch_nos = [], [] + serial_nos, batch_nos = get_serial_batch_from_csv(item_code, file_path) + + return { + "serial_nos": serial_nos, + "batch_nos": batch_nos, + } + + def get_serial_batch_from_csv(item_code, file_path): file_path = frappe.get_site_path() + file_path serial_nos = [] @@ -669,7 +695,6 @@ def get_serial_batch_from_csv(item_code, file_path): if serial_nos: make_serial_nos(item_code, serial_nos) - print(batch_nos) if batch_nos: make_batch_nos(item_code, batch_nos) @@ -938,7 +963,7 @@ def update_serial_batch_no_ledgers(entries, child_row, parent_doc) -> object: doc.append( "entries", { - "qty": 1 if doc.type_of_transaction == "Inward" else -1, + "qty": d.get("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"), @@ -1272,6 +1297,9 @@ def get_available_batches(kwargs): else: query = query.orderby(batch_table.creation) + if kwargs.get("ignore_voucher_nos"): + query = query.where(stock_ledger_entry.voucher_no.notin(kwargs.get("ignore_voucher_nos"))) + data = query.run(as_dict=True) data = list(filter(lambda x: x.qty > 0, data)) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 26226f3ee9..3151c2cf90 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -8,7 +8,41 @@ from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos class TestSerialandBatchBundle(FrappeTestCase): - pass + def test_inward_serial_batch_bundle(self): + pass + + def test_outward_serial_batch_bundle(self): + pass + + def test_old_batch_valuation(self): + pass + + def test_old_batch_batchwise_valuation(self): + pass + + def test_old_serial_no_valuation(self): + pass + + def test_batch_not_belong_to_serial_no(self): + pass + + def test_serial_no_not_exists(self): + pass + + def test_serial_no_item(self): + pass + + def test_serial_no_not_required(self): + pass + + def test_serial_no_required(self): + pass + + def test_batch_no_not_required(self): + pass + + def test_batch_no_required(self): + pass def get_batch_from_bundle(bundle): diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 2162af5eca..ba9482a7ba 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -22,38 +22,10 @@ class SerialNoCannotCannotChangeError(ValidationError): pass -class SerialNoNotRequiredError(ValidationError): - pass - - -class SerialNoRequiredError(ValidationError): - pass - - -class SerialNoQtyError(ValidationError): - pass - - -class SerialNoItemError(ValidationError): - pass - - class SerialNoWarehouseError(ValidationError): pass -class SerialNoBatchError(ValidationError): - pass - - -class SerialNoNotExistsError(ValidationError): - pass - - -class SerialNoDuplicateError(ValidationError): - pass - - class SerialNo(StockController): def __init__(self, *args, **kwargs): super(SerialNo, self).__init__(*args, **kwargs) @@ -69,6 +41,15 @@ class SerialNo(StockController): ) self.set_maintenance_status() + self.validate_warehouse() + + def validate_warehouse(self): + if not self.get("__islocal"): + item_code, warehouse = frappe.db.get_value("Serial No", self.name, ["item_code", "warehouse"]) + if not self.via_stock_ledger and item_code != self.item_code: + frappe.throw(_("Item Code cannot be changed for Serial No."), SerialNoCannotCannotChangeError) + if not self.via_stock_ledger and warehouse != self.warehouse: + frappe.throw(_("Warehouse cannot be changed for Serial No."), SerialNoCannotCannotChangeError) def set_maintenance_status(self): if not self.warranty_expiry_date and not self.amc_expiry_date: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 17e6d8376b..2c8e7a7da4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -744,8 +744,11 @@ frappe.ui.form.on('Stock Entry Detail', { no_batch_serial_number_value = !d.batch_no; } - if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) { + if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) { + frappe.flags.dialog_set = true; erpnext.stock.select_batch_and_serial_no(frm, d); + } else { + frappe.flags.dialog_set = false; } } } diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b1868bba06..4004c0012f 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -181,21 +181,25 @@ class StockReconciliation(StockController): bundle_doc.flags.ignore_permissions = True bundle_doc.save() item.serial_and_batch_bundle = bundle_doc.name - elif item.serial_and_batch_bundle: - pass + elif item.serial_and_batch_bundle and not item.qty and not item.valuation_rate: + bundle_doc = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + + item.qty = bundle_doc.total_qty + item.valuation_rate = bundle_doc.avg_rate def remove_items_with_no_change(self): """Remove items if qty or rate is not changed""" self.difference_amount = 0.0 def _changed(item): + if item.current_serial_and_batch_bundle: + self.calculate_difference_amount(item, frappe._dict({})) + return True + item_dict = get_stock_balance_for( item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no ) - if item.current_serial_and_batch_bundle: - return True - if (item.qty is None or item.qty == item_dict.get("qty")) and ( item.valuation_rate is None or item.valuation_rate == item_dict.get("rate") ): @@ -210,11 +214,7 @@ class StockReconciliation(StockController): item.current_qty = item_dict.get("qty") item.current_valuation_rate = item_dict.get("rate") - self.difference_amount += flt(item.qty, item.precision("qty")) * flt( - item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate") - ) - flt(item_dict.get("qty"), item.precision("qty")) * flt( - item_dict.get("rate"), item.precision("valuation_rate") - ) + self.calculate_difference_amount(item, item_dict) return True items = list(filter(lambda d: _changed(d), self.items)) @@ -231,6 +231,13 @@ class StockReconciliation(StockController): item.idx = i + 1 frappe.msgprint(_("Removed items with no change in quantity or value.")) + def calculate_difference_amount(self, item, item_dict): + self.difference_amount += flt(item.qty, item.precision("qty")) * flt( + item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate") + ) - flt(item_dict.get("qty"), item.precision("qty")) * flt( + item_dict.get("rate"), item.precision("valuation_rate") + ) + def validate_data(self): def _get_msg(row_num, msg): return _("Row # {0}:").format(row_num + 1) + " " + msg @@ -643,7 +650,14 @@ class StockReconciliation(StockController): sl_entries = [] for row in self.items: - if not (row.item_code == item_code and row.batch_no == batch_no): + if ( + not (row.item_code == item_code and row.batch_no == batch_no) + and not row.serial_and_batch_bundle + ): + continue + + if row.current_serial_and_batch_bundle: + self.recalculate_qty_for_serial_and_batch_bundle(row) continue current_qty = get_batch_qty_for_stock_reco( @@ -677,6 +691,27 @@ class StockReconciliation(StockController): if sl_entries: self.make_sl_entries(sl_entries) + def recalculate_qty_for_serial_and_batch_bundle(self, row): + doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle) + precision = doc.entries[0].precision("qty") + + for d in doc.entries: + qty = ( + get_batch_qty( + d.batch_no, + doc.warehouse, + posting_date=doc.posting_date, + posting_time=doc.posting_time, + ignore_voucher_nos=[doc.voucher_no], + ) + or 0 + ) * -1 + + if flt(d.qty, precision) == flt(qty, precision): + continue + + d.db_set("qty", qty) + def get_batch_qty_for_stock_reco( item_code, warehouse, batch_no, posting_date, posting_time, voucher_no diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 316b731ded..a04e2da581 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -694,10 +694,12 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): item_code=item_code, posting_time="09:00:00", target=warehouse, qty=100, basic_rate=700 ) + batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) + # Removed 50 Qty, Balace Qty 50 se2 = make_stock_entry( item_code=item_code, - batch_no=se1.items[0].batch_no, + batch_no=batch_no, posting_time="10:00:00", source=warehouse, qty=50, @@ -709,15 +711,23 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): item_code=item_code, posting_time="11:00:00", warehouse=warehouse, - batch_no=se1.items[0].batch_no, + batch_no=batch_no, qty=100, rate=100, ) + sle = frappe.get_all( + "Stock Ledger Entry", + filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)}, + fields=["actual_qty"], + ) + + self.assertEqual(flt(sle[0].actual_qty), flt(-50.0)) + # Removed 50 Qty, Balace Qty 50 make_stock_entry( item_code=item_code, - batch_no=se1.items[0].batch_no, + batch_no=batch_no, posting_time="12:00:00", source=warehouse, qty=50, @@ -741,12 +751,20 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sle = frappe.get_all( "Stock Ledger Entry", filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, - fields=["qty_after_transaction"], + fields=["qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"], order_by="posting_time desc, creation desc", ) self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0)) + sle = frappe.get_all( + "Stock Ledger Entry", + filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)}, + fields=["actual_qty"], + ) + + self.assertEqual(flt(sle[0].actual_qty), flt(-100.0)) + def test_update_stock_reconciliation_while_reposting(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -914,7 +932,7 @@ def create_stock_reconciliation(**args): "do_not_submit": True, } ) - ) + ).name sr.append( "items", diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 8e148f7dfc..8738f4ae2b 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -17,6 +17,7 @@ "amount", "allow_zero_valuation_rate", "serial_no_and_batch_section", + "add_serial_batch_bundle", "serial_and_batch_bundle", "batch_no", "column_break_11", @@ -203,11 +204,16 @@ "label": "Current Serial / Batch Bundle", "options": "Serial and Batch Bundle", "read_only": 1 + }, + { + "fieldname": "add_serial_batch_bundle", + "fieldtype": "Button", + "label": "Add Serial / Batch No" } ], "istable": 1, "links": [], - "modified": "2023-05-09 18:42:19.224916", + "modified": "2023-05-27 17:35:31.026852", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 0081ccf6eb..77b6de13e3 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -78,6 +78,12 @@ class SerialBatchBundle: self.set_serial_and_batch_bundle(sn_doc) + def validate_actual_qty(self, sn_doc): + precision = sn_doc.precision("total_qty") + if flt(sn_doc.total_qty, precision) != flt(self.sle.actual_qty, precision): + msg = f"Total qty {flt(sn_doc.total_qty, precision)} of Serial and Batch Bundle {sn_doc.name} is not equal to Actual Qty {flt(self.sle.actual_qty, precision)} in the {self.sle.voucher_type} {self.sle.voucher_no}" + frappe.throw(_(msg)) + def validate_item(self): msg = "" if self.sle.actual_qty > 0: @@ -214,6 +220,8 @@ class SerialBatchBundle: def submit_serial_and_batch_bundle(self): doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle) + self.validate_actual_qty(doc) + doc.flags.ignore_voucher_validation = True doc.submit() @@ -426,9 +434,6 @@ class BatchNoValuation(DeprecatedBatchNoValuation): ) else: entries = self.get_batch_no_ledgers() - if frappe.flags.add_breakpoint: - breakpoint() - self.batch_avg_rate = defaultdict(float) self.available_qty = defaultdict(float) self.stock_value_differece = defaultdict(float) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 4694b29f9d..01ba491ab5 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -676,7 +676,7 @@ class update_entries_after(object): if ( sle.voucher_type == "Stock Reconciliation" - and sle.batch_no + and (sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle)) and sle.voucher_detail_no and sle.actual_qty < 0 ): @@ -734,9 +734,17 @@ class update_entries_after(object): self.update_outgoing_rate_on_transaction(sle) def reset_actual_qty_for_stock_reco(self, sle): - current_qty = frappe.get_cached_value( - "Stock Reconciliation Item", sle.voucher_detail_no, "current_qty" - ) + if sle.serial_and_batch_bundle: + current_qty = frappe.get_cached_value( + "Serial and Batch Bundle", sle.serial_and_batch_bundle, "total_qty" + ) + + if current_qty is not None: + current_qty = abs(current_qty) + else: + current_qty = frappe.get_cached_value( + "Stock Reconciliation Item", sle.voucher_detail_no, "current_qty" + ) if current_qty: sle.actual_qty = current_qty * -1 @@ -1524,7 +1532,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: detail = next_stock_reco_detail[0] - if detail.batch_no: + if detail.batch_no or (detail.serial_and_batch_bundle and detail.has_batch_no): regenerate_sle_for_batch_stock_reco(detail) # add condition to update SLEs before this date & time @@ -1602,7 +1610,9 @@ def get_next_stock_reco(kwargs): sle.voucher_no, sle.item_code, sle.batch_no, + sle.serial_and_batch_bundle, sle.actual_qty, + sle.has_batch_no, ) .where( (sle.item_code == kwargs.get("item_code"))