PDF conforme Factur-X #2

Open
jblb wants to merge 10 commits from jblb/BillManager:devel into master
2 changed files with 405 additions and 0 deletions
Showing only changes of commit 8e79796781 - Show all commits

345
generate_facturx_basic.py Normal file
View File

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

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