From b1029d10d37fa23296516e19bea60a04dc1665bd Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 01:32:28 +0100 Subject: [PATCH] generayion du XML a partir d'un template --- generate_facturx_basic.py | 345 ---------------------------------- routers/factures.py | 38 ++-- template_helper.py | 19 +- templates/factures/liste.html | 1 + 4 files changed, 45 insertions(+), 358 deletions(-) delete mode 100644 generate_facturx_basic.py diff --git a/generate_facturx_basic.py b/generate_facturx_basic.py deleted file mode 100644 index 7d78512..0000000 --- a/generate_facturx_basic.py +++ /dev/null @@ -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'{inv.note}') - ) - - # ── 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} €") \ No newline at end of file diff --git a/routers/factures.py b/routers/factures.py index 0a5e89c..94099e4 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -10,8 +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 generate_facturx_basic import Address, Party, Invoice, InvoiceLine, build_facturx +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") @@ -156,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: @@ -173,10 +193,6 @@ def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): 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( content=pdf_bytes, media_type="application/pdf", @@ -194,7 +210,7 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)) "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) invoice = Invoice( number=facture.numero, @@ -228,11 +244,8 @@ def generate_xml(facture_id: int, db: Session): ), ), - - payment_means_code="30", # 30 = virement bancaire iban=settings.asso_iban, - bic=settings.asso_bic, payment_reference=facture.devis_origine.numero, ) @@ -243,6 +256,7 @@ def generate_xml(facture_id: int, db: Session): quantity=l.quantite, unit_code="C62", unit_price=l.prix_unitaire_ht, + vat_category="E", vat_rate=0.0, )) diff --git a/template_helper.py b/template_helper.py index 3dd3c16..7330321 100644 --- a/template_helper.py +++ b/template_helper.py @@ -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}"'} + ) diff --git a/templates/factures/liste.html b/templates/factures/liste.html index 99ed8bf..355891b 100644 --- a/templates/factures/liste.html +++ b/templates/factures/liste.html @@ -29,6 +29,7 @@ Voir PDF + facturx {% endfor %}