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/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 1b20c29f94..448ec54b1e 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() 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: 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 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) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 257488dfc3..a880c2f391 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) @@ -849,14 +853,9 @@ 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)) @@ -1105,9 +1104,18 @@ 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): if row.depreciation_method in ("Straight Line", "Manual"): diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index e759ad0719..f8a8fc551d 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 @@ -838,7 +871,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): @@ -1333,6 +1366,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"): 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..37fb3509ce 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..9216c458c8 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" @@ -347,8 +277,7 @@ "fieldtype": "Data", "label": "Website", "oldfieldname": "website", - "oldfieldtype": "Data", - "options": "URL" + "oldfieldtype": "Data" }, { "fieldname": "territory", @@ -380,14 +309,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,16 +331,11 @@ "fieldtype": "Data", "label": "Last Name" }, - { - "collapsible": 1, - "fieldname": "additional_information_section", - "fieldtype": "Section Break", - "label": "Additional Information" - }, { "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", @@ -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-21 15:10:06.613519", "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..8ddd4e36c2 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", "status", - "converted_by", + "column_break0", + "opportunity_type", + "source", + "opportunity_owner", + "column_break_10", "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", + "customer_group", + "column_break_23", + "industry", + "market_segment", + "column_break_31", + "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,164 @@ "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": "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", + "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-21 15:04:34.363959", "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..08eb472bb9 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -6,53 +6,54 @@ 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") self.validate_cust_name() self.map_fields() + self.set_exchange_rate() 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(): @@ -63,18 +64,65 @@ class Opportunity(TransactionBase): 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 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 +192,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 +210,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 +240,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 +317,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 +462,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/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 4a18e940bc..1ff3267e71 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -77,42 +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 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 +103,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 }] } ] 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..afc6c1dbec 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": [ @@ -71,15 +80,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No. of Employees" - }, - { - "fieldname": "currency", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Currency", - "options": "Currency" + "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", @@ -97,8 +100,7 @@ { "fieldname": "website", "fieldtype": "Data", - "label": "Website", - "options": "URL" + "label": "Website" }, { "fieldname": "prospect_owner", @@ -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-21 15:10:26.887502", "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/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", }, ) 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..b2df36ff35 --- /dev/null +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -0,0 +1,94 @@ +import frappe +from frappe.model.utils.rename_field import rename_field +from frappe.utils import add_months, cstr, today + + +def execute(): + for doctype in ("CRM Note", "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") + + frappe.db.sql( + """ + update `tabProspect Lead` + set parentfield='leads' + where parentfield='partner_lead' + """ + ) + 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.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( + { + "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.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( + { + "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/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) 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..4260319608 --- /dev/null +++ b/erpnext/public/js/templates/crm_activities.html @@ -0,0 +1,176 @@ +
+
+ + + + +
+
+
+
+ {{ __("Open Tasks") }} +
+ {% if (tasks.length) { %} + {% for(var i=0, l=tasks.length; i +
+ +
+ +
+
+ {% if(tasks[i].date) { %} +
+ {%= 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 d775fa93be..863fbc4059 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)