génération XML et emplate

This commit is contained in:
2026-03-16 01:34:40 +01:00
parent b1029d10d3
commit bc82603f96
3 changed files with 581 additions and 0 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

@@ -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>

View File

@@ -0,0 +1,180 @@
<?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>
{% 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>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>{{ tax.amount | amount }}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<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>