Compare commits

...

2 Commits

Author SHA1 Message Date
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
4 changed files with 302 additions and 1 deletions

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

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