Découpage des paiements + page de saisie
This commit is contained in:
+31
-2
@@ -2,7 +2,7 @@ from django.contrib import admin
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Bareme, Entreprise, Client, Facture, LigneFacture, Depense,
|
Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,10 +55,39 @@ class FactureAdmin(admin.ModelAdmin):
|
|||||||
form.instance.recompute_totaux()
|
form.instance.recompute_totaux()
|
||||||
|
|
||||||
|
|
||||||
|
class DepenseInline(admin.TabularInline):
|
||||||
|
model = Depense
|
||||||
|
extra = 0
|
||||||
|
fields = ("date", "libelle", "activite", "regime_tva", "taux_tva",
|
||||||
|
"montant_ttc", "montant_tva")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Paiement)
|
||||||
|
class PaiementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("date", "entreprise", "libelle", "sens", "montant_total",
|
||||||
|
"reparti", "reste", "equilibre")
|
||||||
|
list_filter = ("entreprise", "sens", "date")
|
||||||
|
search_fields = ("libelle",)
|
||||||
|
date_hierarchy = "date"
|
||||||
|
inlines = [DepenseInline]
|
||||||
|
|
||||||
|
@admin.display(description="Réparti")
|
||||||
|
def reparti(self, obj):
|
||||||
|
return obj.reparti
|
||||||
|
|
||||||
|
@admin.display(description="Reste")
|
||||||
|
def reste(self, obj):
|
||||||
|
return obj.reste
|
||||||
|
|
||||||
|
@admin.display(boolean=True, description="Équilibré")
|
||||||
|
def equilibre(self, obj):
|
||||||
|
return obj.equilibre
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Depense)
|
@admin.register(Depense)
|
||||||
class DepenseAdmin(admin.ModelAdmin):
|
class DepenseAdmin(admin.ModelAdmin):
|
||||||
list_display = ("date", "entreprise", "libelle", "activite", "regime_tva",
|
list_display = ("date", "entreprise", "libelle", "activite", "regime_tva",
|
||||||
"montant_ttc", "montant_tva", "tva_autoliq")
|
"montant_ttc", "montant_tva", "tva_autoliq", "paiement")
|
||||||
list_filter = ("entreprise", "regime_tva", "activite", "categorie", "date")
|
list_filter = ("entreprise", "regime_tva", "activite", "categorie", "date")
|
||||||
search_fields = ("libelle", "categorie")
|
search_fields = ("libelle", "categorie")
|
||||||
date_hierarchy = "date"
|
date_hierarchy = "date"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-06-29 22:16
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('compta', '0003_depense_regime_tva_depense_taux_tva_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Paiement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField()),
|
||||||
|
('libelle', models.CharField(max_length=255, verbose_name='Libellé bancaire')),
|
||||||
|
('montant_total', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('sens', models.CharField(choices=[('debit', 'Débit (achat)'), ('credit', 'Crédit (encaissement)')], default='debit', max_length=6)),
|
||||||
|
('cree_le', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('entreprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='paiements', to='compta.entreprise')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Paiement',
|
||||||
|
'verbose_name_plural': 'Paiements',
|
||||||
|
'ordering': ['-date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='depense',
|
||||||
|
name='paiement',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Opération bancaire dont cette dépense fait partie (achat groupé éclaté).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='depenses', to='compta.paiement'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -180,9 +180,47 @@ class LigneFacture(models.Model):
|
|||||||
return (self.quantite * self.prix_unitaire_ht).quantize(Decimal("0.01"))
|
return (self.quantite * self.prix_unitaire_ht).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
|
||||||
|
class Paiement(models.Model):
|
||||||
|
"""Opération bancaire réelle, à éclater en une ou plusieurs dépenses
|
||||||
|
(ex. un débit Amazon regroupant plusieurs vendeurs/factures)."""
|
||||||
|
SENS = [("debit", "Débit (achat)"), ("credit", "Crédit (encaissement)")]
|
||||||
|
entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE,
|
||||||
|
related_name="paiements")
|
||||||
|
date = models.DateField()
|
||||||
|
libelle = models.CharField("Libellé bancaire", max_length=255)
|
||||||
|
montant_total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
sens = models.CharField(max_length=6, choices=SENS, default="debit")
|
||||||
|
cree_le = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Paiement"
|
||||||
|
verbose_name_plural = "Paiements"
|
||||||
|
ordering = ["-date"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.date} — {self.libelle} ({self.montant_total} €)"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reparti(self):
|
||||||
|
total = self.depenses.aggregate(s=Sum("montant_ttc"))["s"] or Decimal("0")
|
||||||
|
return total.quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reste(self):
|
||||||
|
return (self.montant_total - self.reparti).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def equilibre(self):
|
||||||
|
return abs(self.reste) < Decimal("0.01")
|
||||||
|
|
||||||
|
|
||||||
class Depense(models.Model):
|
class Depense(models.Model):
|
||||||
entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE,
|
entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE,
|
||||||
related_name="depenses")
|
related_name="depenses")
|
||||||
|
paiement = models.ForeignKey(Paiement, on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True, related_name="depenses",
|
||||||
|
help_text="Opération bancaire dont cette dépense "
|
||||||
|
"fait partie (achat groupé éclaté).")
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
activite = models.CharField(
|
activite = models.CharField(
|
||||||
max_length=20, choices=Activite.choices, blank=True,
|
max_length=20, choices=Activite.choices, blank=True,
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
<option value="{{ a }}" {% if a == annee %}selected{% endif %}>{{ a }}</option>
|
<option value="{{ a }}" {% if a == annee %}selected{% endif %}>{{ a }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<a href="/admin/"><button type="button">Saisie / Admin</button></a>
|
<a href="/saisie/"><button type="button">+ Saisir un paiement</button></a>
|
||||||
|
<a href="/admin/"><button type="button">Admin</button></a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if bareme_manquant %}
|
{% if bareme_manquant %}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>JB Compta — Saisie d'un paiement</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
|
||||||
|
--accent:#38bdf8; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
|
||||||
|
background:var(--bg); color:var(--txt); padding:24px; }
|
||||||
|
h1 { margin:0 0 4px; font-size:22px; }
|
||||||
|
.sub { color:var(--muted); margin-bottom:20px; }
|
||||||
|
a { color:var(--accent); }
|
||||||
|
.card { background:var(--card); border:1px solid #334155; border-radius:12px;
|
||||||
|
padding:16px; margin-bottom:16px; }
|
||||||
|
label { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
|
||||||
|
input, select { background:#0f172a; color:var(--txt); border:1px solid #334155;
|
||||||
|
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
|
||||||
|
.head { display:grid; grid-template-columns:1fr 1fr 2fr 1fr; gap:12px; }
|
||||||
|
@media(max-width:700px){ .head{grid-template-columns:1fr 1fr;} }
|
||||||
|
table { width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; }
|
||||||
|
th,td { padding:6px; border-bottom:1px solid #334155; text-align:left; vertical-align:top; }
|
||||||
|
th { color:var(--muted); font-weight:600; }
|
||||||
|
td input, td select { padding:6px 8px; }
|
||||||
|
.num { text-align:right; }
|
||||||
|
button { cursor:pointer; border-radius:8px; border:1px solid #334155;
|
||||||
|
background:var(--card); color:var(--txt); padding:8px 14px; font-size:14px; }
|
||||||
|
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
|
||||||
|
button.mini { padding:4px 8px; }
|
||||||
|
.totaux { display:flex; gap:24px; align-items:center; margin-top:12px; flex-wrap:wrap; }
|
||||||
|
.totaux b { font-size:18px; }
|
||||||
|
.ok{color:var(--ok)} .bad{color:var(--bad)}
|
||||||
|
.msg { padding:10px 14px; border-radius:8px; margin-bottom:10px; }
|
||||||
|
.msg.error { background:#7f1d1d; }
|
||||||
|
.msg.success { background:#064e3b; }
|
||||||
|
.actions { display:flex; gap:12px; margin-top:16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Saisie d'un paiement</h1>
|
||||||
|
<div class="sub">Éclate une opération bancaire (ex. un débit Amazon groupé) en
|
||||||
|
plusieurs dépenses. · <a href="/dashboard/">← Tableau de bord</a> ·
|
||||||
|
<a href="/admin/compta/depense/">Voir les dépenses</a></div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for m in messages %}
|
||||||
|
<div class="msg {{ m.tags }}">{{ m }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" id="frm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<label>Entreprise</label>
|
||||||
|
<select name="entreprise" required>
|
||||||
|
{% for e in entreprises %}<option value="{{ e.pk }}">{{ e.nom }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="date" name="date" value="{{ today }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Libellé bancaire</label>
|
||||||
|
<input type="text" name="libelle" placeholder="ex. AMAZON EU SARL" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Montant total payé (€)</label>
|
||||||
|
<input type="number" step="0.01" name="montant_total" id="total"
|
||||||
|
placeholder="150.00" oninput="recalc()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:24%">Libellé / vendeur</th>
|
||||||
|
<th style="width:14%">Catégorie</th>
|
||||||
|
<th style="width:16%">Activité</th>
|
||||||
|
<th style="width:16%">Régime TVA</th>
|
||||||
|
<th style="width:8%">Taux %</th>
|
||||||
|
<th style="width:11%">Montant €</th>
|
||||||
|
<th style="width:11%">TVA déduct. €</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="lignes"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="addRow()">+ Ajouter une ligne</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="totaux">
|
||||||
|
<span>Réparti : <b id="repartis">0.00</b> €</span>
|
||||||
|
<span>Reste : <b id="reste" class="bad">0.00</b> €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="primary">Enregistrer le paiement</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template id="rowtpl">
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" name="l_libelle" placeholder="Vendeur / objet"></td>
|
||||||
|
<td><input type="text" name="l_categorie" placeholder="ex. Matériel"></td>
|
||||||
|
<td>
|
||||||
|
<select name="l_activite">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for val,lab in activites %}<option value="{{ val }}">{{ lab }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="l_regime">
|
||||||
|
{% for val,lab in regimes %}<option value="{{ val }}">{{ lab }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" step="0.01" name="l_taux" value="0" class="num"></td>
|
||||||
|
<td><input type="number" step="0.01" name="l_montant" class="num montant" oninput="recalc()"></td>
|
||||||
|
<td><input type="number" step="0.01" name="l_tva" value="0" class="num"></td>
|
||||||
|
<td><button type="button" class="mini" onclick="this.closest('tr').remove();recalc()">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addRow(){
|
||||||
|
const tpl = document.getElementById('rowtpl');
|
||||||
|
document.getElementById('lignes').appendChild(tpl.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
function recalc(){
|
||||||
|
let s = 0;
|
||||||
|
document.querySelectorAll('.montant').forEach(i => s += parseFloat(i.value||0));
|
||||||
|
const total = parseFloat(document.getElementById('total').value||0);
|
||||||
|
document.getElementById('repartis').textContent = s.toFixed(2);
|
||||||
|
const reste = (total - s);
|
||||||
|
const el = document.getElementById('reste');
|
||||||
|
el.textContent = reste.toFixed(2);
|
||||||
|
el.className = Math.abs(reste) < 0.01 ? 'ok' : 'bad';
|
||||||
|
}
|
||||||
|
// 3 lignes au départ
|
||||||
|
addRow(); addRow(); addRow();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
from . import views_saisie
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("dashboard/", views.dashboard, name="dashboard"),
|
path("dashboard/", views.dashboard, name="dashboard"),
|
||||||
|
path("saisie/", views_saisie.saisie_paiement, name="saisie_paiement"),
|
||||||
path("export/ventes/", views.export_ventes, name="export_ventes"),
|
path("export/ventes/", views.export_ventes, name="export_ventes"),
|
||||||
path("export/tva/", views.export_tva, name="export_tva"),
|
path("export/tva/", views.export_tva, name="export_tva"),
|
||||||
path("export/urssaf/", views.export_urssaf, name="export_urssaf"),
|
path("export/urssaf/", views.export_urssaf, name="export_urssaf"),
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Page de saisie / import : éclater un paiement bancaire en plusieurs dépenses.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
|
||||||
|
from .models import Entreprise, Paiement, Depense, Activite, RegimeTva
|
||||||
|
|
||||||
|
|
||||||
|
def _dec(val, defaut="0"):
|
||||||
|
try:
|
||||||
|
return Decimal((val or defaut).replace(",", ".").strip() or defaut)
|
||||||
|
except (InvalidOperation, AttributeError):
|
||||||
|
return Decimal(defaut)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def saisie_paiement(request):
|
||||||
|
entreprises = Entreprise.objects.all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
entreprise = entreprises.filter(pk=request.POST.get("entreprise")).first()
|
||||||
|
d = request.POST.get("date") or date.today().isoformat()
|
||||||
|
libelle = (request.POST.get("libelle") or "").strip()
|
||||||
|
montant_total = _dec(request.POST.get("montant_total"))
|
||||||
|
|
||||||
|
libelles = request.POST.getlist("l_libelle")
|
||||||
|
categories = request.POST.getlist("l_categorie")
|
||||||
|
activites = request.POST.getlist("l_activite")
|
||||||
|
regimes = request.POST.getlist("l_regime")
|
||||||
|
taux = request.POST.getlist("l_taux")
|
||||||
|
montants = request.POST.getlist("l_montant")
|
||||||
|
tvas = request.POST.getlist("l_tva")
|
||||||
|
|
||||||
|
lignes = []
|
||||||
|
for i in range(len(libelles)):
|
||||||
|
m = _dec(montants[i] if i < len(montants) else "0")
|
||||||
|
lib = (libelles[i] or "").strip()
|
||||||
|
if m == 0 and not lib:
|
||||||
|
continue # ligne vide ignorée
|
||||||
|
lignes.append({
|
||||||
|
"libelle": lib or "(sans libellé)",
|
||||||
|
"categorie": categories[i] if i < len(categories) else "",
|
||||||
|
"activite": activites[i] if i < len(activites) else "",
|
||||||
|
"regime": regimes[i] if i < len(regimes) else RegimeTva.NATIONALE,
|
||||||
|
"taux": _dec(taux[i] if i < len(taux) else "0"),
|
||||||
|
"montant": m,
|
||||||
|
"tva": _dec(tvas[i] if i < len(tvas) else "0"),
|
||||||
|
})
|
||||||
|
|
||||||
|
somme = sum((l["montant"] for l in lignes), Decimal("0"))
|
||||||
|
erreurs = []
|
||||||
|
if not entreprise:
|
||||||
|
erreurs.append("Sélectionne une entreprise.")
|
||||||
|
if not libelle:
|
||||||
|
erreurs.append("Renseigne le libellé du paiement.")
|
||||||
|
if not lignes:
|
||||||
|
erreurs.append("Ajoute au moins une ligne de dépense.")
|
||||||
|
if lignes and abs(somme - montant_total) >= Decimal("0.01"):
|
||||||
|
erreurs.append(
|
||||||
|
f"La somme des lignes ({somme} €) ne correspond pas au "
|
||||||
|
f"montant total ({montant_total} €). Écart : {somme - montant_total} €.")
|
||||||
|
|
||||||
|
if erreurs:
|
||||||
|
for e in erreurs:
|
||||||
|
messages.error(request, e)
|
||||||
|
return render(request, "compta/saisie_paiement.html",
|
||||||
|
_context(entreprises, request.POST))
|
||||||
|
|
||||||
|
paiement = Paiement.objects.create(
|
||||||
|
entreprise=entreprise, date=d, libelle=libelle,
|
||||||
|
montant_total=montant_total, sens="debit")
|
||||||
|
for l in lignes:
|
||||||
|
Depense.objects.create(
|
||||||
|
entreprise=entreprise, paiement=paiement, date=d,
|
||||||
|
libelle=l["libelle"], categorie=l["categorie"],
|
||||||
|
activite=l["activite"] or "", regime_tva=l["regime"],
|
||||||
|
taux_tva=l["taux"], montant_ttc=l["montant"], montant_tva=l["tva"])
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Paiement « {libelle} » enregistré et éclaté en {len(lignes)} dépense(s).")
|
||||||
|
return redirect("saisie_paiement")
|
||||||
|
|
||||||
|
return render(request, "compta/saisie_paiement.html", _context(entreprises))
|
||||||
|
|
||||||
|
|
||||||
|
def _context(entreprises, post=None):
|
||||||
|
return {
|
||||||
|
"entreprises": entreprises,
|
||||||
|
"activites": Activite.choices,
|
||||||
|
"regimes": RegimeTva.choices,
|
||||||
|
"today": date.today().isoformat(),
|
||||||
|
"post": post,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user