diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index e904768b38..6b4b43d30b 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -68,10 +68,12 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= party_details["tax_category"] = get_address_tax_category(party.get("tax_category"), party_address, shipping_address if party_type != "Supplier" else party_address) - if not party_details.get("taxes_and_charges"): - party_details["taxes_and_charges"] = set_taxes(party.name, party_type, posting_date, company, - customer_group=party_details.customer_group, supplier_group=party_details.supplier_group, tax_category=party_details.tax_category, - billing_address=party_address, shipping_address=shipping_address) + tax_template = set_taxes(party.name, party_type, posting_date, company, + customer_group=party_details.customer_group, supplier_group=party_details.supplier_group, tax_category=party_details.tax_category, + billing_address=party_address, shipping_address=shipping_address) + + if tax_template: + party_details['taxes_and_charges'] = tax_template if cint(fetch_payment_terms_template): party_details["payment_terms_template"] = get_payment_terms_template(party.name, party_type, company) diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index ca10b1db19..874fb630f8 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -57,8 +57,10 @@ def make_depreciation_entry(asset_name, date=None): je.finance_book = d.finance_book je.remark = "Depreciation Entry against {0} worth {1}".format(asset_name, d.depreciation_amount) + credit_account, debit_account = get_credit_and_debit_accounts(accumulated_depreciation_account, depreciation_expense_account) + credit_entry = { - "account": accumulated_depreciation_account, + "account": credit_account, "credit_in_account_currency": d.depreciation_amount, "reference_type": "Asset", "reference_name": asset.name, @@ -66,7 +68,7 @@ def make_depreciation_entry(asset_name, date=None): } debit_entry = { - "account": depreciation_expense_account, + "account": debit_account, "debit_in_account_currency": d.depreciation_amount, "reference_type": "Asset", "reference_name": asset.name, @@ -132,6 +134,20 @@ def get_depreciation_accounts(asset): return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account +def get_credit_and_debit_accounts(accumulated_depreciation_account, depreciation_expense_account): + root_type = frappe.get_value("Account", depreciation_expense_account, "root_type") + + if root_type == "Expense": + credit_account = accumulated_depreciation_account + debit_account = depreciation_expense_account + elif root_type == "Income": + credit_account = depreciation_expense_account + debit_account = accumulated_depreciation_account + else: + frappe.throw(_("Depreciation Expense Account should be an Income or Expense Account.")) + + return credit_account, debit_account + @frappe.whitelist() def scrap_asset(asset_name): asset = frappe.get_doc("Asset", asset_name) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 0e8ceb54a7..ce2cb01ab2 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -868,6 +868,72 @@ class TestDepreciationBasics(AssetSetup): self.assertFalse(asset.schedules[1].journal_entry) self.assertFalse(asset.schedules[2].journal_entry) + def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self): + """Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account.""" + + asset = create_asset( + item_code = "Macbook Pro", + calculate_depreciation = 1, + available_for_use_date = "2019-12-31", + depreciation_start_date = "2020-12-31", + frequency_of_depreciation = 12, + total_number_of_depreciations = 3, + expected_value_after_useful_life = 10000, + submit = 1 + ) + + post_depreciation_entries(date="2021-06-01") + asset.load_from_db() + + je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry) + accounting_entries = [{"account": entry.account, "debit": entry.debit, "credit": entry.credit} for entry in je.accounts] + + for entry in accounting_entries: + if entry["account"] == "_Test Depreciations - _TC": + self.assertTrue(entry["debit"]) + self.assertFalse(entry["credit"]) + else: + self.assertTrue(entry["credit"]) + self.assertFalse(entry["debit"]) + + def test_depr_entry_posting_when_depr_expense_account_is_an_income_account(self): + """Tests if the Depreciation Expense Account gets credited and the Accumulated Depreciation Account gets debited when the former's an Income Account.""" + + depr_expense_account = frappe.get_doc("Account", "_Test Depreciations - _TC") + depr_expense_account.root_type = "Income" + depr_expense_account.parent_account = "Income - _TC" + depr_expense_account.save() + + asset = create_asset( + item_code = "Macbook Pro", + calculate_depreciation = 1, + available_for_use_date = "2019-12-31", + depreciation_start_date = "2020-12-31", + frequency_of_depreciation = 12, + total_number_of_depreciations = 3, + expected_value_after_useful_life = 10000, + submit = 1 + ) + + post_depreciation_entries(date="2021-06-01") + asset.load_from_db() + + je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry) + accounting_entries = [{"account": entry.account, "debit": entry.debit, "credit": entry.credit} for entry in je.accounts] + + for entry in accounting_entries: + if entry["account"] == "_Test Depreciations - _TC": + self.assertTrue(entry["credit"]) + self.assertFalse(entry["debit"]) + else: + self.assertTrue(entry["debit"]) + self.assertFalse(entry["credit"]) + + # resetting + depr_expense_account.root_type = "Expense" + depr_expense_account.parent_account = "Expenses - _TC" + depr_expense_account.save() + def test_clear_depreciation_schedule(self): """Tests if clear_depreciation_schedule() works as expected.""" diff --git a/erpnext/assets/doctype/asset_category/asset_category.js b/erpnext/assets/doctype/asset_category/asset_category.js index 51ce157a81..c702687072 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.js +++ b/erpnext/assets/doctype/asset_category/asset_category.js @@ -33,7 +33,7 @@ frappe.ui.form.on('Asset Category', { var d = locals[cdt][cdn]; return { "filters": { - "root_type": "Expense", + "root_type": ["in", ["Expense", "Income"]], "is_group": 0, "company": d.company_name } diff --git a/erpnext/assets/doctype/asset_category/asset_category.py b/erpnext/assets/doctype/asset_category/asset_category.py index e2f3ca318f..bd573bf479 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.py +++ b/erpnext/assets/doctype/asset_category/asset_category.py @@ -42,10 +42,10 @@ class AssetCategory(Document): def validate_account_types(self): account_type_map = { - 'fixed_asset_account': { 'account_type': 'Fixed Asset' }, - 'accumulated_depreciation_account': { 'account_type': 'Accumulated Depreciation' }, - 'depreciation_expense_account': { 'root_type': 'Expense' }, - 'capital_work_in_progress_account': { 'account_type': 'Capital Work in Progress' } + 'fixed_asset_account': {'account_type': ['Fixed Asset']}, + 'accumulated_depreciation_account': {'account_type': ['Accumulated Depreciation']}, + 'depreciation_expense_account': {'root_type': ['Expense', 'Income']}, + 'capital_work_in_progress_account': {'account_type': ['Capital Work in Progress']} } for d in self.accounts: for fieldname in account_type_map.keys(): @@ -53,11 +53,11 @@ class AssetCategory(Document): selected_account = d.get(fieldname) key_to_match = next(iter(account_type_map.get(fieldname))) # acount_type or root_type selected_key_type = frappe.db.get_value('Account', selected_account, key_to_match) - expected_key_type = account_type_map[fieldname][key_to_match] + expected_key_types = account_type_map[fieldname][key_to_match] - if selected_key_type != expected_key_type: + if selected_key_type not in expected_key_types: frappe.throw(_("Row #{}: {} of {} should be {}. Please modify the account or select a different account.") - .format(d.idx, frappe.unscrub(key_to_match), frappe.bold(selected_account), frappe.bold(expected_key_type)), + .format(d.idx, frappe.unscrub(key_to_match), frappe.bold(selected_account), frappe.bold(expected_key_types)), title=_("Invalid Account")) def valide_cwip_account(self): diff --git a/erpnext/crm/doctype/crm_settings/__init__.py b/erpnext/crm/doctype/crm_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.js b/erpnext/crm/doctype/crm_settings/crm_settings.js new file mode 100644 index 0000000000..c6569d8122 --- /dev/null +++ b/erpnext/crm/doctype/crm_settings/crm_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('CRM Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json new file mode 100644 index 0000000000..95b19fa982 --- /dev/null +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -0,0 +1,114 @@ +{ + "actions": [], + "creation": "2021-09-09 17:03:22.754446", + "description": "Settings for Selling Module", + "doctype": "DocType", + "document_type": "Other", + "engine": "InnoDB", + "field_order": [ + "section_break_5", + "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" + ], + "fields": [ + { + "fieldname": "campaign_naming_by", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Campaign Naming By", + "options": "Campaign Name\nNaming Series" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "default_valid_till", + "fieldtype": "Data", + "label": "Default Quotation Validity Days" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "label": "Lead" + }, + { + "default": "0", + "fieldname": "allow_lead_duplication_based_on_emails", + "fieldtype": "Check", + "label": "Allow Lead Duplication based on Emails" + }, + { + "default": "1", + "fieldname": "auto_creation_of_contact", + "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", + "label": "Opportunity" + }, + { + "default": "15", + "description": "Auto close Opportunity Replied after the no. of days mentioned above", + "fieldname": "close_opportunity_after_days", + "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" + }, + { + "fieldname": "quotation_section", + "fieldtype": "Section Break", + "label": "Quotation" + } + ], + "icon": "fa fa-cog", + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "migration_hash": "3ae78b12dd1c64d551736c6e82092f90", + "modified": "2021-11-03 09:00:36.883496", + "modified_by": "Administrator", + "module": "CRM", + "name": "CRM Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py new file mode 100644 index 0000000000..bde52547c9 --- /dev/null +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMSettings(Document): + pass diff --git a/erpnext/crm/doctype/crm_settings/test_crm_settings.py b/erpnext/crm/doctype/crm_settings/test_crm_settings.py new file mode 100644 index 0000000000..3372c5deb4 --- /dev/null +++ b/erpnext/crm/doctype/crm_settings/test_crm_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import unittest + + +class TestCRMSettings(unittest.TestCase): + pass diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index c590523a4f..9adbe8b6f1 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -11,6 +11,7 @@ from frappe.utils import ( cint, comma_and, cstr, + get_link_to_form, getdate, has_gravatar, nowdate, @@ -91,13 +92,14 @@ class Lead(SellingController): self.contact_doc.save() def add_calendar_event(self, opts=None, force=False): - 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) + 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']) @@ -108,12 +110,13 @@ class Lead(SellingController): def check_email_id_is_unique(self): if self.email_id: # validate email is unique - duplicate_leads = frappe.get_all("Lead", filters={"email_id": self.email_id, "name": ["!=", self.name]}) - duplicate_leads = [lead.name for lead in duplicate_leads] + if not frappe.db.get_single_value('CRM Settings', 'allow_lead_duplication_based_on_emails'): + duplicate_leads = frappe.get_all("Lead", filters={"email_id": self.email_id, "name": ["!=", self.name]}) + duplicate_leads = [frappe.bold(get_link_to_form('Lead', lead.name)) for lead in duplicate_leads] - if duplicate_leads: - frappe.throw(_("Email Address must be unique, already exists for {0}") - .format(comma_and(duplicate_leads)), frappe.DuplicateEntryError) + if duplicate_leads: + frappe.throw(_("Email Address must be unique, already exists for {0}") + .format(comma_and(duplicate_leads)), frappe.DuplicateEntryError) def on_trash(self): frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) @@ -172,41 +175,42 @@ class Lead(SellingController): self.title = self.company_name or self.lead_name def create_contact(self): - if not self.lead_name: - self.set_full_name() - self.set_lead_name() + 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() - 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, - "designation": self.designation, - "company_name": self.company_name, - }) - - if self.email_id: - contact.append("email_ids", { - "email_id": self.email_id, - "is_primary": 1 + 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, + "designation": self.designation, + "company_name": self.company_name, }) - if self.phone: - contact.append("phone_nos", { - "phone": self.phone, - "is_primary_phone": 1 - }) + if self.email_id: + contact.append("email_ids", { + "email_id": self.email_id, + "is_primary": 1 + }) - if self.mobile_no: - contact.append("phone_nos", { - "phone": self.mobile_no, - "is_primary_mobile_no":1 - }) + if self.phone: + contact.append("phone_nos", { + "phone": self.phone, + "is_primary_phone": 1 + }) - contact.insert(ignore_permissions=True) + if self.mobile_no: + contact.append("phone_nos", { + "phone": self.mobile_no, + "is_primary_mobile_no":1 + }) - return contact + contact.insert(ignore_permissions=True) + + return contact @frappe.whitelist() def make_customer(source_name, target_doc=None): diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 0bef80a749..fcbd4ded39 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -8,6 +8,7 @@ import frappe from frappe import _ from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import DocType from frappe.utils import cint, cstr, flt, get_fullname from erpnext.setup.utils import get_exchange_rate @@ -28,7 +29,6 @@ class Opportunity(TransactionBase): }) self.make_new_lead_if_required() - self.validate_item_details() self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() @@ -70,21 +70,21 @@ class Opportunity(TransactionBase): """Set lead against new opportunity""" if (not self.get("party_name")) and self.contact_email: # check if customer is already created agains the self.contact_email - customer = frappe.db.sql("""select - distinct `tabDynamic Link`.link_name as customer - from - `tabContact`, - `tabDynamic Link` - where `tabContact`.email_id='{0}' - and - `tabContact`.name=`tabDynamic Link`.parent - and - ifnull(`tabDynamic Link`.link_name, '')<>'' - and - `tabDynamic Link`.link_doctype='Customer' - """.format(self.contact_email), as_dict=True) - if customer and customer[0].customer: - self.party_name = customer[0].customer + dynamic_link, contact = DocType("Dynamic Link"), DocType("Contact") + customer = frappe.qb.from_( + dynamic_link + ).join( + contact + ).on( + (contact.name == dynamic_link.parent) + & (dynamic_link.link_doctype == "Customer") + & (contact.email_id == self.contact_email) + ).select( + dynamic_link.link_name + ).distinct().run(as_dict=True) + + if customer and customer[0].link_name: + self.party_name = customer[0].link_name self.opportunity_from = "Customer" return @@ -191,30 +191,31 @@ class Opportunity(TransactionBase): self.add_calendar_event() def add_calendar_event(self, opts=None, force=False): - if not opts: - opts = frappe._dict() + 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 + opts.description = "" + opts.contact_date = self.contact_date - if self.party_name and self.opportunity_from == 'Customer': - if self.contact_person: - opts.description = 'Contact '+cstr(self.contact_person) - else: - opts.description = 'Contact customer '+cstr(self.party_name) - elif self.party_name and self.opportunity_from == 'Lead': - if self.contact_display: - opts.description = 'Contact '+cstr(self.contact_display) - else: - opts.description = 'Contact lead '+cstr(self.party_name) + if self.party_name and self.opportunity_from == 'Customer': + if self.contact_person: + opts.description = 'Contact '+cstr(self.contact_person) + else: + opts.description = 'Contact customer '+cstr(self.party_name) + elif self.party_name and self.opportunity_from == 'Lead': + if self.contact_display: + opts.description = 'Contact '+cstr(self.contact_display) + else: + opts.description = 'Contact lead '+cstr(self.party_name) - opts.subject = opts.description - opts.description += '. By : ' + cstr(self.contact_by) + opts.subject = opts.description + opts.description += '. By : ' + cstr(self.contact_by) - if self.to_discuss: - opts.description += ' To Discuss : ' + cstr(self.to_discuss) + if self.to_discuss: + opts.description += ' To Discuss : ' + cstr(self.to_discuss) - super(Opportunity, self).add_calendar_event(opts, force) + super(Opportunity, self).add_calendar_event(opts, force) def validate_item_details(self): if not self.get('items'): @@ -363,7 +364,7 @@ def set_multiple_status(names, status): def auto_close_opportunity(): """ auto close the `Replied` Opportunities after 7 days """ - auto_close_after_days = frappe.db.get_single_value("Selling Settings", "close_opportunity_after_days") or 15 + auto_close_after_days = frappe.db.get_single_value("CRM Settings", "close_opportunity_after_days") or 15 opportunities = frappe.db.sql(""" select name from tabOpportunity where status='Replied' and modified str: +def get_or_make_bin(item_code: str , warehouse: str) -> str: bin_record = frappe.db.get_value('Bin', {'item_code': item_code, 'warehouse': warehouse}) if not bin_record: @@ -203,11 +203,12 @@ def get_or_make_bin(item_code, warehouse) -> str: return bin_record def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False): + """WARNING: This function is deprecated. Inline this function instead of using it.""" from erpnext.stock.doctype.bin.bin import update_stock is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item') if is_stock_item: - bin_record = get_or_make_bin(args.get("item_code"), args.get("warehouse")) - update_stock(bin_record, args, allow_negative_stock, via_landed_cost_voucher) + bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) + update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher) else: frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))