[IMP] account, account_facturex: import customer invoices in XML

Task 1942983

Accounting firms need to upload PDF of customer invoices and refunds (not only vendor bills). For that, we now allow to
* define a mail alias on sale journals
* upload customer invoices from list view
* check for `facture X` XML attachments in PDF of customer invoices

closes odoo/odoo#31655

Signed-off-by: Quentin De Paoli (qdp) <qdp@openerp.com>
This commit is contained in:
wan
2019-04-04 07:30:43 +00:00
parent e24a16858d
commit 01d0523fd7
9 changed files with 45 additions and 37 deletions
+7 -8
View File
@@ -487,10 +487,10 @@ class AccountJournal(models.Model):
bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id', readonly=False)
post_at_bank_rec = fields.Boolean(string="Post At Bank Reconciliation", help="Whether or not the payments made in this journal should be generated in draft state, so that the related journal entries are only posted when performing bank reconciliation.")
# alias configuration for 'purchase' type journals
# alias configuration for journals
alias_id = fields.Many2one('mail.alias', string='Alias', copy=False)
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.", readonly=False)
alias_name = fields.Char('Alias Name', related='alias_id.alias_name', help="It creates draft invoices and bills by sending an email.", readonly=False)
journal_group_ids = fields.Many2many('account.journal.group', string="Journal Groups")
@@ -583,13 +583,13 @@ class AccountJournal(models.Model):
self.default_debit_account_id = self.default_credit_account_id
@api.multi
def _get_alias_values(self, alias_name=None):
def _get_alias_values(self, type, 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', 'company_id': self.company_id.id},
'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id},
'alias_parent_thread_id': self.id,
'alias_name': re.sub(r'[^\w]+', '-', alias_name)
}
@@ -617,7 +617,7 @@ class AccountJournal(models.Model):
def _update_mail_alias(self, vals):
self.ensure_one()
alias_values = self._get_alias_values(alias_name=vals.get('alias_name'))
alias_values = self._get_alias_values(type=vals.get('type') or self.type, alias_name=vals.get('alias_name'))
if self.alias_id:
self.alias_id.write(alias_values)
else:
@@ -663,7 +663,7 @@ 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':
if 'alias_name' in vals:
journal._update_mail_alias(vals)
result = super(AccountJournal, self).write(vals)
@@ -787,8 +787,7 @@ class AccountJournal(models.Model):
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.with_context(mail_create_nolog=True)).create(vals)
if journal.type == 'purchase':
# create a mail alias for purchase journals (always, deactivated if alias_name isn't set)
if 'alias_name' in vals:
journal._update_mail_alias(vals)
# Create the bank_account_id if necessary
+1 -1
View File
@@ -1000,7 +1000,7 @@ class AccountInvoice(models.Model):
# lots of duplicate calls to action_invoice_open, so we remove those already open
to_open_invoices = self.filtered(lambda inv: inv.state != 'open')
if 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."))
raise UserError(_("The field Vendor/Customer is required, please complete it to validate the Vendor Bill/Customer Invoice."))
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: float_compare(inv.amount_total, 0.0, precision_rounding=inv.currency_id.rounding) == -1):
@@ -9,10 +9,10 @@ class ImportInvoiceImportWizard(models.TransientModel):
_description = 'Import Your Vendor Bills from Files.'
def _get_default_journal_id(self):
return self.env['account.journal'].search([('type', '=', 'purchase')], limit=1)
return self.env['account.journal'].search([('type', '=', self.env.context.get('journal_type'))], limit=1)
attachment_ids = fields.Many2many('ir.attachment', string='Files')
journal_id = fields.Many2one(string="Journal", comodel_name="account.journal", required=True, domain="[('type', '=', 'purchase')]", default=_get_default_journal_id, help="Journal where to generate the bills")
journal_id = fields.Many2one(string="Journal", comodel_name="account.journal", required=True, domain="[('type', 'in', ('sale', 'purchase'))]", default=_get_default_journal_id, help="Journal where to generate the bills")
@api.multi
def _create_invoice_from_file(self, attachment):
@@ -35,12 +35,14 @@ class ImportInvoiceImportWizard(models.TransientModel):
for attachment in self.attachment_ids:
invoices += self._create_invoice_from_file(attachment)
form_view = self.env.context.get('journal_type') == 'purchase' and self.env.ref('account.invoice_supplier_form').id or self.env.ref('account.invoice_form').id
tree_view = self.env.context.get('journal_type') == 'purchase' and self.env.ref('account.invoice_supplier_tree').id or self.env.ref('account.invoice_tree').id
action_vals = {
'name': _('Invoices'),
'domain': [('id', 'in', invoices.ids)],
'view_type': 'form',
'res_model': 'account.invoice',
'view_id': False,
'views': [[tree_view, "tree"], [form_view, "form"]],
'type': 'ir.actions.act_window',
}
if len(invoices) == 1:
@@ -22,7 +22,6 @@ odoo.define('account.bills.tree', function (require) {
this.$buttons.on('click', '.o_button_upload_bill', function () {
var state = self.model.get(self.handle, {raw: true});
var context = state.getContext()
context['type'] = 'in_invoice'
self.do_action({
type: 'ir.actions.act_window',
res_model: 'account.invoice.import.wizard',
@@ -41,5 +40,5 @@ odoo.define('account.bills.tree', function (require) {
}),
});
viewRegistry.add('account_bills_tree', BillsListView);
viewRegistry.add('account_tree', BillsListView);
});
@@ -153,7 +153,7 @@
<field name="name">account.invoice.tree</field>
<field name="model">account.invoice</field>
<field name="arch" type="xml">
<tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" string="Invoice">
<tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" string="Invoice" js_class="account_tree">
<field name="partner_id" groups="base.group_user" string="Customer"/>
<field name="date_invoice"/>
<field name="number"/>
@@ -233,7 +233,7 @@
<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'" decoration-bf="not partner_id" string="Vendor Bill" js_class="account_bills_tree">
<tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'" decoration-bf="not partner_id" string="Vendor Bill" js_class="account_tree">
<field name="partner_id" invisible="1"/>
<field name="source_email" invisible="1"/>
<field name="invoice_icon" string=" "/>
@@ -402,7 +402,7 @@
</page>
</notebook>
</sheet>
<div class="o_attachment_preview" attrs="{'invisible': ['|',('type', '!=', 'in_invoice'),('state', '!=', 'draft')]}" />
<div class="o_attachment_preview"/>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
@@ -460,7 +460,7 @@
widget="res_partner_many2one"
context="{'search_default_customer':1, 'show_address': 1, 'default_is_company': True, 'show_vat': True}"
options='{"always_reload": True, "no_quick_create": True}'
domain="[('customer', '=', True)]" required="1"/>
domain="[('customer', '=', True)]"/>
<field name="payment_term_id"/>
<field name="cash_rounding_id" groups="account.group_cash_rounding"/>
</group>
@@ -632,6 +632,7 @@
</page>
</notebook>
</sheet>
<div class="o_attachment_preview"/>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
<field name="activity_ids" widget="mail_activity"/>
@@ -291,9 +291,6 @@
</button>
</t>
<t t-if="journal_type == 'purchase'">
<button type="object" name="action_create_new" class="btn btn-primary">
<span>New Bill</span>
</button>
<button type="action" name="%(account_invoice_import_wizard_action)d" class="btn btn-primary">
<span>Upload Bills</span>
</button>
+2 -2
View File
@@ -284,13 +284,13 @@
<field name="invoice_reference_type"/>
<field name="invoice_reference_model" attrs="{'invisible': [('invoice_reference_type', '=', 'none')]}"/>
</group>
<group string="Accounting App Options">
<group string="Accounting App Options" attrs="{'invisible': [('type', 'not in', ['bank', 'cash', 'sale', 'purchase'])]}">
<field name="profit_account_id" options="{'no_create': True}" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="loss_account_id" options="{'no_create': True}" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="group_invoice_lines" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}"/>
<field name="post_at_bank_rec" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/>
</group>
<group name="group_alias" string="Email your Vendor Bills" attrs="{'invisible': [('type', '!=', 'purchase')]}">
<group name="group_alias" string="Email your Invoices/Bills" attrs="{'invisible': [('type', 'not in', ('sale' ,'purchase'))]}">
<label string="Email Alias" attrs="{'invisible': [('alias_domain', '=', False)]}" for="alias_id"/>
<div name="alias_def" attrs="{'invisible': [('alias_domain', '=', False)]}">
<field name="alias_id" class="oe_read_only oe_inline"/>
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
{
'name' : 'Import Vendor Bills From XML',
'name' : 'Import Bills/Invoices From XML',
'version' : '1.0',
'category': 'Accounting',
'depends' : ['account'],
@@ -12,6 +12,9 @@ from collections import namedtuple
import io
import base64
import logging
_logger = logging.getLogger(__name__)
DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d'
@@ -56,22 +59,23 @@ class AccountInvoice(models.Model):
# type must be present in the context to get the right behavior of the _default_journal method (account.invoice).
# journal_id must be present in the context to get the right behavior of the _default_account method (account.invoice.line).
self_ctx = self.with_context(type='in_invoice')
journal_id = self_ctx._default_journal().id
self_ctx = self_ctx.with_context(journal_id=journal_id)
journal_id = self._default_journal()
self_ctx = self.with_context(journal_id=journal_id.id)
# self could be a single record (editing) or be empty (new).
with Form(self_ctx, view='account.invoice_supplier_form') as invoice_form:
view = journal_id.type == 'purchase' and 'account.invoice_supplier_form' or 'account.invoice_form'
with Form(self_ctx, view=view) as invoice_form:
# Partner (first step to avoid warning 'Warning! You must first select a partner.').
elements = tree.xpath('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap)
partner_type = journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty'
elements = tree.xpath('//ram:'+partner_type+'/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap)
partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1)
if not partner:
elements = tree.xpath('//ram:SellerTradeParty/ram:Name', namespaces=tree.nsmap)
elements = tree.xpath('//ram:'+partner_type+'/ram:Name', namespaces=tree.nsmap)
partner_name = elements and elements[0].text
partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if not partner:
elements = tree.xpath('//ram:SellerTradeParty//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap)
elements = tree.xpath('//ram:'+partner_type+'//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap)
partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1)
if partner:
invoice_form.partner_id = partner
@@ -98,7 +102,7 @@ class AccountInvoice(models.Model):
# To handle both, we consider the 'a' mode and switch to 'b' if a negative amount is encountered.
elements = tree.xpath('//rsm:ExchangedDocument/ram:TypeCode', namespaces=tree.nsmap)
type_code = elements[0].text
refund_sign = 1
refund_sign = type_code == '380' and 1 or -1
# Total amount.
elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap)
@@ -131,6 +135,8 @@ class AccountInvoice(models.Model):
if elements:
date_str = elements[0].text
date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT)
# Set to empty record set to avoid readonly on date_due, can not set to False or None in a Form
invoice_form.payment_term_id = self.env['account.payment.term']
invoice_form.date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT)
# Invoice lines.
@@ -163,7 +169,7 @@ class AccountInvoice(models.Model):
# Quantity.
line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap)
if line_elements:
invoice_line_form.quantity = float(line_elements[0].text) * refund_sign
invoice_line_form.quantity = float(line_elements[0].text)
# Price Unit.
line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap)
@@ -188,7 +194,7 @@ class AccountInvoice(models.Model):
tax = self.env['account.tax'].search([
('company_id', '=', invoice_form.company_id.id),
('amount_type', '=', 'percent'),
('type_tax_use', '=', 'purchase'),
('type_tax_use', '=', journal_id.type),
('amount', '=', percentage),
], limit=1)
@@ -202,7 +208,10 @@ class AccountInvoice(models.Model):
invoice_line_form.price_unit = amount_total_import
# Refund.
invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice'
if self_ctx.env.context['journal_type'] == 'purchase':
invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice'
else:
invoice_form.type = 'out_refund' if refund_sign == -1 else 'out_invoice'
return invoice_form.save()
@@ -221,7 +230,7 @@ class AccountInvoice(models.Model):
# Handle both _Attachment namedtuple in mail.thread or ir.attachment.
return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas)
if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft' and self.type in ('in_invoice', 'in_refund'):
if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft':
# Get attachments.
# - 'attachments' is a namedtuple defined in mail.thread looking like:
# _Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
@@ -260,8 +269,9 @@ class AccountInvoice(models.Model):
self._import_facturx_invoice(tree)
buffer.close()
return res
except:
except Exception as e:
# Malformed PDF.
_logger.exception(e)
pass
buffer.close()
return res