diff --git a/l10n_br_account/__manifest__.py b/l10n_br_account/__manifest__.py index ad254bcb445e..32d5b281dc69 100644 --- a/l10n_br_account/__manifest__.py +++ b/l10n_br_account/__manifest__.py @@ -7,7 +7,7 @@ "license": "AGPL-3", "author": "Akretion, Odoo Community Association (OCA)", "website": "https://github.com/OCA/l10n-brazil", - "version": "14.0.10.6.0", + "version": "14.0.11.0.0", "development_status": "Beta", "maintainers": ["renatonlima", "rvalyi"], "depends": [ diff --git a/l10n_br_account/models/account_move.py b/l10n_br_account/models/account_move.py index 7d9a37034cdc..a2bc538aa9db 100644 --- a/l10n_br_account/models/account_move.py +++ b/l10n_br_account/models/account_move.py @@ -6,6 +6,7 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.tests.common import Form from odoo.tools import mute_logger from odoo.addons.l10n_br_fiscal.constants.fiscal import ( @@ -47,6 +48,8 @@ "in_refund": "purchase", } +# l10n_br_fiscal.document field names that are shadowed +# by account.move fields: SHADOWED_FIELDS = ["company_id", "currency_id", "user_id", "partner_id"] @@ -54,6 +57,7 @@ class InheritsCheckMuteLogger(mute_logger): """ Mute the Model#_inherits_check warning because the _inherits field is not required. + (some account.move may have no fiscal document) """ def filter(self, record): @@ -69,7 +73,19 @@ class AccountMove(models.Model): _name, "l10n_br_fiscal.document.move.mixin", ] + + # an account.move has normally 0 or 1 related fiscal document: + # - 0 when it is not related to a Brazilian company for instance. + # - 1 otherwise (usually). In this case the _inherits system + # makes it easy to edit all the fiscal document (lines) fields + # through the account.move form. + # in some rare cases an account.move may have several fiscal + # documents (1 on each account.move.line). In this case + # fiscal_document_id might be used only to sync the "main" fiscal + # document (or the one currently imported or edited). In this case, + # fiscal_document_ids contains all the line fiscal documents. _inherits = {"l10n_br_fiscal.document": "fiscal_document_id"} + _order = "date DESC, name DESC" document_electronic = fields.Boolean( @@ -84,6 +100,15 @@ class AccountMove(models.Model): ondelete="cascade", ) + fiscal_document_ids = fields.One2many( + comodel_name="l10n_br_fiscal.document", + string="Fiscal Documents", + compute="_compute_fiscal_document_ids", + help="""In some rare cases (NFS-e, CT-e...) a single account.move + may have several different fiscal documents related to its account.move.lines. + """, + ) + fiscal_operation_type = fields.Selection( selection=FISCAL_IN_OUT_ALL, related=None, @@ -100,6 +125,15 @@ def _check_fiscal_document_type(self): ) ) + @api.depends("line_ids", "invoice_line_ids") + def _compute_fiscal_document_ids(self): + for move in self: + docs = set() + for line in move.invoice_line_ids: + docs.add(line.document_id.id) + move.fiscal_document_ids = list(docs) + + @api.depends("move_type", "fiscal_operation_id") def _compute_fiscal_operation_type(self): for inv in self: if inv.move_type == "entry": @@ -121,7 +155,7 @@ def _get_amount_lines(self): def _inherits_check(self): """ Overriden to avoid the super method to set the fiscal_document_id - field as required. + field as required (because some account.move may not have any fiscal document). """ with InheritsCheckMuteLogger("odoo.models"): # mute spurious warnings res = super()._inherits_check() @@ -142,6 +176,17 @@ def _inject_shadowed_fields(self, vals_list): if field in vals: vals["fiscal_%s" % (field,)] = vals[field] + def ensure_one_doc(self): + self.ensure_one() + if len(self.fiscal_document_ids) > 1: + raise UserError( + _( + "More than 1 fiscal document!" + "You should open the fiscal view" + "and perform the action on each document!" + ) + ) + @api.model def fields_view_get( self, view_id=None, view_type="form", toolbar=False, submenu=False @@ -281,14 +326,42 @@ def default_get(self, fields_list): @api.model def _move_autocomplete_invoice_lines_create(self, vals_list): + fiscal_document_line_ids = {} + for idx1, move_val in enumerate(vals_list): + if "invoice_line_ids" in move_val: + fiscal_document_line_ids[idx1] = {} + for idx2, line_val in enumerate(move_val["invoice_line_ids"]): + if ( + line_val[0] == 0 + and line_val[1] == 0 + and isinstance(line_val[2], dict) + ): + fiscal_document_line_ids[idx1][idx2] = line_val[2].get( + "fiscal_document_line_id", False + ) + new_vals_list = super( AccountMove, self.with_context(lines_compute_amounts=True) )._move_autocomplete_invoice_lines_create(vals_list) for vals in new_vals_list: if not vals.get("document_type_id"): - vals[ - "fiscal_document_id" - ] = False # self.env.company.fiscal_dummy_id.id + vals["fiscal_document_id"] = False + + for idx1, move_val in enumerate(new_vals_list): + if "line_ids" in move_val: + if fiscal_document_line_ids.get(idx1): + idx2 = 0 + for line_val in move_val["line_ids"]: + if ( + line_val[0] == 0 + and line_val[1] == 0 + and isinstance(line_val[2], dict) + ): + line_val[2][ + "fiscal_document_line_id" + ] = fiscal_document_line_ids[idx1].get(idx2) + idx2 += 1 + return new_vals_list def _move_autocomplete_invoice_lines_values(self): @@ -379,7 +452,6 @@ def _compute_taxes_mapped(self, base_line): icms_origin=base_line.icms_origin, ind_final=base_line.ind_final, ) - return balance_taxes_res def _preprocess_taxes_map(self, taxes_map): @@ -449,41 +521,53 @@ def _onchange_fiscal_operation_id(self): return result def open_fiscal_document(self): - if self.env.context.get("move_type", "") == "out_invoice": - xmlid = "l10n_br_account.fiscal_invoice_out_action" - elif self.env.context.get("move_type", "") == "in_invoice": - xmlid = "l10n_br_account.fiscal_invoice_in_action" + """ + If there is only 1 fiscal document (usual case), open + the fiscal form view for it. + Open the tree view in the case of several fiscal documents. + """ + self.ensure_one() + + # doubt: is this in/out/all action selection relevant? + if self.env.context.get("move_type") == "out_invoice": + xmlid = "l10n_br_fiscal.document_out_action" + elif self.env.context.get("move_type") == "in_invoice": + xmlid = "l10n_br_fiscal.document_in_action" else: - xmlid = "l10n_br_account.fiscal_invoice_all_action" + xmlid = "l10n_br_fiscal.document_all_action" action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) - form_view = [(self.env.ref("l10n_br_account.fiscal_invoice_form").id, "form")] - if "views" in action: - action["views"] = form_view + [ - (state, view) for state, view in action["views"] if view != "form" - ] + + if len(self.fiscal_document_ids) == 1: + form_view = [(self.env.ref("l10n_br_fiscal.document_form").id, "form")] + if "views" in action: + action["views"] = form_view + [ + (state, view) for state, view in action["views"] if view != "form" + ] + else: + action["views"] = form_view + action["res_id"] = self.fiscal_document_ids[0].id else: - action["views"] = form_view - action["res_id"] = self.id + action["domain"] = [("id", "in", self.fiscal_document_ids.ids)] return action def button_draft(self): - for i in self.filtered(lambda d: d.document_type_id): - if i.state_edoc == SITUACAO_EDOC_CANCELADA: - if i.issuer == DOCUMENT_ISSUER_COMPANY: + for move in self.filtered(lambda d: d.document_type_id): + if move.state_edoc == SITUACAO_EDOC_CANCELADA: + if move.issuer == DOCUMENT_ISSUER_COMPANY: raise UserError( _( "You can't set this document number: {} to draft " "because this document is cancelled in SEFAZ" - ).format(i.document_number) + ).format(move.document_number) ) - if i.state_edoc != SITUACAO_EDOC_EM_DIGITACAO: - i.fiscal_document_id.action_document_back2draft() + move.fiscal_document_ids.filtered( + lambda d: d.state_edoc != SITUACAO_EDOC_EM_DIGITACAO + ).action_document_back2draft() return super().button_draft() def action_document_send(self): - invoices = self.filtered(lambda d: d.document_type_id) - if invoices: - invoices.mapped("fiscal_document_id").action_document_send() + for invoice in self.filtered(lambda d: d.document_type_id): + invoice.fiscal_document_ids.action_document_send() # FIXME: na migração para a v14 foi permitido o post antes do envio # para destravar a migração, mas poderia ser cogitado de obrigar a # transmissão antes do post novamente como na v12. @@ -491,23 +575,26 @@ def action_document_send(self): # invoice.move_id.post(invoice=invoice) def action_document_cancel(self): - for i in self.filtered(lambda d: d.document_type_id): - return i.fiscal_document_id.action_document_cancel() + for move in self.filtered(lambda d: d.document_type_id): + move.ensure_one_doc() + return move.fiscal_document_id.action_document_cancel() def action_document_correction(self): - for i in self.filtered(lambda d: d.document_type_id): - return i.fiscal_document_id.action_document_correction() + for move in self.filtered(lambda d: d.document_type_id): + move.ensure_one_doc() + return move.fiscal_document_id.action_document_correction() def action_document_invalidate(self): - for i in self.filtered(lambda d: d.document_type_id): - return i.fiscal_document_id.action_document_invalidate() + for move in self.filtered(lambda d: d.document_type_id): + move.ensure_one_doc() + return move.fiscal_document_id.action_document_invalidate() def action_document_back2draft(self): """Sets fiscal document to draft state and cancel and set to draft the related invoice for both documents remain equivalent state.""" - for i in self.filtered(lambda d: d.document_type_id): - i.button_cancel() - i.button_draft() + for move in self.filtered(lambda d: d.document_type_id): + move.button_cancel() + move.button_draft() def _post(self, soft=True): self.mapped("fiscal_document_id").filtered( @@ -516,15 +603,15 @@ def _post(self, soft=True): return super()._post(soft=soft) def view_xml(self): - self.ensure_one() + self.ensure_one_doc() return self.fiscal_document_id.view_xml() def view_pdf(self): - self.ensure_one() + self.ensure_one_doc() return self.fiscal_document_id.view_pdf() def action_send_email(self): - self.ensure_one() + self.ensure_one_doc() return self.fiscal_document_id.action_send_email() @api.onchange("document_type_id") @@ -628,3 +715,87 @@ def _get_integrity_hash_fields_and_subfields(self): f"line_ids.{subfield}" for subfield in self.env["account.move.line"]._get_integrity_hash_fields() ] + + def button_import_fiscal_document(self): + """ + Import move fields and invoice lines from + the fiscal_document_id record if there is any new line + to import. + You can typically set fiscal_document_id to some l10n_br_fiscal.document + record that was imported previously and import its lines into the + current move. + """ + for move in self: + if move.state != "draft": + raise UserError(_("Cannot import in non draft Account Move!")) + elif ( + move.partner_id + and move.partner_id != move.fiscal_document_id.partner_id + ): + raise UserError(_("Partner mismatch!")) + elif ( + MOVE_TO_OPERATION[move.move_type] + != move.fiscal_document_id.fiscal_operation_type + ): + raise UserError(_("Fiscal Operation Type mismatch!")) + elif move.company_id != move.fiscal_document_id.company_id: + raise UserError(_("Company mismatch!")) + + move_fiscal_lines = set( + move.invoice_line_ids.mapped("fiscal_document_line_id") + ) + fiscal_doc_lines = set(move.fiscal_document_id.fiscal_line_ids) + if move_fiscal_lines == fiscal_doc_lines: + raise UserError(_("No new Fiscal Document Line to import!")) + + self.import_fiscal_document(move.fiscal_document_id, move_id=move.id) + + @api.model + def import_fiscal_document( + self, + fiscal_document, + move_id=None, + move_type="in_invoice", + ): + """ + Import the data from an existing fiscal document into a new + invoice or into an existing invoice. + First it transfers the "shadowed" fields and fill the other + mandatory invoice fields. + The account.move onchanges of these fields are properly + triggered as if the invoice was filled manually. + Then it creates each account.move.line and fill them using + their fiscal_document_id onchange. + """ + if move_id: + move = self.env["account.move"].browse(move_id) + else: + move = self.env["account.move"] + move_form = Form( + move.with_context( + default_move_type=move_type, + account_predictive_bills_disable_prediction=True, + ) + ) + if not move_id or not move.fiscal_document_id: + move_form.invoice_date = fiscal_document.document_date + move_form.date = fiscal_document.document_date + for field in self._shadowed_fields(): + if field in ("company_id", "user_id"): # (readonly fields) + continue + if not move_form._view["fields"].get(field): + continue + setattr(move_form, field, getattr(fiscal_document, field)) + move_form.document_type_id = fiscal_document.document_type_id + move_form.fiscal_document_id = fiscal_document + move_form.fiscal_operation_id = fiscal_document.fiscal_operation_id + + for line in fiscal_document.fiscal_line_ids: + with move_form.invoice_line_ids.new() as line_form: + line_form.cfop_id = ( + line.cfop_id + ) # required if we disable some fiscal tax updates + line_form.fiscal_operation_id = self.fiscal_operation_id + line_form.fiscal_document_line_id = line + move_form.save() + return move_form diff --git a/l10n_br_account/models/account_move_line.py b/l10n_br_account/models/account_move_line.py index b5ec54c4af9f..b9b03da41522 100644 --- a/l10n_br_account/models/account_move_line.py +++ b/l10n_br_account/models/account_move_line.py @@ -16,8 +16,8 @@ # Fields that are related in l10n_br_fiscal.document.line like partner_id or company_id # don't need to be written through the account.move.line write. SHADOWED_FIELDS = [ - "name", "product_id", + "name", "quantity", "price_unit", ] @@ -144,10 +144,26 @@ def _inject_shadowed_fields(self, vals_list): @api.model_create_multi def create(self, vals_list): for values in vals_list: + if values.get("fiscal_document_line_id"): + fiscal_line_data = ( + self.env["l10n_br_fiscal.document.line"] + .browse(values["fiscal_document_line_id"]) + .read(self._shadowed_fields())[0] + ) + for k, v in fiscal_line_data.items(): + if isinstance(v, tuple): # m2o + values[k] = v[0] + else: + values[k] = v + continue + + if values.get("exclude_from_invoice_tab"): + continue + move_id = self.env["account.move"].browse(values["move_id"]) fiscal_doc_id = move_id.fiscal_document_id.id - if not fiscal_doc_id or values.get("exclude_from_invoice_tab"): + if not fiscal_doc_id: continue values.update( @@ -209,7 +225,7 @@ def create(self, vals_list): # of the remaining fiscal document lines with their proper aml. That's why we # remove the useless fiscal document lines here. for line in results: - if not fiscal_doc_id or line.exclude_from_invoice_tab: + if not line.move_id.fiscal_document_id or line.exclude_from_invoice_tab: fiscal_line_to_delete = line.fiscal_document_line_id line.fiscal_document_line_id = False fiscal_line_to_delete.sudo().unlink() @@ -427,6 +443,18 @@ def _get_price_total_and_subtotal_model( return result + @api.onchange("fiscal_document_line_id") + def _onchange_fiscal_document_line_id(self): + if self.fiscal_document_line_id: + for field in self._shadowed_fields(): + value = getattr(self.fiscal_document_line_id, field) + if isinstance(value, tuple): # m2o + setattr(self, field, value[0]) + else: + setattr(self, field, value) + # override the default product uom (set by the onchange): + self.product_uom_id = self.fiscal_document_line_id.uom_id.id + @api.onchange("fiscal_tax_ids") def _onchange_fiscal_tax_ids(self): """Ao alterar o campo fiscal_tax_ids que contém os impostos fiscais, diff --git a/l10n_br_account/tests/__init__.py b/l10n_br_account/tests/__init__.py index 550e57cbc342..7b821ed3cd85 100644 --- a/l10n_br_account/tests/__init__.py +++ b/l10n_br_account/tests/__init__.py @@ -1,6 +1,5 @@ # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from . import common from . import test_account_move_sn from . import test_account_move_lc from . import test_account_taxes diff --git a/l10n_br_account/tests/test_account_move_lc.py b/l10n_br_account/tests/test_account_move_lc.py index f570c3ead27e..7528ae7888f7 100644 --- a/l10n_br_account/tests/test_account_move_lc.py +++ b/l10n_br_account/tests/test_account_move_lc.py @@ -746,3 +746,75 @@ def test_compra_para_revenda(self): # TODO test effect of ind_final? # ver aqui https://github.com/OCA/l10n-brazil/pull/2347#issuecomment-1548345563 + + def test_composite_move(self): + # first we make a few assertions about an existing vendor bill: + self.assertEqual(len(self.move_in_compra_para_revenda.invoice_line_ids), 1) + self.assertEqual(len(self.move_in_compra_para_revenda.line_ids), 10) + self.assertEqual(self.move_in_compra_para_revenda.amount_total, 1050) + + self.assertEqual(len(self.move_in_compra_para_revenda.fiscal_document_ids), 1) + self.assertEqual( + self.move_in_compra_para_revenda.open_fiscal_document()["res_id"], + self.move_in_compra_para_revenda.fiscal_document_id.id, + ) + + # now we create a dumb fiscal document we will import in our vendor bill: + fiscal_doc_to_import = self.env["l10n_br_fiscal.document"].create( + { + "fiscal_operation_id": self.env.ref("l10n_br_fiscal.fo_compras").id, + "document_type_id": self.env.ref("l10n_br_fiscal.document_55").id, + "document_serie": 1, + "document_number": 123, + "issuer": "partner", + "partner_id": self.partner_a.id, + "fiscal_operation_type": "in", + } + ) + + fiscal_doc_line_to_import = self.env["l10n_br_fiscal.document.line"].create( + { + "document_id": fiscal_doc_to_import.id, + "name": "Purchase Test", + "product_id": self.product_a.id, + "fiscal_operation_type": "in", + "fiscal_operation_id": self.env.ref("l10n_br_fiscal.fo_compras").id, + "fiscal_operation_line_id": self.env.ref( + "l10n_br_fiscal.fo_compras_compras" + ).id, + } + ) + fiscal_doc_line_to_import._onchange_product_id_fiscal() + + # let's import it: + self.move_in_compra_para_revenda.fiscal_document_id = fiscal_doc_to_import + self.move_in_compra_para_revenda.button_import_fiscal_document() + + # now a few assertions to check if it has been properly imported: + self.assertEqual(len(self.move_in_compra_para_revenda.invoice_line_ids), 2) + self.assertEqual( + self.move_in_compra_para_revenda.invoice_line_ids[ + 1 + ].fiscal_document_line_id.product_id, + self.product_a, + ) + + self.assertEqual(len(self.move_in_compra_para_revenda.fiscal_document_ids), 2) + self.assertIn( + str(fiscal_doc_to_import.id), + str(self.move_in_compra_para_revenda.open_fiscal_document()["domain"]), + ) + self.assertIn( + str(self.move_in_compra_para_revenda.fiscal_document_id.id), + str(self.move_in_compra_para_revenda.open_fiscal_document()["domain"]), + ) + + invoice_lines = sorted( + self.move_in_compra_para_revenda.invoice_line_ids, key=lambda item: item.id + ) + self.assertEqual( + fiscal_doc_to_import.id, + invoice_lines[1].fiscal_document_line_id.document_id.id, + ) + self.assertEqual(len(self.move_in_compra_para_revenda.line_ids), 11) + self.assertEqual(self.move_in_compra_para_revenda.amount_total, 2100) diff --git a/l10n_br_account/views/account_invoice_view.xml b/l10n_br_account/views/account_invoice_view.xml index c8e15bdde065..f6f570201391 100644 --- a/l10n_br_account/views/account_invoice_view.xml +++ b/l10n_br_account/views/account_invoice_view.xml @@ -147,6 +147,7 @@ widget="many2many_tags" options="{'no_create': True}" /> + @@ -197,6 +198,7 @@ widget="many2many_tags" options="{'no_create': True}" /> + @@ -288,7 +290,6 @@ name="fiscal_price" attrs="{'invisible': [('document_type_id', '=', False)]}" /> - @@ -454,6 +455,13 @@ + + + @@ -470,6 +478,22 @@ + + +