diff --git a/modules/edocument_ubl/edocument.py b/modules/edocument_ubl/edocument.py
index 9426a24379..b9849c4e43 100644
--- a/modules/edocument_ubl/edocument.py
+++ b/modules/edocument_ubl/edocument.py
@@ -1,22 +1,35 @@
# 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 mimetypes
import os
-from base64 import b64encode
-from itertools import groupby
+from base64 import b64decode, b64encode
+from decimal import Decimal
+from io import BytesIO
+from itertools import chain, groupby
+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 trytond.i18n import gettext
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
+from .party import ISO6523_TYPES
+
+ISO6523 = {v: k for k, v in ISO6523_TYPES.items()}
+
if not hasattr(ASTCodeGenerator, 'visit_NameConstant'):
def visit_NameConstant(self, node):
if node.value is None:
@@ -53,6 +66,7 @@ class Invoice(Model):
super().__setup__()
cls.__rpc__.update({
'render': RPC(instantiate=0),
+ 'parse': RPC(readonly=False, result=int),
})
def __init__(self, invoice):
@@ -168,3 +182,873 @@ class Invoice(Model):
@cached_property
def lines(self):
return [l for l in self.invoice.lines if l.type == 'line']
+
+ @classmethod
+ def parse(cls, document):
+ pool = Pool()
+ Attachment = pool.get('ir.attachment')
+
+ tree = etree.parse(BytesIO(document))
+ root = tree.getroot()
+ namespace = root.nsmap.get(None)
+ invoice, attachments = cls.parser(namespace)(root)
+ invoice.save()
+ invoice.update_taxes()
+ cls.checker(namespace)(root, invoice)
+ attachments = list(attachments)
+ for attachment in attachments:
+ attachment.resource = invoice
+ Attachment.save(attachments)
+ return invoice
+
+ @classmethod
+ def parser(cls, namespace):
+ return {
+ 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2': (
+ cls._parse_invoice_2),
+ 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2': (
+ cls._parse_credit_note_2),
+ }.get(namespace)
+
+ @classmethod
+ def checker(cls, namespace):
+ return {
+ 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2': (
+ cls._check_invoice_2),
+ 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2': (
+ cls._check_credit_note_2),
+ }.get(namespace)
+
+ @classmethod
+ def _parse_invoice_2(cls, root):
+ pool = Pool()
+ Invoice = pool.get('account.invoice')
+ Currency = pool.get('currency.currency')
+
+ type_code = root.findtext('./{*}InvoiceTypeCode')
+ if type_code and type_code != '380':
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_invoice_type_code_unsupported',
+ type_code=type_code))
+
+ invoice = Invoice(type='in')
+ invoice.reference = root.findtext('./{*}ID')
+ invoice.invoice_date = dt.date.fromisoformat(
+ root.findtext('./{*}IssueDate'))
+ invoice.party = cls._parse_2_supplier(
+ root.find('./{*}AccountingSupplierParty'), create=True)
+ invoice.set_journal()
+ invoice.on_change_party()
+ invoice.invoice_address = cls._parse_2_address(
+ root.find(
+ './{*}AccountingSupplierParty/{*}Party/{*}PostalAddress'),
+ party=invoice.party)
+ invoice.party_tax_identifier = cls._parse_2_tax_identifier(
+ root.findall(
+ './{*}AccountingSupplierParty/{*}Party/{*}PartyTaxScheme'),
+ party=invoice.party, create=True)
+ if (seller := root.find('./{*}SellerSupplierParty')) is not None:
+ supplier = cls._parse_2_supplier(seller)
+ else:
+ supplier = invoice.party
+ if (customer_party := root.find('./{*}AccountingCustomerParty')
+ ) is not None:
+ invoice.company = cls._parse_2_company(customer_party)
+ else:
+ invoice.company = Invoice.default_company()
+ if not invoice.company:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_company_not_found',
+ company=etree.tostring(
+ customer_party, pretty_print=True).decode()
+ if customer_party else ''))
+
+ if (payee_party := root.find('./{*}PayeeParty')) is not None:
+ party = cls._parse_2_party(payee_party)
+ if not party:
+ party = cls._create_2_party(payee_party)
+ party.save()
+ invoice.alternative_payees = [party]
+
+ currency_code = root.findtext('./{*}DocumentCurrencyCode')
+ if not currency_code:
+ payable_amount = root.find(
+ './{*}LegalMonetaryTotal/{*}PayableAmount')
+ currency_code = payable_amount.get('currencyID')
+ if currency_code is not None:
+ try:
+ invoice.currency, = Currency.search([
+ ('code', '=', currency_code),
+ ], limit=1)
+ except ValueError:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_currency_not_found',
+ code=currency_code))
+
+ invoice.payment_term_date = cls._parse_2_payment_term_date(
+ root.findall('./{*}PaymentTerms'))
+ invoice.lines = [
+ cls._parse_invoice_2_line(
+ line, company=invoice.company, currency=invoice.currency,
+ supplier=supplier)
+ for line in root.iterfind('./{*}InvoiceLine')]
+ invoice.taxes = [
+ cls._parse_2_tax(
+ tax, company=invoice.company)
+ for tax in root.iterfind('./{*}TaxTotal/{*}TaxSubtotal')]
+
+ if (hasattr(Invoice, 'cash_rounding')
+ and (root.find(
+ './{*}LegalMonetaryTotal/{*}PayableRoundingAmount')
+ is not None)):
+ invoice.cash_rounding = True
+ return invoice, cls._parse_2_attachments(root)
+
+ @classmethod
+ def _parse_invoice_2_line(
+ cls, invoice_line, 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)
+ if (invoiced_quantity := invoice_line.find('./{*}InvoicedQuantity')
+ ) is not None:
+ line.quantity = float(invoiced_quantity.text)
+ if (unit_code := invoiced_quantity.get('unitCode')) is not None:
+ try:
+ line.unit, = UoM.search([
+ ('unece_code', '=', unit_code),
+ ], limit=1)
+ except ValueError:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_unit_not_found',
+ code=unit_code))
+ else:
+ line.quantity = 1
+ line.unit = None
+
+ line.product = cls._parse_2_item(
+ invoice_line.find('./{*}Item'), supplier=supplier)
+ if line.product:
+ line.on_change_product()
+
+ line.description = '\n'.join(e.text for e in chain(
+ invoice_line.iterfind('./{*}Item/{*}Name'),
+ invoice_line.iterfind('./{*}Item/{*}BrandName'),
+ invoice_line.iterfind('./{*}Item/{*}ModelName'),
+ invoice_line.iterfind('./{*}Item/{*}Description'),
+ invoice_line.iterfind(
+ './{*}Item/{*}AdditionalInformation'),
+ invoice_line.iterfind('./{*}Item/{*}WarrantyInformation'),
+ ) if e is not None and e.text)
+
+ if not line.product:
+ if line.description:
+ similar_lines = Line.search([
+ ('description', 'ilike', line.description),
+ ('invoice.company', '=', company),
+ ('invoice.type', '=', 'in'),
+ ('invoice.state', 'in',
+ ['validated', 'posted', 'paid']),
+ ],
+ 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
+ else:
+ line.account = account_configuration.get_multivalue(
+ 'default_category_account_expense',
+ company=company.id)
+
+ for line_reference in invoice_line.iterfind('./{*}OrderLineReference'):
+ line.origin = cls._parse_2_line_reference(
+ line_reference, line, company, supplier=supplier)
+ if line.origin:
+ break
+
+ if (price_amount := invoice_line.findtext('./{*}Price/{*}PriceAmount')
+ ) is not None:
+ line.unit_price = round_price(Decimal(price_amount))
+ else:
+ line.unit_price = round_price(
+ Decimal(invoice_line.findtext('./{*}LineExtensionAmount'))
+ / Decimal(str(line.quantity)))
+
+ if invoice_line.find('./{*}Item/{*}ClassifiedTaxCategory') is not None:
+ tax_categories = invoice_line.iterfind(
+ './{*}Item/{*}ClassifiedTaxCategory')
+ else:
+ tax_categories = invoice_line.iterfind(
+ './{*}TaxTotal/{*}TaxSubtotal/{*}TaxCategory')
+ taxes = []
+ for tax_category in tax_categories:
+ domain = cls._parse_2_tax_category(tax_category)
+ 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_ubl.msg_tax_not_found',
+ tax_category=etree.tostring(
+ tax_category, pretty_print=True).decode()))
+ taxes.append(tax)
+ line.taxes = taxes
+ return line
+
+ @classmethod
+ def _parse_credit_note_2(cls, root):
+ pool = Pool()
+ Invoice = pool.get('account.invoice')
+ Currency = pool.get('currency.currency')
+
+ type_code = root.findtext('./{*}CreditNoteTypeCode')
+ if type_code and type_code != '381':
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_credit_note_type_code_unsupported',
+ type_code=type_code))
+
+ invoice = Invoice(type='in')
+ invoice.reference = root.findtext('./{*}ID')
+ invoice.invoice_date = dt.date.fromisoformat(
+ root.findtext('./{*}IssueDate'))
+ invoice.party = cls._parse_2_supplier(
+ root.find('./{*}AccountingSupplierParty'), create=True)
+ invoice.set_journal()
+ invoice.on_change_party()
+ if (seller := root.find('./{*}SellerSupplierParty')) is not None:
+ supplier = cls._parse_2_supplier(seller)
+ else:
+ supplier = invoice.party
+ if (customer_party := root.find('./{*}AccountingCustomerParty')
+ ) is not None:
+ invoice.company = cls._parse_2_company(customer_party)
+ else:
+ invoice.company = Invoice.default_company()
+ if (payee_party := root.find('./{*}PayeeParty')) is not None:
+ party = cls._parse_2_party(payee_party)
+ if not party:
+ party = cls._create_2_party(payee_party)
+ party.save()
+ invoice.alternative_payees = [party]
+ if (currency_code := root.findtext('./{*}DocumentCurrencyCode')
+ ) is not None:
+ try:
+ invoice.currency, = Currency.search([
+ ('code', '=', currency_code),
+ ], limit=1)
+ except ValueError:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_currency_not_found',
+ code=currency_code))
+ invoice.payment_term_date = cls._parse_2_payment_term_date(
+ root.findall('./{*}PaymentTerms'))
+ invoice.lines = [
+ cls._parse_credit_note_2_line(
+ line, company=invoice.company, currency=invoice.currency,
+ supplier=supplier)
+ for line in root.iterfind('./{*}CreditNoteLine')]
+ invoice.taxes = [
+ cls._parse_2_tax(
+ tax, company=invoice.company)
+ for tax in root.iterfind('./{*}TaxTotal/{*}TaxSubtotal')]
+
+ if (hasattr(Invoice, 'cash_rounding')
+ and root.find(
+ './{*}LegalMonetaryTotal/{*}PayableRoundingAmount')
+ is not None):
+ invoice.cash_rounding = True
+ return invoice, cls._parse_2_attachments(root)
+
+ @classmethod
+ def _parse_credit_note_2_line(
+ cls, credit_note_line, 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)
+ if (credited_quantity := credit_note_line.find('./{*}CreditedQuantity')
+ ) is not None:
+ line.quantity = -float(credited_quantity.text)
+ if (unit_code := credited_quantity.get('unitCode')) is not None:
+ try:
+ line.unit, = UoM.search([
+ ('unece_code', '=', unit_code),
+ ], limit=1)
+ except ValueError:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_unit_not_found',
+ code=unit_code))
+ else:
+ line.quantity = -1
+ line.unit = None
+
+ line.product = cls._parse_2_item(
+ credit_note_line.find('./{*}Item'), supplier=supplier)
+ if line.product:
+ line.on_change_product()
+
+ line.description = '\n'.join(e.text for e in chain(
+ credit_note_line.iterfind('./{*}Item/{*}Name'),
+ credit_note_line.iterfind('./{*}Item/{*}BrandName'),
+ credit_note_line.iterfind('./{*}Item/{*}ModelName'),
+ credit_note_line.iterfind('./{*}Item/{*}Description'),
+ credit_note_line.iterfind(
+ './{*}Item/{*}AdditionalInformation'),
+ credit_note_line.iterfind('./{*}Item/{*}WarrantyInformation'),
+ ) if e is not None and e.text)
+
+ if not line.product:
+ if line.description:
+ similar_lines = Line.search([
+ ('description', 'ilike', line.description),
+ ('invoice.company', '=', company),
+ ('invoice.type', '=', 'in'),
+ ('invoice.state', 'in',
+ ['validated', 'posted', 'paid']),
+ ],
+ 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
+ else:
+ line.account = account_configuration.get_multivalue(
+ 'default_category_account_expense',
+ company=company.id)
+
+ for line_reference in credit_note_line.iterfind(
+ './{*}OrderLineReference'):
+ line.origin = cls._parse_2_line_reference(
+ line_reference, line, company, supplier=supplier)
+ if line.origin:
+ break
+
+ if (price_amount := credit_note_line.findtext(
+ './{*}Price/{*}PriceAmount')) is not None:
+ line.unit_price = round_price(Decimal(price_amount))
+ else:
+ line.unit_price = round_price(
+ Decimal(credit_note_line.findtext('./{*}LineExtensionAmount'))
+ / line.quantity)
+
+ if (credit_note_line.find('./{*}Item/{*}ClassifiedTaxCategory')
+ is not None):
+ tax_categories = credit_note_line.iterfind(
+ './{*}Item/{*}ClassifiedTaxCategory')
+ else:
+ tax_categories = credit_note_line.iterfind(
+ './{*}TaxTotal/{*}TaxSubtotal/{*}TaxCategory')
+ taxes = []
+ for tax_category in tax_categories:
+ domain = cls._parse_2_tax_category(tax_category)
+ 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_ubl.msg_tax_not_found',
+ tax_category=etree.tostring(
+ tax_category, pretty_print=True).decode()))
+ taxes.append(tax)
+ line.taxes = taxes
+ return line
+
+ @classmethod
+ def _parse_2_supplier(cls, supplier_party, create=False):
+ pool = Pool()
+ Party = pool.get('party.party')
+ for account_id in filter(None, chain(
+ [supplier_party.find('./{*}CustomerAssignedAccountID')],
+ supplier_party.iterfind('./{*}AdditionalAccountID'))):
+ if account_id.text:
+ try:
+ party, = Party.search([
+ ('code', '=', account_id.text),
+ ])
+ except ValueError:
+ pass
+ else:
+ return party
+ party_el = supplier_party.find('./{*}Party')
+ party = cls._parse_2_party(party_el)
+ if not party and create:
+ party = cls._create_2_party(party_el)
+ party.save()
+ return party
+
+ @classmethod
+ def _parse_2_party(cls, party_el):
+ pool = Pool()
+ Party = pool.get('party.party')
+
+ for identifier in party_el.iterfind('./{*}PartyIdentification/{*}ID'):
+ if identifier.text:
+ parties = Party.search([
+ ('identifiers.code', '=', identifier.text),
+ ])
+ if len(parties) == 1:
+ party, = parties
+ return party
+
+ @classmethod
+ def _create_2_party(cls, party_el):
+ pool = Pool()
+ Party = pool.get('party.party')
+ party = Party()
+ party.name = party_el.findtext('./{*}PartyName/{*}Name')
+ identifiers = []
+ for identifier in party_el.iterfind('./{*}PartyIdentification/{*}ID'):
+ if identifier.text:
+ identifiers.append(cls._create_2_party_identifier(
+ identifier))
+ party.identifiers = identifiers
+ if (address := party_el.find('./{*}PostalAddress')
+ ) is not None:
+ party.addresses = [cls._create_2_address(address)]
+ return party
+
+ @classmethod
+ def _create_2_party_identifier(cls, identifier):
+ pool = Pool()
+ Identifier = pool.get('party.identifier')
+ if schemeId := identifier.get('schemeID'):
+ type = ISO6523.get(schemeId)
+ else:
+ type = None
+ return Identifier(type=type, code=identifier.text)
+
+ @classmethod
+ def _parse_2_address(cls, address_el, party):
+ pool = Pool()
+ Address = pool.get('party.address')
+
+ address = cls._create_2_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_2_address(cls, address_el):
+ pool = Pool()
+ Address = pool.get('party.address')
+ Country = pool.get('country.country')
+
+ address = Address()
+
+ if address_el is None:
+ return address
+
+ address.post_box = address_el.findtext('./{*}Postbox')
+ address.floor_number = address_el.findtext('./{*}Floor')
+ address.room_number = address_el.findtext('./{*}Room')
+ address.street_name = address_el.findtext('./{*}StreetName')
+ address.building_name = address_el.findtext('./{*}BuildingName')
+ address.building_number = address_el.findtext('./{*}BuildingNumber')
+ address.city = address_el.findtext('./{*}CityName')
+ address.postal_code = address_el.findtext('./{*}PostalZone')
+ if (country_code := address_el.findtext(
+ './{*}Country/{*}IdentificationCode[@listId="ISO3166-1"]')
+ ) is not None:
+ try:
+ country, = Country.search([
+ ('code', '=', country_code),
+ ], limit=1)
+ except ValueError:
+ pass
+ else:
+ address.country = country
+ address.street_unstructured = '\n'.join(
+ (line.text
+ for line in address_el.iterfind('./{*}AddressLine/{*}Line')))
+ return address
+
+ @classmethod
+ def _parse_2_tax_identifier(cls, party_tax_schemes, party, create=False):
+ pool = Pool()
+ Identifier = pool.get('party.identifier')
+
+ tax_identifier_types = party.tax_identifier_types()
+
+ for party_tax_scheme in party_tax_schemes:
+ company_id = party_tax_scheme.find('./{*}CompanyID')
+ if company_id is not None:
+ scheme_id = company_id.get('schemeID')
+ value = company_id.text
+
+ for identifier in party.identifiers:
+ if (identifier.type in tax_identifier_types
+ and identifier.iso_6523 == scheme_id
+ and identifier.code == value):
+ return identifier
+ else:
+ if create and scheme_id in ISO6523:
+ identifier = Identifier(
+ party=party,
+ type=ISO6523[scheme_id],
+ code=value)
+ identifier.save()
+ return identifier
+
+ @classmethod
+ def _parse_2_company(cls, customer_party):
+ pool = Pool()
+ Company = pool.get('company.company')
+ try:
+ CustomerCode = pool.get('party.party.customer_code')
+ except KeyError:
+ CustomerCode = None
+
+ if CustomerCode:
+ for account_id in filter(None, chain(
+ [customer_party.find('./{*}CustomerAssignedAccountID'),
+ customer_party.find(
+ './{*}SupplierAssignedAccountID')],
+ customer_party.iterfind('./{*}AdditionalAccountID'))):
+ if account_id.text:
+ try:
+ customer_code, = CustomerCode.search([
+ ('customer_code', '=', account_id.text),
+ ])
+ except ValueError:
+ pass
+ else:
+ return customer_code.company
+ for identifier in customer_party.iterfind(
+ './{*}Party/{*}PartyIdentification/{*}ID'):
+ if identifier.text:
+ companies = Company.search([
+ ('party.identifiers.code', '=', identifier.text),
+ ])
+ if len(companies) == 1:
+ company, = companies
+ return company
+
+ for company_id in customer_party.iterfind(
+ './{*}Party/{*}PartyTaxScheme/{*}CompanyID'):
+ companies = Company.search([
+ ('party.identifiers.code', '=', company_id.text),
+ ])
+ if len(companies) == 1:
+ company, = companies
+ return company
+
+ for name in chain(
+ customer_party.iterfind(
+ './{*}Party/{*}PartyTaxScheme/{*}RegistrationName'),
+ customer_party.iterfind('./{*}Party/{*}PartyName/{*}Name'),
+ ):
+ if name is None:
+ continue
+ companies = Company.search([
+ ('party.name', '=', name.text),
+ ])
+ if len(companies) == 1:
+ company, = companies
+ return company
+
+ @classmethod
+ def _parse_2_payment_term_date(cls, payment_terms):
+ dates = []
+ for payment_term in payment_terms:
+ if (date := payment_term.findtext('./{*}PaymentDueDate')
+ ) is not None:
+ dates.append(dt.date.fromisoformat(date))
+ return min(dates, default=None)
+
+ @classmethod
+ def _parse_2_item(cls, item, supplier=None):
+ pool = Pool()
+ Product = pool.get('product.product')
+
+ if (identifier := item.find('./{*}StandardItemIdentification/{*}ID')
+ ) is not None:
+ if identifier.get('schemeID') == 'GTIN':
+ try:
+ product, = Product.search([
+ ('identifiers', 'where', [
+ ('type', 'in', ['ean', 'isbn', 'ismn']),
+ ('code', '=', identifier.text),
+ ]),
+ ], limit=1)
+ except ValueError:
+ pass
+ else:
+ return product
+ if (code := item.findtext('./{*}BuyersItemIdentification/{*}ID')
+ ) is not None:
+ try:
+ product, = Product.search([
+ ('code', '=', code),
+ ], limit=1)
+ except ValueError:
+ pass
+ else:
+ return product
+
+ @classmethod
+ def _parse_2_line_reference(
+ cls, line_reference, line, company, supplier=None):
+ return
+
+ @classmethod
+ def _parse_2_tax_category(cls, tax_category):
+ domain = [
+ ('parent', '=', None),
+ ]
+ if (unece_category_code := tax_category.findtext('./{*}ID')
+ ) is not None:
+ domain.append(('unece_category_code', '=', unece_category_code))
+ if (unece_code := tax_category.findtext('./{*}TaxScheme/{*}ID')
+ ) is not None:
+ domain.append(('unece_code', '=', unece_code))
+ percent = tax_category.findtext('./{*}Percent')
+ if percent:
+ domain.append(('type', '=', 'percentage'))
+ domain.append(('rate', '=', Decimal(percent) / 100))
+ return domain
+
+ @classmethod
+ def _parse_2_tax(cls, tax, company):
+ pool = Pool()
+ Tax = pool.get('account.tax')
+ InvoiceTax = pool.get('account.invoice.tax')
+
+ invoice_tax = InvoiceTax(manual=False)
+
+ tax_category = tax.find('./{*}TaxCategory')
+ domain = cls._parse_2_tax_category(tax_category)
+ 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_ubl.msg_tax_not_found',
+ tax_category=etree.tostring(
+ tax_category, pretty_print=True).decode()))
+
+ invoice_tax.amount = Decimal(tax.findtext('./{*}TaxAmount'))
+ if (taxable_amount := tax.findtext('./{*}TaxableAmount')) is not None:
+ invoice_tax.base = Decimal(taxable_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_2_attachments(cls, root):
+ pool = Pool()
+ Attachment = pool.get('ir.attachment')
+
+ for name in [
+ 'DespatchDocumentReference',
+ 'ReceiptDocumentReference',
+ 'ContractDocumentReference',
+ 'AdditionalDocumentReference',
+ 'StatementDocumentReference',
+ 'OriginatorDocumentReference'
+ ]:
+ for document in root.iterfind(f'./{{*}}{name}'):
+ attachment = Attachment()
+ name = ' '.join(filter(None, [
+ document.findtext('./{*}DocumentType'),
+ document.findtext('./{*}ID'),
+ ]))
+ if (data := document.find(
+ './{*}Attachment/{*}EmbeddedDocumentBinaryObject')
+ ) is not None:
+ mime_code = (
+ data.get('mimeCode') or 'application/octet-stream')
+ name += mimetypes.guess_extension(mime_code) or ''
+ attachment.type = 'data'
+ data = b64decode(data.text)
+ attachment.data = data
+ elif data := document.findtext(
+ './{*}Attachment/{*}EmbeddedDocument'):
+ name += '.txt'
+ attachment.type = 'data'
+ attachment.data = data
+ elif url := document.findtext(
+ './{*}Attachment/{*}ExternalReference/{*}URI'):
+ attachment.type = 'link'
+ Attachment.link = url
+ attachment.name = name
+ yield attachment
+
+ @classmethod
+ def _check_invoice_2(cls, root, invoice):
+ pool = Pool()
+ Lang = pool.get('ir.lang')
+ lang = Lang.get()
+
+ payable_amount = Decimal(
+ root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount'))
+ prepaid_amount = Decimal(
+ root.findtext('./{*}LegalMonetaryTotal/{*}PrepaidAmount')
+ or 0)
+ amount = payable_amount + prepaid_amount
+ if not getattr(invoice, 'cash_rounding', False):
+ payable_rounding_amount = Decimal(
+ root.findtext(
+ './{*}LegalMonetaryTotal/{*}PayableRoundingAmount')
+ or 0)
+ amount -= payable_rounding_amount
+ if invoice.total_amount != amount:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_invoice_total_amount_different',
+ invoice=invoice.rec_name,
+ total_amount=lang.format_number(invoice.total_amount),
+ amount=lang.format_number(amount)))
+
+ tax_total = sum(Decimal(amount.text) for amount in root.iterfind(
+ './{*}TaxTotal/{*}TaxAmount'))
+ if invoice.tax_amount != tax_total:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_invoice_tax_amount_different',
+ invoice=invoice.rec_name,
+ tax_amount=lang.format_number(invoice.tax_amount),
+ tax_total=lang.format_number(tax_total)))
+
+ @classmethod
+ def _check_credit_note_2(cls, root, invoice):
+ pool = Pool()
+ Lang = pool.get('ir.lang')
+ lang = Lang.get()
+
+ payable_amount = Decimal(
+ root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount'))
+ prepaid_amount = Decimal(
+ root.findtext('./{*}LegalMonetaryTotal/{*}PrepaidAmount')
+ or 0)
+ amount = payable_amount + prepaid_amount
+ if not getattr(invoice, 'cash_rounding', False):
+ payable_rounding_amount = Decimal(
+ root.findtext(
+ './{*}LegalMonetaryTotal/{*}PayableRoundingAmount')
+ or 0)
+ amount -= payable_rounding_amount
+ if -invoice.total_amount != amount:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_invoice_total_amount_different',
+ invoice=invoice.rec_name,
+ total_amount=lang.format_number(-invoice.total_amount),
+ amount=lang.format_number(amount)))
+
+ tax_total = sum(Decimal(amount.text) for amount in root.iterfind(
+ './{*}TaxTotal/{*}TaxAmount'))
+ if -invoice.tax_amount != tax_total:
+ raise InvoiceError(gettext(
+ 'edocument_ubl.msg_invoice_tax_amount_different',
+ invoice=invoice.rec_name,
+ tax_amount=lang.format_number(-invoice.tax_amount),
+ tax_total=lang.format_number(tax_total)))
+
+
+class Invoice_Purchase(metaclass=PoolMeta):
+ __name__ = 'edocument.ubl.invoice'
+
+ @classmethod
+ def _parse_2_item(cls, item, supplier=None):
+ pool = Pool()
+ Product = pool.get('product.product')
+
+ product = super()._parse_2_item(item, supplier=supplier)
+
+ if (not product
+ and supplier
+ and (code := item.findtext(
+ './{*}SellersItemIdentification/{*}ID'))):
+ try:
+ product, = Product.search([
+ ('product_suppliers', 'where', [
+ ('party', '=', supplier.id),
+ ('code', '=', code),
+ ]),
+ ], limit=1)
+ except ValueError:
+ pass
+ return product
+
+ @classmethod
+ def _parse_2_line_reference(
+ cls, line_reference, line, company, supplier=None):
+ pool = Pool()
+ PurchaseLine = pool.get('purchase.line')
+ UoM = pool.get('product.uom')
+
+ origin = super()._parse_2_item_reference(
+ line_reference, line, supplier=supplier)
+ if origin:
+ return origin
+ if not line or not line.product or not line.unit:
+ return
+
+ if numbers := list(filter(None, [
+ line_reference.findtext('./{*}OrderReference/{*}ID'),
+ line_reference.findtext(
+ './{*}OrderReference/{*}SalesOrderID'),
+ ])):
+ 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 --git a/modules/edocument_ubl/exceptions.py b/modules/edocument_ubl/exceptions.py
new file mode 100644
index 0000000000..c00a013831
--- /dev/null
+++ b/modules/edocument_ubl/exceptions.py
@@ -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 --git a/modules/edocument_ubl/message.xml b/modules/edocument_ubl/message.xml
new file mode 100644
index 0000000000..1db8108fab
--- /dev/null
+++ b/modules/edocument_ubl/message.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Unsupported invoice type code "%(type_code)s".
+
+
+ Unsupported credit note type code "%(type_code)s".
+
+
+ Could not find the company for:
+%(company)s
+
+
+ Could not find a currency with code "%(code)s".
+
+
+ Could not find a unit with UNECE code "%(code)s".
+
+
+ Could not find tax for:
+%(tax_category)s
+
+
+ The total amount %(total_amount)s of the invoice "%(invoice)s" is different from the amount %(amount)s.
+
+
+ The tax amount %(tax_amount)s of the invoice "%(invoice)s" is different from the tax total %(tax_total)s.
+
+
+
diff --git a/modules/edocument_ubl/setup.py b/modules/edocument_ubl/setup.py
index 20131a8538..007d02c849 100755
--- a/modules/edocument_ubl/setup.py
+++ b/modules/edocument_ubl/setup.py
@@ -44,15 +44,17 @@ else:
download_url = 'http://downloads.tryton.org/%s.%s/' % (
major_version, minor_version)
-requires = ['Genshi']
+requires = ['Genshi', 'lxml']
for dep in info.get('depends', []):
if not re.match(r'(ir|res)(\W|$)', dep):
requires.append(get_require_version('trytond_%s' % dep))
requires.append(get_require_version('trytond'))
tests_require = [
- 'lxml',
+ get_require_version('proteus'),
+ get_require_version('trytond_account_cash_rounding'),
get_require_version('trytond_account_invoice'),
+ get_require_version('trytond_purchase'),
]
setup(name=name,
@@ -81,7 +83,7 @@ setup(name=name,
'trytond.modules.edocument_ubl': (info.get('xml', [])
+ ['tryton.cfg', 'view/*.xml', 'locale/*.po', '*.fodt',
'icons/*.svg', 'template/*/*.xml', 'tests/*/*/*.xsq',
- 'tests/*.rst', 'tests/*.json']),
+ 'tests/*.rst', 'tests/*.json', 'tests/*.xml']),
},
classifiers=[
'Development Status :: 5 - Production/Stable',
diff --git a/modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml b/modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml
new file mode 100644
index 0000000000..522cf4f55d
--- /dev/null
+++ b/modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml
@@ -0,0 +1,409 @@
+
+
+ 2.1
+ TOSL108
+ 2009-12-15
+ Ordered in our booth at the convention.
+ EUR
+ Project cost code 123
+
+ 2009-11-01
+ 2009-11-30
+
+
+ 123
+
+
+ Contract321
+ Framework agreement
+
+
+ Doc1
+ Timesheet
+
+
+ http://www.suppliersite.eu/sheet001.html
+
+
+
+
+ Doc2
+ Drawing
+
+ UjBsR09EbGhjZ0dTQUxNQUFBUUNBRU1tQ1p0dU1GUXhEUzhi
+
+
+
+
+ 1234567890123
+
+ Supp123
+
+
+ Salescompany ltd.
+
+
+ 1231412341324
+ 5467
+ Main street
+ Suite 123
+ 1
+ Revenue department
+ Big city
+ 54321
+ RegionA
+
+ DK
+
+
+
+ DK12345
+
+ VAT
+
+
+
+ The Sellercompany Incorporated
+ 5402697509
+
+ Big city
+ RegionA
+
+ DK
+
+
+
+
+ 4621230
+ 4621231
+ antonio@salescompany.dk
+
+
+ Antonio
+ M
+ Salemacher
+ Sales manager
+
+
+
+
+
+ 1234567987654
+
+ 345KS5324
+
+
+ Buyercompany ltd
+
+
+ 1238764941386
+ 123
+ Anystreet
+ Back door
+ 8
+ Accounting department
+ Anytown
+ 101
+ RegionB
+
+ BE
+
+
+
+ BE54321
+
+ VAT
+
+
+
+ The buyercompany inc.
+ 5645342123
+
+ Mainplace
+ RegionB
+
+ BE
+
+
+
+
+ 5121230
+ 5121231
+ john@buyercompany.eu
+
+
+ John
+ X
+ Doe
+ Purchasing manager
+
+
+
+
+
+ 098740918237
+
+
+ Ebeneser Scrooge Inc.
+
+
+ 6411982340
+
+
+
+ true
+ Packing cost
+ 100
+
+
+ false
+ Promotion discount
+ 100
+
+
+ 292.20
+
+ 1460.5
+ 292.1
+
+ S
+ 20
+
+ VAT
+
+
+
+
+ 1
+ 0.1
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ -25
+ 0
+
+ E
+ 0
+ AAM
+ Exempt New Means of Transport
+
+ VAT
+
+
+
+
+
+ 1436.5
+ 1436.5
+ 1729
+ 100
+ 100
+ 1000
+ 0.30
+ 729
+
+
+ 1
+ Scratch on box
+ 1
+ 1273
+ BookingCode001
+
+ 254.6
+
+
+ Processor: Intel Core 2 Duo SU9400 LV (1.4GHz). RAM:
+ 3MB. Screen 1440x900
+ Labtop computer
+
+ JB007
+
+
+ 1234567890124
+
+
+ 12344321
+
+
+ 65434568
+
+
+ S
+ 20
+
+ VAT
+
+
+
+ Color
+ black
+
+
+
+ 1273
+ 1
+
+ false
+ Contract
+ 0.15
+ 225
+ 1500
+
+
+
+
+ 2
+ Cover is slightly damaged.
+ -1
+ -3.96
+
+ -0.396
+
+
+ Returned "Advanced computing" book
+
+ JB008
+
+
+ 1234567890125
+
+
+ 32344324
+
+
+ 65434567
+
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ 3.96
+ 1
+
+
+
+ 3
+ 2
+ 4.96
+
+ 0.496
+
+
+ "Computing for dummies" book
+
+ JB009
+
+
+ 1234567890126
+
+
+ 32344324
+
+
+ 65434566
+
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ 2.48
+ 1
+
+ false
+ Contract
+ 0.1
+ 0.275
+ 2.75
+
+
+
+
+ 4
+ -1
+ -25
+
+ 0
+
+
+ Returned IBM 5150 desktop
+
+ JB010
+
+
+ 1234567890127
+
+
+ 12344322
+
+
+ 65434565
+
+
+ E
+ 0
+
+ VAT
+
+
+
+
+ 25
+ 1
+
+
+
+ 5
+ 250
+ 187.5
+ BookingCode002
+
+ 37.5
+
+
+ Network cable
+
+ JB011
+
+
+ 1234567890128
+
+
+ 12344325
+
+
+ 65434564
+
+
+ S
+ 20
+
+ VAT
+
+
+
+ Type
+ Cat5
+
+
+
+ 0.75
+ 1
+
+
+
\ No newline at end of file
diff --git a/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml
new file mode 100644
index 0000000000..ffb6888912
--- /dev/null
+++ b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml
@@ -0,0 +1,39 @@
+
+
+
+ 123
+ 2011-09-22
+
+
+ 2011-08-01
+ 2011-08-31
+
+
+
+
+
+ Custom Cotter Pins
+
+
+
+
+
+
+ North American Veeblefetzer
+
+
+
+
+
+ 100.00
+
+
+
+ 1
+ 100.00
+
+ Cotter pin, MIL-SPEC
+
+
+
+
\ No newline at end of file
diff --git a/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml
new file mode 100644
index 0000000000..a0a615db95
--- /dev/null
+++ b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml
@@ -0,0 +1,470 @@
+
+
+ 2.1
+ TOSL108
+ 2009-12-15
+ 380
+ Ordered in our booth at the convention.
+ 2009-11-30
+ EUR
+ Project cost code 123
+
+ 2009-11-01
+ 2009-11-30
+
+
+ 123
+
+
+ Contract321
+ Framework agreement
+
+
+ Doc1
+ Timesheet
+
+
+ http://www.suppliersite.eu/sheet001.html
+
+
+
+
+ Doc2
+ Drawing
+
+ UjBsR09EbGhjZ0dTQUxNQUFBUUNBRU1tQ1p0dU1GUXhEUzhi
+
+
+
+
+ 1234567890123
+
+ Supp123
+
+
+ Salescompany ltd.
+
+
+ 1231412341324
+ 5467
+ Main street
+ Suite 123
+ 1
+ Revenue department
+ Big city
+ 54321
+ RegionA
+
+ DK
+
+
+
+ DK12345
+
+ VAT
+
+
+
+ The Sellercompany Incorporated
+ 5402697509
+
+ Big city
+ RegionA
+
+ DK
+
+
+
+
+ 4621230
+ 4621231
+ antonio@salescompany.dk
+
+
+ Antonio
+ Salemacher
+ M
+ Sales manager
+
+
+
+
+
+ 1234567987654
+
+ 345KS5324
+
+
+ Buyercompany ltd
+
+
+ 1238764941386
+ 123
+ Anystreet
+ Back door
+ 8
+ Accounting department
+ Anytown
+ 101
+ RegionB
+
+ BE
+
+
+
+ BE54321
+
+ VAT
+
+
+
+ The buyercompany inc.
+ 5645342123
+
+ Mainplace
+ RegionB
+
+ BE
+
+
+
+
+ 5121230
+ 5121231
+ john@buyercompany.eu
+
+
+ John
+ Doe
+ X
+ Purchasing manager
+
+
+
+
+
+ 098740918237
+
+
+ Ebeneser Scrooge Inc.
+
+
+ 6411982340
+
+
+
+ 2009-12-15
+
+ 6754238987648
+
+ Deliverystreet
+ Side door
+ 12
+ DeliveryCity
+ 523427
+ RegionC
+
+ BE
+
+
+
+
+
+ 31
+ 2009-12-31
+ IBAN
+ Payref1
+
+ DK1212341234123412
+
+
+ DKDKABCD
+
+
+
+
+
+ Penalty percentage 10% from due date
+
+
+ true
+ Packing cost
+ 100
+
+
+ false
+ Promotion discount
+ 100
+
+
+ 292.20
+
+ 1460.5
+ 292.1
+
+ S
+ 20
+
+ VAT
+
+
+
+
+ 1
+ 0.1
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ -25
+ 0
+
+ E
+ 0
+ AAM
+ Exempt New Means of Transport
+
+ VAT
+
+
+
+
+
+ 1436.5
+ 1436.5
+ 1729
+ 100
+ 100
+ 1000
+ 0.30
+ 729
+
+
+ 1
+ Scratch on box
+ 1
+ 1273
+ BookingCode001
+
+ 1
+
+
+ false
+ Damage
+ 12
+
+
+ true
+ Testing
+ 10
+
+
+ 254.6
+
+
+ Processor: Intel Core 2 Duo SU9400 LV (1.4GHz). RAM:
+ 3MB. Screen 1440x900
+ Labtop computer
+
+ JB007
+
+
+ 1234567890124
+
+
+ 12344321
+
+
+ 65434568
+
+
+ S
+ 20
+
+ VAT
+
+
+
+ Color
+ black
+
+
+
+ 1273
+ 1
+
+ false
+ Contract
+ 0.15
+ 225
+ 1500
+
+
+
+
+ 2
+ Cover is slightly damaged.
+ -1
+ -3.96
+
+ 5
+
+
+ -0.396
+
+
+ Returned "Advanced computing" book
+
+ JB008
+
+
+ 1234567890125
+
+
+ 32344324
+
+
+ 65434567
+
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ 3.96
+ 1
+
+
+
+ 3
+ 2
+ 4.96
+
+ 3
+
+
+ 0.496
+
+
+ "Computing for dummies" book
+
+ JB009
+
+
+ 1234567890126
+
+
+ 32344324
+
+
+ 65434566
+
+
+ AA
+ 10
+
+ VAT
+
+
+
+
+ 2.48
+ 1
+
+ false
+ Contract
+ 0.1
+ 0.275
+ 2.75
+
+
+
+
+ 4
+ -1
+ -25
+
+ 2
+
+
+ 0
+
+
+ Returned IBM 5150 desktop
+
+ JB010
+
+
+ 1234567890127
+
+
+ 12344322
+
+
+ 65434565
+
+
+ E
+ 0
+
+ VAT
+
+
+
+
+ 25
+ 1
+
+
+
+ 5
+ 250
+ 187.5
+ BookingCode002
+
+ 4
+
+
+ 37.5
+
+
+ Network cable
+
+ JB011
+
+
+ 1234567890128
+
+
+ 12344325
+
+
+ 65434564
+
+
+ S
+ 20
+
+ VAT
+
+
+
+ Type
+ Cat5
+
+
+
+ 0.75
+ 1
+
+
+
\ No newline at end of file
diff --git a/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json
new file mode 100644
index 0000000000..dd316178a9
--- /dev/null
+++ b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json
@@ -0,0 +1,4 @@
+[
+ {"cash_rounding": true}
+ ,{"cash_rounding": false}
+]
diff --git a/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst
new file mode 100644
index 0000000000..5485b4f625
--- /dev/null
+++ b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst
@@ -0,0 +1,90 @@
+=======================
+UBL 2 Credit Note 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
+
+ >>> cash_rounding = globals().get('cash_rounding', False)
+
+Activate modules::
+
+ >>> modules = ['edocument_ubl', 'account_invoice']
+ >>> if cash_rounding:
+ ... modules.append('account_cash_rounding')
+ >>> config = activate_modules(modules, create_company, create_chart)
+
+ >>> Attachment = Model.get('ir.attachment')
+ >>> EInvoice = Model.get('edocument.ubl.invoice')
+ >>> Invoice = Model.get('account.invoice')
+
+Setup company::
+
+ >>> company = get_company()
+ >>> company.party.name = 'Buyercompany ltd'
+ >>> company.party.save()
+
+Create currency::
+
+ >>> eur = get_currency('EUR')
+ >>> if cash_rounding:
+ ... eur.cash_rounding = Decimal('1')
+ >>> eur.save()
+
+Create tax::
+
+ >>> tax20 = create_tax(Decimal('.2'))
+ >>> tax20.unece_category_code = 'S'
+ >>> tax20.unece_code = 'VAT'
+ >>> tax20.save()
+
+ >>> tax10 = create_tax(Decimal('.1'))
+ >>> tax10.unece_category_code = 'AA'
+ >>> tax10.unece_code = 'VAT'
+ >>> tax10.save()
+
+ >>> tax0 = create_tax(Decimal('0'))
+ >>> tax0.unece_category_code = 'E'
+ >>> tax0.unece_code = 'VAT'
+ >>> tax0.save()
+
+Parse the UBL invoice::
+
+ >>> with file_open(
+ ... 'edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml',
+ ... mode='rb') as fp:
+ ... invoice_id = EInvoice.parse(fp.read(), config.context)
+
+ >>> invoice = Invoice(invoice_id)
+
+ >>> invoice.reference
+ 'TOSL108'
+ >>> assertEqual(invoice.invoice_date, dt.date(2009, 12, 15))
+ >>> invoice.party.name
+ 'Salescompany ltd.'
+ >>> invoice.invoice_address.rec_name
+ 'Salescompany ltd., Main street 1 5467, 54321, Big city'
+ >>> assertEqual(invoice.company, company)
+ >>> assertEqual(
+ ... invoice.total_amount,
+ ... Decimal('-1729.00') if cash_rounding else Decimal('-1728.70'))
+ >>> invoice.tax_amount
+ Decimal('-292.20')
+ >>> len(invoice.lines)
+ 5
+
+ >>> attachments = Attachment.find([])
+ >>> len(attachments)
+ 3
+ >>> assertEqual({a.resource for a in attachments}, {invoice})
+ >>> sorted((a.name, a.type) for a in attachments)
+ [('Drawing Doc2.pdf', 'data'), ('Framework agreement Contract321', 'data'), ('Timesheet Doc1', 'link')]
diff --git a/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json
new file mode 100644
index 0000000000..dd316178a9
--- /dev/null
+++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json
@@ -0,0 +1,4 @@
+[
+ {"cash_rounding": true}
+ ,{"cash_rounding": false}
+]
diff --git a/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst
new file mode 100644
index 0000000000..7326b9de79
--- /dev/null
+++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst
@@ -0,0 +1,90 @@
+===================
+UBL 2 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
+
+ >>> cash_rounding = globals().get('cash_rounding', False)
+
+Activate modules::
+
+ >>> modules = ['edocument_ubl', 'account_invoice']
+ >>> if cash_rounding:
+ ... modules.append('account_cash_rounding')
+ >>> config = activate_modules(modules, create_company, create_chart)
+
+ >>> Attachment = Model.get('ir.attachment')
+ >>> EInvoice = Model.get('edocument.ubl.invoice')
+ >>> Invoice = Model.get('account.invoice')
+
+Setup company::
+
+ >>> company = get_company()
+ >>> company.party.name = 'Buyercompany ltd'
+ >>> company.party.save()
+
+Create currency::
+
+ >>> eur = get_currency('EUR')
+ >>> if cash_rounding:
+ ... eur.cash_rounding = Decimal('1')
+ >>> eur.save()
+
+Create tax::
+
+ >>> tax20 = create_tax(Decimal('.2'))
+ >>> tax20.unece_category_code = 'S'
+ >>> tax20.unece_code = 'VAT'
+ >>> tax20.save()
+
+ >>> tax10 = create_tax(Decimal('.1'))
+ >>> tax10.unece_category_code = 'AA'
+ >>> tax10.unece_code = 'VAT'
+ >>> tax10.save()
+
+ >>> tax0 = create_tax(Decimal('0'))
+ >>> tax0.unece_category_code = 'E'
+ >>> tax0.unece_code = 'VAT'
+ >>> tax0.save()
+
+Parse the UBL invoice::
+
+ >>> with file_open(
+ ... 'edocument_ubl/tests/UBL-Invoice-2.1-Example.xml',
+ ... mode='rb') as fp:
+ ... invoice_id = EInvoice.parse(fp.read(), config.context)
+
+ >>> invoice = Invoice(invoice_id)
+
+ >>> invoice.reference
+ 'TOSL108'
+ >>> assertEqual(invoice.invoice_date, dt.date(2009, 12, 15))
+ >>> invoice.party.name
+ 'Salescompany ltd.'
+ >>> invoice.invoice_address.rec_name
+ 'Salescompany ltd., Main street 1 5467, 54321, Big city'
+ >>> assertEqual(invoice.company, company)
+ >>> assertEqual(
+ ... invoice.total_amount,
+ ... Decimal('1729.00') if cash_rounding else Decimal('1728.70'))
+ >>> invoice.tax_amount
+ Decimal('292.20')
+ >>> len(invoice.lines)
+ 5
+
+ >>> attachments = Attachment.find([])
+ >>> len(attachments)
+ 3
+ >>> assertEqual({a.resource for a in attachments}, {invoice})
+ >>> sorted((a.name, a.type) for a in attachments)
+ [('Drawing Doc2.pdf', 'data'), ('Framework agreement Contract321', 'data'), ('Timesheet Doc1', 'link')]
diff --git a/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst
new file mode 100644
index 0000000000..036175f7f6
--- /dev/null
+++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst
@@ -0,0 +1,56 @@
+===========================
+UBL 2 Invoice Parse Trivial
+===========================
+
+Imports::
+
+ >>> import datetime as dt
+
+ >>> from proteus import Model
+ >>> from trytond.modules.account.tests.tools import create_chart
+ >>> 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::
+
+ >>> config = activate_modules(
+ ... ['edocument_ubl', 'account_invoice'], create_company, create_chart)
+
+ >>> EInvoice = Model.get('edocument.ubl.invoice')
+ >>> Invoice = Model.get('account.invoice')
+
+Setup company::
+
+ >>> company = get_company()
+ >>> company.party.name = 'North American Veeblefetzer'
+ >>> company.party.save()
+
+Create currency::
+
+ >>> eur = get_currency('CAD')
+
+Parse the UBL invoice::
+
+ >>> with file_open(
+ ... 'edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml',
+ ... mode='rb') as fp:
+ ... invoice_id = EInvoice.parse(fp.read(), config.context)
+
+ >>> invoice = Invoice(invoice_id)
+
+ >>> assertEqual(invoice.invoice_date, dt.date(2011, 9, 22))
+ >>> invoice.party.name
+ 'Custom Cotter Pins'
+ >>> assertEqual(invoice.company, company)
+ >>> invoice.total_amount
+ Decimal('100.00')
+ >>> line, = invoice.lines
+ >>> line.description
+ 'Cotter pin, MIL-SPEC'
+ >>> line.quantity
+ 1.0
+ >>> line.unit
+ >>> line.unit_price
+ Decimal('100.0000')
diff --git a/modules/edocument_ubl/tests/test_module.py b/modules/edocument_ubl/tests/test_module.py
index 6e7e972171..0906cb61ec 100644
--- a/modules/edocument_ubl/tests/test_module.py
+++ b/modules/edocument_ubl/tests/test_module.py
@@ -9,8 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
from lxml import etree
from trytond.pool import Pool
-from trytond.tests.test_tryton import (
- ModuleTestCase, activate_module, with_transaction)
+from trytond.tests.test_tryton import ModuleTestCase, with_transaction
def get_invoice():
@@ -135,11 +134,7 @@ def get_invoice():
class EdocumentUblTestCase(ModuleTestCase):
"Test Edocument Ubl module"
module = 'edocument_ubl'
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- activate_module('account_invoice')
+ extras = ['account_invoice', 'purchase']
@with_transaction()
def test_Invoice_2(self):
diff --git a/modules/edocument_ubl/tests/test_scenario.py b/modules/edocument_ubl/tests/test_scenario.py
new file mode 100644
index 0000000000..dc1d23e7e5
--- /dev/null
+++ b/modules/edocument_ubl/tests/test_scenario.py
@@ -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 --git a/modules/edocument_ubl/tryton.cfg b/modules/edocument_ubl/tryton.cfg
index 475cb9febf..2f3c52ea2f 100644
--- a/modules/edocument_ubl/tryton.cfg
+++ b/modules/edocument_ubl/tryton.cfg
@@ -5,9 +5,12 @@ depends:
ir
party
extras_depend:
+ account_cash_rounding
account_invoice
+ purchase
xml:
account.xml
+ message.xml
[register]
model:
@@ -18,3 +21,7 @@ model:
model:
edocument.Invoice
account.InvoiceEdocumentStart
+
+[register account_invoice purchase]
+model:
+ edocument.Invoice_Purchase