[ADD] account_facturx: add import/export invoice using the Factur-x standard
Factur-x is the e-invoicing standard in vigor in FR & DE and, by extension, it will probably become the standard in Europe. Thus this will be used as default e-invoicing standard by Odoo. The idea is simple: * when printing your customer invoice report (not in draft ofc), a Factur-x xml file is generated and embedded inside the PDF. * when importing supplier bills in the form of PDF files, we detect if any Factur-x xml file is attached and complete the data of the draft vendor bill accordingly. This is done during the message_post() function. /!\ To be compliant with the Factur-x standard, the PDF should be PDF/A-3b compliant. (See: http://fnfe-mpe.org/wp-content/uploads/2018/07/1.-FACTUR-X-V1.1-2018-07-24-FR.pdf chapter 6) However, wkhtmltopdf is not PDF/A compliant and then, the generated PDF report are not fully compliant with the Factur-x standard. Was task: 1859277 Was PR #25508
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name' : 'Import Vendor Bills From XML',
|
||||
'version' : '1.0',
|
||||
'category': 'Accounting',
|
||||
'depends' : ['account'],
|
||||
'data': [
|
||||
'data/facturx_templates.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': True,
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="account_invoice_line_facturx_export">
|
||||
<t xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<!-- Line number. -->
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID t-esc="line_counter"/>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
|
||||
<!-- Product. -->
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:GlobalID
|
||||
schemeID="0160"
|
||||
t-if="line.product_id and line.product_id.barcode"
|
||||
t-esc="line.product_id.barcode"/>
|
||||
<ram:SellerAssignedID
|
||||
t-if="line.product_id and line.product_id.default_code"
|
||||
t-esc="line.product_id.default_code"/>
|
||||
<ram:Name t-esc="line.name"/>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
|
||||
<!-- Amounts. -->
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<t t-set="taxes" t-value="line.invoice_line_tax_ids.compute_all(line.price_unit)"/>
|
||||
<ram:GrossPriceProductTradePrice>
|
||||
<ram:ChargeAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(taxes['total_excluded'], currency)"/>
|
||||
|
||||
<!-- Discount. -->
|
||||
<ram:AppliedTradeAllowanceCharge t-if="line.discount">
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator t-esc="'false' if line.discount == 1 else 'true'"/>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:CalculationPercent t-esc="line.discount"/>
|
||||
</ram:AppliedTradeAllowanceCharge>
|
||||
</ram:GrossPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
|
||||
<!-- Quantity. -->
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity
|
||||
t-esc="line.quantity"/>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<t t-foreach="line.invoice_line_tax_ids" t-as="tax">
|
||||
<ram:ApplicableTradeTax t-if="tax.amount_type == 'percent'">
|
||||
<ram:RateApplicablePercent t-esc="tax.amount"/>
|
||||
</ram:ApplicableTradeTax>
|
||||
</t>
|
||||
<!-- Subtotal. -->
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(line.price_subtotal, currency)"/>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="account_invoice_partner_facturx_export">
|
||||
<t xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<!-- Contact. -->
|
||||
<ram:Name t-if="partner.name" t-esc="partner.name"/>
|
||||
<ram:DefinedTradeContact>
|
||||
<ram:PersonName t-esc="partner.name"/>
|
||||
<ram:TelephoneUniversalCommunication t-if="partner.phone or partner.mobile">
|
||||
<ram:CompleteNumber t-esc="partner.phone or partner.mobile"/>
|
||||
</ram:TelephoneUniversalCommunication>
|
||||
<ram:EmailURIUniversalCommunication t-if="partner.email">
|
||||
<ram:URIID schemeID='SMTP' t-esc="partner.email"/>
|
||||
</ram:EmailURIUniversalCommunication>
|
||||
</ram:DefinedTradeContact>
|
||||
|
||||
<!-- Address. -->
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:PostcodeCode t-if="partner.zip" t-esc="partner.zip"/>
|
||||
<ram:LineOne t-if="partner.street" t-esc="partner.street"/>
|
||||
<ram:LineTwo t-if="partner.street2" t-esc="partner.street2"/>
|
||||
<ram:CityName t-if="partner.city" t-esc="partner.city"/>
|
||||
<ram:CountryID t-if="partner.country_id" t-esc="partner.country_id.code"/>
|
||||
</ram:PostalTradeAddress>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="account_invoice_facturx_export">
|
||||
<t t-set="currency" t-value="record.currency_id or record.company_currency_id"/>
|
||||
<rsm:CrossIndustryInvoice
|
||||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<!-- Factur-x level:
|
||||
* minimum or basicwl: urn:factur-x.eu:1p0...
|
||||
* basic: urn:cen.eu:en16931:2017:compliant:factur-x.eu:1p0:basic
|
||||
* en16931: urn:cen.eu:en16931:2017
|
||||
-->
|
||||
<rsm:ExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
|
||||
<!-- Document Headers. -->
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID t-esc="record.reference"/>
|
||||
<ram:TypeCode t-esc="'381' if 'refund' in record.type else '380'"/>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102" t-esc="format_date(record.date_invoice)"/>
|
||||
</ram:IssueDateTime>
|
||||
<ram:IncludedNote t-if="record.comment">
|
||||
<ram:Content t-esc="record.comment"/>
|
||||
</ram:IncludedNote>
|
||||
</rsm:ExchangedDocument>
|
||||
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<!-- Invoice lines. -->
|
||||
<t t-set="line_counter" t-value="0"/>
|
||||
<t t-foreach="record.invoice_line_ids.filtered(lambda l: not l.display_type)" t-as="line">
|
||||
<t t-set="line_counter" t-value="line_counter + 1"/>
|
||||
<t t-call="account_facturx.account_invoice_line_facturx_export"/>
|
||||
</t>
|
||||
|
||||
<!-- Partners. -->
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<!-- Seller. -->
|
||||
<ram:SellerTradeParty>
|
||||
<!-- Address. -->
|
||||
<t t-call="account_facturx.account_invoice_partner_facturx_export">
|
||||
<t t-set="partner" t-value="record.company_id.partner_id"/>
|
||||
</t>
|
||||
|
||||
<!-- VAT. -->
|
||||
<ram:SpecifiedTaxRegistration t-if="record.company_id.vat">
|
||||
<ram:ID schemeID="VA" t-esc="record.company_id.vat"/>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:SellerTradeParty>
|
||||
|
||||
<!-- Customer. -->
|
||||
<ram:BuyerTradeParty>
|
||||
<!-- Address. -->
|
||||
<t t-call="account_facturx.account_invoice_partner_facturx_export">
|
||||
<t t-set="partner" t-value="record.commercial_partner_id"/>
|
||||
</t>
|
||||
|
||||
<!-- VAT. -->
|
||||
<ram:SpecifiedTaxRegistration t-if="record.commercial_partner_id.vat">
|
||||
<ram:ID schemeID="VA" t-esc="record.commercial_partner_id.vat"/>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:BuyerTradeParty>
|
||||
|
||||
<!-- Reference. -->
|
||||
<ram:BuyerOrderReferencedDocument>
|
||||
<ram:IssuerAssignedID t-esc="'%s: %s' % str(record.number, record.name) if record.name else record.number"/>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
|
||||
<!-- Delivery. Don't make a dependency with sale only for one field. -->
|
||||
<ram:ApplicableHeaderTradeDelivery>
|
||||
<ram:ShipToTradeParty
|
||||
t-if="'partner_shipping_id' in record._fields and record.partner_shipping_id">
|
||||
<t t-call="account_facturx.account_invoice_partner_facturx_export">
|
||||
<t t-set="partner" t-value="record.partner_shipping_id"/>
|
||||
</t>
|
||||
</ram:ShipToTradeParty>
|
||||
</ram:ApplicableHeaderTradeDelivery>
|
||||
|
||||
<!-- Taxes. -->
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
|
||||
<!-- Bank account. -->
|
||||
<ram:SpecifiedTradeSettlementPaymentMeans t-if="record.partner_bank_id.acc_type == 'iban'">
|
||||
<ram:PayeePartyCreditorFinancialAccount>
|
||||
<ram:IBANID t-esc="record.partner_bank_id.sanitized_acc_number"/>
|
||||
</ram:PayeePartyCreditorFinancialAccount>
|
||||
</ram:SpecifiedTradeSettlementPaymentMeans>
|
||||
|
||||
<!-- Tax Summary. -->
|
||||
<t t-foreach="record.tax_line_ids" t-as="tax_line">
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:CalculatedAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(tax_line.amount, currency)"/>
|
||||
<ram:BasisAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(tax_line.base, currency)"/>
|
||||
<ram:RateApplicablePercent
|
||||
t-if="tax_line.tax_id.amount_type == 'percent'"
|
||||
t-esc="tax_line.tax_id.amount"/>
|
||||
</ram:ApplicableTradeTax>
|
||||
</t>
|
||||
|
||||
<!-- Payment Term. -->
|
||||
<ram:SpecifiedTradePaymentTerms>
|
||||
<ram:Description t-if="record.payment_term_id" t-esc="record.payment_term_id.name"/>
|
||||
<ram:DueDateDateTime t-if="record.date_due">
|
||||
<udt:DateTimeString t-esc="format_date(record.date_due)"/>
|
||||
</ram:DueDateDateTime>
|
||||
</ram:SpecifiedTradePaymentTerms>
|
||||
|
||||
<!-- Summary. -->
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:LineTotalAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(record.amount_untaxed, currency)"/>
|
||||
<ram:TaxBasisTotalAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(sum(record.tax_line_ids.mapped('base')), currency)"/>
|
||||
<ram:TaxTotalAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(record.amount_tax, currency)"/>
|
||||
<ram:GrandTotalAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(record.amount_total, currency)"/>
|
||||
<ram:TotalPrepaidAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(record.amount_total - record.residual, currency)"/>
|
||||
<ram:DuePayableAmount
|
||||
t-att-currencyID="currency.name"
|
||||
t-esc="format_monetary(record.residual, currency)"/>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from . import account_invoice
|
||||
from . import ir_actions_report
|
||||
@@ -0,0 +1,262 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, models, fields, tools, _
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, pycompat, float_repr
|
||||
from odoo.tests.common import Form
|
||||
|
||||
from datetime import datetime
|
||||
from lxml import etree
|
||||
from PyPDF2 import PdfFileReader
|
||||
from collections import namedtuple
|
||||
|
||||
import io
|
||||
import base64
|
||||
|
||||
|
||||
DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d'
|
||||
|
||||
|
||||
class AccountInvoice(models.Model):
|
||||
_inherit = 'account.invoice'
|
||||
_name = 'account.invoice'
|
||||
|
||||
@api.multi
|
||||
def _export_as_facturx_xml(self):
|
||||
''' Create the Factur-x xml file content.
|
||||
:return: The XML content as str.
|
||||
'''
|
||||
self.ensure_one()
|
||||
|
||||
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)
|
||||
|
||||
# Create file content.
|
||||
template_values = {
|
||||
'record': self,
|
||||
'format_date': format_date,
|
||||
'format_monetary': format_monetary,
|
||||
}
|
||||
content = self.env.ref('account_facturx.account_invoice_facturx_export').render(template_values)
|
||||
return b"<?xml version='1.0' encoding='UTF-8'?>" + content
|
||||
|
||||
@api.multi
|
||||
def _import_facturx_invoice(self, tree):
|
||||
''' Extract invoice values from the Factur-x xml tree passed as parameter.
|
||||
|
||||
:param tree: The tree of the Factur-x xml file.
|
||||
:return: A dictionary containing account.invoice values to create/update it.
|
||||
'''
|
||||
amount_total_import = None
|
||||
|
||||
# type must be present in the context to get the right behavior of the _default_journal method (account.invoice).
|
||||
# journal_id must be present in the context to get the right behavior of the _default_account method (account.invoice.line).
|
||||
self_ctx = self.with_context(type='in_invoice')
|
||||
journal_id = self_ctx._default_journal().id
|
||||
self_ctx = self_ctx.with_context(journal_id=journal_id)
|
||||
|
||||
# self could be a single record (editing) or be empty (new).
|
||||
with Form(self_ctx, view='account.invoice_supplier_form') as invoice_form:
|
||||
|
||||
# Partner (first step to avoid warning 'Warning! You must first select a partner.').
|
||||
elements = tree.xpath('//ram:SellerTradeParty/ram:Name', namespaces=tree.nsmap)
|
||||
partner_name = elements and elements[0].text
|
||||
|
||||
elements = tree.xpath('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap)
|
||||
partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1)
|
||||
if not partner:
|
||||
partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if not partner:
|
||||
elements = tree.xpath('//ram:SellerTradeParty//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap)
|
||||
partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1)
|
||||
if partner:
|
||||
invoice_form.partner_id = partner
|
||||
|
||||
# Reference.
|
||||
elements = tree.xpath('//rsm:ExchangedDocument/ram:ID', namespaces=tree.nsmap)
|
||||
if elements:
|
||||
invoice_form.reference = elements[0].text
|
||||
|
||||
# Name.
|
||||
elements = tree.xpath('//ram:BuyerOrderReferencedDocument/ram:IssuerAssignedID', namespaces=tree.nsmap)
|
||||
if elements:
|
||||
invoice_form.name = elements[0].text
|
||||
|
||||
# Comment.
|
||||
elements = tree.xpath('//ram:IncludedNote/ram:Content', namespaces=tree.nsmap)
|
||||
if elements:
|
||||
invoice_form.comment = elements[0].text
|
||||
|
||||
# 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
|
||||
refund_sign = 1
|
||||
|
||||
# Total amount.
|
||||
elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap)
|
||||
if elements:
|
||||
total_amount = float(elements[0].text)
|
||||
|
||||
# Handle 'b' refund mode.
|
||||
if total_amount < 0 and type_code == '380':
|
||||
refund_sign = -1
|
||||
|
||||
# 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.user.company_id.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.date_invoice = 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.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
|
||||
|
||||
# Price Unit.
|
||||
line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap)
|
||||
if line_elements:
|
||||
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:
|
||||
invoice_line_form.price_unit = float(line_elements[0].text)
|
||||
|
||||
# Quantity.
|
||||
line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap)
|
||||
if line_elements:
|
||||
invoice_line_form.quantity = float(line_elements[0].text) * refund_sign
|
||||
|
||||
# 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.invoice_line_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', '=', 'purchase'),
|
||||
('amount', '=', percentage),
|
||||
], limit=1)
|
||||
|
||||
if tax:
|
||||
invoice_line_form.invoice_line_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
|
||||
|
||||
# Refund.
|
||||
invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice'
|
||||
|
||||
return invoice_form.save()
|
||||
|
||||
@api.multi
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def message_post(self, **kwargs):
|
||||
# OVERRIDE
|
||||
# /!\ 'default_res_id' in self._context is used to don't process attachment when using a form view.
|
||||
res = super(AccountInvoice, self).message_post(**kwargs)
|
||||
|
||||
def _get_attachment_filename(attachment):
|
||||
# Handle both _Attachment namedtuple in mail.thread or ir.attachment.
|
||||
return hasattr(attachment, 'fname') and getattr(attachment, 'fname') or attachment.name
|
||||
|
||||
def _get_attachment_content(attachment):
|
||||
# Handle both _Attachment namedtuple in mail.thread or ir.attachment.
|
||||
return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas)
|
||||
|
||||
if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft' and self.type in ('in_invoice', 'in_refund'):
|
||||
# Get attachments.
|
||||
# - 'attachments' is a namedtuple defined in mail.thread looking like:
|
||||
# _Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
|
||||
# - 'attachment_ids' is a list of ir.attachment records ids.
|
||||
attachments = kwargs.get('attachments', [])
|
||||
if kwargs.get('attachment_ids'):
|
||||
attachments += self.env['ir.attachment'].browse(kwargs['attachment_ids'])
|
||||
|
||||
for attachment in attachments:
|
||||
filename = _get_attachment_filename(attachment)
|
||||
content = _get_attachment_content(attachment)
|
||||
|
||||
# Check if the attachment is a pdf.
|
||||
if not filename.endswith('.pdf'):
|
||||
continue
|
||||
|
||||
buffer = io.BytesIO(content)
|
||||
try:
|
||||
reader = PdfFileReader(buffer)
|
||||
|
||||
# Search for Factur-x embedded file.
|
||||
if reader.trailer['/Root'].get('/Names') and reader.trailer['/Root']['/Names'].get('/EmbeddedFiles'):
|
||||
# N.B: embedded_files looks like:
|
||||
# ['file.xml', {'/Type': '/Filespec', '/F': 'file.xml', '/EF': {'/F': IndirectObject(22, 0)}}]
|
||||
embedded_files = reader.trailer['/Root']['/Names']['/EmbeddedFiles']['/Names']
|
||||
# '[::2]' because it's a list [fn_1, content_1, fn_2, content_2, ..., fn_n, content_2]
|
||||
for filename_obj, content_obj in list(pycompat.izip(embedded_files, embedded_files[1:]))[::2]:
|
||||
content = content_obj.getObject()['/EF']['/F'].getData()
|
||||
|
||||
if filename_obj == 'factur-x.xml':
|
||||
try:
|
||||
tree = etree.fromstring(content)
|
||||
except:
|
||||
continue
|
||||
|
||||
self._import_facturx_invoice(tree)
|
||||
buffer.close()
|
||||
return res
|
||||
except:
|
||||
# Malformed PDF.
|
||||
pass
|
||||
buffer.close()
|
||||
return res
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
|
||||
import io
|
||||
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_inherit = 'ir.actions.report'
|
||||
|
||||
@api.multi
|
||||
def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None):
|
||||
# OVERRIDE
|
||||
if self.model == 'account.invoice' and (res_ids or len(res_ids) == 1):
|
||||
invoice = self.env['account.invoice'].browse(res_ids)
|
||||
if invoice.type in ('out_invoice', 'out_refund') and invoice.state != 'draft':
|
||||
xml_content = invoice._export_as_facturx_xml()
|
||||
|
||||
# Add attachment.
|
||||
reader_buffer = io.BytesIO(pdf_content)
|
||||
reader = PdfFileReader(reader_buffer)
|
||||
writer = PdfFileWriter()
|
||||
writer.cloneReaderDocumentRoot(reader)
|
||||
writer.addAttachment('factur-x.xml', xml_content)
|
||||
buffer = io.BytesIO()
|
||||
writer.write(buffer)
|
||||
pdf_content = buffer.getvalue()
|
||||
|
||||
reader_buffer.close()
|
||||
buffer.close()
|
||||
return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids)
|
||||
@@ -162,7 +162,7 @@ class IrActionsReport(models.Model):
|
||||
|
||||
:param record_id: The record that will own the attachment.
|
||||
:param pdf_content: The optional name content of the file to avoid reading both times.
|
||||
:return: The newly generated attachment if no AccessError, else None.
|
||||
:return: A modified buffer if the previous one has been modified, None otherwise.
|
||||
'''
|
||||
attachment_name = safe_eval(self.attachment, {'object': record, 'time': time})
|
||||
if not attachment_name:
|
||||
@@ -174,14 +174,13 @@ class IrActionsReport(models.Model):
|
||||
'res_model': self.model,
|
||||
'res_id': record.id,
|
||||
}
|
||||
attachment = None
|
||||
try:
|
||||
attachment = self.env['ir.attachment'].create(attachment_vals)
|
||||
self.env['ir.attachment'].create(attachment_vals)
|
||||
except AccessError:
|
||||
_logger.info("Cannot save PDF report %r as attachment", attachment_vals['name'])
|
||||
else:
|
||||
_logger.info('The PDF document %s is now saved in the database', attachment_vals['name'])
|
||||
return attachment
|
||||
return buffer
|
||||
|
||||
@api.model
|
||||
def get_wkhtmltopdf_state(self):
|
||||
@@ -521,7 +520,11 @@ class IrActionsReport(models.Model):
|
||||
if len(res_ids) == 1:
|
||||
# Only one record, so postprocess directly and append the whole pdf.
|
||||
if res_ids[0] in record_map and not res_ids[0] in save_in_attachment:
|
||||
self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream)
|
||||
new_stream = self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream)
|
||||
# If the buffer has been modified, mark the old buffer to be closed as well.
|
||||
if new_stream and new_stream != pdf_content_stream:
|
||||
close_streams([pdf_content_stream])
|
||||
pdf_content_stream = new_stream
|
||||
streams.append(pdf_content_stream)
|
||||
else:
|
||||
# In case of multiple docs, we need to split the pdf according the records.
|
||||
@@ -542,7 +545,11 @@ class IrActionsReport(models.Model):
|
||||
stream = io.BytesIO()
|
||||
attachment_writer.write(stream)
|
||||
if res_ids[i] and res_ids[i] not in save_in_attachment:
|
||||
self.postprocess_pdf_report(record_map[res_ids[i]], stream)
|
||||
new_stream = self.postprocess_pdf_report(record_map[res_ids[i]], stream)
|
||||
# If the buffer has been modified, mark the old buffer to be closed as well.
|
||||
if new_stream and new_stream != stream:
|
||||
close_streams([stream])
|
||||
stream = new_stream
|
||||
streams.append(stream)
|
||||
close_streams([pdf_content_stream])
|
||||
else:
|
||||
@@ -557,14 +564,18 @@ class IrActionsReport(models.Model):
|
||||
streams.append(io.BytesIO(content))
|
||||
|
||||
# Build the final pdf.
|
||||
writer = PdfFileWriter()
|
||||
for stream in streams:
|
||||
reader = PdfFileReader(stream)
|
||||
writer.appendPagesFromReader(reader)
|
||||
result_stream = io.BytesIO()
|
||||
streams.append(result_stream)
|
||||
writer.write(result_stream)
|
||||
result = result_stream.getvalue()
|
||||
# If only one stream left, no need to merge them (and then, preserve embedded files).
|
||||
if len(streams) == 1:
|
||||
result = streams[0].getvalue()
|
||||
else:
|
||||
writer = PdfFileWriter()
|
||||
for stream in streams:
|
||||
reader = PdfFileReader(stream)
|
||||
writer.appendPagesFromReader(reader)
|
||||
result_stream = io.BytesIO()
|
||||
streams.append(result_stream)
|
||||
writer.write(result_stream)
|
||||
result = result_stream.getvalue()
|
||||
|
||||
# We have to close the streams after PdfFileWriter's call to write()
|
||||
close_streams(streams)
|
||||
|
||||
Reference in New Issue
Block a user