add factur-x support (#20562)
The following configuration must be added to settings : VENDOR_NAME = 'Entr\'ouvert' VENDOR_SIRET = '44317013900036' VENDOR_TVAI = 'FR09491081899'
This commit is contained in:
parent
3985523fae
commit
4cbf6bdb13
|
@ -217,6 +217,7 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin):
|
|||
'client',
|
||||
'contrat',
|
||||
'intitule',
|
||||
'numero_engagement',
|
||||
'notes',
|
||||
'taux_tva',
|
||||
'emission',
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
# barbacompta - invoicing for dummies
|
||||
# Copyright (C) 2010-2012 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
import facturx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GS_INCLUDE_DIR = os.path.join(os.path.dirname(__file__), 'gs-includes')
|
||||
DEFAULT_ICC_PROFILE = os.path.join(GS_INCLUDE_DIR, 'compatibleWithAdobeRGB1998.icc')
|
||||
|
||||
|
||||
def to_pdfa(pdf_bytes: bytes, icc_profile: str = DEFAULT_ICC_PROFILE):
|
||||
with tempfile.NamedTemporaryFile() as output_fd, tempfile.NamedTemporaryFile() as input_fd:
|
||||
input_fd.write(pdf_bytes)
|
||||
input_fd.flush()
|
||||
args = [
|
||||
'gs',
|
||||
'-dPDFA',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sICCProfile=' + icc_profile,
|
||||
'-sProcessColorModel=DeviceCMYK',
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dPDFACompatibilityPolicy=2',
|
||||
'-sOutputFile=' + output_fd.name,
|
||||
# give right to GS of reading local PDF_def.ps and ICC profile
|
||||
'-I' + GS_INCLUDE_DIR,
|
||||
'PDFA_def.ps',
|
||||
input_fd.name
|
||||
]
|
||||
logger.debug('converting to PDF/A, calling %s', args)
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
check=False)
|
||||
logger.debug('ghostscript result %s', completed)
|
||||
if completed.returncode != 0:
|
||||
logger.error('ghostcript call failed %s', completed)
|
||||
completed.check_returncode()
|
||||
return output_fd.read()
|
||||
|
||||
|
||||
def add_facturx_from_facture(facture):
|
||||
pdf_bytes = facture.pdf()
|
||||
|
||||
facturx_context = {
|
||||
'numero_de_facture': facture.code(),
|
||||
'type_facture': '380',
|
||||
'date_emission': facture.emission,
|
||||
'chorus_service_code': facture.client.chorus_structure.service_code,
|
||||
'vendeur': 'Entr\'ouvert',
|
||||
'vendeur_siret': '44317013900036',
|
||||
'vendeur_tvai': 'FR09491081899',
|
||||
'client_siret': facture.client.chorus_structure.siret,
|
||||
'client_name': facture.client.chorus_structure.name,
|
||||
'numero_engagement': '',
|
||||
'montant_ht': str(facture.montant),
|
||||
'montant_ttc': str(facture.montant_ttc),
|
||||
'montant_tva': str(facture.tva),
|
||||
}
|
||||
return add_facturx_from_bytes(pdf_bytes, facturx_context)
|
||||
|
||||
DEFAULT_FACTURX_TEMPLATE = 'factur-x.xml'
|
||||
|
||||
|
||||
def add_facturx_from_bytes(pdf_bytes: bytes, facturx_context: dict, template: str = DEFAULT_FACTURX_TEMPLATE):
|
||||
pdfa_bytes = to_pdfa(pdf_bytes)
|
||||
facturx_xml = str(render_to_string(template, facturx_context)).encode('utf-8')
|
||||
return facturx.generate_facturx_from_binary(pdfa_bytes, facturx_xml)
|
|
@ -0,0 +1,79 @@
|
|||
%!
|
||||
% This is a sample prefix file for creating a PDF/A document.
|
||||
% Users should modify entries marked with "Customize".
|
||||
% This assumes an ICC profile resides in the file (srgb.icc),
|
||||
% in the current directory unless the user modifies the corresponding line below.
|
||||
|
||||
% Define entries in the document Info dictionary :
|
||||
[ /Title (Title) % Customise
|
||||
/DOCINFO pdfmark
|
||||
|
||||
% Define an ICC profile :
|
||||
% /ICCProfile (/usr/share/color/icc/compatibleWithAdobeRGB1998.icc) % Customise
|
||||
% def
|
||||
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
|
||||
%% This code attempts to set the /N (number of components) key for the ICC colour space.
|
||||
%% To do this it checks the ColorConversionStrategy or the device ProcessColorModel if
|
||||
%% ColorConversionStrategy is not set.
|
||||
%% This is not 100% reliable. A better solution is for the user to edit this and replace
|
||||
%% the code between the ---8<--- lines with a simple declaration like:
|
||||
%% /N 3
|
||||
%% where the value of N is the number of components from the profile defined in /ICCProfile above.
|
||||
%%
|
||||
[{icc_PDFA}
|
||||
<<
|
||||
%% ----------8<--------------8<-------------8<--------------8<----------
|
||||
systemdict /ColorConversionStrategy known {
|
||||
systemdict /ColorConversionStrategy get cvn dup /Gray eq {
|
||||
pop /N 1 false
|
||||
}{
|
||||
dup /RGB eq {
|
||||
pop /N 3 false
|
||||
}{
|
||||
/CMYK eq {
|
||||
/N 4 false
|
||||
}{
|
||||
(ColorConversionStrategy not a device space, falling back to ProcessColorModel, output may not be valid PDF/A.)=
|
||||
true
|
||||
} ifelse
|
||||
} ifelse
|
||||
} ifelse
|
||||
} {
|
||||
(ColorConversionStrategy not set, falling back to ProcessColorModel, output may not be valid PDF/A.)=
|
||||
true
|
||||
} ifelse
|
||||
|
||||
{
|
||||
currentpagedevice /ProcessColorModel get
|
||||
dup /DeviceGray eq {
|
||||
pop /N 1
|
||||
}{
|
||||
dup /DeviceRGB eq {
|
||||
pop /N 3
|
||||
}{
|
||||
dup /DeviceCMYK eq {
|
||||
pop /N 4
|
||||
} {
|
||||
(ProcessColorModel not a device space.)=
|
||||
/ProcessColorModel cvx /rangecheck signalerror
|
||||
} ifelse
|
||||
} ifelse
|
||||
} ifelse
|
||||
} if
|
||||
%% ----------8<--------------8<-------------8<--------------8<----------
|
||||
|
||||
>> /PUT pdfmark
|
||||
[{icc_PDFA} ICCProfile (r) file /PUT pdfmark
|
||||
|
||||
% Define the output intent dictionary :
|
||||
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent % Must be so (the standard requires).
|
||||
/S /GTS_PDFA1 % Must be so (the standard requires).
|
||||
/DestOutputProfile {icc_PDFA} % Must be so (see above).
|
||||
/OutputConditionIdentifier (sRGB) % Customize
|
||||
>> /PUT pdfmark
|
||||
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
Binary file not shown.
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 2.2.9 on 2020-02-01 14:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import eo_gestion.eo_facture.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('eo_facture', '0007_auto_20191009_1335'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='client',
|
||||
name='service_code',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='client',
|
||||
name='siret',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=14, validators=[eo_gestion.eo_facture.validators.validate_siret]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contrat',
|
||||
name='numero_marche',
|
||||
field=models.CharField(blank=True, default='', max_length=128, verbose_name='Numéro du marché'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='facture',
|
||||
name='numero_engagement',
|
||||
field=models.CharField(blank=True, default='', max_length=128, verbose_name="Numéro d'engagement"),
|
||||
),
|
||||
]
|
|
@ -27,11 +27,14 @@ from django.core.validators import validate_email, RegexValidator
|
|||
from django.conf import settings
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.db.models.query import QuerySet
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import six
|
||||
|
||||
from weasyprint import HTML
|
||||
|
||||
from ..eo_banque import models as banque_models
|
||||
from . import fields
|
||||
from . import fields, validators, facturx
|
||||
from eo_gestion.utils import percentage_str
|
||||
|
||||
validate_telephone = RegexValidator(r"[. 0-9]*")
|
||||
|
@ -57,10 +60,26 @@ class Client(models.Model):
|
|||
verbose_name='TVA par défaut', max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA)
|
||||
)
|
||||
picture = models.ImageField('Logo', upload_to='logos/', blank=True, null=True)
|
||||
siret = models.CharField(
|
||||
max_length=len('29202001300010'),
|
||||
validators=[validators.validate_siret],
|
||||
db_index=True,
|
||||
blank=True,
|
||||
default='',
|
||||
)
|
||||
service_code = models.CharField(
|
||||
max_length=128,
|
||||
db_index=True,
|
||||
blank=True,
|
||||
default='',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.nom
|
||||
|
||||
def clean(self):
|
||||
self.service_code = self.service_code.strip()
|
||||
|
||||
class Meta:
|
||||
ordering = ('nom',)
|
||||
|
||||
|
@ -85,6 +104,11 @@ class Contrat(models.Model):
|
|||
percentage_per_year = fields.PercentagePerYearField(default=one_hundred_percent_this_year)
|
||||
montant_sous_traite = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0'))
|
||||
image = models.ImageField('Image', upload_to='images/', blank=True, null=True)
|
||||
numero_marche = models.CharField(
|
||||
max_length=128,
|
||||
verbose_name='Numéro du marché',
|
||||
blank=True,
|
||||
default='')
|
||||
|
||||
def montant_facture(self):
|
||||
return sum([facture.montant for facture in self.factures.non_proforma()])
|
||||
|
@ -112,6 +136,9 @@ class Contrat(models.Model):
|
|||
"""
|
||||
return self.client.nom
|
||||
|
||||
def clean(self):
|
||||
self.numero_marche = self.numero_marche.strip()
|
||||
|
||||
def __str__(self):
|
||||
return self.intitule
|
||||
|
||||
|
@ -200,6 +227,11 @@ class Facture(models.Model):
|
|||
account_on_previous_period = models.BooleanField(
|
||||
verbose_name='Mettre cette facture sur l\'exercice précédent', default=False
|
||||
)
|
||||
numero_engagement = models.CharField(
|
||||
max_length=128,
|
||||
verbose_name='Numéro d\'engagement',
|
||||
blank=True,
|
||||
default='')
|
||||
|
||||
objects = FactureQuerySet.as_manager()
|
||||
|
||||
|
@ -243,6 +275,7 @@ class Facture(models.Model):
|
|||
except ValidationError:
|
||||
raise ValidationError("Il y a un problème avec les lignes de cette facture")
|
||||
self.update_paid(save=False)
|
||||
self.numero_engagement = self.numero_engagement.strip()
|
||||
|
||||
def index(self):
|
||||
if self.contrat:
|
||||
|
@ -293,6 +326,35 @@ class Facture(models.Model):
|
|||
return self.emission.year - 1
|
||||
return self.emission.year
|
||||
|
||||
DEFAULT_FACTURE_TEMPLATE = 'facture.html'
|
||||
|
||||
def html(self, template_name=None, base_uri=None):
|
||||
template = get_template(template_name or self.DEFAULT_FACTURE_TEMPLATE)
|
||||
return template.render({'facture': self, 'base_uri': base_uri or ''})
|
||||
|
||||
def pdf(self, template_name=None, base_uri=None):
|
||||
html = HTML(string=self.html(template_name=template_name, base_uri=base_uri))
|
||||
pdf = html.write_pdf()
|
||||
if not self.proforma:
|
||||
facturx_ctx = {
|
||||
'numero_de_facture': self.code(),
|
||||
'type_facture': '380',
|
||||
'date_emission': self.emission,
|
||||
'vendeur': settings.VENDOR_NAME,
|
||||
'vendeur_siret': settings.VENDOR_SIRET,
|
||||
'vendeur_tvai': settings.VENDOR_TVAI,
|
||||
'client_siret': self.client.siret,
|
||||
'client_name': self.client.nom,
|
||||
'chorus_service_code': self.client.service_code,
|
||||
'numero_engagement': self.numero_engagement,
|
||||
'numero_marche': (self.contrat and self.contrat.numero_marche) or '',
|
||||
'montant_ht': str(self.montant),
|
||||
'montant_ttc': str(self.montant_ttc),
|
||||
'montant_tva': str(self.tva),
|
||||
}
|
||||
return facturx.add_facturx_from_bytes(pdf, facturx_ctx)
|
||||
return pdf
|
||||
|
||||
class Meta:
|
||||
ordering = ("-id",)
|
||||
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
<rsm:CrossIndustryInvoice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
|
||||
<rsm:ExchangedDocumentContext>
|
||||
<ram:BusinessProcessSpecifiedDocumentContextParameter>
|
||||
{% comment %}
|
||||
CHORUSPRO : cette donnée permet de renseigner le cadre de facturation (facture de mandataire, de cotraitant, de sous-traitant, pièce de facturation d'un marché de travaux, etc.). Les codes à utiliser sont définis dans les spécifications CHORUSPRO : A1 (dépôt facture), A2 (dépôt facture déjà payée), ... Par défaut (en cas d'absence de ce champ), c'est le cas A1 qui s'applique.
|
||||
{% endcomment %}
|
||||
<ram:ID>{{ chorus_processus|default:"A1" }}</ram:ID>
|
||||
</ram:BusinessProcessSpecifiedDocumentContextParameter>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:factur-x.eu:1p0:minimum</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>{{ numero_de_facture }}</ram:ID>
|
||||
{% comment %}
|
||||
Les types de document utilisés sont les suivants :
|
||||
380 : Facture commerciale
|
||||
381 : Avoir (note de crédit)
|
||||
384 : Facture rectificative
|
||||
389 : Facture d'autofacturation (créée par l'acheteur pour le compte du fournisseur)
|
||||
261 : Avoir d'autofacturation (non accepté par CHORUSPRO)
|
||||
386 : Facture d'acompte
|
||||
751 : Informations de facture pour comptabilisation (non accepté par CHORUSPRO)
|
||||
751 : Informations de facture pour comptabilisation (non accepté par CHORUSPRO)
|
||||
{% endcomment %}
|
||||
<ram:TypeCode>{{ type_facture }}</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">{{ date_emission|date:"Ymd" }}</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
{% comment %}
|
||||
BuyerReference:
|
||||
CHORUS PRO : pour le secteur public, il s'agit du "Service Executant". Il est obligatoire pour certains acheteurs. Il doit appartenir au référentiel Chorus Pro. Il est limité à 100 caractères
|
||||
{% endcomment %}
|
||||
{% if chorus_service_code %}
|
||||
<ram:BuyerReference>{{ chorus_service_code }}</ram:BuyerReference>
|
||||
{% endif %}
|
||||
<ram:SellerTradeParty>
|
||||
{% comment %}Entr'ouvert{% endcomment %}
|
||||
<ram:Name>{{ vendeur }}</ram:Name>
|
||||
<ram:SpecifiedLegalOrganization>
|
||||
<ram:ID schemeID="0002">{{ vendeur_siret }}</ram:ID>
|
||||
</ram:SpecifiedLegalOrganization>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:CountryID>FR</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
{% if vendeur_tvai %}
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
{% comment %}Numéro de TVA intracommunautaie{% endcomment %}
|
||||
<ram:ID schemeID="VA">{{ vendeur_tvai }}</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
{% endif %}
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
{% comment %}SIRET de l'acheteur{% endcomment %}
|
||||
<ram:Name>{{ client_name }}</ram:Name>
|
||||
<ram:SpecifiedLegalOrganization>
|
||||
<ram:ID schemeID="0002">{{ client_siret }}</ram:ID>
|
||||
</ram:SpecifiedLegalOrganization>
|
||||
</ram:BuyerTradeParty>
|
||||
{% comment %}
|
||||
BuyerOrderReferencedDocument
|
||||
CHORUS PRO : pour le secteur public, il s'agit de "l'Engagement Juridique". Il est obligatoire pour certains acheteurs. Il convient de se référer à l'annuaire Chorus Pro pour identifier ces acheteurs.
|
||||
{% endcomment %}
|
||||
{% if numero_engagement %}
|
||||
<ram:BuyerOrderReferencedDocument >
|
||||
<ram:IssuerAssignedID>{{ numero_engagement }}</ram:IssuerAssignedID>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
{% endif %}
|
||||
{% if numero_marche %}
|
||||
<ram:ContractReferencedDocument>
|
||||
<ram:IssuerAssignedID>{{ numero_marche }}</ram:IssuerAssignedID>
|
||||
</ram:ContractReferencedDocument>
|
||||
{% endif %}
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeDelivery/>
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:TaxBasisTotalAmount>{{ montant_ht }}</ram:TaxBasisTotalAmount>
|
||||
<ram:TaxTotalAmount currencyID="EUR">{{ montant_tva }}</ram:TaxTotalAmount>
|
||||
<ram:GrandTotalAmount>{{ montant_ttc }}</ram:GrandTotalAmount>
|
||||
<ram:DuePayableAmount>{{ montant_ttc }}</ram:DuePayableAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>
|
|
@ -0,0 +1,59 @@
|
|||
# barbacompta - accounting for dummies
|
||||
# Copyright (C) 2010-2019 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
def validate_luhn(string_value, length=None):
|
||||
'''Verify Luhn checksum on a string representing a number'''
|
||||
if not string_value:
|
||||
return False
|
||||
if length is not None and len(string_value) != length:
|
||||
return False
|
||||
if not string_value.isdigit():
|
||||
return False
|
||||
|
||||
# take all digits counting from the right, double value for digits pair
|
||||
# index (counting from 1), if double has 2 digits take their sum
|
||||
checksum = 0
|
||||
for i, x in enumerate(reversed(string_value)):
|
||||
if i % 2 == 0:
|
||||
checksum += int(x)
|
||||
else:
|
||||
checksum += sum(int(y) for y in str(2 * int(x)))
|
||||
if checksum % 10 != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_siren(string_value):
|
||||
if not validate_luhn(string_value, length=9):
|
||||
raise ValidationError(_('invalid SIREN'))
|
||||
|
||||
|
||||
def validate_siret(string_value):
|
||||
# special case : La Poste
|
||||
def helper():
|
||||
if not string_value.isdigit():
|
||||
return False
|
||||
if (string_value.startswith('356000000')
|
||||
and len(string_value) == 14
|
||||
and sum(int(x) for x in string_value) % 5 == 0):
|
||||
return True
|
||||
return validate_luhn(string_value, length=14)
|
||||
if not helper():
|
||||
raise ValidationError(_('invalid SIRET'))
|
|
@ -1,5 +1,5 @@
|
|||
# barbacompta - accounting for dummies
|
||||
# Copyright (C) 2010-2019 Entr'ouvert
|
||||
# barbacompta - invoicing for dummies
|
||||
# Copyright (C) 2010-2020 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
|
@ -15,54 +15,36 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import cgi
|
||||
import json
|
||||
import os.path
|
||||
|
||||
from django import http
|
||||
from django.shortcuts import render
|
||||
from django.template.loader import get_template
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.conf import settings
|
||||
from weasyprint import HTML
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def render_to_pdf(template_src, context_dict):
|
||||
template = get_template(template_src)
|
||||
html = HTML(string=template.render(context_dict))
|
||||
try:
|
||||
pdf = html.write_pdf()
|
||||
if hasattr(settings, 'FACTURE_DIR'):
|
||||
facture = context_dict['facture']
|
||||
filename = os.path.join(
|
||||
settings.FACTURE_DIR,
|
||||
'%s-%s.pdf' % (facture.code(), facture.contrat.client.nom.encode('utf8')),
|
||||
)
|
||||
with open(filename, 'wb') as fd:
|
||||
fd.write(pdf)
|
||||
return http.HttpResponse(pdf, content_type='application/pdf')
|
||||
|
||||
except IOError:
|
||||
return http.HttpResponse('We had some errors<pre>%s</pre>' % cgi.escape(html))
|
||||
from .models import Contrat, Facture
|
||||
|
||||
|
||||
def facture(request, facture):
|
||||
context = {'facture': models.Facture.objects.get(id=facture)}
|
||||
response = render(request, 'facture.html', context=context)
|
||||
return response
|
||||
return http.Response(get_object_or_404(Facture, id=facture).html(base_uri=request.build_absolute_uri('/')))
|
||||
|
||||
|
||||
def facture_pdf(request, facture):
|
||||
context = {'facture': models.Facture.objects.get(id=facture), 'base_uri': request.build_absolute_uri('/')}
|
||||
response = render_to_pdf('facture.html', context)
|
||||
return response
|
||||
pdf = get_object_or_404(Facture, id=facture).pdf(base_uri=request.build_absolute_uri('/'))
|
||||
if hasattr(settings, 'FACTURE_DIR'):
|
||||
filename = os.path.join(
|
||||
settings.FACTURE_DIR,
|
||||
'%s-%s.pdf' % (facture.code(), facture.contrat.client.nom.encode('utf8')),
|
||||
)
|
||||
with open(filename, 'wb') as fd:
|
||||
fd.write(pdf)
|
||||
return http.HttpResponse(pdf, content_type='application/pdf')
|
||||
|
||||
|
||||
def api_references(request):
|
||||
data = []
|
||||
for contract in (
|
||||
models.Contrat.objects.exclude(public_description='')
|
||||
Contrat.objects.exclude(public_description='')
|
||||
.exclude(client__picture__isnull=True)
|
||||
.exclude(client__picture='')
|
||||
):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os.path
|
||||
import facturx
|
||||
|
||||
# Django settings for facturation project.
|
||||
|
||||
|
@ -43,6 +44,11 @@ LOGGING = {
|
|||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'factur-x': {
|
||||
'level': 'WARNING',
|
||||
'handlers': [],
|
||||
'propagate': False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,118 @@
|
|||
# barbacompta - invoicing for dummies
|
||||
# Copyright (C) 2010-2020 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import pytest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import facturx
|
||||
|
||||
from eo_gestion.eo_facture.facturx import to_pdfa, add_facturx_from_bytes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_invoice_bytes():
|
||||
with open('tests/fake-invoice.pdf', 'rb') as fd:
|
||||
return fd.read()
|
||||
|
||||
|
||||
def test_to_pdfa(fake_invoice_bytes):
|
||||
to_pdfa(fake_invoice_bytes)
|
||||
|
||||
|
||||
def test_add_facturx_from_bytes(fake_invoice_bytes):
|
||||
facturx_ctx = {
|
||||
'numero_de_facture': 'F20190001',
|
||||
'type_facture': '380',
|
||||
'date_emission': datetime.date(2019, 1, 1),
|
||||
'chorus_service_code': 'service-code',
|
||||
'vendeur': 'Entr\'ouvert',
|
||||
'vendeur_siret': '44317013900036',
|
||||
'vendeur_tvai': 'FR09491081899',
|
||||
'client_siret': '1234',
|
||||
'client_name': 'RGFIPD',
|
||||
'numero_engagement': '5678',
|
||||
'numero_marche': 'ABCD',
|
||||
'montant_ht': '10',
|
||||
'montant_ttc': '12',
|
||||
'montant_tva': '2',
|
||||
}
|
||||
facturx_bytes = add_facturx_from_bytes(fake_invoice_bytes, facturx_ctx)
|
||||
xml_filename, xml_str = facturx.get_facturx_xml_from_pdf(io.BytesIO(facturx_bytes))
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
def helper(root):
|
||||
tag = root.tag.split('}')[-1]
|
||||
if len(root) == 0:
|
||||
return [tag, root.text or '']
|
||||
else:
|
||||
return [tag] + [helper(node) for node in root]
|
||||
|
||||
assert helper(root) == [
|
||||
'CrossIndustryInvoice',
|
||||
[
|
||||
'ExchangedDocumentContext',
|
||||
['BusinessProcessSpecifiedDocumentContextParameter', ['ID', 'A1']],
|
||||
['GuidelineSpecifiedDocumentContextParameter',
|
||||
['ID', 'urn:factur-x.eu:1p0:minimum']]
|
||||
],
|
||||
[
|
||||
'ExchangedDocument',
|
||||
['ID', 'F20190001'],
|
||||
['TypeCode', '380'],
|
||||
['IssueDateTime', ['DateTimeString', '20190101']]
|
||||
],
|
||||
[
|
||||
'SupplyChainTradeTransaction',
|
||||
[
|
||||
'ApplicableHeaderTradeAgreement',
|
||||
['BuyerReference', 'service-code'],
|
||||
[
|
||||
'SellerTradeParty',
|
||||
['Name', "Entr'ouvert"],
|
||||
[
|
||||
'SpecifiedLegalOrganization',
|
||||
['ID', '44317013900036']
|
||||
],
|
||||
['PostalTradeAddress', ['CountryID', 'FR']],
|
||||
['SpecifiedTaxRegistration', ['ID', 'FR09491081899']]
|
||||
],
|
||||
[
|
||||
'BuyerTradeParty',
|
||||
['Name', 'RGFIPD'],
|
||||
[
|
||||
'SpecifiedLegalOrganization',
|
||||
['ID', '1234'],
|
||||
],
|
||||
],
|
||||
['BuyerOrderReferencedDocument', ['IssuerAssignedID', '5678']],
|
||||
['ContractReferencedDocument', ['IssuerAssignedID', 'ABCD']]
|
||||
],
|
||||
['ApplicableHeaderTradeDelivery', ''],
|
||||
[
|
||||
'ApplicableHeaderTradeSettlement',
|
||||
['InvoiceCurrencyCode', 'EUR'],
|
||||
[
|
||||
'SpecifiedTradeSettlementHeaderMonetarySummation',
|
||||
['TaxBasisTotalAmount', '10'],
|
||||
['TaxTotalAmount', '2'],
|
||||
['GrandTotalAmount', '12'],
|
||||
['DuePayableAmount', '12']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
Loading…
Reference in New Issue