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 @@
+
+
+
+
+
-
-
-
+
+
diff --git a/l10n_br_fiscal/__manifest__.py b/l10n_br_fiscal/__manifest__.py
index c51116d8f1a6..f2e732db2099 100644
--- a/l10n_br_fiscal/__manifest__.py
+++ b/l10n_br_fiscal/__manifest__.py
@@ -10,7 +10,7 @@
"maintainers": ["renatonlima"],
"website": "https://github.com/OCA/l10n-brazil",
"development_status": "Production/Stable",
- "version": "14.0.21.11.0",
+ "version": "14.0.21.12.0",
"depends": [
"product",
"l10n_br_base",
diff --git a/l10n_br_fiscal/views/l10n_br_fiscal_menu.xml b/l10n_br_fiscal/views/l10n_br_fiscal_menu.xml
index aaf90d6845f8..e38a9f9e4b58 100644
--- a/l10n_br_fiscal/views/l10n_br_fiscal_menu.xml
+++ b/l10n_br_fiscal/views/l10n_br_fiscal_menu.xml
@@ -80,7 +80,7 @@