diff --git a/addons/account/data/account_data.xml b/addons/account/data/account_data.xml index 900e7fdead454e4adf9b1339efda2cc5872524db..f7fa31bd517e5a9801d0b063c8d7270b4bb55d41 100644 --- a/addons/account/data/account_data.xml +++ b/addons/account/data/account_data.xml @@ -2,6 +2,15 @@ <odoo> <data noupdate="1"> + <!-- Open Settings from Purchase Journal to configure mail servers --> + <record id="action_open_settings" model="ir.actions.act_window"> + <field name="name">Settings</field> + <field name="res_model">res.config.settings</field> + <field name="view_mode">form</field> + <field name="target">inline</field> + <field name="context" eval="{'module': 'general_settings'}"/> + </record> + <!-- TAGS FOR CASH FLOW STATEMENT --> <record id="account_tag_operating" model="account.account.tag"> diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 87b4ffc2765e93f7fbea43d4cfdb593bc1957270..c8ec7e289ffec6154c90620925870dc1f90a85b6 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -2,6 +2,7 @@ import time import math +import re from odoo.osv import expression from odoo.tools.float_utils import float_round as round @@ -427,10 +428,21 @@ class AccountJournal(models.Model): bank_acc_number = fields.Char(related='bank_account_id.acc_number') bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id') + # alias configuration for 'purchase' type journals + alias_id = fields.Many2one('mail.alias', string='Alias') + alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain', default=lambda self: self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")) + alias_name = fields.Char('Alias Name for Vendor Bills', related='alias_id.alias_name', help="It creates draft vendor bill by sending an email.") + _sql_constraints = [ ('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'), ] + @api.multi + def _compute_alias_domain(self): + alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain") + for record in self: + record.alias_domain = alias_domain + @api.multi # do not depend on 'sequence_id.date_range_ids', because # sequence_id._get_current_sequence() may invalidate it! @@ -509,6 +521,19 @@ class AccountJournal(models.Model): if not self.default_debit_account_id: self.default_debit_account_id = self.default_credit_account_id + @api.multi + def _get_alias_values(self, alias_name=None): + if not alias_name: + alias_name = self.name + if self.company_id != self.env.ref('base.main_company'): + alias_name += '-' + str(self.company_id.name) + return { + 'alias_defaults': {'type': 'in_invoice'}, + 'alias_user_id': self.env.user.id, + 'alias_parent_thread_id': self.id, + 'alias_name': re.sub(r'[^\w]+', '-', alias_name) + } + @api.multi def unlink(self): bank_accounts = self.env['res.partner.bank'].browse() @@ -516,6 +541,7 @@ class AccountJournal(models.Model): accounts = self.search([('bank_account_id', '=', bank_account.id)]) if accounts <= self: bank_accounts += bank_account + self.mapped('alias_id').unlink() ret = super(AccountJournal, self).unlink() bank_accounts.unlink() return ret @@ -529,6 +555,19 @@ class AccountJournal(models.Model): name=_("%s (copy)") % (self.name or '')) return super(AccountJournal, self).copy(default) + def _update_mail_alias(self, vals): + self.ensure_one() + alias_values = self._get_alias_values(alias_name=vals.get('alias_name')) + if self.alias_id: + self.alias_id.write(alias_values) + else: + self.alias_id = self.env['mail.alias'].with_context(alias_model_name='account.invoice', + alias_parent_model_name='account.journal').create(alias_values) + + if vals.get('alias_name'): + # remove alias_name to avoid useless write on alias + del(vals['alias_name']) + @api.multi def write(self, vals): for journal in self: @@ -564,7 +603,8 @@ class AccountJournal(models.Model): bank_account = self.env['res.partner.bank'].browse(vals['bank_account_id']) if bank_account.partner_id != company.partner_id: raise UserError(_("The partners of the journal's company and the related bank account mismatch.")) - + if vals.get('type') == 'purchase': + self._update_mail_alias(vals) result = super(AccountJournal, self).write(vals) # Create the bank_account_id if necessary @@ -676,8 +716,10 @@ class AccountJournal(models.Model): vals.update({'sequence_id': self.sudo()._create_sequence(vals).id}) if vals.get('type') in ('sale', 'purchase') and vals.get('refund_sequence') and not vals.get('refund_sequence_id'): vals.update({'refund_sequence_id': self.sudo()._create_sequence(vals, refund=True).id}) - journal = super(AccountJournal, self).create(vals) + if journal.type == 'purchase': + # create a mail alias for purchase journals (always, deactivated if alias_name isn't set) + journal._update_mail_alias(vals) # Create the bank_account_id if necessary if journal.type == 'bank' and not journal.bank_account_id and vals.get('bank_acc_number'): diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index a4407c557aff008db7d1f73e89510fd5919c4363..3999980159aa84d54df0ab32e428bd7d3c9e533a 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -10,7 +10,7 @@ from dateutil.relativedelta import relativedelta from werkzeug.urls import url_encode from odoo import api, exceptions, fields, models, _ -from odoo.tools import float_is_zero, float_compare, pycompat +from odoo.tools import email_re, email_split, email_escape_char, float_is_zero, float_compare, pycompat from odoo.tools.misc import formatLang from odoo.exceptions import AccessError, UserError, RedirectWarning, ValidationError, Warning @@ -241,7 +241,7 @@ class AccountInvoice(models.Model): ('in_invoice','Vendor Bill'), ('out_refund','Customer Credit Note'), ('in_refund','Vendor Credit Note'), - ], readonly=True, index=True, change_default=True, + ], readonly=True, states={'draft': [('readonly', False)]}, index=True, change_default=True, default=lambda self: self._context.get('type', 'out_invoice'), track_visibility='always') access_token = fields.Char( @@ -284,7 +284,7 @@ class AccountInvoice(models.Model): "term is not set on the invoice. If you keep the Payment terms and the due date empty, it " "means direct payment.") partner_id = fields.Many2one('res.partner', string='Partner', change_default=True, - required=True, readonly=True, states={'draft': [('readonly', False)]}, + readonly=True, states={'draft': [('readonly', False)]}, track_visibility='always') vendor_bill_id = fields.Many2one('account.invoice', string='Vendor Bill', help="Auto-complete from a past bill.") @@ -299,7 +299,7 @@ class AccountInvoice(models.Model): readonly=True, states={'draft': [('readonly', False)]}) account_id = fields.Many2one('account.account', string='Account', - required=True, readonly=True, states={'draft': [('readonly', False)]}, + readonly=True, states={'draft': [('readonly', False)]}, domain=[('deprecated', '=', False)], help="The partner account used for this invoice.") invoice_line_ids = fields.One2many('account.invoice.line', 'invoice_id', string='Invoice Lines', oldname='invoice_line', readonly=True, states={'draft': [('readonly', False)]}, copy=True) @@ -370,10 +370,24 @@ class AccountInvoice(models.Model): sequence_number_next = fields.Char(string='Next Number', compute="_get_sequence_number_next", inverse="_set_sequence_next") sequence_number_next_prefix = fields.Char(string='Next Number Prefix', compute="_get_sequence_prefix") + #fields related to vendor bills automated creation by email + source_email = fields.Char(string='Source Email', track_visibility='onchange') + vendor_display_name = fields.Char(compute='_get_vendor_display_info', store=True) # store=True to enable sorting on that column + invoice_icon = fields.Char(compute='_get_vendor_display_info', store=False) + _sql_constraints = [ ('number_uniq', 'unique(number, company_id, journal_id, type)', 'Invoice Number must be unique per Company!'), ] + @api.depends('partner_id', 'source_email') + def _get_vendor_display_info(self): + for invoice in self: + vendor_display_name = invoice.partner_id.name + if not vendor_display_name and invoice.source_email: + vendor_display_name = _('From: ') + invoice.source_email + invoice.vendor_display_name = vendor_display_name + invoice.invoice_icon = invoice.source_email and '@' or '' + # Load all Vendor Bill lines @api.onchange('vendor_bill_id') def _onchange_vendor_bill(self): @@ -479,8 +493,6 @@ class AccountInvoice(models.Model): for field in changed_fields: if field not in vals and invoice[field]: vals[field] = invoice._fields[field].convert_to_write(invoice[field], invoice) - if not vals.get('account_id',False): - raise UserError(_('No account was found to create the invoice, be sure you have installed a chart of account.')) invoice = super(AccountInvoice, self.with_context(mail_create_nolog=True)).create(vals) @@ -611,6 +623,82 @@ class AccountInvoice(models.Model): self.filtered(lambda inv: not inv.sent).write({'sent': True}) return super(AccountInvoice, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) + @api.model + def message_new(self, msg_dict, custom_values=None): + """ Overrides mail_thread message_new(), called by the mailgateway through message_process, + to complete values for vendor bills created by mails. + """ + # Split `From` and `CC` email address from received email to look for related partners to subscribe on the invoice + subscribed_emails = email_split((msg_dict.get('from') or '') + ',' + (msg_dict.get('cc') or '')) + subscribed_partner_ids = [pid for pid in self._find_partner_from_emails(subscribed_emails) if pid] + + # Detection of the partner_id of the invoice: + # 1) check if the email_from correspond to a supplier + email_from = msg_dict.get('from') or '' + email_from = email_escape_char(email_split(email_from)[0]) + partner_id = self._search_on_partner(email_from, extra_domain=[('supplier', '=', True)]) + + # 2) otherwise, if the email sender is from odoo internal users then it is likely that the vendor sent the bill + # by mail to the internal user who, inturn, forwarded that email to the alias to automatically generate the bill + # on behalf of the vendor. + if not partner_id: + user_partner_id = self._search_on_user(email_from) + if user_partner_id and user_partner_id in self.env.ref('base.group_user').users.mapped('partner_id').ids: + # In this case, we will look for the vendor's email address in email's body and assume if will come first + email_addresses = email_re.findall(msg_dict.get('body')) + if email_addresses: + partner_ids = [pid for pid in self._find_partner_from_emails([email_addresses[0]], force_create=False) if pid] + partner_id = partner_ids and partner_ids[0] + # otherwise, there's no fallback on the partner_id found for the regular author of the mail.message as we want + # the partner_id to stay empty + + # If the partner_id can be found, subscribe it to the bill, otherwise it's left empty to be manually filled + if partner_id: + subscribed_partner_ids.append(partner_id) + + # Find the right purchase journal based on the "TO" email address + destination_emails = email_split((msg_dict.get('to') or '') + ',' + (msg_dict.get('cc') or '')) + alias_names = [mail_to.split('@')[0] for mail_to in destination_emails] + journal = self.env['account.journal'].search([ + ('type', '=', 'purchase'), ('alias_name', 'in', alias_names) + ], limit=1) + + # Create the message and the bill. + values = dict(custom_values or {}, partner_id=partner_id, source_email=email_from) + if journal: + values['journal_id'] = journal.id + # Passing `type` in context so that _default_journal(...) can correctly set journal for new vendor bill + invoice = super(AccountInvoice, self.with_context(type=values.get('type'))).message_new(msg_dict, values) + + # Subscribe people on the newly created bill + if subscribed_partner_ids: + invoice.message_subscribe(subscribed_partner_ids) + return invoice + + @api.model + def complete_empty_list_help(self): + # add help message about email alias in vendor bills empty lists + Journal = self.env['account.journal'] + journals = Journal.browse(self._context.get('default_journal_id')) or Journal.search([('type', '=', 'purchase')]) + + if journals: + links = '' + alias_count = 0 + for journal in journals.filtered(lambda j: j.alias_domain and j.alias_id.alias_name): + email = format(journal.alias_id.alias_name) + "@" + format(journal.alias_domain) + links += "<a id='o_mail_test' href='mailto:{}'>{}</a>".format(email, email) + ", " + alias_count += 1 + if links and alias_count == 1: + help_message = _('Or share the email %s to your vendors: bills will be created automatically upon mail reception.') % (links[:-2]) + elif links: + help_message = _('Or share the emails %s to your vendors: bills will be created automatically upon mail reception.') % (links[:-2]) + else: + help_message = _('''Or set an <a data-oe-id=%s data-oe-model="account.journal" href=#id=%s&model=account.journal>email alias</a> ''' + '''to allow draft vendor bills to be created upon reception of an email.''') % (journals[0].id, journals[0].id) + else: + help_message = _('<p>You can control the invoice from your vendor based on what you purchased or received.</p>') + return help_message + @api.multi def compute_taxes(self): """Function used in other module to compute the taxes on a fresh invoice created (onchanges did not applied)""" @@ -799,10 +887,14 @@ class AccountInvoice(models.Model): def action_invoice_open(self): # lots of duplicate calls to action_invoice_open, so we remove those already open to_open_invoices = self.filtered(lambda inv: inv.state != 'open') + for inv in to_open_invoices.filtered(lambda inv: not inv.partner_id): + raise UserError(_("The field Vendor is required, please complete it to validate the Vendor Bill.")) if to_open_invoices.filtered(lambda inv: inv.state != 'draft'): raise UserError(_("Invoice must be in draft state in order to validate it.")) if to_open_invoices.filtered(lambda inv: inv.amount_total < 0): raise UserError(_("You cannot validate an invoice with a negative total amount. You should create a credit note instead.")) + if to_open_invoices.filtered(lambda inv: not inv.account_id): + raise UserError(_('No account was found to create the invoice, be sure you have installed a chart of account.')) to_open_invoices.action_date_assign() to_open_invoices.action_move_create() return to_open_invoices.invoice_validate() diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index cb58e37df92c35560e7fffecfe019a886ddc260e..af8e26b27ab1d69755c8f3cc7029ba3bf026b32c 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -324,7 +324,7 @@ class account_journal(models.Model): elif self.type == 'sale': action_name = 'action_invoice_tree1' elif self.type == 'purchase': - action_name = 'action_invoice_tree2' + action_name = 'action_vendor_bill_template' else: action_name = 'action_move_journal_line' @@ -354,11 +354,14 @@ class account_journal(models.Model): action['context'] = ctx action['domain'] = self._context.get('use_domain', []) account_invoice_filter = self.env.ref('account.view_account_invoice_filter', False) - if action_name in ['action_invoice_tree1', 'action_invoice_tree2']: + if action_name in ['action_invoice_tree1', 'action_vendor_bill_template']: action['search_view_id'] = account_invoice_filter and account_invoice_filter.id or False if action_name in ['action_bank_statement_tree', 'action_view_bank_statement_tree']: action['views'] = False action['view_id'] = False + if self.type == 'purchase': + new_help = self.env['account.invoice'].with_context(ctx).complete_empty_list_help() + action.update({'help': action.get('help', '') + new_help}) return action @api.multi diff --git a/addons/account/static/src/css/account.css b/addons/account/static/src/css/account.css index 923ea75efe31f9f387dfef91fbfd3010399ee705..217e74f757145b782e9ad4e0751435028377d54d 100644 --- a/addons/account/static/src/css/account.css +++ b/addons/account/static/src/css/account.css @@ -23,4 +23,3 @@ font-style: italic; color: grey; } - diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index 0ce2d057a5bd36b55476d8565aa4549a8bfd49a4..8345b94ac5825bf3ed716fcda604f14bd27389b8 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -190,8 +190,11 @@ <field name="name">account.invoice.supplier.tree</field> <field name="model">account.invoice</field> <field name="arch" type="xml"> - <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" string="Invoice"> - <field name="partner_id" groups="base.group_user" string="Vendor"/> + <tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" decoration-bf="not partner_id" string="Vendor Bill"> + <field name="partner_id" invisible="1"/> + <field name="source_email" invisible="1"/> + <field name="invoice_icon" string=" "/> + <field name="vendor_display_name" groups="base.group_user" string="Vendor"/> <field name="date_invoice" string="Bill Date"/> <field name="number"/> <field name="reference"/> @@ -250,7 +253,7 @@ <group> <field string="Vendor" name="partner_id" context="{'default_customer': 0, 'search_default_supplier': 1, 'default_supplier': 1, 'default_company_type': 'company'}" - domain="[('supplier', '=', True)]"/> + domain="[('supplier', '=', True)]" required="1"/> <field name="reference" string="Vendor Reference"/> <field name="vendor_bill_id" attrs="{'invisible': [('state','not in',['draft'])]}" domain="[('partner_id','child_of', [partner_id]), ('state','in',('open','paid')), ('type','=','in_invoice')]" @@ -259,6 +262,7 @@ </group> <group> <field name="origin" attrs="{'invisible': [('origin', '=', False)]}"/> + <field name="source_email" widget="email" groups="base.group_no_one" attrs="{'invisible': [('source_email', '=', False)]}"/> <field name="date_invoice" string="Bill Date"/> <field name="date_due" attrs="{'readonly': ['|',('payment_term_id','!=',False), ('state', 'in', ['open', 'paid'])]}" force_save="1"/> <field name="move_name" invisible="1"/> @@ -348,7 +352,7 @@ </page> </notebook> </sheet> - <div class="o_attachment_preview" attrs="{'invisible': ['|',('type', '!=', 'in_invoice'),('state', '!=', 'draft')]}"/> + <div class="o_attachment_preview" attrs="{'invisible': ['|',('type', '!=', 'in_invoice'),('state', '!=', 'draft')]}" options="{'preview_priority_type': 'pdf'}"/> <div class="oe_chatter"> <field name="message_follower_ids" widget="mail_followers"/> <field name="activity_ids" widget="mail_activity"/> @@ -402,7 +406,7 @@ <field string="Customer" name="partner_id" context="{'search_default_customer':1, 'show_address': 1, 'default_company_type': 'company'}" options='{"always_reload": True, "no_quick_create": True}' - domain="[('customer', '=', True)]"/> + domain="[('customer', '=', True)]" required="1"/> <field name="payment_term_id"/> <field name="cash_rounding_id" groups="account.group_cash_rounding"/> </group> @@ -707,7 +711,7 @@ parent="menu_finance_receivables_documents" sequence="1"/> - <record id="action_invoice_tree2" model="ir.actions.act_window"> + <record id="action_vendor_bill_template" model="ir.actions.act_window"> <field name="name">Vendor Bills</field> <field name="res_model">account.invoice</field> <field name="view_type">form</field> @@ -719,25 +723,36 @@ <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Record a new vendor bill - </p><p> - You can control the invoice from your vendor based on - what you purchased or received. </p> </field> </record> + <!-- + server action opening the vendor bills and returning the right help tooltip + --> + <record id="action_invoice_tree2" model="ir.actions.server"> + <field name="name">Vendor Bills</field> + <field name="model_id" ref="model_account_invoice"/> + <field name="state">code</field> + <field name="code"> +action_values = env.ref('account.action_vendor_bill_template').read()[0] +new_help = model.complete_empty_list_help() +action_values.update({'help': action_values.get('help', '') + new_help}) +action = action_values + </field> + </record> <record id="action_invoice_supplier_tree1_view1" model="ir.actions.act_window.view"> <field eval="1" name="sequence"/> <field name="view_mode">tree</field> <field name="view_id" ref="invoice_supplier_tree"/> - <field name="act_window_id" ref="action_invoice_tree2"/> + <field name="act_window_id" ref="action_vendor_bill_template"/> </record> <record id="action_invoice__supplier_tree1_view2" model="ir.actions.act_window.view"> <field eval="2" name="sequence"/> <field name="view_mode">form</field> <field name="view_id" ref="invoice_supplier_form"/> - <field name="act_window_id" ref="action_invoice_tree2"/> + <field name="act_window_id" ref="action_vendor_bill_template"/> </record> <menuitem action="action_invoice_tree2" id="menu_action_invoice_tree2" parent="menu_finance_payables_documents" sequence="1"/> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 4d6a7e5a51b6b05e145a27622785452df00555da..6e22ba8b77aae7364b9cfaf6309603fcb6417035 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -333,6 +333,18 @@ <field name="loss_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/> <field name="show_on_dashboard" groups="base.group_no_one"/> </group> + <group name="group_alias" string="Email your Vendor Bills" attrs="{'invisible': [('type', '!=', 'purchase')]}"> + <label string="Email Alias" attrs="{'invisible': [('alias_domain', '=', False)]}"/> + <div name="alias_def" attrs="{'invisible': [('alias_domain', '=', False)]}"> + <field name="alias_id" class="oe_read_only oe_inline"/> + <div class="oe_edit_only oe_inline" name="edit_alias" style="display: inline;" > + <field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/> + </div> + </div> + <div class="content-group" attrs="{'invisible': [('alias_domain', '!=', False)]}"> + <a type='action' name='%(action_open_settings)d' class="btn btn-sm btn-link"><i class="fa fa-fw o_button_icon fa-arrow-right"/> Configure Email Servers</a> + </div> + </group> </group> </page> <page name="bank_account" string="Bank Account" attrs="{'invisible': [('type', '!=', 'bank')]}">