generayion du XML a partir d'un template

This commit is contained in:
2026-03-16 01:32:28 +01:00
parent 8e79796781
commit b1029d10d3
4 changed files with 45 additions and 358 deletions

View File

@@ -1,345 +0,0 @@
"""
Usage
pip install lxml
python generate_facturx_basic.py
"""
"""
Générateur de XML Factur-X - Profil BASIC
Conforme à la norme EN 16931, syntaxe CII (Cross Industry Invoice)
Factur-X 1.0 / ZUGFeRD 2.x
"""
from lxml import etree
from datetime import date
from dataclasses import dataclass, field
from typing import List, Optional
# ─────────────────────────────────────────────
# Namespaces CII obligatoires
# ─────────────────────────────────────────────
NSMAP = {
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
"qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
}
RSM = NSMAP["rsm"]
RAM = NSMAP["ram"]
UDT = NSMAP["udt"]
PROFILE_BASIC = "urn:factur-x.eu:1p0:basic"
# ─────────────────────────────────────────────
# Structures de données
# ─────────────────────────────────────────────
@dataclass
class Address:
line1: str
city: str
postcode: str
country_code: str # ISO 3166-1 alpha-2, ex: "FR"
line2: Optional[str] = None
@dataclass
class Party:
name: str
vat_id: Optional[str] = None # ex: "FR12345678901"
siret: Optional[str] = None # schemeID="0002"
address: Optional[Address] = None
@dataclass
class TaxLine:
base_amount: float
rate: float # ex: 20.0 pour 20%
amount: float
category: str = "E" # S=standard, Z=zéro, E=exonéré, AE=autoliquidation
@dataclass
class InvoiceLine:
line_id: str
description: str
quantity: float
unit_code: str # ex: "C62"=unité, "HUR"=heure, "KGM"=kg
unit_price: float # prix HT unitaire
vat_rate: float # ex: 20.0
vat_category: str = "S"
# Le montant HT de la ligne est calculé automatiquement
@dataclass
class Invoice:
# En-tête
number: str
issue_date: date
due_date: Optional[date]
currency: str # ex: "EUR"
type_code: str = "380" # 380=facture, 381=avoir, 384=facture rectificative
# Parties
seller: Party = None
buyer: Party = None
# Lignes
lines: List[InvoiceLine] = field(default_factory=list)
# Paiement
payment_means_code: str = "30" # 30=virement
iban: Optional[str] = None
bic: Optional[str] = None
payment_reference: Optional[str] = None
# Note (champ libre)
note: Optional[str] = None
# ─────────────────────────────────────────────
# Helpers XML
# ─────────────────────────────────────────────
def sub(parent, ns, tag, text=None, **attribs):
el = etree.SubElement(parent, f"{{{ns}}}{tag}", **attribs)
if text is not None:
el.text = str(text)
return el
def date_str(d: date) -> str:
return d.strftime("%Y%m%d")
def amount_str(v: float) -> str:
return f"{v:.2f}"
# ─────────────────────────────────────────────
# Construction du XML
# ─────────────────────────────────────────────
def build_facturx(inv: Invoice) -> etree._ElementTree:
root = etree.Element(f"{{{RSM}}}CrossIndustryInvoice", nsmap=NSMAP)
# ── 1. ExchangedDocumentContext ──────────────
ctx = sub(root, RSM, "ExchangedDocumentContext")
gp = sub(ctx, RAM, "GuidelineSpecifiedDocumentContextParameter")
sub(gp, RAM, "ID", PROFILE_BASIC)
# ── 2. ExchangedDocument ────────────────────
doc = sub(root, RSM, "ExchangedDocument")
sub(doc, RAM, "ID", inv.number)
sub(doc, RAM, "TypeCode", inv.type_code)
idt = sub(doc, RAM, "IssueDateTime")
sub(idt, UDT, "DateTimeString", date_str(inv.issue_date), format="102")
if inv.note:
sub(doc, RAM, "IncludedNote").append(
etree.fromstring(f'<ram:Content xmlns:ram="{RAM}">{inv.note}</ram:Content>')
)
# ── 3. SupplyChainTradeTransaction ──────────
tx = sub(root, RSM, "SupplyChainTradeTransaction")
# ── 3a. Lignes de facture (BASIC = lignes obligatoires) ──
for ln in inv.lines:
net = round(ln.quantity * ln.unit_price, 2)
ili = sub(tx, RAM, "IncludedSupplyChainTradeLineItem")
assoc = sub(ili, RAM, "AssociatedDocumentLineDocument")
sub(assoc, RAM, "LineID", ln.line_id)
prod = sub(ili, RAM, "SpecifiedTradeProduct")
sub(prod, RAM, "Name", ln.description)
la = sub(ili, RAM, "SpecifiedLineTradeAgreement")
gpp = sub(la, RAM, "GrossPriceProductTradePrice")
sub(gpp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency)
npp = sub(la, RAM, "NetPriceProductTradePrice")
sub(npp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency)
ld = sub(ili, RAM, "SpecifiedLineTradeDelivery")
sub(ld, RAM, "BilledQuantity", amount_str(ln.quantity), unitCode=ln.unit_code)
ls = sub(ili, RAM, "SpecifiedLineTradeSettlement")
tax = sub(ls, RAM, "ApplicableTradeTax")
sub(tax, RAM, "TypeCode", "VAT")
sub(tax, RAM, "CategoryCode", ln.vat_category)
sub(tax, RAM, "RateApplicablePercent", amount_str(ln.vat_rate))
smls = sub(ls, RAM, "SpecifiedTradeSettlementLineMonetarySummation")
sub(smls, RAM, "LineTotalAmount", amount_str(net), currencyID=inv.currency)
# ── 3b. ApplicableHeaderTradeAgreement ──────
agr = sub(tx, RAM, "ApplicableHeaderTradeAgreement")
def _party(parent_el, tag, party: Party):
p = sub(parent_el, RAM, tag)
sub(p, RAM, "Name", party.name)
if party.siret:
sr = sub(p, RAM, "SpecifiedLegalOrganization")
sub(sr, RAM, "ID", party.siret, schemeID="0002")
if party.vat_id:
tr = sub(p, RAM, "SpecifiedTaxRegistration")
sub(tr, RAM, "ID", party.vat_id, schemeID="VA")
if party.address:
addr = sub(p, RAM, "PostalTradeAddress")
sub(addr, RAM, "PostcodeCode", party.address.postcode)
sub(addr, RAM, "LineOne", party.address.line1)
if party.address.line2:
sub(addr, RAM, "LineTwo", party.address.line2)
sub(addr, RAM, "CityName", party.address.city)
sub(addr, RAM, "CountryID", party.address.country_code)
return p
_party(agr, "SellerTradeParty", inv.seller)
_party(agr, "BuyerTradeParty", inv.buyer)
if inv.payment_reference:
bor = sub(agr, RAM, "BuyerOrderReferencedDocument")
sub(bor, RAM, "IssuerAssignedID", inv.payment_reference)
# ── 3c. ApplicableHeaderTradeDelivery ───────
sub(tx, RAM, "ApplicableHeaderTradeDelivery")
# ── 3d. ApplicableHeaderTradeSettlement ─────
stl = sub(tx, RAM, "ApplicableHeaderTradeSettlement")
sub(stl, RAM, "InvoiceCurrencyCode", inv.currency)
# Moyens de paiement
pm = sub(stl, RAM, "SpecifiedTradeSettlementPaymentMeans")
sub(pm, RAM, "TypeCode", inv.payment_means_code)
if inv.iban:
acc = sub(pm, RAM, "PayeePartyCreditorFinancialAccount")
sub(acc, RAM, "IBANID", inv.iban)
if inv.bic:
fi = sub(pm, RAM, "PayeeSpecifiedCreditorFinancialInstitution")
sub(fi, RAM, "BICID", inv.bic)
# TVA par taux (calcul à partir des lignes)
tax_totals: dict[tuple, list] = {}
for ln in inv.lines:
key = (ln.vat_rate, ln.vat_category)
tax_totals.setdefault(key, []).append(round(ln.quantity * ln.unit_price, 2))
total_ht = 0.0
total_tva = 0.0
for (rate, cat), bases in tax_totals.items():
base = round(sum(bases), 2)
tva = round(base * rate / 100, 2)
total_ht += base
total_tva += tva
tax = sub(stl, RAM, "ApplicableTradeTax")
sub(tax, RAM, "CalculatedAmount", amount_str(tva), currencyID=inv.currency)
sub(tax, RAM, "TypeCode", "VAT")
sub(tax, RAM, "BasisAmount", amount_str(base), currencyID=inv.currency)
sub(tax, RAM, "CategoryCode", cat)
sub(tax, RAM, "RateApplicablePercent", amount_str(rate))
total_ht = round(total_ht, 2)
total_tva = round(total_tva, 2)
total_ttc = round(total_ht + total_tva, 2)
# Échéance
if inv.due_date:
stp = sub(stl, RAM, "SpecifiedTradePaymentTerms")
ddt = sub(stp, RAM, "DueDateDateTime")
sub(ddt, UDT, "DateTimeString", date_str(inv.due_date), format="102")
# Totaux
sums = sub(stl, RAM, "SpecifiedTradeSettlementHeaderMonetarySummation")
sub(sums, RAM, "LineTotalAmount", amount_str(total_ht), currencyID=inv.currency)
sub(sums, RAM, "TaxBasisTotalAmount",amount_str(total_ht), currencyID=inv.currency)
sub(sums, RAM, "TaxTotalAmount", amount_str(total_tva), currencyID=inv.currency)
sub(sums, RAM, "GrandTotalAmount", amount_str(total_ttc), currencyID=inv.currency)
sub(sums, RAM, "DuePayableAmount", amount_str(total_ttc), currencyID=inv.currency)
return etree.ElementTree(root)
# ─────────────────────────────────────────────
# 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="FR12345678901",
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",
),
),
lines=[
InvoiceLine(
line_id="1",
description="Développement logiciel - Sprint 1",
quantity=10,
unit_code="HUR", # heures
unit_price=150.00,
vat_rate=0.0,
),
InvoiceLine(
line_id="2",
description="Licence logicielle annuelle",
quantity=1,
unit_code="C62", # unité
unit_price=500.00,
vat_rate=0.0,
),
InvoiceLine(
line_id="3",
description="Formation utilisateurs",
quantity=2,
unit_code="HUR",
unit_price=200.00,
vat_rate=0.0,
),
],
payment_means_code="30", # 30 = virement bancaire
iban="FR7612345678901234567890189",
bic="BNPAFRPPXXX",
payment_reference="CTR-2024-01",
)
tree = build_facturx(invoice)
output_file = "factur-x.xml"
tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True)
print(f"✅ Fichier généré : {output_file}")
print(f" Facture : {invoice.number}")
print(f" Émetteur : {invoice.seller.name}")
print(f" Destinataire : {invoice.buyer.name}")
# Calcul récapitulatif
total_ht = sum(round(l.quantity * l.unit_price, 2) for l in invoice.lines)
total_tva = round(total_ht * 0.20, 2)
print(f" Total HT : {total_ht:.2f}")
print(f" TVA 20% : {total_tva:.2f}")
print(f" Total TTC : {total_ht + total_tva:.2f}")

View File

@@ -10,8 +10,8 @@ from models import Facture, LigneFacture, Client, StatutFacture
from numerotation import generer_numero_facture from numerotation import generer_numero_facture
from config import settings from config import settings
from auth import get_current_user from auth import get_current_user
from template_helper import render from template_helper import render, render_xml
from generate_facturx_basic import Address, Party, Invoice, InvoiceLine, build_facturx from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml
router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -156,9 +156,29 @@ def changer_statut_facture(
db.commit() db.commit()
return RedirectResponse(f"/factures/{facture_id}", status_code=303) 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") @router.get("/{facture_id}/facturx")
def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): 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 from weasyprint import HTML
facture = db.query(Facture).get(facture_id) facture = db.query(Facture).get(facture_id)
if not facture: if not facture:
@@ -173,10 +193,6 @@ def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)):
filename = f"facture-{facture.numero}.pdf" filename = f"facture-{facture.numero}.pdf"
tree = build_facturx(generate_xml (facture_id, db))
output_file = "factur-x.xml"
tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True)
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",
@@ -194,7 +210,7 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db))
"settings": settings, "settings": settings,
}) })
def generate_xml(facture_id: int, db: Session): def get_invoice_data(facture_id: int, db: Session):
facture = db.query(Facture).get(facture_id) facture = db.query(Facture).get(facture_id)
invoice = Invoice( invoice = Invoice(
number=facture.numero, number=facture.numero,
@@ -228,11 +244,8 @@ def generate_xml(facture_id: int, db: Session):
), ),
), ),
payment_means_code="30", # 30 = virement bancaire payment_means_code="30", # 30 = virement bancaire
iban=settings.asso_iban, iban=settings.asso_iban,
bic=settings.asso_bic,
payment_reference=facture.devis_origine.numero, payment_reference=facture.devis_origine.numero,
) )
@@ -243,6 +256,7 @@ def generate_xml(facture_id: int, db: Session):
quantity=l.quantite, quantity=l.quantite,
unit_code="C62", unit_code="C62",
unit_price=l.prix_unitaire_ht, unit_price=l.prix_unitaire_ht,
vat_category="E",
vat_rate=0.0, vat_rate=0.0,
)) ))

View File

@@ -3,7 +3,7 @@ Helper pour injecter automatiquement current_user dans le contexte Jinja2.
""" """
from fastapi import Request from fastapi import Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, Response
def render(templates: Jinja2Templates, template_name: str, 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 = templates.TemplateResponse(template_name, ctx)
response.status_code = status_code response.status_code = status_code
return response 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> <td>
<a href="/factures/{{ f.id }}" class="btn btn-sm">Voir</a> <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 }}/pdf" class="btn btn-sm btn-primary">PDF</a>
<a href="/factures/{{ f.id }}/facturx" class="btn btn-sm btn-primary">facturx</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}