[IMP] account{,_edi{,_ubl_cii}}: harmonize invoice upload

This commit aims at:

1. Fixing a bug where the invoice would be uploaded in the wrong journal
------------------------------------------------------------------------
To reproduce:
- Go to Customer invoices
- Upload an invoice (with an embedded FacturX)
- The journal is set to Vendor Bill

2. Letting the FacturX move type override the user-chosen move type
-------------------------------------------------------------------
Currently, if the user uploads a credit note in a customer invoice journal,
the document is not created and set to the OCR. This is due to a restrictive check
which has been removed. Therefore, when the move type is defined in the FacturX XML,
we will use it to override the user choice so that the document is always created
within the right journal.

3. Harmonizing the invoice upload between the Accounting and the Documents apps and avoid code duplication
----------------------------------------------------------------------------------------------------------
Currently, the flow of uploading an invoice from the Accounting app and the Documents app is different.
Indeed, if one uploads an invoice in the Document app and click on the "Create invoice",
the document is sent directly to the OCR. Now, instead, we will pass this document to the same upload method
of the Accounting (which will try to create the invoice from the FacturX XML if present).
Therefore, the flow will now be the same from the two apps for better harmonization.

4. Adding a 4th button in the Documents app to create a Vendor refund
---------------------------------------------------------------------
Currently, there are 3 buttons to create a customer invoice, a credit note, a vendor bill, but no vendor refund.
This is due to a duplicate xmlid which has now been fixed allowing the 4th button to be seen in the UI.

5. Adding a button "Switch into customer invoice/vendor bill" button in the account.move's form view
----------------------------------------------------------------------------------------------------
Currently, the user has access to a "Switch into credit note/refund" but not the reverse button to
go from a credit note/refund to an invoice/bill. This is now the case.

Task id 2961932

closes odoo/odoo#103427

Related: odoo/enterprise#32890
Signed-off-by: William André (wan) <wan@odoo.com>
This commit is contained in:
Ricardo Gomes Rodrigues (rigr)
2022-12-14 22:03:52 +01:00
parent 1f7994efba
commit e54510cb8b
13 changed files with 161 additions and 44 deletions
+20 -5
View File
@@ -688,10 +688,19 @@ class AccountJournal(models.Model):
# We simply call the setup bar function.
return self.env['res.company'].setting_init_bank_account_action()
def create_document_from_attachment(self, attachment_ids=None):
''' Create the invoices from files.
:return: A action redirecting to account.move tree/form view.
'''
def _create_document_from_attachment(self, attachment_ids=None):
""" Create the invoices from files."""
context_move_type = self._context.get("default_move_type", "entry")
if not self:
if context_move_type in self.env['account.move'].get_sale_types():
journal_type = "sale"
elif context_move_type in self.env['account.move'].get_purchase_types():
journal_type = "purchase"
else:
raise UserError(_("The journal in which to upload the invoice is not specified. "))
self = self.env['account.journal'].search([
('company_id', '=', self.env.company.id), ('type', '=', journal_type)
], limit=1)
attachments = self.env['ir.attachment'].browse(attachment_ids)
if not attachments:
raise UserError(_("No attachment was provided"))
@@ -703,14 +712,20 @@ class AccountJournal(models.Model):
decoders = self.env['account.move']._get_create_document_from_attachment_decoders()
invoice = False
for decoder in sorted(decoders, key=lambda d: d[0]):
invoice = decoder[1](attachment)
invoice = decoder[1](attachment, journal=self)
if invoice:
break
if not invoice:
invoice = self.env['account.move'].create({})
invoice.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
invoices += invoice
return invoices
def create_document_from_attachment(self, attachment_ids=None):
""" Create the invoices from files.
:return: A action redirecting to account.move tree/form view.
"""
invoices = self._create_document_from_attachment(attachment_ids=attachment_ids)
action_vals = {
'name': _('Generated Documents'),
'domain': [('id', 'in', invoices.ids)],
+9 -4
View File
@@ -658,7 +658,7 @@ class AccountMove(models.Model):
domain = [('company_id', '=', company_id), ('type', '=', journal_type)]
m.suitable_journal_ids = self.env['account.journal'].search(domain)
@api.depends('posted_before', 'state', 'journal_id', 'date')
@api.depends('posted_before', 'state', 'journal_id', 'date', 'move_type')
def _compute_name(self):
self = self.sorted(lambda m: (m.date, m.ref or '', m.id))
highest_name = self[0]._get_last_sequence(lock=False) if self else False
@@ -3398,13 +3398,18 @@ class AccountMove(models.Model):
action['domain'] = [('id', 'in', moves.ids)]
return action
def action_switch_invoice_into_refund_credit_note(self):
if any(move.move_type not in ('in_invoice', 'out_invoice') for move in self):
def action_switch_move_type(self):
if any(move.posted_before for move in self):
raise ValidationError(_("You cannot switch the type of a posted document."))
if any(move.move_type == "entry" for move in self):
raise ValidationError(_("This action isn't available for this document."))
for move in self:
in_out, old_move_type = move.move_type.split('_')
new_move_type = f"{in_out}_{'invoice' if old_move_type == 'refund' else 'refund'}"
move.name = False
move.write({
'move_type': move.move_type.replace('invoice', 'refund'),
'move_type': new_move_type,
'partner_bank_id': False,
'currency_id': move.currency_id.id,
})
@@ -1534,8 +1534,9 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon):
with self.assertRaises(RedirectWarning):
invoice_2.action_post()
def test_in_invoice_switch_in_refund_1(self):
# Test creating an account_move with an in_invoice_type and switch it in an in_refund.
def test_in_invoice_switch_type_1(self):
# Test creating an account_move with an in_invoice_type and switch it in an in_refund,
# then switching it back to an in_invoice.
move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
@@ -1557,7 +1558,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon):
}),
],
})
move.action_switch_invoice_into_refund_credit_note()
move.action_switch_move_type() # Switch to refund.
self.assertRecordValues(move, [{'move_type': 'in_refund'}])
self.assertInvoiceValues(move, [
@@ -1602,8 +1603,54 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon):
'currency_id': self.currency_data['currency'].id,
})
def test_in_invoice_switch_in_refund_2(self):
# Test creating an account_move with an in_invoice_type and switch it in an in_refund and a negative quantity.
move.action_switch_move_type() # Switch back to invoice.
self.assertRecordValues(move, [{'move_type': 'in_invoice'}])
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 800.0,
'credit': 0,
'debit': 400.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 160.0,
'credit': 0.0,
'debit': 80,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 144.0,
'credit': 0,
'debit': 72,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 24.0,
'credit': 0,
'debit': 12,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': -1128.0,
'debit': 0,
'credit': 564,
},
], {
**self.move_vals,
'date': fields.Date.from_string('2019-01-31'),
'currency_id': self.currency_data['currency'].id,
})
def test_in_invoice_switch_type_2(self):
# Test creating an account_move with an in_invoice_type and switch it in an in_refund and a negative quantity,
# then switching it back to an in_invoice.
move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
@@ -1678,7 +1725,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon):
'amount_total' : -self.move_vals['amount_total'],
'amount_untaxed' : -self.move_vals['amount_untaxed'],
})
move.action_switch_invoice_into_refund_credit_note()
move.action_switch_move_type() # Switch to refund
self.assertRecordValues(move, [{'move_type': 'in_refund'}])
self.assertInvoiceValues(move, [
@@ -1725,6 +1772,53 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon):
'amount_total' : self.move_vals['amount_total'],
'amount_untaxed' : self.move_vals['amount_untaxed'],
})
move.action_switch_move_type() # Switch back to invoice
self.assertRecordValues(move, [{'move_type': 'in_invoice'}])
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 800.0,
'credit': 0,
'debit': 400,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 160.0,
'credit': 0,
'debit': 80,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 144.0,
'credit': 0,
'debit': 72,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'amount_currency': 24.0,
'credit': 0,
'debit': 12,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'amount_currency': -1128.0,
'debit': 0,
'credit': 564,
},
], {
**self.move_vals,
'date': fields.Date.from_string('2019-01-31'),
'currency_id': self.currency_data['currency'].id,
'amount_tax': self.move_vals['amount_tax'],
'amount_total': self.move_vals['amount_total'],
'amount_untaxed': self.move_vals['amount_untaxed'],
})
def test_in_invoice_change_period_accrual_1(self):
move = self.env['account.move'].create({
@@ -2380,7 +2380,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon):
}),
],
})
move.action_switch_invoice_into_refund_credit_note()
move.action_switch_move_type()
self.assertRecordValues(move, [{'move_type': 'out_refund'}])
self.assertInvoiceValues(move, [
@@ -2500,7 +2500,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon):
'amount_untaxed' : -self.move_vals['amount_untaxed'],
})
move.action_switch_invoice_into_refund_credit_note()
move.action_switch_move_type()
self.assertRecordValues(move, [{'move_type': 'out_refund'}])
self.assertInvoiceValues(move, [
+3 -3
View File
@@ -1565,8 +1565,8 @@
<field name="search_view_id" ref="view_account_move_filter"/>
</record>
<record model="ir.actions.server" id="action_move_switch_invoice_to_credit_note">
<field name="name">Switch into refund/credit note</field>
<record model="ir.actions.server" id="action_move_switch_move_type">
<field name="name">Switch into invoice/credit note</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/>
<field name="binding_model_id" ref="account.model_account_move" />
@@ -1574,7 +1574,7 @@
<field name="binding_view_types">form</field>
<field name="code">
if records:
action = records.action_switch_invoice_into_refund_credit_note()
action = records.action_switch_move_type()
</field>
</record>
@@ -128,11 +128,12 @@ class AccountEdiFormat(models.Model):
self.ensure_one()
return self.env['account.move']
def _create_invoice_from_pdf_reader(self, filename, reader):
def _create_invoice_from_pdf_reader(self, filename, reader, journal):
""" Create a new invoice with the data inside a pdf.
:param filename: The name of the pdf.
:param reader: The OdooPdfFileReader of the pdf to import.
:param journal The journal in which we want to create the invoice.
:returns: The created invoice.
"""
# TO OVERRIDE
@@ -298,7 +299,7 @@ class AccountEdiFormat(models.Model):
return to_process
def _create_document_from_attachment(self, attachment):
def _create_document_from_attachment(self, attachment, journal=None):
"""Decodes an ir.attachment to create an invoice.
:param attachment: An ir.attachment record.
@@ -309,9 +310,9 @@ class AccountEdiFormat(models.Model):
res = False
try:
if file_data['type'] == 'xml':
res = edi_format.with_company(self.env.company)._create_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'])
res = edi_format.with_company(self.env.company)._create_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], journal)
elif file_data['type'] == 'pdf':
res = edi_format.with_company(self.env.company)._create_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'])
res = edi_format.with_company(self.env.company)._create_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'], journal)
file_data['pdf_reader'].stream.close()
else:
res = edi_format._create_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension'])
@@ -249,7 +249,15 @@ class AccountEdiCommon(models.AbstractModel):
def _import_invoice(self, journal, filename, tree, existing_invoice=None):
move_type, qty_factor = self._get_import_document_amount_sign(filename, tree)
if not move_type or (existing_invoice and existing_invoice.move_type != move_type):
if not move_type:
return
if journal.type == 'sale':
move_type = 'out_' + move_type
elif journal.type == 'purchase':
move_type = 'in_' + move_type
else:
return
if existing_invoice and existing_invoice.move_type != move_type:
return
with (existing_invoice or self.env['account.move']).with_context(
@@ -351,7 +359,7 @@ class AccountEdiCommon(models.AbstractModel):
('company_id', '=', journal.company_id.id),
('amount', '=', float(tax_categ_percent_el.text)),
('amount_type', '=', 'percent'),
('type_tax_use', '=', 'purchase'),
('type_tax_use', '=', journal.type), # Journal type is ensured by _create_invoice_from_xml_tree to be either 'sale' or 'purchase'
], limit=1)
if tax:
tax_ids += tax.ids
@@ -177,12 +177,6 @@ class AccountEdiFormat(models.Model):
# EXTENDS account_edi
self.ensure_one()
if not journal:
# infer the journal
journal = self.env['account.journal'].search([
('company_id', '=', self.env.company.id), ('type', '=', 'purchase')
], limit=1)
if not self._is_ubl_cii_available(journal.company_id) and self.code != 'facturx_1_0_05':
return super()._create_invoice_from_xml_tree(filename, tree, journal=journal)
@@ -216,7 +216,7 @@ class AccountEdiXmlCII(models.AbstractModel):
# ==== partner_id ====
partner_type = invoice.journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty'
partner_type = invoice.journal_id.type == journal.type and 'SellerTradeParty' or 'BuyerTradeParty'
invoice.partner_id = self.env['account.edi.format']._retrieve_partner(
name=_find_value(f"//ram:{partner_type}/ram:Name"),
mail=_find_value(f"//ram:{partner_type}//ram:URIID[@schemeID='SMTP']"),
@@ -352,9 +352,9 @@ class AccountEdiXmlCII(models.AbstractModel):
if move_type_code is None:
return None, None
if move_type_code.text == '381':
return 'in_refund', 1
return 'refund', 1
if move_type_code.text == '380':
amount_node = tree.find('.//{*}SpecifiedTradeSettlementHeaderMonetarySummation/{*}TaxBasisTotalAmount')
if amount_node is not None and float(amount_node.text) < 0:
return 'in_refund', -1
return 'in_invoice', 1
return 'refund', -1
return 'invoice', 1
@@ -545,7 +545,7 @@ class AccountEdiXmlUBL20(models.AbstractModel):
# ==== invoice_line_ids: InvoiceLine/CreditNoteLine ====
invoice_line_tag = 'InvoiceLine' if invoice.move_type == 'in_invoice' or qty_factor == -1 else 'CreditNoteLine'
invoice_line_tag = 'InvoiceLine' if invoice.move_type in ('in_invoice', 'out_invoice') or qty_factor == -1 else 'CreditNoteLine'
for i, invl_el in enumerate(tree.findall('./{*}' + invoice_line_tag)):
invoice_line = invoice.invoice_line_ids.create({'move_id': invoice.id})
invl_logs = self._import_fill_invoice_line_form(journal, invl_el, invoice, invoice_line, qty_factor)
@@ -579,7 +579,7 @@ class AccountEdiXmlUBL20(models.AbstractModel):
'gross_price_unit': './{*}Price/{*}AllowanceCharge/{*}BaseAmount',
'rebate': './{*}Price/{*}AllowanceCharge/{*}Amount',
'net_price_unit': './{*}Price/{*}PriceAmount',
'billed_qty': './{*}InvoicedQuantity' if invoice.move_type == 'in_invoice' or qty_factor == -1 else './{*}CreditedQuantity',
'billed_qty': './{*}InvoicedQuantity' if invoice.move_type in ('in_invoice', 'out_invoice') or qty_factor == -1 else './{*}CreditedQuantity',
'allowance_charge': './/{*}AllowanceCharge',
'allowance_charge_indicator': './{*}ChargeIndicator', # below allowance_charge node
'allowance_charge_amount': './{*}Amount', # below allowance_charge node
@@ -609,10 +609,10 @@ class AccountEdiXmlUBL20(models.AbstractModel):
if tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}Invoice':
amount_node = tree.find('.//{*}LegalMonetaryTotal/{*}TaxExclusiveAmount')
if amount_node is not None and float(amount_node.text) < 0:
return 'in_refund', -1
return 'in_invoice', 1
return 'refund', -1
return 'invoice', 1
if tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2}CreditNote':
return 'in_refund', 1
return 'refund', 1
return None, None
def _import_retrieve_partner_map(self, company):
@@ -74,7 +74,7 @@ class TestUBLCommon(AccountEdiTestCommon):
Create an account.move directly from an xml file, asserts the invoice obtained is the same as the expected
invoice.
"""
self.company_data['default_journal_purchase'].create_document_from_attachment(attachment.ids)
invoice.journal_id.create_document_from_attachment(attachment.ids)
new_invoice = self.env['account.move'].search([], order='id desc', limit=1)
self.assertTrue(new_invoice)
+1 -1
View File
@@ -612,7 +612,7 @@ class PurchaseOrder(models.Model):
# 4) Some moves might actually be refunds: convert them if the total amount is negative
# We do this after the moves have been created since we need taxes, etc. to know if the total
# is actually negative or not
moves.filtered(lambda m: m.currency_id.round(m.amount_total) < 0).action_switch_invoice_into_refund_credit_note()
moves.filtered(lambda m: m.currency_id.round(m.amount_total) < 0).action_switch_move_type()
return self.action_view_invoice(moves)
+1 -1
View File
@@ -1179,7 +1179,7 @@ class SaleOrder(models.Model):
# We do this after the moves have been created since we need taxes, etc. to know if the total
# is actually negative or not
if final:
moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_invoice_into_refund_credit_note()
moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_move_type()
for move in moves:
move.message_post_with_view(
'mail.message_origin_link',