From e54510cb8bb9961909efddbf2a67191dc47a22e1 Mon Sep 17 00:00:00 2001 From: "Ricardo Gomes Rodrigues (rigr)" Date: Mon, 5 Dec 2022 12:22:34 +0000 Subject: [PATCH] [IMP] account{,_edi{,_ubl_cii}}: harmonize invoice upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- addons/account/models/account_journal.py | 25 ++++- addons/account/models/account_move.py | 13 ++- .../tests/test_account_move_in_invoice.py | 106 +++++++++++++++++- .../tests/test_account_move_out_invoice.py | 4 +- addons/account/views/account_move_views.xml | 6 +- .../account_edi/models/account_edi_format.py | 9 +- .../models/account_edi_common.py | 12 +- .../models/account_edi_format.py | 6 - .../models/account_edi_xml_cii_facturx.py | 8 +- .../models/account_edi_xml_ubl_20.py | 10 +- .../tests/common.py | 2 +- addons/purchase/models/purchase.py | 2 +- addons/sale/models/sale_order.py | 2 +- 13 files changed, 161 insertions(+), 44 deletions(-) diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index a7d94a1b403..df5dbfe6b8f 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -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)], diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index b7b3e4ed8d6..187269c9e26 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -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, }) diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index aadec9275a5..1cfe6b1e2ed 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -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({ diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index 61879d19b29..0a2ae3b92c0 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -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, [ diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index d3dcc53a211..42f3ba96628 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -1565,8 +1565,8 @@ - - Switch into refund/credit note + + Switch into invoice/credit note @@ -1574,7 +1574,7 @@ form if records: - action = records.action_switch_invoice_into_refund_credit_note() + action = records.action_switch_move_type() diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py index ff7613ea955..0bb5e791a97 100644 --- a/addons/account_edi/models/account_edi_format.py +++ b/addons/account_edi/models/account_edi_format.py @@ -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']) diff --git a/addons/account_edi_ubl_cii/models/account_edi_common.py b/addons/account_edi_ubl_cii/models/account_edi_common.py index d6b81b8857b..6ff758fc450 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_common.py +++ b/addons/account_edi_ubl_cii/models/account_edi_common.py @@ -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 diff --git a/addons/account_edi_ubl_cii/models/account_edi_format.py b/addons/account_edi_ubl_cii/models/account_edi_format.py index 1bf74e27cab..ecdd11a974b 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_format.py +++ b/addons/account_edi_ubl_cii/models/account_edi_format.py @@ -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) diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py index f8dee20b275..21ad9c17322 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py @@ -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 diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index 40c080521fc..0b2e60a096e 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -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): diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py index ba1bfaa811d..c56ec871660 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py @@ -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) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index b7d884748b3..463b6472f6e 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -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) diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index dfff1f3e0f8..a6cc3e56160 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -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',