Files
tryton/modules/edocument_uncefact/edocument.py
T

1017 lines
38 KiB
Python
Raw Normal View History

2018-09-01 00:16:26 +02:00
# 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
2018-09-01 00:16:26 +02:00
import functools
import os
from decimal import Decimal
from io import BytesIO
from itertools import chain
from operator import itemgetter
2018-09-01 00:16:26 +02:00
import genshi
import genshi.template
2021-12-11 17:58:42 +01:00
# XXX fix: https://genshi.edgewall.org/ticket/582
from genshi.template.astutil import ASTCodeGenerator, ASTTransformer
from lxml import etree
from stdnum import bic, iban
2018-09-01 00:16:26 +02:00
from trytond.i18n import gettext, ngettext
2018-09-01 00:16:26 +02:00
from trytond.model import Model
from trytond.modules.product import round_price
from trytond.pool import Pool, PoolMeta
2018-09-01 00:16:26 +02:00
from trytond.rpc import RPC
2025-10-29 15:55:04 +01:00
from trytond.tools import cached_property, slugify
2018-09-01 00:16:26 +02:00
from trytond.transaction import Transaction
from .exceptions import InvoiceError
2018-09-01 00:34:41 +02:00
if not hasattr(ASTCodeGenerator, 'visit_NameConstant'):
def visit_NameConstant(self, node):
if node.value is None:
self._write('None')
elif node.value is True:
self._write('True')
elif node.value is False:
self._write('False')
else:
raise Exception("Unknown NameConstant %r" % (node.value,))
ASTCodeGenerator.visit_NameConstant = visit_NameConstant
if not hasattr(ASTTransformer, 'visit_NameConstant'):
# Re-use visit_Name because _clone is deleted
ASTTransformer.visit_NameConstant = ASTTransformer.visit_Name
2018-09-01 00:16:26 +02:00
loader = genshi.template.TemplateLoader(
os.path.join(os.path.dirname(__file__), 'template'),
auto_reload=True)
def has_goods_assets(func):
@functools.wraps(func)
def wrapper(self):
if any(l.product.type in {'goods', 'assets'}
for l in self.invoice.lines if l.product):
return func(self)
return wrapper
def remove_comment(stream):
for kind, data, pos in stream:
if kind is genshi.core.COMMENT:
continue
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',
}
2018-09-01 00:16:26 +02:00
class Invoice(Model):
__name__ = 'edocument.uncefact.invoice'
__slots__ = ('invoice',)
2018-09-01 00:16:26 +02:00
@classmethod
def __setup__(cls):
2025-04-04 00:45:18 +02:00
super().__setup__()
2018-09-01 00:16:26 +02:00
cls.__rpc__.update({
'render': RPC(instantiate=0),
'parse': RPC(readonly=False, result=int),
2018-09-01 00:16:26 +02:00
})
def __init__(self, invoice):
pool = Pool()
Invoice = pool.get('account.invoice')
super().__init__()
2018-09-01 00:16:26 +02:00
if int(invoice) >= 0:
invoice = Invoice(int(invoice))
with Transaction().set_context(language=invoice.party_lang):
self.invoice = invoice.__class__(int(invoice))
else:
self.invoice = invoice
2026-09-15 08:32:03 +02:00
def render(self, template, exchange_context=None):
2018-09-01 00:16:26 +02:00
if self.invoice.state not in {'posted', 'paid'}:
raise ValueError("Invoice must be posted")
tmpl = self._get_template(template)
if not tmpl:
raise NotImplementedError
return (tmpl.generate(
this=self,
2026-09-15 08:32:03 +02:00
exchange_context=exchange_context,
Decimal=Decimal)
2018-09-01 00:16:26 +02:00
.filter(remove_comment)
.render()
.encode('utf-8'))
def _get_template(self, version):
return loader.load(os.path.join(version, 'CrossIndustryInvoice.xml'))
2025-10-29 15:55:04 +01:00
@property
def filename(self):
return f'{slugify(self.invoice.rec_name)}.xml'
2018-09-01 00:16:26 +02:00
@cached_property
def type_code(self):
if self.invoice.type == 'out':
if all(l.amount < 0 for l in self.lines):
2018-09-01 00:16:26 +02:00
return '381'
else:
return '380'
else:
if all(l.amount < 0 for l in self.lines):
2018-09-01 00:16:26 +02:00
return '261'
else:
return '389'
@cached_property
def type_sign(self):
"The sign of the quantity depending of the type code"
if self.type_code in {'381', '261'}:
return -1
return 1
@cached_property
def lines(self):
return [l for l in self.invoice.lines if l.type == 'line']
@cached_property
def seller_trade_party(self):
if self.invoice.type == 'out':
return self.invoice.company.party
else:
return self.invoice.party
@cached_property
def seller_trade_address(self):
if self.invoice.type == 'out':
return self.invoice.company.party.address_get('invoice')
else:
return self.invoice.invoice_address
@cached_property
def seller_trade_tax_identifier(self):
if self.invoice.type == 'out':
return self.invoice.tax_identifier
else:
return self.invoice.party_tax_identifier
@cached_property
def buyer_trade_party(self):
if self.invoice.type == 'out':
return self.invoice.party
else:
return self.invoice.company.party
@cached_property
def buyer_trade_address(self):
if self.invoice.type == 'out':
return self.invoice.invoice_address
else:
return None
@cached_property
def buyer_trade_tax_identifier(self):
if self.invoice.type == 'out':
return self.invoice.party_tax_identifier
else:
return self.invoice.tax_identifier
@cached_property
@has_goods_assets
def ship_to_trade_party(self):
if self.invoice.type == 'out':
if getattr(self.invoice, 'sales', None):
2018-09-01 00:16:26 +02:00
sale = self.invoice.sales[0] # XXX
if sale.shipment_party != self.buyer_trade_party:
return sale.shipment_party
else:
if getattr(self.invoice, 'purchases', None):
2018-09-01 00:16:26 +02:00
purchase = self.invoice.purchases[0] # XXX
address = purchase.warehouse.address
if (address and address.party != self.buyer_trade_party):
return address.party
@cached_property
@has_goods_assets
def ship_to_trade_address(self):
if self.invoice.type == 'out':
if getattr(self.invoice, 'sales', None):
2018-09-01 00:16:26 +02:00
sale = self.invoice.sales[0] # XXX
if sale.shipment_party != self.buyer_trade_party:
return sale.shipment_address
else:
if getattr(self.invoice, 'purchases', None):
2018-09-01 00:16:26 +02:00
purchase = self.invoice.purchases[0] # XXX
address = purchase.warehouse.address
if (address and address.party != self.buyer_trade_party):
return address
@cached_property
@has_goods_assets
def ship_from_trade_party(self):
if self.invoice.type == 'out':
if getattr(self.invoice, 'sales', None):
2018-09-01 00:16:26 +02:00
sale = self.invoice.sales[0] # XXX
address = sale.warehouse.address
if address and address.party != self.seller_trade_party:
return address.shipment_party
@cached_property
@has_goods_assets
def ship_from_trade_address(self):
if self.invoice.type == 'out':
if getattr(self.invoice, 'sales', None):
2018-09-01 00:16:26 +02:00
sale = self.invoice.sales[0] # XXX
address = sale.warehouse.address
if address and address.party != self.seller_trade_party:
return address
@cached_property
def payment_reference(self):
return self.invoice.number
@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