Compare commits

...

3 Commits

Author SHA1 Message Date
8e79796781 génération du fichier XML pour le format factur-x 2026-03-14 00:41:41 +01:00
8ed1df83ec add template for pdf devis 2026-03-13 01:13:06 +01:00
2838a87413 generate pdf file for devis 2026-03-13 01:11:18 +01:00
6 changed files with 707 additions and 1 deletions

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

@@ -1,7 +1,7 @@
import json import json
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request, Form, HTTPException from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,6 +10,7 @@ from models import Devis, LigneDevis, Client, StatutDevis
from numerotation import generer_numero_devis from numerotation import generer_numero_devis
from auth import get_current_user from auth import get_current_user
from template_helper import render from template_helper import render
from config import settings
router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -150,6 +151,27 @@ def changer_statut_devis(
db.commit() db.commit()
return RedirectResponse(f"/devis/{devis_id}", status_code=303) return RedirectResponse(f"/devis/{devis_id}", status_code=303)
@router.get("/{devis_id}/pdf")
def telecharger_devis__pdf(devis_id: int, db: Session = Depends(get_db)):
from weasyprint import HTML
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
html_content = templates.get_template("pdf/devis.html").render({
"devis": devis,
"settings": settings,
})
pdf_bytes = HTML(string=html_content, base_url=".").write_pdf()
filename = f"devis-{devis.numero}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.post("/{devis_id}/convertir") @router.post("/{devis_id}/convertir")
def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)): def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)):

View File

@@ -11,6 +11,7 @@ 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
from generate_facturx_basic import Address, Party, Invoice, InvoiceLine, build_facturx
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")
@@ -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() pdf_bytes = HTML(string=html_content, base_url=".").write_pdf()
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",
@@ -187,3 +193,57 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db))
"facture": facture, "facture": facture,
"settings": settings, "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

View File

@@ -5,6 +5,7 @@
<h1>Devis {{ devis.numero }}</h1> <h1>Devis {{ devis.numero }}</h1>
<div class="btn-group"> <div class="btn-group">
<a href="/devis/" class="btn">← Retour</a> <a href="/devis/" class="btn">← Retour</a>
<a href="/devis/{{ devis.id }}/pdf" class="btn btn-primary">⬇ Télécharger PDF</a>
{% if devis.statut.value == 'brouillon' %} {% if devis.statut.value == 'brouillon' %}
<a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a> <a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a>
{% endif %} {% endif %}

View File

@@ -28,6 +28,7 @@
<td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td> <td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td>
<td> <td>
<a href="/devis/{{ d.id }}" class="btn btn-sm">Voir</a> <a href="/devis/{{ d.id }}" class="btn btn-sm">Voir</a>
<a href="/devis/{{ d.id }}/pdf" class="btn btn-sm btn-primary">PDF</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

277
templates/pdf/devis.html Normal file
View File

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 1.5cm 1.8cm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2em;
padding-bottom: 1.5em;
border-bottom: 2px solid #2c3e50;
}
.emetteur h1 {
font-size: 16pt;
color: #2c3e50;
margin-bottom: 0.3em;
}
.emetteur p { font-size: 9pt; color: #555; line-height: 1.6; }
.facture-titre {
text-align: right;
}
.facture-titre h2 {
font-size: 22pt;
color: #2c3e50;
letter-spacing: 1px;
text-transform: uppercase;
}
.facture-titre .numero {
font-size: 13pt;
font-weight: bold;
color: #e74c3c;
margin-top: 0.2em;
}
.facture-titre .dates {
font-size: 9pt;
color: #555;
margin-top: 0.5em;
line-height: 1.8;
}
.parties {
display: flex;
justify-content: space-between;
margin-bottom: 2em;
gap: 2em;
}
.partie {
flex: 1;
padding: 1em;
border: 1px solid #ddd;
border-radius: 4px;
}
.partie h3 {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 0.7em;
border-bottom: 1px solid #eee;
padding-bottom: 0.4em;
}
.partie p { font-size: 9.5pt; line-height: 1.7; }
.partie .nom { font-weight: bold; font-size: 11pt; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5em;
}
thead tr {
background-color: #2c3e50;
color: white;
}
thead th {
padding: 0.6em 0.8em;
text-align: left;
font-size: 9pt;
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.5px;
}
thead th:last-child { text-align: right; }
tbody tr:nth-child(even) { background: #f8f9fa; }
tbody td {
padding: 0.6em 0.8em;
font-size: 9.5pt;
border-bottom: 1px solid #eee;
vertical-align: top;
}
tbody td:last-child { text-align: right; font-weight: 500; }
td.qte { text-align: center; }
td.pu { text-align: right; }
.totaux {
display: flex;
justify-content: flex-end;
margin-bottom: 2em;
}
.totaux table {
width: auto;
min-width: 280px;
}
.totaux td {
padding: 0.4em 0.8em;
font-size: 10pt;
border: none;
}
.totaux tr.total-ht td {
font-size: 12pt;
font-weight: bold;
color: #2c3e50;
border-top: 2px solid #2c3e50;
padding-top: 0.6em;
}
.totaux td:last-child { text-align: right; }
.tva-mention {
font-size: 8.5pt;
color: #666;
font-style: italic;
text-align: right;
margin-bottom: 1.5em;
}
.footer-info {
border-top: 1px solid #ddd;
padding-top: 1em;
font-size: 8.5pt;
color: #555;
line-height: 1.7;
}
.footer-info .conditions {
margin-bottom: 0.5em;
}
.footer-info .penalites {
font-size: 8pt;
color: #888;
}
.footer-info .iban {
margin-top: 0.5em;
font-weight: bold;
}
.devis-ref {
font-size: 8.5pt;
color: #888;
margin-bottom: 1.5em;
}
.notes {
background: #f8f9fa;
border-left: 3px solid #2c3e50;
padding: 0.7em 1em;
font-size: 9pt;
margin-bottom: 1.5em;
color: #444;
}
</style>
</head>
<body>
<!-- EN-TÊTE -->
<div class="header">
<div class="emetteur">
<h1>{{ settings.asso_nom }}</h1>
<p>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_email %}{{ settings.asso_email }}<br>{% endif %}
{% if settings.asso_telephone %}{{ settings.asso_telephone }}<br>{% endif %}
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="facture-titre">
<h2>Devis</h2>
<div class="numero">N° {{ devis.numero }}</div>
<div class="dates">
Date d'émission : {{ devis.date_emission.strftime('%d/%m/%Y') }}<br>
Date d'échéance : {{ devis.date_validite.strftime('%d/%m/%Y') }}
</div>
</div>
</div>
<!-- PARTIES -->
<div class="parties">
<div class="partie">
<h3>Émetteur</h3>
<p>
<span class="nom">{{ settings.asso_nom }}</span><br>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="partie">
<h3>Destinataire</h3>
<p>
<span class="nom">{{ devis.client.nom }}</span><br>
{{ devis.client.adresse }}<br>
{{ devis.client.code_postal }} {{ devis.client.ville }}
{% if devis.client.siret %}<br>SIRET : {{ devis.client.siret }}{% endif %}
{% if devis.client.email %}<br>{{ devis.client.email }}{% endif %}
</p>
</div>
</div>
<!-- LIGNES -->
<table>
<thead>
<tr>
<th style="width:55%">Désignation</th>
<th style="width:10%" class="qte">Qté</th>
<th style="width:17%">Prix unitaire HT</th>
<th style="width:18%">Total HT</th>
</tr>
</thead>
<tbody>
{% for l in devis.lignes %}
<tr>
<td>{{ l.description }}</td>
<td class="qte">
{% if l.quantite == l.quantite | int %}{{ l.quantite | int }}{% else %}{{ l.quantite }}{% endif %}
</td>
<td class="pu">
{{ "%.2f"|format(l.prix_unitaire_ht) | replace('.', ',') }} €
</td>
<td>{{ "%.2f"|format(l.total_ht) | replace('.', ',') }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- TOTAUX -->
<div class="totaux">
<table>
<tr>
<td>Montant HT</td>
<td>{{ "%.2f"|format(devis.total_ht) | replace('.', ',') }} €</td>
</tr>
<tr>
<td>TVA</td>
<td>0,00 €</td>
</tr>
<tr class="total-ht">
<td>Total TTC</td>
<td>{{ "%.2f"|format(devis.total_ht) | replace('.', ',') }} €</td>
</tr>
</table>
</div>
<p class="tva-mention">TVA non applicable — art. 293B du CGI</p>
<!-- PIED DE PAGE LÉGAL -->
<div class="footer-info">
{% if settings.asso_iban %}
<div class="iban">
Coordonnées bancaires — IBAN : {{ settings.asso_iban }}
{% if settings.asso_bic %} — BIC : {{ settings.asso_bic }}{% endif %}
</div>
{% endif %}
</div>
</body>
</html>