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