From 333c62de72b1e47a15285b463ec6715cbf46bc40 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 01:19:45 +0530 Subject: [PATCH 01/39] fix: transferred batches are not fetched while making Manufacture stock entry --- .../stock/doctype/stock_entry/stock_entry.py | 223 ++++++++++-------- 1 file changed, 120 insertions(+), 103 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f1df54dd6a..293c2e54f5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1656,118 +1656,58 @@ class StockEntry(StockController): ) def get_transfered_raw_materials(self): - transferred_materials = frappe.db.sql( - """ - select - item_name, original_item, item_code, sum(qty) as qty, sed.t_warehouse as warehouse, - description, stock_uom, expense_account, cost_center - from `tabStock Entry` se,`tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 and se.purpose='Material Transfer for Manufacture' - and se.work_order= %s and ifnull(sed.t_warehouse, '') != '' - group by sed.item_code, sed.t_warehouse - """, + available_materials = get_available_materials(self.work_order) + + wo_data = frappe.db.get_value( + "Work Order", self.work_order, + ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"], as_dict=1, ) - materials_already_backflushed = frappe.db.sql( - """ - select - item_code, sed.s_warehouse as warehouse, sum(qty) as qty - from - `tabStock Entry` se, `tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 - and (se.purpose='Manufacture' or se.purpose='Material Consumption for Manufacture') - and se.work_order= %s and ifnull(sed.s_warehouse, '') != '' - group by sed.item_code, sed.s_warehouse - """, - self.work_order, - as_dict=1, - ) - - backflushed_materials = {} - for d in materials_already_backflushed: - backflushed_materials.setdefault(d.item_code, []).append({d.warehouse: d.qty}) - - po_qty = frappe.db.sql( - """select qty, produced_qty, material_transferred_for_manufacturing from - `tabWork Order` where name=%s""", - self.work_order, - as_dict=1, - )[0] - - manufacturing_qty = flt(po_qty.qty) or 1 - produced_qty = flt(po_qty.produced_qty) - trans_qty = flt(po_qty.material_transferred_for_manufacturing) or 1 - - for item in transferred_materials: - qty = item.qty - item_code = item.original_item or item.item_code - req_items = frappe.get_all( - "Work Order Item", - filters={"parent": self.work_order, "item_code": item_code}, - fields=["required_qty", "consumed_qty"], + for key, row in available_materials.items(): + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( + flt(wo_data.trans_qty) - flt(wo_data.produced_qty) ) - req_qty = flt(req_items[0].required_qty) if req_items else flt(4) - req_qty_each = flt(req_qty / manufacturing_qty) - consumed_qty = flt(req_items[0].consumed_qty) if req_items else 0 - - if trans_qty and manufacturing_qty > (produced_qty + flt(self.fg_completed_qty)): - if qty >= req_qty: - qty = (req_qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = qty - consumed_qty - - if self.purpose == "Manufacture": - # If Material Consumption is booked, must pull only remaining components to finish product - if consumed_qty != 0: - remaining_qty = consumed_qty - (produced_qty * req_qty_each) - exhaust_qty = req_qty_each * produced_qty - if remaining_qty > exhaust_qty: - if (remaining_qty / (req_qty_each * flt(self.fg_completed_qty))) >= 1: - qty = 0 - else: - qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty - else: - if self.flags.backflush_based_on == "Material Transferred for Manufacture": - qty = (item.qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = req_qty_each * flt(self.fg_completed_qty) - - elif backflushed_materials.get(item.item_code): - precision = frappe.get_precision("Stock Entry Detail", "qty") - for d in backflushed_materials.get(item.item_code): - if d.get(item.warehouse) > 0: - if qty > req_qty: - qty = ( - (flt(qty, precision) - flt(d.get(item.warehouse), precision)) - / (flt(trans_qty, precision) - flt(produced_qty, precision)) - ) * flt(self.fg_completed_qty) - - d[item.warehouse] -= qty - + item = row.item_details if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): qty = frappe.utils.ceil(qty) - if qty > 0: - self.add_to_stock_entry_detail( - { - item.item_code: { - "from_warehouse": item.warehouse, - "to_warehouse": "", - "qty": qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.expense_account, - "cost_center": item.buying_cost_center, - "original_item": item.original_item, - } - } - ) + if row.batch_details: + for batch_no, batch_qty in row.batch_details.items(): + if qty <= 0: + continue + + if batch_qty > qty: + batch_qty = qty + + item.batch_no = batch_no + self.update_item_in_stock_entry_detail(row, item, batch_qty) + + row.batch_details[batch_no] -= batch_qty + qty -= batch_qty + else: + self.update_item_in_stock_entry_detail(row, item, qty) + + def update_item_in_stock_entry_detail(self, row, item, qty): + ste_item_details = { + "from_warehouse": item.warehouse, + "to_warehouse": "", + "qty": qty, + "item_name": item.item_name, + "batch_no": item.batch_no, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.expense_account, + "cost_center": item.buying_cost_center, + "original_item": item.original_item, + } + + if item.serial_no: + ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + + self.add_to_stock_entry_detail({item.item_code: ste_item_details}) def get_pending_raw_materials(self, backflush_based_on=None): """ @@ -2521,3 +2461,80 @@ def get_supplied_items(purchase_order): ) return supplied_item_details + + +def get_available_materials(work_order) -> dict: + data = get_stock_entry_data(work_order) + + available_materials = {} + for row in data: + key = (row.item_code, row.warehouse) + if row.purpose != "Material Transfer for Manufacture": + key = (row.item_code, row.s_warehouse) + + if key not in available_materials: + available_materials.setdefault( + key, + frappe._dict( + {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} + ), + ) + + item_data = available_materials[key] + + if row.purpose == "Material Transfer for Manufacture": + item_data.qty += row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] += row.qty + + if row.serial_no: + item_data.serial_nos.extend(get_serial_nos(row.serial_no)) + else: + # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' + + item_data.qty -= row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] -= row.qty + + if row.serial_no: + for serial_no in get_serial_nos(row.serial_no): + item_data.serial_nos.remove(serial_no) + + return available_materials + + +def get_stock_entry_data(work_order): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .from_(stock_entry_detail) + .select( + stock_entry_detail.item_name, + stock_entry_detail.original_item, + stock_entry_detail.item_code, + stock_entry_detail.qty, + (stock_entry_detail.t_warehouse).as_("warehouse"), + (stock_entry_detail.s_warehouse).as_("s_warehouse"), + stock_entry_detail.description, + stock_entry_detail.stock_uom, + stock_entry_detail.expense_account, + stock_entry_detail.cost_center, + stock_entry_detail.batch_no, + stock_entry_detail.serial_no, + stock_entry.purpose, + ) + .where( + (stock_entry.name == stock_entry_detail.parent) + & (stock_entry.work_order == work_order) + & (stock_entry.docstatus == 1) + & (stock_entry_detail.s_warehouse.isnotnull()) + & ( + stock_entry.purpose.isin( + ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"] + ) + ) + ) + .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) + ).run(as_dict=1, debug=1) From d94ff3ede8ca1d57b1ad869a31bbc6bf93fa8a72 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 16:07:17 +0530 Subject: [PATCH 02/39] test: test cases to cover batch, serialized raw materials --- .../doctype/work_order/test_work_order.py | 277 +++++++++++++++++- .../stock/doctype/stock_entry/stock_entry.py | 56 ++-- 2 files changed, 303 insertions(+), 30 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2aba48231b..bd19dec504 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import copy + import frappe from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.utils import add_days, add_months, cint, flt, now, today @@ -19,6 +21,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_bin @@ -28,6 +31,7 @@ class TestWorkOrder(FrappeTestCase): def setUp(self): self.warehouse = "_Test Warehouse 2 - _TC" self.item = "_Test Item" + prepare_data_for_backflush_based_on_materials_transferred() def tearDown(self): frappe.db.rollback() @@ -518,6 +522,8 @@ class TestWorkOrder(FrappeTestCase): work_order.cancel() def test_work_order_with_non_transfer_item(self): + frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0} for item, allow_transfer in items.items(): make_item(item, {"include_item_in_manufacturing": allow_transfer}) @@ -1062,7 +1068,7 @@ class TestWorkOrder(FrappeTestCase): sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100)) for row in sm.get("items"): if row.get("item_code") == "_Test Item": - row.qty = 110 + row.qty = 120 sm.submit() cancel_stock_entry.append(sm.name) @@ -1070,21 +1076,21 @@ class TestWorkOrder(FrappeTestCase): s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90)) for row in s.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 100) + self.assertEqual(row.get("qty"), 108) s.submit() cancel_stock_entry.append(s.name) s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s1.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) s1.submit() cancel_stock_entry.append(s1.name) s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s2.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) cancel_stock_entry.reverse() for ste in cancel_stock_entry: @@ -1194,6 +1200,269 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_backflushed_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + batch_item = "Test Batch MCC Keyboard" + fg_item = "Test FG Item with Batch Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": batch_item, + "item_name": batch_item, + "description": batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_list = [row.batch_no for row in ste_doc.items] + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batch_list[0] + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batch_list[1] + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc1.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc2.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + self.assertEqual(manufacture_ste_doc2.items[1].batch_no, batch_list[1]) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + def test_backflushed_serial_no_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_item = "Test Serial No BTT Headphone" + fg_item = "Test FG Item with Serial No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True + ) + + # Inward raw materials in Stores warehouse + ste_doc.submit() + + serial_nos_list = sorted(get_serial_nos(ste_doc.items[0].serial_no)) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc1.items[0].serial_no), serial_nos_list[0:1]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc2.items[0].serial_no), serial_nos_list[1:3]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 2) + + def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_batch_item = "Test Batch Serial No WebCam" + fg_item = "Test FG Item with Serial & Batch No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": sn_batch_item, + "item_name": sn_batch_item, + "description": sn_batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_dict = {row.batch_no: get_serial_nos(row.serial_no) for row in ste_doc.items} + batches = list(batch_dict.keys()) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batches[0] + transferred_ste_doc.items[0].serial_no = "\n".join(batch_dict.get(batches[0])) + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batches[1] + new_row.serial_no = "\n".join(batch_dict.get(batches[1])) + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc1.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc1.items[0].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc2.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[0].serial_no)[0], batch_dict.get(batch_no)[1] + ) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + + batch_no = manufacture_ste_doc2.items[1].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[1].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + +def prepare_data_for_backflush_based_on_materials_transferred(): + batch_item_doc = make_item( + "Test Batch MCC Keyboard", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBMK.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Batch Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name]) + + sn_item_doc = make_item( + "Test Serial No BTT Headphone", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSBH.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_item_doc.name]) + + sn_batch_item_doc = make_item( + "Test Batch Serial No WebCam", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSW.#####", + "has_serial_no": 1, + "serial_no_series": "TBSWC.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial & Batch No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name]) + def update_job_card(job_card, jc_qty=None): employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 293c2e54f5..08ce83b707 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -596,21 +596,6 @@ class StockEntry(StockController): title=_("Insufficient Stock"), ) - def set_serial_nos(self, work_order): - previous_se = frappe.db.get_value( - "Stock Entry", - {"work_order": work_order, "purpose": "Material Transfer for Manufacture"}, - "name", - ) - - for d in self.get("items"): - transferred_serial_no = frappe.db.get_value( - "Stock Entry Detail", {"parent": previous_se, "item_code": d.item_code}, "serial_no" - ) - - if transferred_serial_no: - d.serial_no = transferred_serial_no - @frappe.whitelist() def get_stock_and_rate(self): """ @@ -1321,7 +1306,7 @@ class StockEntry(StockController): and not self.pro_doc.skip_transfer and self.flags.backflush_based_on == "Material Transferred for Manufacture" ): - self.get_transfered_raw_materials() + self.add_transfered_raw_materials_in_items() elif ( self.work_order @@ -1365,7 +1350,6 @@ class StockEntry(StockController): # fetch the serial_no of the first stock entry for the second stock entry if self.work_order and self.purpose == "Manufacture": - self.set_serial_nos(self.work_order) work_order = frappe.get_doc("Work Order", self.work_order) add_additional_cost(self, work_order) @@ -1655,7 +1639,7 @@ class StockEntry(StockController): } ) - def get_transfered_raw_materials(self): + def add_transfered_raw_materials_in_items(self) -> None: available_materials = get_available_materials(self.work_order) wo_data = frappe.db.get_value( @@ -1666,9 +1650,11 @@ class StockEntry(StockController): ) for key, row in available_materials.items(): - qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( - flt(wo_data.trans_qty) - flt(wo_data.produced_qty) - ) + remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty) + if remaining_qty_to_produce <= 0: + continue + + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce item = row.item_details if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): @@ -1676,7 +1662,7 @@ class StockEntry(StockController): if row.batch_details: for batch_no, batch_qty in row.batch_details.items(): - if qty <= 0: + if qty <= 0 or batch_qty <= 0: continue if batch_qty > qty: @@ -1690,7 +1676,7 @@ class StockEntry(StockController): else: self.update_item_in_stock_entry_detail(row, item, qty) - def update_item_in_stock_entry_detail(self, row, item, qty): + def update_item_in_stock_entry_detail(self, row, item, qty) -> None: ste_item_details = { "from_warehouse": item.warehouse, "to_warehouse": "", @@ -1704,11 +1690,28 @@ class StockEntry(StockController): "original_item": item.original_item, } - if item.serial_no: - ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + if row.serial_nos: + serial_nos = row.serial_nos + if item.batch_no: + serial_nos = self.get_serial_nos_based_on_transferred_batch(item.batch_no, row.serial_nos) + + serial_nos = serial_nos[0 : cint(qty)] + ste_item_details["serial_no"] = "\n".join(serial_nos) + + # remove consumed serial nos from list + for sn in serial_nos: + row.serial_nos.remove(sn) self.add_to_stock_entry_detail({item.item_code: ste_item_details}) + @staticmethod + def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list: + serial_nos = frappe.get_all( + "Serial No", filters={"batch_no": batch_no, "name": ("in", serial_nos)}, order_by="creation" + ) + + return [d.name for d in serial_nos] + def get_pending_raw_materials(self, backflush_based_on=None): """ issue (item quantity) that is pending to issue or desire to transfer, @@ -2489,6 +2492,7 @@ def get_available_materials(work_order) -> dict: if row.serial_no: item_data.serial_nos.extend(get_serial_nos(row.serial_no)) + item_data.serial_nos.sort() else: # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' @@ -2537,4 +2541,4 @@ def get_stock_entry_data(work_order): ) ) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) - ).run(as_dict=1, debug=1) + ).run(as_dict=1) From 65b21ee7d61e5bb9744b5545d625140897afd35c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 7 Jun 2022 14:49:24 +0530 Subject: [PATCH 03/39] fix: internal transfer GLE validation --- erpnext/controllers/stock_controller.py | 2 +- .../selling/doctype/customer/test_customer.py | 6 +++++ .../delivery_note/test_delivery_note.py | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index feec42f43a..e90a4f6241 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -166,7 +166,7 @@ class StockController(AccountsController): "against": warehouse_account[sle.warehouse]["account"], "cost_center": item_row.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(sle.stock_value_difference, precision), + "debit": -1 * flt(sle.stock_value_difference, precision), "project": item_row.get("project") or self.get("project"), "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", }, diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 24587564cf..7dc3fab623 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -375,6 +375,12 @@ def create_internal_customer( if not allowed_to_interact_with: allowed_to_interact_with = represents_company + exisiting_representative = frappe.db.get_value( + "Customer", {"represents_company": represents_company} + ) + if exisiting_representative: + return exisiting_representative + if not frappe.db.exists("Customer", customer_name): customer = frappe.get_doc( { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index fffcdca380..6bcab737b3 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1064,6 +1064,33 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.items[0].rate, rate) + def test_internal_transfer_precision_gle(self): + from erpnext.selling.doctype.customer.test_customer import create_internal_customer + + item = make_item(properties={"valuation_method": "Moving Average"}).name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + target = "Finished Goods - TCP1" + customer = create_internal_customer(represents_company=company) + + # average rate = 128.015 + rates = [101.45, 150.46, 138.25, 121.9] + + for rate in rates: + make_stock_entry(item_code=item, target=warehouse, qty=1, rate=rate) + + dn = create_delivery_note( + item_code=item, + company=company, + customer=customer, + qty=4, + warehouse=warehouse, + target_warehouse=target, + ) + self.assertFalse( + frappe.db.exists("GL Entry", {"voucher_no": dn.name, "voucher_type": dn.doctype}) + ) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") From d05d15346a22de311cf16f437e80804207b4fe60 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 14 Jun 2022 12:50:49 +0530 Subject: [PATCH 04/39] fix: Conversion rate validation for multi-currency invoices --- .../doctype/purchase_invoice/purchase_invoice.py | 11 ----------- .../accounts/doctype/sales_invoice/sales_invoice.py | 1 + erpnext/controllers/accounts_controller.py | 11 +++++++++++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 23ad223e77..4e0d1c966d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -165,17 +165,6 @@ class PurchaseInvoice(BuyingController): super(PurchaseInvoice, self).set_missing_values(for_validate) - def check_conversion_rate(self): - default_currency = erpnext.get_company_currency(self.company) - if not default_currency: - throw(_("Please enter default currency in Company Master")) - if ( - (self.currency == default_currency and flt(self.conversion_rate) != 1.00) - or not self.conversion_rate - or (self.currency != default_currency and flt(self.conversion_rate) == 1.00) - ): - throw(_("Conversion rate cannot be 0 or 1")) - def validate_credit_to_acc(self): if not self.credit_to: self.credit_to = get_party_account("Supplier", self.supplier, self.company) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index a580d45acc..1a3164b0d9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -114,6 +114,7 @@ class SalesInvoice(SellingController): self.set_income_account_for_fixed_assets() self.validate_item_cost_centers() self.validate_income_account() + self.check_conversion_rate() validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_invoice_reference diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 854c0d00f5..389d7cc983 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1838,6 +1838,17 @@ class AccountsController(TransactionBase): jv.save() jv.submit() + def check_conversion_rate(self): + default_currency = erpnext.get_company_currency(self.company) + if not default_currency: + throw(_("Please enter default currency in Company Master")) + if ( + (self.currency == default_currency and flt(self.conversion_rate) != 1.00) + or not self.conversion_rate + or (self.currency != default_currency and flt(self.conversion_rate) == 1.00) + ): + throw(_("Conversion rate cannot be 0 or 1")) + @frappe.whitelist() def get_tax_rate(account_head): From 0097a2b60c9b0fbdc69a5f460a5edee5f8354578 Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <99652762+nihantra@users.noreply.github.com> Date: Fri, 17 Jun 2022 18:35:11 +0530 Subject: [PATCH 05/39] Update bank_clearance_summary.py --- .../report/bank_clearance_summary/bank_clearance_summary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index 20f7643a1c..9d2deea523 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -43,7 +43,7 @@ def get_columns(): "options": "Account", "width": 170, }, - {"label": _("Amount"), "fieldname": "amount", "width": 120}, + {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120}, ] return columns From 0320b59ea615b372c9d627db54d272607b104878 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 20 Jun 2022 16:25:34 +0530 Subject: [PATCH 06/39] chore: Clear Progress section for completed logs & `on_submit` UX - Delete `BOM Update Batch` table on 'Completed' log, to save space - Hide Progress section on 'Completed' log - Enqueue `on_submit` for 'Update Cost' job, getting leaf boms could take time for huge DBs. Users have to wait for screen to unfreeze. - Add error handling to `process_boms_cost_level_wise` (Called via cron job and on submit, both in background) --- .../bom_update_log/bom_update_log.json | 10 ++-- .../doctype/bom_update_log/bom_update_log.py | 59 +++++++++++-------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index c32e383b08..a926e69ee6 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -7,11 +7,11 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "current_bom", - "new_bom", - "column_break_3", "update_type", "status", + "column_break_3", + "current_bom", + "new_bom", "error_log", "progress_section", "current_level", @@ -37,6 +37,7 @@ "options": "BOM" }, { + "depends_on": "eval:doc.update_type === \"Replace BOM\"", "fieldname": "column_break_3", "fieldtype": "Column Break" }, @@ -87,6 +88,7 @@ "options": "BOM Update Batch" }, { + "depends_on": "eval:doc.status !== \"Completed\"", "fieldname": "current_level", "fieldtype": "Int", "label": "Current Level" @@ -96,7 +98,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-06-06 15:15:23.883251", + "modified": "2022-06-20 15:43:55.696388", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index 9c9c24044a..af5ff8e1c2 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -77,7 +77,11 @@ class BOMUpdateLog(Document): now=frappe.flags.in_test, ) else: - process_boms_cost_level_wise(self) + frappe.enqueue( + method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise", + update_doc=self, + now=frappe.flags.in_test, + ) def run_replace_bom_job( @@ -112,28 +116,31 @@ def process_boms_cost_level_wise( current_boms = {} values = {} - if update_doc.status == "Queued": - # First level yet to process. On Submit. - current_level = 0 - current_boms = get_leaf_boms() - values = { - "processed_boms": json.dumps({}), - "status": "In Progress", - "current_level": current_level, - } - else: - # Resume next level. via Cron Job. - if not parent_boms: - return + try: + if update_doc.status == "Queued": + # First level yet to process. On Submit. + current_level = 0 + current_boms = get_leaf_boms() + values = { + "processed_boms": json.dumps({}), + "status": "In Progress", + "current_level": current_level, + } + else: + # Resume next level. via Cron Job. + if not parent_boms: + return - current_level = cint(update_doc.current_level) + 1 + current_level = cint(update_doc.current_level) + 1 - # Process the next level BOMs. Stage parents as current BOMs. - current_boms = parent_boms.copy() - values = {"current_level": current_level} + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = parent_boms.copy() + values = {"current_level": current_level} - set_values_in_log(update_doc.name, values, commit=True) - queue_bom_cost_jobs(current_boms, update_doc, current_level) + set_values_in_log(update_doc.name, values, commit=True) + queue_bom_cost_jobs(current_boms, update_doc, current_level) + except Exception: + handle_exception(update_doc) def queue_bom_cost_jobs( @@ -199,16 +206,22 @@ def resume_bom_cost_update_jobs(): current_boms, processed_boms = get_processed_current_boms(log, bom_batches) parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) - # Unset processed BOMs if log is complete, it is used for next level BOMs + # Unset processed BOMs (it is used for next level BOMs) & change status if log is complete + status = "Completed" if not parent_boms else "In Progress" + processed_boms = json.dumps([] if not parent_boms else processed_boms) set_values_in_log( log.name, values={ - "processed_boms": json.dumps([] if not parent_boms else processed_boms), - "status": "Completed" if not parent_boms else "In Progress", + "processed_boms": processed_boms, + "status": status, }, commit=True, ) + # clear progress section + if status == "Completed": + frappe.db.delete("BOM Update Batch", {"parent": log.name}) + if parent_boms: # there is a next level to process process_boms_cost_level_wise( update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms From 8f373930448af79e7222eb78eb7d8c364a4b7fd0 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 20 Jun 2022 22:00:32 +0530 Subject: [PATCH 07/39] test: Add test case --- .../doctype/sales_invoice/test_sales_invoice.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index b8154dd1f9..1b20c29f94 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1583,6 +1583,17 @@ class TestSalesInvoice(unittest.TestCase): self.assertTrue(gle) + def test_invoice_exchange_rate(self): + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=1, + do_not_save=1, + ) + + self.assertRaises(frappe.ValidationError, si.save) + def test_invalid_currency(self): # Customer currency = USD From 4cf2225a29c0d1c1a1967de20c78b411f055bf53 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 21 Jun 2022 14:03:05 +0530 Subject: [PATCH 08/39] chore: Implement Log clearing interface in BOM Update Log - Implement Log clearing interface in BOM Update Log - Add additional info in sidebar: Log clearing only happens for 'Update Cost' type logs - 'Replace BOM' logs have important info and is used in BOM timeline, so we'll let users decide if they wanna keep or discard it --- .../doctype/bom_update_log/bom_update_log.py | 13 ++++++++++++ .../bom_update_log/bom_update_log_list.js | 21 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index af5ff8e1c2..c3f52d4583 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now from frappe.utils import cint, cstr from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( @@ -22,6 +24,17 @@ class BOMMissingError(frappe.ValidationError): class BOMUpdateLog(Document): + @staticmethod + def clear_old_logs(days=None): + days = days or 90 + table = DocType("BOM Update Log") + frappe.db.delete( + table, + filters=( + (table.modified < (Now() - Interval(days=days))) & (table.update_type == "Update Cost") + ), + ) + def validate(self): if self.update_type == "Replace BOM": self.validate_boms_are_specified() diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js index e39b5637c7..bc709d8fc4 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js @@ -1,6 +1,6 @@ frappe.listview_settings['BOM Update Log'] = { add_fields: ["status"], - get_indicator: function(doc) { + get_indicator: (doc) => { let status_map = { "Queued": "orange", "In Progress": "blue", @@ -9,5 +9,22 @@ frappe.listview_settings['BOM Update Log'] = { }; return [__(doc.status), status_map[doc.status], "status,=," + doc.status]; - } + }, + onload: () => { + if (!frappe.model.can_write("Log Settings")) { + return; + } + + let sidebar_entry = $( + '' + ).appendTo(cur_list.page.sidebar); + let message = __("Note: Automatic log deletion only applies to logs of type Update Cost"); + $(`
${message}
`).appendTo(sidebar_entry); + + frappe.require("logtypes.bundle.js", () => { + frappe.utils.logtypes.show_log_retention_message(cur_list.doctype); + }); + + + }, }; \ No newline at end of file From dc2830da4d35045377425a8e84fd8a19eb48134f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Jun 2022 13:58:00 +0530 Subject: [PATCH 09/39] fix: set default_bom for item --- erpnext/manufacturing/doctype/bom/bom.py | 1 + erpnext/manufacturing/doctype/bom/test_bom.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 4c88eca8f6..b29f6710e1 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -445,6 +445,7 @@ class BOM(WebsiteGenerator): and self.is_active ): frappe.db.set(self, "is_default", 1) + frappe.db.set_value("Item", self.item, "default_bom", self.name) else: frappe.db.set(self, "is_default", 0) item = frappe.get_doc("Item", self.item) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 182a20c6bb..860512c91d 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -559,6 +559,42 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.items[0].rate, 42) + def test_set_default_bom_for_item_having_single_bom(self): + from erpnext.stock.doctype.item.test_item import make_item + + fg_item = make_item(properties={"is_stock_item": 1}) + bom_item = make_item(properties={"is_stock_item": 1}) + + # Step 1: Create BOM + bom = frappe.new_doc("BOM") + bom.item = fg_item.item_code + bom.quantity = 1 + bom.append( + "items", + { + "item_code": bom_item.item_code, + "qty": 1, + "uom": bom_item.stock_uom, + "stock_uom": bom_item.stock_uom, + "rate": 100.0, + }, + ) + bom.save() + bom.submit() + self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + + # Step 2: Uncheck is_active field + bom.is_active = 0 + bom.save() + bom.reload() + self.assertIsNone(frappe.get_value("Item", fg_item.item_code, "default_bom")) + + # Step 3: Check is_active field + bom.is_active = 1 + bom.save() + bom.reload() + self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From ce1b4e40a15eb88a715d0fd25089ceb8df5f3cde Mon Sep 17 00:00:00 2001 From: Vladislav Date: Tue, 21 Jun 2022 16:55:05 +0300 Subject: [PATCH 10/39] fix: update ru translate (#31404) * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv fix logic * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv --- erpnext/translations/ru.csv | 84 ++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 743b29493c..a4bfb86c01 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -44,7 +44,7 @@ Accessable Value,Доступная стоимость, Account,Аккаунт, Account Number,Номер аккаунта, Account Number {0} already used in account {1},"Номер счета {0}, уже использованный в учетной записи {1}", -Account Pay Only,Счет Оплатить только, +Account Pay Only,Только оплатить счет, Account Type,Тип учетной записи, Account Type for {0} must be {1},Тип счета для {0} должен быть {1}, "Account balance already in Credit, you are not allowed to set 'Balance Must Be' as 'Debit'","Баланс счета в Кредите, запрещена установка 'Баланс должен быть' как 'Дебет'", @@ -117,7 +117,7 @@ Add Item,Добавить продукт, Add Items,Добавить продукты, Add Leads,Добавить лид, Add Multiple Tasks,Добавить несколько задач, -Add Row,Добавить ряд, +Add Row,Добавить строку, Add Sales Partners,Добавить партнеров по продажам, Add Serial No,Добавить серийный номер, Add Students,Добавить студентов, @@ -692,7 +692,7 @@ Created {0} scorecards for {1} between: ,Созданы {0} оценочные Creating Company and Importing Chart of Accounts,Создание компании и импорт плана счетов, Creating Fees,Создание сборов, Creating Payment Entries......,Создание платежных записей......, -Creating Salary Slips...,Создание зарплатных листков..., +Creating Salary Slips...,Создание зарплатных ведомостей..., Creating student groups,Создание групп студентов, Creating {0} Invoice,Создание {0} счета, Credit,Кредит, @@ -995,7 +995,7 @@ Expenses,Расходы, Expenses Included In Asset Valuation,"Расходы, включенные в оценку активов", Expenses Included In Valuation,"Затрат, включаемых в оценке", Expired Batches,Просроченные партии, -Expires On,Годен до, +Expires On,Актуален до, Expiring On,Срок действия, Expiry (In Days),Срок действия (в днях), Explore,Обзор, @@ -1411,7 +1411,7 @@ Lab Test UOM,Лабораторная проверка UOM, Lab Tests and Vital Signs,Лабораторные тесты и жизненные знаки, Lab result datetime cannot be before testing datetime,Лабораторный результат datetime не может быть до тестирования даты и времени, Lab testing datetime cannot be before collection datetime,Лабораторное тестирование datetime не может быть до даты сбора данных, -Label,Ярлык, +Label,Метка, Laboratory,Лаборатория, Language Name,Название языка, Large,Большой, @@ -2874,7 +2874,7 @@ Supplier Id,Id поставщика, Supplier Invoice Date cannot be greater than Posting Date,"Дата Поставщик Счет не может быть больше, чем Дата публикации", Supplier Invoice No,Поставщик Счет №, Supplier Invoice No exists in Purchase Invoice {0},Номер счета поставщика отсутствует в счете на покупку {0}, -Supplier Name,наименование поставщика, +Supplier Name,Наименование поставщика, Supplier Part No,Деталь поставщика №, Supplier Quotation,Предложение поставщика, Supplier Scorecard,Оценочная карта поставщика, @@ -3091,7 +3091,7 @@ Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total, Total Payments,Всего платежей, Total Present,Итого Текущая, Total Qty,Общее количество, -Total Quantity,Общая численность, +Total Quantity,Общее количество, Total Revenue,Общий доход, Total Student,Всего учеников, Total Target,Всего целей, @@ -3498,7 +3498,7 @@ Postal,Почтовый, Postal Code,Почтовый индекс, Previous,Предыдущая, Provider,Поставщик, -Read Only,Только чтения, +Read Only,Только чтение, Recipient,Сторона-реципиент, Reviews,Отзывы, Sender,Отправитель, @@ -3879,7 +3879,7 @@ On Lead Creation,Создание лида, On Supplier Creation,Создание поставщика, On Customer Creation,Создание клиента, Only .csv and .xlsx files are supported currently,В настоящее время поддерживаются только файлы .csv и .xlsx, -Only expired allocation can be cancelled,Только истекшее распределение может быть отменено, +Only expired allocation can be cancelled,Отменить можно только просроченное распределение, Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия, Open,Открыт, Open Contact,Открытый контакт, @@ -4046,7 +4046,7 @@ Server Error,Ошибка сервера, Service Level Agreement has been changed to {0}.,Соглашение об уровне обслуживания изменено на {0}., Service Level Agreement was reset.,Соглашение об уровне обслуживания было сброшено., Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Соглашение об уровне обслуживания с типом объекта {0} и объектом {1} уже существует., -Set,Задать, +Set,Комплект, Set Meta Tags,Установить метатеги, Set {0} in company {1},Установить {0} в компании {1}, Setup,Настройки, @@ -4059,7 +4059,7 @@ Show Stock Ageing Data,Показать данные о старении зап Show Warehouse-wise Stock,Показать складской запас, Size,Размер, Something went wrong while evaluating the quiz.,Что-то пошло не так при оценке теста., -Sr,Sr, +Sr,№, Start,Начать, Start Date cannot be before the current date,Дата начала не может быть раньше текущей даты, Start Time,Время начала, @@ -4513,7 +4513,7 @@ Mandatory For Profit and Loss Account,Обязательно для счета Accounting Period,Период учета, Period Name,Название периода, Closed Documents,Закрытые документы, -Accounts Settings,Настройки аккаунта, +Accounts Settings,Настройка счетов, Settings for Accounts,Настройки для счетов, Make Accounting Entry For Every Stock Movement,Создавать бухгалтерские проводки при каждом перемещении запасов, Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts,"Пользователи с этой ролью могут замороживать счета, а также создавать / изменять бухгалтерские проводки замороженных счетов", @@ -5084,8 +5084,8 @@ Allow Zero Valuation Rate,Разрешить нулевую оценку, Item Tax Rate,Ставка налогов на продукт, Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges,Налоговый Подробная таблица выбирается из мастера элемента в виде строки и хранится в этой области.\n Используется по налогам и сборам, Purchase Order Item,Заказ товара, -Purchase Receipt Detail,Деталь квитанции о покупке, -Item Weight Details,Деталь Вес Подробности, +Purchase Receipt Detail,Сведения о квитанции о покупке, +Item Weight Details,Сведения о весе товара, Weight Per Unit,Вес на единицу, Total Weight,Общий вес, Weight UOM,Вес Единица измерения, @@ -5198,7 +5198,7 @@ Address and Contacts,Адрес и контакты, Contact List,Список контактов, Hidden list maintaining the list of contacts linked to Shareholder,"Скрытый список, поддерживающий список контактов, связанных с Акционером", Specify conditions to calculate shipping amount,Укажите условия для расчета суммы доставки, -Shipping Rule Label,Название правила доставки, +Shipping Rule Label,Метка правила доставки, example: Next Day Shipping,Пример: доставка на следующий день, Shipping Rule Type,Тип правила доставки, Shipping Account,Счет доставки, @@ -5236,7 +5236,7 @@ Billing Interval,Интервал выставления счетов, Billing Interval Count,Счет интервала фактурирования, "Number of intervals for the interval field e.g if Interval is 'Days' and Billing Interval Count is 3, invoices will be generated every 3 days","Количество интервалов для поля интервалов, например, если Interval является «Days», а количество интервалов фактурирования - 3, счета-фактуры будут генерироваться каждые 3 дня", Payment Plan,Платежный план, -Subscription Plan Detail,Деталь плана подписки, +Subscription Plan Detail,Сведения о плана подписки, Plan,План, Subscription Settings,Настройки подписки, Grace Period,Льготный период, @@ -5802,7 +5802,7 @@ Make Academic Term Mandatory,Сделать академический срок Skip User creation for new Student,Пропустить создание пользователя для нового студента, "By default, a new User is created for every new Student. If enabled, no new User will be created when a new Student is created.","По умолчанию для каждого нового Студента создается новый Пользователь. Если этот параметр включен, при создании нового Студента новый Пользователь не создается.", Instructor Records to be created by,Записи инструкторов должны быть созданы, -Employee Number,Общее число сотрудников, +Employee Number,Номер сотрудника, Fee Category,Категория платы, Fee Component,Компонент платы, Fees Category,Категория плат, @@ -6196,7 +6196,7 @@ Inpatient Occupancy,Стационарное размещение, Occupancy Status,Статус занятости, Vacant,Вакантно, Occupied,Занято, -Item Details,Детальная информация о товаре, +Item Details,Детальная информация о продукте, UOM Conversion in Hours,Преобразование UOM в часы, Rate / UOM,Скорость / UOM, Change in Item,Изменение продукта, @@ -6868,8 +6868,8 @@ Only Tax Impact (Cannot Claim But Part of Taxable Income),Только нало Create Separate Payment Entry Against Benefit Claim,Создать отдельную заявку на подачу заявки на получение пособия, Condition and Formula,Состояние и формула, Amount based on formula,Сумма на основе формулы, -Formula,формула, -Salary Detail,Заработная плата: Подробности, +Formula,Формула, +Salary Detail,Подробно об заработной плате, Component,Компонент, Do not include in total,Не включать в общей сложности, Default Amount,По умолчанию количество, @@ -6891,7 +6891,7 @@ Total Principal Amount,Общая сумма, Total Interest Amount,Общая сумма процентов, Total Loan Repayment,Общая сумма погашения кредита, net pay info,Чистая информация платить, -Gross Pay - Total Deduction - Loan Repayment,Gross Pay - Итого Вычет - Погашение кредита, +Gross Pay - Total Deduction - Loan Repayment,Валовая заработная плата - Общий вычет - Погашение кредита, Total in words,Всего в словах, Net Pay (in words) will be visible once you save the Salary Slip.,"Чистая плата (прописью) будет видна, как только вы сохраните зарплатную ведомость.", Salary Component for timesheet based payroll.,Компонент заработной платы для расчета зарплаты на основе расписания., @@ -6961,7 +6961,7 @@ Trainer Email,Электронная почта тренера, Attendees,Присутствующие, Employee Emails,Электронные почты сотрудников, Training Event Employee,Обучение сотрудников Событие, -Invited,приглашенный, +Invited,Приглашенный, Feedback Submitted,Отзыв отправлен, Optional,Необязательный, Training Result Employee,Результат обучения сотрудника, @@ -7185,7 +7185,7 @@ Ordered Quantity,Заказанное количество, Item to be manufactured or repacked,Продукт должен быть произведен или переупакован, Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Количество пункта получены после изготовления / переупаковка от заданных величин сырья, Set rate of sub-assembly item based on BOM,Установить скорость сборки на основе спецификации, -Allow Alternative Item,Разрешить альтернативный элемент, +Allow Alternative Item,Разрешить альтернативный продукт, Item UOM,Единиц продукта, Conversion Rate,Коэффициент конверсии, Rate Of Materials Based On,Оценить материалов на основе, @@ -7600,7 +7600,7 @@ Invoices with no Place Of Supply,Счета без места поставки, Import Supplier Invoice,Импортная накладная поставщика, Invoice Series,Серия счетов, Upload XML Invoices,Загрузить XML-счета, -Zip File,Zip-файл, +Zip File,Zip файл, Import Invoices,Импорт счетов, Click on Import Invoices button once the zip file has been attached to the document. Any errors related to processing will be shown in the Error Log.,"Нажмите кнопку «Импортировать счета-фактуры», когда файл zip прикреплен к документу. Любые ошибки, связанные с обработкой, будут отображаться в журнале ошибок.", Lower Deduction Certificate,Свидетельство о нижнем удержании, @@ -7635,7 +7635,7 @@ Restaurant Order Entry Item,Номер заказа заказа рестора Served,Подается, Restaurant Reservation,Бронирование ресторанов, Waitlisted,Лист ожидания, -No Show,Нет шоу, +No Show,Не показывать, No of People,Нет людей, Reservation Time,Время резервирования, Reservation End Time,Время окончания бронирования, @@ -7873,8 +7873,8 @@ Disable In Words,Отключить в словах, "If disable, 'In Words' field will not be visible in any transaction","Если отключить, "В словах" поле не будет видно в любой сделке", Item Classification,Продуктовая классификация, General Settings,Основные настройки, -Item Group Name,Пункт Название группы, -Parent Item Group,Родитель Пункт Группа, +Item Group Name,Название группы продуктов, +Parent Item Group,Родительская группа продукта, Item Group Defaults,Элемент группы по умолчанию, Item Tax,Налог на продукт, Check this if you want to show in website,"Проверьте это, если вы хотите показать в веб-сайт", @@ -7971,13 +7971,13 @@ Customs Tariff Number,Номер таможенного тарифа, Tariff Number,Тарифный номер, Delivery To,Доставка, MAT-DN-.YYYY.-,MAT-DN-.YYYY.-, -Is Return,Является Вернуться, +Is Return,Возврат, Issue Credit Note,Кредитная кредитная карта, -Return Against Delivery Note,Вернуться На накладной, -Customer's Purchase Order No,Клиентам Заказ Нет, +Return Against Delivery Note,Возврат по накладной, +Customer's Purchase Order No,Заказ клиента №, Billing Address Name,Название адреса для выставления счета, Required only for sample item.,Требуется только для образца пункта., -"If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в шаблонах Налоги с налогами и сбором платежей, выберите его и нажмите кнопку ниже.", +"If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в Шаблоне налогов и сборов с продаж, выберите его и нажмите кнопку ниже.", In Words will be visible once you save the Delivery Note.,По словам будет виден только вы сохраните накладной., In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной., Transporter Info,Информация для транспортировки, @@ -7991,8 +7991,8 @@ Installation Status,Состояние установки, Excise Page Number,Количество Акцизный Страница, Instructions,Инструкции, From Warehouse,Со склада, -Against Sales Order,По Сделке, -Against Sales Order Item,По Продукту Сделки, +Against Sales Order,По сделке, +Against Sales Order Item,По позиции сделки, Against Sales Invoice,Повторная накладная, Against Sales Invoice Item,Счет на продажу продукта, Available Batch Qty at From Warehouse,Доступные Пакетная Кол-во на со склада, @@ -8008,7 +8008,7 @@ Delivery Stop,Остановить доставку, Lock,Заблокировано, Visited,Посещен, Order Information,запросить информацию, -Contact Information,Контакты, +Contact Information,Контактная информация, Email sent to,Письмо отправлено, Dispatch Information,Информация о доставке, Estimated Arrival,Ожидаемое прибытие, @@ -8121,7 +8121,7 @@ Two-way,Двусторонний, Alternative Item Name,Альтернативное название продукта, Attribute Name,Название атрибута, Numeric Values,Числовые значения, -From Range,От хребта, +From Range,Из диапазона, Increment,Приращение, To Range,В диапазоне, Item Attribute Values,Пункт значений атрибутов, @@ -8143,7 +8143,7 @@ Default Supplier,Поставщик по умолчанию, Default Expense Account,Счет учета затрат по умолчанию, Sales Defaults,По умолчанию, Default Selling Cost Center,По умолчанию Продажа Стоимость центр, -Item Manufacturer,Пункт Производитель, +Item Manufacturer,Производитель товара, Item Price,Цена продукта, Packing Unit,Упаковочный блок, Quantity that must be bought or sold per UOM,"Количество, которое необходимо купить или продать за UOM", @@ -8177,7 +8177,7 @@ Purchase Receipts,Покупка Поступления, Purchase Receipt Items,Покупка продуктов, Get Items From Purchase Receipts,Получить продукты из покупки., Distribute Charges Based On,Распределите платежи на основе, -Landed Cost Help,Земельные Стоимость Помощь, +Landed Cost Help,Справка по стоимости доставки, Manufacturers used in Items,Производители использовали в пунктах, Limited to 12 characters,Ограничено до 12 символов, MAT-MR-.YYYY.-,МАТ-MR-.YYYY.-, @@ -8186,13 +8186,13 @@ Transferred,Переданы, % Ordered,% заказано, Terms and Conditions Content,Условия Содержимое, Quantity and Warehouse,Количество и Склад, -Lead Time Date,Время и Дата Лида, -Min Order Qty,Минимальный заказ Кол-во, +Lead Time Date,Дата выполнения заказа, +Min Order Qty,Минимальное количество для заказа, Packed Item,Упаковано, To Warehouse (Optional),На склад (Необязательно), Actual Batch Quantity,Фактическое количество партий, Prevdoc DocType,Prevdoc DocType, -Parent Detail docname,Родитель Деталь DOCNAME, +Parent Detail docname,Сведения о родителе docname, "Generate packing slips for packages to be delivered. Used to notify package number, package contents and its weight.","Создаёт упаковочные листы к упаковкам для доставки. Содержит номер упаковки, перечень содержимого и вес.", Indicates that the package is a part of this delivery (Only Draft),"Указывает, что пакет является частью этой поставки (только проект)", MAT-PAC-.YYYY.-,MAT-PAC-.YYYY.-, @@ -8353,7 +8353,7 @@ Automatically Set Serial Nos based on FIFO,Автоматически устан Auto Material Request,Автоматический запрос материалов, Inter Warehouse Transfer Settings,Настройки передачи между складами, Freeze Stock Entries,Замораживание поступления запасов, -Stock Frozen Upto,остатки заморожены до, +Stock Frozen Upto,Остатки заморожены до, Batch Identification,Идентификация партии, Use Naming Series,Использовать серийный номер, Naming Series Prefix,Префикс Идентификации по Имени, @@ -8372,7 +8372,7 @@ Issue Split From,Выпуск Сплит От, Service Level,Уровень обслуживания, Response By,Ответ от, Response By Variance,Ответ по отклонениям, -Ongoing,постоянный, +Ongoing,Постоянный, Resolution By,Разрешение по, Resolution By Variance,Разрешение по отклонениям, Service Level Agreement Creation,Создание соглашения об уровне обслуживания, From 5826b7b0718a1838746eb9755bb001308ed9a642 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 21 Jun 2022 19:50:50 +0530 Subject: [PATCH 11/39] fix: identify empty values "" in against_voucher columns --- erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index 1e0d20d059..e15aa4a1f4 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,6 +1,6 @@ import frappe from frappe import qb -from frappe.query_builder import Case +from frappe.query_builder import Case, CustomFunction from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import IfNull @@ -87,6 +87,7 @@ def execute(): gl = qb.DocType("GL Entry") account = qb.DocType("Account") + ifelse = CustomFunction("IF", ["condition", "then", "else"]) gl_entries = ( qb.from_(gl) @@ -96,8 +97,12 @@ def execute(): gl.star, ConstantColumn(1).as_("docstatus"), account.account_type.as_("account_type"), - IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), - IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), + IfNull( + ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type + ).as_("against_voucher_type"), + IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_( + "against_voucher_no" + ), # convert debit/credit to amount Case() .when(account.account_type == "Receivable", gl.debit - gl.credit) From 8b1ff96e30e88f6a8c9dabed097efeac310645c1 Mon Sep 17 00:00:00 2001 From: hrzzz Date: Tue, 21 Jun 2022 15:10:19 -0300 Subject: [PATCH 12/39] fix: translation for filter status on report --- .../report/purchase_order_analysis/purchase_order_analysis.js | 1 + .../selling/report/sales_order_analysis/sales_order_analysis.js | 1 + 2 files changed, 2 insertions(+) diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js index ca3be03da6..721e54e46f 100644 --- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js +++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js @@ -59,6 +59,7 @@ frappe.query_reports["Purchase Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) } diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js index 76a5bb51ca..91748bc7be 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js @@ -55,6 +55,7 @@ frappe.query_reports["Sales Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) } From 00807abe314344fec1ec636ac5f281473589f7f1 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 22 Jun 2022 10:48:49 +0530 Subject: [PATCH 13/39] fix: add UOM validation for planned-qty --- .../production_plan/production_plan.py | 2 ++ .../production_plan/test_production_plan.py | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 8a28454af2..a73b9bcc69 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -25,6 +25,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.utilities.transaction_base import validate_uom_is_integer class ProductionPlan(Document): @@ -33,6 +34,7 @@ class ProductionPlan(Document): self.calculate_total_planned_qty() self.set_status() self._rename_temporary_references() + validate_uom_is_integer(self, "stock_uom", "planned_qty") def set_pending_qty_in_row_without_reference(self): "Set Pending Qty in independent rows (not from SO or MR)." diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index e88049d810..040e791e00 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -679,15 +679,23 @@ class TestProductionPlan(FrappeTestCase): self.assertFalse(pp.all_items_completed()) def test_production_plan_planned_qty(self): - pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55) - pln.make_work_order() - work_order = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name") - wo_doc = frappe.get_doc("Work Order", work_order) - wo_doc.update( - {"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Finished Goods - _TC"} + # Case 1: When Planned Qty is non-integer and UOM is integer. + from erpnext.utilities.transaction_base import UOMMustBeIntegerError + + self.assertRaises( + UOMMustBeIntegerError, create_production_plan, item_code="_Test FG Item", planned_qty=0.55 ) - wo_doc.submit() - self.assertEqual(wo_doc.qty, 0.55) + + # Case 2: When Planned Qty is non-integer and UOM is also non-integer. + from erpnext.stock.doctype.item.test_item import make_item + + fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name + bom_item = make_item().name + + make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan(item_code=fg_item, planned_qty=0.55, stock_uom="_Test UOM 1") + self.assertEqual(pln.po_items[0].planned_qty, 0.55) def test_temporary_name_relinking(self): @@ -751,6 +759,7 @@ def create_production_plan(**args): "bom_no": frappe.db.get_value("Item", args.item_code, "default_bom"), "planned_qty": args.planned_qty or 1, "planned_start_date": args.planned_start_date or now_datetime(), + "stock_uom": args.stock_uom or "Nos", }, ) From ab13a178b55218c6a9295cb793e6f6deee5c07a2 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:24:22 +0530 Subject: [PATCH 14/39] fix: Replace asset life with total no of depreciations --- erpnext/assets/doctype/asset/asset.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 257488dfc3..650411c3aa 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -849,14 +849,10 @@ class Asset(AccountsController): if args.get("rate_of_depreciation") and on_validate: return args.get("rate_of_depreciation") - no_of_years = ( - flt(args.get("total_number_of_depreciations") * flt(args.get("frequency_of_depreciation"))) - / 12 - ) value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) # square root of flt(salvage_value) / flt(asset_cost) - depreciation_rate = math.pow(value, 1.0 / flt(no_of_years, 2)) + depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) return 100 * (1 - flt(depreciation_rate, float_precision)) From 2d9153ea3058abb849527dd5c34aa056e2a25d3a Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:24:46 +0530 Subject: [PATCH 15/39] fix: Remove misleading comment --- erpnext/assets/doctype/asset/asset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 650411c3aa..cf556a659c 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -851,7 +851,6 @@ class Asset(AccountsController): value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) - # square root of flt(salvage_value) / flt(asset_cost) depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) return 100 * (1 - flt(depreciation_rate, float_precision)) From b07aae4da5f186e6c4b9714e13bf9ef3a7a53ec7 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:33:17 +0530 Subject: [PATCH 16/39] fix: Correct pro-rata amount calculation --- erpnext/assets/doctype/asset/asset.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index cf556a659c..88c462e1df 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -860,6 +860,14 @@ class Asset(AccountsController): months = month_diff(to_date, from_date) total_days = get_total_days(to_date, row.frequency_of_depreciation) + print("\n"*10) + print("from_date: ", from_date) + print("to_date: ", to_date) + print("\n") + print("days: ", days) + print("total_days: ", total_days) + print("\n"*10) + return (depreciation_amount * flt(days)) / flt(total_days), days, months @@ -1100,8 +1108,16 @@ def is_cwip_accounting_enabled(asset_category): def get_total_days(date, frequency): period_start_date = add_months(date, cint(frequency) * -1) + if is_last_day_of_the_month(date): + period_start_date = get_last_day(period_start_date) + return date_diff(date, period_start_date) +def is_last_day_of_the_month(date): + last_day_of_the_month = get_last_day(date) + + return getdate(last_day_of_the_month) == getdate(date) + @erpnext.allow_regional def get_depreciation_amount(asset, depreciable_value, row): From 154e258ad097e750512592fbccac0d15d4667f59 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:39:39 +0530 Subject: [PATCH 17/39] fix: Get last day of the monthif depr posting date is the last day of its month --- erpnext/assets/doctype/asset/asset.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 88c462e1df..fc1e7afd3a 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -252,6 +252,7 @@ class Asset(AccountsController): number_of_pending_depreciations += 1 skip_row = False + should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date) for n in range(start[finance_book.idx - 1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) @@ -265,6 +266,9 @@ class Asset(AccountsController): finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) ) + if should_get_last_day: + schedule_date = get_last_day(schedule_date) + # schedule date will be a year later from start date # so monthly schedule date is calculated by removing 11 months from it monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1) @@ -860,14 +864,6 @@ class Asset(AccountsController): months = month_diff(to_date, from_date) total_days = get_total_days(to_date, row.frequency_of_depreciation) - print("\n"*10) - print("from_date: ", from_date) - print("to_date: ", to_date) - print("\n") - print("days: ", days) - print("total_days: ", total_days) - print("\n"*10) - return (depreciation_amount * flt(days)) / flt(total_days), days, months From e62beaefc1cb5db9656be6cd0b354bd368e5f971 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:23:01 +0530 Subject: [PATCH 18/39] test: Test if final day of the month is taken if depr_start_date is the last day of its month --- erpnext/assets/doctype/asset/test_asset.py | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index e759ad0719..7ef47d280c 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1333,6 +1333,32 @@ class TestDepreciationBasics(AssetSetup): asset.cost_center = "Main - _TC" asset.submit() + def test_depreciation_on_final_day_of_the_month(self): + """Tests if final day of the month is picked each time, if the depreciation start date is the last day of the month.""" + + asset = create_asset( + item_code="Macbook Pro", + calculate_depreciation=1, + purchase_date="2020-01-30", + available_for_use_date="2020-02-15", + depreciation_start_date="2020-02-29", + frequency_of_depreciation=1, + total_number_of_depreciations=5, + submit=1, + ) + + expected_dates = [ + "2020-02-29", + "2020-03-31", + "2020-04-30", + "2020-05-31", + "2020-06-30", + "2020-07-15", + ] + + for i, schedule in enumerate(asset.schedules): + self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date)) + def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): From 2b7ab72a72fc8482f569a7a3edc303d78e7e762e Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:46:48 +0530 Subject: [PATCH 19/39] fix: Convert string to datetime object --- erpnext/assets/doctype/asset/test_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 7ef47d280c..f1be08cb7f 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -838,7 +838,7 @@ class TestDepreciationBasics(AssetSetup): expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]] for i, schedule in enumerate(asset.schedules): - self.assertEqual(expected_values[i][0], schedule.schedule_date) + self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) def test_set_accumulated_depreciation(self): From 1b1786532afb9298db6a8c5f8770d4a5b9fba71b Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:53:25 +0530 Subject: [PATCH 20/39] test: Test monthly depreciation by Written Down Value method --- erpnext/assets/doctype/asset/test_asset.py | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index f1be08cb7f..58fd40088f 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -707,6 +707,39 @@ class TestDepreciationMethods(AssetSetup): self.assertEqual(schedules, expected_schedules) + def test_monthly_depreciation_by_wdv_method(self): + asset = create_asset( + calculate_depreciation=1, + available_for_use_date="2022-02-15", + purchase_date="2022-02-15", + depreciation_method="Written Down Value", + gross_purchase_amount=10000, + expected_value_after_useful_life=5000, + depreciation_start_date="2022-02-28", + total_number_of_depreciations=5, + frequency_of_depreciation=1, + ) + + expected_schedules = [ + ["2022-02-28", 645.0, 645.0], + ["2022-03-31", 1206.8, 1851.8], + ["2022-04-30", 1051.12, 2902.92], + ["2022-05-31", 915.52, 3818.44], + ["2022-06-30", 797.42, 4615.86], + ["2022-07-15", 384.14, 5000.0] + ] + + schedules = [ + [ + cstr(d.schedule_date), + flt(d.depreciation_amount, 2), + flt(d.accumulated_depreciation_amount, 2), + ] + for d in asset.get("schedules") + ] + + self.assertEqual(schedules, expected_schedules) + def test_discounted_wdv_depreciation_rate_for_indian_region(self): # set indian company company_flag = frappe.flags.company From 416d578290d8c9c1b2e387954164306266bf8ebc Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 23:29:03 +0530 Subject: [PATCH 21/39] fix: Add missing comma --- erpnext/assets/doctype/asset/asset.py | 1 + erpnext/assets/doctype/asset/test_asset.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index fc1e7afd3a..a880c2f391 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -1109,6 +1109,7 @@ def get_total_days(date, frequency): return date_diff(date, period_start_date) + def is_last_day_of_the_month(date): last_day_of_the_month = get_last_day(date) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 58fd40088f..f8a8fc551d 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -726,7 +726,7 @@ class TestDepreciationMethods(AssetSetup): ["2022-04-30", 1051.12, 2902.92], ["2022-05-31", 915.52, 3818.44], ["2022-06-30", 797.42, 4615.86], - ["2022-07-15", 384.14, 5000.0] + ["2022-07-15", 384.14, 5000.0], ] schedules = [ From 71f6f78d94b4a98b7f268fdde7fd307d95db9a82 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 24 Jun 2022 13:36:15 +0530 Subject: [PATCH 22/39] fix: incorrect outstanding for invoice --- erpnext/accounts/general_ledger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 8146804705..76ef3abb6f 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -132,7 +132,7 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None): for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items(): gle = copy.deepcopy(d) gle.cost_center = sub_cost_center - for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"): + for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"): gle[field] = flt(flt(d.get(field)) * percentage / 100, precision) new_gl_map.append(gle) else: From 321fea322cf205749c60da2a71f572cfc272e56a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 24 Jun 2022 17:36:37 +0530 Subject: [PATCH 23/39] test: invoice outstanding when gl's are split on cost center allocat --- .../sales_invoice/test_sales_invoice.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index b8154dd1f9..adac77f69d 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -792,6 +792,54 @@ class TestSalesInvoice(unittest.TestCase): jv.cancel() self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0) + def test_outstanding_on_cost_center_allocation(self): + # setup cost centers + from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center + from erpnext.accounts.doctype.cost_center_allocation.test_cost_center_allocation import ( + create_cost_center_allocation, + ) + + cost_centers = [ + "Main Cost Center 1", + "Sub Cost Center 1", + "Sub Cost Center 2", + ] + for cc in cost_centers: + create_cost_center(cost_center_name=cc, company="_Test Company") + + cca = create_cost_center_allocation( + "_Test Company", + "Main Cost Center 1 - _TC", + {"Sub Cost Center 1 - _TC": 60, "Sub Cost Center 2 - _TC": 40}, + ) + + # make invoice + si = frappe.copy_doc(test_records[0]) + si.is_pos = 0 + si.insert() + si.submit() + + from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry + + # make payment - fully paid + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_from_account_currency = si.currency + pe.paid_to_account_currency = si.currency + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.paid_amount = si.outstanding_amount + pe.cost_center = cca.main_cost_center + pe.insert() + pe.submit() + + # cancel cost center allocation + cca.cancel() + + si.reload() + self.assertEqual(si.outstanding_amount, 0) + def test_sales_invoice_gl_entry_without_perpetual_inventory(self): si = frappe.copy_doc(test_records[1]) si.insert() From 58fe220479d18e8b974f438b7ebfe0a2753ae5bd Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 24 Jun 2022 19:43:50 +0530 Subject: [PATCH 24/39] fix: Quotation and Sales Order item sync --- erpnext/selling/doctype/quotation/quotation.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 4fa4515a0f..d775fa93be 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -218,6 +218,15 @@ def make_sales_order(source_name, target_doc=None): def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): customer = _make_customer(source_name, ignore_permissions) + ordered_items = frappe._dict( + frappe.db.get_all( + "Sales Order Item", + {"prevdoc_docname": source_name, "docstatus": 1}, + ["item_code", "sum(qty)"], + group_by="item_code", + as_list=1, + ) + ) def set_missing_values(source, target): if customer: @@ -233,7 +242,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): target.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) + balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0) + target.qty = balance_qty if balance_qty > 0 else 0 + target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) if obj.against_blanket_order: target.against_blanket_order = obj.against_blanket_order @@ -249,6 +260,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): "doctype": "Sales Order Item", "field_map": {"parent": "prevdoc_docname"}, "postprocess": update_item, + "condition": lambda doc: doc.qty > 0, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, From 6acd0325be034ddb28153b0fbd3f1163a10ce8f6 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 23 Jun 2022 22:03:09 +0530 Subject: [PATCH 25/39] fix: General Ledger and TB opening entries mismatch issues --- .../doctype/journal_entry/journal_entry.js | 16 ---------------- .../doctype/journal_entry/journal_entry.json | 5 +++-- .../doctype/journal_entry/journal_entry.py | 18 ------------------ .../report/general_ledger/general_ledger.py | 2 +- 4 files changed, 4 insertions(+), 37 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 3cc28a3dc8..4e7a653e39 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -149,22 +149,6 @@ frappe.ui.form.on("Journal Entry", { } }); } - else if(frm.doc.voucher_type=="Opening Entry") { - return frappe.call({ - type:"GET", - method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_opening_accounts", - args: { - "company": frm.doc.company - }, - callback: function(r) { - frappe.model.clear_table(frm.doc, "accounts"); - if(r.message) { - update_jv_details(frm.doc, r.message); - } - cur_frm.set_value("is_opening", "Yes"); - } - }); - } } }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 4493c72254..8e5ba3718f 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -137,7 +137,8 @@ "fieldname": "finance_book", "fieldtype": "Link", "label": "Finance Book", - "options": "Finance Book" + "options": "Finance Book", + "read_only": 1 }, { "fieldname": "2_add_edit_gl_entries", @@ -538,7 +539,7 @@ "idx": 176, "is_submittable": 1, "links": [], - "modified": "2022-04-06 17:18:46.865259", + "modified": "2022-06-23 22:01:32.348337", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 787efd2a42..50df65b183 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1204,24 +1204,6 @@ def get_payment_entry(ref_doc, args): return je if args.get("journal_entry") else je.as_dict() -@frappe.whitelist() -def get_opening_accounts(company): - """get all balance sheet accounts for opening entry""" - accounts = frappe.db.sql_list( - """select - name from tabAccount - where - is_group=0 and report_type='Balance Sheet' and company={0} and - name not in (select distinct account from tabWarehouse where - account is not null and account != '') - order by name asc""".format( - frappe.db.escape(company) - ) - ) - - return [{"account": a, "balance": get_balance_on(a)} for a in accounts] - - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_against_jv(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index e4b561e5f6..e77e828e16 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -425,7 +425,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map): update_value_in_dict(totals, "opening", gle) update_value_in_dict(totals, "closing", gle) - elif gle.posting_date <= to_date: + elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries): if not group_by_voucher_consolidated: update_value_in_dict(gle_map[group_by_value].totals, "total", gle) update_value_in_dict(gle_map[group_by_value].totals, "closing", gle) From f904ac599ef12bc505d7c3c9540896bce644203b Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 10 Jun 2022 11:15:22 +0530 Subject: [PATCH 26/39] fix: merge conflicts and sider issues --- erpnext/crm/doctype/crm_note/__init__.py | 0 erpnext/crm/doctype/crm_note/crm_note.json | 48 +++ erpnext/crm/doctype/crm_note/crm_note.py | 9 + .../doctype/crm_settings/crm_settings.json | 17 +- erpnext/crm/doctype/lead/lead.js | 157 +++++++-- erpnext/crm/doctype/lead/lead.json | 318 +++++++++-------- erpnext/crm/doctype/lead/lead.py | 304 +++++++++------- erpnext/crm/doctype/lead/lead_list.js | 2 +- erpnext/crm/doctype/lead/test_lead.py | 105 +++++- .../crm/doctype/opportunity/opportunity.js | 52 ++- .../crm/doctype/opportunity/opportunity.json | 328 ++++++++++++------ .../crm/doctype/opportunity/opportunity.py | 169 ++++----- .../opportunity/opportunity_calendar.js | 19 - erpnext/crm/doctype/prospect/prospect.js | 21 ++ erpnext/crm/doctype/prospect/prospect.json | 144 +++++--- erpnext/crm/doctype/prospect/prospect.py | 63 ++-- erpnext/crm/doctype/prospect/test_prospect.py | 2 +- .../doctype/prospect_lead/prospect_lead.json | 33 +- .../doctype/prospect_opportunity/__init__.py | 0 .../prospect_opportunity.json | 101 ++++++ .../prospect_opportunity.py | 9 + .../lost_opportunity/lost_opportunity.js | 6 - .../lost_opportunity/lost_opportunity.json | 4 +- .../lost_opportunity/lost_opportunity.py | 11 - erpnext/crm/utils.py | 139 +++++++- erpnext/hooks.py | 7 +- erpnext/patches.txt | 1 + erpnext/patches/v14_0/crm_ux_cleanup.py | 84 +++++ erpnext/public/js/erpnext.bundle.js | 3 + .../public/js/templates/crm_activities.html | 172 +++++++++ erpnext/public/js/templates/crm_notes.html | 74 ++++ erpnext/public/js/utils/crm_activities.js | 234 +++++++++++++ .../selling/doctype/quotation/quotation.py | 11 - erpnext/templates/utils.py | 1 - erpnext/utilities/transaction_base.py | 60 +--- 35 files changed, 1974 insertions(+), 734 deletions(-) create mode 100644 erpnext/crm/doctype/crm_note/__init__.py create mode 100644 erpnext/crm/doctype/crm_note/crm_note.json create mode 100644 erpnext/crm/doctype/crm_note/crm_note.py delete mode 100644 erpnext/crm/doctype/opportunity/opportunity_calendar.js create mode 100644 erpnext/crm/doctype/prospect_opportunity/__init__.py create mode 100644 erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json create mode 100644 erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py create mode 100644 erpnext/patches/v14_0/crm_ux_cleanup.py create mode 100644 erpnext/public/js/templates/crm_activities.html create mode 100644 erpnext/public/js/templates/crm_notes.html create mode 100644 erpnext/public/js/utils/crm_activities.js diff --git a/erpnext/crm/doctype/crm_note/__init__.py b/erpnext/crm/doctype/crm_note/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/crm_note/crm_note.json b/erpnext/crm/doctype/crm_note/crm_note.json new file mode 100644 index 0000000000..fc2a4d1192 --- /dev/null +++ b/erpnext/crm/doctype/crm_note/crm_note.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-06-04 15:49:23.416644", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "note", + "added_by", + "added_on" + ], + "fields": [ + { + "columns": 5, + "fieldname": "note", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Note" + }, + { + "fieldname": "added_by", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Added By", + "options": "User" + }, + { + "fieldname": "added_on", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Added On" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-06-04 16:29:07.807252", + "modified_by": "Administrator", + "module": "CRM", + "name": "CRM Note", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/crm/doctype/crm_note/crm_note.py b/erpnext/crm/doctype/crm_note/crm_note.py new file mode 100644 index 0000000000..6c7eeb4c7e --- /dev/null +++ b/erpnext/crm/doctype/crm_note/crm_note.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMNote(Document): + pass diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index a2a19b9e79..26a07d2e85 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -10,12 +10,10 @@ "campaign_naming_by", "allow_lead_duplication_based_on_emails", "column_break_4", - "create_event_on_next_contact_date", "auto_creation_of_contact", "opportunity_section", "close_opportunity_after_days", "column_break_9", - "create_event_on_next_contact_date_opportunity", "quotation_section", "default_valid_till", "section_break_13", @@ -55,12 +53,6 @@ "fieldtype": "Check", "label": "Auto Creation of Contact" }, - { - "default": "1", - "fieldname": "create_event_on_next_contact_date", - "fieldtype": "Check", - "label": "Create Event on Next Contact Date" - }, { "fieldname": "opportunity_section", "fieldtype": "Section Break", @@ -73,12 +65,6 @@ "fieldtype": "Int", "label": "Close Replied Opportunity After Days" }, - { - "default": "1", - "fieldname": "create_event_on_next_contact_date_opportunity", - "fieldtype": "Check", - "label": "Create Event on Next Contact Date" - }, { "fieldname": "column_break_4", "fieldtype": "Column Break" @@ -105,7 +91,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-12-20 12:51:38.894252", + "modified": "2022-06-06 11:22:08.464253", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", @@ -143,5 +129,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js index 999599ce95..4814311558 100644 --- a/erpnext/crm/doctype/lead/lead.js +++ b/erpnext/crm/doctype/lead/lead.js @@ -24,31 +24,39 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller this.frm.set_query("lead_owner", function (doc, cdt, cdn) { return { query: "frappe.core.doctype.user.user.user_query" } }); - - this.frm.set_query("contact_by", function (doc, cdt, cdn) { - return { query: "frappe.core.doctype.user.user.user_query" } - }); } refresh () { + var me = this; let doc = this.frm.doc; erpnext.toggle_naming_series(); - frappe.dynamic_link = { doc: doc, fieldname: 'name', doctype: 'Lead' } + frappe.dynamic_link = { + doc: doc, + fieldname: 'name', + doctype: 'Lead' + }; if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) { this.frm.add_custom_button(__("Customer"), this.make_customer, __("Create")); - this.frm.add_custom_button(__("Opportunity"), this.make_opportunity, __("Create")); + this.frm.add_custom_button(__("Opportunity"), function() { + me.frm.trigger("make_opportunity"); + }, __("Create")); this.frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); - this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create")); - this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action')); + if (!doc.__onload.linked_prospects.length) { + this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create")); + this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action')); + } } if (!this.frm.is_new()) { frappe.contacts.render_address_and_contact(this.frm); - cur_frm.trigger('render_contact_day_html'); } else { frappe.contacts.clear_address_and_contact(this.frm); } + + this.frm.dashboard.links_area.hide(); + this.show_notes(); + this.show_activities(); } add_lead_to_prospect () { @@ -74,7 +82,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller } }, freeze: true, - freeze_message: __('...Adding Lead to Prospect') + freeze_message: __('Adding Lead to Prospect...') }); }, __('Add Lead to Prospect'), __('Add')); } @@ -86,13 +94,6 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller }) } - make_opportunity () { - frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_opportunity", - frm: cur_frm - }) - } - make_quotation () { frappe.model.open_mapped_doc({ method: "erpnext.crm.doctype.lead.lead.make_quotation", @@ -111,9 +112,10 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller prospect.fax = cur_frm.doc.fax; prospect.website = cur_frm.doc.website; prospect.prospect_owner = cur_frm.doc.lead_owner; + prospect.notes = cur_frm.doc.notes; - let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead'); - lead_prospect_row.lead = cur_frm.doc.name; + let leads_row = frappe.model.add_child(prospect, 'leads'); + leads_row.lead = cur_frm.doc.name; frappe.set_route("Form", "Prospect", prospect.name); }); @@ -125,26 +127,109 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller } } - contact_date () { - if (this.frm.doc.contact_date) { - let d = moment(this.frm.doc.contact_date); - d.add(1, "day"); - this.frm.set_value("ends_on", d.format(frappe.defaultDatetimeFormat)); - } + show_notes() { + if (this.frm.doc.docstatus == 1) return; + + const crm_notes = new erpnext.utils.CRMNotes({ + frm: this.frm, + notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); } - render_contact_day_html() { - if (cur_frm.doc.contact_date) { - let contact_date = frappe.datetime.obj_to_str(cur_frm.doc.contact_date); - let diff_days = frappe.datetime.get_day_diff(contact_date, frappe.datetime.get_today()); - let color = diff_days > 0 ? "orange" : "green"; - let message = diff_days > 0 ? __("Next Contact Date") : __("Last Contact Date"); - let html = `
- ${message} : ${frappe.datetime.global_date_format(contact_date)} -
` ; - cur_frm.dashboard.set_headline_alert(html); - } + show_activities() { + if (this.frm.doc.docstatus == 1) return; + + const crm_activities = new erpnext.utils.CRMActivities({ + frm: this.frm, + open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(this.frm.wrapper), + }); + crm_activities.refresh(); } }; + extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm })); + +frappe.ui.form.on("Lead", { + make_opportunity: async function(frm) { + let existing_prospect = (await frappe.db.get_value("Prospect Lead", + { + "lead": frm.doc.name + }, + "name", null, "Prospect" + )).message.name; + + if (!existing_prospect) { + var fields = [ + { + "label": "Create Prospect", + "fieldname": "create_prospect", + "fieldtype": "Check", + "default": 1 + }, + { + "label": "Prospect Name", + "fieldname": "prospect_name", + "fieldtype": "Data", + "default": frm.doc.company_name, + "depends_on": "create_prospect" + } + ]; + } + let existing_contact = (await frappe.db.get_value("Contact", + { + "first_name": frm.doc.first_name || frm.doc.lead_name, + "last_name": frm.doc.last_name + }, + "name" + )).message.name; + + if (!existing_contact) { + fields.push( + { + "label": "Create Contact", + "fieldname": "create_contact", + "fieldtype": "Check", + "default": "1" + } + ); + } + + if (fields) { + var d = new frappe.ui.Dialog({ + title: __('Create Opportunity'), + fields: fields, + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: 'create_prospect_and_contact', + doc: frm.doc, + args: { + data: data, + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + frappe.model.open_mapped_doc({ + method: "erpnext.crm.doctype.lead.lead.make_opportunity", + frm: frm + }); + } + d.hide(); + } + }); + }, + primary_action_label: __('Create') + }); + d.show(); + } else { + frappe.model.open_mapped_doc({ + method: "erpnext.crm.doctype.lead.lead.make_opportunity", + frm: frm + }); + } + } +}) \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 542977e689..df6ff56986 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -3,78 +3,76 @@ "allow_events_in_timeline": 1, "allow_import": 1, "autoname": "naming_series:", - "creation": "2013-04-10 11:45:37", + "creation": "2022-02-08 13:14:41.083327", "doctype": "DocType", "document_type": "Document", "email_append_to": 1, "engine": "InnoDB", "field_order": [ - "lead_details", "naming_series", "salutation", "first_name", "middle_name", "last_name", + "column_break_1", "lead_name", - "col_break123", - "status", - "company_name", - "designation", + "job_title", "gender", - "contact_details_section", + "source", + "col_break123", + "lead_owner", + "status", + "customer", + "type", + "request_type", + "contact_info_tab", "email_id", + "website", + "column_break_20", "mobile_no", "whatsapp_no", "column_break_16", "phone", "phone_ext", - "additional_information_section", + "organization_section", + "company_name", "no_of_employees", + "column_break_28", + "annual_revenue", "industry", "market_segment", - "column_break_22", + "column_break_31", + "territory", "fax", - "website", - "type", - "request_type", "address_section", "address_html", - "city", - "pincode", - "county", "column_break2", "contact_html", - "state", - "country", - "section_break_12", - "lead_owner", - "ends_on", - "column_break_14", - "contact_by", - "contact_date", - "lead_source_details_section", - "company", - "territory", - "language", - "column_break_50", - "source", + "qualification_tab", + "qualification_status", + "column_break_64", + "qualified_by", + "qualified_on", + "other_info_tab", "campaign_name", + "company", + "column_break_22", + "language", + "image", + "title", + "column_break_50", + "disabled", "unsubscribed", "blog_subscriber", - "notes_section", - "notes", - "other_information_section", - "customer", - "image", - "title" + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", + "notes_tab", + "notes_html", + "notes" ], "fields": [ - { - "fieldname": "lead_details", - "fieldtype": "Section Break", - "label": "Lead Details", - "options": "fa fa-user" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -86,6 +84,7 @@ "set_only_once": 1 }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "lead_name", "fieldtype": "Data", "in_global_search": 1, @@ -108,7 +107,7 @@ { "fieldname": "email_id", "fieldtype": "Data", - "label": "Email Address", + "label": "Email", "oldfieldname": "email_id", "oldfieldtype": "Data", "options": "Email", @@ -189,50 +188,9 @@ "print_hide": 1 }, { - "fieldname": "section_break_12", + "fieldname": "contact_info_tab", "fieldtype": "Section Break", - "label": "Follow Up" - }, - { - "fieldname": "contact_by", - "fieldtype": "Link", - "label": "Next Contact By", - "oldfieldname": "contact_by", - "oldfieldtype": "Link", - "options": "User", - "width": "100px" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "fieldname": "contact_date", - "fieldtype": "Datetime", - "label": "Next Contact Date", - "no_copy": 1, - "oldfieldname": "contact_date", - "oldfieldtype": "Date", - "width": "100px" - }, - { - "bold": 1, - "fieldname": "ends_on", - "fieldtype": "Datetime", - "label": "Ends On", - "no_copy": 1 - }, - { - "collapsible": 1, - "fieldname": "notes_section", - "fieldtype": "Section Break", - "label": "Notes" - }, - { - "fieldname": "notes", - "fieldtype": "Text Editor", - "label": "Notes" + "label": "Contact Info" }, { "fieldname": "address_html", @@ -240,34 +198,6 @@ "label": "Address HTML", "read_only": 1 }, - { - "fieldname": "city", - "fieldtype": "Data", - "label": "City/Town", - "mandatory_depends_on": "eval: doc.address_title && doc.address_type" - }, - { - "fieldname": "county", - "fieldtype": "Data", - "label": "County" - }, - { - "fieldname": "state", - "fieldtype": "Data", - "label": "State" - }, - { - "fieldname": "country", - "fieldtype": "Link", - "label": "Country", - "mandatory_depends_on": "eval: doc.address_title && doc.address_type", - "options": "Country" - }, - { - "fieldname": "pincode", - "fieldtype": "Data", - "label": "Postal Code" - }, { "fieldname": "column_break2", "fieldtype": "Column Break" @@ -289,7 +219,7 @@ { "fieldname": "mobile_no", "fieldtype": "Data", - "label": "Mobile No.", + "label": "Mobile No", "oldfieldname": "mobile_no", "oldfieldtype": "Data", "options": "Phone" @@ -380,14 +310,6 @@ "label": "Title", "print_hide": 1 }, - { - "fieldname": "designation", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Designation", - "options": "Designation" - }, { "fieldname": "language", "fieldtype": "Link", @@ -410,12 +332,6 @@ "fieldtype": "Data", "label": "Last Name" }, - { - "collapsible": 1, - "fieldname": "additional_information_section", - "fieldtype": "Section Break", - "label": "Additional Information" - }, { "fieldname": "no_of_employees", "fieldtype": "Int", @@ -428,35 +344,13 @@ { "fieldname": "whatsapp_no", "fieldtype": "Data", - "label": "WhatsApp No.", + "label": "WhatsApp", "options": "Phone" }, - { - "collapsible": 1, - "depends_on": "eval: !doc.__islocal", - "fieldname": "address_section", - "fieldtype": "Section Break", - "label": "Address" - }, - { - "fieldname": "lead_source_details_section", - "fieldtype": "Section Break", - "label": "Lead Source Details" - }, { "fieldname": "column_break_50", "fieldtype": "Column Break" }, - { - "fieldname": "other_information_section", - "fieldtype": "Section Break", - "label": "Other Information" - }, - { - "fieldname": "contact_details_section", - "fieldtype": "Section Break", - "label": "Contact Details" - }, { "fieldname": "column_break_16", "fieldtype": "Column Break" @@ -465,17 +359,136 @@ "fieldname": "phone_ext", "fieldtype": "Data", "label": "Phone Ext." + }, + { + "collapsible": 1, + "fieldname": "qualification_tab", + "fieldtype": "Section Break", + "label": "Qualification" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "notes_tab", + "fieldtype": "Tab Break", + "label": "Notes" + }, + { + "collapsible": 1, + "fieldname": "other_info_tab", + "fieldtype": "Section Break", + "label": "Additional Information" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "qualified_by", + "fieldtype": "Link", + "label": "Qualified By", + "options": "User" + }, + { + "fieldname": "qualified_on", + "fieldtype": "Date", + "label": "Qualified on" + }, + { + "fieldname": "qualification_status", + "fieldtype": "Select", + "label": "Qualification Status", + "options": "Unqualified\nIn Process\nQualified" + }, + { + "collapsible": 1, + "fieldname": "address_section", + "fieldtype": "Section Break", + "label": "Address & Contacts" + }, + { + "fieldname": "column_break_64", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Job Title" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "organization_section", + "fieldtype": "Section Break", + "label": "Organization" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" } ], "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2021-08-04 00:24:57.208590", + "modified": "2022-06-06 18:10:14.494424", "modified_by": "Administrator", "module": "CRM", "name": "Lead", "name_case": "Title Case", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -535,6 +548,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "subject_field": "title", "title_field": "title" } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index c9a64ff8e6..0d12499771 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -1,27 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe from frappe import _ from frappe.contacts.address_and_contact import load_address_and_contact from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc -from frappe.utils import ( - comma_and, - cstr, - get_link_to_form, - getdate, - has_gravatar, - nowdate, - validate_email_address, -) +from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address from erpnext.accounts.party import set_taxes from erpnext.controllers.selling_controller import SellingController +from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events -class Lead(SellingController): +class Lead(SellingController, CRMNote): def get_feed(self): return "{0}: {1}".format(_(self.status), self.lead_name) @@ -29,6 +21,7 @@ class Lead(SellingController): customer = frappe.db.get_value("Customer", {"lead_name": self.name}) self.get("__onload").is_customer = customer load_address_and_contact(self) + self.set_onload("linked_prospects", self.get_linked_prospects()) def validate(self): self.set_full_name() @@ -37,79 +30,42 @@ class Lead(SellingController): self.set_status() self.check_email_id_is_unique() self.validate_email_id() - self.validate_contact_date() - self.set_prev() + + def before_insert(self): + self.contact_doc = None + if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): + self.contact_doc = self.create_contact() + + def after_insert(self): + self.link_to_contact() + + def on_update(self): + self.update_prospect() + + def on_trash(self): + frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) + + self.unlink_dynamic_links() + self.remove_link_from_prospect() def set_full_name(self): if self.first_name: - self.lead_name = " ".join(filter(None, [self.first_name, self.middle_name, self.last_name])) - - def validate_email_id(self): - if self.email_id: - if not self.flags.ignore_email_validation: - validate_email_address(self.email_id, throw=True) - - if self.email_id == self.lead_owner: - frappe.throw(_("Lead Owner cannot be same as the Lead")) - - if self.email_id == self.contact_by: - frappe.throw(_("Next Contact By cannot be same as the Lead Email Address")) - - if self.is_new() or not self.image: - self.image = has_gravatar(self.email_id) - - def validate_contact_date(self): - if self.contact_date and getdate(self.contact_date) < getdate(nowdate()): - frappe.throw(_("Next Contact Date cannot be in the past")) - - if self.ends_on and self.contact_date and (getdate(self.ends_on) < getdate(self.contact_date)): - frappe.throw(_("Ends On date cannot be before Next Contact Date.")) - - def on_update(self): - self.add_calendar_event() - self.update_prospects() - - def set_prev(self): - if self.is_new(): - self._prev = frappe._dict({"contact_date": None, "ends_on": None, "contact_by": None}) - else: - self._prev = frappe.db.get_value( - "Lead", self.name, ["contact_date", "ends_on", "contact_by"], as_dict=1 + self.lead_name = " ".join( + filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name]) ) - def before_insert(self): - self.contact_doc = self.create_contact() + def set_lead_name(self): + if not self.lead_name: + # Check for leads being created through data import + if not self.company_name and not self.email_id and not self.flags.ignore_mandatory: + frappe.throw(_("A Lead requires either a person's name or an organization's name")) + elif self.company_name: + self.lead_name = self.company_name + else: + self.lead_name = self.email_id.split("@")[0] - def after_insert(self): - self.update_links() - - def update_links(self): - # update contact links - if self.contact_doc: - self.contact_doc.append( - "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name} - ) - self.contact_doc.save() - - def add_calendar_event(self, opts=None, force=False): - if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date"): - super(Lead, self).add_calendar_event( - { - "owner": self.lead_owner, - "starts_on": self.contact_date, - "ends_on": self.ends_on or "", - "subject": ("Contact " + cstr(self.lead_name)), - "description": ("Contact " + cstr(self.lead_name)) - + (self.contact_by and (". By : " + cstr(self.contact_by)) or ""), - }, - force, - ) - - def update_prospects(self): - prospects = frappe.get_all("Prospect Lead", filters={"lead": self.name}, fields=["parent"]) - for row in prospects: - prospect = frappe.get_doc("Prospect", row.parent) - prospect.save(ignore_permissions=True) + def set_title(self): + self.title = self.company_name or self.lead_name def check_email_id_is_unique(self): if self.email_id: @@ -124,15 +80,47 @@ class Lead(SellingController): if duplicate_leads: frappe.throw( - _("Email Address must be unique, already exists for {0}").format(comma_and(duplicate_leads)), + _("Email Address must be unique, it is already used in {0}").format( + comma_and(duplicate_leads) + ), frappe.DuplicateEntryError, ) - def on_trash(self): - frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) + def validate_email_id(self): + if self.email_id: + if not self.flags.ignore_email_validation: + validate_email_address(self.email_id, throw=True) - self.unlink_dynamic_links() - self.delete_events() + if self.email_id == self.lead_owner: + frappe.throw(_("Lead Owner cannot be same as the Lead Email Address")) + + if self.is_new() or not self.image: + self.image = has_gravatar(self.email_id) + + def link_to_contact(self): + # update contact links + if self.contact_doc: + self.contact_doc.append( + "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name} + ) + self.contact_doc.save() + + def update_prospect(self): + lead_row_name = frappe.db.get_value( + "Prospect Lead", filters={"lead": self.name}, fieldname="name" + ) + if lead_row_name: + lead_row = frappe.get_doc("Prospect Lead", lead_row_name) + lead_row.update( + { + "lead_name": self.lead_name, + "email": self.email_id, + "mobile_no": self.mobile_no, + "lead_owner": self.lead_owner, + "status": self.status, + } + ) + lead_row.db_update() def unlink_dynamic_links(self): links = frappe.get_all( @@ -155,6 +143,30 @@ class Lead(SellingController): linked_doc.remove(to_remove) linked_doc.save(ignore_permissions=True) + def remove_link_from_prospect(self): + prospects = self.get_linked_prospects() + + for d in prospects: + prospect = frappe.get_doc("Prospect", d.parent) + if len(prospect.get("leads")) == 1: + prospect.delete(ignore_permissions=True) + else: + to_remove = None + for d in prospect.get("leads"): + if d.lead == self.name: + to_remove = d + + if to_remove: + prospect.remove(to_remove) + prospect.save(ignore_permissions=True) + + def get_linked_prospects(self): + return frappe.get_all( + "Prospect Lead", + filters={"lead": self.name}, + fields=["parent"], + ) + def has_customer(self): return frappe.db.get_value("Customer", {"lead_name": self.name}) @@ -171,50 +183,78 @@ class Lead(SellingController): "Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"} ) - def set_lead_name(self): - if not self.lead_name: - # Check for leads being created through data import - if not self.company_name and not self.email_id and not self.flags.ignore_mandatory: - frappe.throw(_("A Lead requires either a person's name or an organization's name")) - elif self.company_name: - self.lead_name = self.company_name - else: - self.lead_name = self.email_id.split("@")[0] + @frappe.whitelist() + def create_prospect_and_contact(self, data): + data = frappe._dict(data) + if data.create_contact: + self.create_contact() - def set_title(self): - self.title = self.company_name or self.lead_name + if data.create_prospect: + self.create_prospect(data.prospect_name) def create_contact(self): - if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): - if not self.lead_name: - self.set_full_name() - self.set_lead_name() + if not self.lead_name: + self.set_full_name() + self.set_lead_name() - contact = frappe.new_doc("Contact") - contact.update( + contact = frappe.new_doc("Contact") + contact.update( + { + "first_name": self.first_name or self.lead_name, + "last_name": self.last_name, + "salutation": self.salutation, + "gender": self.gender, + "job_title": self.job_title, + "company_name": self.company_name, + } + ) + + if self.email_id: + contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1}) + + if self.phone: + contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) + + if self.mobile_no: + contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) + + contact.insert(ignore_permissions=True) + contact.reload() # load changes by hooks on contact + + return contact + + def create_prospect(self, company_name): + try: + prospect = frappe.new_doc("Prospect") + + prospect.company_name = company_name or self.company_name + prospect.no_of_employees = self.no_of_employees + prospect.industry = self.industry + prospect.market_segment = self.market_segment + prospect.annual_revenue = self.annual_revenue + prospect.territory = self.territory + prospect.fax = self.fax + prospect.website = self.website + prospect.prospect_owner = self.lead_owner + prospect.company = self.company + prospect.notes = self.notes + + prospect.append( + "leads", { - "first_name": self.first_name or self.lead_name, - "last_name": self.last_name, - "salutation": self.salutation, - "gender": self.gender, - "designation": self.designation, - "company_name": self.company_name, - } + "lead": self.name, + "lead_name": self.lead_name, + "email": self.email_id, + "mobile_no": self.mobile_no, + "lead_owner": self.lead_owner, + "status": self.status, + }, ) - - if self.email_id: - contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1}) - - if self.phone: - contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) - - if self.mobile_no: - contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) - - contact.insert(ignore_permissions=True) - contact.reload() # load changes by hooks on contact - - return contact + prospect.flags.ignore_permissions = True + prospect.flags.ignore_mandatory = True + prospect.save() + except frappe.DuplicateEntryError: + frappe.throw(_("Prospect {0} already exists").format(company_name or self.company_name)) @frappe.whitelist() @@ -274,6 +314,8 @@ def make_opportunity(source_name, target_doc=None): "company_name": "customer_name", "email_id": "contact_email", "mobile_no": "contact_mobile", + "lead_owner": "opportunity_owner", + "notes": "notes", }, } }, @@ -422,21 +464,25 @@ def get_lead_with_phone_number(number): return lead -def daily_open_lead(): - leads = frappe.get_all("Lead", filters=[["contact_date", "Between", [nowdate(), nowdate()]]]) - for lead in leads: - frappe.db.set_value("Lead", lead.name, "status", "Open") - - @frappe.whitelist() def add_lead_to_prospect(lead, prospect): prospect = frappe.get_doc("Prospect", prospect) - prospect.append("prospect_lead", {"lead": lead}) + prospect.append("leads", {"lead": lead}) prospect.save(ignore_permissions=True) + + carry_forward_communication_and_comments = frappe.db.get_single_value( + "CRM Settings", "carry_forward_communication_and_comments" + ) + + if carry_forward_communication_and_comments: + copy_comments("Lead", lead, prospect) + link_communications("Lead", lead, prospect) + link_open_events("Lead", lead, prospect) + frappe.msgprint( _("Lead {0} has been added to prospect {1}.").format( frappe.bold(lead), frappe.bold(prospect.name) ), - title=_("Lead Added"), + title=_("Lead -> Prospect"), indicator="green", ) diff --git a/erpnext/crm/doctype/lead/lead_list.js b/erpnext/crm/doctype/lead/lead_list.js index 75208fa64b..dbeaf608ff 100644 --- a/erpnext/crm/doctype/lead/lead_list.js +++ b/erpnext/crm/doctype/lead/lead_list.js @@ -16,7 +16,7 @@ frappe.listview_settings['Lead'] = { prospect.prospect_owner = r.lead_owner; leads.forEach(function(lead) { - let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead'); + let lead_prospect_row = frappe.model.add_child(prospect, 'leads'); lead_prospect_row.lead = lead.name; }); frappe.set_route("Form", "Prospect", prospect.name); diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py index 166ae2c353..8fe688de46 100644 --- a/erpnext/crm/doctype/lead/test_lead.py +++ b/erpnext/crm/doctype/lead/test_lead.py @@ -5,7 +5,10 @@ import unittest import frappe -from frappe.utils import random_string +from frappe.utils import random_string, today + +from erpnext.crm.doctype.lead.lead import make_opportunity +from erpnext.crm.utils import get_linked_prospect test_records = frappe.get_test_records("Lead") @@ -83,6 +86,105 @@ class TestLead(unittest.TestCase): self.assertEqual(frappe.db.exists("Lead", lead_doc.name), None) self.assertEqual(len(address_1.get("links")), 1) + def test_prospect_creation_from_lead(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + event = create_event("Meeting 1", today(), "Lead", lead.name) + + lead.create_prospect(lead.company_name) + + prospect = get_linked_prospect("Lead", lead.name) + self.assertEqual(prospect, "Prospect Company") + + event.reload() + self.assertEqual(event.event_participants[1].reference_doctype, "Prospect") + self.assertEqual(event.event_participants[1].reference_docname, prospect) + + def test_opportunity_from_lead(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabOpportunity` where party_name='Rahul Tripathi'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + lead.add_note("test note") + event = create_event("Meeting 1", today(), "Lead", lead.name) + create_todo("followup", "Lead", lead.name) + + opportunity = make_opportunity(lead.name) + opportunity.save() + + self.assertEqual(opportunity.get("party_name"), lead.name) + self.assertEqual(opportunity.notes[0].note, "test note") + + event.reload() + self.assertEqual(event.event_participants[1].reference_doctype, "Opportunity") + self.assertEqual(event.event_participants[1].reference_docname, opportunity.name) + + self.assertTrue( + frappe.db.get_value( + "ToDo", {"reference_type": "Opportunity", "reference_name": opportunity.name} + ) + ) + + def test_copy_events_from_lead_to_prospect(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + lead.create_prospect(lead.company_name) + prospect = get_linked_prospect("Lead", lead.name) + + event = create_event("Meeting", today(), "Lead", lead.name) + + self.assertEqual(len(event.event_participants), 2) + self.assertEqual(event.event_participants[1].reference_doctype, "Prospect") + self.assertEqual(event.event_participants[1].reference_docname, prospect) + + +def create_event(subject, starts_on, reference_type, reference_name): + event = frappe.new_doc("Event") + event.subject = subject + event.starts_on = starts_on + event.event_type = "Private" + event.all_day = 1 + event.owner = "Administrator" + event.append( + "event_participants", {"reference_doctype": reference_type, "reference_docname": reference_name} + ) + event.reference_type = reference_type + event.reference_name = reference_name + event.insert() + return event + + +def create_todo(description, reference_type, reference_name): + todo = frappe.new_doc("ToDo") + todo.description = description + todo.owner = "Administrator" + todo.reference_type = reference_type + todo.reference_name = reference_name + todo.insert() + return todo + def make_lead(**args): args = frappe._dict(args) @@ -93,6 +195,7 @@ def make_lead(**args): "first_name": args.first_name or "_Test", "last_name": args.last_name or "Lead", "email_id": args.email_id or "new_lead_{}@example.com".format(random_string(5)), + "company_name": args.company_name or "_Test Company", } ).insert() diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index 8e7d67e057..c53ea9d5c3 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -32,13 +32,6 @@ frappe.ui.form.on("Opportunity", { } }, - contact_date: function(frm) { - if(frm.doc.contact_date < frappe.datetime.now_datetime()){ - frm.set_value("contact_date", ""); - frappe.throw(__("Next follow up date should be greater than now.")) - } - }, - onload_post_render: function(frm) { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, @@ -130,6 +123,13 @@ frappe.ui.form.on("Opportunity", { }); } } + + if (!frm.is_new()) { + frappe.contacts.render_address_and_contact(frm); + // frm.trigger('render_contact_day_html'); + } else { + frappe.contacts.clear_address_and_contact(frm); + } }, set_contact_link: function(frm) { @@ -227,8 +227,7 @@ frappe.ui.form.on("Opportunity", { 'total': flt(total), 'base_total': flt(base_total) }); - } - + }, }); frappe.ui.form.on("Opportunity Item", { calculate: function(frm, cdt, cdn) { @@ -264,13 +263,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { this.frm.trigger('currency'); } + refresh() { + this.show_notes(); + this.show_activities(); + } + setup_queries() { var me = this; - if(this.frm.fields_dict.contact_by.df.options.match(/^User/)) { - this.frm.set_query("contact_by", erpnext.queries.user); - } - me.frm.set_query('customer_address', erpnext.queries.address_query); this.frm.set_query("item_code", "items", function() { @@ -287,6 +287,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { } else if (me.frm.doc.opportunity_from == "Customer") { me.frm.set_query('party_name', erpnext.queries['customer']); + } else if (me.frm.doc.opportunity_from == "Prospect") { + me.frm.set_query('party_name', function() { + return { + filters: { + "company": me.frm.doc.company + } + }; + }); } } @@ -303,6 +311,24 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { frm: cur_frm }) } + + show_notes() { + const crm_notes = new erpnext.utils.CRMNotes({ + frm: this.frm, + notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); + } + + show_activities() { + const crm_activities = new erpnext.utils.CRMActivities({ + frm: this.frm, + open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(this.frm.wrapper), + }); + crm_activities.refresh(); + } }; extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({frm: cur_frm})); diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 089f2d2faa..7854007919 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_events_in_timeline": 1, "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", @@ -11,68 +12,84 @@ "email_append_to": 1, "engine": "InnoDB", "field_order": [ - "from_section", "naming_series", "opportunity_from", "party_name", "customer_name", - "source", "column_break0", - "title", "opportunity_type", + "source", + "opportunity_owner", + "column_break_10", "status", - "converted_by", "sales_stage", - "first_response_time", "expected_closing", - "next_contact", - "contact_by", - "contact_date", - "column_break2", - "to_discuss", + "probability", + "organization_details_section", + "no_of_employees", + "annual_revenue", + "column_break_23", + "customer_group", + "industry", + "column_break_31", + "market_segment", + "territory", + "website", "section_break_14", "currency", + "column_break_36", "conversion_rate", - "base_opportunity_amount", - "with_items", "column_break_17", - "probability", "opportunity_amount", + "base_opportunity_amount", + "more_info", + "company", + "campaign", + "transaction_date", + "column_break1", + "language", + "amended_from", + "title", + "first_response_time", + "lost_detail_section", + "lost_reasons", + "order_lost_reason", + "column_break_56", + "competitors", + "contact_info", + "primary_contact_section", + "contact_person", + "job_title", + "column_break_54", + "contact_email", + "contact_mobile", + "column_break_22", + "whatsapp", + "phone", + "phone_ext", + "address_contact_section", + "address_html", + "customer_address", + "address_display", + "column_break3", + "contact_html", + "contact_display", "items_section", "items", "section_break_32", "base_total", "column_break_33", "total", - "contact_info", - "customer_address", - "address_display", - "territory", - "customer_group", - "column_break3", - "contact_person", - "contact_display", - "contact_email", - "contact_mobile", - "more_info", - "company", - "campaign", - "column_break1", - "transaction_date", - "language", - "amended_from", - "lost_detail_section", - "lost_reasons", - "order_lost_reason", - "column_break_56", - "competitors" + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", + "notes_tab", + "notes_html", + "notes", + "dashboard_tab" ], "fields": [ - { - "fieldname": "from_section", - "fieldtype": "Section Break", - "options": "fa fa-user" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -113,8 +130,9 @@ "bold": 1, "fieldname": "customer_name", "fieldtype": "Data", + "hidden": 1, "in_global_search": 1, - "label": "Customer / Lead Name", + "label": "Customer Name", "read_only": 1 }, { @@ -166,48 +184,10 @@ "fieldtype": "Date", "label": "Expected Closing Date" }, - { - "collapsible": 1, - "collapsible_depends_on": "contact_by", - "fieldname": "next_contact", - "fieldtype": "Section Break", - "label": "Follow Up" - }, - { - "fieldname": "contact_by", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Next Contact By", - "oldfieldname": "contact_by", - "oldfieldtype": "Link", - "options": "User", - "width": "75px" - }, - { - "fieldname": "contact_date", - "fieldtype": "Datetime", - "label": "Next Contact Date", - "oldfieldname": "contact_date", - "oldfieldtype": "Date" - }, - { - "fieldname": "column_break2", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "width": "50%" - }, - { - "fieldname": "to_discuss", - "fieldtype": "Small Text", - "label": "To Discuss", - "no_copy": 1, - "oldfieldname": "to_discuss", - "oldfieldtype": "Small Text" - }, { "fieldname": "section_break_14", "fieldtype": "Section Break", - "label": "Sales" + "label": "Opportunity Value" }, { "fieldname": "currency", @@ -221,12 +201,6 @@ "label": "Opportunity Amount", "options": "currency" }, - { - "default": "0", - "fieldname": "with_items", - "fieldtype": "Check", - "label": "With Items" - }, { "fieldname": "column_break_17", "fieldtype": "Column Break" @@ -245,9 +219,8 @@ "label": "Probability (%)" }, { - "depends_on": "with_items", "fieldname": "items_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" @@ -262,18 +235,16 @@ "options": "Opportunity Item" }, { - "collapsible": 1, - "collapsible_depends_on": "next_contact_by", - "depends_on": "eval:doc.party_name", "fieldname": "contact_info", - "fieldtype": "Section Break", - "label": "Contact Info", + "fieldtype": "Tab Break", + "label": "Contacts", "options": "fa fa-bullhorn" }, { "depends_on": "eval:doc.party_name", "fieldname": "customer_address", "fieldtype": "Link", + "hidden": 1, "label": "Customer / Lead Address", "options": "Address", "print_hide": 1 @@ -327,19 +298,16 @@ "read_only": 1 }, { - "depends_on": "eval:doc.party_name", "fieldname": "contact_email", "fieldtype": "Data", "label": "Contact Email", - "options": "Email", - "read_only": 1 + "options": "Email" }, { - "depends_on": "eval:doc.party_name", "fieldname": "contact_mobile", - "fieldtype": "Small Text", - "label": "Contact Mobile No", - "read_only": 1 + "fieldtype": "Data", + "label": "Contact Mobile", + "options": "Phone" }, { "collapsible": 1, @@ -416,12 +384,6 @@ "options": "Opportunity Lost Reason Detail", "read_only": 1 }, - { - "fieldname": "converted_by", - "fieldtype": "Link", - "label": "Converted By", - "options": "User" - }, { "bold": 1, "fieldname": "first_response_time", @@ -474,6 +436,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.status===\"Lost\"", "fieldname": "lost_detail_section", "fieldtype": "Section Break", "label": "Lost Reasons" @@ -488,12 +451,163 @@ "label": "Competitors", "options": "Competitor Detail", "read_only": 1 + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "organization_details_section", + "fieldtype": "Section Break", + "label": "Organization" + }, + { + "fieldname": "no_of_employees", + "fieldtype": "Int", + "label": "No of Employees" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue" + }, + { + "fieldname": "industry", + "fieldtype": "Link", + "label": "Industry", + "options": "Industry Type" + }, + { + "fieldname": "market_segment", + "fieldtype": "Link", + "label": "Market Segment", + "options": "Market Segment" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "address_contact_section", + "fieldtype": "Section Break", + "label": "Address & Contact" + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fieldname": "opportunity_owner", + "fieldtype": "Link", + "label": "Opportunity Owner", + "options": "User" + }, + { + "fieldname": "website", + "fieldtype": "Data", + "label": "Website" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "whatsapp", + "fieldtype": "Data", + "label": "WhatsApp", + "options": "Phone" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "options": "Phone" + }, + { + "fieldname": "phone_ext", + "fieldtype": "Data", + "label": "Phone Ext." + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "fieldname": "primary_contact_section", + "fieldtype": "Section Break", + "label": "Primary Contact" + }, + { + "fieldname": "column_break_54", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "dashboard_tab", + "fieldtype": "Tab Break", + "label": "Dashboard", + "show_dashboard": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "notes_tab", + "fieldtype": "Tab Break", + "label": "Notes" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "label": "Job Title" + }, + { + "fieldname": "address_html", + "fieldtype": "HTML", + "label": "Address HTML" + }, + { + "fieldname": "contact_html", + "fieldtype": "HTML", + "label": "Contact HTML" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-01-29 19:32:26.382896", + "modified": "2022-06-10 10:37:25.537444", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index c70a4f61b8..0dc0cd3762 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -6,39 +6,42 @@ import json import frappe from frappe import _ +from frappe.contacts.address_and_contact import load_address_and_contact from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now -from frappe.utils import cint, flt, get_fullname +from frappe.utils import flt, get_fullname -from erpnext.crm.utils import add_link_in_communication, copy_comments +from erpnext.crm.utils import ( + CRMNote, + copy_comments, + link_communications, + link_open_events, + link_open_tasks, +) from erpnext.setup.utils import get_exchange_rate from erpnext.utilities.transaction_base import TransactionBase -class Opportunity(TransactionBase): +class Opportunity(TransactionBase, CRMNote): + def onload(self): + ref_doc = frappe.get_doc(self.opportunity_from, self.party_name) + load_address_and_contact(ref_doc) + self.set("__onload", ref_doc.get("__onload")) + def after_insert(self): if self.opportunity_from == "Lead": frappe.get_doc("Lead", self.party_name).set_status(update=True) + self.disable_lead() - if self.opportunity_from in ["Lead", "Prospect"]: + link_open_tasks(self.opportunity_from, self.party_name, self) + link_open_events(self.opportunity_from, self.party_name, self) if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): copy_comments(self.opportunity_from, self.party_name, self) - add_link_in_communication(self.opportunity_from, self.party_name, self) + link_communications(self.opportunity_from, self.party_name, self) def validate(self): - self._prev = frappe._dict( - { - "contact_date": frappe.db.get_value("Opportunity", self.name, "contact_date") - if (not cint(self.get("__islocal"))) - else None, - "contact_by": frappe.db.get_value("Opportunity", self.name, "contact_by") - if (not cint(self.get("__islocal"))) - else None, - } - ) - self.make_new_lead_if_required() self.validate_item_details() self.validate_uom_is_integer("uom", "qty") @@ -48,11 +51,8 @@ class Opportunity(TransactionBase): if not self.title: self.title = self.customer_name - if not self.with_items: - self.items = [] - - else: - self.calculate_totals() + self.calculate_totals() + self.update_prospect() def map_fields(self): for field in self.meta.get_valid_columns(): @@ -75,6 +75,44 @@ class Opportunity(TransactionBase): self.total = flt(total) self.base_total = flt(base_total) + def update_prospect(self): + prospect_name = None + if self.opportunity_from == "Prospect" and self.party_name: + prospect_name = self.party_name + elif self.opportunity_from == "Lead": + prospect_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent") + + if prospect_name: + prospect = frappe.get_doc("Prospect", prospect_name) + + opportunity_values = { + "opportunity": self.name, + "amount": self.opportunity_amount, + "stage": self.sales_stage, + "deal_owner": self.opportunity_owner, + "probability": self.probability, + "expected_closing": self.expected_closing, + "currency": self.currency, + "contact_person": self.contact_person, + } + + opportunity_already_added = False + for d in prospect.get("opportunities", []): + if d.opportunity == self.name: + opportunity_already_added = True + d.update(opportunity_values) + d.db_update() + + if not opportunity_already_added: + prospect.append("opportunities", opportunity_values) + prospect.flags.ignore_permissions = True + prospect.flags.ignore_mandatory = True + prospect.save() + + def disable_lead(self): + if self.opportunity_from == "Lead": + frappe.db.set_value("Lead", self.party_name, {"disabled": 1, "docstatus": 1}) + def make_new_lead_if_required(self): """Set lead against new opportunity""" if (not self.get("party_name")) and self.contact_email: @@ -144,11 +182,8 @@ class Opportunity(TransactionBase): else: frappe.throw(_("Cannot declare as lost, because Quotation has been made.")) - def on_trash(self): - self.delete_events() - def has_active_quotation(self): - if not self.with_items: + if not self.get("items", []): return frappe.get_all( "Quotation", {"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, @@ -165,7 +200,7 @@ class Opportunity(TransactionBase): ) def has_ordered_quotation(self): - if not self.with_items: + if not self.get("items", []): return frappe.get_all( "Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name" ) @@ -195,43 +230,20 @@ class Opportunity(TransactionBase): return True def validate_cust_name(self): - if self.party_name and self.opportunity_from == "Customer": - self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name") - elif self.party_name and self.opportunity_from == "Lead": - lead_name, company_name = frappe.db.get_value( - "Lead", self.party_name, ["lead_name", "company_name"] - ) - self.customer_name = company_name or lead_name + if self.party_name: + if self.opportunity_from == "Customer": + self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name") + elif self.opportunity_from == "Lead": + customer_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent") + if not customer_name: + lead_name, company_name = frappe.db.get_value( + "Lead", self.party_name, ["lead_name", "company_name"] + ) + customer_name = company_name or lead_name - def on_update(self): - self.add_calendar_event() - - def add_calendar_event(self, opts=None, force=False): - if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date_opportunity"): - if not opts: - opts = frappe._dict() - - opts.description = "" - opts.contact_date = self.contact_date - - if self.party_name and self.opportunity_from == "Customer": - if self.contact_person: - opts.description = f"Contact {self.contact_person}" - else: - opts.description = f"Contact customer {self.party_name}" - elif self.party_name and self.opportunity_from == "Lead": - if self.contact_display: - opts.description = f"Contact {self.contact_display}" - else: - opts.description = f"Contact lead {self.party_name}" - - opts.subject = opts.description - opts.description += f". By : {self.contact_by}" - - if self.to_discuss: - opts.description += f" To Discuss : {frappe.render_template(self.to_discuss, {'doc': self})}" - - super(Opportunity, self).add_calendar_event(opts, force) + self.customer_name = customer_name + elif self.opportunity_from == "Prospect": + self.customer_name = self.party_name def validate_item_details(self): if not self.get("items"): @@ -295,7 +307,7 @@ def make_quotation(source_name, target_doc=None): quotation.run_method("set_missing_values") quotation.run_method("calculate_taxes_and_totals") - if not source.with_items: + if not source.get("items", []): quotation.opportunity = source.name doclist = get_mapped_doc( @@ -440,34 +452,3 @@ def make_opportunity_from_communication(communication, company, ignore_communica link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) return opportunity.name - - -@frappe.whitelist() -def get_events(start, end, filters=None): - """Returns events for Gantt / Calendar view rendering. - :param start: Start date-time. - :param end: End date-time. - :param filters: Filters (JSON). - """ - from frappe.desk.calendar import get_event_conditions - - conditions = get_event_conditions("Opportunity", filters) - - data = frappe.db.sql( - """ - select - distinct `tabOpportunity`.name, `tabOpportunity`.customer_name, `tabOpportunity`.opportunity_amount, - `tabOpportunity`.title, `tabOpportunity`.contact_date - from - `tabOpportunity` - where - (`tabOpportunity`.contact_date between %(start)s and %(end)s) - {conditions} - """.format( - conditions=conditions - ), - {"start": start, "end": end}, - as_dict=True, - update={"allDay": 0}, - ) - return data diff --git a/erpnext/crm/doctype/opportunity/opportunity_calendar.js b/erpnext/crm/doctype/opportunity/opportunity_calendar.js deleted file mode 100644 index 58fa2b8cd8..0000000000 --- a/erpnext/crm/doctype/opportunity/opportunity_calendar.js +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt -frappe.views.calendar["Opportunity"] = { - field_map: { - "start": "contact_date", - "end": "contact_date", - "id": "name", - "title": "customer_name", - "allDay": "allDay" - }, - options: { - header: { - left: 'prev,next today', - center: 'title', - right: 'month' - } - }, - get_events_method: 'erpnext.crm.doctype.opportunity.opportunity.get_events' -} diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js index 8721a5b42d..495ed291ae 100644 --- a/erpnext/crm/doctype/prospect/prospect.js +++ b/erpnext/crm/doctype/prospect/prospect.js @@ -27,5 +27,26 @@ frappe.ui.form.on('Prospect', { } else { frappe.contacts.clear_address_and_contact(frm); } + frm.trigger("show_notes"); + frm.trigger("show_activities"); + }, + + show_notes (frm) { + const crm_notes = new erpnext.utils.CRMNotes({ + frm: frm, + notes_wrapper: $(frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); + }, + + show_activities (frm) { + const crm_activities = new erpnext.utils.CRMActivities({ + frm: frm, + open_activities_wrapper: $(frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(frm.wrapper), + }); + crm_activities.refresh(); } + }); diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json index c9554ba31a..2750e5b421 100644 --- a/erpnext/crm/doctype/prospect/prospect.json +++ b/erpnext/crm/doctype/prospect/prospect.json @@ -1,33 +1,42 @@ { "actions": [], + "allow_events_in_timeline": 1, "autoname": "field:company_name", "creation": "2021-08-19 00:21:06.995448", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "overview_tab", "company_name", - "industry", - "market_segment", "customer_group", + "no_of_employees", + "annual_revenue", + "column_break_4", + "market_segment", + "industry", "territory", "column_break_6", - "no_of_employees", - "currency", - "annual_revenue", - "more_details_section", - "fax", - "website", - "column_break_13", "prospect_owner", + "website", + "fax", "company", - "leads_section", - "prospect_lead", "address_and_contact_section", + "column_break_16", + "contacts_tab", "address_html", - "column_break_17", + "column_break_18", "contact_html", + "leads_section", + "leads", + "opportunities_tab", + "opportunities", + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", "notes_section", + "notes_html", "notes" ], "fields": [ @@ -74,13 +83,6 @@ "fieldtype": "Int", "label": "No. of Employees" }, - { - "fieldname": "currency", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Currency", - "options": "Currency" - }, { "fieldname": "annual_revenue", "fieldtype": "Currency", @@ -108,23 +110,14 @@ }, { "fieldname": "leads_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Leads" }, - { - "fieldname": "prospect_lead", - "fieldtype": "Table", - "options": "Prospect Lead" - }, { "fieldname": "address_html", "fieldtype": "HTML", "label": "Address HTML" }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, { "fieldname": "contact_html", "fieldtype": "HTML", @@ -132,28 +125,16 @@ }, { "collapsible": 1, + "depends_on": "eval:!doc.__islocal", "fieldname": "notes_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Notes" }, - { - "fieldname": "notes", - "fieldtype": "Text Editor" - }, - { - "fieldname": "more_details_section", - "fieldtype": "Section Break", - "label": "More Details" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, { "depends_on": "eval: !doc.__islocal", "fieldname": "address_and_contact_section", "fieldtype": "Section Break", - "label": "Address and Contact" + "label": "Address" }, { "fieldname": "company", @@ -161,11 +142,83 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "opportunities_tab", + "fieldtype": "Tab Break", + "label": "Opportunities" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "fieldname": "opportunities", + "fieldtype": "Table", + "label": "Opportunities", + "options": "Prospect Opportunity" + }, + { + "fieldname": "contacts_tab", + "fieldtype": "Tab Break", + "label": "Address & Contact" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "leads", + "fieldtype": "Table", + "options": "Prospect Lead" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "overview_tab", + "fieldtype": "Tab Break", + "label": "Overview" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-11-01 13:10:36.759249", + "modified": "2022-06-09 16:58:45.100244", "modified_by": "Administrator", "module": "CRM", "name": "Prospect", @@ -207,6 +260,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "company_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py index 39436f5918..fbb115883f 100644 --- a/erpnext/crm/doctype/prospect/prospect.py +++ b/erpnext/crm/doctype/prospect/prospect.py @@ -3,19 +3,15 @@ import frappe from frappe.contacts.address_and_contact import load_address_and_contact -from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from erpnext.crm.utils import add_link_in_communication, copy_comments +from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events -class Prospect(Document): +class Prospect(CRMNote): def onload(self): load_address_and_contact(self) - def validate(self): - self.update_lead_details() - def on_update(self): self.link_with_lead_contact_and_address() @@ -23,23 +19,24 @@ class Prospect(Document): self.unlink_dynamic_links() def after_insert(self): - if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): - for row in self.get("prospect_lead"): - copy_comments("Lead", row.lead, self) - add_link_in_communication("Lead", row.lead, self) + carry_forward_communication_and_comments = frappe.db.get_single_value( + "CRM Settings", "carry_forward_communication_and_comments" + ) - def update_lead_details(self): - for row in self.get("prospect_lead"): - lead = frappe.get_value( - "Lead", row.lead, ["lead_name", "status", "email_id", "mobile_no"], as_dict=True - ) - row.lead_name = lead.lead_name - row.status = lead.status - row.email = lead.email_id - row.mobile_no = lead.mobile_no + for row in self.get("leads"): + if carry_forward_communication_and_comments: + copy_comments("Lead", row.lead, self) + link_communications("Lead", row.lead, self) + link_open_events("Lead", row.lead, self) + + for row in self.get("opportunities"): + if carry_forward_communication_and_comments: + copy_comments("Opportunity", row.opportunity, self) + link_communications("Opportunity", row.opportunity, self) + link_open_events("Opportunity", row.opportunity, self) def link_with_lead_contact_and_address(self): - for row in self.prospect_lead: + for row in self.leads: links = frappe.get_all( "Dynamic Link", filters={"link_doctype": "Lead", "link_name": row.lead}, @@ -116,9 +113,7 @@ def make_opportunity(source_name, target_doc=None): { "Prospect": { "doctype": "Opportunity", - "field_map": { - "name": "party_name", - }, + "field_map": {"name": "party_name", "prospect_owner": "opportunity_owner"}, } }, target_doc, @@ -127,3 +122,25 @@ def make_opportunity(source_name, target_doc=None): ) return doclist + + +@frappe.whitelist() +def get_opportunities(prospect): + return frappe.get_all( + "Opportunity", + filters={"opportunity_from": "Prospect", "party_name": prospect}, + fields=[ + "opportunity_owner", + "sales_stage", + "status", + "expected_closing", + "probability", + "opportunity_amount", + "currency", + "contact_person", + "contact_email", + "contact_mobile", + "creation", + "name", + ], + ) diff --git a/erpnext/crm/doctype/prospect/test_prospect.py b/erpnext/crm/doctype/prospect/test_prospect.py index ddd7b932aa..874f84ca84 100644 --- a/erpnext/crm/doctype/prospect/test_prospect.py +++ b/erpnext/crm/doctype/prospect/test_prospect.py @@ -20,7 +20,7 @@ class TestProspect(unittest.TestCase): add_lead_to_prospect(lead_doc.name, prospect_doc.name) prospect_doc.reload() lead_exists_in_prosoect = False - for rec in prospect_doc.get("prospect_lead"): + for rec in prospect_doc.get("leads"): if rec.lead == lead_doc.name: lead_exists_in_prosoect = True self.assertEqual(lead_exists_in_prosoect, True) diff --git a/erpnext/crm/doctype/prospect_lead/prospect_lead.json b/erpnext/crm/doctype/prospect_lead/prospect_lead.json index 3c160d9e80..075c0f9be5 100644 --- a/erpnext/crm/doctype/prospect_lead/prospect_lead.json +++ b/erpnext/crm/doctype/prospect_lead/prospect_lead.json @@ -7,12 +7,15 @@ "field_order": [ "lead", "lead_name", - "status", "email", - "mobile_no" + "column_break_4", + "mobile_no", + "lead_owner", + "status" ], "fields": [ { + "columns": 2, "fieldname": "lead", "fieldtype": "Link", "in_list_view": 1, @@ -21,6 +24,8 @@ "reqd": 1 }, { + "columns": 2, + "fetch_from": "lead.lead_name", "fieldname": "lead_name", "fieldtype": "Data", "in_list_view": 1, @@ -28,14 +33,17 @@ "read_only": 1 }, { + "columns": 1, + "fetch_from": "lead.status", "fieldname": "status", - "fieldtype": "Select", + "fieldtype": "Data", "in_list_view": 1, "label": "Status", - "options": "Lead\nOpen\nReplied\nOpportunity\nQuotation\nLost Quotation\nInterested\nConverted\nDo Not Contact", "read_only": 1 }, { + "columns": 2, + "fetch_from": "lead.email_id", "fieldname": "email", "fieldtype": "Data", "in_list_view": 1, @@ -44,18 +52,32 @@ "read_only": 1 }, { + "columns": 2, + "fetch_from": "lead.mobile_no", "fieldname": "mobile_no", "fieldtype": "Data", "in_list_view": 1, "label": "Mobile No", "options": "Phone", "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fetch_from": "lead.lead_owner", + "fieldname": "lead_owner", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Lead Owner" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-25 12:58:24.638054", + "modified": "2022-04-28 20:27:58.805970", "modified_by": "Administrator", "module": "CRM", "name": "Prospect Lead", @@ -63,5 +85,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect_opportunity/__init__.py b/erpnext/crm/doctype/prospect_opportunity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json new file mode 100644 index 0000000000..d8c2520176 --- /dev/null +++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-04-27 17:40:37.965161", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "opportunity", + "amount", + "stage", + "deal_owner", + "column_break_4", + "probability", + "expected_closing", + "currency", + "contact_person" + ], + "fields": [ + { + "columns": 2, + "fieldname": "opportunity", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Opportunity", + "options": "Opportunity" + }, + { + "columns": 2, + "fetch_from": "opportunity.opportunity_amount", + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "currency" + }, + { + "columns": 2, + "fetch_from": "opportunity.sales_stage", + "fieldname": "stage", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Stage" + }, + { + "columns": 1, + "fetch_from": "opportunity.probability", + "fieldname": "probability", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Probability" + }, + { + "columns": 1, + "fetch_from": "opportunity.expected_closing", + "fieldname": "expected_closing", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Closing" + }, + { + "fetch_from": "opportunity.currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fetch_from": "opportunity.opportunity_owner", + "fieldname": "deal_owner", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Deal Owner" + }, + { + "fetch_from": "opportunity.contact_person", + "fieldname": "contact_person", + "fieldtype": "Link", + "label": "Contact Person", + "options": "Contact" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-04-28 10:05:38.730368", + "modified_by": "Administrator", + "module": "CRM", + "name": "Prospect Opportunity", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py new file mode 100644 index 0000000000..8f5d19aaf2 --- /dev/null +++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ProspectOpportunity(Document): + pass diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.js b/erpnext/crm/report/lost_opportunity/lost_opportunity.js index 97c56f8c43..927c54df07 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.js +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.js @@ -57,11 +57,5 @@ frappe.query_reports["Lost Opportunity"] = { "fieldtype": "Dynamic Link", "options": "opportunity_from" }, - { - "fieldname":"contact_by", - "label": __("Next Contact By"), - "fieldtype": "Link", - "options": "User" - }, ] }; diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.json b/erpnext/crm/report/lost_opportunity/lost_opportunity.json index e7a8e12ba7..f6f36bd2b5 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.json +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.json @@ -7,8 +7,8 @@ "doctype": "Report", "idx": 0, "is_standard": "Yes", - "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"contact_by\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}", - "modified": "2020-07-29 15:49:02.848845", + "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}", + "modified": "2022-06-04 15:49:02.848845", "modified_by": "Administrator", "module": "CRM", "name": "Lost Opportunity", diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.py b/erpnext/crm/report/lost_opportunity/lost_opportunity.py index a57b44be47..254511c92f 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.py +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.py @@ -61,13 +61,6 @@ def get_columns(): "options": "Territory", "width": 150, }, - { - "label": _("Next Contact By"), - "fieldname": "contact_by", - "fieldtype": "Link", - "options": "User", - "width": 150, - }, ] return columns @@ -81,7 +74,6 @@ def get_data(filters): `tabOpportunity`.party_name, `tabOpportunity`.customer_name, `tabOpportunity`.opportunity_type, - `tabOpportunity`.contact_by, GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason, `tabOpportunity`.sales_stage, `tabOpportunity`.territory @@ -115,9 +107,6 @@ def get_conditions(filters): if filters.get("party_name"): conditions.append(" and `tabOpportunity`.party_name=%(party_name)s") - if filters.get("contact_by"): - conditions.append(" and `tabOpportunity`.contact_by=%(contact_by)s") - return " ".join(conditions) if conditions else "" diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 5783b2c661..33441b166d 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -1,4 +1,6 @@ import frappe +from frappe.model.document import Document +from frappe.utils import cstr, now def update_lead_phone_numbers(contact, method): @@ -41,7 +43,7 @@ def copy_comments(doctype, docname, doc): comment.insert() -def add_link_in_communication(doctype, docname, doc): +def link_communications(doctype, docname, doc): communication_list = get_linked_communication_list(doctype, docname) for communication in communication_list: @@ -60,3 +62,138 @@ def get_linked_communication_list(doctype, docname): ) return communications + communication_links + + +def link_communications_with_prospect(communication, method): + prospect = get_linked_prospect(communication.reference_doctype, communication.reference_name) + + if prospect: + already_linked = any( + [ + d.name + for d in communication.get("timeline_links") + if d.link_doctype == "Prospect" and d.link_name == prospect + ] + ) + if not already_linked: + row = communication.append("timeline_links") + row.link_doctype = "Prospect" + row.link_name = prospect + row.db_update() + + +def get_linked_prospect(reference_doctype, reference_name): + prospect = None + if reference_doctype == "Lead": + prospect = frappe.db.get_value("Prospect Lead", {"lead": reference_name}, "parent") + + elif reference_doctype == "Opportunity": + opportunity_from, party_name = frappe.db.get_value( + "Opportunity", reference_name, ["opportunity_from", "party_name"] + ) + if opportunity_from == "Lead": + prospect = frappe.db.get_value( + "Prospect Opportunity", {"opportunity": reference_name}, "parent" + ) + if opportunity_from == "Prospect": + prospect = party_name + + return prospect + + +def link_events_with_prospect(event, method): + if event.event_participants: + ref_doctype = event.event_participants[0].reference_doctype + ref_docname = event.event_participants[0].reference_docname + prospect = get_linked_prospect(ref_doctype, ref_docname) + if prospect: + event.add_participant("Prospect", prospect) + event.save() + + +def link_open_tasks(ref_doctype, ref_docname, doc): + todos = get_open_todos(ref_doctype, ref_docname) + + for todo in todos: + todo_doc = frappe.get_doc("ToDo", todo.name) + todo_doc.reference_type = doc.doctype + todo_doc.reference_name = doc.name + todo_doc.db_update() + + +def link_open_events(ref_doctype, ref_docname, doc): + events = get_open_events(ref_doctype, ref_docname) + for event in events: + event_doc = frappe.get_doc("Event", event.name) + event_doc.add_participant(doc.doctype, doc.name) + event_doc.save() + + +@frappe.whitelist() +def get_open_activities(ref_doctype, ref_docname): + tasks = get_open_todos(ref_doctype, ref_docname) + events = get_open_events(ref_doctype, ref_docname) + + return {"tasks": tasks, "events": events} + + +def get_open_todos(ref_doctype, ref_docname): + return frappe.get_all( + "ToDo", + filters={"reference_type": ref_doctype, "reference_name": ref_docname, "status": "Open"}, + fields=[ + "name", + "description", + "allocated_to", + "date", + ], + ) + + +def get_open_events(ref_doctype, ref_docname): + event = frappe.qb.DocType("Event") + event_link = frappe.qb.DocType("Event Participants") + + query = ( + frappe.qb.from_(event) + .join(event_link) + .on(event_link.parent == event.name) + .select( + event.name, + event.subject, + event.event_category, + event.starts_on, + event.ends_on, + event.description, + ) + .where( + (event_link.reference_doctype == ref_doctype) + & (event_link.reference_docname == ref_docname) + & (event.status == "Open") + ) + ) + data = query.run(as_dict=True) + + return data + + +class CRMNote(Document): + @frappe.whitelist() + def add_note(self, note): + self.append("notes", {"note": note, "added_by": frappe.session.user, "added_on": now()}) + self.save() + + @frappe.whitelist() + def edit_note(self, note, row_id): + for d in self.notes: + if cstr(d.name) == row_id: + d.note = note + d.db_update() + + @frappe.whitelist() + def delete_note(self, row_id): + for d in self.notes: + if cstr(d.name) == row_id: + self.remove(d) + break + self.save() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7d7f65dfd7..b3c35cfe0b 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -299,7 +299,11 @@ doc_events = { "on_update": [ "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update", "erpnext.support.doctype.issue.issue.set_first_response_time", - ] + ], + "after_insert": "erpnext.crm.utils.link_communications_with_prospect", + }, + "Event": { + "after_insert": "erpnext.crm.utils.link_events_with_prospect", }, "Sales Taxes and Charges Template": { "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings" @@ -453,7 +457,6 @@ scheduler_events = { "erpnext.hr.utils.allocate_earned_leaves", "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", - "erpnext.crm.doctype.lead.lead.daily_open_lead", ], "weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"], "monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"], diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 318875d2a4..2addf91976 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -375,3 +375,4 @@ execute:frappe.delete_doc("DocType", "Naming Series") erpnext.patches.v13_0.set_payroll_entry_status erpnext.patches.v13_0.job_card_status_on_hold erpnext.patches.v14_0.migrate_gl_to_payment_ledger +erpnext.patches.v14_0.crm_ux_cleanup diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py new file mode 100644 index 0000000000..923daee604 --- /dev/null +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -0,0 +1,84 @@ +import frappe +from frappe.model.utils.rename_field import rename_field +from frappe.utils import add_months, cstr, today + + +def execute(): + for doctype in ("Lead", "Opportunity", "Prospect", "Prospect Lead"): + frappe.reload_doc("crm", "doctype", doctype) + + try: + rename_field("Lead", "designation", "job_title") + rename_field("Opportunity", "converted_by", "opportunity_owner") + rename_field("Prospect", "prospect_lead", "leads") + except Exception as e: + if e.args[0] != 1054: + raise + + add_calendar_event_for_leads() + add_calendar_event_for_opportunities() + + +def add_calendar_event_for_leads(): + # create events based on next contact date + leads = frappe.get_all( + "Lead", + {"contact_date": [">=", add_months(today(), -1)]}, + ["name", "contact_date", "contact_by", "ends_on", "lead_name", "lead_owner"], + ) + for d in leads: + event = frappe.get_doc( + { + "doctype": "Event", + "owner": d.lead_owner, + "subject": ("Contact " + cstr(d.lead_name)), + "description": ( + ("Contact " + cstr(d.lead_name)) + (("
By: " + cstr(d.contact_by)) if d.contact_by else "") + ), + "starts_on": d.contact_date, + "ends_on": d.ends_on, + "event_type": "Private", + } + ) + + event.append("event_participants", {"reference_doctype": "Lead", "reference_docname": d.name}) + + event.insert(ignore_permissions=True) + + +def add_calendar_event_for_opportunities(): + # create events based on next contact date + opportunities = frappe.get_all( + "Opportunity", + {"contact_date": [">=", add_months(today(), -1)]}, + [ + "name", + "contact_date", + "contact_by", + "to_discuss", + "party_name", + "opportunity_owner", + "contact_person", + ], + ) + for d in opportunities: + event = frappe.get_doc( + { + "doctype": "Event", + "owner": d.opportunity_owner, + "subject": ("Contact " + cstr(d.contact_person or d.party_name)), + "description": ( + ("Contact " + cstr(d.contact_person or d.party_name)) + + (("
By: " + cstr(d.contact_by)) if d.contact_by else "") + + (("
Agenda: " + cstr(d.to_discuss)) if d.to_discuss else "") + ), + "starts_on": d.contact_date, + "event_type": "Private", + } + ) + + event.append( + "event_participants", {"reference_doctype": "Opportunity", "reference_docname": d.name} + ) + + event.insert(ignore_permissions=True) diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 3dae6d407b..d545929ce5 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -22,5 +22,8 @@ import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; import "./bulk_transaction_processing"; +import "./utils/crm_activities"; +import "./templates/crm_activities.html"; +import "./templates/crm_notes.html"; // import { sum } from 'frappe/public/utils/util.js' diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html new file mode 100644 index 0000000000..e0c237cde9 --- /dev/null +++ b/erpnext/public/js/templates/crm_activities.html @@ -0,0 +1,172 @@ +
+
+ + + + +
+
+
+
+ {{ __("Open Tasks") }} +
+ {% if (tasks.length) { %} + {% for(var i=0, l=tasks.length; i +
+ +
+ +
+
+
{%= frappe.datetime.global_date_format(tasks[i].date) %}
+ {% if(tasks[i].allocated_to) { %} +
+ {{ __("Allocated To:") }} + {%= tasks[i].allocated_to %} +
+ {% } %} +
+ {% } %} + {% } else { %} +
+ {{ __("No open task") }} +
+ {% } %} +
+
+
+ {{ __("Open Events") }} +
+ {% if (events.length) { %} + {% let icon_set = {"Sent/Received Email": "mail", "Call": "call", "Meeting": "share-people"}; %} + {% for(var i=0, l=events.length; i +
+ +
+ +
+
+
+ {%= frappe.datetime.global_date_format(events[i].starts_on) %} + + {% if (events[i].ends_on) { %} + {% if (frappe.datetime.obj_to_user(events[i].starts_on) != frappe.datetime.obj_to_user(events[i].ends_on)) %} + - + {%= frappe.datetime.global_date_format(frappe.datetime.obj_to_user(events[i].ends_on)) %} + {%= frappe.datetime.get_time(events[i].ends_on) %} + {% } else if (events[i].ends_on) { %} + - + {%= frappe.datetime.get_time(events[i].ends_on) %} + {% } %} + {% } %} + +
+
+ {% } %} + {% } else { %} +
+ {{ __("No open event") }} +
+ {% } %} +
+ + + + + \ No newline at end of file diff --git a/erpnext/public/js/templates/crm_notes.html b/erpnext/public/js/templates/crm_notes.html new file mode 100644 index 0000000000..fddeb1c1cc --- /dev/null +++ b/erpnext/public/js/templates/crm_notes.html @@ -0,0 +1,74 @@ +
+
+ +
+
+ {% if (notes.length) { %} + {% for(var i=0, l=notes.length; i +
+
+
+ {{ frappe.avatar(notes[i].added_by) }} +
+
+
+ {{ strip_html(notes[i].added_by) }} +
+
+ {{ frappe.datetime.global_date_format(notes[i].added_on) }} +
+
+
+
+
+ {{ notes[i].note }} +
+
+ + + + + + +
+
+ {% } %} + {% } else { %} +
+ {{ __("No Notes") }} +
+ {% } %} +
+ + + \ No newline at end of file diff --git a/erpnext/public/js/utils/crm_activities.js b/erpnext/public/js/utils/crm_activities.js new file mode 100644 index 0000000000..bbd9ded8c9 --- /dev/null +++ b/erpnext/public/js/utils/crm_activities.js @@ -0,0 +1,234 @@ +erpnext.utils.CRMActivities = class CRMActivities { + constructor(opts) { + $.extend(this, opts); + } + + refresh() { + var me = this; + $(this.open_activities_wrapper).empty(); + let cur_form_footer = this.form_wrapper.find('.form-footer'); + + // all activities + if (!$(this.all_activities_wrapper).find('.form-footer').length) { + this.all_activities_wrapper.empty(); + $(cur_form_footer).appendTo(this.all_activities_wrapper); + + // remove frappe-control class to avoid absolute position for action-btn + $(this.all_activities_wrapper).removeClass('frappe-control'); + // hide new event button + $('.timeline-actions').find('.btn-default').hide(); + // hide new comment box + $(".comment-box").hide(); + // show only communications by default + $($('.timeline-content').find('.nav-link')[0]).tab('show'); + } + + // open activities + frappe.call({ + method: "erpnext.crm.utils.get_open_activities", + args: { + ref_doctype: this.frm.doc.doctype, + ref_docname: this.frm.doc.name + }, + callback: (r) => { + if (!r.exc) { + var activities_html = frappe.render_template('crm_activities', { + tasks: r.message.tasks, + events: r.message.events + }); + + $(activities_html).appendTo(me.open_activities_wrapper); + + $(".open-tasks").find(".completion-checkbox").on("click", function() { + me.update_status(this, "ToDo"); + }); + + $(".open-events").find(".completion-checkbox").on("click", function() { + me.update_status(this, "Event"); + }); + + me.create_task(); + me.create_event(); + } + } + }); + } + + create_task () { + let me = this; + let _create_task = () => { + const args = { + doc: me.frm.doc, + frm: me.frm, + title: __("New Task") + }; + let composer = new frappe.views.InteractionComposer(args); + composer.dialog.get_field('interaction_type').set_value("ToDo"); + // hide column having interaction type field + $(composer.dialog.get_field('interaction_type').wrapper).closest('.form-column').hide(); + // hide summary field + $(composer.dialog.get_field('summary').wrapper).closest('.form-section').hide(); + }; + $(".new-task-btn").click(_create_task); + } + + create_event () { + let me = this; + let _create_event = () => { + const args = { + doc: me.frm.doc, + frm: me.frm, + title: __("New Event") + }; + let composer = new frappe.views.InteractionComposer(args); + composer.dialog.get_field('interaction_type').set_value("Event"); + $(composer.dialog.get_field('interaction_type').wrapper).hide(); + }; + $(".new-event-btn").click(_create_event); + } + + async update_status (input_field, doctype) { + let completed = $(input_field).prop("checked") ? 1 : 0; + let docname = $(input_field).attr("name"); + if (completed) { + await frappe.db.set_value(doctype, docname, "status", "Closed"); + this.refresh(); + } + } +}; + +erpnext.utils.CRMNotes = class CRMNotes { + constructor(opts) { + $.extend(this, opts); + } + + refresh() { + var me = this; + this.notes_wrapper.find('.notes-section').remove(); + + let notes = this.frm.doc.notes || []; + notes.sort( + function(a, b) { + return new Date(b.added_on) - new Date(a.added_on); + } + ); + + let notes_html = frappe.render_template( + 'crm_notes', + { + notes: notes + } + ); + $(notes_html).appendTo(this.notes_wrapper); + + this.add_note(); + + $(".notes-section").find(".edit-note-btn").on("click", function() { + me.edit_note(this); + }); + + $(".notes-section").find(".delete-note-btn").on("click", function() { + me.delete_note(this); + }); + } + + + add_note () { + let me = this; + let _add_note = () => { + var d = new frappe.ui.Dialog({ + title: __('Add a Note'), + fields: [ + { + "label": "Note", + "fieldname": "note", + "fieldtype": "Text Editor", + "reqd": 1 + } + ], + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: "add_note", + doc: me.frm.doc, + args: { + note: data.note + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + } + d.hide(); + } + }); + }, + primary_action_label: __('Add') + }); + d.show(); + }; + $(".new-note-btn").click(_add_note); + } + + edit_note (edit_btn) { + var me = this; + let row = $(edit_btn).closest('.comment-content'); + let row_id = row.attr("name"); + let row_content = $(row).find(".content").html(); + if (row_content) { + var d = new frappe.ui.Dialog({ + title: __('Edit Note'), + fields: [ + { + "label": "Note", + "fieldname": "note", + "fieldtype": "Text Editor", + "default": row_content + } + ], + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: "edit_note", + doc: me.frm.doc, + args: { + note: data.note, + row_id: row_id + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + d.hide(); + } + + } + }); + }, + primary_action_label: __('Done') + }); + d.show(); + } + } + + delete_note (delete_btn) { + var me = this; + let row_id = $(delete_btn).closest('.comment-content').attr("name"); + frappe.call({ + method: "delete_note", + doc: me.frm.doc, + args: { + row_id: row_id + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + } + } + }); + } +}; diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 4fa4515a0f..a513b8fc88 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -8,7 +8,6 @@ from frappe.model.mapper import get_mapped_doc from frappe.utils import flt, getdate, nowdate from erpnext.controllers.selling_controller import SellingController -from erpnext.crm.utils import add_link_in_communication, copy_comments form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -36,16 +35,6 @@ class Quotation(SellingController): make_packing_list(self) - def after_insert(self): - if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): - if self.opportunity: - copy_comments("Opportunity", self.opportunity, self) - add_link_in_communication("Opportunity", self.opportunity, self) - - elif self.quotation_to == "Lead" and self.party_name: - copy_comments("Lead", self.party_name, self) - add_link_in_communication("Lead", self.party_name, self) - def validate_valid_till(self): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): frappe.throw(_("Valid till date cannot be before transaction date")) diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py index 4295188dc0..48b44802a8 100644 --- a/erpnext/templates/utils.py +++ b/erpnext/templates/utils.py @@ -34,7 +34,6 @@ def send_message(subject="Website Query", message="", sender="", status="Open"): status="Open", title=subject, contact_email=sender, - to_discuss=message, ) ) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 73cbcd4094..cd1bf9f321 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -5,7 +5,7 @@ import frappe import frappe.share from frappe import _ -from frappe.utils import cint, cstr, flt, get_time, now_datetime +from frappe.utils import cint, flt, get_time, now_datetime from erpnext.controllers.status_updater import StatusUpdater @@ -30,64 +30,6 @@ class TransactionBase(StatusUpdater): except ValueError: frappe.throw(_("Invalid Posting Time")) - def add_calendar_event(self, opts, force=False): - if ( - cstr(self.contact_by) != cstr(self._prev.contact_by) - or cstr(self.contact_date) != cstr(self._prev.contact_date) - or force - or (hasattr(self, "ends_on") and cstr(self.ends_on) != cstr(self._prev.ends_on)) - ): - - self.delete_events() - self._add_calendar_event(opts) - - def delete_events(self): - participations = frappe.get_all( - "Event Participants", - filters={ - "reference_doctype": self.doctype, - "reference_docname": self.name, - "parenttype": "Event", - }, - fields=["name", "parent"], - ) - - if participations: - for participation in participations: - total_participants = frappe.get_all( - "Event Participants", filters={"parenttype": "Event", "parent": participation.parent} - ) - - if len(total_participants) <= 1: - frappe.db.sql("delete from `tabEvent` where name='%s'" % participation.parent) - - frappe.db.sql("delete from `tabEvent Participants` where name='%s'" % participation.name) - - def _add_calendar_event(self, opts): - opts = frappe._dict(opts) - - if self.contact_date: - event = frappe.get_doc( - { - "doctype": "Event", - "owner": opts.owner or self.owner, - "subject": opts.subject, - "description": opts.description, - "starts_on": self.contact_date, - "ends_on": opts.ends_on, - "event_type": "Private", - } - ) - - event.append( - "event_participants", {"reference_doctype": self.doctype, "reference_docname": self.name} - ) - - event.insert(ignore_permissions=True) - - if frappe.db.exists("User", self.contact_by): - frappe.share.add("Event", event.name, self.contact_by, flags={"ignore_share_permission": True}) - def validate_uom_is_integer(self, uom_field, qty_fields): validate_uom_is_integer(self, uom_field, qty_fields) From d8163f3e47707336a79fee81f6aa788c62c146f2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 10 Jun 2022 15:31:31 +0530 Subject: [PATCH 27/39] fix: set exchange rate --- erpnext/crm/doctype/lead/lead.js | 2 +- erpnext/crm/doctype/opportunity/opportunity.py | 14 ++++++++++++-- erpnext/patches/v14_0/crm_ux_cleanup.py | 9 ++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js index 4814311558..37fb3509ce 100644 --- a/erpnext/crm/doctype/lead/lead.js +++ b/erpnext/crm/doctype/lead/lead.js @@ -232,4 +232,4 @@ frappe.ui.form.on("Lead", { }); } } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 0dc0cd3762..08eb472bb9 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -47,6 +47,7 @@ class Opportunity(TransactionBase, CRMNote): self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() self.map_fields() + self.set_exchange_rate() if not self.title: self.title = self.customer_name @@ -63,12 +64,21 @@ class Opportunity(TransactionBase, CRMNote): except Exception: continue + def set_exchange_rate(self): + company_currency = frappe.get_cached_value("Company", self.company, "default_currency") + if self.currency == company_currency: + self.conversion_rate = 1.0 + return + + if not self.conversion_rate or self.conversion_rate == 1.0: + self.conversion_rate = get_exchange_rate(self.currency, company_currency, self.transaction_date) + def calculate_totals(self): total = base_total = 0 for item in self.get("items"): item.amount = flt(item.rate) * flt(item.qty) - item.base_rate = flt(self.conversion_rate * item.rate) - item.base_amount = flt(self.conversion_rate * item.amount) + item.base_rate = flt(self.conversion_rate) * flt(item.rate) + item.base_amount = flt(self.conversion_rate) * flt(item.amount) total += item.amount base_total += item.base_amount diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py index 923daee604..c1bc6b265d 100644 --- a/erpnext/patches/v14_0/crm_ux_cleanup.py +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -10,7 +10,14 @@ def execute(): try: rename_field("Lead", "designation", "job_title") rename_field("Opportunity", "converted_by", "opportunity_owner") - rename_field("Prospect", "prospect_lead", "leads") + + frappe.db.sql( + """ + update `tabProspect Lead` + set parentfield='leads' + where parentfield='partner_lead' + """ + ) except Exception as e: if e.args[0] != 1054: raise From 82bf59e2a3a5bd30e35deb70bde0143152f299dd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 13:07:54 +0530 Subject: [PATCH 28/39] fix: test case --- .../crm/doctype/opportunity/opportunity.json | 8 ++++---- .../crm/doctype/opportunity/test_opportunity.py | 17 ----------------- .../crm/doctype/opportunity/test_records.json | 4 +++- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 7854007919..98c8d32307 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -16,23 +16,23 @@ "opportunity_from", "party_name", "customer_name", + "status", "column_break0", "opportunity_type", "source", "opportunity_owner", "column_break_10", - "status", "sales_stage", "expected_closing", "probability", "organization_details_section", "no_of_employees", "annual_revenue", - "column_break_23", "customer_group", + "column_break_23", "industry", - "column_break_31", "market_segment", + "column_break_31", "territory", "website", "section_break_14", @@ -607,7 +607,7 @@ "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-10 10:37:25.537444", + "modified": "2022-06-10 16:19:33.310006", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 4a18e940bc..3ca701670f 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -97,22 +97,6 @@ class TestOpportunity(unittest.TestCase): self.assertEqual(quotation_comment_count, 4) self.assertEqual(quotation_communication_count, 4) - def test_render_template_for_to_discuss(self): - doc = make_opportunity(with_items=0, opportunity_from="Lead") - doc.contact_by = "test@example.com" - doc.contact_date = add_days(today(), days=2) - doc.to_discuss = "{{ doc.name }} test data" - doc.save() - - event = frappe.get_all( - "Event Participants", - fields=["parent"], - filters={"reference_doctype": doc.doctype, "reference_docname": doc.name}, - ) - - event_description = frappe.db.get_value("Event", event[0].parent, "description") - self.assertTrue(doc.name in event_description) - def make_opportunity_from_lead(): new_lead_email_id = "new{}@example.com".format(random_string(5)) @@ -139,7 +123,6 @@ def make_opportunity(**args): "opportunity_from": args.opportunity_from or "Customer", "opportunity_type": "Sales", "conversion_rate": 1.0, - "with_items": args.with_items or 0, "transaction_date": today(), } ) diff --git a/erpnext/crm/doctype/opportunity/test_records.json b/erpnext/crm/doctype/opportunity/test_records.json index a1e0ad921b..f7e8350f30 100644 --- a/erpnext/crm/doctype/opportunity/test_records.json +++ b/erpnext/crm/doctype/opportunity/test_records.json @@ -8,7 +8,9 @@ "transaction_date": "2013-12-12", "items": [{ "item_name": "Test Item", - "description": "Some description" + "description": "Some description", + "qty": 5, + "rate": 100 }] } ] From 6a0d0a338db71dd2eb101d6349447d36663ebcdd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 15:29:39 +0530 Subject: [PATCH 29/39] fix: Test cases removed related to copying comments from opportunity to quotation --- .../doctype/opportunity/test_opportunity.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 3ca701670f..1ff3267e71 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -77,26 +77,6 @@ class TestOpportunity(unittest.TestCase): create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email) create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email) - quotation_doc = make_quotation(opp_doc.name) - quotation_doc.append("items", {"item_code": "_Test Item", "qty": 1}) - quotation_doc.run_method("set_missing_values") - quotation_doc.run_method("calculate_taxes_and_totals") - quotation_doc.save() - - quotation_comment_count = frappe.db.count( - "Comment", - { - "reference_doctype": quotation_doc.doctype, - "reference_name": quotation_doc.name, - "comment_type": "Comment", - }, - ) - quotation_communication_count = len( - get_linked_communication_list(quotation_doc.doctype, quotation_doc.name) - ) - self.assertEqual(quotation_comment_count, 4) - self.assertEqual(quotation_communication_count, 4) - def make_opportunity_from_lead(): new_lead_email_id = "new{}@example.com".format(random_string(5)) From 483fc420a1c247ee0d073a458044d87a240ada3c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 15:46:41 +0530 Subject: [PATCH 30/39] test: invoice from timesheet --- erpnext/projects/doctype/timesheet/test_timesheet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 57bfd5b607..7298c037a7 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -84,7 +84,9 @@ class TestTimesheet(unittest.TestCase): emp = make_employee("test_employee_6@salary.com") timesheet = make_timesheet(emp, simulate=True, is_billable=1) - sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer") + sales_invoice = make_sales_invoice( + timesheet.name, "_Test Item", "_Test Customer", currency="INR" + ) sales_invoice.due_date = nowdate() sales_invoice.submit() timesheet = frappe.get_doc("Timesheet", timesheet.name) From 2d226be3c4b5970c5b1db0f857dfe3d2b5072eb5 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 22 Jun 2022 12:51:56 +0530 Subject: [PATCH 31/39] fix: Made no of employees a select field --- erpnext/crm/doctype/lead/lead.json | 10 +++++----- erpnext/crm/doctype/opportunity/opportunity.json | 7 ++++--- erpnext/crm/doctype/prospect/prospect.json | 10 +++++----- erpnext/public/js/templates/crm_activities.html | 6 +++++- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index df6ff56986..9216c458c8 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -277,8 +277,7 @@ "fieldtype": "Data", "label": "Website", "oldfieldname": "website", - "oldfieldtype": "Data", - "options": "URL" + "oldfieldtype": "Data" }, { "fieldname": "territory", @@ -334,8 +333,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No. of Employees" + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "column_break_22", @@ -483,7 +483,7 @@ "idx": 5, "image_field": "image", "links": [], - "modified": "2022-06-06 18:10:14.494424", + "modified": "2022-06-21 15:10:06.613519", "modified_by": "Administrator", "module": "CRM", "name": "Lead", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 98c8d32307..8ddd4e36c2 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -463,8 +463,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No of Employees" + "fieldtype": "Select", + "label": "No of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "annual_revenue", @@ -607,7 +608,7 @@ "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-10 16:19:33.310006", + "modified": "2022-06-21 15:04:34.363959", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json index 2750e5b421..afc6c1dbec 100644 --- a/erpnext/crm/doctype/prospect/prospect.json +++ b/erpnext/crm/doctype/prospect/prospect.json @@ -80,8 +80,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No. of Employees" + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "annual_revenue", @@ -99,8 +100,7 @@ { "fieldname": "website", "fieldtype": "Data", - "label": "Website", - "options": "URL" + "label": "Website" }, { "fieldname": "prospect_owner", @@ -218,7 +218,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-09 16:58:45.100244", + "modified": "2022-06-21 15:10:26.887502", "modified_by": "Administrator", "module": "CRM", "name": "Prospect", diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html index e0c237cde9..4260319608 100644 --- a/erpnext/public/js/templates/crm_activities.html +++ b/erpnext/public/js/templates/crm_activities.html @@ -39,7 +39,11 @@ name="{{tasks[i].name}}" title="{{ __('Mark As Closed') }}"> -
{%= frappe.datetime.global_date_format(tasks[i].date) %}
+ {% if(tasks[i].date) { %} +
+ {%= frappe.datetime.global_date_format(tasks[i].date) %} +
+ {% } %} {% if(tasks[i].allocated_to) { %}
{{ __("Allocated To:") }} From f72d5506de0b5ffc55922db0c95b52ee28cdc87e Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 25 Jun 2022 15:08:13 +0530 Subject: [PATCH 32/39] fix: get_all replace by sql --- erpnext/patches/v14_0/crm_ux_cleanup.py | 37 +++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py index c1bc6b265d..b2df36ff35 100644 --- a/erpnext/patches/v14_0/crm_ux_cleanup.py +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -4,7 +4,7 @@ from frappe.utils import add_months, cstr, today def execute(): - for doctype in ("Lead", "Opportunity", "Prospect", "Prospect Lead"): + for doctype in ("CRM Note", "Lead", "Opportunity", "Prospect", "Prospect Lead"): frappe.reload_doc("crm", "doctype", doctype) try: @@ -28,11 +28,16 @@ def execute(): def add_calendar_event_for_leads(): # create events based on next contact date - leads = frappe.get_all( - "Lead", - {"contact_date": [">=", add_months(today(), -1)]}, - ["name", "contact_date", "contact_by", "ends_on", "lead_name", "lead_owner"], + leads = frappe.db.sql( + """ + select name, contact_date, contact_by, ends_on, lead_name, lead_owner + from tabLead + where contact_date >= %s + """, + add_months(today(), -1), + as_dict=1, ) + for d in leads: event = frappe.get_doc( { @@ -55,19 +60,17 @@ def add_calendar_event_for_leads(): def add_calendar_event_for_opportunities(): # create events based on next contact date - opportunities = frappe.get_all( - "Opportunity", - {"contact_date": [">=", add_months(today(), -1)]}, - [ - "name", - "contact_date", - "contact_by", - "to_discuss", - "party_name", - "opportunity_owner", - "contact_person", - ], + opportunities = frappe.db.sql( + """ + select name, contact_date, contact_by, to_discuss, + party_name, opportunity_owner, contact_person + from tabOpportunity + where contact_date >= %s + """, + add_months(today(), -1), + as_dict=1, ) + for d in opportunities: event = frappe.get_doc( { From 20dac08f5f729081e8fae2e60b3b3b95bbfda4d9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 27 Jun 2022 15:54:54 +0530 Subject: [PATCH 33/39] refactor: clean up product bundle client side code (#31455) refactor: clean up product bundle cient side code - Remove deprecated CUR_FRM scripts - Remove client side fetches and move it to doctype schema --- .../doctype/product_bundle/product_bundle.js | 28 ++++++++----------- .../product_bundle_item.json | 7 ++++- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 7a04c6ab06..3096b692a7 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -1,19 +1,13 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.cscript.refresh = function(doc, cdt, cdn) { - cur_frm.toggle_enable('new_item_code', doc.__islocal); -} - -cur_frm.fields_dict.new_item_code.get_query = function() { - return{ - query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code" - } -} -cur_frm.fields_dict.new_item_code.query_description = __('Please select Item where "Is Stock Item" is "No" and "Is Sales Item" is "Yes" and there is no other Product Bundle'); - -cur_frm.cscript.onload = function() { - // set add fetch for item_code's item_name and description - cur_frm.add_fetch('item_code', 'stock_uom', 'uom'); - cur_frm.add_fetch('item_code', 'description', 'description'); -} +frappe.ui.form.on("Product Bundle", { + refresh: function (frm) { + frm.toggle_enable("new_item_code", frm.is_new()); + frm.set_query("new_item_code", () => { + return { + query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code", + }; + }); + }, +}); diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json index dc071e4d65..fc8caeb31d 100644 --- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json +++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json @@ -33,6 +33,8 @@ "reqd": 1 }, { + "fetch_from": "item_code.description", + "fetch_if_empty": 1, "fieldname": "description", "fieldtype": "Text Editor", "in_list_view": 1, @@ -51,6 +53,8 @@ "print_hide": 1 }, { + "fetch_from": "item_code.stock_uom", + "fetch_if_empty": 1, "fieldname": "uom", "fieldtype": "Link", "in_list_view": 1, @@ -64,7 +68,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-02-28 14:06:05.725655", + "modified": "2022-06-27 05:30:18.475150", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle Item", @@ -72,5 +76,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From dd11f26eba45937b809047404ad453b8df2670ac Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 27 Jun 2022 15:55:08 +0530 Subject: [PATCH 34/39] fix: dont update RM items table if not required (#31408) Currently on PO update RM item table is auto computed again and again, if there was any transfer/consumption against that then it will be lost. This change: 1. Disables updating RM table if no change in qty of FG was made. Since RM table can't possibly be different with same FG qty. 2. Blocks update completely if qty is changed and RM items are already transferred. --- .../purchase_order/test_purchase_order.py | 37 +++++++++++++++++ erpnext/controllers/accounts_controller.py | 40 +++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index d732b755fe..5f84de60d0 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -140,6 +140,43 @@ class TestPurchaseOrder(FrappeTestCase): # ordered qty decreases as ordered qty is 0 (deleted row) self.assertEqual(get_ordered_qty(), existing_ordered_qty - 10) # 0 + def test_supplied_items_validations_on_po_update_after_submit(self): + po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1, qty=5, rate=100) + item = po.items[0] + + original_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + + # Just update rate + trans_item = [ + { + "item_code": "_Test FG Item", + "rate": 20, + "qty": 5, + "conversion_factor": 1.0, + "docname": item.name, + } + ] + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + po.reload() + + new_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + self.assertEqual(set(original_supplied_items.keys()), set(new_supplied_items.keys())) + + # Update qty to 2x + trans_item[0]["qty"] *= 2 + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + po.reload() + + new_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + self.assertEqual(2 * sum(original_supplied_items.values()), sum(new_supplied_items.values())) + + # Set transfer qty and attempt to update qty, shouldn't be allowed + po.supplied_items[0].supplied_qty = 2 + po.supplied_items[0].db_update() + trans_item[0]["qty"] *= 2 + with self.assertRaises(frappe.ValidationError): + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + def test_update_child(self): mr = make_material_request(qty=10) po = make_purchase_order(mr.name) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index fc6fdcdeff..ceac815bf4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2440,7 +2440,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data): +def validate_and_delete_children(parent, data) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -2459,6 +2459,8 @@ def validate_and_delete_children(parent, data): for d in deleted_children: update_bin_on_delete(d, parent.doctype) + return bool(deleted_children) + @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): @@ -2522,13 +2524,38 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ): frappe.throw(_("Cannot set quantity less than received quantity")) + def should_update_supplied_items(doc) -> bool: + """Subcontracted PO can allow following changes *after submit*: + + 1. Change rate of subcontracting - regardless of other changes. + 2. Change qty and/or add new items and/or remove items + Exception: Transfer/Consumption is already made, qty change not allowed. + """ + + supplied_items_processed = any( + item.supplied_qty or item.consumed_qty or item.returned_qty for item in doc.supplied_items + ) + + update_supplied_items = ( + any_qty_changed or items_added_or_removed or any_conversion_factor_changed + ) + if update_supplied_items and supplied_items_processed: + frappe.throw(_("Item qty can not be updated as raw materials are already processed.")) + + return update_supplied_items + data = json.loads(trans_items) + any_qty_changed = False # updated to true if any item's qty changes + items_added_or_removed = False # updated to true if any new item is added or removed + any_conversion_factor_changed = False + sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"] parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - validate_and_delete_children(parent, data) + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: new_child_flag = False @@ -2539,6 +2566,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if not d.get("docname"): new_child_flag = True + items_added_or_removed = True check_doc_permissions(parent, "create") child_item = get_new_child_item(d) else: @@ -2561,6 +2589,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil qty_unchanged = prev_qty == new_qty uom_unchanged = prev_uom == new_uom conversion_factor_unchanged = prev_con_fac == new_con_fac + any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( prev_date == getdate(new_date) if prev_date and new_date else False ) # in case of delivery note etc @@ -2574,6 +2603,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) + if flt(child_item.get("qty")) != flt(d.get("qty")): + any_qty_changed = True child_item.qty = flt(d.get("qty")) rate_precision = child_item.precision("rate") or 2 @@ -2679,8 +2710,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.update_ordered_and_reserved_qty() parent.update_receiving_percentage() if parent.is_subcontracted: - parent.update_reserved_qty_for_subcontract() - parent.create_raw_materials_supplied("supplied_items") + if should_update_supplied_items(parent): + parent.update_reserved_qty_for_subcontract() + parent.create_raw_materials_supplied("supplied_items") parent.save() else: # Sales Order parent.validate_warehouse() From 7921a1a60594deebc15208de1cad01ff5409ff94 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 27 Jun 2022 21:57:03 +0530 Subject: [PATCH 35/39] fix: Restored city, state and country fields --- erpnext/crm/doctype/lead/lead.json | 31 ++++++++++++++++--- .../crm/doctype/opportunity/opportunity.json | 30 ++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 9216c458c8..d47373fa61 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -46,6 +46,10 @@ "fax", "address_section", "address_html", + "column_break_38", + "city", + "state", + "country", "column_break2", "contact_html", "qualification_tab", @@ -333,9 +337,8 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Select", - "label": "No. of Employees", - "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" + "fieldtype": "Int", + "label": "No. of Employees" }, { "fieldname": "column_break_22", @@ -477,13 +480,33 @@ "fieldname": "disabled", "fieldtype": "Check", "label": "Disabled" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, + { + "fieldname": "city", + "fieldtype": "Data", + "label": "City" + }, + { + "fieldname": "state", + "fieldtype": "Data", + "label": "State" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" } ], "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2022-06-21 15:10:06.613519", + "modified": "2022-06-27 21:56:17.392756", "modified_by": "Administrator", "module": "CRM", "name": "Lead", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 8ddd4e36c2..1a6f23bc7b 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -32,9 +32,12 @@ "column_break_23", "industry", "market_segment", - "column_break_31", - "territory", "website", + "column_break_31", + "city", + "state", + "country", + "territory", "section_break_14", "currency", "column_break_36", @@ -463,9 +466,8 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Select", - "label": "No of Employees", - "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" + "fieldtype": "Int", + "label": "No of Employees" }, { "fieldname": "annual_revenue", @@ -603,12 +605,28 @@ "label": "Notes", "no_copy": 1, "options": "CRM Note" + }, + { + "fieldname": "city", + "fieldtype": "Data", + "label": "City" + }, + { + "fieldname": "state", + "fieldtype": "Data", + "label": "State" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-21 15:04:34.363959", + "modified": "2022-06-27 18:44:32.858696", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", From 925b9d985e8f614d5a4de5f20218a7fca59f51ac Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 27 Jun 2022 21:58:19 +0530 Subject: [PATCH 36/39] fix: open lead and opportunities based on today's event --- erpnext/crm/utils.py | 24 +++++++++++++++++++++++- erpnext/hooks.py | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 33441b166d..a2528c3703 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -1,6 +1,7 @@ import frappe from frappe.model.document import Document -from frappe.utils import cstr, now +from frappe.utils import cstr, now, today +from pypika import functions def update_lead_phone_numbers(contact, method): @@ -177,6 +178,27 @@ def get_open_events(ref_doctype, ref_docname): return data +def open_leads_opportunities_based_on_todays_event(): + event = frappe.qb.DocType("Event") + event_link = frappe.qb.DocType("Event Participants") + + query = ( + frappe.qb.from_(event) + .join(event_link) + .on(event_link.parent == event.name) + .select(event_link.reference_doctype, event_link.reference_docname) + .where( + (event_link.reference_doctype.isin(["Lead", "Opportunity"])) + & (event.status == "Open") + & (functions.Date(event.starts_on) == today()) + ) + ) + data = query.run(as_dict=True) + + for d in data: + frappe.db.set_value(d.reference_doctype, d.reference_docname, "status", "Open") + + class CRMNote(Document): @frappe.whitelist() def add_note(self, note): diff --git a/erpnext/hooks.py b/erpnext/hooks.py index b3c35cfe0b..8abf65f4b5 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -457,6 +457,7 @@ scheduler_events = { "erpnext.hr.utils.allocate_earned_leaves", "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", + "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", ], "weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"], "monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"], From bedb11ee670e3f19b3c6524ece78be5aba66d815 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 28 Jun 2022 10:50:44 +0530 Subject: [PATCH 37/39] fix: youtube stats background sync failures --- erpnext/utilities/doctype/video/video.py | 25 +++++++----------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index a39d0a95eb..15dbccde89 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -9,6 +9,7 @@ import frappe import pytz from frappe import _ from frappe.model.document import Document +from frappe.utils import cint from pyyoutube import Api @@ -46,7 +47,7 @@ def is_tracking_enabled(): def get_frequency(value): # Return numeric value from frequency field, return 1 as fallback default value: 1 hour if value != "Daily": - return frappe.utils.cint(value[:2].strip()) + return cint(value[:2].strip()) elif value: return 24 return 1 @@ -120,24 +121,12 @@ def batch_update_youtube_data(): video_stats = entry.to_dict().get("statistics") video_id = entry.to_dict().get("id") stats = { - "like_count": video_stats.get("likeCount"), - "view_count": video_stats.get("viewCount"), - "dislike_count": video_stats.get("dislikeCount"), - "comment_count": video_stats.get("commentCount"), - "video_id": video_id, + "like_count": cint(video_stats.get("likeCount")), + "view_count": cint(video_stats.get("viewCount")), + "dislike_count": cint(video_stats.get("dislikeCount")), + "comment_count": cint(video_stats.get("commentCount")), } - - frappe.db.sql( - """ - UPDATE `tabVideo` - SET - like_count = %(like_count)s, - view_count = %(view_count)s, - dislike_count = %(dislike_count)s, - comment_count = %(comment_count)s - WHERE youtube_video_id = %(video_id)s""", - stats, - ) + frappe.db.set_value("Video", video_id, stats) video_list = frappe.get_all("Video", fields=["youtube_video_id"]) if len(video_list) > 50: From 5d73697c647d5aeadd1b0738c1be8409a3ef7337 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 28 Jun 2022 12:22:17 +0530 Subject: [PATCH 38/39] fix: offset some scheduled jobs to avoid locks (#31466) If your site has multiple background workers then there's possibility that two jobs will execute in parallal, this creates problem when both are on operating on same data. This PR adds a separate section for hourly and daily jobs which have frequency offset from default frequency to avoid such conflicts. --- erpnext/hooks.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 8abf65f4b5..816cb62644 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -402,6 +402,14 @@ scheduler_events = { "0/30 * * * *": [ "erpnext.utilities.doctype.video.video.update_youtube_data", ], + # Hourly but offset by 30 minutes + "30 * * * *": [ + "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", + ], + # Daily but offset by 45 minutes + "45 0 * * *": [ + "erpnext.stock.reorder_item.reorder_item", + ], }, "all": [ "erpnext.projects.doctype.project.project.project_status_update_reminder", @@ -411,7 +419,6 @@ scheduler_events = { "hourly": [ "erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails", "erpnext.accounts.doctype.subscription.subscription.process_all", - "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.hourly_reminder", "erpnext.projects.doctype.project.project.collect_project_status", @@ -422,7 +429,6 @@ scheduler_events = { "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", ], "daily": [ - "erpnext.stock.reorder_item.reorder_item", "erpnext.support.doctype.issue.issue.auto_close_tickets", "erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity", "erpnext.controllers.accounts_controller.update_invoice_status", From 080fcb91f25af9f4174183b4fe662049ff710e0c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 28 Jun 2022 13:46:12 +0530 Subject: [PATCH 39/39] ci: pin semgrep to old version current version has problem with PRs originating from fork --- .github/workflows/linters.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index ebb88c9eda..af6d8f26a7 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -11,10 +11,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: '3.10' - name: Install and Run Pre-commit uses: pre-commit/action@v2.0.3 @@ -22,10 +22,8 @@ jobs: - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules - - uses: returntocorp/semgrep-action@v1 - env: - SEMGREP_TIMEOUT: 120 - with: - config: >- - r/python.lang.correctness - ./frappe-semgrep-rules/rules + - name: Download semgrep + run: pip install semgrep==0.97.0 + + - name: Run Semgrep rules + run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness