[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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user