1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_br_edi/models/account_move.py
2024-12-10 09:04:09 +07:00

486 lines
20 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo import models, fields, api, _
from odoo.addons.iap import InsufficientCreditError
from odoo.exceptions import UserError, ValidationError
from odoo.tools import street_split, html2plaintext
FREIGHT_MODEL_SELECTION = [
("CIF", "Freight contracting on behalf of the Sender (CIF)"),
("FOB", "Contracting of freight on behalf of the recipient/sender (FOB)"),
("Thirdparty", "Contracting Freight on behalf of third parties"),
("SenderVehicle", "Own transport on behalf of the sender"),
("ReceiverVehicle", "Own transport on behalf of the recipient"),
("FreeShipping", "Free shipping"),
]
PAYMENT_METHOD_SELECTION = [
("01", "Money"),
("02", "Check"),
("03", "Credit Card"),
("04", "Debit Card"),
("05", "Store Credit"),
("10", "Food voucher"),
("11", "Meal Voucher"),
("12", "Gift certificate"),
("13", "Fuel Voucher"),
("14", "Duplicate Mercantil"),
("15", "Boleto Bancario"),
("16", "Bank Deposit"),
("17", "Instant Payment (PIX)"),
("18", "Bank transfer, Digital Wallet"),
("19", "Loyalty program, Cashback, Virtual Credit"),
("90", "No Payment"),
("99", "Others"),
]
class AccountMove(models.Model):
_inherit = "account.move"
l10n_br_edi_avatax_data = fields.Text(
help="Brazil: technical field that remembers the last tax summary returned by Avatax.", copy=False
)
l10n_br_edi_is_needed = fields.Boolean(
compute="_compute_l10n_br_edi_is_needed",
help="Brazil: technical field to determine if this invoice is eligible to be e-invoiced.",
)
l10n_br_edi_transporter_id = fields.Many2one(
"res.partner",
"Transporter Brazil",
help="Brazil: if you use a transport company, add its company contact here.",
)
l10n_br_edi_freight_model = fields.Selection(
FREIGHT_MODEL_SELECTION,
string="Freight Model",
help="Brazil: used to determine the freight model used on this transaction.",
)
l10n_br_edi_payment_method = fields.Selection(
PAYMENT_METHOD_SELECTION,
string="Payment Method Brazil",
help="Brazil: expected payment method to be used.",
)
l10n_br_access_key = fields.Char(
"Access Key",
copy=False,
help="Brazil: access key associated with the electronic document. Can be used to get invoice information directly from the government.",
)
l10n_br_edi_error = fields.Text(
"Brazil E-Invoice Error",
copy=False,
readonly=True,
help="Brazil: error details for invoices in the 'error' state.",
)
l10n_br_last_edi_status = fields.Selection(
[
("accepted", "Accepted"),
("error", "Error"),
("cancelled", "Cancelled"),
],
string="Brazil E-Invoice Status",
copy=False,
tracking=True,
readonly=True,
help="Brazil: the state of the most recent e-invoicing attempt.",
)
l10n_br_edi_xml_attachment_file = fields.Binary(
string="Brazil E-Invoice XML File",
copy=False,
attachment=True,
help="Brazil: technical field holding the e-invoice XML data for security reasons.",
)
l10n_br_edi_xml_attachment_id = fields.Many2one(
"ir.attachment",
string="Brazil E-Invoice XML",
compute=lambda self: self._compute_linked_attachment_id(
"l10n_br_edi_xml_attachment_id", "l10n_br_edi_xml_attachment_file"
),
depends=["l10n_br_edi_xml_attachment_file"],
help="Brazil: the most recent e-invoice XML returned by Avalara.",
)
l10n_br_edi_last_correction_number = fields.Integer(
"Brazil Correction Number",
readonly=True,
copy=False,
help="Brazil: technical field that holds the latest correction that happened to this invoice",
)
def _l10n_br_call_avatax_taxes(self):
"""Override to store the retrieved Avatax data."""
document_to_response = super()._l10n_br_call_avatax_taxes()
for document, response in document_to_response.items():
document.l10n_br_edi_avatax_data = json.dumps(
{
"header": response.get("header"),
"lines": response.get("lines"),
"summary": response.get("summary"),
}
)
return document_to_response
@api.depends("l10n_br_last_edi_status", "country_code", "company_currency_id", "move_type", "fiscal_position_id")
def _compute_l10n_br_edi_is_needed(self):
for move in self:
move.l10n_br_edi_is_needed = (
not move.l10n_br_last_edi_status
and move.country_code == "BR"
and move.move_type in ("out_invoice", "out_refund")
and move.fiscal_position_id.l10n_br_is_avatax
)
@api.depends("l10n_br_last_edi_status")
def _compute_need_cancel_request(self):
# EXTENDS 'account' to add dependencies
super()._compute_need_cancel_request()
def _need_cancel_request(self):
# EXTENDS 'account'
return super()._need_cancel_request() or self.l10n_br_last_edi_status == "accepted"
def button_request_cancel(self):
# EXTENDS 'account'
if self._need_cancel_request() and self.l10n_br_last_edi_status == "accepted":
return {
"name": _("Fiscal Document Cancellation"),
"type": "ir.actions.act_window",
"view_type": "form",
"view_mode": "form",
"res_model": "l10n_br_edi.invoice.update",
"target": "new",
"context": {"default_move_id": self.id, "default_mode": "cancel"},
}
return super().button_request_cancel()
def button_draft(self):
# EXTENDS 'account'
self.write(
{
"l10n_br_last_edi_status": False,
"l10n_br_edi_error": False,
"l10n_br_edi_avatax_data": False,
}
)
return super().button_draft()
def button_request_correction(self):
return {
"name": _("Fiscal Document Correction"),
"type": "ir.actions.act_window",
"view_type": "form",
"view_mode": "form",
"res_model": "l10n_br_edi.invoice.update",
"target": "new",
"context": {
"default_move_id": self.id,
"default_mode": "correct",
},
}
def _l10n_br_iap_submit_invoice_goods(self, transaction):
return self._l10n_br_iap_request("submit_invoice_goods", transaction)
def _l10n_br_iap_cancel_invoice_goods(self, transaction):
return self._l10n_br_iap_request("cancel_invoice_goods", transaction)
def _l10n_br_iap_correct_invoice_goods(self, transaction):
return self._l10n_br_iap_request("correct_invoice_goods", transaction)
def _l10n_br_iap_cancel_range_goods(self, transaction, company):
return self._l10n_br_iap_request("cancel_range_goods", transaction, company=company)
def _l10n_br_edi_check_calculated_tax(self):
if not self.l10n_br_edi_avatax_data:
return [_('Tax has never been calculated on this invoice, please "Reset to Draft" and re-post.')]
return []
def _l10n_br_edi_get_xml_attachment_name(self):
return f"{self.name}_edi.xml"
def _l10n_br_edi_set_successful_status(self):
"""Can be overridden for invoices that are processed asynchronously."""
self.l10n_br_last_edi_status = "accepted"
def _l10n_br_edi_attachments_from_response(self, response):
# Unset old ones because otherwise `_compute_linked_attachment_id()` will set the oldest
# attachment, not this new one.
self.invoice_pdf_report_id.res_field = False
self.l10n_br_edi_xml_attachment_id.res_field = False
# Creating the e-invoice PDF like this prevents the standard invoice PDF from being generated.
invoice_pdf = self.env["ir.attachment"].create(
{
"res_model": "account.move",
"res_id": self.id,
"res_field": "invoice_pdf_report_file",
"name": self._get_invoice_report_filename(),
"datas": response["pdf"]["base64"],
}
)
# make sure latest PDF shows to the right of the chatter
invoice_pdf.register_as_main_attachment(force=True)
invoice_xml = self.env["ir.attachment"].create(
{
"res_model": "account.move",
"res_id": self.id,
"res_field": "l10n_br_edi_xml_attachment_file",
"name": self._l10n_br_edi_get_xml_attachment_name(),
"datas": response["xml"]["base64"],
}
)
self.invalidate_recordset(
fnames=[
"invoice_pdf_report_id",
"invoice_pdf_report_file",
"l10n_br_edi_xml_attachment_id",
"l10n_br_edi_xml_attachment_file",
]
)
return invoice_pdf | invoice_xml
def _l10n_br_edi_send(self):
"""Sends the e-invoice and returns an array of error strings."""
for invoice in self:
payload, validation_errors = invoice._l10n_br_prepare_invoice_payload()
if validation_errors:
return validation_errors
else:
response, api_error = self._l10n_br_submit_invoice(invoice, payload)
if api_error:
invoice.l10n_br_last_edi_status = "error"
return [api_error]
else:
invoice._l10n_br_edi_set_successful_status()
invoice.l10n_br_access_key = response["key"]
invoice.with_context(no_new_invoice=True).message_post(
body=_("E-invoice submitted successfully."),
attachment_ids=invoice._l10n_br_edi_attachments_from_response(response).ids,
)
# Now that the invoice is submitted and accepted we no longer need the saved tax computation data.
invoice.l10n_br_edi_avatax_data = False
def _l10n_br_edi_vat_for_api(self, vat):
# Typically users enter the VAT as e.g. "xx.xxx.xxx/xxxx-xx", but the API errors on non-digit characters
return "".join(c for c in vat or "" if c.isdigit())
def _l10n_br_edi_get_goods_values(self):
"""Returns the appropriate (finNFe, goal) tuple for the goods section in the header."""
if self.debit_origin_id:
return 2, "Complementary"
elif self.move_type == "out_refund":
return 4, "Return"
else:
return 1, "Normal"
def _l10n_br_edi_get_invoice_refs(self):
"""For credit and debit notes this returns the appropriate reference to the original invoice. For tax
calculation we send these references as documentCode, which are Odoo references (e.g. account.move_31).
For EDI the government requires these references as refNFe instead. They should contain the access key
assigned when the original invoice was e-invoiced. Returns a (dict, errors) tuple."""
if origin := self._l10n_br_get_origin_invoice():
if not origin.l10n_br_access_key:
return {}, (
_(
"The originating invoice (%s) must have an access key before electronically invoicing %s. The access key can be set manually or by electronically invoicing %s.",
origin.display_name,
self.display_name,
origin.display_name,
)
)
return self._l10n_br_invoice_refs_for_code("refNFe", origin.l10n_br_access_key), None
return {}, None
def _l10n_br_edi_get_tax_data(self):
"""Due to Avalara bugs they're unable to resolve we have to change their tax calculation response before
sending it back to them. This returns a tuple with what to include in the request ("lines" and "summary")
and the header (separate because it shouldn't be included)."""
# These return errors when null in /v3/invoices
keys_to_remove_when_null = ("ruleId", "ruleCode")
tax_calculation_response = json.loads(self.l10n_br_edi_avatax_data)
for line in tax_calculation_response.get("lines", []):
for detail in line.get("taxDetails", []):
for key in keys_to_remove_when_null:
if key in detail and detail[key] is None:
del detail[key]
return tax_calculation_response, tax_calculation_response.pop("header")
def _l10n_br_edi_validate_partner(self, partner):
required_fields = ("street", "street2", "zip", "vat")
errors = []
if not partner:
return []
for field in required_fields:
if not partner[field]:
errors.append(
_(
"%s on partner %s is required for e-invoicing",
partner._fields[field].string,
partner.display_name,
)
)
return errors
def _l10n_br_prepare_payment_mode(self):
payment_value = False
if self.l10n_br_edi_payment_method != "90": # if different from no payment
payment_value = self.amount_total
payment_mode = {
"mode": self.l10n_br_edi_payment_method,
"value": payment_value,
}
if self.l10n_br_edi_payment_method == "99":
payment_mode["modeDescription"] = _("Other")
card_methods = {"03", "04", "10", "11", "12", "13", "15", "17", "18"}
if self.l10n_br_edi_payment_method in card_methods:
payment_mode["cardTpIntegration"] = "2"
return payment_mode
def _l10n_br_get_location_dict(self, partner):
street_data = street_split(partner.street)
return {
"name": partner.name,
"businessName": partner.name,
"type": self._l10n_br_get_partner_type(partner),
"federalTaxId": partner.vat,
"cityTaxId": partner.l10n_br_im_code,
"stateTaxId": partner.l10n_br_ie_code,
"suframa": partner.l10n_br_isuf_code,
"address": {
"neighborhood": partner.street2,
"street": street_data["street_name"],
"zipcode": partner.zip,
"cityName": partner.city,
"state": partner.state_id.code,
"countryCode": partner.country_id.l10n_br_edi_code,
"number": street_data["street_number"],
"phone": partner.phone,
"email": partner.email,
},
}
def _l10n_br_prepare_invoice_payload(self):
def deep_update(d, u):
"""Like {}.update but handles nested dicts recursively. Based on https://stackoverflow.com/a/3233356."""
for k, v in u.items():
if isinstance(v, dict):
d[k] = deep_update(d.get(k, {}), v)
else:
d[k] = v
return d
def deep_clean(d):
"""Recursively removes keys with a falsy value in dicts. Based on https://stackoverflow.com/a/48152075."""
cleaned_dict = {}
for k, v in d.items():
if isinstance(v, dict):
v = deep_clean(v)
if v:
cleaned_dict[k] = v
return cleaned_dict or None
errors = []
# Don't raise because it would break account.move.send's async batch mode.
try:
# The /transaction payload requires a superset of the /calculate payload we use for tax calculation.
payload = self._l10n_br_get_calculate_payload()
except (UserError, ValidationError) as e:
payload = {}
errors.append(str(e).replace("- ", ""))
customer = self.partner_id
company_partner = self.company_id.partner_id
transporter = self.l10n_br_edi_transporter_id
is_invoice = self.move_type == "out_invoice"
if self.l10n_br_edi_freight_model == "SenderVehicle":
transporter = self.company_id.partner_id if is_invoice else customer
elif self.l10n_br_edi_freight_model == "ReceiverVehicle":
transporter = customer if is_invoice else self.company_id.partner_id
errors.extend(self._l10n_br_edi_check_calculated_tax())
errors.extend(self._l10n_br_edi_validate_partner(customer))
errors.extend(self._l10n_br_edi_validate_partner(company_partner))
errors.extend(self._l10n_br_edi_validate_partner(transporter))
invoice_refs, error = self._l10n_br_edi_get_invoice_refs()
if error:
errors.append(error)
goods_nfe, goods_goal = self._l10n_br_edi_get_goods_values()
tax_data_to_include, tax_data_header = self._l10n_br_edi_get_tax_data()
extra_payload = {
"header": {
"companyLocation": self._l10n_br_edi_vat_for_api(company_partner.vat),
**invoice_refs,
"locations": {
"entity": self._l10n_br_get_location_dict(customer),
"establishment": self._l10n_br_get_location_dict(company_partner),
"transporter": self._l10n_br_get_location_dict(transporter),
},
"payment": {
"paymentInfo": {
"paymentMode": [
self._l10n_br_prepare_payment_mode(),
],
},
},
"invoiceNumber": self.l10n_latam_document_number,
"invoiceSerial": self.journal_id.l10n_br_invoice_serial,
"goods": {
"model": self.l10n_latam_document_type_id.code,
"class": tax_data_header.get("goods", {}).get("class"),
"tplmp": "4", # DANFe NFC-e
"goal": goods_goal,
"finNFe": goods_nfe,
"transport": {
"modFreight": self.l10n_br_edi_freight_model,
},
},
"additionalInfo": {
"complementaryInfo": self.narration and html2plaintext(self.narration), # html2plaintext turns False into "False"
},
"shippingDate": fields.Date.to_string(self.delivery_date),
},
}
# extra_payload is cleaned before it's used to avoid e.g. "cityName": False or "number": "". These make
# Avatax return various errors: e.g. "Falha na estrutura enviada". This is to avoid having lots of if
# statements.
deep_update(payload, deep_clean(extra_payload))
# This adds the "lines" and "summary" dicts received during tax calculation.
payload.update(tax_data_to_include)
return payload, errors
def _l10n_br_get_error_from_response(self, response):
if error := response.get("error"):
return f"Code {error['code']}: {error['message']}"
def _l10n_br_submit_invoice(self, invoice, payload):
try:
response = invoice._l10n_br_iap_submit_invoice_goods(payload)
return response, self._l10n_br_get_error_from_response(response)
except (UserError, InsufficientCreditError) as e:
# These exceptions can be thrown by iap_jsonrpc()
return None, str(e)