634 lines
22 KiB
Python
634 lines
22 KiB
Python
"""
|
||
Fusion Accounting - UBL 2.1 Invoice / CreditNote Generator & Parser
|
||
|
||
Generates OASIS UBL 2.1 compliant XML documents from ``account.move``
|
||
records and parses incoming UBL XML back into invoice value dictionaries.
|
||
|
||
References
|
||
----------
|
||
* OASIS UBL 2.1 specification
|
||
https://docs.oasis-open.org/ubl/os-UBL-2.1/UBL-2.1.html
|
||
* Namespace URIs used below are taken directly from the published
|
||
OASIS schemas.
|
||
|
||
Original implementation by Nexa Systems Inc.
|
||
"""
|
||
|
||
import logging
|
||
from datetime import date
|
||
from lxml import etree
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import UserError
|
||
from odoo.tools import float_round
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
# ======================================================================
|
||
# XML Namespace constants (OASIS UBL 2.1)
|
||
# ======================================================================
|
||
NS_UBL_INVOICE = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||
NS_UBL_CREDIT_NOTE = (
|
||
"urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
|
||
)
|
||
NS_CAC = (
|
||
"urn:oasis:names:specification:ubl:schema:xsd:"
|
||
"CommonAggregateComponents-2"
|
||
)
|
||
NS_CBC = (
|
||
"urn:oasis:names:specification:ubl:schema:xsd:"
|
||
"CommonBasicComponents-2"
|
||
)
|
||
|
||
NSMAP_INVOICE = {
|
||
None: NS_UBL_INVOICE,
|
||
"cac": NS_CAC,
|
||
"cbc": NS_CBC,
|
||
}
|
||
|
||
NSMAP_CREDIT_NOTE = {
|
||
None: NS_UBL_CREDIT_NOTE,
|
||
"cac": NS_CAC,
|
||
"cbc": NS_CBC,
|
||
}
|
||
|
||
# UBL type code mapping (UNCL 1001)
|
||
MOVE_TYPE_CODE_MAP = {
|
||
"out_invoice": "380", # Commercial Invoice
|
||
"out_refund": "381", # Credit Note
|
||
"in_invoice": "380",
|
||
"in_refund": "381",
|
||
}
|
||
|
||
|
||
class FusionUBLGenerator(models.AbstractModel):
|
||
"""
|
||
Generates and parses OASIS UBL 2.1 Invoice and CreditNote documents.
|
||
|
||
This is implemented as an Odoo abstract model so it participates in
|
||
the ORM registry and has access to ``self.env`` for related lookups.
|
||
It does **not** create its own database table.
|
||
"""
|
||
|
||
_name = "fusion.ubl.generator"
|
||
_description = "Fusion UBL 2.1 Generator"
|
||
|
||
# ==================================================================
|
||
# Public API
|
||
# ==================================================================
|
||
def generate_ubl_invoice(self, move):
|
||
"""Build a UBL 2.1 XML document for a single ``account.move``.
|
||
|
||
Args:
|
||
move: An ``account.move`` singleton (invoice or credit note).
|
||
|
||
Returns:
|
||
bytes: UTF-8 encoded XML conforming to UBL 2.1.
|
||
|
||
Raises:
|
||
UserError: If required data is missing on the move.
|
||
"""
|
||
move.ensure_one()
|
||
self._validate_move(move)
|
||
|
||
is_credit_note = move.move_type in ("out_refund", "in_refund")
|
||
root = self._build_root_element(is_credit_note)
|
||
|
||
self._add_header(root, move, is_credit_note)
|
||
self._add_supplier_party(root, move)
|
||
self._add_customer_party(root, move)
|
||
self._add_payment_means(root, move)
|
||
self._add_tax_total(root, move)
|
||
self._add_legal_monetary_total(root, move, is_credit_note)
|
||
self._add_invoice_lines(root, move, is_credit_note)
|
||
|
||
return etree.tostring(
|
||
root,
|
||
xml_declaration=True,
|
||
encoding="UTF-8",
|
||
pretty_print=True,
|
||
)
|
||
|
||
def parse_ubl_invoice(self, xml_bytes):
|
||
"""Parse a UBL 2.1 Invoice or CreditNote XML into a values dict.
|
||
|
||
The returned dictionary is structured for direct use with
|
||
``account.move.create()`` (with minor caller-side adjustments
|
||
for partner resolution, etc.).
|
||
|
||
Args:
|
||
xml_bytes (bytes): Raw UBL XML.
|
||
|
||
Returns:
|
||
dict: Invoice values including ``invoice_line_ids`` as a
|
||
list of ``Command.create()`` tuples.
|
||
"""
|
||
root = etree.fromstring(xml_bytes)
|
||
|
||
# Detect document type from root tag
|
||
tag = etree.QName(root).localname
|
||
is_credit_note = tag == "CreditNote"
|
||
|
||
ns = {"cac": NS_CAC, "cbc": NS_CBC}
|
||
|
||
# ------ Header ------
|
||
invoice_date_str = self._xpath_text(root, "cbc:IssueDate", ns)
|
||
due_date_str = self._xpath_text(root, "cbc:DueDate", ns)
|
||
currency_code = self._xpath_text(
|
||
root, "cbc:DocumentCurrencyCode", ns
|
||
)
|
||
ref = self._xpath_text(root, "cbc:ID", ns)
|
||
|
||
# ------ Supplier / Customer ------
|
||
supplier_name = self._xpath_text(
|
||
root,
|
||
"cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name",
|
||
ns,
|
||
)
|
||
supplier_vat = self._xpath_text(
|
||
root,
|
||
"cac:AccountingSupplierParty/cac:Party/"
|
||
"cac:PartyTaxScheme/cbc:CompanyID",
|
||
ns,
|
||
)
|
||
customer_name = self._xpath_text(
|
||
root,
|
||
"cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name",
|
||
ns,
|
||
)
|
||
customer_vat = self._xpath_text(
|
||
root,
|
||
"cac:AccountingCustomerParty/cac:Party/"
|
||
"cac:PartyTaxScheme/cbc:CompanyID",
|
||
ns,
|
||
)
|
||
|
||
# ------ Lines ------
|
||
line_tag = "CreditNoteLine" if is_credit_note else "InvoiceLine"
|
||
line_nodes = root.findall(f"cac:{line_tag}", ns)
|
||
lines = []
|
||
for ln in line_nodes:
|
||
qty_tag = (
|
||
"cbc:CreditedQuantity" if is_credit_note
|
||
else "cbc:InvoicedQuantity"
|
||
)
|
||
lines.append({
|
||
"name": self._xpath_text(
|
||
ln, "cac:Item/cbc:Name", ns
|
||
) or "",
|
||
"quantity": float(
|
||
self._xpath_text(ln, qty_tag, ns) or "1"
|
||
),
|
||
"price_unit": float(
|
||
self._xpath_text(
|
||
ln, "cac:Price/cbc:PriceAmount", ns
|
||
) or "0"
|
||
),
|
||
})
|
||
|
||
move_type = "out_refund" if is_credit_note else "out_invoice"
|
||
|
||
return {
|
||
"move_type": move_type,
|
||
"ref": ref,
|
||
"invoice_date": invoice_date_str,
|
||
"invoice_date_due": due_date_str,
|
||
"currency_id": currency_code,
|
||
"supplier_name": supplier_name,
|
||
"supplier_vat": supplier_vat,
|
||
"customer_name": customer_name,
|
||
"customer_vat": customer_vat,
|
||
"invoice_line_ids": lines,
|
||
}
|
||
|
||
# ==================================================================
|
||
# Internal – XML construction helpers
|
||
# ==================================================================
|
||
def _validate_move(self, move):
|
||
"""Ensure the move has the minimum data needed for UBL export."""
|
||
if not move.partner_id:
|
||
raise UserError(
|
||
_("Cannot generate UBL: invoice '%s' has no partner.",
|
||
move.name or _("Draft"))
|
||
)
|
||
if not move.company_id.vat and not move.company_id.company_registry:
|
||
_log.warning(
|
||
"Company '%s' has no VAT or registry number; "
|
||
"the UBL document may be rejected by recipients.",
|
||
move.company_id.name,
|
||
)
|
||
|
||
def _build_root_element(self, is_credit_note):
|
||
"""Create the root ``<Invoice>`` or ``<CreditNote>`` element."""
|
||
if is_credit_note:
|
||
return etree.Element(
|
||
f"{{{NS_UBL_CREDIT_NOTE}}}CreditNote",
|
||
nsmap=NSMAP_CREDIT_NOTE,
|
||
)
|
||
return etree.Element(
|
||
f"{{{NS_UBL_INVOICE}}}Invoice",
|
||
nsmap=NSMAP_INVOICE,
|
||
)
|
||
|
||
# ----- Header -----
|
||
def _add_header(self, root, move, is_credit_note):
|
||
"""Populate the document header (ID, dates, currency, etc.)."""
|
||
cbc = NS_CBC
|
||
|
||
self._sub(root, f"{{{cbc}}}UBLVersionID", "2.1")
|
||
self._sub(root, f"{{{cbc}}}CustomizationID",
|
||
"urn:cen.eu:en16931:2017")
|
||
self._sub(root, f"{{{cbc}}}ProfileID",
|
||
"urn:fdc:peppol.eu:2017:poacc:billing:01:1.0")
|
||
self._sub(root, f"{{{cbc}}}ID", move.name or "DRAFT")
|
||
|
||
issue_date = move.invoice_date or fields.Date.context_today(move)
|
||
self._sub(root, f"{{{cbc}}}IssueDate", str(issue_date))
|
||
|
||
if move.invoice_date_due:
|
||
self._sub(root, f"{{{cbc}}}DueDate", str(move.invoice_date_due))
|
||
|
||
type_code = MOVE_TYPE_CODE_MAP.get(move.move_type, "380")
|
||
self._sub(root, f"{{{cbc}}}InvoiceTypeCode", type_code)
|
||
|
||
if move.narration:
|
||
# Strip HTML tags for the Note element
|
||
import re
|
||
plain = re.sub(r"<[^>]+>", "", move.narration)
|
||
self._sub(root, f"{{{cbc}}}Note", plain)
|
||
|
||
self._sub(
|
||
root,
|
||
f"{{{cbc}}}DocumentCurrencyCode",
|
||
move.currency_id.name or "USD",
|
||
)
|
||
|
||
# ----- Parties -----
|
||
def _add_supplier_party(self, root, move):
|
||
"""Add ``AccountingSupplierParty`` from the company."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
company = move.company_id
|
||
partner = company.partner_id
|
||
|
||
supplier = self._sub(root, f"{{{cac}}}AccountingSupplierParty")
|
||
party = self._sub(supplier, f"{{{cac}}}Party")
|
||
|
||
# Endpoint (electronic address)
|
||
if company.vat:
|
||
endpoint = self._sub(party, f"{{{cbc}}}EndpointID", company.vat)
|
||
endpoint.set("schemeID", "9925")
|
||
|
||
# Party name
|
||
party_name_el = self._sub(party, f"{{{cac}}}PartyName")
|
||
self._sub(party_name_el, f"{{{cbc}}}Name", company.name)
|
||
|
||
# Postal address
|
||
self._add_postal_address(party, partner)
|
||
|
||
# Tax scheme
|
||
if company.vat:
|
||
tax_scheme_el = self._sub(party, f"{{{cac}}}PartyTaxScheme")
|
||
self._sub(tax_scheme_el, f"{{{cbc}}}CompanyID", company.vat)
|
||
scheme = self._sub(tax_scheme_el, f"{{{cac}}}TaxScheme")
|
||
self._sub(scheme, f"{{{cbc}}}ID", "VAT")
|
||
|
||
# Legal entity
|
||
legal = self._sub(party, f"{{{cac}}}PartyLegalEntity")
|
||
self._sub(legal, f"{{{cbc}}}RegistrationName", company.name)
|
||
if company.company_registry:
|
||
self._sub(
|
||
legal, f"{{{cbc}}}CompanyID", company.company_registry
|
||
)
|
||
|
||
def _add_customer_party(self, root, move):
|
||
"""Add ``AccountingCustomerParty`` from the invoice partner."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
partner = move.partner_id
|
||
|
||
customer = self._sub(root, f"{{{cac}}}AccountingCustomerParty")
|
||
party = self._sub(customer, f"{{{cac}}}Party")
|
||
|
||
# Endpoint
|
||
if partner.vat:
|
||
endpoint = self._sub(party, f"{{{cbc}}}EndpointID", partner.vat)
|
||
endpoint.set("schemeID", "9925")
|
||
|
||
# Party name
|
||
party_name_el = self._sub(party, f"{{{cac}}}PartyName")
|
||
self._sub(
|
||
party_name_el, f"{{{cbc}}}Name",
|
||
partner.commercial_partner_id.name or partner.name,
|
||
)
|
||
|
||
# Postal address
|
||
self._add_postal_address(party, partner)
|
||
|
||
# Tax scheme
|
||
if partner.vat:
|
||
tax_scheme_el = self._sub(party, f"{{{cac}}}PartyTaxScheme")
|
||
self._sub(tax_scheme_el, f"{{{cbc}}}CompanyID", partner.vat)
|
||
scheme = self._sub(tax_scheme_el, f"{{{cac}}}TaxScheme")
|
||
self._sub(scheme, f"{{{cbc}}}ID", "VAT")
|
||
|
||
# Legal entity
|
||
legal = self._sub(party, f"{{{cac}}}PartyLegalEntity")
|
||
self._sub(
|
||
legal, f"{{{cbc}}}RegistrationName",
|
||
partner.commercial_partner_id.name or partner.name,
|
||
)
|
||
|
||
def _add_postal_address(self, parent, partner):
|
||
"""Append a ``cac:PostalAddress`` block for the given partner."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
address = self._sub(parent, f"{{{cac}}}PostalAddress")
|
||
|
||
if partner.street:
|
||
self._sub(address, f"{{{cbc}}}StreetName", partner.street)
|
||
if partner.street2:
|
||
self._sub(
|
||
address, f"{{{cbc}}}AdditionalStreetName", partner.street2
|
||
)
|
||
if partner.city:
|
||
self._sub(address, f"{{{cbc}}}CityName", partner.city)
|
||
if partner.zip:
|
||
self._sub(address, f"{{{cbc}}}PostalZone", partner.zip)
|
||
if partner.state_id:
|
||
self._sub(
|
||
address,
|
||
f"{{{cbc}}}CountrySubentity",
|
||
partner.state_id.name,
|
||
)
|
||
if partner.country_id:
|
||
country = self._sub(address, f"{{{cac}}}Country")
|
||
self._sub(
|
||
country,
|
||
f"{{{cbc}}}IdentificationCode",
|
||
partner.country_id.code,
|
||
)
|
||
|
||
# ----- Payment Means -----
|
||
def _add_payment_means(self, root, move):
|
||
"""Add ``PaymentMeans`` with bank account details if available."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
|
||
pm = self._sub(root, f"{{{cac}}}PaymentMeans")
|
||
# Code 30 = Credit transfer (most common)
|
||
self._sub(pm, f"{{{cbc}}}PaymentMeansCode", "30")
|
||
|
||
if move.partner_bank_id:
|
||
account = self._sub(pm, f"{{{cac}}}PayeeFinancialAccount")
|
||
self._sub(
|
||
account, f"{{{cbc}}}ID",
|
||
move.partner_bank_id.acc_number or "",
|
||
)
|
||
if move.partner_bank_id.bank_id:
|
||
branch = self._sub(
|
||
account, f"{{{cac}}}FinancialInstitutionBranch"
|
||
)
|
||
self._sub(
|
||
branch, f"{{{cbc}}}ID",
|
||
move.partner_bank_id.bank_id.bic or "",
|
||
)
|
||
|
||
# ----- Tax Total -----
|
||
def _add_tax_total(self, root, move):
|
||
"""Add ``TaxTotal`` with per-tax breakdown."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
currency = move.currency_id.name or "USD"
|
||
|
||
tax_total = self._sub(root, f"{{{cac}}}TaxTotal")
|
||
tax_amount_el = self._sub(
|
||
tax_total, f"{{{cbc}}}TaxAmount",
|
||
self._fmt(move.amount_tax),
|
||
)
|
||
tax_amount_el.set("currencyID", currency)
|
||
|
||
# Build per-tax subtotals from the tax lines
|
||
tax_groups = {}
|
||
for line in move.line_ids.filtered(
|
||
lambda l: l.tax_line_id and l.tax_line_id.amount_type != "group"
|
||
):
|
||
tax = line.tax_line_id
|
||
key = (tax.id, tax.name, tax.amount)
|
||
if key not in tax_groups:
|
||
tax_groups[key] = {
|
||
"tax": tax,
|
||
"tax_amount": 0.0,
|
||
"base_amount": 0.0,
|
||
}
|
||
tax_groups[key]["tax_amount"] += abs(line.balance)
|
||
|
||
# Compute base amounts from invoice lines
|
||
for line in move.invoice_line_ids:
|
||
for tax in line.tax_ids:
|
||
key = (tax.id, tax.name, tax.amount)
|
||
if key in tax_groups:
|
||
tax_groups[key]["base_amount"] += abs(line.balance)
|
||
|
||
for _key, data in tax_groups.items():
|
||
subtotal = self._sub(tax_total, f"{{{cac}}}TaxSubtotal")
|
||
taxable_el = self._sub(
|
||
subtotal, f"{{{cbc}}}TaxableAmount",
|
||
self._fmt(data["base_amount"]),
|
||
)
|
||
taxable_el.set("currencyID", currency)
|
||
|
||
sub_tax_el = self._sub(
|
||
subtotal, f"{{{cbc}}}TaxAmount",
|
||
self._fmt(data["tax_amount"]),
|
||
)
|
||
sub_tax_el.set("currencyID", currency)
|
||
|
||
cat = self._sub(subtotal, f"{{{cac}}}TaxCategory")
|
||
self._sub(cat, f"{{{cbc}}}ID", self._tax_category(data["tax"]))
|
||
self._sub(
|
||
cat, f"{{{cbc}}}Percent", self._fmt(abs(data["tax"].amount))
|
||
)
|
||
scheme = self._sub(cat, f"{{{cac}}}TaxScheme")
|
||
self._sub(scheme, f"{{{cbc}}}ID", "VAT")
|
||
|
||
# ----- Legal Monetary Total -----
|
||
def _add_legal_monetary_total(self, root, move, is_credit_note):
|
||
"""Add ``LegalMonetaryTotal`` summarising the invoice amounts."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
currency = move.currency_id.name or "USD"
|
||
|
||
lmt = self._sub(root, f"{{{cac}}}LegalMonetaryTotal")
|
||
|
||
line_ext_el = self._sub(
|
||
lmt, f"{{{cbc}}}LineExtensionAmount",
|
||
self._fmt(move.amount_untaxed),
|
||
)
|
||
line_ext_el.set("currencyID", currency)
|
||
|
||
tax_excl_el = self._sub(
|
||
lmt, f"{{{cbc}}}TaxExclusiveAmount",
|
||
self._fmt(move.amount_untaxed),
|
||
)
|
||
tax_excl_el.set("currencyID", currency)
|
||
|
||
tax_incl_el = self._sub(
|
||
lmt, f"{{{cbc}}}TaxInclusiveAmount",
|
||
self._fmt(move.amount_total),
|
||
)
|
||
tax_incl_el.set("currencyID", currency)
|
||
|
||
payable_el = self._sub(
|
||
lmt, f"{{{cbc}}}PayableAmount",
|
||
self._fmt(move.amount_residual),
|
||
)
|
||
payable_el.set("currencyID", currency)
|
||
|
||
# ----- Invoice Lines -----
|
||
def _add_invoice_lines(self, root, move, is_credit_note):
|
||
"""Append one ``InvoiceLine`` / ``CreditNoteLine`` per product line."""
|
||
cac, cbc = NS_CAC, NS_CBC
|
||
currency = move.currency_id.name or "USD"
|
||
|
||
for idx, line in enumerate(move.invoice_line_ids, start=1):
|
||
if line.display_type in ("line_section", "line_note"):
|
||
continue
|
||
|
||
line_tag = (
|
||
f"{{{cac}}}CreditNoteLine"
|
||
if is_credit_note
|
||
else f"{{{cac}}}InvoiceLine"
|
||
)
|
||
inv_line = self._sub(root, line_tag)
|
||
self._sub(inv_line, f"{{{cbc}}}ID", str(idx))
|
||
|
||
qty_tag = (
|
||
f"{{{cbc}}}CreditedQuantity"
|
||
if is_credit_note
|
||
else f"{{{cbc}}}InvoicedQuantity"
|
||
)
|
||
qty_el = self._sub(
|
||
inv_line, qty_tag, self._fmt(line.quantity)
|
||
)
|
||
qty_el.set("unitCode", self._uom_unece(line))
|
||
|
||
ext_el = self._sub(
|
||
inv_line, f"{{{cbc}}}LineExtensionAmount",
|
||
self._fmt(line.price_subtotal),
|
||
)
|
||
ext_el.set("currencyID", currency)
|
||
|
||
# Per-line tax info
|
||
for tax in line.tax_ids:
|
||
tax_el = self._sub(inv_line, f"{{{cac}}}TaxTotal")
|
||
tax_amt_el = self._sub(
|
||
tax_el, f"{{{cbc}}}TaxAmount",
|
||
self._fmt(line.price_total - line.price_subtotal),
|
||
)
|
||
tax_amt_el.set("currencyID", currency)
|
||
|
||
# Item
|
||
item = self._sub(inv_line, f"{{{cac}}}Item")
|
||
self._sub(
|
||
item, f"{{{cbc}}}Name",
|
||
line.name or line.product_id.name or _("(Unnamed)"),
|
||
)
|
||
if line.product_id and line.product_id.default_code:
|
||
seller_id_el = self._sub(
|
||
item, f"{{{cac}}}SellersItemIdentification"
|
||
)
|
||
self._sub(
|
||
seller_id_el, f"{{{cbc}}}ID",
|
||
line.product_id.default_code,
|
||
)
|
||
|
||
# Classified tax category per line item
|
||
for tax in line.tax_ids:
|
||
ctc = self._sub(item, f"{{{cac}}}ClassifiedTaxCategory")
|
||
self._sub(
|
||
ctc, f"{{{cbc}}}ID", self._tax_category(tax)
|
||
)
|
||
self._sub(
|
||
ctc, f"{{{cbc}}}Percent", self._fmt(abs(tax.amount))
|
||
)
|
||
ts = self._sub(ctc, f"{{{cac}}}TaxScheme")
|
||
self._sub(ts, f"{{{cbc}}}ID", "VAT")
|
||
|
||
# Price
|
||
price_el = self._sub(inv_line, f"{{{cac}}}Price")
|
||
price_amt_el = self._sub(
|
||
price_el, f"{{{cbc}}}PriceAmount",
|
||
self._fmt(line.price_unit),
|
||
)
|
||
price_amt_el.set("currencyID", currency)
|
||
|
||
# ==================================================================
|
||
# Utility helpers
|
||
# ==================================================================
|
||
@staticmethod
|
||
def _sub(parent, tag, text=None):
|
||
"""Create a sub-element, optionally setting its text content."""
|
||
el = etree.SubElement(parent, tag)
|
||
if text is not None:
|
||
el.text = str(text)
|
||
return el
|
||
|
||
@staticmethod
|
||
def _fmt(value, precision=2):
|
||
"""Format a numeric value with the given decimal precision."""
|
||
return f"{float_round(float(value or 0), precision_digits=precision):.{precision}f}"
|
||
|
||
@staticmethod
|
||
def _tax_category(tax):
|
||
"""Map an Odoo tax to a UBL tax category code (UNCL 5305).
|
||
|
||
Standard rate -> S, Zero -> Z, Exempt -> E, Reverse charge -> AE.
|
||
"""
|
||
amount = abs(tax.amount)
|
||
if amount == 0:
|
||
return "Z"
|
||
tax_name_lower = (tax.name or "").lower()
|
||
if "exempt" in tax_name_lower:
|
||
return "E"
|
||
if "reverse" in tax_name_lower:
|
||
return "AE"
|
||
return "S"
|
||
|
||
@staticmethod
|
||
def _uom_unece(line):
|
||
"""Return the UN/ECE Rec 20 unit code for the invoice line.
|
||
|
||
Falls back to ``C62`` (one / unit) when no mapping exists.
|
||
"""
|
||
uom = line.product_uom_id
|
||
if not uom:
|
||
return "C62"
|
||
unece_code = getattr(uom, "unece_code", None)
|
||
if unece_code:
|
||
return unece_code
|
||
# Heuristic fallback for common units
|
||
name = (uom.name or "").lower()
|
||
mapping = {
|
||
"unit": "C62",
|
||
"units": "C62",
|
||
"piece": "C62",
|
||
"pieces": "C62",
|
||
"pce": "C62",
|
||
"kg": "KGM",
|
||
"kilogram": "KGM",
|
||
"g": "GRM",
|
||
"gram": "GRM",
|
||
"l": "LTR",
|
||
"liter": "LTR",
|
||
"litre": "LTR",
|
||
"m": "MTR",
|
||
"meter": "MTR",
|
||
"metre": "MTR",
|
||
"hour": "HUR",
|
||
"hours": "HUR",
|
||
"day": "DAY",
|
||
"days": "DAY",
|
||
}
|
||
return mapping.get(name, "C62")
|
||
|
||
@staticmethod
|
||
def _xpath_text(node, xpath, ns):
|
||
"""Return the text of the first matching element, or ``None``."""
|
||
found = node.find(xpath, ns)
|
||
return found.text if found is not None else None
|