From 8e79796781ce4708a367fd7e52ba1dd3f554b783 Mon Sep 17 00:00:00 2001 From: JbLb Date: Sat, 14 Mar 2026 00:41:41 +0100 Subject: [PATCH] =?UTF-8?q?g=C3=A9n=C3=A9ration=20du=20fichier=20XML=20pou?= =?UTF-8?q?r=20le=20format=20factur-x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate_facturx_basic.py | 345 ++++++++++++++++++++++++++++++++++++++ routers/factures.py | 60 +++++++ 2 files changed, 405 insertions(+) create mode 100644 generate_facturx_basic.py diff --git a/generate_facturx_basic.py b/generate_facturx_basic.py new file mode 100644 index 0000000..7d78512 --- /dev/null +++ b/generate_facturx_basic.py @@ -0,0 +1,345 @@ +""" +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 ca010f9..0a5e89c 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -11,6 +11,7 @@ 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 router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) templates = Jinja2Templates(directory="templates") @@ -171,6 +172,11 @@ 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" + + 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", @@ -187,3 +193,57 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)) "facture": facture, "settings": settings, }) + +def generate_xml(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, + bic=settings.asso_bic, + 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_rate=0.0, + )) + + return invoice