[IMP] account: vendor bill creation upon email reception

This allows to set up a mail alias per purchase journal. Then share that email address to your supplier, or use it internally to forward the vendor bills received by mail, to automatically create an empty vendor bill with the mail attachments linked, and the partner might be filled if the source email matches a supplier.

Thanks to the document preview on the side, it's now super easy and super fast to copy the vendor bill info from the received PDF into the account.invoice object. This would eventually be improved later on (IAP).

Was task: 37703
Was PR #22158
This commit is contained in:
Sanjay Jamod
2018-06-06 14:17:03 +02:00
committed by qdp-odoo
parent a6b75d401d
commit a4df9f8c93
7 changed files with 194 additions and 22 deletions
+9
View File
@@ -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">
+44 -2
View File
@@ -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'):
+98 -6
View File
@@ -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()
@@ -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
@@ -23,4 +23,3 @@
font-style: italic;
color: grey;
}
+26 -11
View File
@@ -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"/>
+12
View File
@@ -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')]}">