forked from seb_vallee/BillManager
Compare commits
3 Commits
master
...
8e79796781
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e79796781 | |||
| 8ed1df83ec | |||
| 2838a87413 |
345
generate_facturx_basic.py
Normal file
345
generate_facturx_basic.py
Normal 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} €")
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,6 +10,7 @@ from models import Devis, LigneDevis, Client, StatutDevis
|
||||
from numerotation import generer_numero_devis
|
||||
from auth import get_current_user
|
||||
from template_helper import render
|
||||
from config import settings
|
||||
|
||||
router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -150,6 +151,27 @@ def changer_statut_devis(
|
||||
db.commit()
|
||||
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")
|
||||
def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<h1>Devis {{ devis.numero }}</h1>
|
||||
<div class="btn-group">
|
||||
<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' %}
|
||||
<a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td>
|
||||
<td>
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
277
templates/pdf/devis.html
Normal file
277
templates/pdf/devis.html
Normal 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>
|
||||
Reference in New Issue
Block a user