Compare commits

...

4 Commits

Author SHA1 Message Date
40c05f7860 remove unused file 2026-03-16 01:35:39 +01:00
bc82603f96 génération XML et emplate 2026-03-16 01:34:40 +01:00
b1029d10d3 generayion du XML a partir d'un template 2026-03-16 01:32:28 +01:00
8e79796781 génération du fichier XML pour le format factur-x 2026-03-14 00:41:41 +01:00
5 changed files with 497 additions and 4 deletions

225
generate_facturx_jinja2.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Générateur Factur-X BASIC via template Jinja2
"""
from jinja2 import Environment, FileSystemLoader
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import date
from collections import defaultdict
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
# ─────────────────────────────────────────────
# Structures de données (identiques au générateur lxml)
# ─────────────────────────────────────────────
@dataclass
class Address:
line1: str
city: str
postcode: str
country_code: str
line2: Optional[str] = None
@dataclass
class Party:
name: str
vat_id: Optional[str] = None
siret: Optional[str] = None
address: Optional[Address] = None
@dataclass
class InvoiceLine:
line_id: str
description: str
quantity: float
unit_code: str
unit_price: float
vat_rate: float
vat_category: str = "S"
# Raison d'exonération par catégorie TVA
EXEMPTION_REASONS = {
"E": "TVA non applicable, art. 293 B du CGI",
"Z": "Taux zéro",
"AE": "Autoliquidation",
"K": "Exonération intracommunautaire",
"G": "Exportation hors UE",
}
@dataclass
class TaxLine:
rate: float
category: str
base_amount: float
amount: float
exemption_reason: Optional[str] = None
@dataclass
class Invoice:
number: str
issue_date: date
currency: str
seller: Party
buyer: Party
lines: List[InvoiceLine] = field(default_factory=list)
due_date: Optional[date] = None
type_code: str = "380"
payment_means_code: str = "30"
iban: Optional[str] = None
payment_reference: Optional[str] = None
note: Optional[str] = None
# Calculés automatiquement (voir compute())
tax_lines: List[TaxLine] = field(default_factory=list)
total_ht: float = 0.0
total_tva: float = 0.0
total_ttc: float = 0.0
def compute(self):
"""Calcule les totaux et les lignes de TVA depuis les lignes de facture."""
tax_groups = defaultdict(float)
self.total_ht = 0.0
for ln in self.lines:
net = round(ln.quantity * ln.unit_price, 2)
self.total_ht += net
tax_groups[(ln.vat_rate, ln.vat_category)] += net
self.total_ht = round(self.total_ht, 2)
self.tax_lines = []
self.total_tva = 0.0
for (rate, category), base in tax_groups.items():
base = round(base, 2)
amount = round(base * rate / 100, 2)
self.total_tva += amount
self.tax_lines.append(TaxLine(
rate=rate,
category=category,
base_amount=base,
amount=amount,
# Raison d'exonération automatique si catégorie != "S" (standard)
exemption_reason=EXEMPTION_REASONS.get(category) if category != "S" else None,
))
self.total_tva = round(self.total_tva, 2)
self.total_ttc = round(self.total_ht + self.total_tva, 2)
# ─────────────────────────────────────────────
# Filtres Jinja2 personnalisés
# ─────────────────────────────────────────────
def filter_amount(value: float) -> str:
return f"{float(value):.2f}"
def filter_datefmt(value: date) -> str:
return value.strftime("%Y%m%d")
# ─────────────────────────────────────────────
# Générateur
# ─────────────────────────────────────────────
def generate_facturx_xml(invoice: Invoice, template_dir: str = ".", output_file: str = "factur-x.xml") -> str:
"""
Génère le XML Factur-X BASIC depuis le template Jinja2.
Retourne le XML sous forme de chaîne et l'écrit dans output_file.
"""
# Calcul des totaux
invoice.compute()
# Environnement Jinja2
env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=False, # XML géré manuellement
trim_blocks=True, # supprime le \n après les blocs {% %}
lstrip_blocks=True, # supprime les espaces avant {% %}
)
env.filters["amount"] = filter_amount
env.filters["datefmt"] = filter_datefmt
template = env.get_template("templates/xml/factur-x-basic.xml.j2")
xml_str = template.render(
invoice=invoice,
seller=invoice.seller,
buyer=invoice.buyer,
)
with open(output_file, "w", encoding="utf-8") as f:
f.write(xml_str)
return xml_str
# ─────────────────────────────────────────────
# Exemple d'utilisation
# ─────────────────────────────────────────────
if __name__ == "__main__":
invoice = Invoice(
number="FA-2024-00123",
issue_date=date(2024, 3, 15),
due_date=date(2024, 4, 14),
currency="EUR",
type_code="380",
note="Facture relative au contrat n°CTR-2024-01",
seller=Party(
name="Ma Société SAS",
# vat_id absent : pas de numéro TVA en franchise (art. 293 B CGI)
siret="12345678900012",
address=Address(
line1="10 rue de la Paix",
city="Paris",
postcode="75001",
country_code="FR",
),
),
buyer=Party(
name="Client SARL",
vat_id="FR98765432100",
siret="98765432100011",
address=Address(
line1="5 avenue des Fleurs",
city="Lyon",
postcode="69001",
country_code="FR",
),
),
payment_means_code="30",
iban="FR7612345678901234567890189",
payment_reference="CTR-2024-01",
)
# Ajout des lignes — franchise de TVA (art. 293 B du CGI)
# Utiliser vat_category="E" et vat_rate=0.0
articles = [
{"desc": "Développement logiciel - Sprint 1", "qty": 10, "unit": "HUR", "prix": 150.0, "tva": 0.0, "cat": "E"},
{"desc": "Licence logicielle annuelle", "qty": 1, "unit": "C62", "prix": 500.0, "tva": 0.0, "cat": "E"},
{"desc": "Formation utilisateurs", "qty": 2, "unit": "HUR", "prix": 200.0, "tva": 0.0, "cat": "E"},
]
for i, art in enumerate(articles, start=1):
invoice.lines.append(InvoiceLine(
line_id=str(i),
description=art["desc"],
quantity=art["qty"],
unit_code=art["unit"],
unit_price=art["prix"],
vat_rate=art["tva"],
vat_category=art["cat"], # "E" = exonéré (franchise TVA)
))
xml = generate_facturx_xml(invoice, template_dir=".", output_file="factur-x.xml")
print(f"✅ Fichier généré : factur-x.xml")
print(f" Total HT : {invoice.total_ht:.2f}")
print(f" TVA 20% : {invoice.total_tva:.2f}")
print(f" Total TTC : {invoice.total_ttc:.2f}")

View File

@@ -10,7 +10,8 @@ from models import Facture, LigneFacture, Client, StatutFacture
from numerotation import generer_numero_facture
from config import settings
from auth import get_current_user
from template_helper import render
from template_helper import render, render_xml
from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml
router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates")
@@ -155,9 +156,29 @@ def changer_statut_facture(
db.commit()
return RedirectResponse(f"/factures/{facture_id}", status_code=303)
from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt
@router.get("/{facture_id}/pdf")
def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)):
@router.get("/{facture_id}/facturx")
def telecharger_facturx(request: Request, facture_id: int, db: Session = Depends(get_db)):
facture = get_invoice_data(facture_id, db) # votre appel BDD
# facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
xml_bytes = generate_facturx_xml(facture)
filename=f"factur-x-{facture_id}.xml"
return Response(
content= xml_bytes,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/{facture_id}/pdf", response_class=HTMLResponse)
def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)):
from weasyprint import HTML
facture = db.query(Facture).get(facture_id)
if not facture:
@@ -171,6 +192,7 @@ def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)):
pdf_bytes = HTML(string=html_content, base_url=".").write_pdf()
filename = f"facture-{facture.numero}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
@@ -187,3 +209,55 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db))
"facture": facture,
"settings": settings,
})
def get_invoice_data(facture_id: int, db: Session):
facture = db.query(Facture).get(facture_id)
invoice = Invoice(
number=facture.numero,
issue_date=facture.date_emission,
due_date=facture.date_echeance,
currency="EUR",
type_code="380",
note=("Facture établie suite au devis " + facture.devis_origine.numero),
seller=Party(
name=settings.asso_nom,
#vat_id="FR12345678901",
siret=settings.asso_siret,
address=Address(
line1=settings.asso_adresse,
city=settings.asso_ville,
postcode=settings.asso_code_postal,
country_code="FR",
),
),
buyer=Party(
name=facture.client.nom,
#vat_id="FR98765432100",
siret=facture.client.siret,
address=Address(
line1=facture.client.adresse,
city=facture.client.ville,
postcode=facture.client.code_postal,
country_code="FR",
),
),
payment_means_code="30", # 30 = virement bancaire
iban=settings.asso_iban,
payment_reference=facture.devis_origine.numero,
)
for l in facture.lignes:
invoice.lines.append(InvoiceLine(
line_id=l.id,
description=l.description,
quantity=l.quantite,
unit_code="C62",
unit_price=l.prix_unitaire_ht,
vat_category="E",
vat_rate=0.0,
))
return invoice

View File

@@ -3,7 +3,7 @@ Helper pour injecter automatiquement current_user dans le contexte Jinja2.
"""
from fastapi import Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from starlette.responses import HTMLResponse, Response
def render(templates: Jinja2Templates, template_name: str,
@@ -17,3 +17,20 @@ def render(templates: Jinja2Templates, template_name: str,
response = templates.TemplateResponse(template_name, ctx)
response.status_code = status_code
return response
def render_xml(templates: Jinja2Templates, template_name: str,
request: Request, invoice: "Invoice",
filename: str = "factur-x.xml") -> Response:
xml_content = templates.env.get_template(template_name).render(
request=request,
invoice=invoice,
seller=invoice.seller,
buyer=invoice.buyer,
)
return Response(
content=xml_content,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)

View File

@@ -29,6 +29,7 @@
<td>
<a href="/factures/{{ f.id }}" class="btn btn-sm">Voir</a>
<a href="/factures/{{ f.id }}/pdf" class="btn btn-sm btn-primary">PDF</a>
<a href="/factures/{{ f.id }}/facturx" class="btn btn-sm btn-primary">facturx</a>
</td>
</tr>
{% endfor %}

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. CONTEXTE -->
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<!-- 2. EN-TÊTE FACTURE -->
<rsm:ExchangedDocument>
<ram:ID>{{ invoice.number }}</ram:ID>
<ram:TypeCode>{{ invoice.type_code }}</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">{{ invoice.issue_date | datefmt }}</udt:DateTimeString>
</ram:IssueDateTime>
{% if invoice.note %}
<ram:IncludedNote>
<ram:Content>{{ invoice.note }}</ram:Content>
</ram:IncludedNote>
{% endif %}
</rsm:ExchangedDocument>
<!-- 3. TRANSACTION COMMERCIALE -->
<rsm:SupplyChainTradeTransaction>
<!-- 3a. Lignes de facture -->
{% for line in invoice.lines %}
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>{{ line.line_id }}</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>{{ line.description }}</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>{{ line.unit_price | amount }}</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="{{ line.unit_code }}">{{ line.quantity | amount }}</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>{{ line.vat_category }}</ram:CategoryCode>
<ram:RateApplicablePercent>{{ line.vat_rate | amount }}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>{{ (line.quantity * line.unit_price) | amount }}</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
{% endfor %}
<!-- 3b. Accord commercial -->
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>{{ seller.name }}</ram:Name>
{% if seller.siret %}
<ram:SpecifiedLegalOrganization>
<ram:ID schemeID="0002">{{ seller.siret }}</ram:ID>
</ram:SpecifiedLegalOrganization>
{% endif %}
{% if seller.address %}
<ram:PostalTradeAddress>
<ram:PostcodeCode>{{ seller.address.postcode }}</ram:PostcodeCode>
<ram:LineOne>{{ seller.address.line1 }}</ram:LineOne>
{% if seller.address.line2 %}
<ram:LineTwo>{{ seller.address.line2 }}</ram:LineTwo>
{% endif %}
<ram:CityName>{{ seller.address.city }}</ram:CityName>
<ram:CountryID>{{ seller.address.country_code }}</ram:CountryID>
</ram:PostalTradeAddress>
{% endif %}
{% if seller.vat_id %}
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">{{ seller.vat_id }}</ram:ID>
</ram:SpecifiedTaxRegistration>
{% elif seller.siret %}
{# Franchise TVA (art. 293 B CGI) : pas de n° TVA, on utilise le SIRET avec schemeID="FC" #}
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">{{ seller.siret }}</ram:ID>
</ram:SpecifiedTaxRegistration>
{% endif %}
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>{{ buyer.name }}</ram:Name>
{% if buyer.siret %}
<ram:SpecifiedLegalOrganization>
<ram:ID schemeID="0002">{{ buyer.siret }}</ram:ID>
</ram:SpecifiedLegalOrganization>
{% endif %}
{% if buyer.address %}
<ram:PostalTradeAddress>
<ram:PostcodeCode>{{ buyer.address.postcode }}</ram:PostcodeCode>
<ram:LineOne>{{ buyer.address.line1 }}</ram:LineOne>
{% if buyer.address.line2 %}
<ram:LineTwo>{{ buyer.address.line2 }}</ram:LineTwo>
{% endif %}
<ram:CityName>{{ buyer.address.city }}</ram:CityName>
<ram:CountryID>{{ buyer.address.country_code }}</ram:CountryID>
</ram:PostalTradeAddress>
{% endif %}
</ram:BuyerTradeParty>
{% if invoice.payment_reference %}
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>{{ invoice.payment_reference }}</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
{% endif %}
</ram:ApplicableHeaderTradeAgreement>
<!-- 3c. Livraison -->
<ram:ApplicableHeaderTradeDelivery>
<ram:ShipToTradeParty>
<ram:Name>{{ buyer.name }}</ram:Name>
</ram:ShipToTradeParty>
</ram:ApplicableHeaderTradeDelivery>
<!-- 3d. Règlement -->
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>{{ invoice.currency }}</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>{{ invoice.payment_means_code }}</ram:TypeCode>
{% if invoice.iban %}
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>{{ invoice.iban }}</ram:IBANID>
</ram:PayeePartyCreditorFinancialAccount>
{% endif %}
</ram:SpecifiedTradeSettlementPaymentMeans>
{% for tax in invoice.tax_lines %}
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>{{ tax.amount | amount }}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
{% if tax.exemption_reason %}
<ram:ExemptionReason>{{ tax.exemption_reason }}</ram:ExemptionReason>
{% endif %}
<ram:BasisAmount>{{ tax.base_amount | amount }}</ram:BasisAmount>
<ram:CategoryCode>{{ tax.category }}</ram:CategoryCode>
<ram:RateApplicablePercent>{{ tax.rate | amount }}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
{% endfor %}
{% if invoice.due_date %}
<ram:SpecifiedTradePaymentTerms>
<ram:DueDateDateTime>
<udt:DateTimeString format="102">{{ invoice.due_date | datefmt }}</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
{% endif %}
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>{{ invoice.total_ht | amount }}</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>{{ invoice.total_ht | amount }}</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="{{ invoice.currency }}">{{ invoice.total_tva | amount }}</ram:TaxTotalAmount>
<ram:GrandTotalAmount>{{ invoice.total_ttc | amount }}</ram:GrandTotalAmount>
<ram:DuePayableAmount>{{ invoice.total_ttc | amount }}</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>