# -*- coding: utf-8 -*- from odoo import api, models, fields, tools, _ from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, float_repr from odoo.tests.common import Form from odoo.exceptions import UserError from datetime import datetime from lxml import etree from PyPDF2 import PdfFileReader import base64 import io import logging _logger = logging.getLogger(__name__) DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d' class AccountEdiFormat(models.Model): _inherit = 'account.edi.format' def _is_compatible_with_journal(self, journal): self.ensure_one() res = super()._is_compatible_with_journal(journal) if self.code != 'facturx_1_0_05': return res return journal.type == 'sale' def _post_invoice_edi(self, invoices, test_mode=False): self.ensure_one() if self.code != 'facturx_1_0_05': return super()._post_invoice_edi(invoices, test_mode=test_mode) res = {} for invoice in invoices: attachment = self._export_facturx(invoice) res[invoice] = {'attachment': attachment} return res def _is_embedding_to_invoice_pdf_needed(self): # OVERRIDE self.ensure_one() return True if self.code == 'facturx_1_0_05' else super()._is_embedding_to_invoice_pdf_needed() def _get_embedding_to_invoice_pdf_values(self, invoice): values = super()._get_embedding_to_invoice_pdf_values(invoice) if values and self.code == 'facturx_1_0_05': values['name'] = 'factur-x.xml' return values def _export_facturx(self, invoice): def format_date(dt): # Format the date in the Factur-x standard. dt = dt or datetime.now() return dt.strftime(DEFAULT_FACTURX_DATE_FORMAT) def format_monetary(number, currency): # Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001). return float_repr(number, currency.decimal_places) self.ensure_one() # Create file content. template_values = { 'record': invoice, 'format_date': format_date, 'format_monetary': format_monetary, 'invoice_line_values': [], } # Tax lines. aggregated_taxes_details = {line.tax_line_id.id: { 'line': line, 'tax_amount': -line.amount_currency if line.currency_id else -line.balance, 'tax_base_amount': 0.0, } for line in invoice.line_ids.filtered('tax_line_id')} # Invoice lines. for i, line in enumerate(invoice.invoice_line_ids.filtered(lambda l: not l.display_type)): price_unit_with_discount = line.price_unit * (1 - (line.discount / 100.0)) taxes_res = line.tax_ids.with_context(force_sign=line.move_id._get_tax_force_sign()).compute_all( price_unit_with_discount, currency=line.currency_id, quantity=line.quantity, product=line.product_id, partner=invoice.partner_id, is_refund=line.move_id.move_type in ('in_refund', 'out_refund'), ) line_template_values = { 'line': line, 'index': i + 1, 'tax_details': [], 'net_price_subtotal': taxes_res['total_excluded'], } for tax_res in taxes_res['taxes']: tax = self.env['account.tax'].browse(tax_res['id']) line_template_values['tax_details'].append({ 'tax': tax, 'tax_amount': tax_res['amount'], 'tax_base_amount': tax_res['base'], }) if tax.id in aggregated_taxes_details: aggregated_taxes_details[tax.id]['tax_base_amount'] += tax_res['base'] template_values['invoice_line_values'].append(line_template_values) template_values['tax_details'] = list(aggregated_taxes_details.values()) xml_content = b"" xml_content += self.env.ref('account_edi_facturx.account_invoice_facturx_export')._render(template_values) xml_name = '%s_facturx.xml' % (invoice.name.replace('/', '_')) return self.env['ir.attachment'].create({ 'name': xml_name, 'datas': base64.encodebytes(xml_content), 'mimetype': 'application/xml' }) def _is_facturx(self, filename, tree): return self.code == 'facturx_1_0_05' and tree.tag == '{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}CrossIndustryInvoice' def _create_invoice_from_xml_tree(self, filename, tree): self.ensure_one() if self._is_facturx(filename, tree): return self._import_facturx(tree, self.env['account.move']) return super()._create_invoice_from_xml_tree(filename, tree) def _update_invoice_from_xml_tree(self, filename, tree, invoice): self.ensure_one() if self._is_facturx(filename, tree): return self._import_facturx(tree, invoice) return super()._update_invoice_from_xml_tree(filename, tree, invoice) def _import_facturx(self, tree, invoice): """ Decodes a factur-x invoice into an invoice. :param tree: the factur-x tree to decode. :param invoice: the invoice to update or an empty recordset. :returns: the invoice where the factur-x data was imported. """ amount_total_import = None default_move_type = False if invoice._context.get('default_journal_id'): journal = self.env['account.journal'].browse(self.env.context['default_journal_id']) default_move_type = 'out_invoice' if journal.type == 'sale' else 'in_invoice' elif invoice._context.get('default_move_type'): default_move_type = self._context['default_move_type'] elif invoice.move_type in self.env['account.move'].get_invoice_types(include_receipts=True): # in case an attachment is saved on a draft invoice previously created, we might # have lost the default value in context but the type was already set default_move_type = invoice.move_type if not default_move_type: raise UserError(_("No information about the journal or the type of invoice is passed")) if default_move_type == 'entry': return # Total amount. elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap) total_amount = elements and float(elements[0].text) or 0.0 # Refund type. # There is two modes to handle refund in Factur-X: # a) type_code == 380 for invoice, type_code == 381 for refund, all positive amounts. # b) type_code == 380, negative amounts in case of refund. # To handle both, we consider the 'a' mode and switch to 'b' if a negative amount is encountered. elements = tree.xpath('//rsm:ExchangedDocument/ram:TypeCode', namespaces=tree.nsmap) type_code = elements[0].text default_move_type.replace('_refund', '_invoice') if type_code == '381': default_move_type = 'out_refund' if default_move_type == 'out_invoice' else 'in_refund' refund_sign = -1 else: # Handle 'b' refund mode. if total_amount < 0: default_move_type = 'out_refund' if default_move_type == 'out_invoice' else 'in_refund' refund_sign = -1 if 'refund' in default_move_type else 1 # Write the type as the journal entry is already created. invoice.move_type = default_move_type # self could be a single record (editing) or be empty (new). with Form(invoice.with_context(default_move_type=default_move_type, account_predictive_bills_disable_prediction=True)) as invoice_form: # Partner (first step to avoid warning 'Warning! You must first select a partner.'). partner_type = invoice_form.journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty' invoice_form.partner_id = self._retrieve_partner( name=self._find_value('//ram:' + partner_type + '/ram:Name', tree, namespaces=tree.nsmap), mail=self._find_value('//ram:' + partner_type + '//ram:URIID[@schemeID=\'SMTP\']', tree, namespaces=tree.nsmap), vat=self._find_value('//ram:' + partner_type + '/ram:SpecifiedTaxRegistration/ram:ID', tree, namespaces=tree.nsmap), ) # Delivery partner if 'partner_shipping_id' in invoice._fields: invoice_form.partner_shipping_id = self._retrieve_partner( name=self._find_value('//ram:ShipToTradeParty/ram:Name', tree, namespaces=tree.nsmap), mail=self._find_value('//ram:ShipToTradeParty//ram:URIID[@schemeID=\'SMTP\']', tree, namespaces=tree.nsmap), vat=self._find_value('//ram:ShipToTradeParty/ram:SpecifiedTaxRegistration/ram:ID', tree, namespaces=tree.nsmap), ) # Reference. elements = tree.xpath('//rsm:ExchangedDocument/ram:ID', namespaces=tree.nsmap) if elements: invoice_form.ref = elements[0].text # Name. elements = tree.xpath('//ram:BuyerOrderReferencedDocument/ram:IssuerAssignedID', namespaces=tree.nsmap) if elements: invoice_form.payment_reference = elements[0].text # Comment. elements = tree.xpath('//ram:IncludedNote/ram:Content', namespaces=tree.nsmap) if elements: invoice_form.narration = elements[0].text # Total amount. elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap) if elements: # Currency. if elements[0].attrib.get('currencyID'): currency_str = elements[0].attrib['currencyID'] currency = self.env.ref('base.%s' % currency_str.upper(), raise_if_not_found=False) if currency != self.env.company.currency_id and currency.active: invoice_form.currency_id = currency # Store xml total amount. amount_total_import = total_amount * refund_sign # Date. elements = tree.xpath('//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString', namespaces=tree.nsmap) if elements: date_str = elements[0].text date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT) invoice_form.invoice_date = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT) # Due date. elements = tree.xpath('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString', namespaces=tree.nsmap) if elements: date_str = elements[0].text date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT) invoice_form.invoice_date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT) # Invoice lines. elements = tree.xpath('//ram:IncludedSupplyChainTradeLineItem', namespaces=tree.nsmap) if elements: for element in elements: with invoice_form.invoice_line_ids.new() as invoice_line_form: # Sequence. line_elements = element.xpath('.//ram:AssociatedDocumentLineDocument/ram:LineID', namespaces=tree.nsmap) if line_elements: invoice_line_form.sequence = int(line_elements[0].text) # Product. line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:Name', namespaces=tree.nsmap) if line_elements: invoice_line_form.name = line_elements[0].text line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:SellerAssignedID', namespaces=tree.nsmap) if line_elements and line_elements[0].text: product = self.env['product.product'].search([('default_code', '=', line_elements[0].text)]) if product: invoice_line_form.product_id = product if not invoice_line_form.product_id: line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:GlobalID', namespaces=tree.nsmap) if line_elements and line_elements[0].text: product = self.env['product.product'].search([('barcode', '=', line_elements[0].text)]) if product: invoice_line_form.product_id = product # Quantity. line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap) if line_elements: invoice_line_form.quantity = float(line_elements[0].text) # Price Unit. line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap) if line_elements: quantity_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:BasisQuantity', namespaces=tree.nsmap) if quantity_elements: invoice_line_form.price_unit = float(line_elements[0].text) / float(quantity_elements[0].text) else: invoice_line_form.price_unit = float(line_elements[0].text) else: line_elements = element.xpath('.//ram:NetPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap) if line_elements: quantity_elements = element.xpath('.//ram:NetPriceProductTradePrice/ram:BasisQuantity', namespaces=tree.nsmap) if quantity_elements: invoice_line_form.price_unit = float(line_elements[0].text) / float(quantity_elements[0].text) else: invoice_line_form.price_unit = float(line_elements[0].text) # Discount. line_elements = element.xpath('.//ram:AppliedTradeAllowanceCharge/ram:CalculationPercent', namespaces=tree.nsmap) if line_elements: invoice_line_form.discount = float(line_elements[0].text) # Taxes line_elements = element.xpath('.//ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent', namespaces=tree.nsmap) invoice_line_form.tax_ids.clear() for tax_element in line_elements: percentage = float(tax_element.text) tax = self.env['account.tax'].search([ ('company_id', '=', invoice_form.company_id.id), ('amount_type', '=', 'percent'), ('type_tax_use', '=', invoice_form.journal_id.type), ('amount', '=', percentage), ], limit=1) if tax: invoice_line_form.tax_ids.add(tax) elif amount_total_import: # No lines in BASICWL. with invoice_form.invoice_line_ids.new() as invoice_line_form: invoice_line_form.name = invoice_form.comment or '/' invoice_line_form.quantity = 1 invoice_line_form.price_unit = amount_total_import return invoice_form.save()