mirror of
https://github.com/bringout/oca-edi.git
synced 2026-04-22 03:52:04 +02:00
1009 lines
44 KiB
Python
1009 lines
44 KiB
Python
# Copyright 2016-2022 Akretion France (http://www.akretion.com)
|
|
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
|
|
|
import logging
|
|
|
|
from lxml import etree
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import (
|
|
float_compare,
|
|
float_is_zero,
|
|
float_round,
|
|
html2plaintext,
|
|
is_html_empty,
|
|
)
|
|
from odoo.tools.misc import format_date
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from facturx import generate_from_file, xml_check_xsd
|
|
except ImportError:
|
|
logger.debug("Cannot import facturx")
|
|
|
|
|
|
FACTURX_FILENAME = "factur-x.xml"
|
|
DIRECT_DEBIT_CODES = ("49", "59")
|
|
CREDIT_TRF_CODES = ("30", "31", "42")
|
|
PROFILES_EN_UP = ["en16931", "extended"]
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_name = "account.move"
|
|
_inherit = ["account.move", "base.facturx"]
|
|
|
|
@api.model
|
|
def _cii_add_address_block(self, partner, parent_node, ns):
|
|
address = etree.SubElement(parent_node, ns["ram"] + "PostalTradeAddress")
|
|
if ns["level"] != "minimum":
|
|
if partner.zip:
|
|
address_zip = etree.SubElement(address, ns["ram"] + "PostcodeCode")
|
|
address_zip.text = partner.zip
|
|
if partner.street:
|
|
address_street = etree.SubElement(address, ns["ram"] + "LineOne")
|
|
address_street.text = partner.street
|
|
if partner.street2:
|
|
address_street2 = etree.SubElement(address, ns["ram"] + "LineTwo")
|
|
address_street2.text = partner.street2
|
|
if hasattr(partner, "street3") and partner.street3:
|
|
address_street3 = etree.SubElement(address, ns["ram"] + "LineThree")
|
|
address_street3.text = partner.street3
|
|
if partner.city:
|
|
address_city = etree.SubElement(address, ns["ram"] + "CityName")
|
|
address_city.text = partner.city
|
|
if not partner.country_id:
|
|
raise UserError(
|
|
_(
|
|
"Country is not set on partner '%s'. In the Factur-X "
|
|
"standard, the country is required for buyer and seller."
|
|
)
|
|
% partner.display_name
|
|
)
|
|
address_country = etree.SubElement(address, ns["ram"] + "CountryID")
|
|
address_country.text = partner.country_id.code
|
|
if ns["level"] != "minimum" and partner.state_id:
|
|
address_state = etree.SubElement(
|
|
address, ns["ram"] + "CountrySubDivisionName"
|
|
)
|
|
address_state.text = partner.state_id.name
|
|
|
|
def _cii_trade_contact_department_name(self, partner):
|
|
return False
|
|
|
|
@api.model
|
|
def _cii_add_trade_contact_block(self, partner, parent_node, ns):
|
|
trade_contact = etree.SubElement(parent_node, ns["ram"] + "DefinedTradeContact")
|
|
contact_name = etree.SubElement(trade_contact, ns["ram"] + "PersonName")
|
|
contact_name.text = partner.name
|
|
department = self._cii_trade_contact_department_name(partner)
|
|
if department:
|
|
department_name = etree.SubElement(
|
|
trade_contact, ns["ram"] + "DepartmentName"
|
|
)
|
|
department_name.text = department
|
|
phone = partner.phone or partner.mobile
|
|
if phone:
|
|
phone_node = etree.SubElement(
|
|
trade_contact, ns["ram"] + "TelephoneUniversalCommunication"
|
|
)
|
|
phone_number = etree.SubElement(phone_node, ns["ram"] + "CompleteNumber")
|
|
phone_number.text = phone
|
|
if partner.email:
|
|
email_node = etree.SubElement(
|
|
trade_contact, ns["ram"] + "EmailURIUniversalCommunication"
|
|
)
|
|
email_uriid = etree.SubElement(
|
|
email_node, ns["ram"] + "URIID", schemeID="SMTP"
|
|
)
|
|
email_uriid.text = partner.email
|
|
|
|
@api.model
|
|
def _cii_add_date(
|
|
self, node_name, date_datetime, parent_node, ns, date_ns_type="udt"
|
|
):
|
|
date_node = etree.SubElement(parent_node, ns["ram"] + node_name)
|
|
date_node_str = etree.SubElement(
|
|
date_node, ns[date_ns_type] + "DateTimeString", format="102"
|
|
)
|
|
# 102 = format YYYYMMDD
|
|
date_node_str.text = date_datetime.strftime("%Y%m%d")
|
|
|
|
def _cii_add_document_context_block(self, root, ns):
|
|
self.ensure_one()
|
|
doc_ctx = etree.SubElement(root, ns["rsm"] + "ExchangedDocumentContext")
|
|
ctx_param = etree.SubElement(
|
|
doc_ctx, ns["ram"] + "GuidelineSpecifiedDocumentContextParameter"
|
|
)
|
|
ctx_param_id = etree.SubElement(ctx_param, ns["ram"] + "ID")
|
|
if ns["level"] == "en16931":
|
|
urn = "urn:cen.eu:en16931:2017"
|
|
elif ns["level"] == "basic":
|
|
urn = "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic"
|
|
elif ns["level"] == "extended":
|
|
urn = "urn:cen.eu:en16931:2017#conformant#" "urn:factur-x.eu:1p0:extended"
|
|
else:
|
|
urn = "urn:factur-x.eu:1p0:%s" % ns["level"]
|
|
ctx_param_id.text = urn
|
|
|
|
def _cii_add_header_block(self, root, ns):
|
|
self.ensure_one()
|
|
header_doc = etree.SubElement(root, ns["rsm"] + "ExchangedDocument")
|
|
header_doc_id = etree.SubElement(header_doc, ns["ram"] + "ID")
|
|
if self.state == "posted":
|
|
header_doc_id.text = self.name
|
|
else:
|
|
header_doc_id.text = self._fields["state"].convert_to_export(
|
|
self.state, self
|
|
)
|
|
header_doc_typecode = etree.SubElement(header_doc, ns["ram"] + "TypeCode")
|
|
if self.move_type == "out_invoice":
|
|
header_doc_typecode.text = "380"
|
|
elif self.move_type == "out_refund":
|
|
header_doc_typecode.text = ns["refund_type"]
|
|
# 2 options allowed in Factur-X :
|
|
# a) invoice and refunds -> 380 ; negative amounts if refunds
|
|
# b) invoice -> 380 refunds -> 381, with positive amounts
|
|
# In ZUGFeRD samples, they use option a)
|
|
# For Chorus, they impose option b)
|
|
# Until August 2017, I was using option a), now I use option b)
|
|
# Starting from November 2017, it's a config option !
|
|
invoice_date_dt = self.invoice_date or fields.Date.context_today(self)
|
|
self._cii_add_date("IssueDateTime", invoice_date_dt, header_doc, ns)
|
|
if not is_html_empty(self.narration) and ns["level"] != "minimum":
|
|
note = etree.SubElement(header_doc, ns["ram"] + "IncludedNote")
|
|
content_note = etree.SubElement(note, ns["ram"] + "Content")
|
|
content_note.text = html2plaintext(self.narration)
|
|
|
|
@api.model
|
|
def _cii_get_party_identification(self, commercial_partner):
|
|
"""This method is designed to be inherited in localisation modules
|
|
Should return a dict with key=SchemeName, value=Identifier"""
|
|
return {}
|
|
|
|
@api.model
|
|
def _cii_add_party_identification(self, commercial_partner, parent_node, ns):
|
|
id_dict = self._cii_get_party_identification(commercial_partner)
|
|
if id_dict:
|
|
party_identification = etree.SubElement(
|
|
parent_node, ns["ram"] + "SpecifiedLegalOrganization"
|
|
)
|
|
for scheme_name, party_id_text in id_dict.items():
|
|
party_identification_id = etree.SubElement(
|
|
party_identification, ns["ram"] + "ID", schemeID=scheme_name
|
|
)
|
|
party_identification_id.text = party_id_text
|
|
return
|
|
|
|
def _cii_trade_agreement_buyer_ref(self, partner):
|
|
return False
|
|
|
|
def _cii_add_trade_agreement_block(self, trade_transaction, ns):
|
|
self.ensure_one()
|
|
company = self.company_id
|
|
trade_agreement = etree.SubElement(
|
|
trade_transaction, ns["ram"] + "ApplicableHeaderTradeAgreement"
|
|
)
|
|
buyer_ref = self._cii_trade_agreement_buyer_ref(self.partner_id)
|
|
if buyer_ref:
|
|
buyer_reference = etree.SubElement(
|
|
trade_agreement, ns["ram"] + "BuyerReference"
|
|
)
|
|
buyer_reference.text = buyer_ref
|
|
seller = etree.SubElement(trade_agreement, ns["ram"] + "SellerTradeParty")
|
|
seller_name = etree.SubElement(seller, ns["ram"] + "Name")
|
|
seller_name.text = company.name
|
|
self._cii_add_party_identification(company.partner_id, seller, ns)
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
self._cii_add_trade_contact_block(
|
|
self.invoice_user_id.partner_id or company.partner_id, seller, ns
|
|
)
|
|
self._cii_add_address_block(company.partner_id, seller, ns)
|
|
if company.vat:
|
|
seller_tax_reg = etree.SubElement(
|
|
seller, ns["ram"] + "SpecifiedTaxRegistration"
|
|
)
|
|
seller_tax_reg_id = etree.SubElement(
|
|
seller_tax_reg, ns["ram"] + "ID", schemeID="VA"
|
|
)
|
|
seller_tax_reg_id.text = company.vat
|
|
buyer = etree.SubElement(trade_agreement, ns["ram"] + "BuyerTradeParty")
|
|
if ns["level"] != "minimum" and self.commercial_partner_id.ref:
|
|
buyer_id = etree.SubElement(buyer, ns["ram"] + "ID")
|
|
buyer_id.text = self.commercial_partner_id.ref
|
|
buyer_name = etree.SubElement(buyer, ns["ram"] + "Name")
|
|
buyer_name.text = self.commercial_partner_id.name
|
|
self._cii_add_party_identification(self.commercial_partner_id, buyer, ns)
|
|
if (
|
|
ns["level"] in PROFILES_EN_UP
|
|
and self.commercial_partner_id != self.partner_id
|
|
and self.partner_id.name
|
|
):
|
|
self._cii_add_trade_contact_block(self.partner_id, buyer, ns)
|
|
self._cii_add_address_block(self.partner_id, buyer, ns)
|
|
if self.commercial_partner_id.vat:
|
|
buyer_tax_reg = etree.SubElement(
|
|
buyer, ns["ram"] + "SpecifiedTaxRegistration"
|
|
)
|
|
buyer_tax_reg_id = etree.SubElement(
|
|
buyer_tax_reg, ns["ram"] + "ID", schemeID="VA"
|
|
)
|
|
buyer_tax_reg_id.text = self.commercial_partner_id.vat
|
|
if ns["level"] == "extended" and self.invoice_incoterm_id:
|
|
delivery_terms = etree.SubElement(
|
|
trade_agreement, ns["ram"] + "ApplicableTradeDeliveryTerms"
|
|
)
|
|
delivery_code = etree.SubElement(
|
|
delivery_terms, ns["ram"] + "DeliveryTypeCode"
|
|
)
|
|
delivery_code.text = self.invoice_incoterm_id.code
|
|
self._cii_add_buyer_order_reference(trade_agreement, ns)
|
|
self._cii_add_contract_reference(trade_agreement, ns)
|
|
|
|
def _cii_add_buyer_order_reference(self, trade_agreement, ns):
|
|
self.ensure_one()
|
|
if self.ref:
|
|
buyer_order_ref = etree.SubElement(
|
|
trade_agreement, ns["ram"] + "BuyerOrderReferencedDocument"
|
|
)
|
|
buyer_order_id = etree.SubElement(
|
|
buyer_order_ref, ns["ram"] + "IssuerAssignedID"
|
|
)
|
|
buyer_order_id.text = self.ref
|
|
|
|
def _cii_add_contract_reference(self, trade_agreement, ns):
|
|
self.ensure_one()
|
|
contract_code = self._get_contract_code()
|
|
if ns["level"] != "minimum" and contract_code:
|
|
contract_ref = etree.SubElement(
|
|
trade_agreement, ns["ram"] + "ContractReferencedDocument"
|
|
)
|
|
contract_id = etree.SubElement(contract_ref, ns["ram"] + "IssuerAssignedID")
|
|
contract_id.text = contract_code
|
|
|
|
def _get_contract_code(self):
|
|
"""This method is designed to be inherited
|
|
There are so many different ways to handle a contract in Odoo!
|
|
So it's difficult to have a common datamodel for it"""
|
|
return False
|
|
|
|
def _cii_add_trade_delivery_block(self, trade_transaction, ns):
|
|
self.ensure_one()
|
|
trade_agreement = etree.SubElement(
|
|
trade_transaction, ns["ram"] + "ApplicableHeaderTradeDelivery"
|
|
)
|
|
# partner_shipping_id is provided by the sale module
|
|
if (
|
|
ns["level"] in PROFILES_EN_UP
|
|
and hasattr(self, "partner_shipping_id")
|
|
and self.partner_shipping_id
|
|
):
|
|
shipto_trade_party = etree.SubElement(
|
|
trade_agreement, ns["ram"] + "ShipToTradeParty"
|
|
)
|
|
self._cii_add_address_block(
|
|
self.partner_shipping_id, shipto_trade_party, ns
|
|
)
|
|
return trade_agreement
|
|
|
|
def _cii_add_trade_settlement_payment_means_block(self, trade_settlement, ns):
|
|
payment_means = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "SpecifiedTradeSettlementPaymentMeans"
|
|
)
|
|
payment_means_code = etree.SubElement(payment_means, ns["ram"] + "TypeCode")
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
payment_means_info = etree.SubElement(
|
|
payment_means, ns["ram"] + "Information"
|
|
)
|
|
if self.payment_mode_id:
|
|
payment_means_code.text = self.payment_mode_id.payment_method_id.unece_code
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
payment_means_info.text = (
|
|
self.payment_mode_id.note or self.payment_mode_id.name
|
|
)
|
|
else:
|
|
payment_means_code.text = "30" # use 30 and not 31,
|
|
# for wire transfer, according to Factur-X CIUS
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
payment_means_info.text = _("Wire transfer")
|
|
logger.warning(
|
|
"Missing payment mode on invoice ID %d. "
|
|
"Using 30 (wire transfer) as UNECE code as fallback "
|
|
"for payment mean",
|
|
self.id,
|
|
)
|
|
if payment_means_code.text in CREDIT_TRF_CODES:
|
|
partner_bank = self.partner_bank_id
|
|
if (
|
|
not partner_bank
|
|
and self.payment_mode_id
|
|
and self.payment_mode_id.bank_account_link == "fixed"
|
|
and self.payment_mode_id.fixed_journal_id
|
|
):
|
|
partner_bank = self.payment_mode_id.fixed_journal_id.bank_account_id
|
|
if partner_bank and partner_bank.acc_type == "iban":
|
|
payment_means_bank_account = etree.SubElement(
|
|
payment_means, ns["ram"] + "PayeePartyCreditorFinancialAccount"
|
|
)
|
|
iban = etree.SubElement(
|
|
payment_means_bank_account, ns["ram"] + "IBANID"
|
|
)
|
|
iban.text = partner_bank.sanitized_acc_number
|
|
if ns["level"] in PROFILES_EN_UP and partner_bank.bank_bic:
|
|
payment_means_bank = etree.SubElement(
|
|
payment_means,
|
|
ns["ram"] + "PayeeSpecifiedCreditorFinancialInstitution",
|
|
)
|
|
payment_means_bic = etree.SubElement(
|
|
payment_means_bank, ns["ram"] + "BICID"
|
|
)
|
|
payment_means_bic.text = partner_bank.bank_bic
|
|
# Field mandate_id provided by the OCA module account_banking_mandate
|
|
elif (
|
|
payment_means_code.text in DIRECT_DEBIT_CODES
|
|
and hasattr(self, "mandate_id")
|
|
and self.mandate_id.partner_bank_id
|
|
and self.mandate_id.partner_bank_id.acc_type == "iban"
|
|
and self.mandate_id.partner_bank_id.sanitized_acc_number
|
|
):
|
|
debtor_acc = etree.SubElement(
|
|
payment_means, ns["ram"] + "PayerPartyDebtorFinancialAccount"
|
|
)
|
|
debtor_acc_iban = etree.SubElement(debtor_acc, ns["ram"] + "IBANID")
|
|
debtor_acc_iban.text = self.mandate_id.partner_bank_id.sanitized_acc_number
|
|
|
|
def _cii_trade_payment_terms_block(self, trade_settlement, ns):
|
|
trade_payment_term = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "SpecifiedTradePaymentTerms"
|
|
)
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
trade_payment_term_desc = etree.SubElement(
|
|
trade_payment_term, ns["ram"] + "Description"
|
|
)
|
|
# The 'Description' field of SpecifiedTradePaymentTerms
|
|
# is a required field, so we must always give a value
|
|
if self.invoice_payment_term_id:
|
|
trade_payment_term_desc.text = self.invoice_payment_term_id.name
|
|
else:
|
|
trade_payment_term_desc.text = _("No specific payment term selected")
|
|
|
|
if self.invoice_date_due:
|
|
self._cii_add_date(
|
|
"DueDateDateTime", self.invoice_date_due, trade_payment_term, ns
|
|
)
|
|
|
|
# Direct debit Mandate
|
|
if (
|
|
self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
|
|
and hasattr(self, "mandate_id")
|
|
and self.mandate_id.unique_mandate_reference
|
|
):
|
|
mandate = etree.SubElement(
|
|
trade_payment_term, ns["ram"] + "DirectDebitMandateID"
|
|
)
|
|
mandate.text = self.mandate_id.unique_mandate_reference
|
|
|
|
def _cii_check_tax_required_info(self, tax_dict):
|
|
if not tax_dict:
|
|
# Hack when there is NO tax at all
|
|
# ApplicableTradeTax is a required field, both on line and total
|
|
tax_dict.update(
|
|
{
|
|
"unece_type_code": "VAT",
|
|
"unece_categ_code": "E",
|
|
"amount": 0,
|
|
"amount_type": "percent",
|
|
"display_name": "Empty virtual tax",
|
|
}
|
|
)
|
|
if not tax_dict["unece_type_code"]:
|
|
raise UserError(
|
|
_("Missing UNECE Tax Type on tax '%s'") % tax_dict["display_name"]
|
|
)
|
|
if not tax_dict["unece_categ_code"]:
|
|
raise UserError(
|
|
_("Missing UNECE Tax Category on tax '%s'") % tax_dict["display_name"]
|
|
)
|
|
|
|
def _cii_line_applicable_trade_tax_block(
|
|
self, tax_recordset, parent_node, ns, allowance=False
|
|
):
|
|
tax = {}
|
|
if tax_recordset:
|
|
tax = ns["tax_speeddict"][tax_recordset.id]
|
|
self._cii_check_tax_required_info(tax)
|
|
if allowance:
|
|
node_name = "CategoryTradeTax"
|
|
else:
|
|
node_name = "ApplicableTradeTax"
|
|
trade_tax = etree.SubElement(parent_node, ns["ram"] + node_name)
|
|
trade_tax_typecode = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
|
|
trade_tax_typecode.text = tax["unece_type_code"]
|
|
trade_tax_categcode = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
|
|
trade_tax_categcode.text = tax["unece_categ_code"]
|
|
# No 'DueDateTypeCode' on lines
|
|
if tax.get("amount_type") == "percent":
|
|
trade_tax_percent = etree.SubElement(
|
|
trade_tax, ns["ram"] + "RateApplicablePercent"
|
|
)
|
|
trade_tax_percent.text = "%0.*f" % (2, tax["amount"])
|
|
|
|
def _cii_total_applicable_trade_tax_block(
|
|
self, tax_recordset, tax_amount, base_amount, parent_node, ns
|
|
):
|
|
if ns["level"] == "minimum":
|
|
return
|
|
tax = {}
|
|
if tax_recordset:
|
|
tax = ns["tax_speeddict"][tax_recordset.id]
|
|
self._cii_check_tax_required_info(tax)
|
|
trade_tax = etree.SubElement(parent_node, ns["ram"] + "ApplicableTradeTax")
|
|
amount = etree.SubElement(trade_tax, ns["ram"] + "CalculatedAmount")
|
|
amount.text = "%0.*f" % (ns["cur_prec"], tax_amount * ns["sign"])
|
|
tax_type = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
|
|
tax_type.text = tax["unece_type_code"]
|
|
|
|
if (
|
|
tax["unece_categ_code"] != "S"
|
|
and float_is_zero(tax_amount, precision_digits=ns["cur_prec"])
|
|
and self.fiscal_position_id
|
|
and ns["fp_speeddict"][self.fiscal_position_id.id]["note"]
|
|
):
|
|
exemption_reason = etree.SubElement(
|
|
trade_tax, ns["ram"] + "ExemptionReason"
|
|
)
|
|
exemption_reason.text = ns["fp_speeddict"][self.fiscal_position_id.id][
|
|
"note"
|
|
]
|
|
|
|
base = etree.SubElement(trade_tax, ns["ram"] + "BasisAmount")
|
|
base.text = "%0.*f" % (ns["cur_prec"], base_amount * ns["sign"])
|
|
tax_categ_code = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
|
|
tax_categ_code.text = tax["unece_categ_code"]
|
|
due_date_type_code = self._get_unece_due_date_type_code() or tax.get(
|
|
"unece_due_date_code"
|
|
)
|
|
if due_date_type_code:
|
|
trade_tax_due_date = etree.SubElement(
|
|
trade_tax, ns["ram"] + "DueDateTypeCode"
|
|
)
|
|
trade_tax_due_date.text = due_date_type_code
|
|
# Field tax_exigibility is not required, so no error if missing
|
|
if tax.get("amount_type") == "percent":
|
|
percent = etree.SubElement(trade_tax, ns["ram"] + "RateApplicablePercent")
|
|
percent.text = "%0.*f" % (2, tax["amount"])
|
|
|
|
def _cii_add_trade_settlement_block(self, trade_transaction, allowance_ilines, ns):
|
|
self.ensure_one()
|
|
trade_settlement = etree.SubElement(
|
|
trade_transaction, ns["ram"] + "ApplicableHeaderTradeSettlement"
|
|
)
|
|
# ICS, provided by the OCA module account_banking_sepa_direct_debit
|
|
if (
|
|
ns["level"] != "minimum"
|
|
and self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
|
|
and hasattr(self.company_id, "sepa_creditor_identifier")
|
|
and self.company_id.sepa_creditor_identifier
|
|
):
|
|
ics = etree.SubElement(trade_settlement, ns["ram"] + "CreditorReferenceID")
|
|
ics.text = self.company_id.sepa_creditor_identifier
|
|
|
|
if ns["level"] != "minimum":
|
|
payment_ref = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "PaymentReference"
|
|
)
|
|
payment_ref.text = self.name or self.state
|
|
invoice_currency = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "InvoiceCurrencyCode"
|
|
)
|
|
invoice_currency.text = ns["currency"]
|
|
if (
|
|
self.payment_mode_id
|
|
and not self.payment_mode_id.payment_method_id.unece_code
|
|
):
|
|
raise UserError(
|
|
_("Missing UNECE code on payment method '%s'")
|
|
% self.payment_mode_id.payment_method_id.display_name
|
|
)
|
|
if ns["level"] != "minimum" and not (
|
|
self.move_type == "out_refund"
|
|
and self.payment_mode_id
|
|
and self.payment_mode_id.payment_method_id.unece_code in CREDIT_TRF_CODES
|
|
):
|
|
self._cii_add_trade_settlement_payment_means_block(trade_settlement, ns)
|
|
|
|
at_least_one_tax = False
|
|
# move_type == 'out_invoice': tline.amount_currency < 0
|
|
# move_type == 'out_refund': tline.amount_currency > 0
|
|
tax_amount_sign = self.move_type == "out_invoice" and -1 or 1
|
|
for tline in self.line_ids.filtered(lambda x: x.tax_line_id):
|
|
tax_base_amount = tline.tax_base_amount
|
|
tax_amount = tline.amount_currency * tax_amount_sign
|
|
self._cii_total_applicable_trade_tax_block(
|
|
tline.tax_line_id,
|
|
tax_amount,
|
|
tax_base_amount,
|
|
trade_settlement,
|
|
ns,
|
|
)
|
|
at_least_one_tax = True
|
|
tax_zero_amount = {} # key = tax recordset, value = base
|
|
for line in self.line_ids:
|
|
for tax in line.tax_ids.filtered(
|
|
lambda t: float_is_zero(t.amount, precision_digits=ns["cur_prec"])
|
|
):
|
|
tax_zero_amount.setdefault(tax, 0.0)
|
|
tax_zero_amount[tax] += line.price_subtotal
|
|
for tax, tax_base_amount in tax_zero_amount.items():
|
|
self._cii_total_applicable_trade_tax_block(
|
|
tax, 0, tax_base_amount, trade_settlement, ns
|
|
)
|
|
at_least_one_tax = True
|
|
|
|
if not at_least_one_tax:
|
|
self._cii_total_applicable_trade_tax_block(None, 0, 0, trade_settlement, ns)
|
|
|
|
# Global Allowance lines = invoice lines with negative price
|
|
for allowance_iline in allowance_ilines:
|
|
self._cii_allowance_line(allowance_iline, trade_settlement, ns)
|
|
|
|
if ns["level"] != "minimum":
|
|
self._cii_trade_payment_terms_block(trade_settlement, ns)
|
|
|
|
self._cii_monetary_summation_block(trade_settlement, ns)
|
|
# When you create a full refund from an invoice, Odoo will
|
|
# set the field reversed_entry_id
|
|
if self.reversed_entry_id and self.reversed_entry_id.state == "posted":
|
|
inv_ref_doc = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "InvoiceReferencedDocument"
|
|
)
|
|
inv_ref_doc_num = etree.SubElement(
|
|
inv_ref_doc, ns["ram"] + "IssuerAssignedID"
|
|
)
|
|
inv_ref_doc_num.text = self.reversed_entry_id.name
|
|
self._cii_add_date(
|
|
"FormattedIssueDateTime",
|
|
self.reversed_entry_id.invoice_date,
|
|
inv_ref_doc,
|
|
ns,
|
|
date_ns_type="qdt",
|
|
)
|
|
|
|
def _cii_allowance_line(self, iline, trade_settlement, ns):
|
|
allowance_line = etree.SubElement(
|
|
trade_settlement, ns["ram"] + "SpecifiedTradeAllowanceCharge"
|
|
)
|
|
charge_indic = etree.SubElement(allowance_line, ns["ram"] + "ChargeIndicator")
|
|
indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
|
|
indicator.text = "false"
|
|
if not float_is_zero(iline.discount, ns["disc_prec"]):
|
|
calculation_percent = etree.SubElement(
|
|
allowance_line, ns["ram"] + "CalculationPercent"
|
|
)
|
|
calculation_percent.text = "%0.*f" % (ns["disc_prec"], iline.discount)
|
|
basis_amount = etree.SubElement(allowance_line, ns["ram"] + "BasisAmount")
|
|
basis_amount.text = "%0.*f" % (
|
|
ns["price_prec"],
|
|
iline.price_unit * iline.quantity * -1,
|
|
)
|
|
|
|
actual_amount = iline.price_subtotal * -1
|
|
ns["allowance_total_amount"] += actual_amount
|
|
actual_amount_node = etree.SubElement(
|
|
allowance_line, ns["ram"] + "ActualAmount"
|
|
)
|
|
actual_amount_node.text = "%0.*f" % (ns["cur_prec"], actual_amount)
|
|
|
|
reason = etree.SubElement(allowance_line, ns["ram"] + "Reason")
|
|
reason.text = (
|
|
iline.name
|
|
or (iline.product_id and iline.product_id.display_name)
|
|
or _("Discount")
|
|
)
|
|
self._cii_invoice_line_taxes(iline, allowance_line, ns, allowance=True)
|
|
|
|
def _cii_monetary_summation_block(self, trade_settlement, ns):
|
|
sums = etree.SubElement(
|
|
trade_settlement,
|
|
ns["ram"] + "SpecifiedTradeSettlementHeaderMonetarySummation",
|
|
)
|
|
if ns["level"] != "minimum":
|
|
line_total = etree.SubElement(sums, ns["ram"] + "LineTotalAmount")
|
|
line_total.text = "%0.*f" % (
|
|
ns["cur_prec"],
|
|
(self.amount_untaxed - ns["allowance_total_amount"]) * ns["sign"],
|
|
)
|
|
# We don't want to generate charge total, because we don't have the
|
|
# notion of charge in Odoo. We only support allowance:
|
|
# an allowance is an invoice line with a negative price
|
|
# Warning: the allowance amount is positive (but has negative meaning)
|
|
if not self.currency_id.is_zero(ns["allowance_total_amount"]):
|
|
allowance_total = etree.SubElement(
|
|
sums, ns["ram"] + "AllowanceTotalAmount"
|
|
)
|
|
allowance_total.text = "%0.*f" % (
|
|
ns["cur_prec"],
|
|
ns["allowance_total_amount"],
|
|
)
|
|
tax_basis_total_amt = etree.SubElement(sums, ns["ram"] + "TaxBasisTotalAmount")
|
|
tax_basis_total_amt.text = "%0.*f" % (
|
|
ns["cur_prec"],
|
|
self.amount_untaxed * ns["sign"],
|
|
)
|
|
tax_total = etree.SubElement(
|
|
sums, ns["ram"] + "TaxTotalAmount", currencyID=ns["currency"]
|
|
)
|
|
tax_total.text = "%0.*f" % (ns["cur_prec"], self.amount_tax * ns["sign"])
|
|
total = etree.SubElement(sums, ns["ram"] + "GrandTotalAmount")
|
|
total.text = "%0.*f" % (ns["cur_prec"], self.amount_total * ns["sign"])
|
|
if ns["level"] != "minimum":
|
|
prepaid = etree.SubElement(sums, ns["ram"] + "TotalPrepaidAmount")
|
|
prepaid.text = "%0.*f" % (
|
|
ns["cur_prec"],
|
|
(self.amount_total - self.amount_residual) * ns["sign"],
|
|
)
|
|
residual = etree.SubElement(sums, ns["ram"] + "DuePayableAmount")
|
|
residual.text = "%0.*f" % (ns["cur_prec"], self.amount_residual * ns["sign"])
|
|
|
|
def _set_iline_product_information(self, iline, trade_product, ns):
|
|
if iline.product_id:
|
|
if iline.product_id.barcode:
|
|
barcode = etree.SubElement(
|
|
trade_product, ns["ram"] + "GlobalID", schemeID="0160"
|
|
)
|
|
# 0160 = GS1 Global Trade Item Number (GTIN, EAN)
|
|
barcode.text = iline.product_id.barcode
|
|
if ns["level"] in PROFILES_EN_UP and iline.product_id.default_code:
|
|
product_code = etree.SubElement(
|
|
trade_product, ns["ram"] + "SellerAssignedID"
|
|
)
|
|
product_code.text = iline.product_id.default_code
|
|
product_name = etree.SubElement(trade_product, ns["ram"] + "Name")
|
|
product_name.text = iline.name or _("No invoice line label")
|
|
if (
|
|
ns["level"] in PROFILES_EN_UP
|
|
and iline.product_id
|
|
and iline.product_id.description_sale
|
|
):
|
|
product_desc = etree.SubElement(trade_product, ns["ram"] + "Description")
|
|
product_desc.text = iline.product_id.description_sale
|
|
|
|
def _set_iline_product_attributes(self, iline, trade_product, ns):
|
|
if iline.product_id and ns["level"] in PROFILES_EN_UP:
|
|
product = iline.product_id
|
|
for attrib_val in product.product_template_attribute_value_ids:
|
|
attrib_value_rec = attrib_val.product_attribute_value_id
|
|
attrib_value = attrib_value_rec.name
|
|
attribute_name = attrib_value_rec.attribute_id.name
|
|
product_charact = etree.SubElement(
|
|
trade_product, ns["ram"] + "ApplicableProductCharacteristic"
|
|
)
|
|
product_charact_desc = etree.SubElement(
|
|
product_charact, ns["ram"] + "Description"
|
|
)
|
|
product_charact_desc.text = attribute_name
|
|
product_charact_value = etree.SubElement(
|
|
product_charact, ns["ram"] + "Value"
|
|
)
|
|
product_charact_value.text = attrib_value
|
|
if hasattr(product, "hs_code_id") and product.type in ("product", "consu"):
|
|
hs_code = product.get_hs_code_recursively()
|
|
if hs_code:
|
|
product_classification = etree.SubElement(
|
|
trade_product, ns["ram"] + "DesignatedProductClassification"
|
|
)
|
|
product_classification_code = etree.SubElement(
|
|
product_classification, ns["ram"] + "ClassCode", listID="HS"
|
|
)
|
|
product_classification_code.text = hs_code.local_code
|
|
# origin_country_id and hs_code_id are provided
|
|
# by the OCA module product_harmonized_system
|
|
if (
|
|
hasattr(product, "origin_country_id")
|
|
and product.type in ("product", "consu")
|
|
and product.origin_country_id
|
|
):
|
|
origin_trade_country = etree.SubElement(
|
|
trade_product, ns["ram"] + "OriginTradeCountry"
|
|
)
|
|
origin_trade_country_code = etree.SubElement(
|
|
origin_trade_country, ns["ram"] + "ID"
|
|
)
|
|
origin_trade_country_code.text = product.origin_country_id.code
|
|
|
|
def _cii_add_invoice_line_block(self, trade_transaction, iline, line_number, ns):
|
|
self.ensure_one()
|
|
line_item = etree.SubElement(
|
|
trade_transaction, ns["ram"] + "IncludedSupplyChainTradeLineItem"
|
|
)
|
|
line_doc = etree.SubElement(
|
|
line_item, ns["ram"] + "AssociatedDocumentLineDocument"
|
|
)
|
|
etree.SubElement(line_doc, ns["ram"] + "LineID").text = str(line_number)
|
|
|
|
trade_product = etree.SubElement(line_item, ns["ram"] + "SpecifiedTradeProduct")
|
|
self._set_iline_product_information(iline, trade_product, ns)
|
|
self._set_iline_product_attributes(iline, trade_product, ns)
|
|
line_trade_agreement = etree.SubElement(
|
|
line_item, ns["ram"] + "SpecifiedLineTradeAgreement"
|
|
)
|
|
# convert gross price_unit to tax_excluded value
|
|
taxres = iline.tax_ids.compute_all(iline.price_unit)
|
|
gross_price_val = float_round(
|
|
taxres["total_excluded"], precision_digits=ns["price_prec"]
|
|
)
|
|
# Use oline.price_subtotal/qty to compute net unit price to be sure
|
|
# to get a *tax_excluded* net unit price
|
|
if float_is_zero(iline.quantity, precision_digits=ns["qty_prec"]):
|
|
net_price_val = 0.0
|
|
else:
|
|
net_price_val = float_round(
|
|
iline.price_subtotal / float(iline.quantity),
|
|
precision_digits=ns["price_prec"],
|
|
)
|
|
if ns["level"] in PROFILES_EN_UP:
|
|
gross_price = etree.SubElement(
|
|
line_trade_agreement, ns["ram"] + "GrossPriceProductTradePrice"
|
|
)
|
|
gross_price_amount = etree.SubElement(
|
|
gross_price, ns["ram"] + "ChargeAmount"
|
|
)
|
|
gross_price_amount.text = "%0.*f" % (ns["price_prec"], gross_price_val)
|
|
fc_discount = float_compare(
|
|
iline.discount, 0.0, precision_digits=ns["disc_prec"]
|
|
)
|
|
if fc_discount in [-1, 1]:
|
|
trade_allowance = etree.SubElement(
|
|
gross_price, ns["ram"] + "AppliedTradeAllowanceCharge"
|
|
)
|
|
charge_indic = etree.SubElement(
|
|
trade_allowance, ns["ram"] + "ChargeIndicator"
|
|
)
|
|
indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
|
|
if fc_discount == 1:
|
|
indicator.text = "false"
|
|
ac_sign = 1
|
|
else:
|
|
indicator.text = "true"
|
|
ac_sign = -1
|
|
calculation_percent = etree.SubElement(
|
|
trade_allowance, ns["ram"] + "CalculationPercent"
|
|
)
|
|
calculation_percent.text = "%0.*f" % (
|
|
ns["disc_prec"],
|
|
iline.discount * ac_sign,
|
|
)
|
|
basis_amount = etree.SubElement(
|
|
trade_allowance, ns["ram"] + "BasisAmount"
|
|
)
|
|
basis_amount.text = "%0.*f" % (
|
|
ns["price_prec"],
|
|
iline.price_unit * iline.quantity,
|
|
)
|
|
actual_amount = etree.SubElement(
|
|
trade_allowance, ns["ram"] + "ActualAmount"
|
|
)
|
|
actual_amount_val = float_round(
|
|
ac_sign
|
|
* ((iline.price_unit * iline.quantity) - iline.price_subtotal),
|
|
precision_digits=ns["price_prec"],
|
|
)
|
|
actual_amount.text = "%0.*f" % (
|
|
ns["price_prec"],
|
|
actual_amount_val * ns["sign"],
|
|
)
|
|
|
|
net_price = etree.SubElement(
|
|
line_trade_agreement, ns["ram"] + "NetPriceProductTradePrice"
|
|
)
|
|
net_price_amount = etree.SubElement(net_price, ns["ram"] + "ChargeAmount")
|
|
net_price_amount.text = "%0.*f" % (ns["price_prec"], net_price_val)
|
|
line_trade_delivery = etree.SubElement(
|
|
line_item, ns["ram"] + "SpecifiedLineTradeDelivery"
|
|
)
|
|
if iline.product_uom_id and iline.product_uom_id.unece_code:
|
|
unitCode = iline.product_uom_id.unece_code
|
|
else:
|
|
unitCode = "C62"
|
|
if not iline.product_uom_id:
|
|
logger.warning(
|
|
"No unit of measure on invoice line '%s', "
|
|
"using C62 (piece) as fallback",
|
|
iline.name,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Missing UNECE Code on unit of measure %s, "
|
|
"using C62 (piece) as fallback",
|
|
iline.product_uom_id.name,
|
|
)
|
|
billed_qty = etree.SubElement(
|
|
line_trade_delivery, ns["ram"] + "BilledQuantity", unitCode=unitCode
|
|
)
|
|
billed_qty.text = "%0.*f" % (ns["qty_prec"], iline.quantity * ns["sign"])
|
|
line_trade_settlement = etree.SubElement(
|
|
line_item, ns["ram"] + "SpecifiedLineTradeSettlement"
|
|
)
|
|
self._cii_invoice_line_taxes(iline, line_trade_settlement, ns)
|
|
# Fields start_date and end_date are provided by the OCA
|
|
# module account_invoice_start_end_dates
|
|
if (
|
|
ns["level"] in PROFILES_EN_UP
|
|
and hasattr(iline, "start_date")
|
|
and hasattr(iline, "end_date")
|
|
and iline.start_date
|
|
and iline.end_date
|
|
):
|
|
bill_period = etree.SubElement(
|
|
line_trade_settlement, ns["ram"] + "BillingSpecifiedPeriod"
|
|
)
|
|
self._cii_add_date("StartDateTime", iline.start_date, bill_period, ns)
|
|
self._cii_add_date("EndDateTime", iline.end_date, bill_period, ns)
|
|
|
|
subtotal = etree.SubElement(
|
|
line_trade_settlement,
|
|
ns["ram"] + "SpecifiedTradeSettlementLineMonetarySummation",
|
|
)
|
|
subtotal_amount = etree.SubElement(subtotal, ns["ram"] + "LineTotalAmount")
|
|
subtotal_amount.text = "%0.*f" % (
|
|
ns["cur_prec"],
|
|
iline.price_subtotal * ns["sign"],
|
|
)
|
|
|
|
def _cii_invoice_line_taxes(self, iline, parent_node, ns, allowance=False):
|
|
if iline.tax_ids:
|
|
for tax in iline.tax_ids:
|
|
self._cii_line_applicable_trade_tax_block(
|
|
tax, parent_node, ns, allowance=allowance
|
|
)
|
|
else:
|
|
self._cii_line_applicable_trade_tax_block(
|
|
None, parent_node, ns, allowance=allowance
|
|
)
|
|
|
|
def generate_facturx_xml(self):
|
|
self.ensure_one()
|
|
assert self.move_type in (
|
|
"out_invoice",
|
|
"out_refund",
|
|
), "only works for customer invoice and refunds"
|
|
dpo = self.env["decimal.precision"]
|
|
level = self.company_id.facturx_level or "en16931"
|
|
refund_type = self.company_id.facturx_refund_type or "381"
|
|
sign = 1
|
|
if self.move_type == "out_refund" and refund_type == "380":
|
|
sign = -1
|
|
lang = self.partner_id.lang or self.env.user.lang or "en_US"
|
|
tax_speeddict = self.company_id._get_tax_unece_speeddict()
|
|
fp_speeddict = self.company_id._get_fiscal_position_speeddict(lang=lang)
|
|
self = self.with_context(lang=lang)
|
|
nsmap = {
|
|
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
|
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
|
|
"ram": "urn:un:unece:uncefact:data:standard:"
|
|
"ReusableAggregateBusinessInformationEntity:100",
|
|
"qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
|
|
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
|
|
}
|
|
ns = {
|
|
"rsm": "{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}",
|
|
"ram": "{urn:un:unece:uncefact:data:standard:"
|
|
"ReusableAggregateBusinessInformationEntity:100}",
|
|
"qdt": "{urn:un:unece:uncefact:data:standard:QualifiedDataType:100}",
|
|
"udt": "{urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100}",
|
|
"level": level,
|
|
"refund_type": refund_type,
|
|
"sign": sign,
|
|
"currency": self.currency_id.name,
|
|
"cur_prec": self.currency_id.decimal_places,
|
|
"price_prec": dpo.precision_get("Product Price"),
|
|
"disc_prec": dpo.precision_get("Discount"),
|
|
"qty_prec": dpo.precision_get("Product Unit of Measure"),
|
|
"lang": lang,
|
|
"tax_speeddict": tax_speeddict,
|
|
"fp_speeddict": fp_speeddict,
|
|
"allowance_total_amount": 0.0,
|
|
}
|
|
|
|
root = etree.Element(ns["rsm"] + "CrossIndustryInvoice", nsmap=nsmap)
|
|
self._cii_add_document_context_block(root, ns)
|
|
self._cii_add_header_block(root, ns)
|
|
|
|
trade_transaction = etree.SubElement(
|
|
root, ns["rsm"] + "SupplyChainTradeTransaction"
|
|
)
|
|
|
|
allowance_ilines = self.env["account.move.line"]
|
|
if ns["level"] in ("extended", "en16931", "basic"):
|
|
line_number = 0
|
|
for iline in self.invoice_line_ids.filtered(
|
|
lambda x: x.display_type == "product"
|
|
):
|
|
price_compare = float_compare(
|
|
iline.price_unit, 0, precision_digits=ns["price_prec"]
|
|
)
|
|
if price_compare >= 0:
|
|
line_number += 1
|
|
self._cii_add_invoice_line_block(
|
|
trade_transaction, iline, line_number, ns
|
|
)
|
|
else:
|
|
# global allowance
|
|
allowance_ilines |= iline
|
|
|
|
self._cii_add_trade_agreement_block(trade_transaction, ns)
|
|
self._cii_add_trade_delivery_block(trade_transaction, ns)
|
|
self._cii_add_trade_settlement_block(trade_transaction, allowance_ilines, ns)
|
|
|
|
xml_byte = etree.tostring(
|
|
root, pretty_print=True, encoding="UTF-8", xml_declaration=True
|
|
)
|
|
logger.debug("Factur-X XML file generated for invoice ID %d", self.id)
|
|
logger.debug(xml_byte.decode("utf-8"))
|
|
try:
|
|
xml_check_xsd(xml_byte, flavor="factur-x", level=ns["level"])
|
|
except Exception as e:
|
|
raise UserError(str(e)) from e
|
|
return (xml_byte, level)
|
|
|
|
def _prepare_pdf_metadata(self):
|
|
self.ensure_one()
|
|
inv_type = self.move_type == "out_refund" and _("Refund") or _("Invoice")
|
|
if self.invoice_date:
|
|
invoice_date = format_date(
|
|
self.env, self.invoice_date, lang_code=self.partner_id.lang
|
|
)
|
|
else:
|
|
invoice_date = _("(no date)")
|
|
if self.state == "posted":
|
|
invoice_number = self.name
|
|
else:
|
|
invoice_number = self._fields["state"].convert_to_export(self.state, self)
|
|
format_vals = {
|
|
"company_name": self.company_id.name,
|
|
"invoice_type": inv_type,
|
|
"invoice_number": invoice_number,
|
|
"invoice_date": invoice_date,
|
|
}
|
|
pdf_metadata = {
|
|
"author": format_vals["company_name"],
|
|
"keywords": ", ".join([inv_type, _("Factur-X")]),
|
|
"title": _(
|
|
"{company_name}: {invoice_type} {invoice_number} dated {invoice_date}"
|
|
).format(**format_vals),
|
|
"subject": _(
|
|
"Factur-X {invoice_type} {invoice_number} dated {invoice_date} "
|
|
"issued by {company_name}"
|
|
).format(**format_vals),
|
|
}
|
|
return pdf_metadata
|
|
|
|
def _prepare_facturx_attachments(self):
|
|
# This method is designed to be inherited in other modules
|
|
self.ensure_one()
|
|
return {}
|
|
|
|
def regular_pdf_invoice_to_facturx_invoice(self, pdf_bytesio):
|
|
self.ensure_one()
|
|
assert pdf_bytesio, "Missing pdf_bytesio"
|
|
if self.move_type in ("out_invoice", "out_refund"):
|
|
facturx_xml_bytes, level = self.generate_facturx_xml()
|
|
pdf_metadata = self._prepare_pdf_metadata()
|
|
lang = (
|
|
self.partner_id.lang and self.partner_id.lang.replace("_", "-") or None
|
|
)
|
|
# Generate a new PDF with XML file as attachment
|
|
attachments = self._prepare_facturx_attachments()
|
|
generate_from_file(
|
|
pdf_bytesio,
|
|
facturx_xml_bytes,
|
|
flavor="factur-x",
|
|
level=level,
|
|
check_xsd=False,
|
|
pdf_metadata=pdf_metadata,
|
|
lang=lang,
|
|
attachments=attachments,
|
|
)
|
|
logger.info("%s file added to PDF invoice", FACTURX_FILENAME)
|