details: https://code.tryton.org/tryton/commit/56a9566c4b88 branch: default user: Cédric Krier <[email protected]> date: Mon Aug 31 12:26:39 2026 +0200 description: Parse Cross Industry Invoice as supplier invoice diffstat:
modules/edocument_uncefact/CHANGELOG | 1 + modules/edocument_uncefact/edocument.py | 799 +++++++++- modules/edocument_uncefact/exceptions.py | 8 + modules/edocument_uncefact/message.xml | 29 + modules/edocument_uncefact/pyproject.toml | 9 +- modules/edocument_uncefact/tests/CII_example1.xml | 639 +++++++ modules/edocument_uncefact/tests/scenario_cii_invoice_parse.rst | 84 + modules/edocument_uncefact/tests/test_module.py | 2 +- modules/edocument_uncefact/tests/test_scenario.py | 8 + modules/edocument_uncefact/tryton.cfg | 14 +- 10 files changed, 1588 insertions(+), 5 deletions(-) diffs (1709 lines): diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/CHANGELOG --- a/modules/edocument_uncefact/CHANGELOG Thu Jul 30 09:35:03 2026 +0200 +++ b/modules/edocument_uncefact/CHANGELOG Mon Aug 31 12:26:39 2026 +0200 @@ -1,3 +1,4 @@ +* Parse Cross Industry Invoice as supplier invoice Version 8.0.0 - 2026-04-20 -------------------------- diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/edocument.py --- a/modules/edocument_uncefact/edocument.py Thu Jul 30 09:35:03 2026 +0200 +++ b/modules/edocument_uncefact/edocument.py Mon Aug 31 12:26:39 2026 +0200 @@ -1,20 +1,32 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. + +import bisect +import datetime as dt import functools import os from decimal import Decimal +from io import BytesIO +from itertools import chain +from operator import itemgetter import genshi import genshi.template # XXX fix: https://genshi.edgewall.org/ticket/582 from genshi.template.astutil import ASTCodeGenerator, ASTTransformer +from lxml import etree +from stdnum import bic, iban +from trytond.i18n import gettext, ngettext from trytond.model import Model -from trytond.pool import Pool +from trytond.modules.product import round_price +from trytond.pool import Pool, PoolMeta from trytond.rpc import RPC from trytond.tools import cached_property, slugify from trytond.transaction import Transaction +from .exceptions import InvoiceError + if not hasattr(ASTCodeGenerator, 'visit_NameConstant'): def visit_NameConstant(self, node): if node.value is None: @@ -51,6 +63,14 @@ yield kind, data, pos +DATETIME_FORMATS = { + '102': '%Y%m%d', + '203': '%Y%m%d%H%M', + '204': '%Y%m%d%H%M%S', + '610': '%Y%m', + } + + class Invoice(Model): __name__ = 'edocument.uncefact.invoice' __slots__ = ('invoice',) @@ -60,6 +80,7 @@ super().__setup__() cls.__rpc__.update({ 'render': RPC(instantiate=0), + 'parse': RPC(readonly=False, result=int), }) def __init__(self, invoice): @@ -217,3 +238,779 @@ @classmethod def party_legal_ids(cls, party, address): return [] + + @classmethod + def parse(cls, document): + pool = Pool() + Attachment = pool.get('ir.attachment') + + tree = etree.parse(BytesIO(document)) + root = tree.getroot() + namespace = root.nsmap.get(root.prefix) + invoice, attachments = cls.parser(namespace)(root) + invoice.save() + invoice.update_taxes() + attachments = list(attachments) + for attachment in attachments: + attachment.resource = invoice + Attachment.save(attachments) + return invoice + + @classmethod + def parser(cls, namespace): + return { + 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100': ( + cls._parse_cii), + }.get(namespace) + + @classmethod + def _parse_cii(cls, root): + pool = Pool() + Invoice = pool.get('account.invoice') + Currency = pool.get('currency.currency') + + exchanged_document = root.find('./{*}ExchangedDocument') + settlement = root.find( + './{*}SupplyChainTradeTransaction/' + '{*}ApplicableHeaderTradeSettlement') + agreement = root.find( + './{*}SupplyChainTradeTransaction/' + '{*}ApplicableHeaderTradeAgreement') + transaction = root.find('./{*}SupplyChainTradeTransaction') + + type_code = exchanged_document.findtext('./{*}TypeCode') + if type_code and type_code not in {'380', '381'}: + raise InvoiceError(gettext( + 'edocument_uncefact.msg_invoice_type_code_unsupported', + type_code=type_code)) + type_sign = -1 if type_code == '381' else 1 + + invoice = Invoice(type='in') + + if (buyer_trade_party := agreement.find('./{*}BuyerTradeParty') + ) is not None: + invoice.company = cls._parse_cii_company(buyer_trade_party) + else: + invoice.company = Invoice.default_company() + if not invoice.company: + raise InvoiceError(gettext( + 'edocument_uncefact.msg_company_not_found', + company=etree.tostring( + buyer_trade_party, pretty_print=True).decode() + if buyer_trade_party else '')) + + invoice.reference = exchanged_document.findtext('./{*}ID') + for invoice_date in chain( + settlement.iterfind('./{*}InvoiceDateTime/{*}DateTimeString'), + exchanged_document.iterfind( + './{*}IssueDateTime/{*}DateTimeString'), + ): + invoice.invoice_date = dt.datetime.strptime( + invoice_date.text, + DATETIME_FORMATS.get(invoice_date.get('format') or '102') + ).date() + invoice.party = cls._parse_cii_party( + agreement.find('./{*}SellerTradeParty'), create=True).id + payees = [invoice.party] + invoice.set_journal() + invoice.on_change_party() + invoice.invoice_address = cls._parse_cii_address( + agreement.find('./{*}SellerTradeParty/{*}PostalTradeAddress'), + party=invoice.party) + invoice.party_tax_identifier = cls._parse_cii_tax_identifier( + agreement.findall( + './{*}SellerTradeParty/{*}SpecifiedTaxRegistration'), + party=invoice.party, create=True) + + if (payee_trade_party := settlement.find('./{*}PayeeTradeParty') + ) is not None: + party = cls._parse_cii_party(payee_trade_party, create=True) + invoice.alternative_payees = [party] + payees.append(party) + if (currency_code := settlement.findtext('./{*}InvoiceCurrencyCode') + ) is not None: + try: + invoice.currency, = Currency.search([ + ('code', '=', currency_code), + ], limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_uncefact.msg_currency_not_found', + code=currency_code)) + + invoice.supplier_payment_reference = settlement.findtext( + './{*}SpecifiedTradeSettlementPaymentMeans/{*}ID') + invoice.payment_means = cls._parse_cii_payment_means( + settlement.findall('./{*}SpecifiedTradeSettlementPaymentMeans'), + payees=payees) + invoice.payment_term_date = cls._parse_cii_payment_term_date( + settlement.findall('./{*}SpecifiedTradePaymentTerms')) + lines = [ + cls._parse_cii_line( + line, + sign=type_sign, + company=invoice.company, + currency=invoice.currency, + supplier=invoice.party) + for line in transaction.iterfind( + './{*}IncludedSupplyChainTradeLineItem')] + invoice.lines = lines + invoice.taxes = [ + cls._parse_cii_tax(tax, type_sign, company=invoice.company) + for tax in settlement.iterfind('./{*}ApplicableTradeTax')] + + if (hasattr(Invoice, 'cash_rounding') + and (settlement.find( + './{*}SpecifiedTradeSettlementHeaderMonetarySummation' + '/{*}RoundingAmount') is not None)): + invoice.cash_rounding = True + + if tax_basis_total_amount := settlement.findtext( + './{*}SpecifiedTradeSettlementHeaderMonetarySummation' + '/{*}TaxBasisTotalAmount'): + untaxed_amount = Decimal(tax_basis_total_amount) + # TODO: Allowance + invoice.source_untaxed_amount = untaxed_amount * type_sign + + if tax_total_amount := settlement.findtext( + './{*}SpecifiedTradeSettlementHeaderMonetarySummation' + '/{*}TaxTotalAmount'): + invoice.source_tax_amount = Decimal(tax_total_amount) * type_sign + if grand_total_amount := settlement.findtext( + './{*}SpecifiedTradeSettlementHeaderMonetarySummation' + '/{*}GrandTotalAmount'): + invoice.source_total_amount = ( + Decimal(grand_total_amount) * type_sign) + + return invoice, cls._parse_cii_attachments(root) + + @classmethod + def _parse_cii_line( + cls, invoice_item, sign, company, currency, supplier=None): + pool = Pool() + Line = pool.get('account.invoice.line') + UoM = pool.get('product.uom') + Tax = pool.get('account.tax') + AccountConfiguration = pool.get('account.configuration') + + account_configuration = AccountConfiguration(1) + + line = Line( + type='line', company=company, currency=currency, invoice_type='in') + if (billed_quantity := invoice_item.find( + './{*}SpecifiedLineTradeDelivery/{*}BilledQuantity') + ) is not None: + line.quantity = float(billed_quantity.text) + digits = ( + -Decimal(billed_quantity.text) + .normalize().as_tuple().exponent) + if (unit_code := billed_quantity.get('unitCode')) not in { + None, 'ZZ', 'XZZ'}: + try: + line.unit, = UoM.search([ + ('unece_code', '=', unit_code), + ('digits', '>=', digits), + ], + order=[('digits', 'ASC')], + limit=1) + except ValueError: + raise InvoiceError(ngettext( + 'edocument_uncefact.msg_unit_not_found', + digits, + code=unit_code, + digits=digits)) + else: + line.unit = None + else: + line.quantity = 1 + line.unit = None + digits = 0 + + line.product = cls._parse_cii_product( + invoice_item.find('./{*}SpecifiedTradeProduct'), supplier=supplier) + if line.product: + line.on_change_product() + if line.unit and line.unit.digits < digits: + # Search the unit with enough digits + try: + line.unit, = UoM.search([ + ('category', '=', line.unit.category), + ('digits', '>=', digits), + ['OR', + ('factor', '=', line.unit.factor), + ('rate', '=', line.unit.rate), + ], + ], + order=[('digits', 'ASC')], + limit=1) + except ValueError: + pass + line.description = "\n".join(e.text for e in chain( + invoice_item.iterfind('./{*}SpecifiedTradeProduct/{*}Name'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}TradeName'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}Description'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}EndItemName'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}UseDescription'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}BrandName'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}SubBrandName'), + invoice_item.iterfind( + './{*}SpecifiedTradeProduct/{*}Designation'), + ) if e is not None and e.text) + + if not line.product: + if line.description: + similar_domain = [ + ('description', 'ilike', line.description), + ('invoice.company', '=', company), + ('invoice.type', '=', 'in'), + ('invoice.state', 'in', + ['validated', 'posted', 'paid']), + ] + if line.unit: + similar_domain.append( + ('unit.category', '=', line.unit.category)) + similar_lines = Line.search( + similar_domain, + order=[('invoice.invoice_date', 'DESC')], + limit=1) + else: + similar_lines = [] + if similar_lines: + similar_line, = similar_lines + line.account = similar_line.account + line.product = similar_line.product + if not line.unit: + line.unit = similar_line.unit + else: + line.account = account_configuration.get_multivalue( + 'default_category_account_expense', + company=company.id) + + line_trade_agreement = invoice_item.find( + './{*}SpecifiedLineTradeAgreement') + for reference in chain( + line_trade_agreement.iterfind( + './{*}SellerOrderReferencedDocument'), + line_trade_agreement.iterfind( + './{*}BuyerOrderReferencedDocument'), + line_trade_agreement.iterfind( + './{*}QuotationReferencedDocument'), + line_trade_agreement.iterfind( + './{*}ContractReferencedDocument'), + line_trade_agreement.iterfind( + './{*}DemandForecastReferencedDocument'), + line_trade_agreement.iterfind( + './{*}PromotionalDealReferencedDocument'), + line_trade_agreement.iterfind( + './{*}AdditionalReferencedDocument'), + ): + line.origin = cls._parse_cii_line_reference( + reference, line, company, supplier=supplier) + if line.origin: + break + + unit_price = Decimal(line_trade_agreement.findtext( + './{*}NetPriceProductTradePrice/{*}ChargeAmount')) + if (basis_quantity := line_trade_agreement.find( + './{*}NetPriceProductTradePrice/{*}BasisQuantity') + ) is not None: + unit_price /= Decimal(basis_quantity.text) + line.unit_price = round_price(abs(unit_price)) + + line_trade_settlement = invoice_item.find( + './{*}SpecifiedLineTradeSettlement') + + total_amount = Decimal(line_trade_settlement.findtext( + './{*}SpecifiedTradeSettlementLineMonetarySummation' + '/{*}LineTotalAmount')) + line.quantity = ( + -abs(line.quantity) if total_amount.is_signed() + else abs(line.quantity)) * sign + + taxes = [] + for trade_tax in line_trade_settlement.iterfind( + './{*}ApplicableTradeTax'): + domain = cls._parse_cii_tax_domain(trade_tax) + domain.extend([ + ['OR', + ('group', '=', None), + ('group.kind', 'in', ['purchase', 'both']), + ], + ('company', '=', company.id), + ]) + try: + tax, = Tax.search(domain, limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_uncefact.msg_tax_not_found', + tax_category=etree.tostring( + trade_tax, pretty_print=True).decode())) + taxes.append(tax) + line.taxes = taxes + return line + + @classmethod + def _parse_cii_party(cls, trade_party, create=False): + pool = Pool() + Party = pool.get('party.party') + + for identifier in chain( + trade_party.iterfind('./{*}ID'), + trade_party.iterfind('./{*}GlobalID'), + trade_party.iterfind('./{*}SpecifiedLegalOrganization/{*}ID'), + trade_party.iterfind('./{*}SpecifiedTaxRegistration/{*}ID'), + ): + if identifier.text: + domain = [ + ('code_compact', '=', identifier.text), + ] + if schemeId := identifier.get('schemeID'): + if schemeId == 'VA': + domain.append(('type', '=', 'eu_vat')) + parties = Party.search([ + ('identifiers', 'where', domain), + ]) + if len(parties) == 1: + party, = parties + return party + if create: + party = cls._create_cii_party(trade_party) + party.save() + return party + + @classmethod + def _create_cii_party(cls, trade_party): + pool = Pool() + Party = pool.get('party.party') + party = Party() + party.name = ( + trade_party.findtext('./{*}SpecifiedLegalOrganization/{*}Name') + or trade_party.findtext( + './{*}SpecifiedLegalOrganization/{*}TradingBusinessName') + or trade_party.findtext('./{*}Name')) + identifiers = [] + identifiers_done = set() + for identifier in chain( + trade_party.iterfind('./{*}ID'), + trade_party.iterfind('./{*}GlobalID'), + trade_party.iterfind('./{*}SpecifiedLegalOrganization/{*}ID'), + trade_party.iterfind('./{*}SpecifiedTaxRegistration/{*}ID'), + ): + if identifier.text: + identifier_key = (identifier.text, identifier.get('schemeID')) + if identifier_key not in identifiers_done: + identifiers.append(cls._create_cii_party_identifier( + identifier)) + identifiers_done.add(identifier_key) + party.identifiers = identifiers + if (address := trade_party.find('./{*}PostalTradeAddress') + ) is not None: + party.addresses = [cls._create_cii_address(address)] + return party + + @classmethod + def _create_cii_party_identifier(cls, identifier): + pool = Pool() + Identifier = pool.get('party.identifier') + type = None + if schemeID := identifier.get('schemeID'): + if schemeID == 'VA': + type = 'eu_vat' + return Identifier(type=type, code=identifier.text) + + @classmethod + def _parse_cii_address(cls, address_el, party): + pool = Pool() + Address = pool.get('party.address') + + address = cls._create_cii_address(address_el) + + domain = [('party', '=', party)] + for fname in Address._fields: + if value := getattr(address, fname, None): + domain.append((fname, '=', value)) + try: + address, = Address.search(domain, limit=1) + except ValueError: + address.party = party + address.save() + return address + + @classmethod + def _create_cii_address(cls, address_el): + pool = Pool() + Address = pool.get('party.address') + Country = pool.get('country.country') + Subdivision = pool.get('country.subdivision') + + address = Address() + + if address_el is None: + return address + + address.postal_code = address_el.findtext('./{*}PostcodeCode') + address.post_box = address_el.findtext('./{*}PostOfficeBox') + address.building_name = address_el.findtext('./{*}BuildingName') + address.street_name = address_el.findtext('./{*}StreetName') + address.city = address_el.findtext('./{*}CityName') + if country_id := address_el.findtext('./{*}CountryID'): + try: + country, = Country.search([ + ('code', '=', country_id), + ], limit=1) + except ValueError: + pass + else: + address.country = country + if subdivision_id := address_el.findtext( + './{*}CountrySubDivisionID'): + try: + subdivision, = Subdivision.search([ + ('code', '=', subdivision_id), + ('country', '=', country), + ('type', 'in', country.subdivision_types), + ], limit=1) + except ValueError: + pass + else: + address.subdivision = subdivision + address.building_number = address_el.findtext('./{*}BuildingNumber') + + address.street_unstructured = '\n'.join(filter(None, ( + address_el.findtext('./{*}LineOne'), + address_el.findtext('./{*}LineTwo'), + address_el.findtext('./{*}LineThree'), + address_el.findtext('./{*}LineFour'), + address_el.findtext('./{*}LineFive'), + ))) + return address + + @classmethod + def _parse_cii_tax_identifier(cls, tax_registrations, party, create=False): + pool = Pool() + Identifier = pool.get('party.identifier') + + tax_identifier_types = party.tax_identifier_types() + + for tax_registration in tax_registrations: + id_ = tax_registration.find('./{*}ID') + if id_ is not None: + scheme_id = id_.get('schemeID') + value = id_.text + + for identifier in party.identifiers: + if (identifier.type in tax_identifier_types + and identifier.code_compact == value): + return identifier + else: + if create and scheme_id == 'VA': + identifier = Identifier( + party=party, + type='eu_vat', + code=value) + identifier.save() + return identifier + + @classmethod + def _parse_cii_company(cls, trade_party): + pool = Pool() + Company = pool.get('company.company') + + for identifier in chain( + trade_party.iterfind('./{*}ID'), + trade_party.iterfind('./{*}GlobalID'), + trade_party.iterfind('./{*}SpecifiedLegalOrganization/{*}ID'), + trade_party.iterfind('./{*}SpecifiedTaxRegistration/{*}ID'), + ): + if identifier.text: + domain = [ + ('code_compact', '=', identifier.text), + ] + if schemeId := identifier.get('schemeID'): + if schemeId == 'VA': + domain.append(('type', '=', 'eu_vat')) + companies = Company.search([ + ('party.identifiers', 'where', domain), + ]) + if len(companies) == 1: + company, = companies + return company + + for name in chain( + trade_party.iterfind( + './{*}SpecifiedLegalOrganization/{*}Name'), + trade_party.iterfind( + './{*}SpecifiedLegalOrganization/{*}TradingBusinessName'), + trade_party.iterfind('./{*}Name'), + ): + if name is None: + continue + companies = Company.search([ + ('party.name', '=', name.text), + ]) + if len(companies) == 1: + company, = companies + return company + + @classmethod + def _parse_cii_payment_means(cls, payment_means, payees): + pool = Pool() + PaymentMean = pool.get('account.invoice.payment.mean') + means = [] + for payment_mean in payment_means: + if instrument := cls._parse_cii_payment_mean(payment_mean, payees): + means.append(PaymentMean(instrument=instrument)) + return means + + @classmethod + def _parse_cii_payment_mean(cls, payment_mean, payees): + pass + + @classmethod + def _parse_cii_payment_term_date(cls, payment_terms): + dates = [] + for payment_term in payment_terms: + if (date := payment_term.find( + './{*}DueDateDateTime/DateTimeString') is not None): + dates.append(dt.datetime.strptime( + date.text, + DATETIME_FORMATS.get(date.get('format') or '102') + ).date()) + return min(dates, default=None) + + @classmethod + def _parse_cii_product(cls, product_el, supplier=None): + pool = Pool() + Product = pool.get('product.product') + + for identifier in chain( + product_el.iterfind('./{*}ID'), + product_el.iterfind('./{*}GlobalID'), + product_el.iterfind('./{*}ManufacturerAssignedID'), + ): + if identifier.text: + domain = [ + ('code', '=', identifier.text), + ] + # TOOD: schemeID + try: + product, = Product.search([ + ('identifiers', 'where', domain), + ], limit=1) + except ValueError: + pass + else: + return product + if code := product_el.findtext('./{*}BuyerAssignedID'): + try: + product, = Product.search([ + ('code', '=', code), + ], limit=1) + except ValueError: + pass + else: + return product + + @classmethod + def _parse_cii_line_reference( + cls, reference, line, company, supplier=None): + pass + + @classmethod + def _parse_cii_tax_domain(cls, tax): + pool = Pool() + Tax = pool.get('account.tax') + domain = [ + ('parent', '=', None), + ] + if (unece_category_code := tax.findtext('./{*}CategoryCode') + ) is not None: + domain.append(('unece_category_code', '=', unece_category_code)) + if (unece_code := tax.findtext('./{*}TypeCode')) is not None: + domain.append(('unece_code', '=', unece_code)) + if percent := tax.findtext('./{*}RateApplicablePercent'): + domain.append(('type', '=', 'percentage')) + domain.append(('rate', '=', Decimal(percent) / 100)) + if (hasattr(Tax, 'vatex_code') + and (tax_exemption_reason_code := tax.findtext( + './{*}ExemptionReasonCode'))): + domain.append(['OR', + ('vatex_code', '=', tax_exemption_reason_code), + ('vatex_code', '=', None), + ]) + return domain + + @classmethod + def _parse_cii_tax(cls, tax, sign, company): + pool = Pool() + Tax = pool.get('account.tax') + InvoiceTax = pool.get('account.invoice.tax') + + invoice_tax = InvoiceTax(manual=False) + domain = cls._parse_cii_tax_domain(tax) + domain.extend([ + ['OR', + ('group', '=', None), + ('group.kind', 'in', ['purchase', 'both']), + ], + ('company', '=', company.id), + ]) + try: + invoice_tax.tax, = Tax.search(domain, limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_uncefact.msg_tax_not_found', + tax_category=etree.tostring( + tax, pretty_print=True).decode())) + + invoice_tax.amount = sign * Decimal( + tax.findtext('./{*}CalculatedAmount')) + if (basis_amount := tax.findtext('./{*}BasisAmount')) is not None: + invoice_tax.base = Decimal(basis_amount) + else: + # Use tax amount to define the sign of unknown base + invoice_tax.base = invoice_tax.amount + + invoice_tax.on_change_tax() + + return invoice_tax + + @classmethod + def _parse_cii_attachments(cls, root): + yield from () + + +class Invoice_Bank(metaclass=PoolMeta): + __name__ = 'edocument.uncefact.invoice' + + @classmethod + def _parse_cii_payment_mean(cls, payment_mean, payees): + pool = Pool() + Account = pool.get('bank.account') + instrument = super()._parse_cii_payment_mean(payment_mean, payees) + if (financial_account := payment_mean.find( + './{*}PayeePartyCreditorFinancialAccount')) is not None: + if iban := financial_account.findtext('./{*}IBANID'): + try: + account, = Account.search([ + ('numbers', 'where', ['OR', + ('number', '=', iban), + ('number_compact', '=', iban), + ]), + ('owners.id', 'in', payees), + ], limit=1) + except ValueError: + party = payees[-1] + if party.id in Transaction().create_records['party.party']: + account = cls._create_cii_bank_account( + financial_account, party) + account.save() + else: + raise InvoiceError(ngettext( + 'edocument_uncefact.' + 'msg_bank_account_not_found', + len(payees), + parties=', '.join( + [p.rec_name for p in payees]), + account=etree.tostring( + financial_account, + pretty_print=True).decode())) + instrument = account + return instrument + + @classmethod + def _create_cii_bank_account(cls, financial_account, party): + pool = Pool() + Bank = pool.get('bank') + Account = pool.get('bank.account') + Number = pool.get('bank.account.number') + account = Account(owners=[party]) + if identifier := financial_account.findtext('./{*}IBANID'): + number = Number(number=identifier) + if iban.is_valid(identifier): + number.type = 'iban' + else: + number.type = 'other' + account.numbers = [number] + if identifier := financial_account.findtext( + './{*}PayeeSpecifiedCreditorFinancialInstitution/{*}BICID'): + if bic.is_valid(identifier): + account.bank = Bank.from_bic(identifier) + return account + + +class Invoice_Purchase(metaclass=PoolMeta): + __name__ = 'edocument.uncefact.invoice' + + @classmethod + def _parse_cii_product(cls, product_el, supplier=None): + pool = Pool() + Product = pool.get('product.product') + + product = super()._parse_cii_product(product_el, supplier=supplier) + + if (not product + and supplier + and (code := product_el.findtext('./{*}SellerAssignedID'))): + try: + product, = Product.search([ + ('product_suppliers', 'where', [ + ('party', '=', supplier), + ('code', '=', code), + ]), + ], limit=1) + except ValueError: + pass + return product + + @classmethod + def _parse_cii_line_reference( + cls, reference, line, company, supplier=None): + pool = Pool() + PurchaseLine = pool.get('purchase.line') + UoM = pool.get('product.uom') + + origin = super()._parse_cii_line_reference( + reference, line, company, supplier=supplier) + if origin: + return origin + if not line or not line.product or not line.unit: + return + + if reference.tag not in { + 'SellerOrderReferencedDocument', + 'BuyerOrderReferencedDocument', + 'QuotationReferencedDocument'}: + return + + if numbers := list(filter(None, [ + reference.findtext('./{*}IssuerAssignedID'), + reference.findtext('./{*}URIID'), + reference.findtext('./{*}GlobalID'), + reference.findtext('./{*}RevisionID'), + ])): + purchase_lines = PurchaseLine.search([ + ('purchase.company', '=', company), + ('purchase.rec_name', 'in', numbers), + ('type', '=', 'line'), + ('product', '=', line.product), + ]) + if purchase_lines: + quantities = [] + for purchase_line in purchase_lines: + quantity = UoM.compute_qty( + purchase_line.unit, purchase_line.quantity, line.unit) + quantities.append((quantity, purchase_line)) + key = itemgetter(0) + quantities.sort(key=key) + index = bisect.bisect_left(quantities, line.quantity, key=key) + if index >= len(quantities): + index = -1 + origin = quantities[index][1] + return origin diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/exceptions.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_uncefact/exceptions.py Mon Aug 31 12:26:39 2026 +0200 @@ -0,0 +1,8 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from trytond.exceptions import UserError + + +class InvoiceError(UserError): + pass diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/message.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_uncefact/message.xml Mon Aug 31 12:26:39 2026 +0200 @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tryton> + <data grouped="1"> + <record model="ir.message" id="msg_invoice_type_code_unsupported"> + <field name="text">Unsupported invoice type code "%(type_code)s".</field> + </record> + <record model="ir.message" id="msg_company_not_found"> + <field name="text">Could not find the company for: +%(company)s</field> + </record> + <record model="ir.message" id="msg_currency_not_found"> + <field name="text">Could not find a currency with code "%(code)s".</field> + </record> + <record model="ir.message" id="msg_unit_not_found"> + <field name="text">Could not find a unit with UNECE code "%(code)s" and at least %(digits)s digit.</field> + <field name="text_plural">Could not find a unit with UNECE code "%(code)s" and at least %(digits)s digits.</field> + </record> + <record model="ir.message" id="msg_tax_not_found"> + <field name="text">Could not find tax for: +%(tax_category)s</field> + </record> + <record model="ir.message" id="msg_bank_account_not_found"> + <field name="text">Could not find bank account "%(account)s" for party "%(parties)s".</field> + <field name="text_plural">Could not find bank account "%(account)s" for any of the parties "%(parties)s".</field> + </record> + </data> +</tryton> diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/pyproject.toml --- a/modules/edocument_uncefact/pyproject.toml Thu Jul 30 09:35:03 2026 +0200 +++ b/modules/edocument_uncefact/pyproject.toml Mon Aug 31 12:26:39 2026 +0200 @@ -45,6 +45,7 @@ 'tests/**/*.rst', 'tests/**/*.json', 'tests/**/*.xsd', + 'tests/**/*.xml', ] exclude = ['doc'] @@ -52,7 +53,11 @@ "" = "trytond/modules/edocument_uncefact" [tool.hatch.metadata.hooks.tryton] -dependencies = ['Genshi'] +dependencies = [ + 'Genshi', + 'lxml', + 'python-stdnum', + ] copyright = 'COPYRIGHT' readme = 'README.rst' @@ -60,4 +65,4 @@ test = ['lxml'] [tool.hatch.metadata.hooks.tryton.tryton-optional-dependencies] -test = [] +test = ['proteus'] diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/tests/CII_example1.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_uncefact/tests/CII_example1.xml Mon Aug 31 12:26:39 2026 +0200 @@ -0,0 +1,639 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + + Licensed under European Union Public Licence (EUPL) version 1.2. + +--> +<!-- XML instance generated by Andreas Pelekies --> +<!-- Example 1: Invoice with multiple line items for EN16931 --> +<!-- Timestamp: 2017-08-24 00:00:00 +0200 --> +<rsm:CrossIndustryInvoice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100 ../schema/D16B%20SCRDM%20(Subset)/uncoupled%20clm/CII/uncefact/data/standard/CrossIndustryInvoice_100pD16B.xsd" + xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" + xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" + xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" + xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"> + <rsm:ExchangedDocumentContext> + <ram:GuidelineSpecifiedDocumentContextParameter> + <ram:ID>urn:cen.eu:en16931:2017</ram:ID> + </ram:GuidelineSpecifiedDocumentContextParameter> + </rsm:ExchangedDocumentContext> + <rsm:ExchangedDocument> + <ram:ID>12115118</ram:ID> + <ram:TypeCode>380</ram:TypeCode> + <ram:IssueDateTime> + <udt:DateTimeString format="102">20150109</udt:DateTimeString> + </ram:IssueDateTime> + <ram:IncludedNote> + <ram:Content>Alle leveringen zijn franco. Alle prijzen zijn incl. BTW. Betalingstermijn: 14 dagen netto. Prijswijzigingen voorbehouden. Op al onze aanbiedingen, leveringen en overeenkomsten zijn van toepassing in de algemene verkoop en leveringsvoorwaarden. Gedeponeerd bij de K.v.K. te Amsterdam 25-04-'85.</ram:Content> + <ram:SubjectCode>AAR</ram:SubjectCode> + </ram:IncludedNote> + </rsm:ExchangedDocument> + <rsm:SupplyChainTradeTransaction> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>1</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>166022</ram:SellerAssignedID> + <ram:Name>PATAT FRITES 10MM 10KG</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>9.95</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>19.9</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>2</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>661813</ram:SellerAssignedID> + <ram:Name>KAAS 50PL. JONG BEL. 1KG</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>9.85</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>9.85</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>3</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>438146</ram:SellerAssignedID> + <ram:Name>POT KETCHUP 3 LT</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>8.29</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>8.29</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>4</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>438103</ram:SellerAssignedID> + <ram:Name>FRITESSAUS 3 LRR</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>7.23</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>14.46</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>5</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>666955</ram:SellerAssignedID> + <ram:Name>KOFFIE BLIK 3,5KG SNELF </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>35</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>35</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>6</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>664871</ram:SellerAssignedID> + <ram:Name>KOFFIE 3.5 KG BLIK STAND </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>35</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>35</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>7</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>350257</ram:SellerAssignedID> + <ram:Name>SUIKERKLONT</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>10.65</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>10.65</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>8</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>350258</ram:SellerAssignedID> + <ram:Name>1 KG UL BLOKJES </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>1.55</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>1.55</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>9</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999998</ram:SellerAssignedID> + <ram:Name>BLOCKNOTE A5 </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>4.79</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">3</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>14.37</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>10</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>740810</ram:SellerAssignedID> + <ram:Name>CHIPS NAT KLEIN ZAKJES</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>8.29</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>8.29</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>11</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>740829</ram:SellerAssignedID> + <ram:Name>CHIPS PAP KLEINE ZAKJES</ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>8.29</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>16.58</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>12</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>740828</ram:SellerAssignedID> + <ram:Name>TR KL PAKJES APPELSAP </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>9.95</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>9.95</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>13</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>740827</ram:SellerAssignedID> + <ram:Name>PK CHOCOLADEMELK </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>1.65</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>3.3</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>14</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999996</ram:SellerAssignedID> + <ram:Name>KRAT BIER </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>10.8</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>21</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>10.8</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>15</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999995</ram:SellerAssignedID> + <ram:Name>STATIEGELD </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>3.9</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>3.9</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>16</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>102172</ram:SellerAssignedID> + <ram:Name>BLEEK 3 X 750 ML </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>3.8</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>21</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>7.6</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>17</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999994</ram:SellerAssignedID> + <ram:Name>WC PAPIER </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>4.67</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">2</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>21</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>9.34</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>18</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999993</ram:SellerAssignedID> + <ram:Name>BALPENNEN 50 ST BLAUW </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>18.63</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">1</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>21</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>18.63</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>19</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>999992</ram:SellerAssignedID> + <ram:Name>EM FRITUURVET </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>17.02</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">6</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>102.12</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:IncludedSupplyChainTradeLineItem> + <ram:AssociatedDocumentLineDocument> + <ram:LineID>20</ram:LineID> + </ram:AssociatedDocumentLineDocument> + <ram:SpecifiedTradeProduct> + <ram:SellerAssignedID>175137</ram:SellerAssignedID> + <ram:Name>FRITUUR VET 10 KG RETOUR </ram:Name> + </ram:SpecifiedTradeProduct> + <ram:SpecifiedLineTradeAgreement> + <ram:NetPriceProductTradePrice> + <ram:ChargeAmount>18.33</ram:ChargeAmount> + </ram:NetPriceProductTradePrice> + </ram:SpecifiedLineTradeAgreement> + <ram:SpecifiedLineTradeDelivery> + <ram:BilledQuantity unitCode="H87">6</ram:BilledQuantity> + </ram:SpecifiedLineTradeDelivery> + <ram:SpecifiedLineTradeSettlement> + <ram:ApplicableTradeTax> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradeSettlementLineMonetarySummation> + <ram:LineTotalAmount>-109.98</ram:LineTotalAmount> + </ram:SpecifiedTradeSettlementLineMonetarySummation> + </ram:SpecifiedLineTradeSettlement> + </ram:IncludedSupplyChainTradeLineItem> + <ram:ApplicableHeaderTradeAgreement> + <ram:SellerTradeParty> + <ram:Name>De Koksmaat</ram:Name> + <ram:SpecifiedLegalOrganization> + <ram:ID>57151520</ram:ID> + </ram:SpecifiedLegalOrganization> + <ram:PostalTradeAddress> + <ram:PostcodeCode>1950 AB</ram:PostcodeCode> + <ram:LineOne>Postbus 7l</ram:LineOne> + <ram:CityName>Velsen-Noord</ram:CityName> + <ram:CountryID>NL</ram:CountryID> + </ram:PostalTradeAddress> + <ram:SpecifiedTaxRegistration> + <ram:ID schemeID="VA">NL8200.98.395.B.01</ram:ID> + </ram:SpecifiedTaxRegistration> + </ram:SellerTradeParty> + <ram:BuyerTradeParty> + <ram:ID>10202</ram:ID> + <ram:Name>ODIN 59</ram:Name> + <ram:DefinedTradeContact> + <ram:PersonName>Dhr. J BLOKKER</ram:PersonName> + </ram:DefinedTradeContact> + <ram:PostalTradeAddress> + <ram:PostcodeCode>1960 AJ</ram:PostcodeCode> + <ram:LineOne>POSTBUS 367</ram:LineOne> + <ram:CityName>HEEMSKERK</ram:CityName> + <ram:CountryID>NL</ram:CountryID> + </ram:PostalTradeAddress> + </ram:BuyerTradeParty> + </ram:ApplicableHeaderTradeAgreement> + <ram:ApplicableHeaderTradeDelivery/> + <ram:ApplicableHeaderTradeSettlement> + <ram:PaymentReference>Deb. 10202 / Fact. 12115118</ram:PaymentReference> + <ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode> + <ram:SpecifiedTradeSettlementPaymentMeans> + <ram:TypeCode>30</ram:TypeCode> + <ram:PayeePartyCreditorFinancialAccount> + <ram:IBANID>NL57 RABO 0107307510</ram:IBANID> + </ram:PayeePartyCreditorFinancialAccount> + </ram:SpecifiedTradeSettlementPaymentMeans> + <ram:ApplicableTradeTax> + <ram:CalculatedAmount>10.99</ram:CalculatedAmount> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:BasisAmount>183.23</ram:BasisAmount> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>6</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:ApplicableTradeTax> + <ram:CalculatedAmount>9.74</ram:CalculatedAmount> + <ram:TypeCode>VAT</ram:TypeCode> + <ram:BasisAmount>46.37</ram:BasisAmount> + <ram:CategoryCode>S</ram:CategoryCode> + <ram:RateApplicablePercent>21</ram:RateApplicablePercent> + </ram:ApplicableTradeTax> + <ram:SpecifiedTradePaymentTerms> + <ram:DueDateDateTime><udt:DateTimeString format="102">20150109</udt:DateTimeString></ram:DueDateDateTime> + </ram:SpecifiedTradePaymentTerms> + <ram:SpecifiedTradeSettlementHeaderMonetarySummation> + <ram:LineTotalAmount>229.6</ram:LineTotalAmount> + <ram:TaxBasisTotalAmount>229.6</ram:TaxBasisTotalAmount> + <ram:TaxTotalAmount currencyID="EUR">20.73</ram:TaxTotalAmount> + <ram:GrandTotalAmount>250.33</ram:GrandTotalAmount> + <ram:DuePayableAmount>250.33</ram:DuePayableAmount> + </ram:SpecifiedTradeSettlementHeaderMonetarySummation> + </ram:ApplicableHeaderTradeSettlement> + </rsm:SupplyChainTradeTransaction> +</rsm:CrossIndustryInvoice> diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/tests/scenario_cii_invoice_parse.rst --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_uncefact/tests/scenario_cii_invoice_parse.rst Mon Aug 31 12:26:39 2026 +0200 @@ -0,0 +1,84 @@ +================= +CII Invoice Parse +================= + +Imports:: + + >>> import datetime as dt + >>> from decimal import Decimal + + >>> from proteus import Model + >>> from trytond.modules.account.tests.tools import create_chart, create_tax + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.currency.tests.tools import get_currency + >>> from trytond.tests.tools import activate_modules, assertEqual + >>> from trytond.tools import file_open + +Activate modules:: + + >>> modules = ['edocument_uncefact', 'account_invoice', 'bank', 'purchase'] + >>> config = activate_modules(modules, create_company, create_chart) + + >>> EInvoice = Model.get('edocument.uncefact.invoice') + >>> Invoice = Model.get('account.invoice') + >>> UoM = Model.get('product.uom') + +Setup company:: + + >>> company = get_company() + >>> company.party.name = 'ODIN 59' + >>> company.party.save() + +Create currency:: + + >>> eur = get_currency('EUR') + >>> eur.save() + +Create tax:: + + >>> tax21 = create_tax(Decimal('.21')) + >>> tax21.unece_category_code = 'S' + >>> tax21.unece_code = 'VAT' + >>> tax21.save() + + >>> tax6 = create_tax(Decimal('.06')) + >>> tax6.unece_category_code = 'S' + >>> tax6.unece_code = 'VAT' + >>> tax6.save() + +Create unit:: + + >>> uom, = UoM.find([('name', '=', "Unit")]) + >>> _ = uom.duplicate({'name': "piece", 'unece_code': 'H87'}) + +Parse the CII:: + + >>> with file_open( + ... 'edocument_uncefact/tests/CII_example1.xml', + ... mode='rb') as fp: + ... invoice_id = EInvoice.parse(fp.read(), {**config.context, 'company': None}) + + >>> invoice = Invoice(invoice_id) + >>> invoice.reference + '12115118' + >>> assertEqual(invoice.invoice_date, dt.date(2015, 1, 9)) + >>> invoice.party.name + 'De Koksmaat' + >>> invoice.invoice_address.rec_name + 'De Koksmaat, Postbus 7l, 1950 AB, Velsen-Noord' + >>> assertEqual(invoice.company, company) + >>> invoice.total_amount + Decimal('250.33') + >>> invoice.tax_amount + Decimal('20.73') + >>> assertEqual(invoice.source_untaxed_amount, Decimal('229.60')) + >>> assertEqual(invoice.source_tax_amount, Decimal('20.73')) + >>> assertEqual(invoice.source_total_amount, Decimal('250.33')) + >>> len(invoice.lines) + 20 + + >>> payment_mean, = invoice.payment_means + >>> account_number, = payment_mean.instrument.numbers + >>> account_number.number + 'NL57 RABO 0107 3075 10' + >>> assertEqual(payment_mean.instrument.owners, [invoice.party]) diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/tests/test_module.py --- a/modules/edocument_uncefact/tests/test_module.py Thu Jul 30 09:35:03 2026 +0200 +++ b/modules/edocument_uncefact/tests/test_module.py Mon Aug 31 12:26:39 2026 +0200 @@ -110,7 +110,7 @@ class EDocumentUNCEFACTTestCase(ModuleTestCase): 'Test EDocument UN/CEFACT module' module = 'edocument_uncefact' - extras = ['account_invoice'] + extras = ['account_invoice', 'bank', 'purchase'] @with_transaction() def test_16B_CII_CrossIndustryInvoice(self): diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/tests/test_scenario.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_uncefact/tests/test_scenario.py Mon Aug 31 12:26:39 2026 +0200 @@ -0,0 +1,8 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from trytond.tests.test_tryton import load_doc_tests + + +def load_tests(*args, **kwargs): + return load_doc_tests(__name__, __file__, *args, **kwargs) diff -r 507dd9ca1648 -r 56a9566c4b88 modules/edocument_uncefact/tryton.cfg --- a/modules/edocument_uncefact/tryton.cfg Thu Jul 30 09:35:03 2026 +0200 +++ b/modules/edocument_uncefact/tryton.cfg Mon Aug 31 12:26:39 2026 +0200 @@ -1,14 +1,26 @@ [tryton] version=8.1.0 depends: + edocument_unece ir - edocument_unece + party extras_depend: account_invoice + bank + purchase xml: account.xml + message.xml [register account_invoice] model: edocument.Invoice account.InvoiceEdocumentStart + +[register account_invoice bank] +model: + edocument.Invoice_Bank + +[register account_invoice purchase] +model: + edocument.Invoice_Purchase
