288 lines
9.5 KiB
Python
288 lines
9.5 KiB
Python
import json
|
|
from datetime import date, timedelta
|
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from models import Facture, LigneFacture, Client, StatutFacture
|
|
from numerotation import generer_numero_facture
|
|
from config import settings
|
|
from auth import get_current_user
|
|
from template_helper import render
|
|
from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml
|
|
from facturx import generate_from_binary
|
|
|
|
router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def liste_factures(request: Request, db: Session = Depends(get_db)):
|
|
factures = db.query(Facture).order_by(Facture.date_emission.desc()).all()
|
|
return render(templates, "factures/liste.html", request, {
|
|
"factures": factures
|
|
})
|
|
|
|
|
|
@router.get("/nouvelle", response_class=HTMLResponse)
|
|
def nouvelle_facture_form(request: Request, db: Session = Depends(get_db)):
|
|
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
|
|
return render(templates, "factures/form.html", request, {
|
|
"facture": None,
|
|
"clients": clients,
|
|
"titre": "Nouvelle facture",
|
|
"date_aujourd_hui": date.today().isoformat(),
|
|
"date_echeance_defaut": (date.today() + timedelta(days=30)).isoformat(),
|
|
})
|
|
|
|
|
|
@router.post("/nouvelle")
|
|
def creer_facture(
|
|
client_id: int = Form(...),
|
|
date_emission: str = Form(...),
|
|
date_echeance: str = Form(...),
|
|
notes: str = Form(""),
|
|
conditions_reglement: str = Form("Paiement à réception de facture."),
|
|
lignes_json: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
numero = generer_numero_facture(db)
|
|
facture = Facture(
|
|
numero=numero,
|
|
client_id=client_id,
|
|
date_emission=date.fromisoformat(date_emission),
|
|
date_echeance=date.fromisoformat(date_echeance),
|
|
notes=notes,
|
|
conditions_reglement=conditions_reglement,
|
|
)
|
|
db.add(facture)
|
|
db.flush()
|
|
|
|
lignes = json.loads(lignes_json)
|
|
for i, l in enumerate(lignes):
|
|
ligne = LigneFacture(
|
|
facture_id=facture.id,
|
|
description=l["description"],
|
|
quantite=float(l["quantite"]),
|
|
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
|
|
ordre=i
|
|
)
|
|
db.add(ligne)
|
|
|
|
db.commit()
|
|
return RedirectResponse(f"/factures/{facture.id}", status_code=303)
|
|
|
|
|
|
@router.get("/{facture_id}", response_class=HTMLResponse)
|
|
def voir_facture(request: Request, facture_id: int, db: Session = Depends(get_db)):
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
return render(templates, "factures/detail.html", request, {
|
|
"facture": facture, "StatutFacture": StatutFacture
|
|
})
|
|
|
|
|
|
@router.get("/{facture_id}/modifier", response_class=HTMLResponse)
|
|
def modifier_facture_form(request: Request, facture_id: int, db: Session = Depends(get_db)):
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
if facture.statut != StatutFacture.emise:
|
|
raise HTTPException(status_code=400, detail="Seules les factures émises peuvent être modifiées.")
|
|
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
|
|
return render(templates, "factures/form.html", request, {
|
|
"facture": facture,
|
|
"clients": clients,
|
|
"titre": "Modifier la facture",
|
|
"date_aujourd_hui": facture.date_emission.isoformat(),
|
|
"date_echeance_defaut": facture.date_echeance.isoformat(),
|
|
})
|
|
|
|
|
|
@router.post("/{facture_id}/modifier")
|
|
def modifier_facture(
|
|
facture_id: int,
|
|
client_id: int = Form(...),
|
|
date_emission: str = Form(...),
|
|
date_echeance: str = Form(...),
|
|
notes: str = Form(""),
|
|
conditions_reglement: str = Form(""),
|
|
lignes_json: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
facture.client_id = client_id
|
|
facture.date_emission = date.fromisoformat(date_emission)
|
|
facture.date_echeance = date.fromisoformat(date_echeance)
|
|
facture.notes = notes
|
|
facture.conditions_reglement = conditions_reglement
|
|
|
|
for ligne in facture.lignes:
|
|
db.delete(ligne)
|
|
db.flush()
|
|
|
|
lignes = json.loads(lignes_json)
|
|
for i, l in enumerate(lignes):
|
|
ligne = LigneFacture(
|
|
facture_id=facture.id,
|
|
description=l["description"],
|
|
quantite=float(l["quantite"]),
|
|
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
|
|
ordre=i
|
|
)
|
|
db.add(ligne)
|
|
|
|
db.commit()
|
|
return RedirectResponse(f"/factures/{facture_id}", status_code=303)
|
|
|
|
|
|
@router.post("/{facture_id}/statut")
|
|
def changer_statut_facture(
|
|
facture_id: int,
|
|
statut: str = Form(...),
|
|
date_paiement: str = Form(""),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
facture.statut = StatutFacture(statut)
|
|
if statut == "payee" and date_paiement:
|
|
facture.date_paiement = date.fromisoformat(date_paiement)
|
|
db.commit()
|
|
return RedirectResponse(f"/factures/{facture_id}", status_code=303)
|
|
|
|
from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt
|
|
|
|
@router.get("/{facture_id}/pdf")
|
|
def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)):
|
|
from weasyprint import HTML, Attachment
|
|
from facturx import generate_from_binary
|
|
import hashlib
|
|
import pikepdf
|
|
from pikepdf import Name
|
|
import io
|
|
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
|
|
# 1. XML Factur-X
|
|
invoice = get_invoice_data(facture_id, db)
|
|
xml_str = generate_facturx_xml(invoice)
|
|
xml_bytes = xml_str.encode("utf-8")
|
|
|
|
# 2. Rendu HTML → PDF simple (pas PDF/A-3 ici)
|
|
html_content = templates.get_template("pdf/facture.html").render({
|
|
"facture": facture,
|
|
"settings": settings,
|
|
})
|
|
pdf_bytes = HTML(string=html_content, base_url=".").write_pdf(
|
|
pdf_variant="pdf/a-3b",
|
|
)
|
|
|
|
# 3. generate_from_binary gère :
|
|
# - l'intégration du XML
|
|
# - les métadonnées XMP Factur-X (DocumentType, DocumentFileName, etc.)
|
|
# - la conformité PDF/A-3b
|
|
pdf_bytes = generate_from_binary(
|
|
pdf_bytes,
|
|
xml_bytes,
|
|
flavor="factur-x",
|
|
level="BASIC",
|
|
)
|
|
|
|
# 4. Corriger uniquement /EF/F/Subtype → /text/xml avec pikepdf
|
|
pdf_io = io.BytesIO(pdf_bytes)
|
|
with pikepdf.open(pdf_io) as pdf:
|
|
names = pdf.Root.Names.EmbeddedFiles.Names
|
|
i = 0
|
|
while i < len(names):
|
|
i += 1 # sauter la clé (string)
|
|
if i < len(names):
|
|
obj = names[i]
|
|
try:
|
|
obj.EF.F.Subtype = Name("/text/xml")
|
|
except Exception:
|
|
pass
|
|
i += 1
|
|
|
|
output = io.BytesIO()
|
|
pdf.save(output)
|
|
pdf_bytes = output.getvalue()
|
|
|
|
filename = f"facture-{facture.numero}.pdf"
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
|
)
|
|
|
|
|
|
@router.get("/{facture_id}/apercu-pdf", response_class=HTMLResponse)
|
|
def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)):
|
|
facture = db.query(Facture).get(facture_id)
|
|
if not facture:
|
|
raise HTTPException(status_code=404)
|
|
return render(templates, "pdf/facture.html", request, {
|
|
"facture": facture,
|
|
"settings": settings,
|
|
})
|
|
|
|
def get_invoice_data(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,
|
|
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_category="E",
|
|
vat_rate=0.0,
|
|
))
|
|
|
|
return invoice
|